Normalize image output dimensions

This commit is contained in:
134 2026-06-13 13:33:02 +08:00
parent a265a77095
commit 4eac5cf0c0
10 changed files with 261 additions and 183 deletions

View File

@ -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}`;
} }

View 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,
},
);
}

View File

@ -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,
});
}
/** /**
* 创建缩略图 * 创建缩略图
*/ */

View File

@ -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> {

View File

@ -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) {

View File

@ -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) {

View File

@ -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}`;
} }

View File

@ -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);

View File

@ -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 {

View File

@ -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 };
} }