From 35e48f614f3b5f364c13d05bc57416ce5af67afb Mon Sep 17 00:00:00 2001 From: MengHao Date: Sat, 18 Jul 2026 16:28:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=8C=89=E6=89=80=E9=80=89K=E6=A1=A3=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一前端占位与gpt-image-2实际请求尺寸矩阵 取消显式K档生成后的整图放大恢复 角色、图标和UI去背景仅重采样透明蒙版 补充前后端尺寸测试并同步编辑器文档与项目记忆 --- .../shared-memory/decision-log.md | 12 +- docs/project-memory/shared-memory/pitfalls.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 8 +- ...辑器】画板角色形象生成入口设计-2026-06-15.md | 6 +- .../crates/api-server/src/editor_project.rs | 215 ++++++++++++------ .../src/vector_engine/request.rs | 29 +++ .../ImageCanvasGenerationDialogModel.test.ts | 30 +-- .../ImageCanvasGenerationDialogModel.ts | 19 +- .../ImageCanvasGenerationModel.test.ts | 30 +++ .../ImageCanvasGenerationModel.ts | 34 +++ 11 files changed, 292 insertions(+), 101 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bf08b405c..d7e4092ba 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,14 @@ ``` --- +## 2026-07-18 图片生成 K 档由 provider 直接生成 + +- 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。 +- 决策:用户选择的模型、比例和 K 档先映射为 provider 可直接接受的真实像素,前端占位、api-server 请求和 VectorEngine request body 保持一致。带显式尺寸选项的用户生成不再用回图后缩放恢复 K 档;宣发素材固定交付尺寸与旧无尺寸请求保留原有兼容恢复。角色、图标和 UI 去背景降采样时只重采样 alpha 蒙版并应用回 provider 原始 RGB,不放大低分辨率后处理 RGB。 +- 影响范围:普通图片、角色形象、图标图集、UI 设计图的占位与生成请求,gpt-image-2 尺寸矩阵,以及角色透明后处理。 +- 验证方式:前端尺寸矩阵和入口占位测试、api-server 生成参数与 alpha 合成测试、platform-image 最终 request body 测试、类型检查、Rust check、编码和 diff 门禁。 +- 关联文档:`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-07-17 画布 Agent 普通消息不提供客户端停止 - 背景:普通消息进入 LLM 前,后端已经把用户消息写入 OSS;前端中断 fetch 只能停止本地等待,不能保证后端停止规划,且会保留无法与后端消息对齐的 optimistic message。 @@ -42,9 +50,9 @@ ## 2026-07-17 图片改造保持源图与所选清晰度 - 背景:图片画布从已生成的 2K 角色图重新打开生成器时,面板恢复逻辑会优先采用新建面板的 1K 默认值;即使用户重新选择 2K,角色透明化链路也可能接受 BgFilter / 阿里云返回的 1K 后处理图,并因 `nanobanana2` 使用标量清晰度档位而跳过几何尺寸恢复,最终把 2K provider 原图降为 1K 透明图。 -- 决策:从既有图片重新打开普通图片、角色、UI 或宣发生成器时,在没有仍存活的生成对话框快照时按当前图层真实 `originalWidth / originalHeight` 恢复比例与清晰度,并按目标模型支持范围归一;恢复或切换比例 / 清晰度后,普通图片、角色、图标图集和 UI 设计图的待生成及生成中占位框必须同步使用目标像素尺寸,不能保留新建 draft 的默认 1K 框。UI 素材提取的占位按框选数量对应的 1K / 2K 计划生成,旧图片修改入口按源图真实尺寸占位。角色形象去背景完成后必须把透明结果恢复到去背景前 provider 原图的像素尺寸,再进入既有交付尺寸恢复和持久化,去背景供应商不得改变最终素材分辨率。 +- 决策:从既有图片重新打开普通图片、角色、UI 或宣发生成器时,在没有仍存活的生成对话框快照时按当前图层真实 `originalWidth / originalHeight` 恢复比例与清晰度,并按目标模型支持范围归一;恢复或切换比例 / 清晰度后,普通图片、角色、图标图集和 UI 设计图的待生成及生成中占位框必须同步使用目标像素尺寸,不能保留新建 draft 的默认 1K 框。UI 素材提取的占位按框选数量对应的 1K / 2K 计划生成,旧图片修改入口按源图真实尺寸占位。角色形象去背景完成后必须保持去背景前 provider 原图的像素尺寸;若去背景供应商返回较小结果,只把 alpha 蒙版重采样回原图并保留原始 RGB,不放大低分辨率透明成品。 - 影响范围:图片画布生成对话框恢复、生成中占位尺寸、UI 素材提取与旧图片修改的 `canvasCompletion`、角色形象 BgFilter / 阿里云 / 本地去背后处理、项目资源与账号素材尺寸元数据。 -- 验证方式:覆盖“持久化 2K 角色图重开仍为 2K”“普通图片 / 角色 / 图标 / UI 改造的 2K 占位与目标一致”“普通生图、规范图和角色图生成中占位不回退 1K”“UI 提取和旧修改入口的完成占位使用业务目标尺寸”以及“1K 去背结果恢复到 2K provider 原图尺寸”的前后端定向测试,并运行前端类型检查、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 验证方式:覆盖“持久化 2K 角色图重开仍为 2K”“普通图片 / 角色 / 图标 / UI 改造的 2K 占位与目标一致”“普通生图、规范图和角色图生成中占位不回退 1K”“UI 提取和旧修改入口的完成占位使用业务目标尺寸”以及“较小去背结果只提供 alpha、最终 RGB 仍来自 2K provider 原图”的前后端定向测试,并运行前端类型检查、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。 ## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index a7d1b6c59..d1b8299ea 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## 图片生成的 K 档不能靠回图后缩放实现 + +- 现象:用户选择 2K 时占位框看起来是 2K,最终资源元数据也显示为 2K,但模型请求实际仍是固定 1K 或竖版回落尺寸;画面只是后端放大后的低分辨率结果。 +- 原因:前端占位尺寸、api-server 的模型尺寸映射和 VectorEngine provider 合法尺寸各自维护;同时通用交付恢复与角色去背景恢复会直接缩放整张回图,掩盖了上游请求尺寸错误。 +- 处理:`model + imageSize + aspectRatio` 必须先解析为 provider 可直接生成的真实尺寸,前端占位和后端请求使用同一矩阵。带新尺寸字段的用户生成不执行回图后交付放大;角色、图标和 UI 的去背景服务若降采样,只缩放 alpha 蒙版并应用回模型原始 K 档 RGB。 +- 验证:覆盖 nanobanana2 / gpt-image-2 的比例与 K 档尺寸矩阵、VectorEngine 最终请求体、普通图片 / 角色 / 图标 / UI 占位,以及低分辨率去背景结果只贡献 alpha、不贡献被放大的 RGB。 +- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/platform-image/src/vector_engine/request.rs`。 + ## 多产物任务保留中间原图不等于把中间原图放进画布 - 现象:角色形象只提交一次、任务列表和泥点也只记录一次,但画布同时出现透明角色与纯色背景角色;图标或 UI 生成也可能多出一张纯色背景图集。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 180604662..c9b43b5ab 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -87,7 +87,7 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。透明处理正常成功但返回尺寸与 provider 原图不同时,必须先把透明 PNG 恢复到 provider 原图像素尺寸,再执行交付尺寸恢复和持久化,避免 2K 角色图在去背景后降为 1K。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成必须把当前 K 档对应的真实像素直接传给 provider,前端占位与该请求尺寸使用同一映射;不得先请求固定 1K 再放大为 2K。角色生成可携带 `screenColor`、`segModel` 和 `referenceImageSrcs`;api-server 先保存带纯色背景源图,再调用 BgFilter 并传入 `screen_color=`、`seg_model=`,透明处理成功时生成透明 PNG,最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。角色、图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,只重采样透明图的 alpha 蒙版并应用回 provider 原图的原始分辨率 RGB,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片源,校验登录态后无条件创建外部生成任务,响应只返回 `queueState`。worker 由 api-server 解析图片文件,并通过共享 BgFilter HTTP client 调用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`;multipart 固定为 `file + background_mode=complex + seg_model=birefnet + cross_check=off`,不包含 `screen_color`,首次失败立即重试 `1` 次,两次都失败返回最终错误。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`,有 `canvasCompletion` 时完成后按生成占位写入结果图层,否则沿用旧的目标图层替换路径。令牌只在服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并尝试拆分。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index e0f18415b..7e64fed07 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -10,7 +10,7 @@ - 底部 AI 画布工具栏新增 `生成图标素材` 按钮。 - 点击后立即在画布中心创建图标素材占位图,不复用普通“单张空白图片”图标;占位图表现为一叠空白素材图标卡片。 -- 图标素材占位图使用 `360x360` 的画布展示尺寸和 `512x512` 的原始图集尺寸;面板中的模型、比例和尺寸仍按生成契约独立提交,不用通用图片生成的 `1K` 画布外框。 +- 图标素材占位图必须按当前模型、比例和 K 档对应的真实 provider 请求像素初始化;切换参数后继续保持占位尺寸与请求尺寸一致,不得用固定 `360x360 / 512x512` 框代替生成目标。 - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 - 透明背景处理正常成功后删除占位态,把后端返回的透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 的图集图层放到画布,并把按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材铺到图集右侧;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 @@ -45,8 +45,8 @@ - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 - `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。 - 模型与尺寸选项: - - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 - - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`,按 `size` 映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 + - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 + - `gpt-image-2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/edits`,把图标规范图作为 multipart `image`。K 档按最长边计算,并转换为 provider 可直接生成的合法像素:`1K` 的 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9` 分别为 `1024x1024 / 1024x768 / 1024x688 / 688x1024 / 608x1088 / 1088x608`;`2K` 分别为 `2048x2048 / 2048x1536 / 2048x1376 / 1376x2048 / 1152x2048 / 2048x1152`。其中 9:16 的 1K 尺寸按 provider 最小总像素和 16 对齐约束修正。禁止把 2K 竖图回落为 1K 请求,也禁止在回图后放大伪造所选 K 档。 - 用户在角色或图标素材面板中切换过模型后,下一次打开这两类面板继续使用上次模型。 - 不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`,最终 prompt 和 BgFilter 不透传 `auto`。 - Prompt 固定为: @@ -60,7 +60,7 @@ ## 去背与保存 - 后端收到 spritesheet 后先把带解析后纯色背景的源图写入 OSS,再调用 BgFilter 透明化;BgFilter multipart 固定传 `background_mode=flat`、`cross_check=off`,请求字段同时包含 `screenColor` 和 `segModel`。前端用户路径固定提交 `screenColor=auto` 与默认 `birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。 -- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 +- 透明背景处理正常成功时,带背景原图和去背后的透明 spritesheet 都先写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。 - 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。 - 手动拆分调用 `POST /api/editor/icon-spritesheets/slices`,只允许读取当前用户项目中的 `icon-spritesheet` 资源,不调用图片生成 provider,不扣除泥点。输入限制为单边最多 `4096` 像素、总像素最多 `2048×2048`,单次最多持久化 `64` 个切片;超限在任何切片写入前拒绝。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index 549481bcd..4489dce74 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -55,8 +55,8 @@ - 前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,不从生成器快照或输入快照恢复旧手动背景色 / 抠图模型。后端在组装 prompt 前把 `auto` 自动决策为具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`;调用 BgFilter 时只把解析后的具体 hex 作为 `screen_color` 传入。后端仍识别内部保留的 `anime-seg`,但该选项不对用户可见。 - 比例按 `x:y` 展示;大小按 `0.5K / 1K / 2K` 展示。 - 尺寸选项来源以 VectorEngine 接入文档为准: - - `nanobanana2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 - - `gpt-image-2`:比例 `1:1 / 2:3 / 3:2 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations` 或 `/v1/images/edits`,按文档尺寸映射:`1K 1:1 -> 1024x1024`、`1K 2:3/9:16 -> 1024x1536`、`1K 3:2/16:9 -> 1536x1024`、`2K 1:1 -> 2048x2048`、`2K 3:2/16:9 -> 2048x1152`;文档未列出 `2K` 竖版,`2K 2:3/9:16` 后端回落到 `1024x1536`。 + - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把比例写入 `generationConfig.imageConfig.aspectRatio`,把大小写入 `generationConfig.imageConfig.imageSize`;其中 `0.5K` 按文档传 `"512"`。 + - `gpt-image-2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `1K / 2K`。后端走 `/v1/images/generations` 或 `/v1/images/edits`。K 档按最长边计算,并转换为 provider 可直接生成的合法像素:`1K` 的 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9` 分别为 `1024x1024 / 1024x768 / 1024x688 / 688x1024 / 608x1088 / 1088x608`;`2K` 分别为 `2048x2048 / 2048x1536 / 2048x1376 / 1376x2048 / 1152x2048 / 2048x1152`。其中 9:16 的 1K 尺寸按 provider 最小总像素和 16 对齐约束修正。禁止把 2K 竖图回落为 1K 请求,也禁止在回图后放大伪造所选 K 档。 - 后端如果收到参考图,`nanobanana2` 把参考图作为 `inline_data` 传入原生 `generateContent`;`gpt-image-2` 走带多参考图的图片编辑链路。没有参考图时按所选模型走纯文本生成链路。 - `kind = "character"` 时,后端不直接把前端文本当完整生图提示词,而是把文本作为 `角色设定` 填入固定提示词骨架: @@ -114,7 +114,7 @@ - 角色生成提交统一走 `/api/editor/images/generations`,按 `角色规范 -> 常规参考图` 顺序传 `referenceImageSrcs`,并写入 `assetKind: "character"`。 - 角色图层重绘同样走 `/api/editor/images/generations` 的 `kind: "character"` 分支,原图作为参考图提交,生成结果继续保留 `assetKind: "character"`。 - 角色和图标素材生成已接入 `nanobanana2` / `gpt-image-2` 模型切换、上次模型记忆,以及按模型归一的比例 / 大小尺寸;`nanobanana2` 使用原生 `generateContent` 的 `imageConfig.aspectRatio/imageSize`,`gpt-image-2` 使用文档列出的 `size` 字符串。 -- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 +- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images//image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。 - `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。 - 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。 - 本次验证命令: diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4cbd7944a..535c4d513 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1854,11 +1854,7 @@ pub(crate) async fn generate_editor_image_for_owner( }; let removal_provider = removal.provider; let (restored_removal_image, postprocess_dimension_error) = - restore_editor_postprocessed_image_output_dimensions_or_original( - removal.image, - provider_width, - provider_height, - ); + apply_editor_postprocessed_alpha_to_provider_source_or_original(&image, removal.image); if let Some(error) = postprocess_dimension_error { tracing::warn!( task_id = %generated.task_id, @@ -1881,14 +1877,18 @@ pub(crate) async fn generate_editor_image_for_owner( None }; - // TODO the image size passed to api is already normalized, should remove those normalize - // and let resize here to get the proper size + // 中文注释:带模型尺寸选项的用户生成已经把目标像素直接传给 provider,不能在回图后靠缩放伪造 K 档。 + // 只有宣发素材的固定交付尺寸和没有新尺寸字段的历史请求继续执行交付尺寸恢复。 let (restored_image, dimension_restore_error) = - restore_editor_generated_image_output_dimensions_or_original( - image, - generation_options.model, - image_size.as_ref(), - ); + if is_publication_material_generation || !has_dimension_options { + restore_editor_generated_image_output_dimensions_or_original( + image, + generation_options.model, + image_size.as_ref(), + ) + } else { + (image, None) + }; if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -2303,13 +2303,17 @@ fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: match (image_size, aspect_ratio) { ("2K", "1:1") => "2048x2048", - // 中文注释:gpt-image-2 文档未列出 2K 竖版,竖版选择回落到文档明确支持的 1K 竖版。 - ("2K", "2:3") | ("2K", "9:16") => "1024x1536", + ("2K", "2:3") => "1376x2048", + ("2K", "9:16") => "1152x2048", ("2K", "4:3") => "2048x1536", - ("2K", "16:9") | ("2K", "3:2") => "2048x1152", - ("1K", "2:3") | ("1K", "9:16") => "1024x1536", - ("1K", "4:3") => "1536x1024", - ("1K", "3:2") | ("1K", "16:9") => "1536x1024", + ("2K", "3:2") => "2048x1376", + ("2K", "16:9") => "2048x1152", + ("1K", "2:3") => "688x1024", + // 中文注释:9:16 的 1024 长边会低于 provider 最小总像素,按原比例放大并做 16 对齐后直接请求。 + ("1K", "9:16") => "608x1088", + ("1K", "4:3") => "1024x768", + ("1K", "3:2") => "1024x688", + ("1K", "16:9") => "1088x608", _ => "1024x1024", } .to_string() @@ -2591,48 +2595,66 @@ fn restore_editor_generated_image_output_dimensions_or_original( } } -fn restore_editor_postprocessed_image_output_dimensions( - output: &DownloadedOpenAiImage, - target_width: u32, - target_height: u32, +fn apply_editor_postprocessed_alpha_to_provider_source( + provider_source: &DownloadedOpenAiImage, + postprocessed: &DownloadedOpenAiImage, ) -> Result, AppError> { - let decoded = image::load_from_memory(output.bytes.as_slice()).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-character-image", - "message": format!("角色透明图不是有效图片:{error}"), - })) - })?; - if decoded.width() == target_width && decoded.height() == target_height { + let provider_source = + image::load_from_memory(provider_source.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "message": format!("provider 原图不是有效图片:{error}"), + })) + })?; + let postprocessed = + image::load_from_memory(postprocessed.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-image-postprocess", + "message": format!("透明后处理图不是有效图片:{error}"), + })) + })?; + if postprocessed.width() == provider_source.width() + && postprocessed.height() == provider_source.height() + { return Ok(None); } - let restored = decoded.resize_exact( - target_width, - target_height, + // 中文注释:图片模型已经按用户所选 K 档直接生成 provider 原图。 + // 去背景服务若缩小图片,只重采样其 alpha 蒙版并应用回原始分辨率 RGB,禁止把低分辨率成品整图放大。 + let postprocessed = postprocessed.to_rgba8(); + let alpha = image::GrayImage::from_fn(postprocessed.width(), postprocessed.height(), |x, y| { + image::Luma([postprocessed.get_pixel(x, y).0[3]]) + }); + let alpha = image::imageops::resize( + &alpha, + provider_source.width(), + provider_source.height(), image::imageops::FilterType::Lanczos3, ); + let mut restored = provider_source.to_rgba8(); + for (x, y, pixel) in restored.enumerate_pixels_mut() { + pixel.0[3] = alpha.get_pixel(x, y).0[0]; + } Ok(Some(DownloadedOpenAiImage { bytes: encode_editor_image_png( - restored, + image::DynamicImage::ImageRgba8(restored), StatusCode::BAD_GATEWAY, - "editor-character-image", - "恢复角色透明图尺寸失败", + "editor-image-postprocess", + "恢复透明蒙版尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), })) } -fn restore_editor_postprocessed_image_output_dimensions_or_original( - output: DownloadedOpenAiImage, - target_width: u32, - target_height: u32, +fn apply_editor_postprocessed_alpha_to_provider_source_or_original( + provider_source: &DownloadedOpenAiImage, + postprocessed: DownloadedOpenAiImage, ) -> (DownloadedOpenAiImage, Option) { - match restore_editor_postprocessed_image_output_dimensions(&output, target_width, target_height) - { + match apply_editor_postprocessed_alpha_to_provider_source(provider_source, &postprocessed) { Ok(Some(restored)) => (restored, None), - Ok(None) => (output, None), - Err(error) => (output, Some(error)), + Ok(None) => (postprocessed, None), + Err(error) => (postprocessed, Some(error)), } } @@ -4306,7 +4328,19 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( )); } }; - let image = removal.image; + let removal_provider = removal.provider; + let (image, postprocess_dimension_error) = + apply_editor_postprocessed_alpha_to_provider_source_or_original(&image, removal.image); + if let Some(error) = postprocess_dimension_error { + tracing::warn!( + task_id = %generated.task_id, + provider = removal_provider, + source_width, + source_height, + error = ?error, + "图标透明图蒙版尺寸恢复失败,保留去背景服务原始输出" + ); + } let matting_generation_inputs = build_editor_derived_asset_generation_inputs( "图标图集抠图", &removal.model, @@ -5038,7 +5072,19 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( )); } }; - let image = removal.image; + let removal_provider = removal.provider; + let (image, postprocess_dimension_error) = + apply_editor_postprocessed_alpha_to_provider_source_or_original(&image, removal.image); + if let Some(error) = postprocess_dimension_error { + tracing::warn!( + task_id = %generated.task_id, + provider = removal_provider, + source_width, + source_height, + error = ?error, + "UI 素材透明图蒙版尺寸恢复失败,保留去背景服务原始输出" + ); + } let matting_generation_inputs = build_editor_derived_asset_generation_inputs("UI图集抠图", &removal.model, &source_record); let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) @@ -8497,28 +8543,39 @@ mod tests { } #[test] - fn character_postprocess_restores_provider_dimensions() { - let image = image::DynamicImage::new_rgba8(1024, 1024); - let mut bytes = Cursor::new(Vec::new()); - image - .write_to(&mut bytes, image::ImageFormat::Png) + fn static_image_postprocess_restores_only_alpha_at_provider_dimensions() { + let provider_source = image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 10, 20, 255])); + let mut provider_source_bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(provider_source) + .write_to(&mut provider_source_bytes, image::ImageFormat::Png) + .expect("test image should encode"); + let provider_source = DownloadedOpenAiImage { + bytes: provider_source_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let postprocessed = image::RgbaImage::from_pixel(2, 2, image::Rgba([1, 2, 240, 77])); + let mut postprocessed_bytes = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(postprocessed) + .write_to(&mut postprocessed_bytes, image::ImageFormat::Png) .expect("test image should encode"); let postprocessed = DownloadedOpenAiImage { - bytes: bytes.into_inner(), + bytes: postprocessed_bytes.into_inner(), mime_type: "image/png".to_string(), extension: "png".to_string(), }; let restored = - restore_editor_postprocessed_image_output_dimensions(&postprocessed, 2048, 2048) - .expect("postprocessed image should restore provider dimensions") - .expect("1K postprocessed image should be resized back to 2K"); - let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); + apply_editor_postprocessed_alpha_to_provider_source(&provider_source, &postprocessed) + .expect("postprocessed alpha should restore provider dimensions") + .expect("smaller alpha mask should be applied to provider source"); + let restored_image = image::load_from_memory(restored.bytes.as_slice()) + .unwrap() + .to_rgba8(); - assert_eq!( - (restored_image.width(), restored_image.height()), - (2048, 2048) - ); + assert_eq!((restored_image.width(), restored_image.height()), (4, 4)); + assert_eq!(restored_image.get_pixel(1, 1).0, [200, 10, 20, 77]); assert_eq!(restored.mime_type, "image/png"); assert_eq!(restored.extension, "png"); } @@ -8642,7 +8699,7 @@ mod tests { let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); assert_eq!(gpt.model, GPT_IMAGE_2_MODEL); - assert_eq!(gpt.size, "1024x1536"); + assert_eq!(gpt.size, "688x1024"); assert_eq!(gpt.aspect_ratio, "2:3"); assert_eq!(gpt.image_size, "1K"); assert_eq!(gpt.provider_image_size, "1K"); @@ -8656,7 +8713,7 @@ mod tests { let gpt_cover = normalize_editor_generation_options(Some("gpt-image-2"), Some("4:3"), Some("1K")); assert_eq!(gpt_cover.model, GPT_IMAGE_2_MODEL); - assert_eq!(gpt_cover.size, "1536x1024"); + assert_eq!(gpt_cover.size, "1024x768"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = @@ -8666,12 +8723,34 @@ mod tests { assert_eq!(gpt_landscape_2k.aspect_ratio, "16:9"); assert_eq!(gpt_landscape_2k.image_size, "2K"); - let gpt_portrait_2k_fallback = + let gpt_portrait_2k = normalize_editor_generation_options(Some("gpt-image-2"), Some("9:16"), Some("2K")); - assert_eq!(gpt_portrait_2k_fallback.model, GPT_IMAGE_2_MODEL); - assert_eq!(gpt_portrait_2k_fallback.size, "1024x1536"); - assert_eq!(gpt_portrait_2k_fallback.aspect_ratio, "9:16"); - assert_eq!(gpt_portrait_2k_fallback.image_size, "2K"); + assert_eq!(gpt_portrait_2k.model, GPT_IMAGE_2_MODEL); + assert_eq!(gpt_portrait_2k.size, "1152x2048"); + assert_eq!(gpt_portrait_2k.aspect_ratio, "9:16"); + assert_eq!(gpt_portrait_2k.image_size, "2K"); + + for (image_size, aspect_ratio, expected_size) in [ + ("1K", "1:1", "1024x1024"), + ("1K", "4:3", "1024x768"), + ("1K", "3:2", "1024x688"), + ("1K", "2:3", "688x1024"), + ("1K", "9:16", "608x1088"), + ("1K", "16:9", "1088x608"), + ("2K", "1:1", "2048x2048"), + ("2K", "4:3", "2048x1536"), + ("2K", "3:2", "2048x1376"), + ("2K", "2:3", "1376x2048"), + ("2K", "9:16", "1152x2048"), + ("2K", "16:9", "2048x1152"), + ] { + let options = normalize_editor_generation_options( + Some(GPT_IMAGE_2_MODEL), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!(options.size, expected_size); + } let fallback = normalize_editor_generation_options( Some("unknown-model"), @@ -8701,7 +8780,7 @@ mod tests { ); assert_eq!( resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), - "1536x1024" + "1024x768" ); } @@ -10801,7 +10880,7 @@ mod tests { let success_marker = if start.contains("generate_editor_image_for_owner") { "image = restored_removal_image;" } else { - "let image = removal.image;" + "let removal_provider = removal.provider;" }; let fallback_end = body[fallback_start..] .find(success_marker) diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index e67771f15..91d6075c4 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -370,4 +370,33 @@ mod tests { assert!(!serialized.contains("Bearer")); assert!(!serialized.contains("[1,2,3,4,5]")); } + + #[test] + fn gpt_image_2_request_keeps_direct_1k_and_2k_pixel_sizes() { + for expected_size in [ + "1024x1024", + "1024x768", + "1024x688", + "688x1024", + "608x1088", + "1088x608", + "2048x2048", + "2048x1536", + "2048x1376", + "1376x2048", + "1152x2048", + "2048x1152", + ] { + let body = build_vector_engine_image_request_body_with_model( + GPT_IMAGE_2_MODEL, + "测试", + None, + expected_size, + 1, + &[], + ); + + assert_eq!(body["size"], expected_size); + } + } } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index cbe39a79c..ca579fdbd 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -144,7 +144,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); - it('creates character generation drafts with model dimensions and icon drafts with icon frame dimensions', () => { + it('creates character and icon generation drafts with the selected model dimensions', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; @@ -183,12 +183,12 @@ describe('ImageCanvasGenerationDialogModel', () => { iconSpecReference: null, iconDescriptions: [], placeholder: { - x: 300, - y: 180, - width: 360, - height: 360, - originalWidth: 512, - originalHeight: 512, + x: -32, + y: -152, + width: 1024, + height: 1024, + originalWidth: 1024, + originalHeight: 1024, }, }); }); @@ -233,10 +233,10 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '1K', uiDesignSpecReference: null, placeholder: { - x: -32, - y: 72, - width: 1024, - height: 576, + x: -64, + y: 56, + width: 1088, + height: 608, }, }); }); @@ -621,9 +621,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1365, + width: 1376, height: 2048, - originalWidth: 1365, + originalWidth: 1376, originalHeight: 2048, }, }); @@ -661,9 +661,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1365, + width: 1376, height: 2048, - originalWidth: 1365, + originalWidth: 1376, originalHeight: 2048, }, }); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 2765a74fd..6e2500563 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -31,8 +31,6 @@ import { EDITOR_IMAGE_DIMENSION_OPTIONS, EDITOR_IMAGE_MODEL_OPTIONS, ICON_DESCRIPTION_LIMIT, - ICON_FRAME_DISPLAY_SIZE, - ICON_FRAME_ORIGINAL_SIZE, IMAGE_MODEL_GPT_IMAGE_2, inferEditorImageAspectRatio, inferEditorImageSizeLabel, @@ -299,6 +297,11 @@ export function createIconGenerationDialogDraft({ const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); const normalizedImageModel = normalizeEditorImageModel(imageModel); const dimensionDefaults = resolveImageDimensionDefaults(normalizedImageModel); + const placeholderSize = resolveEditorImageGenerationPixelSize({ + model: normalizedImageModel, + aspectRatio: dimensionDefaults.aspectRatio, + imageSize: dimensionDefaults.imageSize, + }); return { mode: 'icon', prompt: '', @@ -311,12 +314,12 @@ export function createIconGenerationDialogDraft({ aspectRatio: dimensionDefaults.aspectRatio, imageSize: dimensionDefaults.imageSize, placeholder: { - x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2, - width: ICON_FRAME_DISPLAY_SIZE.width, - height: ICON_FRAME_DISPLAY_SIZE.height, - originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, - originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, + x: worldCenter.x - placeholderSize.width / 2, + y: worldCenter.y - placeholderSize.height / 2, + width: placeholderSize.width, + height: placeholderSize.height, + originalWidth: placeholderSize.width, + originalHeight: placeholderSize.height, }, }; } diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 5951c4be4..7e8309e9a 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -42,8 +42,10 @@ import { getGenerationFrameAriaLabel, getGenerationFrameLabel, IMAGE_MODEL_GPT_IMAGE_2, + IMAGE_MODEL_NANOBANANA2, isQuickEditUnsupportedAssetKind, resolveCharacterAnimationSourceImageSrc, + resolveEditorImageGenerationPixelSize, resolveImageGenerationErrorMessage, VIDEO_MODEL_KLING_3, VIDEO_MODEL_KLING_3_OMNI, @@ -101,6 +103,34 @@ describe('ImageCanvasGenerationModel', () => { ).toBeTruthy(); }); + it.each([ + [IMAGE_MODEL_NANOBANANA2, '1K', '1:1', 1024, 1024], + [IMAGE_MODEL_NANOBANANA2, '2K', '2:3', 1365, 2048], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '1:1', 1024, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '4:3', 1024, 768], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '3:2', 1024, 688], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 688, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 608, 1088], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1088, 608], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '1:1', 2048, 2048], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '4:3', 2048, 1536], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '3:2', 2048, 1376], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1376, 2048], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '9:16', 1152, 2048], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '16:9', 2048, 1152], + ] as const)( + '%s 的 %s %s 占位尺寸与 provider 直接请求尺寸一致', + (model, imageSize, aspectRatio, width, height) => { + expect( + resolveEditorImageGenerationPixelSize({ + model, + aspectRatio, + imageSize, + }), + ).toEqual({ width, height }); + }, + ); + it('所有生成按钮价格都从模型定价配置推导', () => { expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL]['1K'], diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index d593e531b..e04b43e91 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -173,7 +173,38 @@ function resolveImageFrameSizeFromRatio({ }; } +function alignImageDimensionUp(value: number, alignment = 16) { + return Math.ceil(value / alignment) * alignment; +} + +function resolveGptImage2FrameSize({ + aspectRatio, + imageSize, +}: { + aspectRatio: string | null | undefined; + imageSize: string | null | undefined; +}): EditorImageFrameSize { + const requested = resolveImageFrameSizeFromRatio({ aspectRatio, imageSize }); + let width = requested.width; + let height = requested.height; + const pixels = width * height; + + // 中文注释:gpt-image-2 要求总像素不少于 655360,且宽高均为 16 的倍数。 + // 这里只把用户选择的 K 档和比例转换为 provider 可直接生成的真实尺寸,不做生成后放大。 + if (pixels < 655_360) { + const scale = Math.sqrt(655_360 / pixels); + width *= scale; + height *= scale; + } + + return { + width: alignImageDimensionUp(width), + height: alignImageDimensionUp(height), + }; +} + export function resolveEditorImageGenerationPixelSize({ + model, aspectRatio, imageSize, }: { @@ -181,6 +212,9 @@ export function resolveEditorImageGenerationPixelSize({ aspectRatio: string | null | undefined; imageSize: string | null | undefined; }): EditorImageFrameSize { + if (model?.trim() === IMAGE_MODEL_GPT_IMAGE_2) { + return resolveGptImage2FrameSize({ aspectRatio, imageSize }); + } return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize }); }