diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 7c1b78bd4..3bff57d46 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` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `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/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 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 / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 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 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 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 15695106b..a3dfcd624 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` 的统一业务像素矩阵计算;同一规格不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。`nanobanana2` provider 原生 K 档回图若更大,普通生成和快速编辑在持久化前归一到该业务像素;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 +- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;所有共用规格都不因模型不同改变画布占地,例如 `nanobanana2` 与 `gpt-image-2` 的 `16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。provider 请求尺寸可因接口合法值不同,但不得泄漏为正常完成的画布尺寸;回图更大时只允许缩小和轻微裁切,回图低于目标时保留实际像素并告警,禁止放大伪造所选档位;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。 - 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p` 为 `854 x 480`,切换比例、`720p` 或 `1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。 - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 @@ -124,7 +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` 秒自动消失,避免一次错误提示持续遮挡画布。 +- 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `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 430fdc743..5f3c0c041 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -122,6 +122,7 @@ const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_ const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_GENERATION_POSTPROCESS_WARNING_CODE: &str = "postprocess-failed-source-preserved"; +const EDITOR_GENERATION_DIMENSION_WARNING_CODE: &str = "dimension-restore-fallback"; const EDITOR_GENERATION_PHASE_REPORT_RETRY_COUNT: usize = 1; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; @@ -631,6 +632,19 @@ fn editor_postprocess_fallback_warning(reason: &'static str) -> EditorGeneration } } +fn editor_dimension_restore_warning(error: &AppError) -> EditorGenerationWarningResponse { + let reason = error + .details() + .and_then(|details| details.get("message")) + .and_then(Value::as_str) + .unwrap_or("provider 回图未能归一到目标交付尺寸,已保留实际原图尺寸。") + .to_string(); + EditorGenerationWarningResponse { + code: EDITOR_GENERATION_DIMENSION_WARNING_CODE, + reason, + } +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorImageGenerationResponse { @@ -1582,6 +1596,12 @@ pub(crate) async fn generate_editor_image_for_owner( has_dimension_options, &generation_options, ); + let provider_request_size = resolve_editor_image_provider_request_size( + normalized_kind, + payload.size.as_deref(), + has_dimension_options, + &generation_options, + ); let failure_context = match normalized_kind { Some("character") => "图片画布生成角色形象", Some("spec") => "图片画布生成规范", @@ -1633,6 +1653,11 @@ pub(crate) async fn generate_editor_image_for_owner( } else { image_size }; + let provider_request_size = if is_ui_design_generation { + Cow::Owned(generation_options.provider_size.clone()) + } else { + provider_request_size + }; let configured_price_mud_points = state .editor_generation_pricing() .await @@ -1714,7 +1739,7 @@ pub(crate) async fn generate_editor_image_for_owner( generation_options.model, submitted_prompt.as_str(), negative_prompt, - image_size.as_ref(), + provider_request_size.as_ref(), 1, &[], failure_context, @@ -1727,7 +1752,7 @@ pub(crate) async fn generate_editor_image_for_owner( generation_options.model, submitted_prompt.as_str(), negative_prompt, - image_size.as_ref(), + provider_request_size.as_ref(), 1, reference_images.as_slice(), failure_context, @@ -1770,8 +1795,8 @@ pub(crate) async fn generate_editor_image_for_owner( })) })?; // 中文注释:nanobanana2 的 2K 是 provider 清晰度档位,16:9 实际可能返回 - // 2752x1536;画布业务规格仍以统一的 2048 长边像素矩阵为准,持久化前归一, - // 保证完成图与生成前占位标注一致。 + // 2752x1536;画布业务规格统一使用 512 / 1024 / 2048 长边像素矩阵,持久化前归一, + // 保证不同模型的完成图与生成前占位标注一致。 let (restored_image, dimension_restore_error) = if is_character_generation { // 角色任务的 provider 原图是独立可复用中间产物,先按原始分辨率保存; // 透明主结果会在后处理完成后再归一到画布业务规格。 @@ -1783,6 +1808,9 @@ pub(crate) async fn generate_editor_image_for_owner( image_size.as_ref(), ) }; + let mut dimension_warning = dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -1951,6 +1979,7 @@ pub(crate) async fn generate_editor_image_for_owner( image_size.as_ref(), ); if let Some(error) = dimension_restore_error { + dimension_warning = Some(editor_dimension_restore_warning(&error)); tracing::warn!( task_id = %generated.task_id, provider_width, @@ -2055,7 +2084,7 @@ pub(crate) async fn generate_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: None, + warning: dimension_warning, }, )) } @@ -2292,6 +2321,25 @@ fn resolve_editor_image_request_size( legacy_size } +fn resolve_editor_image_provider_request_size( + normalized_kind: Option<&str>, + payload_size: Option<&str>, + has_dimension_options: bool, + generation_options: &EditorGenerationOptions, +) -> Cow<'static, str> { + let legacy_size = normalize_editor_image_generation_size(payload_size); + let has_explicit_payload_size = payload_size + .map(str::trim) + .is_some_and(|value| !value.is_empty()); + if matches!(normalized_kind, Some("publication-material")) && has_explicit_payload_size { + return legacy_size; + } + if has_dimension_options { + return Cow::Owned(generation_options.provider_size.clone()); + } + legacy_size +} + fn is_editor_custom_image_size(value: &str) -> bool { let Some((width, height)) = value.split_once('x') else { return false; @@ -2324,7 +2372,9 @@ fn normalize_editor_generation_options( }; let aspect_ratio = normalize_editor_generation_aspect_ratio(aspect_ratio); let image_size = normalize_editor_generation_image_size(normalized_model, image_size); - let size = editor_generation_size_for_model(normalized_model, aspect_ratio, image_size); + let size = editor_generation_delivery_size(aspect_ratio, image_size); + let provider_size = + editor_generation_provider_size_for_model(normalized_model, aspect_ratio, image_size); let provider_image_size = editor_generation_provider_image_size_for_model(normalized_model, image_size); @@ -2333,6 +2383,7 @@ fn normalize_editor_generation_options( aspect_ratio, image_size, size, + provider_size, provider_image_size, } } @@ -2362,35 +2413,42 @@ 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 { - 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}"); - } +fn editor_generation_delivery_size(aspect_ratio: &str, image_size: &str) -> 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, + ) + }; + format!("{width}x{height}") +} +fn editor_generation_provider_size_for_model( + model: &str, + aspect_ratio: &str, + image_size: &str, +) -> String { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + return editor_generation_delivery_size(aspect_ratio, image_size); + } match (image_size, aspect_ratio) { ("2K", "1:1") => "2048x2048", ("2K", "2:3") => "1376x2048", @@ -2626,6 +2684,65 @@ fn prepare_editor_image_edit_references( }) } +fn ensure_editor_output_can_restore_without_upscaling( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, + operation: &str, +) -> Result<(), AppError> { + if target_width == 0 || target_height == 0 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "invalid-delivery-size", + "message": "目标交付尺寸必须大于 0", + "operation": operation, + "targetWidth": target_width, + "targetHeight": target_height, + })), + ); + } + if source_width < target_width || source_height < target_height { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "provider-image-under-delivery", + "message": "provider 回图小于目标交付尺寸,禁止放大伪造清晰度档位", + "operation": operation, + "sourceWidth": source_width, + "sourceHeight": source_height, + "targetWidth": target_width, + "targetHeight": target_height, + })), + ); + } + + let source_ratio = f64::from(source_width) / f64::from(source_height); + let target_ratio = f64::from(target_width) / f64::from(target_height); + let crop_fraction = if source_ratio >= target_ratio { + 1.0 - target_ratio / source_ratio + } else { + 1.0 - source_ratio / target_ratio + }; + if crop_fraction > 0.05 { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "code": "provider-image-aspect-ratio-mismatch", + "message": "provider 回图比例偏差过大,禁止大幅裁切为目标交付尺寸", + "operation": operation, + "sourceWidth": source_width, + "sourceHeight": source_height, + "targetWidth": target_width, + "targetHeight": target_height, + "cropFraction": crop_fraction, + })), + ); + } + Ok(()) +} + fn restore_editor_image_edit_output_dimensions( output: &DownloadedOpenAiImage, dimensions: &EditorImageEditDimensions, @@ -2639,6 +2756,13 @@ fn restore_editor_image_edit_output_dimensions( if decoded.width() == dimensions.target_width && decoded.height() == dimensions.target_height { return Ok(None); } + ensure_editor_output_can_restore_without_upscaling( + decoded.width(), + decoded.height(), + dimensions.target_width, + dimensions.target_height, + "editor-image-edit", + )?; let restored = if decoded.width() == dimensions.provider_width && decoded.height() == dimensions.provider_height @@ -2786,7 +2910,7 @@ 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> { let (target_width, target_height) = target_size.split_once('x').ok_or_else(|| { @@ -2816,6 +2940,13 @@ fn restore_editor_generated_image_output_dimensions( if decoded.width() == target_width && decoded.height() == target_height { return Ok(None); } + ensure_editor_output_can_restore_without_upscaling( + decoded.width(), + decoded.height(), + target_width, + target_height, + model, + )?; let restored = decoded.resize_to_fill( target_width, @@ -2928,7 +3059,18 @@ pub(crate) async fn edit_editor_image_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); + let delivery_size = if has_dimension_options { + Cow::Owned(generation_options.size.clone()) + } else { + requested_image_size.clone() + }; + let provider_requested_image_size = if has_dimension_options { + Cow::Owned(generation_options.provider_size.clone()) + } else { + requested_image_size.clone() + }; let mut reference_images = Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len)); reference_images.push( @@ -2961,7 +3103,7 @@ pub(crate) async fn edit_editor_image_for_owner( } else { Some(prepare_editor_image_edit_references( &mut reference_images, - requested_image_size.as_ref(), + provider_requested_image_size.as_ref(), )?) }; let provider_size = edit_dimensions @@ -3038,15 +3180,20 @@ pub(crate) async fn edit_editor_image_for_owner( })?; let asset_label = resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果"); - let (image, dimension_restore_error) = if let Some(edit_dimensions) = edit_dimensions.as_ref() { + let (image, dimension_restore_error) = if let Some(edit_dimensions) = + edit_dimensions.as_ref().filter(|_| !has_dimension_options) + { restore_editor_image_edit_output_dimensions_or_original(provider_image, edit_dimensions) } else { restore_editor_generated_image_output_dimensions_or_original( provider_image, generation_options.model, - generation_options.size.as_str(), + delivery_size.as_ref(), ) }; + let dimension_warning = dimension_restore_error + .as_ref() + .map(editor_dimension_restore_warning); if let Some(error) = dimension_restore_error { tracing::warn!( task_id = %generated.task_id, @@ -3054,7 +3201,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, + target_size = %delivery_size, error = ?error, "画板快速编辑尺寸恢复失败,保留并上传 provider 原图" ); @@ -3144,7 +3291,7 @@ pub(crate) async fn edit_editor_image_for_owner( resource: generated_asset.resource, asset: generated_asset.asset, project: completed_project, - warning: None, + warning: dimension_warning, }, )) } @@ -3881,7 +4028,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( Some(generation_options.image_size), ) .await?; - let size = generation_options.size.as_str(); + let size = generation_options.provider_size.as_str(); let settings = require_openai_image_settings(state)?.with_external_api_audit_context( request_context, @@ -4703,7 +4850,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( generation_options.model, prompt.as_str(), None, - generation_options.size.as_str(), + generation_options.provider_size.as_str(), 1, reference_images.as_slice(), "图片画布提取UI设计图素材 spritesheet", @@ -6840,7 +6987,9 @@ struct EditorGenerationOptions { model: &'static str, aspect_ratio: &'static str, image_size: &'static str, + // 中文注释:size 是模型无关的画布交付像素;provider_size 只用于满足 GPT 图片接口的合法像素矩阵。 size: String, + provider_size: String, provider_image_size: &'static str, } @@ -8541,6 +8690,70 @@ mod tests { ); } + #[test] + fn provider_1k_under_delivery_is_not_upscaled_to_2k() { + let image = image::DynamicImage::new_rgba8(1024, 576); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test image should encode"); + let provider_output = DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let expected_bytes = provider_output.bytes.clone(); + + let (delivery_output, restore_error) = + restore_editor_generated_image_output_dimensions_or_original( + provider_output, + EDITOR_IMAGE_MODEL_NANOBANANA2, + "2048x1152", + ); + + let restore_error = restore_error.expect("under delivery should be recorded"); + assert_eq!(delivery_output.bytes, expected_bytes); + assert_eq!( + restore_error + .details() + .and_then(|details| details.get("code")), + Some(&json!("provider-image-under-delivery")) + ); + let warning = editor_dimension_restore_warning(&restore_error); + assert_eq!(warning.code, EDITOR_GENERATION_DIMENSION_WARNING_CODE); + assert!(warning.reason.contains("禁止放大伪造清晰度档位")); + let delivery_image = image::load_from_memory(delivery_output.bytes.as_slice()).unwrap(); + assert_eq!( + (delivery_image.width(), delivery_image.height()), + (1024, 576) + ); + } + + #[test] + fn generated_dimension_restore_allows_only_slight_aspect_ratio_crop() { + ensure_editor_output_can_restore_without_upscaling( + 2064, + 1152, + 2048, + 1152, + "test-generation", + ) + .expect("provider rounding drift should allow slight crop"); + + let error = ensure_editor_output_can_restore_without_upscaling( + 2048, + 2048, + 2048, + 1152, + "test-generation", + ) + .expect_err("large aspect ratio crop should be rejected"); + assert_eq!( + error.details().and_then(|details| details.get("code")), + Some(&json!("provider-image-aspect-ratio-mismatch")) + ); + } + #[test] fn failed_dimension_restore_keeps_provider_output_for_single_upload() { let image = image::DynamicImage::new_rgba8(896, 896); @@ -8602,7 +8815,8 @@ 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, "688x1024"); + assert_eq!(gpt.size, "683x1024"); + assert_eq!(gpt.provider_size, "688x1024"); assert_eq!(gpt.aspect_ratio, "2:3"); assert_eq!(gpt.image_size, "1K"); assert_eq!(gpt.provider_image_size, "1K"); @@ -8617,6 +8831,7 @@ mod tests { 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, "1024x768"); + assert_eq!(gpt_cover.provider_size, "1024x768"); assert_eq!(gpt_cover.aspect_ratio, "4:3"); let gpt_landscape_2k = @@ -8636,14 +8851,14 @@ mod tests { 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"), + ("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", "2048x1376"), - ("2K", "2:3", "1376x2048"), + ("2K", "3:2", "2048x1365"), + ("2K", "2:3", "1365x2048"), ("2K", "9:16", "1152x2048"), ("2K", "16:9", "2048x1152"), ] { @@ -8655,6 +8870,32 @@ mod tests { assert_eq!(options.size, expected_size); } + for (image_size, aspect_ratio, expected_delivery_size) in [ + ("1K", "16:9", "1024x576"), + ("1K", "2:3", "683x1024"), + ("2K", "3:2", "2048x1365"), + ("2K", "9:16", "1152x2048"), + ] { + let gpt = normalize_editor_generation_options( + Some(GPT_IMAGE_2_MODEL), + Some(aspect_ratio), + Some(image_size), + ); + let nanobanana = normalize_editor_generation_options( + Some(EDITOR_IMAGE_MODEL_NANOBANANA2), + Some(aspect_ratio), + Some(image_size), + ); + assert_eq!(gpt.size, expected_delivery_size); + assert_eq!(nanobanana.size, expected_delivery_size); + assert_eq!(gpt.size, nanobanana.size); + } + + let gpt_landscape_1k = + normalize_editor_generation_options(Some(GPT_IMAGE_2_MODEL), Some("16:9"), Some("1K")); + assert_eq!(gpt_landscape_1k.provider_size, "1088x608"); + assert_eq!(gpt_landscape_1k.size, "1024x576"); + for (image_size, aspect_ratio, expected_size) in [ ("0.5K", "1:1", "512x512"), ("0.5K", "4:3", "512x384"), @@ -8692,6 +8933,7 @@ mod tests { assert_eq!(fallback.size, "1024x1024"); assert_eq!(fallback.aspect_ratio, "1:1"); assert_eq!(fallback.image_size, "1K"); + assert_eq!(fallback.provider_size, "1024x1024"); assert_eq!(fallback.provider_image_size, "1K"); } @@ -8713,6 +8955,15 @@ mod tests { resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), "1024x768" ); + assert_eq!( + resolve_editor_image_provider_request_size( + Some("publication-material"), + Some("720x540"), + true, + &options, + ), + "720x540" + ); } #[test] diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index a4011bc1b..6ffcdefcf 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -233,10 +233,10 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '1K', uiDesignSpecReference: null, placeholder: { - x: -64, - y: 56, - width: 1088, - height: 608, + x: -32, + y: 72, + width: 1024, + height: 576, }, }); }); @@ -622,9 +622,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1376, + width: 1365, height: 2048, - originalWidth: 1376, + originalWidth: 1365, originalHeight: 2048, }, }); @@ -662,9 +662,9 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, - width: 1376, + width: 1365, height: 2048, - originalWidth: 1376, + originalWidth: 1365, originalHeight: 2048, }, }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 7e8309e9a..c2e21ecbf 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -108,18 +108,18 @@ describe('ImageCanvasGenerationModel', () => { [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, '1K', '3:2', 1024, 683], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '2:3', 683, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '9:16', 576, 1024], + [IMAGE_MODEL_GPT_IMAGE_2, '1K', '16:9', 1024, 576], [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', '3:2', 2048, 1365], + [IMAGE_MODEL_GPT_IMAGE_2, '2K', '2:3', 1365, 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 直接请求尺寸一致', + '%s 的 %s %s 占位尺寸与统一业务交付尺寸一致', (model, imageSize, aspectRatio, width, height) => { expect( resolveEditorImageGenerationPixelSize({ diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index e04b43e91..4b589d6de 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -173,38 +173,8 @@ 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, + model: _model, aspectRatio, imageSize, }: { @@ -212,9 +182,6 @@ 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 }); } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index b7b497ff8..3e55a91d6 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -671,6 +671,42 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); }); + it('shows dimension restore warnings after non-character quick edits', async () => { + editEditorImageMock.mockResolvedValueOnce( + createGenerated({ + prompt: '改成雨天', + width: 1024, + height: 576, + warning: { + code: 'dimension-restore-fallback', + reason: 'provider 回图小于目标尺寸,已保留实际回图。', + }, + }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(screen.getByTestId('generation-warning').textContent).toBe( + 'provider 回图小于目标尺寸,已保留实际回图。', + ); + }); + }); + it('rejects direct quick edit submission for icon assets', async () => { render( {generationWorkflow.generationWarning ? ( { } }); + it('restarts the dismissal timer when the same warning is shown again', () => { + vi.useFakeTimers(); + try { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); + + act(() => { + vi.advanceTimersByTime(2_999); + }); + + fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); + act(() => { + vi.advanceTimersByTime(1); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '选择的图片不是角色规范图,请选择生成规范里的角色规范。', + ); + + act(() => { + vi.advanceTimersByTime(2_999); + }); + + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + } finally { + vi.useRealTimers(); + } + }); + it('restores the last picked character spec from local cache after remounting the editor', () => { const firstRender = render(); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 372c18053..96ebc61d0 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -720,6 +720,11 @@ export function useImageCanvasGenerationWorkflow({ const [generationWarning, setGenerationWarning] = useState( null, ); + const [generationWarningVersion, setGenerationWarningVersion] = useState(0); + const showGenerationWarning = useCallback((warning: string) => { + setGenerationWarning(warning); + setGenerationWarningVersion((version) => version + 1); + }, []); useEffect(() => { if (!generationWarning) { @@ -733,7 +738,7 @@ export function useImageCanvasGenerationWorkflow({ return () => { window.clearTimeout(timer); }; - }, [generationWarning]); + }, [generationWarning, generationWarningVersion]); const [cropExpandPanel, setCropExpandPanel] = useState(null); const [uiAssetExtractionState, setUiAssetExtractionState] = @@ -1335,7 +1340,7 @@ export function useImageCanvasGenerationWorkflow({ return; } if (sourceLayer.assetKind === 'character-animation') { - setGenerationWarning('未找到角色动作关联的原角色图层,无法改造。'); + showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。'); return; } const redrawPanel = createRedrawPanelDraft(sourceLayer, { @@ -1375,6 +1380,7 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool, setImageContextMenu, setMetadataLayer, + showGenerationWarning, canvasGenerationDialogs, canvasSize, layers, @@ -1703,7 +1709,7 @@ export function useImageCanvasGenerationWorkflow({ }), refreshTaskListForQueuedGeneration, onWalletBalanceMayHaveChanged, - setGenerationWarning, + showGenerationWarning, backgroundRemovalDialogId, applyBackgroundRemovalProjectSnapshot ? (project) => @@ -1736,6 +1742,7 @@ export function useImageCanvasGenerationWorkflow({ refreshTaskListForQueuedGeneration, setImageContextMenu, setMetadataLayer, + showGenerationWarning, updateCanvasGenerationDialogById, ], ); @@ -1819,7 +1826,7 @@ export function useImageCanvasGenerationWorkflow({ const pickCharacterSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isCharacterSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_CHARACTER_SPEC_WARNING); + showGenerationWarning(INVALID_CHARACTER_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1838,7 +1845,7 @@ export function useImageCanvasGenerationWorkflow({ setIsCharacterSpecMenuOpen(false); setImageContextMenu(null); }, - [setGenerateDialog, setImageContextMenu], + [setGenerateDialog, setImageContextMenu, showGenerationWarning], ); const pickGenerationReferenceFromLayer = useCallback( @@ -1879,7 +1886,7 @@ export function useImageCanvasGenerationWorkflow({ const pickIconSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isIconSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_ICON_SPEC_WARNING); + showGenerationWarning(INVALID_ICON_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1895,13 +1902,18 @@ export function useImageCanvasGenerationWorkflow({ setIsIconSpecMenuOpen(false); setImageContextMenu(null); }, - [generateDialog, setGenerateDialog, setImageContextMenu], + [ + generateDialog, + setGenerateDialog, + setImageContextMenu, + showGenerationWarning, + ], ); const pickUiDesignSpecFromLayer = useCallback( (layer: CanvasLayer) => { if (!isIconSpecReferenceLayer(layer)) { - setGenerationWarning(INVALID_ICON_SPEC_WARNING); + showGenerationWarning(INVALID_ICON_SPEC_WARNING); return; } setGenerationWarning(null); @@ -1920,7 +1932,12 @@ export function useImageCanvasGenerationWorkflow({ setIsUiDesignSpecMenuOpen(false); setImageContextMenu(null); }, - [generateDialog, setGenerateDialog, setImageContextMenu], + [ + generateDialog, + setGenerateDialog, + setImageContextMenu, + showGenerationWarning, + ], ); const pickPublicationReferenceFromLayer = useCallback( @@ -1986,7 +2003,7 @@ export function useImageCanvasGenerationWorkflow({ applyProjectSnapshot, onQueuedGenerationTask: refreshTaskListForQueuedGeneration, onWalletBalanceMayHaveChanged, - onGenerationWarning: setGenerationWarning, + onGenerationWarning: showGenerationWarning, }); const setEffectiveCharacterAnimationPanel: Dispatch< @@ -2554,7 +2571,8 @@ export function useImageCanvasGenerationWorkflow({ isPickingPublicationReferenceFromCanvas, setIsPickingPublicationReferenceFromCanvas, generationWarning, - showGenerationWarning: setGenerationWarning, + generationWarningVersion, + showGenerationWarning, clearGenerationWarning: () => setGenerationWarning(null), openGenerateDialog, openSpecDialog, @@ -2649,6 +2667,7 @@ export function useImageCanvasGenerationWorkflow({ isSpecMenuOpen, isPickingPublicationReferenceFromCanvas, generationWarning, + generationWarningVersion, openBackgroundMusicGenerationDialog, openCharacterAnimationPanel, openCharacterGenerationDialog, @@ -2686,6 +2705,7 @@ export function useImageCanvasGenerationWorkflow({ updateIconDescriptionsText, updateSpecFormValue, rememberImageModel, + showGenerationWarning, ], ); }