From 4eac5cf0c0e4d9aa68f02ab0b84a76ee269fb8bc Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 13 Jun 2026 13:33:02 +0800 Subject: [PATCH] Normalize image output dimensions --- app/api/scene-fusion/route.ts | 38 +++++------- lib/ai/image-size-constraints.ts | 96 ++++++++++++++++++++++++++++++ lib/ai/providers/alibaba-image.ts | 65 +++++--------------- lib/ai/providers/bfl-image.ts | 29 ++++----- lib/ai/providers/fal-image.ts | 77 ++++++++++++++---------- lib/ai/providers/krea.ts | 31 ++++------ lib/ai/providers/openai-image.ts | 44 ++++---------- lib/ai/providers/stepfun-image.ts | 13 +++- lib/ai/providers/volcano-vision.ts | 19 +++++- lib/canvas/generate-image.ts | 32 +++++++--- 10 files changed, 261 insertions(+), 183 deletions(-) create mode 100644 lib/ai/image-size-constraints.ts diff --git a/app/api/scene-fusion/route.ts b/app/api/scene-fusion/route.ts index 87a0cdd83..917cf86b2 100644 --- a/app/api/scene-fusion/route.ts +++ b/app/api/scene-fusion/route.ts @@ -34,6 +34,7 @@ import { import { acquireCanvasImageModelSlot } from "@/lib/canvas/image-concurrency"; import { createLogger } from "@/lib/observability/logger"; import { resolveModelRuntimeId } from "@/lib/ai/model-runtime"; +import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints"; // 请求体 Schema const sceneFusionRequestSchema = z.object({ @@ -1264,32 +1265,21 @@ function calculateSize(aspectRatio: string, imageSize: string): string | undefin "4K": 2048, }; const baseSize = baseSizeMap[imageSize] || 1536; - - // 根据比例计算宽高 - const ratioMap: Record = { - "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) { + if (!/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/.test(aspectRatio)) { return undefined; } - - const [w, h] = ratio; - // 保持总像素接近 baseSize * baseSize,同时维持比例 - const scale = Math.sqrt((baseSize * baseSize) / (w * h)); - let width = Math.round(w * scale); - let height = Math.round(h * scale); - - // 确保宽高在 512-2048 范围内 - const clamp = (v: number) => Math.max(512, Math.min(2048, v)); - width = clamp(width); - height = clamp(height); - + + const { width, height } = resolveLongestEdgeDimensions({ + aspectRatio, + longestEdge: baseSize, + constraints: { + maxEdge: 2048, + maxPixels: 2048 * 2048, + minEdge: 512, + multiple: 2, + }, + }); + return `${width}*${height}`; } diff --git a/lib/ai/image-size-constraints.ts b/lib/ai/image-size-constraints.ts new file mode 100644 index 000000000..1fb0cb9e1 --- /dev/null +++ b/lib/ai/image-size-constraints.ts @@ -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, + }, + ); +} diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index a3d89154f..3aae63608 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -9,6 +9,7 @@ */ import sharp from "sharp"; +import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; interface AlibabaImageConfig { @@ -50,6 +51,7 @@ interface AlibabaImageResponse { const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000; const ALIBABA_MIN_SIZE = 512; const ALIBABA_MAX_SIZE = 2048; +const ALIBABA_OUTPUT_SIZE_MULTIPLE = 2; const ALIBABA_MAX_INPUT_SIDE = 4096; const ALIBABA_DEFAULT_MAX_INPUT_BYTES = 18 * 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 width = Math.round(height * safeAspect); - if (width > ALIBABA_MAX_SIZE) { - 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; + ({ width, height } = constrainAlibabaOutputDimensions({ width, height })); return `${width}*${height}`; } @@ -211,33 +188,23 @@ function calculateAlibabaSizeFromDimensions( let scaledWidth = Math.round(safeWidth * scale); let scaledHeight = Math.round(safeHeight * scale); - if (scaledWidth > ALIBABA_MAX_SIZE || scaledHeight > ALIBABA_MAX_SIZE) { - const clampScale = Math.min( - ALIBABA_MAX_SIZE / scaledWidth, - 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; + ({ width: scaledWidth, height: scaledHeight } = constrainAlibabaOutputDimensions({ + width: scaledWidth, + height: 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, + }); +} + /** * 创建缩略图 */ diff --git a/lib/ai/providers/bfl-image.ts b/lib/ai/providers/bfl-image.ts index d9fca7075..1e2ccee86 100644 --- a/lib/ai/providers/bfl-image.ts +++ b/lib/ai/providers/bfl-image.ts @@ -1,6 +1,7 @@ import "server-only"; import sharp from "sharp"; +import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; type BflImageConfig = { @@ -160,30 +161,22 @@ function parseAspectRatioValue(ratio: string) { return width / height; } -function roundToMultipleOf32(value: number) { - return Math.max(32, Math.round(value / 32) * 32); -} - function resolveBflDimensions(aspectRatio: string, imageSize: "1K" | "2K" | "4K") { const baseMap: Record = { "1K": 1024, "2K": 1536, "4K": 2048, }; - const base = baseMap[imageSize] || 1536; - const ratioValue = parseAspectRatioValue(aspectRatio); - - if (ratioValue >= 1) { - return { - width: base, - height: roundToMultipleOf32(base / ratioValue), - }; - } - - return { - width: roundToMultipleOf32(base * ratioValue), - height: base, - }; + return resolveLongestEdgeDimensions({ + aspectRatio, + longestEdge: baseMap[imageSize] || 1536, + constraints: { + maxEdge: 4096, + maxPixels: 4096 * 4096, + minEdge: 32, + multiple: 32, + }, + }); } async function inferAspectRatioFromImageSource(imageUrl: string): Promise { diff --git a/lib/ai/providers/fal-image.ts b/lib/ai/providers/fal-image.ts index bee405a58..0b80e1afb 100644 --- a/lib/ai/providers/fal-image.ts +++ b/lib/ai/providers/fal-image.ts @@ -2,6 +2,10 @@ import "server-only"; import { createHash } from "node:crypto"; import sharp from "sharp"; +import { + constrainImageDimensions, + resolveLongestEdgeDimensions, +} from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; interface FalImageConfig { @@ -43,6 +47,10 @@ const DEFAULT_RESOLUTION = "1K"; const DEFAULT_OUTPUT_FORMAT = "png"; const DEFAULT_SAFETY_TOLERANCE = "4"; 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([ "fal-ai/nano-banana", "fal-ai/nano-banana-2", @@ -234,35 +242,39 @@ function normalizeAnyExplicitAspectRatio(aspectRatio?: string) { 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 { - const ratio = parseAspectRatioValue(aspectRatio) || 1; const bucket = normalizeResolution(resolution); - const areaByBucket: Record = { - "0.5K": 768 * 768, - "1K": 1024 * 1024, - "2K": 1536 * 1536, - "4K": 2048 * 2048, + const longestEdgeByBucket: Record = { + "0.5K": 768, + "1K": 1024, + "2K": 1536, + "4K": 2048, }; - const targetArea = areaByBucket[bucket] || areaByBucket["1K"]; - const width = roundToMultiple(Math.sqrt(targetArea * ratio), 16); - const height = roundToMultiple(width / ratio, 16); + const { width, height } = resolveLongestEdgeDimensions({ + aspectRatio, + longestEdge: longestEdgeByBucket[bucket] || longestEdgeByBucket["1K"], + constraints: { + maxEdge: FAL_GENERIC_MAX_EDGE, + maxPixels: FAL_GENERIC_MAX_PIXELS, + minEdge: 256, + multiple: 16, + }, + }); 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 { const rawSize = String(imageSize || "").trim(); const normalizedSize = rawSize.toLowerCase().replace(/\s+/g, ""); @@ -282,10 +294,7 @@ function normalizeFalImageSize(imageSize?: string, aspectRatio?: string): FalIma width <= 4096 && height <= 4096 ) { - return { - width: roundToMultiple(width, 16), - height: roundToMultiple(height, 16), - }; + return constrainFalImageSize({ width, height }); } } @@ -393,18 +402,24 @@ function normalizeGptImage2EditImageSize( width % 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 ratio = normalizeAspectRatio(aspectRatio).toLowerCase(); - return ( + const resolvedSize = GPT_IMAGE_2_EDIT_SIZE_BY_BUCKET[sizeBucket]?.[ratio] || (normalizeAnyExplicitAspectRatio(aspectRatio) ? resolveFalCustomImageSize(aspectRatio, sizeBucket) - : "auto") - ); + : "auto"); + return constrainFalImageSize(resolvedSize, { + maxEdge: GPT_IMAGE_2_MAX_EDGE, + maxPixels: GPT_IMAGE_2_MAX_PIXELS, + }); } function getFalImageSizeLabel(size: FalImageSize) { diff --git a/lib/ai/providers/krea.ts b/lib/ai/providers/krea.ts index ba418726a..ba699cd19 100644 --- a/lib/ai/providers/krea.ts +++ b/lib/ai/providers/krea.ts @@ -1,6 +1,7 @@ import "server-only"; import sharp from "sharp"; +import { resolveLongestEdgeDimensions } from "@/lib/ai/image-size-constraints"; import { getKreaModelPresetById, inferKreaModelKind, @@ -258,10 +259,6 @@ function clamp(value: number, min: number, max: number) { 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" { const normalized = normalizeText(imageSize).toUpperCase(); if (normalized === "1K" || normalized === "2K" || normalized === "4K") { @@ -274,21 +271,17 @@ function resolveImageDimensions(aspectRatio?: string, imageSize?: string) { const size = normalizeImageSizeLabel(imageSize); const base = size === "4K" ? 2048 : size === "2K" ? 1536 : 1024; const ratio = normalizeAspectRatio(aspectRatio, "1:1"); - if (ratio === "auto" || !ratio.includes(":")) { - return { width: base, height: base, imageSize: size }; - } - - const [rawW, rawH] = ratio.split(":").map((item) => Number(item)); - const ratioW = Number.isFinite(rawW) && rawW > 0 ? rawW : 1; - const ratioH = Number.isFinite(rawH) && rawH > 0 ? rawH : 1; - const landscape = ratioW >= ratioH; - const width = landscape ? base : Math.round((base * ratioW) / ratioH); - const height = landscape ? Math.round((base * ratioH) / ratioW) : base; - return { - width: clamp(roundToMultiple(width), 256, 2048), - height: clamp(roundToMultiple(height), 256, 2048), - imageSize: size, - }; + const { width, height } = resolveLongestEdgeDimensions({ + aspectRatio: ratio === "auto" ? "1:1" : ratio, + longestEdge: base, + constraints: { + maxEdge: 2048, + maxPixels: 2048 * 2048, + minEdge: 256, + multiple: 16, + }, + }); + return { width, height, imageSize: size }; } function resolveVideoDimensions(ratio?: string, resolution?: string) { diff --git a/lib/ai/providers/openai-image.ts b/lib/ai/providers/openai-image.ts index f44033961..128616366 100644 --- a/lib/ai/providers/openai-image.ts +++ b/lib/ai/providers/openai-image.ts @@ -1,6 +1,9 @@ import "server-only"; import sharp from "sharp"; +import { + resolveLongestEdgeDimensions, +} from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; interface OpenAIImageConfig { @@ -124,10 +127,6 @@ function ceilToMultiple(value: number, multiple: number) { 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) { const normalized = String(prompt || "").replace(/:/g, ":"); 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, "4K": COMFLY_GPT_IMAGE_MAX_EDGE, }; - const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 }; - const longestEdge = edgeMap[imageSize] || edgeMap["2K"]; - const scale = longestEdge / Math.max(ratioPair.width, ratioPair.height); - let width = floorToMultiple(ratioPair.width * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE); - let height = floorToMultiple(ratioPair.height * scale, OPENAI_IMAGE_DIMENSION_MULTIPLE); - - while ( - 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; - } - } + const { width, height } = resolveLongestEdgeDimensions({ + aspectRatio, + longestEdge: edgeMap[imageSize] || edgeMap["2K"], + constraints: { + maxEdge: COMFLY_GPT_IMAGE_MAX_EDGE, + maxPixels: COMFLY_GPT_IMAGE_MAX_TOTAL_PIXELS, + multiple: OPENAI_IMAGE_DIMENSION_MULTIPLE, + }, + }); return `${width}x${height}`; } diff --git a/lib/ai/providers/stepfun-image.ts b/lib/ai/providers/stepfun-image.ts index 0c94cdc51..da1b32b55 100644 --- a/lib/ai/providers/stepfun-image.ts +++ b/lib/ai/providers/stepfun-image.ts @@ -1,6 +1,7 @@ import "server-only"; import sharp from "sharp"; +import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; interface StepfunImageConfig { @@ -60,7 +61,17 @@ function parseAspectRatioValue(ratio?: string | null) { function resolveStepfunSize(aspectRatio?: string, explicitSize?: string) { const normalizedExplicit = String(explicitSize || "").trim(); 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); diff --git a/lib/ai/providers/volcano-vision.ts b/lib/ai/providers/volcano-vision.ts index 01c233bc2..098a6cb58 100644 --- a/lib/ai/providers/volcano-vision.ts +++ b/lib/ai/providers/volcano-vision.ts @@ -1,5 +1,6 @@ import "server-only"; +import { constrainImageDimensions } from "@/lib/ai/image-size-constraints"; import { uploadFile } from "@/lib/storage/service"; import { getDefaultStorageConfig } from "@/lib/db/queries"; 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 DEFAULT_VOLCANO_IMAGE_SIZE = "2K"; 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< VolcanoImageSizeBucket, @@ -112,7 +116,8 @@ function normalizeVolcanoExactSize(imageSize?: string): string | null { return null; } - return `${width}x${height}`; + const constrained = constrainVolcanoOutputDimensions({ width, height }); + return `${constrained.width}x${constrained.height}`; } function parseVolcanoAspectRatio(aspectRatio?: string): number | null { @@ -153,7 +158,17 @@ function resolveVolcanoAspectAwareSize( return currentBest; }, 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 { diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 2e10eb147..05fd1d274 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -341,6 +341,29 @@ function isCanvasImageSizeSupported( return true; } +function pickSupportedCanvasImageSize( + config: CanvasImageGenConfig | null | undefined, + requestedSize: CanvasImageSizeValue +): CanvasImageSizeValue { + if (isCanvasImageSizeSupported(config, requestedSize)) return requestedSize; + + const preferredFallbacks: Record = { + "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: { imageCount: number; imageSize: string; @@ -353,13 +376,8 @@ function normalizeCanvasImageParamsByModel(params: { : 12; const imageCount = Math.max(1, Math.min(maxImages, Math.floor(params.imageCount))); - let imageSize = normalizeCanvasImageSizeValue(params.imageSize); - if (!isCanvasImageSizeSupported(params.imageGenConfig, imageSize)) { - imageSize = - (["2K", "1K", "4K"] as const).find((candidate) => - isCanvasImageSizeSupported(params.imageGenConfig, candidate) - ) || "2K"; - } + const requestedSize = normalizeCanvasImageSizeValue(params.imageSize); + const imageSize = pickSupportedCanvasImageSize(params.imageGenConfig, requestedSize); return { imageCount, imageSize }; }