From 25ee537e393bcf11a00c240efaf76d4b0b1788b2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 27 Jul 2026 20:24:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E4=B8=8E=E5=9B=BE=E7=89=87=E5=B0=BA=E5=AF=B8=E4=B8=80?= =?UTF-8?q?=E8=87=B4=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提示弹窗三秒后自动消失并保留手动关闭 统一 nanobanana2 与 gpt-image-2 同规格图片的交付像素 补充尺寸归一和提示生命周期回归测试 同步图片画布生成契约文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 3 +- .../crates/api-server/src/editor_project.rs | 166 +++++++++++++----- .../useImageCanvasGenerationWorkflow.test.tsx | 10 +- .../useImageCanvasGenerationWorkflow.ts | 14 ++ 5 files changed, 147 insertions(+), 50 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 50ab585e2..7c1b78bd4 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -92,11 +92,11 @@ - `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 生成图片。带 `model / aspectRatio / imageSize` 的用户生成必须把当前 K 档对应的真实像素直接传给 provider,前端占位与该请求尺寸使用同一映射;不得先请求固定 1K 再放大为 2K。普通图片的 provider 回图先留在内存,尺寸变换成功后只上传变换结果,变换失败则只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时生成透明 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/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 直接请求对应合法像素;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`,provider 回图若大于业务目标(例如返回 `2752x1536`)则在内存归一到 `2048x1152` 后只上传最终结果,避免生成完成时覆盖占位尺寸并突然放大。尺寸变换失败时只上传 provider 原图,主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程先按 provider 原始分辨率保存带纯色背景源图,随后只以 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连);透明处理成功时只重采样透明图的 alpha 蒙版并应用回 provider 原图 RGB,最后把透明主结果归一到统一业务像素;最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 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`;前端按后端快照落画布,不补造缺失产物。 -- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数,回图后在内存恢复业务目标尺寸;nanobanana2 路径保留 provider 按比例和清晰度返回的几何尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图;无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。16 对齐尺寸不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 +- `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都在内存恢复统一业务目标尺寸。成功时只上传最终结果,尺寸恢复失败时只上传 provider 原图;无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 - `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `asset` 快照,基础响应返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。 - `POST /api/editor/audios/sound-effects/generations` 与 `POST /api/editor/audios/background-music/generations`:按音效 / 背景音乐参数生成音频并持久化到 OSS;请求携带 `projectId` / `assetFolderId` 时同步创建 project resource / 账号素材并返回 `project` / `resource` / `asset` 快照,基础响应返回 `audioSrc`、prompt、model、provider、taskId、duration、歌词和 `priceMudPoints`。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 26bc34cb6..15695106b 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -97,7 +97,7 @@ - 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。 - 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。 - 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。 -- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 计算像素尺寸;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 +- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;同一规格不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。`nanobanana2` provider 原生 K 档回图若更大,普通生成和快速编辑在持久化前归一到该业务像素;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 @@ -124,6 +124,7 @@ - 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。 - 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。 - 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,与 `sliceWarning` 互斥;`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。 +- 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。 - 普通图片、图片修改、规范、角色、图标、UI 设计、宣发素材、视频、音效、背景音乐和角色动作生成面板不展示“资源名称”输入,默认继续使用现有“类型 + 编号”名称;提示词输入保持统一可见边框。状态与请求契约仍兼容可选 `assetLabel`,内部调用或历史状态携带名称时最多 80 个字符并在提交时 trim,最终解析出的同一个名称必须同时写入画布图层、`editor_project_resource`、`editor_asset` 和 `canvasCompletion.title`。中间原图在主名称后追加“(原图)/(原始输出)”,拆分图标仍使用各自素材描述。 - 图片、视频和音频生成结果都要写入账号级素材库;视频 / 音频结果由后端持久化到 OSS 并回传 `objectKey` / `assetObjectId`,前端保存素材库时一并记录,后续预览和再次加入画布走统一换签链路。 - 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 144b94149..430fdc743 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1769,6 +1769,34 @@ pub(crate) async fn generate_editor_image_for_owner( "message": format!("素材生成结果不是有效图片:{error}"), })) })?; + // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 + // 2752x1536;画布业务规格仍以统一的 2048 长边像素矩阵为准,持久化前归一, + // 保证完成图与生成前占位标注一致。 + let (restored_image, dimension_restore_error) = if is_character_generation { + // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; + // 透明主结果会在后处理完成后再归一到画布业务规格。 + (image, None) + } else { + restore_editor_generated_image_output_dimensions_or_original( + image, + generation_options.model, + image_size.as_ref(), + ) + }; + if let Some(error) = dimension_restore_error { + tracing::warn!( + task_id = %generated.task_id, + provider_width, + provider_height, + target_size = %image_size, + error = ?error, + "画板生成图片尺寸恢复失败,保留并上传 provider 原图" + ); + } + image = restored_image; + let (delivery_width, delivery_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((provider_width, provider_height)); let storage_profile = editor_generated_image_storage_profile(normalized_kind); let mut output_prompt = submitted_prompt.clone(); let mut output_actual_prompt = generated.actual_prompt.clone(); @@ -1803,8 +1831,8 @@ pub(crate) async fn generate_editor_image_for_owner( .clone() .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"), - width: provider_width, - height: provider_height, + width: delivery_width, + height: delivery_height, prompt: role_setting.clone(), actual_prompt: generated.actual_prompt.clone(), model: generation_options.model.to_string(), @@ -1863,8 +1891,8 @@ pub(crate) async fn generate_editor_image_for_owner( image_src: source_image_src, object_key: Some(source_object_key), asset_object_id: Some(source_asset_object_id), - width: provider_width, - height: provider_height, + width: delivery_width, + height: delivery_height, source_type: "generated", prompt: role_setting, actual_prompt: generated.actual_prompt, @@ -1886,8 +1914,8 @@ pub(crate) async fn generate_editor_image_for_owner( apply_editor_postprocessed_alpha_from_persisted_provider_source_or_original( state, source_object_key.as_str(), - provider_width, - provider_height, + delivery_width, + delivery_height, removal.image, ) .await; @@ -1897,6 +1925,8 @@ pub(crate) async fn generate_editor_image_for_owner( provider = removal_provider, provider_width, provider_height, + delivery_width, + delivery_height, error = ?error, "角色透明图尺寸恢复失败,保留去背景服务原始输出" ); @@ -1913,26 +1943,26 @@ pub(crate) async fn generate_editor_image_for_owner( None }; - // 中文注释:带模型尺寸选项的用户生成已经把目标像素直接传给 provider,不能在回图后靠缩放伪造 K 档。 - // 只有宣发素材的固定交付尺寸和没有新尺寸字段的历史请求继续执行交付尺寸恢复。 - let (restored_image, dimension_restore_error) = - if is_publication_material_generation || !has_dimension_options { + if is_character_generation { + let (restored_image, dimension_restore_error) = 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, - error = ?error, - "画板生成图片尺寸恢复失败,保留并上传 provider 原图" - ); + ); + if let Some(error) = dimension_restore_error { + tracing::warn!( + task_id = %generated.task_id, + provider_width, + provider_height, + target_size = %image_size, + error = ?error, + "画板角色透明主图尺寸恢复失败,保留后处理原图" + ); + } + image = restored_image; } - image = restored_image; + let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2334,13 +2364,31 @@ fn normalize_editor_generation_image_size(model: &str, image_size: Option<&str>) fn editor_generation_size_for_model(model: &str, aspect_ratio: &str, image_size: &str) -> String { if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - return match image_size { - // 中文注释:VectorEngine 的 nanobanana2 文档要求 0.5K 传入 512。 - "0.5K" => "512", - "2K" => "2048", - _ => "1024", - } - .to_string(); + let long_edge = match image_size { + "0.5K" => 512_u32, + "2K" => 2048_u32, + _ => 1024_u32, + }; + let (ratio_width, ratio_height) = match aspect_ratio { + "2:3" => (2_u32, 3_u32), + "3:2" => (3_u32, 2_u32), + "4:3" => (4_u32, 3_u32), + "9:16" => (9_u32, 16_u32), + "16:9" => (16_u32, 9_u32), + _ => (1_u32, 1_u32), + }; + let (width, height) = if ratio_width >= ratio_height { + ( + long_edge, + (long_edge * ratio_height + ratio_width / 2) / ratio_width, + ) + } else { + ( + (long_edge * ratio_width + ratio_height / 2) / ratio_height, + long_edge, + ) + }; + return format!("{width}x{height}"); } match (image_size, aspect_ratio) { @@ -2738,14 +2786,9 @@ async fn apply_editor_postprocessed_alpha_from_persisted_provider_source_or_orig fn restore_editor_generated_image_output_dimensions( output: &DownloadedOpenAiImage, - model: &str, + _model: &str, target_size: &str, ) -> Result, AppError> { - // nanobanana 使用标量清晰度档位和独立比例;provider 回图已经是目标几何尺寸, - // 不能再把 512 / 1024 / 2K 当作 WIDTHxHEIGHT 解析。 - if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { - return Ok(None); - } let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", @@ -2783,7 +2826,7 @@ fn restore_editor_generated_image_output_dimensions( bytes: encode_editor_image_edit_png( restored, StatusCode::BAD_GATEWAY, - "恢复宣发素材交付尺寸失败", + "恢复图片生成目标尺寸失败", )?, mime_type: "image/png".to_string(), extension: "png".to_string(), @@ -2998,7 +3041,11 @@ pub(crate) async fn edit_editor_image_for_owner( let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref() { restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions) } else { - (provider_image, None) + restore_editor_generated_image_output_dimensions_or_original( + provider_image, + generation_options.model, + generation_options.size.as_str(), + ) }; if let Some(error) = dimension_restore_error { tracing::warn!( @@ -3007,6 +3054,7 @@ pub(crate) async fn edit_editor_image_for_owner( provider_height, target_width = edit_dimensions.map(|dimensions| dimensions.target_width), target_height = edit_dimensions.map(|dimensions| dimensions.target_height), + target_size = %generation_options.size, error = ?error, "画板快速编辑尺寸恢复失败,保留并上传 provider 原图" ); @@ -8466,8 +8514,8 @@ mod tests { } #[test] - fn nanobanana_generation_keeps_provider_dimensions_for_scalar_size_tiers() { - let image = image::DynamicImage::new_rgba8(896, 896); + fn nanobanana_generation_restores_shared_business_pixel_size() { + let image = image::DynamicImage::new_rgba8(2752, 1536); let mut bytes = Cursor::new(Vec::new()); image .write_to(&mut bytes, image::ImageFormat::Png) @@ -8482,14 +8530,14 @@ mod tests { restore_editor_generated_image_output_dimensions_or_original( provider_output, EDITOR_IMAGE_MODEL_NANOBANANA2, - "1024", + "2048x1152", ); assert!(restore_error.is_none()); let restored_image = image::load_from_memory(restored.bytes.as_slice()).unwrap(); assert_eq!( (restored_image.width(), restored_image.height()), - (896, 896) + (2048, 1152) ); } @@ -8532,7 +8580,7 @@ mod tests { Some("0.5K"), ); assert_eq!(nanobanana.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana.size, "512"); + assert_eq!(nanobanana.size, "512x512"); assert_eq!(nanobanana.aspect_ratio, "1:1"); assert_eq!(nanobanana.image_size, "0.5K"); assert_eq!(nanobanana.provider_image_size, "512"); @@ -8540,7 +8588,7 @@ mod tests { let nanobanana_alias = normalize_editor_generation_options(Some("nanobanana2"), Some("1:1"), Some("0.5K")); assert_eq!(nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana_alias.size, "512"); + assert_eq!(nanobanana_alias.size, "512x512"); assert_eq!(nanobanana_alias.provider_image_size, "512"); let legacy_nanobanana_alias = @@ -8549,7 +8597,7 @@ mod tests { legacy_nanobanana_alias.model, EDITOR_IMAGE_MODEL_NANOBANANA2 ); - assert_eq!(legacy_nanobanana_alias.size, "2048"); + assert_eq!(legacy_nanobanana_alias.size, "2048x1152"); assert_eq!(legacy_nanobanana_alias.aspect_ratio, "16:9"); let gpt = normalize_editor_generation_options(Some("gpt-image-2"), Some("2:3"), Some("1K")); @@ -8562,7 +8610,7 @@ mod tests { let nanobanana_cover = normalize_editor_generation_options(Some("nanobanana2"), Some("4:3"), Some("1K")); assert_eq!(nanobanana_cover.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(nanobanana_cover.size, "1024"); + assert_eq!(nanobanana_cover.size, "1024x768"); assert_eq!(nanobanana_cover.aspect_ratio, "4:3"); let gpt_cover = @@ -8607,13 +8655,41 @@ mod tests { assert_eq!(options.size, expected_size); } + for (image_size, aspect_ratio, expected_size) in [ + ("0.5K", "1:1", "512x512"), + ("0.5K", "4:3", "512x384"), + ("0.5K", "3:2", "512x341"), + ("0.5K", "2:3", "341x512"), + ("0.5K", "9:16", "288x512"), + ("0.5K", "16:9", "512x288"), + ("1K", "1:1", "1024x1024"), + ("1K", "4:3", "1024x768"), + ("1K", "3:2", "1024x683"), + ("1K", "2:3", "683x1024"), + ("1K", "9:16", "576x1024"), + ("1K", "16:9", "1024x576"), + ("2K", "1:1", "2048x2048"), + ("2K", "4:3", "2048x1536"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), + ("2K", "9:16", "1152x2048"), + ("2K", "16:9", "2048x1152"), + ] { + let options = normalize_editor_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!(options.size, expected_size); + } + let fallback = normalize_editor_generation_options( Some("unknown-model"), Some("bad-ratio"), Some("bad-size"), ); assert_eq!(fallback.model, EDITOR_IMAGE_MODEL_NANOBANANA2); - assert_eq!(fallback.size, "1024"); + assert_eq!(fallback.size, "1024x1024"); assert_eq!(fallback.aspect_ratio, "1:1"); assert_eq!(fallback.image_size, "1K"); assert_eq!(fallback.provider_image_size, "1K"); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index f2ada27d1..33448721b 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -1417,7 +1417,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); - it('keeps reference pick warnings visible until the user takes a follow-up action', () => { + it('dismisses reference pick warnings after three seconds', () => { vi.useFakeTimers(); try { render(); @@ -1430,12 +1430,18 @@ describe('useImageCanvasGenerationWorkflow', () => { ); act(() => { - vi.advanceTimersByTime(10_000); + vi.advanceTimersByTime(2_999); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); + + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); } finally { vi.useRealTimers(); } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 1e4a630f1..372c18053 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -720,6 +720,20 @@ export function useImageCanvasGenerationWorkflow({ const [generationWarning, setGenerationWarning] = useState( null, ); + + useEffect(() => { + if (!generationWarning) { + return; + } + + const timer = window.setTimeout(() => { + setGenerationWarning(null); + }, 3_000); + + return () => { + window.clearTimeout(timer); + }; + }, [generationWarning]); const [cropExpandPanel, setCropExpandPanel] = useState(null); const [uiAssetExtractionState, setUiAssetExtractionState] =