修复图片画布生成尺寸与重复落图
同步图片生成与改造占位框到所选目标尺寸。 修复角色、图标和UI正常生成时中间原图重复进入画布的问题。 补充前后端回归测试并更新架构与项目记忆文档。
This commit is contained in:
@@ -16,12 +16,20 @@
|
||||
|
||||
---
|
||||
|
||||
## 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` 恢复比例与清晰度,并按目标模型支持范围归一;角色形象去背景完成后必须把透明结果恢复到去背景前 provider 原图的像素尺寸,再进入既有交付尺寸恢复和持久化,去背景供应商不得改变最终素材分辨率。
|
||||
- 影响范围:图片画布生成对话框恢复、角色形象 BgFilter / 阿里云 / 本地去背后处理、项目资源与账号素材尺寸元数据。
|
||||
- 验证方式:覆盖“持久化 2K 角色图重开仍为 2K”和“1K 去背结果恢复到 2K provider 原图尺寸”的前后端定向测试,并运行前端类型检查、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 决策:从既有图片重新打开普通图片、角色、UI 或宣发生成器时,在没有仍存活的生成对话框快照时按当前图层真实 `originalWidth / originalHeight` 恢复比例与清晰度,并按目标模型支持范围归一;恢复或切换比例 / 清晰度后,普通图片、角色、图标图集和 UI 设计图的待生成及生成中占位框必须同步使用目标像素尺寸,不能保留新建 draft 的默认 1K 框。UI 素材提取的占位按框选数量对应的 1K / 2K 计划生成,旧图片修改入口按源图真实尺寸占位。角色形象去背景完成后必须把透明结果恢复到去背景前 provider 原图的像素尺寸,再进入既有交付尺寸恢复和持久化,去背景供应商不得改变最终素材分辨率。
|
||||
- 影响范围:图片画布生成对话框恢复、生成中占位尺寸、UI 素材提取与旧图片修改的 `canvasCompletion`、角色形象 BgFilter / 阿里云 / 本地去背后处理、项目资源与账号素材尺寸元数据。
|
||||
- 验证方式:覆盖“持久化 2K 角色图重开仍为 2K”“普通图片 / 角色 / 图标 / UI 改造的 2K 占位与目标一致”“普通生图、规范图和角色图生成中占位不回退 1K”“UI 提取和旧修改入口的完成占位使用业务目标尺寸”以及“1K 去背结果恢复到 2K provider 原图尺寸”的前后端定向测试,并运行前端类型检查、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展
|
||||
@@ -104,7 +112,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,14 @@
|
||||
- 关联:相关文件、文档、提交或 Issue
|
||||
```
|
||||
|
||||
## 多产物任务保留中间原图不等于把中间原图放进画布
|
||||
|
||||
- 现象:角色形象只提交一次、任务列表和泥点也只记录一次,但画布同时出现透明角色与纯色背景角色;图标或 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 +408,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”“生成占位框的位置和目标像素尺寸共同作为完成落图契约”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。
|
||||
|
||||
## 第十三阶段模块
|
||||
|
||||
|
||||
@@ -196,6 +196,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 缓存下的跨版本字符串兼容。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -1952,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)
|
||||
};
|
||||
@@ -4451,7 +4445,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 {
|
||||
@@ -5178,7 +5171,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 {
|
||||
@@ -6491,10 +6483,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<Value>, Option<String>) {
|
||||
let Some(primary_resource) = primary_resource else {
|
||||
return (Vec::new(), None);
|
||||
@@ -6508,52 +6499,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<Value>, Option<String>) {
|
||||
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);
|
||||
}
|
||||
@@ -9271,7 +9227,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(),
|
||||
@@ -9290,17 +9246,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")
|
||||
@@ -9308,11 +9257,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",
|
||||
@@ -9343,7 +9287,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()
|
||||
@@ -9428,7 +9372,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(),
|
||||
@@ -9447,12 +9391,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(),
|
||||
@@ -9466,25 +9404,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(),
|
||||
@@ -9503,24 +9438,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")
|
||||
@@ -10877,7 +10799,7 @@ 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;"
|
||||
};
|
||||
@@ -10931,7 +10853,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];
|
||||
@@ -10956,7 +10878,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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: 1365,
|
||||
height: 2048,
|
||||
originalWidth: 1365,
|
||||
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: 1365,
|
||||
height: 2048,
|
||||
originalWidth: 1365,
|
||||
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 };
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||
resolveEditorImageGenerationPixelSize,
|
||||
resolveEditorVideoGenerationPixelSize,
|
||||
resizeGenerationPlaceholderToImageSelection,
|
||||
SPEC_FRAME_ORIGINAL_SIZE,
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
|
||||
@@ -764,7 +765,7 @@ function restoreSharedImageOptions(
|
||||
);
|
||||
const preferredAspectRatio = sourceDialog?.aspectRatio ?? inferredAspectRatio;
|
||||
const preferredImageSize = sourceDialog?.imageSize ?? inferredImageSize;
|
||||
return {
|
||||
return resizeGenerationPlaceholderToImageSelection({
|
||||
...draft,
|
||||
imageModel,
|
||||
aspectRatio: supportedAspectRatios.includes(preferredAspectRatio)
|
||||
@@ -779,7 +780,7 @@ function restoreSharedImageOptions(
|
||||
: (dimensionOptions.imageSizes.find((size) => size === '1K') ??
|
||||
dimensionOptions.imageSizes[0] ??
|
||||
'1K'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSpecTypeFromSourceLayer(
|
||||
|
||||
@@ -225,9 +225,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 +249,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,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -2499,6 +2510,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="project-ui-extraction-size"
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
assetKind: 'ui-design',
|
||||
@@ -2540,6 +2552,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(
|
||||
|
||||
Reference in New Issue
Block a user