Normalize image output dimensions
This commit is contained in:
parent
a265a77095
commit
4eac5cf0c0
@ -34,6 +34,7 @@ import {
|
|||||||
import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency";
|
import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
import { resolveModelRuntimeId } from "@/lib/ai/model-runtime";
|
||||||
|
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
|
|
||||||
// 请求体 Schema
|
// 请求体 Schema
|
||||||
const sceneFusionRequestSchema = z.object({
|
const sceneFusionRequestSchema = z.object({
|
||||||
@ -1264,32 +1265,21 @@ function calculateSize(aspectRatio: string, imageSize: string): string | undefin
|
|||||||
"4K": 2048,
|
"4K": 2048,
|
||||||
};
|
};
|
||||||
const baseSize = baseSizeMap[imageSize] || 1536;
|
const baseSize = baseSizeMap[imageSize] || 1536;
|
||||||
|
if (!/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.test(aspectRatio)) {
|
||||||
// 根据比例计算宽高
|
|
||||||
const ratioMap: Record<string, [number, number]> = {
|
|
||||||
"1:1": [1, 1],
|
|
||||||
"4:3": [4, 3],
|
|
||||||
"3:4": [3, 4],
|
|
||||||
"16:9": [16, 9],
|
|
||||||
"9:16": [9, 16],
|
|
||||||
};
|
|
||||||
|
|
||||||
const ratio = ratioMap[aspectRatio];
|
|
||||||
if (!ratio) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [w, h] = ratio;
|
const { width, height } = resolveLongestEdgeDimensions({
|
||||||
// 保持总像素接近 baseSize * baseSize,同时维持比例
|
aspectRatio,
|
||||||
const scale = Math.sqrt((baseSize * baseSize) / (w * h));
|
longestEdge: baseSize,
|
||||||
let width = Math.round(w * scale);
|
constraints: {
|
||||||
let height = Math.round(h * scale);
|
maxEdge: 2048,
|
||||||
|
maxPixels: 2048 * 2048,
|
||||||
// 确保宽高在 512-2048 范围内
|
minEdge: 512,
|
||||||
const clamp = (v: number) => Math.max(512, Math.min(2048, v));
|
multiple: 2,
|
||||||
width = clamp(width);
|
},
|
||||||
height = clamp(height);
|
});
|
||||||
|
|
||||||
return `${width}*${height}`;
|
return `${width}*${height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
lib/ai/image-size-constraints.ts
Normal file
96
lib/ai/image-size-constraints.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
export type ImageSizeBucket = "1K" | "2K" | "4K";
|
||||||
|
|
||||||
|
export type ImageDimensionConstraints = {
|
||||||
|
maxEdge?: number;
|
||||||
|
maxPixels?: number;
|
||||||
|
minEdge?: number;
|
||||||
|
multiple?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageDimensions = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeImageSizeBucket(value?: string, fallback: ImageSizeBucket = "2K"): ImageSizeBucket {
|
||||||
|
const normalized = String(value || "").trim().toUpperCase();
|
||||||
|
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAspectRatioPair(value?: string): ImageDimensions | null {
|
||||||
|
const normalized = String(value || "").trim().toLowerCase();
|
||||||
|
if (!normalized || normalized === "auto") return null;
|
||||||
|
const match = normalized.match(/^(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const width = Number(match[1]);
|
||||||
|
const height = Number(match[2]);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function roundToMultiple(value: number, multiple = 1) {
|
||||||
|
return Math.max(multiple, Math.round(value / multiple) * multiple);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function floorToMultiple(value: number, multiple = 1) {
|
||||||
|
return Math.max(multiple, Math.floor(value / multiple) * multiple);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constrainImageDimensions(
|
||||||
|
dimensions: ImageDimensions,
|
||||||
|
constraints: ImageDimensionConstraints = {},
|
||||||
|
): ImageDimensions {
|
||||||
|
const multiple = Math.max(1, Math.floor(constraints.multiple || 1));
|
||||||
|
const minEdge = Math.max(multiple, Math.floor(constraints.minEdge || multiple));
|
||||||
|
const maxEdge = Number.isFinite(constraints.maxEdge)
|
||||||
|
? Math.max(minEdge, Math.floor(Number(constraints.maxEdge)))
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const maxPixels = Number.isFinite(constraints.maxPixels)
|
||||||
|
? Math.max(minEdge * minEdge, Math.floor(Number(constraints.maxPixels)))
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
let width = Math.max(minEdge, floorToMultiple(Math.max(1, dimensions.width), multiple));
|
||||||
|
let height = Math.max(minEdge, floorToMultiple(Math.max(1, dimensions.height), multiple));
|
||||||
|
|
||||||
|
while (width > maxEdge || height > maxEdge || width * height > maxPixels) {
|
||||||
|
const scale = Math.min(maxEdge / width, maxEdge / height, Math.sqrt(maxPixels / (width * height)), 1);
|
||||||
|
const nextWidth = Math.max(minEdge, floorToMultiple(width * scale, multiple));
|
||||||
|
const nextHeight = Math.max(minEdge, floorToMultiple(height * scale, multiple));
|
||||||
|
|
||||||
|
if (nextWidth === width && nextHeight === height) {
|
||||||
|
if (width >= height && width > minEdge) width -= multiple;
|
||||||
|
else if (height > minEdge) height -= multiple;
|
||||||
|
else break;
|
||||||
|
} else {
|
||||||
|
width = nextWidth;
|
||||||
|
height = nextHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLongestEdgeDimensions(params: {
|
||||||
|
aspectRatio?: string;
|
||||||
|
longestEdge: number;
|
||||||
|
multiple?: number;
|
||||||
|
constraints?: ImageDimensionConstraints;
|
||||||
|
}): ImageDimensions {
|
||||||
|
const ratioPair = parseAspectRatioPair(params.aspectRatio) || { width: 1, height: 1 };
|
||||||
|
const scale = params.longestEdge / Math.max(ratioPair.width, ratioPair.height);
|
||||||
|
return constrainImageDimensions(
|
||||||
|
{
|
||||||
|
width: ratioPair.width * scale,
|
||||||
|
height: ratioPair.height * scale,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
multiple: params.multiple || params.constraints?.multiple,
|
||||||
|
...params.constraints,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
interface AlibabaImageConfig {
|
interface AlibabaImageConfig {
|
||||||
@ -50,6 +51,7 @@ interface AlibabaImageResponse {
|
|||||||
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
||||||
const ALIBABA_MIN_SIZE = 512;
|
const ALIBABA_MIN_SIZE = 512;
|
||||||
const ALIBABA_MAX_SIZE = 2048;
|
const ALIBABA_MAX_SIZE = 2048;
|
||||||
|
const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2;
|
||||||
const ALIBABA_MAX_INPUT_SIDE = 4096;
|
const ALIBABA_MAX_INPUT_SIDE = 4096;
|
||||||
const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024;
|
const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 1024 * 1024;
|
||||||
const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 1024 * 1024;
|
const ALIBABA_QWEN_MAX_INPUT_BYTES = 9.5 * 1024 * 1024;
|
||||||
@ -129,32 +131,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
|||||||
let height = Math.round(Math.sqrt(totalPixels / safeAspect));
|
let height = Math.round(Math.sqrt(totalPixels / safeAspect));
|
||||||
let width = Math.round(height * safeAspect);
|
let width = Math.round(height * safeAspect);
|
||||||
|
|
||||||
if (width > ALIBABA_MAX_SIZE) {
|
({ width, height } = constrainAlibabaOutputDimensions({ width, height }));
|
||||||
const scale = ALIBABA_MAX_SIZE / width;
|
|
||||||
width = ALIBABA_MAX_SIZE;
|
|
||||||
height = Math.round(height * scale);
|
|
||||||
}
|
|
||||||
if (height > ALIBABA_MAX_SIZE) {
|
|
||||||
const scale = ALIBABA_MAX_SIZE / height;
|
|
||||||
height = ALIBABA_MAX_SIZE;
|
|
||||||
width = Math.round(width * scale);
|
|
||||||
}
|
|
||||||
if (width < ALIBABA_MIN_SIZE) {
|
|
||||||
const scale = ALIBABA_MIN_SIZE / width;
|
|
||||||
width = ALIBABA_MIN_SIZE;
|
|
||||||
height = Math.round(height * scale);
|
|
||||||
}
|
|
||||||
if (height < ALIBABA_MIN_SIZE) {
|
|
||||||
const scale = ALIBABA_MIN_SIZE / height;
|
|
||||||
height = ALIBABA_MIN_SIZE;
|
|
||||||
width = Math.round(width * scale);
|
|
||||||
}
|
|
||||||
|
|
||||||
width = Math.max(ALIBABA_MIN_SIZE, Math.min(ALIBABA_MAX_SIZE, width));
|
|
||||||
height = Math.max(ALIBABA_MIN_SIZE, Math.min(ALIBABA_MAX_SIZE, height));
|
|
||||||
|
|
||||||
width = Math.round(width / 2) * 2;
|
|
||||||
height = Math.round(height / 2) * 2;
|
|
||||||
|
|
||||||
return `${width}*${height}`;
|
return `${width}*${height}`;
|
||||||
}
|
}
|
||||||
@ -211,33 +188,23 @@ function calculateAlibabaSizeFromDimensions(
|
|||||||
let scaledWidth = Math.round(safeWidth * scale);
|
let scaledWidth = Math.round(safeWidth * scale);
|
||||||
let scaledHeight = Math.round(safeHeight * scale);
|
let scaledHeight = Math.round(safeHeight * scale);
|
||||||
|
|
||||||
if (scaledWidth > ALIBABA_MAX_SIZE || scaledHeight > ALIBABA_MAX_SIZE) {
|
({ width: scaledWidth, height: scaledHeight } = constrainAlibabaOutputDimensions({
|
||||||
const clampScale = Math.min(
|
width: scaledWidth,
|
||||||
ALIBABA_MAX_SIZE / scaledWidth,
|
height: scaledHeight,
|
||||||
ALIBABA_MAX_SIZE / scaledHeight,
|
}));
|
||||||
);
|
|
||||||
scaledWidth = Math.round(scaledWidth * clampScale);
|
|
||||||
scaledHeight = Math.round(scaledHeight * clampScale);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scaledWidth < ALIBABA_MIN_SIZE || scaledHeight < ALIBABA_MIN_SIZE) {
|
|
||||||
const boostScale = Math.max(
|
|
||||||
ALIBABA_MIN_SIZE / scaledWidth,
|
|
||||||
ALIBABA_MIN_SIZE / scaledHeight,
|
|
||||||
);
|
|
||||||
scaledWidth = Math.round(scaledWidth * boostScale);
|
|
||||||
scaledHeight = Math.round(scaledHeight * boostScale);
|
|
||||||
}
|
|
||||||
|
|
||||||
scaledWidth = Math.max(ALIBABA_MIN_SIZE, Math.min(ALIBABA_MAX_SIZE, scaledWidth));
|
|
||||||
scaledHeight = Math.max(ALIBABA_MIN_SIZE, Math.min(ALIBABA_MAX_SIZE, scaledHeight));
|
|
||||||
|
|
||||||
scaledWidth = Math.round(scaledWidth / 2) * 2;
|
|
||||||
scaledHeight = Math.round(scaledHeight / 2) * 2;
|
|
||||||
|
|
||||||
return `${scaledWidth}*${scaledHeight}`;
|
return `${scaledWidth}*${scaledHeight}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function constrainAlibabaOutputDimensions(dimensions: { width: number; height: number }) {
|
||||||
|
return constrainImageDimensions(dimensions, {
|
||||||
|
maxEdge: ALIBABA_MAX_SIZE,
|
||||||
|
maxPixels: ALIBABA_MAX_SIZE * ALIBABA_MAX_SIZE,
|
||||||
|
minEdge: ALIBABA_MIN_SIZE,
|
||||||
|
multiple: ALIBABA_OUTPUT_SIZE_MULTIPLE,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建缩略图
|
* 创建缩略图
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
type BflImageConfig = {
|
type BflImageConfig = {
|
||||||
@ -160,30 +161,22 @@ function parseAspectRatioValue(ratio: string) {
|
|||||||
return width / height;
|
return width / height;
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundToMultipleOf32(value: number) {
|
|
||||||
return Math.max(32, Math.round(value / 32) * 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
|
||||||
const baseMap: Record<typeof imageSize, number> = {
|
const baseMap: Record<typeof imageSize, number> = {
|
||||||
"1K": 1024,
|
"1K": 1024,
|
||||||
"2K": 1536,
|
"2K": 1536,
|
||||||
"4K": 2048,
|
"4K": 2048,
|
||||||
};
|
};
|
||||||
const base = baseMap[imageSize] || 1536;
|
return resolveLongestEdgeDimensions({
|
||||||
const ratioValue = parseAspectRatioValue(aspectRatio);
|
aspectRatio,
|
||||||
|
longestEdge: baseMap[imageSize] || 1536,
|
||||||
if (ratioValue >= 1) {
|
constraints: {
|
||||||
return {
|
maxEdge: 4096,
|
||||||
width: base,
|
maxPixels: 4096 * 4096,
|
||||||
height: roundToMultipleOf32(base / ratioValue),
|
minEdge: 32,
|
||||||
};
|
multiple: 32,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
return {
|
|
||||||
width: roundToMultipleOf32(base * ratioValue),
|
|
||||||
height: base,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inferAspectRatioFromImageSource(imageUrl: string): Promise<string | null> {
|
async function inferAspectRatioFromImageSource(imageUrl: string): Promise<string | null> {
|
||||||
|
|||||||
@ -2,6 +2,10 @@ import "server-only";
|
|||||||
|
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
constrainImageDimensions,
|
||||||
|
resolveLongestEdgeDimensions,
|
||||||
|
} from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
interface FalImageConfig {
|
interface FalImageConfig {
|
||||||
@ -43,6 +47,10 @@ const DEFAULT_RESOLUTION = "1K";
|
|||||||
const DEFAULT_OUTPUT_FORMAT = "png";
|
const DEFAULT_OUTPUT_FORMAT = "png";
|
||||||
const DEFAULT_SAFETY_TOLERANCE = "4";
|
const DEFAULT_SAFETY_TOLERANCE = "4";
|
||||||
const DEFAULT_TIMEOUT_MS = 300000;
|
const DEFAULT_TIMEOUT_MS = 300000;
|
||||||
|
const FAL_GENERIC_MAX_EDGE = 4096;
|
||||||
|
const FAL_GENERIC_MAX_PIXELS = 4096 * 4096;
|
||||||
|
const GPT_IMAGE_2_MAX_EDGE = 3840;
|
||||||
|
const GPT_IMAGE_2_MAX_PIXELS = 8_294_400;
|
||||||
const FAL_NANO_BANANA_MODEL_IDS = new Set([
|
const FAL_NANO_BANANA_MODEL_IDS = new Set([
|
||||||
"fal-ai/nano-banana",
|
"fal-ai/nano-banana",
|
||||||
"fal-ai/nano-banana-2",
|
"fal-ai/nano-banana-2",
|
||||||
@ -234,35 +242,39 @@ function normalizeAnyExplicitAspectRatio(aspectRatio?: string) {
|
|||||||
return normalized === "auto" ? undefined : normalized;
|
return normalized === "auto" ? undefined : normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAspectRatioValue(aspectRatio?: string) {
|
|
||||||
const normalized = normalizeAnyExplicitAspectRatio(aspectRatio);
|
|
||||||
if (!normalized) return undefined;
|
|
||||||
const [width, height] = normalized.split(":").map((part) => Number(part));
|
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return width / height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roundToMultiple(value: number, multiple: number) {
|
|
||||||
return Math.max(multiple, Math.round(value / multiple) * multiple);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveFalCustomImageSize(aspectRatio?: string, resolution?: string): FalImageSize {
|
function resolveFalCustomImageSize(aspectRatio?: string, resolution?: string): FalImageSize {
|
||||||
const ratio = parseAspectRatioValue(aspectRatio) || 1;
|
|
||||||
const bucket = normalizeResolution(resolution);
|
const bucket = normalizeResolution(resolution);
|
||||||
const areaByBucket: Record<string, number> = {
|
const longestEdgeByBucket: Record<string, number> = {
|
||||||
"0.5K": 768 * 768,
|
"0.5K": 768,
|
||||||
"1K": 1024 * 1024,
|
"1K": 1024,
|
||||||
"2K": 1536 * 1536,
|
"2K": 1536,
|
||||||
"4K": 2048 * 2048,
|
"4K": 2048,
|
||||||
};
|
};
|
||||||
const targetArea = areaByBucket[bucket] || areaByBucket["1K"];
|
const { width, height } = resolveLongestEdgeDimensions({
|
||||||
const width = roundToMultiple(Math.sqrt(targetArea * ratio), 16);
|
aspectRatio,
|
||||||
const height = roundToMultiple(width / ratio, 16);
|
longestEdge: longestEdgeByBucket[bucket] || longestEdgeByBucket["1K"],
|
||||||
|
constraints: {
|
||||||
|
maxEdge: FAL_GENERIC_MAX_EDGE,
|
||||||
|
maxPixels: FAL_GENERIC_MAX_PIXELS,
|
||||||
|
minEdge: 256,
|
||||||
|
multiple: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function constrainFalImageSize(size: FalImageSize, constraints = {
|
||||||
|
maxEdge: FAL_GENERIC_MAX_EDGE,
|
||||||
|
maxPixels: FAL_GENERIC_MAX_PIXELS,
|
||||||
|
}) {
|
||||||
|
if (typeof size === "string") return size;
|
||||||
|
return constrainImageDimensions(size, {
|
||||||
|
...constraints,
|
||||||
|
minEdge: 256,
|
||||||
|
multiple: 16,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeFalImageSize(imageSize?: string, aspectRatio?: string): FalImageSize {
|
function normalizeFalImageSize(imageSize?: string, aspectRatio?: string): FalImageSize {
|
||||||
const rawSize = String(imageSize || "").trim();
|
const rawSize = String(imageSize || "").trim();
|
||||||
const normalizedSize = rawSize.toLowerCase().replace(/\s+/g, "");
|
const normalizedSize = rawSize.toLowerCase().replace(/\s+/g, "");
|
||||||
@ -282,10 +294,7 @@ function normalizeFalImageSize(imageSize?: string, aspectRatio?: string): FalIma
|
|||||||
width <= 4096 &&
|
width <= 4096 &&
|
||||||
height <= 4096
|
height <= 4096
|
||||||
) {
|
) {
|
||||||
return {
|
return constrainFalImageSize({ width, height });
|
||||||
width: roundToMultiple(width, 16),
|
|
||||||
height: roundToMultiple(height, 16),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -393,18 +402,24 @@ function normalizeGptImage2EditImageSize(
|
|||||||
width % 16 === 0 &&
|
width % 16 === 0 &&
|
||||||
height % 16 === 0
|
height % 16 === 0
|
||||||
) {
|
) {
|
||||||
return { width, height };
|
return constrainFalImageSize(
|
||||||
|
{ width, height },
|
||||||
|
{ maxEdge: GPT_IMAGE_2_MAX_EDGE, maxPixels: GPT_IMAGE_2_MAX_PIXELS }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeBucket = rawSize.toUpperCase();
|
const sizeBucket = rawSize.toUpperCase();
|
||||||
const ratio = normalizeAspectRatio(aspectRatio).toLowerCase();
|
const ratio = normalizeAspectRatio(aspectRatio).toLowerCase();
|
||||||
return (
|
const resolvedSize =
|
||||||
GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] ||
|
GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] ||
|
||||||
(normalizeAnyExplicitAspectRatio(aspectRatio)
|
(normalizeAnyExplicitAspectRatio(aspectRatio)
|
||||||
? resolveFalCustomImageSize(aspectRatio, sizeBucket)
|
? resolveFalCustomImageSize(aspectRatio, sizeBucket)
|
||||||
: "auto")
|
: "auto");
|
||||||
);
|
return constrainFalImageSize(resolvedSize, {
|
||||||
|
maxEdge: GPT_IMAGE_2_MAX_EDGE,
|
||||||
|
maxPixels: GPT_IMAGE_2_MAX_PIXELS,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFalImageSizeLabel(size: FalImageSize) {
|
function getFalImageSizeLabel(size: FalImageSize) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import {
|
import {
|
||||||
getKreaModelPresetById,
|
getKreaModelPresetById,
|
||||||
inferKreaModelKind,
|
inferKreaModelKind,
|
||||||
@ -258,10 +259,6 @@ function clamp(value: number, min: number, max: number) {
|
|||||||
return Math.max(min, Math.min(max, value));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
function roundToMultiple(value: number, multiple = 16) {
|
|
||||||
return Math.max(multiple, Math.round(value / multiple) * multiple);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
|
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
|
||||||
const normalized = normalizeText(imageSize).toUpperCase();
|
const normalized = normalizeText(imageSize).toUpperCase();
|
||||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
if (normalized === "1K" || normalized === "2K" || normalized === "4K") {
|
||||||
@ -274,21 +271,17 @@ function resolveImageDimensions(aspectRatio?: string, imageSize?: string) {
|
|||||||
const size = normalizeImageSizeLabel(imageSize);
|
const size = normalizeImageSizeLabel(imageSize);
|
||||||
const base = size === "4K" ? 2048 : size === "2K" ? 1536 : 1024;
|
const base = size === "4K" ? 2048 : size === "2K" ? 1536 : 1024;
|
||||||
const ratio = normalizeAspectRatio(aspectRatio, "1:1");
|
const ratio = normalizeAspectRatio(aspectRatio, "1:1");
|
||||||
if (ratio === "auto" || !ratio.includes(":")) {
|
const { width, height } = resolveLongestEdgeDimensions({
|
||||||
return { width: base, height: base, imageSize: size };
|
aspectRatio: ratio === "auto" ? "1:1" : ratio,
|
||||||
}
|
longestEdge: base,
|
||||||
|
constraints: {
|
||||||
const [rawW, rawH] = ratio.split(":").map((item) => Number(item));
|
maxEdge: 2048,
|
||||||
const ratioW = Number.isFinite(rawW) && rawW > 0 ? rawW : 1;
|
maxPixels: 2048 * 2048,
|
||||||
const ratioH = Number.isFinite(rawH) && rawH > 0 ? rawH : 1;
|
minEdge: 256,
|
||||||
const landscape = ratioW >= ratioH;
|
multiple: 16,
|
||||||
const width = landscape ? base : Math.round((base * ratioW) / ratioH);
|
},
|
||||||
const height = landscape ? Math.round((base * ratioH) / ratioW) : base;
|
});
|
||||||
return {
|
return { width, height, imageSize: size };
|
||||||
width: clamp(roundToMultiple(width), 256, 2048),
|
|
||||||
height: clamp(roundToMultiple(height), 256, 2048),
|
|
||||||
imageSize: size,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveVideoDimensions(ratio?: string, resolution?: string) {
|
function resolveVideoDimensions(ratio?: string, resolution?: string) {
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
resolveLongestEdgeDimensions,
|
||||||
|
} from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
interface OpenAIImageConfig {
|
interface OpenAIImageConfig {
|
||||||
@ -124,10 +127,6 @@ function ceilToMultiple(value: number, multiple: number) {
|
|||||||
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
|
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
|
||||||
}
|
}
|
||||||
|
|
||||||
function floorToMultiple(value: number, multiple: number) {
|
|
||||||
return Math.max(multiple, Math.floor(value / multiple) * multiple);
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferAspectRatioFromPrompt(prompt: string) {
|
function inferAspectRatioFromPrompt(prompt: string) {
|
||||||
const normalized = String(prompt || "").replace(/:/g, ":");
|
const normalized = String(prompt || "").replace(/:/g, ":");
|
||||||
const match = normalized.match(/(\d{1,2})\s*(?::|比)\s*(\d{1,2})/);
|
const match = normalized.match(/(\d{1,2})\s*(?::|比)\s*(\d{1,2})/);
|
||||||
@ -212,34 +211,15 @@ function resolveComflyGptImageSize(aspectRatio: string, imageSize: "1K" | "2K" |
|
|||||||
"2K": 2048,
|
"2K": 2048,
|
||||||
"4K": COMFLY_GPT_IMAGE_MAX_EDGE,
|
"4K": COMFLY_GPT_IMAGE_MAX_EDGE,
|
||||||
};
|
};
|
||||||
const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 };
|
const { width, height } = resolveLongestEdgeDimensions({
|
||||||
const longestEdge = edgeMap[imageSize] || edgeMap["2K"];
|
aspectRatio,
|
||||||
const scale = longestEdge / Math.max(ratioPair.width, ratioPair.height);
|
longestEdge: edgeMap[imageSize] || edgeMap["2K"],
|
||||||
let width = floorToMultiple(ratioPair.width * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
constraints: {
|
||||||
let height = floorToMultiple(ratioPair.height * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
maxEdge: COMFLY_GPT_IMAGE_MAX_EDGE,
|
||||||
|
maxPixels: COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS,
|
||||||
while (
|
multiple: OPENAI_IMAGE_DIMENSION_MULTIPLE,
|
||||||
width > COMFLY_GPT_IMAGE_MAX_EDGE ||
|
},
|
||||||
height > COMFLY_GPT_IMAGE_MAX_EDGE ||
|
});
|
||||||
width * height > COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS
|
|
||||||
) {
|
|
||||||
const scaleDown = Math.min(
|
|
||||||
COMFLY_GPT_IMAGE_MAX_EDGE / width,
|
|
||||||
COMFLY_GPT_IMAGE_MAX_EDGE / height,
|
|
||||||
Math.sqrt(COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS / (width * height)),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
const nextWidth = floorToMultiple(width * scaleDown, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
|
||||||
const nextHeight = floorToMultiple(height * scaleDown, OPENAI_IMAGE_DIMENSION_MULTIPLE);
|
|
||||||
|
|
||||||
if (nextWidth === width && nextHeight === height) {
|
|
||||||
if (width >= height) width -= OPENAI_IMAGE_DIMENSION_MULTIPLE;
|
|
||||||
else height -= OPENAI_IMAGE_DIMENSION_MULTIPLE;
|
|
||||||
} else {
|
|
||||||
width = nextWidth;
|
|
||||||
height = nextHeight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${width}x${height}`;
|
return `${width}x${height}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
|
|
||||||
interface StepfunImageConfig {
|
interface StepfunImageConfig {
|
||||||
@ -60,7 +61,17 @@ function parseAspectRatioValue(ratio?: string | null) {
|
|||||||
function resolveStepfunSize(aspectRatio?: string, explicitSize?: string) {
|
function resolveStepfunSize(aspectRatio?: string, explicitSize?: string) {
|
||||||
const normalizedExplicit = String(explicitSize || "").trim();
|
const normalizedExplicit = String(explicitSize || "").trim();
|
||||||
if (/^\d{3,4}x\d{3,4}$/i.test(normalizedExplicit)) {
|
if (/^\d{3,4}x\d{3,4}$/i.test(normalizedExplicit)) {
|
||||||
return normalizedExplicit;
|
const [rawWidth, rawHeight] = normalizedExplicit.split("x").map((item) => Number(item));
|
||||||
|
const { width, height } = constrainImageDimensions(
|
||||||
|
{ width: rawWidth, height: rawHeight },
|
||||||
|
{
|
||||||
|
maxEdge: 1360,
|
||||||
|
maxPixels: 1360 * 1360,
|
||||||
|
minEdge: 256,
|
||||||
|
multiple: 8,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return `${width}x${height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ratio = parseAspectRatioValue(aspectRatio);
|
const ratio = parseAspectRatioValue(aspectRatio);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
|
import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||||
import { uploadFile } from "@/lib/storage/service";
|
import { uploadFile } from "@/lib/storage/service";
|
||||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||||
import { createLogger } from "@/lib/observability/logger";
|
import { createLogger } from "@/lib/observability/logger";
|
||||||
@ -23,6 +24,9 @@ const VOLCANO_IMAGE_FETCH_TIMEOUT_MS = 20_000;
|
|||||||
const VOLCANO_INLINE_MAX_SIDE = 1024;
|
const VOLCANO_INLINE_MAX_SIDE = 1024;
|
||||||
const DEFAULT_VOLCANO_IMAGE_SIZE = "2K";
|
const DEFAULT_VOLCANO_IMAGE_SIZE = "2K";
|
||||||
const SEEDREAM_MODEL_ID_PATTERN = /doubao-seedream-4-5/i;
|
const SEEDREAM_MODEL_ID_PATTERN = /doubao-seedream-4-5/i;
|
||||||
|
const VOLCANO_MAX_OUTPUT_EDGE = 6240;
|
||||||
|
const VOLCANO_MAX_OUTPUT_PIXELS = 4096 * 4096;
|
||||||
|
const VOLCANO_OUTPUT_SIZE_MULTIPLE = 16;
|
||||||
|
|
||||||
const VOLCANO_ASPECT_SIZE_OPTIONS: Record<
|
const VOLCANO_ASPECT_SIZE_OPTIONS: Record<
|
||||||
VolcanoImageSizeBucket,
|
VolcanoImageSizeBucket,
|
||||||
@ -112,7 +116,8 @@ function normalizeVolcanoExactSize(imageSize?: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${width}x${height}`;
|
const constrained = constrainVolcanoOutputDimensions({ width, height });
|
||||||
|
return `${constrained.width}x${constrained.height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseVolcanoAspectRatio(aspectRatio?: string): number | null {
|
function parseVolcanoAspectRatio(aspectRatio?: string): number | null {
|
||||||
@ -153,7 +158,17 @@ function resolveVolcanoAspectAwareSize(
|
|||||||
return currentBest;
|
return currentBest;
|
||||||
}, candidates[0]);
|
}, candidates[0]);
|
||||||
|
|
||||||
return `${best.width}x${best.height}`;
|
const constrained = constrainVolcanoOutputDimensions(best);
|
||||||
|
return `${constrained.width}x${constrained.height}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainVolcanoOutputDimensions(dimensions: { width: number; height: number }) {
|
||||||
|
return constrainImageDimensions(dimensions, {
|
||||||
|
maxEdge: VOLCANO_MAX_OUTPUT_EDGE,
|
||||||
|
maxPixels: VOLCANO_MAX_OUTPUT_PIXELS,
|
||||||
|
minEdge: 256,
|
||||||
|
multiple: VOLCANO_OUTPUT_SIZE_MULTIPLE,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFirstImageSource(image: string | string[] | undefined): string | null {
|
function getFirstImageSource(image: string | string[] | undefined): string | null {
|
||||||
|
|||||||
@ -341,6 +341,29 @@ function isCanvasImageSizeSupported(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickSupportedCanvasImageSize(
|
||||||
|
config: CanvasImageGenConfig | null | undefined,
|
||||||
|
requestedSize: CanvasImageSizeValue
|
||||||
|
): CanvasImageSizeValue {
|
||||||
|
if (isCanvasImageSizeSupported(config, requestedSize)) return requestedSize;
|
||||||
|
|
||||||
|
const preferredFallbacks: Record<CanvasImageSizeValue, CanvasImageSizeValue[]> = {
|
||||||
|
"4K": ["2K", "1K"],
|
||||||
|
"2K": ["1K"],
|
||||||
|
"1K": ["2K", "4K"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
preferredFallbacks[requestedSize].find((candidate) =>
|
||||||
|
isCanvasImageSizeSupported(config, candidate)
|
||||||
|
) ||
|
||||||
|
(["1K", "2K", "4K"] as const).find((candidate) =>
|
||||||
|
isCanvasImageSizeSupported(config, candidate)
|
||||||
|
) ||
|
||||||
|
"2K"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeCanvasImageParamsByModel(params: {
|
function normalizeCanvasImageParamsByModel(params: {
|
||||||
imageCount: number;
|
imageCount: number;
|
||||||
imageSize: string;
|
imageSize: string;
|
||||||
@ -353,13 +376,8 @@ function normalizeCanvasImageParamsByModel(params: {
|
|||||||
: 12;
|
: 12;
|
||||||
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
|
const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount)));
|
||||||
|
|
||||||
let imageSize = normalizeCanvasImageSizeValue(params.imageSize);
|
const requestedSize = normalizeCanvasImageSizeValue(params.imageSize);
|
||||||
if (!isCanvasImageSizeSupported(params.imageGenConfig, imageSize)) {
|
const imageSize = pickSupportedCanvasImageSize(params.imageGenConfig, requestedSize);
|
||||||
imageSize =
|
|
||||||
(["2K", "1K", "4K"] as const).find((candidate) =>
|
|
||||||
isCanvasImageSizeSupported(params.imageGenConfig, candidate)
|
|
||||||
) || "2K";
|
|
||||||
}
|
|
||||||
|
|
||||||
return { imageCount, imageSize };
|
return { imageCount, imageSize };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user