修复图片改造后二维清晰度降级问题 (#90)

前端按图层真实尺寸恢复图片改造比例与清晰度

后端在角色去背景后恢复生成原图像素尺寸

补充前后端二千像素尺寸回归测试

同步更新图片画布技术文档与项目决策记录

---------

Co-authored-by: MengHao <menghao@MengHaodeMacBook-Air.local>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/90
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 孟豪 <mh18530625731@163.com>
Co-committed-by: 孟豪 <mh18530625731@163.com>
This commit was merged in pull request #90.
This commit is contained in:
2026-07-18 16:50:17 +08:00
committed by 段舒康
parent a5ae283561
commit 47f3297e84
16 changed files with 583 additions and 190 deletions
@@ -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`
@@ -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`;图集开放快速编辑后尤其容易触发。
File diff suppressed because one or more lines are too long
@@ -118,7 +118,7 @@
- `ImageCanvasGenerationLayerModel.ts`
- 承载生成结果落画布的纯数据规则:普通生图、修改图片、快速编辑和图标素材批量结果如何生成图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、`assetKind`、源图关联和 `generationInputs`
- 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。
- 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。
- 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框的位置和目标像素尺寸共同作为完成落图契约”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。
## 第十三阶段模块
@@ -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 缓存下的跨版本字符串兼容。
## 验收
@@ -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` 个切片;超限在任何切片写入前拒绝。
@@ -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/<taskId>/image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- 角色生成后端已按固定 prompt 骨架补入 `角色设定` 和自动决策纯色抠图背景,并在生成成功后先保存纯色背景源图,再通过 BgFilter 按用户路径默认 `segModel=birefnet` 执行透明化;透明化成功时把处理图写入 `generated-character-drafts/editor/character-images/<taskId>/image.png` 路径下的 OSS 私有对象,最终失败时则保留并返回已经持久化的 provider 原图和通用 warning。若 BgFilter 返回较小图片,只允许把其 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不得放大低分辨率透明成品。最终回包的 `objectKey` / `assetObjectId` 会随画板资源记录保存。
- `Esc` 只退出角色规范画布点选状态,不关闭角色生成面板。
- 已补充回归测试覆盖角色形象生成、点选退出、角色动画入口隔离和快速编辑入口。
- 本次验证命令:
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
}
@@ -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();
@@ -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 },
@@ -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<CanvasGenerationDialogState, 'id'> {
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(
@@ -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'],
@@ -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(
@@ -1364,6 +1364,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
render(
<SubmissionWorkflowHarness
projectId="project-edit-size"
initialDialog={{
mode: 'edit',
prompt: '',
@@ -1384,6 +1385,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
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(
<SubmissionWorkflowHarness
projectId="project-ui-extraction-size"
initialLayers={[
createLayer({
assetKind: 'ui-design',
@@ -2541,6 +2553,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
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({
@@ -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(