From 923e21c58124502adc09ad6d4a0570125c1c92e4 Mon Sep 17 00:00:00 2001 From: stupid-h4er Date: Thu, 28 May 2026 14:28:53 +0800 Subject: [PATCH] fix: normalize gpt image request size --- docs/pending-test.md | 1 + web/src/components/image-settings-panel.tsx | 7 +- web/src/services/api/image.ts | 78 +++++++++++++++------ 3 files changed, 62 insertions(+), 24 deletions(-) diff --git a/docs/pending-test.md b/docs/pending-test.md index bdb8a9b..2db3ddf 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。 - Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。 - Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。 - Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。 diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index 66025ce..61095b7 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -17,9 +17,10 @@ const aspectOptions = [ { value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" }, { value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" }, { value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" }, - { value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" }, - { value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" }, - { value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" }, + { value: "4:3", label: "4:3", width: 1360, height: 1024, icon: "landscape" }, + { value: "3:4", label: "3:4", width: 1024, height: 1360, icon: "portrait" }, + { value: "16:9", label: "16:9", width: 1824, height: 1024, icon: "landscape" }, + { value: "9:16", label: "9:16", width: 1024, height: 1824, icon: "portrait" }, { value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" }, { value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" }, { value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" }, diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index dea317e..87319e2 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -31,6 +31,12 @@ const QUALITY_ALIASES: Record = { "2k": "medium", "4k": "high", }; +const DEFAULT_IMAGE_SHORT_SIDE = 1024; +const IMAGE_SIZE_STEP = 16; +const IMAGE_MIN_PIXELS = 655360; +const IMAGE_MAX_PIXELS = 8294400; +const IMAGE_MAX_EDGE = 3840; +const IMAGE_MAX_RATIO = 3; function normalizeQuality(quality: string) { const value = quality.trim().toLowerCase(); @@ -38,36 +44,66 @@ function normalizeQuality(quality: string) { return QUALITY_BASE[normalized] ? normalized : undefined; } -/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". Returns undefined when quality is auto. */ -function resolveSize(quality: string, ratio: string): string | undefined { - const basePixels = QUALITY_BASE[quality]; - if (!basePixels || ratio === "auto" || !ratio) return undefined; +/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */ +function resolveSize(quality: string | undefined, ratio: string): string { + const parsedRatio = parseImageRatio(ratio); + const basePixels = quality ? QUALITY_BASE[quality] : undefined; + const isLandscape = parsedRatio.width >= parsedRatio.height; + const longRatio = isLandscape ? parsedRatio.width / parsedRatio.height : parsedRatio.height / parsedRatio.width; + let longSide: number; + let shortSide: number; - const parts = ratio.split(":"); - if (parts.length !== 2) return undefined; - const w = Number(parts[0]); - const h = Number(parts[1]); - if (!w || !h) return undefined; - - const targetPixels = basePixels * basePixels; - const isLandscape = w >= h; - const longRatio = isLandscape ? w / h : h / w; - - const longSideRaw = Math.sqrt(targetPixels * longRatio); - const longSide = Math.floor(longSideRaw / 16) * 16; - const shortSide = Math.round((longSide / longRatio) / 16) * 16; + if (basePixels) { + const targetPixels = basePixels * basePixels; + const longSideRaw = Math.sqrt(targetPixels * longRatio); + longSide = Math.floor(longSideRaw / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP; + shortSide = Math.round(longSide / longRatio / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP; + } else { + shortSide = DEFAULT_IMAGE_SHORT_SIDE; + longSide = Math.round((shortSide * longRatio) / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP; + } const width = isLandscape ? longSide : shortSide; const height = isLandscape ? shortSide : longSide; - + validateImageSize(width, height); return `${width}x${height}`; } +function parseImageRatio(value: string) { + const parts = value.split(":"); + if (parts.length !== 2) throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024"); + const w = Number(parts[0]); + const h = Number(parts[1]); + if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) throw new Error("图像比例必须是正数,例如 9:16"); + if (Math.max(w, h) / Math.min(w, h) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸"); + return { width: w, height: h }; +} + +function parseImageDimensions(value: string) { + const match = value.match(/^(\d+)x(\d+)$/i); + if (!match) return null; + return { width: Number(match[1]), height: Number(match[2]) }; +} + +function validateImageSize(width: number, height: number) { + if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error("图像尺寸必须是正整数,例如 1024x1024"); + if (width % IMAGE_SIZE_STEP !== 0 || height % IMAGE_SIZE_STEP !== 0) throw new Error("图像尺寸的宽高必须是 16 的倍数,请调整尺寸"); + if (Math.max(width, height) > IMAGE_MAX_EDGE) throw new Error("图像尺寸最长边不能超过 3840px,请调整尺寸"); + if (Math.max(width, height) / Math.min(width, height) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸"); + const pixels = width * height; + if (pixels < IMAGE_MIN_PIXELS || pixels > IMAGE_MAX_PIXELS) throw new Error("图像总像素需在 655360 到 8294400 之间,请调整尺寸"); +} + function resolveRequestSize(quality: string | undefined, size: string) { const value = size.trim(); - if (!value || value === "auto") return undefined; - if (/^\d+x\d+$/.test(value)) return value; - return (quality && resolveSize(quality, value)) || value; + if (!value || value.toLowerCase() === "auto") return undefined; + const dimensions = parseImageDimensions(value); + if (dimensions) { + validateImageSize(dimensions.width, dimensions.height); + return `${dimensions.width}x${dimensions.height}`; + } + if (value.includes(":")) return resolveSize(quality, value); + throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024"); } function resolveImageDataUrl(item: Record) {