diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7af62ba89..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。 @@ -31,6 +39,22 @@ - 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。 +## 2026-07-18 图片多产物任务的中间原图不进入正常完成画布 + +- 背景:角色形象、图标 spritesheet 和 UI 素材提取会同时持久化带纯色背景的 provider 原图与透明后处理结果。正常成功时若把两者都写入 `canvasCompletion`,用户执行一次任务会在画布看到两张近似图片,误以为重复生成;任务与扣费实际仍只有一次。 +- 决策:provider 原图继续写入 OSS、`asset_object`、项目资源和账号素材库,保留追溯与后处理失败恢复能力,但正常成功的画布只放透明主结果;图标和 UI 在主图集后仍可放业务上明确需要的拆分素材。只有透明背景处理最终失败时,才把 provider 原图作为唯一主图完成占位并返回 warning。 +- 影响范围:角色形象、图标 spritesheet、UI 素材提取的画布完成快照,以及多产物持久化与画布展示边界。 +- 验证方式:后端定向测试断言角色正常成功只落一个透明角色图层、图标 / UI 不把 provider 原图加入画布、无切片时只落透明主图集,同时保留 source-only 失败降级测试;并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-07-17 图片改造保持源图与所选清晰度 + +- 背景:图片画布从已生成的 2K 角色图重新打开生成器时,面板恢复逻辑会优先采用新建面板的 1K 默认值;即使用户重新选择 2K,角色透明化链路也可能接受 BgFilter / 阿里云返回的 1K 后处理图,并因 `nanobanana2` 使用标量清晰度档位而跳过几何尺寸恢复,最终把 2K provider 原图降为 1K 透明图。 +- 决策:从既有图片重新打开普通图片、角色、UI 或宣发生成器时,在没有仍存活的生成对话框快照时按当前图层真实 `originalWidth / originalHeight` 恢复比例与清晰度,并按目标模型支持范围归一;恢复或切换比例 / 清晰度后,普通图片、角色、图标图集和 UI 设计图的待生成及生成中占位框必须同步使用目标像素尺寸,不能保留新建 draft 的默认 1K 框。UI 素材提取的占位按框选数量对应的 1K / 2K 计划生成,旧图片修改入口按源图真实尺寸占位。角色形象去背景完成后必须保持去背景前 provider 原图的像素尺寸;若去背景供应商返回较小结果,只把 alpha 蒙版重采样回原图并保留原始 RGB,不放大低分辨率透明成品。 +- 影响范围:图片画布生成对话框恢复、生成中占位尺寸、UI 素材提取与旧图片修改的 `canvasCompletion`、角色形象 BgFilter / 阿里云 / 本地去背后处理、项目资源与账号素材尺寸元数据。 +- 验证方式:覆盖“持久化 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 请求超时按帧数扩展 - 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。 @@ -111,7 +135,7 @@ - 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。 - 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时保留纯色背景原图与透明后处理结果;透明背景处理最终失败时只保留已经持久化的 provider 原图,并按下一条降级规则收口。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。 -- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 +- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,正常成功时生成器 `generatedLayerId` 锚定主后处理结果,provider 原图只保留在项目资源和账号素材库,不作为第二张近似图片进入画布;图标和 UI 的业务拆分素材仍可随透明主图集进入画布。角色形象、图标 spritesheet 或 UI 素材提取已经保存 provider 原图、但透明背景处理最终失败时,任务以 `completed + warning` 收口,原图作为唯一主图完成画布占位;不写入不存在的透明处理图,图标和 UI 也不继续拆分。透明处理成功后的图标和 UI 图集自动拆分仍是 best-effort;识别或切片持久化失败继续完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。 - 2026-07-16 告警契约补充:inline / external v1 继续返回结构化原始诊断;queue 有意把通用 `warning` 或 `sliceWarning` 归一为展示就绪字符串,通用 `warning.reason` 原样保留,`sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀,摘要与 BFF 原样投影,Web 直接展示。历史值保留写入时快照,不按新格式回填或推断;该内部字符串契约通过 API/worker 与 Web 同一维护窗口、同版本发布收口,不增加混部兼容层。 - 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、`character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。 - 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2b91e6ed7..d1b8299ea 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,22 @@ - 关联:相关文件、文档、提交或 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 生成也可能多出一张纯色背景图集。 +- 原因:后端为了可追溯和后处理失败恢复而持久化 provider 原图时,又把它与透明主结果一起传入同一次 `canvasCompletion`,把“资源必须保留”错误等同为“画布必须展示”。 +- 处理:provider 原图继续写 OSS、项目资源和账号素材库;正常成功的画布只放透明主结果及明确需要的图标 / UI 拆分素材。仅当透明处理最终失败时,才用 provider 原图作为唯一主图完成占位。 +- 验证:覆盖角色正常成功只落一个图层、图标 / UI 正常成功不落 provider 原图、无切片时只有透明主图集,以及透明处理失败仍由原图单独完成占位。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`。 + ## phase 上报的业务拒绝与传输失败不能共用字符串错误 - 现象:provider 已经返回并保存原图,worker 上报 `processing` 时一次断连或超时就直接把任务判为失败;或者为了规避误杀而重试所有错误,导致 stale lease 的旧 worker 继续执行后处理。 @@ -400,6 +416,14 @@ - 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。 - 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/ImageCanvasGenerationLayerModel.ts`、`server-rs/crates/api-server/src/editor_project.rs`。 +## 图片画布生成中占位必须同步业务目标尺寸 + +- 现象:用户选择 `2K` 生成或从 2K 普通图、角色图再次改造时,最终成品仍是 2K,但待生成 / 生成中的灰色框保持 1K 大小,完成后突然放大;UI 素材提取也可能始终显示 512 方框。 +- 原因:同来源改造先按默认 1K 创建 draft,再只恢复 `imageModel / aspectRatio / imageSize`,没有重新计算 placeholder;UI 提取和旧修改入口则分别写死图标展示尺寸与 `1024x1024`。 +- 处理:所有共享图片参数恢复和面板比例 / 清晰度切换都经过 `resizeGenerationPlaceholderToImageSelection(...)`,保持占位中心不变并同步 `width/height/originalWidth/originalHeight`;UI 提取按 `resolveUiAssetExtractionGenerationPlan(...)` 的 1K / 2K 计划计算占位,旧修改入口使用源图真实 Resolution。快速编辑覆盖源图,不另建生成占位,仍按目标尺寸更新原图层。 +- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx --reporter=dot`。 +- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts`、`src/components/image-editor/ImageCanvasGenerationDialogModel.ts`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 图片画布快速编辑模型必须在后端选择正确的 provider 协议 - 现象:快速编辑继承或选择 `nanobanana2` 后,上游返回 `not supported model for image generation`;图集开放快速编辑后尤其容易触发。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 0a33c8375..c9b43b5ab 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,11 +20,11 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一使用 BgFilter 服务 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL/remove-background`,默认 `http://58.87.105.82/bgfilter/remove-background`,默认请求超时 `180000ms`(BgFilter CPU 推理)。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;worker 固定提交 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不提交 `screen_color`。首次请求失败后立即重试 `1` 次,两次都失败则返回最终错误;manual complex 不接入依赖纯色键值的阿里云 / 本地键色降级链,也不改变 flat 链路的熔断状态。手动与标准纯色背景两类模式共用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 和共享 HTTP client;BgFilter token 未配置时只兼容回退读取旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。所有令牌都只在服务端注入,前端不持有令牌。worker 对上游结果做响应字节和图片尺寸上限保护,并先落 OSS / asset object;接口只返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端,完成后由后端写入结果图层和最新项目快照。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一调用 BgFilter `background_mode=flat`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的前端用户路径都固定把 `screenColor=auto` 注入请求体,但用户可见 `generationInputs.fields` 不再记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和 BgFilter `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。四条 flat 路径同时把默认 `segModel=birefnet` 传为 `seg_model`,并显式传 `cross_check`:角色形象生成和角色动作逐帧去背传 `on`,图标 spritesheet 和 UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值;后端仍保留识别 `anime-seg` 的内部兼容能力,但前端用户入口不展示也不提交 `seg_model`、`background_mode` 或 `cross_check`。flat 请求首次失败后立即重试 `1` 次;第二次仍失败、返回非成功状态、空图片或非法图片时,以及连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 后的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 秒熔断期,api-server 都先调用阿里云通用抠图,只有阿里云失败才用本地 `editor_green_screen` 按同一 `screenColor` 兜底去背。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `BgFilter(background_mode=flat,cross_check=on)→ 阿里云 → 本地键色` 链路。 - 角色动作逐帧抠图在 api-server 内复用共享 BgFilter HTTP Client;每一次 HTTP attempt 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。该值是单个请求从发起到响应体读取完成的 timeout,不是整批帧或整项角色动作任务超时;单帧首次失败立即重试 `1` 次并重新计时,第二次仍失败才进入阿里云/本地降级链,整项任务另受 worker long-job 预算约束。全部 `32 / 40 / 48` 帧按“对应绿幕源图落 OSS → BgFilter/降级 → 透明帧落 OSS”连续加入无序在途流水线,允许响应乱序完成并在最终返回前按 `frameIndex` 恢复顺序;任一帧最终失败时仍排空全部已启动请求,整个动作任务失败退款,不发布缺帧动画。 -- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`,再通过一次 `canvasCompletion` 原子写入画布。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,原图及其它附属产物从主结果右侧开始错开放置;透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 +- 多产物生成以后端项目快照为唯一画布真相:同一任务实际产生的原始产物、抠图 / 透明化结果和拆分结果都要先登记为 `editor_project_resource`。角色形象、图标 spritesheet 和 UI 素材提取的纯色背景原图不能只留在 OSS;透明后处理成功时,处理结果保持主图层和 `generatedLayerId` 锚点,provider 原图只保留在项目资源与账号素材库,不作为第二张近似图片进入画布;图标和 UI 的实际拆分素材从透明主图集右侧开始放置。透明背景处理最终失败时,只把已保存的原图作为主图完成占位,不放透明处理图,图标和 UI 不继续拆分。source-only fallback 的前端只消费后端返回的 `project` / `resource` 快照,不按缺失字段自行构造透明图、切片或图层;任务以 `completed + warning` 收口。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。通用 `warning.reason` 是可直接展示的完整原因,并优先于 `sliceWarning`;既有 `sliceWarning.reason` 只表示透明图成功后的自动拆分失败,保留后端原始诊断,inline 前端仅在展示时补充“图集已生成,但自动拆分未完成:”提示,queue worker 则把它归一为 BFF `warning` 字符串后由前端直接展示。无项目上下文时不创建项目资源或画布图层。 - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。 @@ -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` 收口。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits;`nanobanana2` 的 `512 / 1024 / 2K` 是标量清晰度档位,后端保留 provider 输出几何尺寸,不按 `宽x高` 解析。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `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`;前端按后端快照落画布,不补造缺失产物。 @@ -114,6 +114,7 @@ - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片或生成占位框接近其它图片 / 生成占位框边缘、中心或等距分布位置时显示吸附线,并保存吸附后的最终布局。 - 生成图片点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 +- 选择 `1K / 2K` 或切换比例后,占位框在待生成和生成中阶段都必须立即显示对应目标像素尺寸;从普通图片、角色、图标图集或 UI 设计图再次改造时同样适用,完成落图前后不得从默认 1K 框跳变为 2K 成品。 - 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。 - 画布常用快捷键必须与右上角快捷键弹窗一致;新增快捷键时应同步更新 `ImageCanvasShortcutModel`、快捷键 hook 单测和本方案。输入框、文本域和 contenteditable 聚焦时不得触发画布编辑快捷键。 - 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index fd05d28d6..695067656 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -118,7 +118,7 @@ - `ImageCanvasGenerationLayerModel.ts` - 承载生成结果落画布的纯数据规则:普通生图、修改图片、快速编辑和图标素材批量结果如何生成图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、`assetKind`、源图关联和 `generationInputs`。 - 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。 - - 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。 + - 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框的位置和目标像素尺寸共同作为完成落图契约”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。 ## 第十三阶段模块 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 7428ce850..1316af685 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -197,6 +197,8 @@ controller 配置: 角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 +透明背景处理正常成功时,provider 原图仍保留在项目资源与账号素材库中,但不作为第二张近似图片进入画布;角色画布只放透明主结果,图标和 UI 画布放透明主图集以及实际拆分出的业务素材。这样一次任务仍能保留全部可恢复产物,同时不会让用户误以为请求执行或扣费了两次。 + inline 与 external v1 成功响应继续使用结构化 `warning.code/reason`;图标 / UI 的透明图已经成功、只有自动拆分失败时,继续返回结构化 `sliceWarning.code/reason`,其中 `sliceWarning.reason` 保留原始诊断。queue worker 把两类告警归一为有界的 `result_payload_json.warning`:通用 `warning` 优先并原样保留完整 `reason`;只有不存在通用 `warning` 时,才给 `sliceWarning.reason` 添加“图集已生成,但自动拆分未完成:”前缀。任务摘要将该展示就绪的 `reason` 原样提取到 `warning_message`,单 job 状态和刷新后的任务列表 BFF 再以 `warning: string` 返回;Web 必须直接展示,不再补前缀或按 code 推断类型。历史任务保留写入时的 `reason` 快照,摘要 backfill 不按当前格式重新解释或补写前缀。该字符串语义是 worker / BFF / Web 的内部同版本契约,三者必须协调发布,不承诺滚动混部或旧 Web 缓存下的跨版本字符串兼容。 ## 验收 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 4d5e82c72..535c4d513 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -1852,11 +1852,24 @@ pub(crate) async fn generate_editor_image_for_owner( )); } }; - image = removal.image; + let removal_provider = removal.provider; + let (restored_removal_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, + provider_width, + provider_height, + error = ?error, + "角色透明图尺寸恢复失败,保留去背景服务原始输出" + ); + } + image = restored_removal_image; output_prompt = "去除纯色背景".to_string(); output_actual_prompt = None; output_model = removal.model; - output_provider = removal.provider.to_string(); + output_provider = removal_provider.to_string(); output_generation_inputs = build_editor_derived_asset_generation_inputs("角色抠图", &output_model, &source_record); Some(source_record) @@ -1864,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, @@ -1935,13 +1952,7 @@ pub(crate) async fn generate_editor_image_for_owner( .await?; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { - build_multi_asset_canvas_layer_items( - completion, - generated_asset.resource.as_ref(), - source_record - .as_ref() - .and_then(|record| record.resource.as_ref()), - ) + build_primary_canvas_layer_items(completion, generated_asset.resource.as_ref()) } else { (Vec::new(), None) }; @@ -2292,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() @@ -2418,9 +2433,10 @@ async fn ensure_editor_image_edit_source_allowed( ensure_editor_image_edit_asset_kind_allowed(asset_kind) } -fn encode_editor_image_edit_png( +fn encode_editor_image_png( image: image::DynamicImage, failure_status: StatusCode, + provider: &str, failure_message: &str, ) -> Result, AppError> { let mut bytes = Cursor::new(Vec::new()); @@ -2428,13 +2444,21 @@ fn encode_editor_image_edit_png( .write_to(&mut bytes, image::ImageFormat::Png) .map_err(|error| { AppError::from_status(failure_status).with_details(json!({ - "provider": "editor-image-edit", + "provider": provider, "message": format!("{failure_message}:{error}"), })) })?; Ok(bytes.into_inner()) } +fn encode_editor_image_edit_png( + image: image::DynamicImage, + failure_status: StatusCode, + failure_message: &str, +) -> Result, AppError> { + encode_editor_image_png(image, failure_status, "editor-image-edit", failure_message) +} + fn prepare_editor_image_edit_references( reference_images: &mut [OpenAiReferenceImage], target_size: &str, @@ -2571,6 +2595,69 @@ fn restore_editor_generated_image_output_dimensions_or_original( } } +fn apply_editor_postprocessed_alpha_to_provider_source( + provider_source: &DownloadedOpenAiImage, + postprocessed: &DownloadedOpenAiImage, +) -> Result, AppError> { + 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); + } + + // 中文注释:图片模型已经按用户所选 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( + image::DynamicImage::ImageRgba8(restored), + StatusCode::BAD_GATEWAY, + "editor-image-postprocess", + "恢复透明蒙版尺寸失败", + )?, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + })) +} + +fn apply_editor_postprocessed_alpha_to_provider_source_or_original( + provider_source: &DownloadedOpenAiImage, + postprocessed: DownloadedOpenAiImage, +) -> (DownloadedOpenAiImage, Option) { + match apply_editor_postprocessed_alpha_to_provider_source(provider_source, &postprocessed) { + Ok(Some(restored)) => (restored, None), + Ok(None) => (postprocessed, None), + Err(error) => (postprocessed, Some(error)), + } +} + fn restore_editor_generated_image_output_dimensions( output: &DownloadedOpenAiImage, model: &str, @@ -4241,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, @@ -4380,7 +4479,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), - source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -4974,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()) @@ -5107,7 +5217,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), - source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -6420,10 +6529,9 @@ fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { } } -fn build_multi_asset_canvas_layer_items( +fn build_primary_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, primary_resource: Option<&EditorProjectResourcePayload>, - source_resource: Option<&EditorProjectResourcePayload>, ) -> (Vec, Option) { let Some(primary_resource) = primary_resource else { return (Vec::new(), None); @@ -6437,52 +6545,17 @@ fn build_multi_asset_canvas_layer_items( primary_layer_id.as_str(), 0, ); - let primary_right = primary_item - .get("x") - .and_then(Value::as_f64) - .unwrap_or(placeholder.x) - + primary_item - .get("width") - .and_then(Value::as_f64) - .unwrap_or(placeholder.width.max(1.0)); - let primary_y = primary_item - .get("y") - .and_then(Value::as_f64) - .unwrap_or(placeholder.y); - let mut items = vec![primary_item]; - - if let Some(source_resource) = source_resource { - let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str()); - let mut source_item = build_generated_canvas_layer_item( - completion, - &placeholder, - source_resource, - source_layer_id.as_str(), - 0, - ); - if let Some(object) = source_item.as_object_mut() { - object.insert("x".to_string(), json!(primary_right + 32.0)); - object.insert("y".to_string(), json!(primary_y)); - object.insert( - "title".to_string(), - json!(format!("{}(原图)", completion.title.trim())), - ); - } - items.push(source_item); - } - - (items, Some(primary_layer_id)) + (vec![primary_item], Some(primary_layer_id)) } fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, - source_resource: Option<&EditorProjectResourcePayload>, icons: &[EditorIconSpritesheetIconResponse], ) -> (Vec, Option) { let placeholder = completion.placeholder.clone(); let (mut items, primary_layer_id) = - build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource); + build_primary_canvas_layer_items(completion, spritesheet_resource); if items.is_empty() { return (items, primary_layer_id); } @@ -8469,6 +8542,44 @@ mod tests { assert_eq!(restored.extension, "png"); } + #[test] + 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: postprocessed_bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let restored = + 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()), (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"); + } + #[test] fn publication_material_generation_restores_provider_output_to_workflow_dimensions() { let image = image::DynamicImage::new_rgba8(944, 704); @@ -8588,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"); @@ -8602,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 = @@ -8612,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"), @@ -8647,7 +8780,7 @@ mod tests { ); assert_eq!( resolve_editor_image_request_size(Some("generate"), Some("720x540"), true, &options), - "1536x1024" + "1024x768" ); } @@ -9173,7 +9306,7 @@ mod tests { } #[test] - fn editor_canvas_multi_asset_items_keep_source_and_anchor_processed_result() { + fn editor_canvas_character_generation_only_places_processed_result() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-character".to_string()), title: "角色形象 1".to_string(), @@ -9192,17 +9325,10 @@ mod tests { 400, 600, ); - let source = editor_project_resource_for_canvas_test( - "resource-character-source", - "character", - 400, - 600, - ); - let (items, primary_layer_id) = - build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source)); + build_primary_canvas_layer_items(&completion, Some(&processed)); - assert_eq!(items.len(), 2); + assert_eq!(items.len(), 1); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-character-cutout") @@ -9210,11 +9336,6 @@ mod tests { assert_eq!(items[0]["resourceId"], json!("resource-character-cutout")); assert_eq!(items[0]["x"], json!(0.0)); assert_eq!(items[0]["y"], json!(-70.0)); - assert_eq!(items[1]["resourceId"], json!("resource-character-source")); - assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); - assert_eq!(items[1]["assetKind"], json!("character")); - assert_eq!(items[1]["x"], json!(432.0)); - assert_eq!(items[1]["y"], json!(-70.0)); let layers = json!([{ "itemType": "generation-dialog", @@ -9245,7 +9366,7 @@ mod tests { .and_then(Value::as_str) .is_some_and(|id| id.starts_with("resource-character-"))) .count(), - 2 + 1 ); let dialog = completed_items .iter() @@ -9330,7 +9451,7 @@ mod tests { } #[test] - fn editor_canvas_spritesheet_items_include_source_sheet_and_slices() { + fn editor_canvas_spritesheet_items_include_processed_sheet_and_slices() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-ui-assets".to_string()), title: "UI素材图集".to_string(), @@ -9349,12 +9470,6 @@ mod tests { 512, 512, ); - let source = editor_project_resource_for_canvas_test( - "resource-ui-source", - "icon-spritesheet", - 512, - 512, - ); let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128); let icons = vec![EditorIconSpritesheetIconResponse { name: "开始按钮".to_string(), @@ -9368,25 +9483,22 @@ mod tests { let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( &completion, Some(&processed), - Some(&source), icons.as_slice(), ); - assert_eq!(items.len(), 3); + assert_eq!(items.len(), 2); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-ui-sheet") ); assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); - assert_eq!(items[1]["resourceId"], json!("resource-ui-source")); - assert_eq!(items[1]["assetKind"], json!("icon-spritesheet")); - assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); - let source_right = items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); - assert!(items[2]["x"].as_f64().unwrap() > source_right); + assert_eq!(items[1]["resourceId"], json!("resource-ui-slice")); + let sheet_right = items[0]["x"].as_f64().unwrap() + items[0]["width"].as_f64().unwrap(); + assert!(items[1]["x"].as_f64().unwrap() > sheet_right); } #[test] - fn editor_canvas_spritesheet_items_keep_sheet_and_source_when_slices_are_empty() { + fn editor_canvas_spritesheet_items_keep_only_processed_sheet_when_slices_are_empty() { let completion = EditorCanvasGenerationCompletionRequest { dialog_id: Some("dialog-icon-sheet".to_string()), title: "图标素材图集".to_string(), @@ -9405,24 +9517,11 @@ mod tests { 512, 512, ); - let source = editor_project_resource_for_canvas_test( - "resource-icon-source", - "icon-spritesheet", - 512, - 512, - ); + let (items, primary_layer_id) = + build_icon_spritesheet_canvas_layer_items(&completion, Some(&processed), &[]); - let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( - &completion, - Some(&processed), - Some(&source), - &[], - ); - - assert_eq!(items.len(), 2); + assert_eq!(items.len(), 1); assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); - assert_eq!(items[1]["resourceId"], json!("resource-icon-source")); - assert_eq!(items[1]["assetKind"], json!("icon-spritesheet")); assert_eq!( primary_layer_id.as_deref(), Some("layer-editor-resource-resource-icon-sheet") @@ -10779,9 +10878,9 @@ mod tests { .find("let removal = match removal {") .unwrap_or_else(|| panic!("{start} should match only the postprocess result")); let success_marker = if start.contains("generate_editor_image_for_owner") { - "image = removal.image;" + "image = restored_removal_image;" } else { - "let image = removal.image;" + "let removal_provider = removal.provider;" }; let fallback_end = body[fallback_start..] .find(success_marker) @@ -10833,7 +10932,7 @@ mod tests { .find("let removal = match removal {") .expect("only the postprocess result should be matched"); let fallback_end = body[fallback_start..] - .find("image = removal.image;") + .find("image = restored_removal_image;") .map(|offset| fallback_start + offset) .expect("processed-image success path should follow the fallback match"); let fallback = &body[fallback_start..fallback_end]; @@ -10858,7 +10957,7 @@ mod tests { "persist_editor_generated_image(", "persist_editor_generated_asset(", "slice_generated_icon_spritesheet", - "build_multi_asset_canvas_layer_items", + "build_primary_canvas_layer_items", ] { assert!( !fallback.contains(forbidden), 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/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index cb64f0f54..8bf6dafd2 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -1236,6 +1236,11 @@ describe('ImageCanvasEditorView generation integration', () => { { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成图片' })); + selectGenerationDimensions( + screen.getByRole('dialog', { name: '生成图片' }), + '1:1', + '2K', + ); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中的普通图片' }, }); @@ -1243,6 +1248,7 @@ describe('ImageCanvasEditorView generation integration', () => { }, dialogName: '生成图片', frameLabel: '图像生成占位图', + expectedFrameSize: { width: 2048, height: 2048 }, }, { open: () => { @@ -1268,10 +1274,16 @@ describe('ImageCanvasEditorView generation integration', () => { }, dialogName: '生成规范', frameLabel: '规范生成占位图', + expectedFrameSize: { width: 2048, height: 1152 }, }, { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); + selectGenerationDimensions( + screen.getByRole('dialog', { name: '生成角色形象' }), + '2:3', + '2K', + ); fireEvent.change(screen.getByLabelText('角色设定'), { target: { value: '生成中的角色形象' }, }); @@ -1279,6 +1291,7 @@ describe('ImageCanvasEditorView generation integration', () => { }, dialogName: '生成角色形象', frameLabel: '角色生成占位图', + expectedFrameSize: { width: 1365, height: 2048 }, }, ] as const; @@ -1298,6 +1311,12 @@ describe('ImageCanvasEditorView generation integration', () => { expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); + expect((frame as HTMLElement).style.width).toBe( + `${testCase.expectedFrameSize.width}px`, + ); + expect((frame as HTMLElement).style.height).toBe( + `${testCase.expectedFrameSize.height}px`, + ); expect(within(frame).getByRole('status').textContent).toContain('生成中'); unmount(); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 65fb3c79f..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, }, }); }); @@ -538,6 +538,10 @@ describe('ImageCanvasGenerationDialogModel', () => { placeholder: { x: 472, y: 140, + width: 1024, + height: 768, + originalWidth: 1024, + originalHeight: 768, }, }); @@ -614,9 +618,58 @@ describe('ImageCanvasGenerationDialogModel', () => { imageModel: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', + placeholder: { + x: 472, + y: 140, + width: 1376, + height: 2048, + originalWidth: 1376, + originalHeight: 2048, + }, }); }); + it.each([ + ['character', 'character'], + ['icon', 'icon-spritesheet'], + ['ui-design', 'ui-design'], + ] as const)( + 'restores %s remodel placeholders to the selected 2K dimensions', + (mode, assetKind) => { + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ assetKind }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + sourceDialog: { + id: `generation-dialog-${mode}`, + mode, + prompt: '来源生成器', + status: 'idle', + generatedLayerId: 'layer-source', + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '2:3', + imageSize: '2K', + }, + mode: 'redraw', + }), + ).toMatchObject({ + mode, + imageModel: IMAGE_MODEL_GPT_IMAGE_2, + aspectRatio: '2:3', + imageSize: '2K', + placeholder: { + x: 472, + y: 140, + width: 1376, + height: 2048, + originalWidth: 1376, + originalHeight: 2048, + }, + }); + }, + ); + it('restores a plain generated layer from its own metadata', () => { const canvasSize = { width: 960, height: 720 }; const viewport = { x: 0, y: 0, scale: 1 }; @@ -672,6 +725,33 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); + it('preserves 2K dimensions when reopening a persisted character layer', () => { + expect( + createLayerGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + assetKind: 'character', + model: IMAGE_MODEL_NANOBANANA2, + originalWidth: 2048, + originalHeight: 2048, + generationInputs: { + fields: [{ title: '角色设定', value: '香水师角色' }], + references: [], + }, + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }), + ).toMatchObject({ + mode: 'character', + prompt: '香水师角色', + imageModel: IMAGE_MODEL_NANOBANANA2, + aspectRatio: '1:1', + imageSize: '2K', + generatedLayerId: 'layer-source', + }); + }); + it('restores video and audio generators from media layer metadata', () => { const context = { canvasSize: { width: 960, height: 720 }, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6d8a724db..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, @@ -40,6 +38,7 @@ import { PUBLICATION_FRAME_ORIGINAL_SIZE, resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, + resizeGenerationPlaceholderToImageSelection, SPEC_FRAME_ORIGINAL_SIZE, } from './ImageCanvasGenerationModel'; import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel'; @@ -298,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: '', @@ -310,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, }, }; } @@ -706,8 +710,7 @@ export function createLayerGenerationDialogDraft({ return { ...draft, prompt: draft.prompt || sourceLayer.prompt?.trim() || '', - imageModel: - sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel, + imageModel: sourceDialog?.imageModel ?? draft.imageModel, status: 'idle', composerOpen: true, generatedLayerId: sourceLayer.id, @@ -755,24 +758,47 @@ function restoreSharedImageOptions( sourceLayer: CanvasLayer, sourceDialog?: CanvasGenerationDialogState | null, ): Omit { - return { + const requestedModel = normalizeEditorImageModel( + sourceDialog?.imageModel ?? sourceLayer.model ?? draft.imageModel, + ); + const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some( + (option) => option.value === requestedModel, + ) + ? requestedModel + : DEFAULT_IMAGE_MODEL; + const dimensionOptions = + EDITOR_IMAGE_DIMENSION_OPTIONS[ + imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; + const supportedAspectRatios = + dimensionOptions.aspectRatios as readonly string[]; + const supportedImageSizes = dimensionOptions.imageSizes as readonly string[]; + const inferredAspectRatio = inferEditorImageAspectRatio( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const inferredImageSize = inferEditorImageSizeLabel( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ); + const preferredAspectRatio = sourceDialog?.aspectRatio ?? inferredAspectRatio; + const preferredImageSize = sourceDialog?.imageSize ?? inferredImageSize; + return resizeGenerationPlaceholderToImageSelection({ ...draft, - imageModel: sourceDialog?.imageModel ?? draft.imageModel, - aspectRatio: - sourceDialog?.aspectRatio ?? - draft.aspectRatio ?? - inferEditorImageAspectRatio( - sourceLayer.originalWidth, - sourceLayer.originalHeight, - ), - imageSize: - sourceDialog?.imageSize ?? - draft.imageSize ?? - inferEditorImageSizeLabel( - sourceLayer.originalWidth, - sourceLayer.originalHeight, - ), - }; + imageModel, + aspectRatio: supportedAspectRatios.includes(preferredAspectRatio) + ? preferredAspectRatio + : draft.aspectRatio && supportedAspectRatios.includes(draft.aspectRatio) + ? draft.aspectRatio + : (dimensionOptions.aspectRatios[0] ?? '1:1'), + imageSize: supportedImageSizes.includes(preferredImageSize) + ? preferredImageSize + : draft.imageSize && supportedImageSizes.includes(draft.imageSize) + ? draft.imageSize + : (dimensionOptions.imageSizes.find((size) => size === '1K') ?? + dimensionOptions.imageSizes[0] ?? + '1K'), + }); } function resolveSpecTypeFromSourceLayer( 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 0d7287103..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 }); } @@ -225,9 +259,9 @@ export function inferEditorImageSizeLabel(width: number, height: number) { return '1K'; } -export function resizeGenerationPlaceholderToImageSelection( - dialog: GenerateDialogState, -): GenerateDialogState { +export function resizeGenerationPlaceholderToImageSelection< + T extends GenerateDialogState, +>(dialog: T): T { if (!dialog.placeholder) { return dialog; } @@ -249,7 +283,7 @@ export function resizeGenerationPlaceholderToImageSelection( originalWidth: size.width, originalHeight: size.height, }, - }; + } as T; } function resolveEvenVideoWidth( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 4c321ed3c..c315ab568 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1364,6 +1364,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); render( { expect.objectContaining({ prompt: '修改当前图片', sourceImageSrc: 'resource-source', + canvasCompletion: expect.objectContaining({ + placeholder: { + x: 472, + y: 140, + width: 1024, + height: 768, + originalWidth: 1024, + originalHeight: 768, + }, + }), }), ); }); @@ -2500,6 +2511,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }); render( { segModel: 'birefnet', aspectRatio: '1:1', imageSize: '2K', + canvasCompletion: expect.objectContaining({ + placeholder: { + x: 472, + y: 140, + width: 2048, + height: 2048, + originalWidth: 2048, + originalHeight: 2048, + }, + }), generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 4171a94f7..f57269231 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -61,8 +61,6 @@ import { DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, - ICON_FRAME_DISPLAY_SIZE, - ICON_FRAME_ORIGINAL_SIZE, inferEditorImageAspectRatio, inferEditorImageSizeLabel, isCanvasGenerationDialog, @@ -1085,13 +1083,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({ const extractionPlan = resolveUiAssetExtractionGenerationPlan( options.marks?.length ?? 0, ); + const extractionSize = resolveEditorImageGenerationPixelSize({ + model: options.model ?? DEFAULT_IMAGE_MODEL, + aspectRatio: extractionPlan.aspectRatio, + imageSize: extractionPlan.imageSize, + }); const extractionFrame = { x: sourceLayer.x + sourceLayer.width + 32, y: sourceLayer.y, - width: ICON_FRAME_DISPLAY_SIZE.width, - height: ICON_FRAME_DISPLAY_SIZE.height, - originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, - originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, + width: extractionSize.width, + height: extractionSize.height, + originalWidth: extractionSize.width, + originalHeight: extractionSize.height, }; const generated = await runEditorGenerationWithWalletRefresh( extractEditorUiDesignAssets({ @@ -1778,10 +1781,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({ buildRightSideCanvasCompletionPlaceholder( submissionPlan.sourceLayer, { - width: 1024, - height: 1024, - originalWidth: 1024, - originalHeight: 1024, + width: submissionPlan.sourceLayer.originalWidth, + height: submissionPlan.sourceLayer.originalHeight, + originalWidth: submissionPlan.sourceLayer.originalWidth, + originalHeight: submissionPlan.sourceLayer.originalHeight, }, ); const generated = await runEditorGenerationWithWalletRefresh(