统一画布生成产物改造契约

新增 generationInputs V2 稳定 action、field id 与 reference id 契约
恢复图片、视频、音频、角色动作等生成面板并保留 legacy 回退
非生成裁扩、去背景和图集拆分不再继承生成配方
匿名精选响应不再返回 owner generationInputs
同步前后端测试与架构文档
This commit is contained in:
2026-08-03 20:07:06 +08:00
parent 38c43b708a
commit 5fd26a11ee
26 changed files with 1606 additions and 253 deletions
@@ -29,10 +29,10 @@
## 2026-07-31 修正抠图内部元数据的普通用户读取边界
- 背景:2026-07-30 的记录误把素材 owner 与后台审计并列为原始抠图执行信息的读取方。owner 是普通用户,前端不展示字段不能阻止其从项目资源、素材库、精选提交 / 点赞回包、画布布局或任务完成响应的网络 payload 读取 BgFilter、阿里云、本地键色、具体分割模型或背景色。
- 决策:本条取代 2026-07-30 决策中“素材 owner、精选提交响应仍可读取原始值”的表述。普通用户(包括素材 owner)和匿名公开读取必须共同过滤素材顶层 `provider`、内部处理 `model`,以及 `generationInputs` 顶层 `screenColorHex``mattingProvider``mattingModel`;正常用户可见生成 `model` 和其他合法功能性顶层字段(例如 `characterAnimation`)保持不变。User/Owner mapper 完成清理,public mapper 在其基础上叠加公开字段规则;后台管理与服务端审计继续使用 raw mapper 和持久化原值。历史数据不迁移,统一在读取边界清理。
- 决策:本条取代 2026-07-30 决策中“素材 owner、精选提交响应仍可读取原始值”的表述。普通用户(包括素材 owner)读取必须过滤素材顶层 `provider`、内部处理 `model`,以及 `generationInputs` 顶层 `screenColorHex``mattingProvider``mattingModel`;正常用户可见生成 `model` 和其他合法功能性顶层字段(例如 `characterAnimation`)保持不变。匿名公开素材 payload 不包含整个 `generationInputs`User/Owner mapper 完成普通用户清理,public mapper 在其基础上移除该 owner-only 配方字段;后台管理与服务端审计继续使用 raw mapper 和持久化原值。历史数据不迁移,统一在读取边界清理。
- 入站与持久化:客户端提交的 `generationInputs` 不得伪造上述内部键,服务端在实际处理完成后才写入可信值。手动去背景的正式素材 `model` 必须继承经服务端验证的正常源生图模型;若来源或祖先链不存在正常模型则为 `null`,不得写入 `BgFilter complex` 等内部处理模型。内部抠图 provider / model 可继续持久化供后台审计,普通用户完成响应和用户可见错误文本均不得暴露它们;手动去背景与角色动作透明化失败在 Owner HTTP / 任务状态边界统一替换为稳定业务文案,原始错误只留在任务记录、tracing 和后台审计。
- 影响范围:项目资源、素材库、精选提交 / 点赞、图片 / 图标 / 视频 / 音频 / 角色动画生成完成、Agent 紧凑结果和识别出的画布资源 / 图层快照的 User/Public mapper;手动去背景持久化和完成响应;External Editor API 创建素材 / 资源时的保留键入站清理;相应响应 / OpenAPI 契约、前端搜索 / 详情 / ZIP 过滤测试与后台 raw 审计测试。同源画布 BFF 的角色、图标和 UI 请求继续由前端自动提交默认 `segModel=birefnet`,后端继续校验并在缺失时回落默认值;该请求控制字段不进入 `generationInputs`、普通用户响应、搜索、详情、导出或错误详情。角色动作的 `seg_model` 继续由后端固定。不修改 SpacetimeDB schema、迁移或 bindings。
- 验证方式:Owner 和匿名响应覆盖无 `provider`、无内部 `model`、无三个内部 `generationInputs` 键,正常 `model``characterAnimation` 仍保留Admin raw payload 保持完整。覆盖历史 `BgFilter complex`、三类入站伪造键、手动去背景源模型回溯和用户错误文本过滤;运行 api-server 定向测试、前端定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 验证方式:Owner 响应覆盖无 `provider`、无内部 `model`、无三个内部 `generationInputs` 键,同时保留正常 `model``characterAnimation`;匿名公开素材响应完全不含 `generationInputs`Admin raw payload 保持完整。覆盖历史 `BgFilter complex`、三类入站伪造键、手动去背景源模型回溯和用户错误文本过滤;运行 api-server 定向测试、前端定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
@@ -1024,7 +1024,7 @@
## 2026-06-21 图片画布参考图元数据只保存项目内行引用
- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。
- 决策:`generationInputs.references` 只保存 `{ title, label, refType, refId }`其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId``refType="asset"` 指向 `editor_asset.assetId`。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。
- 决策:`generationInputs.references` 只保存稳定行引用,不保存媒体本身。2026-08-03 起 V2 新写入结构为 `{ id, title, label?, refType, refId }`其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId``refType="asset"` 指向 `editor_asset.assetId`,媒体类型从对应行的查询结果获得。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。
- 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。
- 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck``npm run check:encoding``git diff --check`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -5908,3 +5908,12 @@
- 显式协作合同:autonomous 的旧 `code-prototype + quality-review` 首批合同退出。显式 project collaboration policy 或持久 batch 恢复若进入首批 `agent.delegate` 路径,只允许且要求三个 Director 各一次;策划与程序 Director 是只读规划且 `expectedArtifacts=[]`,美术 Director 是非只读规范图任务且必须交付 `assets/art-spec.png`。任何非 repair 底层委派与 isolated child 都在首批失败关闭;默认 manifest DAG 仍是唯一自动首轮执行链,不额外复制三个 Director 委派。
- 输出决策:保留未提交 `streaming / ready` 的当前 revision 门;已提交的专业 Agent final reply 继续使用既有 durable response-stream 身份,后续项目 revision 变化不再隐藏早期阶段回复。
- 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs``start-dev-stack.mjs``src-tauri/src/agent/runtime_protocol/autonomous_completion.rs``response_stream.rs``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-08-03 图片画布生成产物统一“改造”契约
- 背景:画布生成结果统一显示“改造”,但部分动画 / 音频结果无法恢复面板;另一些非生成型派生结果继承最近生成输入,产生错误可执行动作。中文标题和素材类别被同时当成显示文案、参数键和路由键,改名后容易漂移。
- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`V2 以稳定 `action``fields[].id``references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用的媒体类型从其资源行查回,不重复写入快照。提交前参数只归一一次,请求与持久快照共用同一归一值。
- 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。
- 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
File diff suppressed because one or more lines are too long
@@ -620,6 +620,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind``generation_inputs_json` 和历史 `public_showcase_enabled``asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation``陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot``source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-runapply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。
- `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs``background_mode``cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。
- `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与资源;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。引用媒体类型从 `refType/refId` 对应的工程资源或账号素材查询结果获得,不在快照内重复保存。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。
- 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。
- 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。
- 索引:`by_editor_project_resource_project_id``by_editor_project_resource_owner_user_id`
@@ -112,8 +112,8 @@
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,必须通过 `sourceResourceId` 找回原角色图层并重新打开角色动作生成器;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- 恢复已生成图层的来源生成器时,角色、动作、规范、图标、UI、宣发、视频、音效背景音乐必须按 `assetKind / mediaType` 恢复对应面板;去背景或快速编辑产生的派生 `quick-edit` 对话框不得遮住图层原有来源生成器
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、视频、音效背景音乐、角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复;历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。独立非生成型裁扩、手动去背景和手动图集拆分结果不继承 `generationInputs`,不显示该动作;原生成任务内自动后处理的同源产物可保留原生成输入
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
## 画布保存
@@ -52,7 +52,7 @@
- 音频元数据弹窗使用 `时长`,不使用图片 / 视频的分辨率语义;音频生成占位不显示分辨率或时长角标。
- 音频图层右上角标签显示在信息按钮左侧,和其他素材卡右上角信息区保持一致。
- 元数据弹窗按音频显示 `音频信息` / `音频类型` / `时长`,生成输入快照只展示用户面板字段。
- 音频图层上方浮动工具栏只保留 `改造``下载按钮`点击 `改造` 后打开对应的音效或背景音乐生成面板,不展示参考图组件;面板底部模型与参数位置和原生成入口一致,并允许继续修改后再次生成,新结果落在原音频旁边。
- 音频图层上方浮动工具栏只保留 `改造``下载按钮`只有当前 owner 的可执行生成输入存在时才显示 `改造`;点击后按 `action` 恢复对应的音效或背景音乐生成面板,音效再按 `fields[id="model"]``fields[id="durationSeconds"]` 恢复模型与时长;面板不展示参考图组件,允许修改后生成新音频,新结果落在原音频旁边。
## 前端提交契约
@@ -131,6 +131,6 @@ POST /api/editor/audios/background-music/generations
- 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。
- 鼠标未悬停音频卡时底部播放器辅助控件不可见,悬停时从底部滑动显现;提示词位于卡片左上角。
- 音频卡片、悬停角标和待生成占位不显示时长;信息弹窗显示时长。
- 音频素材浮动工具栏显示 `改造``下载按钮``改造`对应生成面板且没有参考图组件。
- 音频素材浮动工具栏在存在可执行生成输入时显示 `改造``下载按钮`否则只显示下载;`改造` 复对应生成面板且没有参考图组件。
- 私有 generated 音频能先换签再预览播放,不出现播放条一直为 `0:00` 的裸路径失败状态。
- 刷新后 layout 能恢复音频生成器和音频图层。
@@ -919,6 +919,8 @@ pub struct EditorProjectResourcePayload {
asset_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
showcase_category: Option<String>,
// TODO: generation_inputs V2 的 action 暂以字符串保存在 JSON 中;待 shared-contracts 提供 Rust DTO 后收口为共享 enum。
#[serde(skip_serializing_if = "Option::is_none")]
generation_inputs: Option<Value>,
public_showcase_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -5502,7 +5504,8 @@ pub async fn split_editor_icon_spritesheet(
.provider
.clone()
.unwrap_or_else(|| "Genarrative".to_string());
let generation_inputs = source_resource.generation_inputs.clone();
// 手动拆图是确定性派生操作,切片不得继承源图集的生成输入,避免错误展示“改造”。
let generation_inputs = None;
let icon_image_srcs = persist_editor_spritesheet_slices(
&state,
slices,
@@ -6764,6 +6767,7 @@ fn editor_project_resource_public_payload_from_record(
resolve_editor_project_resource_public_author(state, payload.owner_user_id.as_str());
payload.author_display_name = display_name;
payload.author_public_user_code = public_user_code;
payload.generation_inputs = None;
payload
}
@@ -9952,14 +9956,16 @@ mod tests {
}
#[test]
fn editor_project_resource_public_payload_removes_internal_generation_metadata() {
fn editor_project_resource_public_payload_omits_generation_inputs() {
let state = AppState::new(AppConfig::default()).expect("state should build");
let record = EditorShowcaseAssetRecord {
model: Some("BgFilter complex".to_string()),
provider: Some("BgFilter".to_string()),
generation_inputs: Some(json!({
"fields": [{"title": "处理阶段", "value": "角色抠图"}],
"references": [{"refType": "asset", "refId": "asset-source"}],
"version": 2,
"action": "image.generate",
"fields": [{"id": "prompt", "title": "生成提示词", "value": "角色立绘"}],
"references": [{"id": "reference", "label": "角色参考", "refType": "asset", "refId": "asset-source"}],
"characterAnimation": {"action": "idle"},
"screenColorHex": "#CFEFFF",
"mattingProvider": "BgFilter",
@@ -9980,6 +9986,13 @@ mod tests {
assert!(!owner_generation_inputs.contains_key("screenColorHex"));
assert!(!owner_generation_inputs.contains_key("mattingProvider"));
assert!(!owner_generation_inputs.contains_key("mattingModel"));
assert_eq!(owner_generation_inputs["version"], json!(2));
assert_eq!(owner_generation_inputs["action"], json!("image.generate"));
assert_eq!(owner_generation_inputs["fields"][0]["id"], json!("prompt"));
assert_eq!(
owner_generation_inputs["references"][0]["id"],
json!("reference")
);
assert_eq!(
owner_generation_inputs["characterAnimation"],
json!({"action": "idle"})
@@ -9991,24 +10004,7 @@ mod tests {
.expect("public payload should serialize");
assert!(public_payload.get("provider").is_none());
assert!(public_payload.get("model").is_none());
let generation_inputs = public_payload["generationInputs"]
.as_object()
.expect("generation inputs should stay object");
assert!(!generation_inputs.contains_key("screenColorHex"));
assert!(!generation_inputs.contains_key("mattingProvider"));
assert!(!generation_inputs.contains_key("mattingModel"));
assert_eq!(
generation_inputs["fields"],
json!([{"title": "处理阶段", "value": "角色抠图"}])
);
assert_eq!(
generation_inputs["references"],
json!([{"refType": "asset", "refId": "asset-source"}])
);
assert_eq!(
generation_inputs["characterAnimation"],
json!({"action": "idle"})
);
assert!(public_payload.get("generationInputs").is_none());
}
#[test]
@@ -646,7 +646,7 @@ function getGroupsForTab(assets: EditorAssetSnapshot[], tab: ShowcaseTabId) {
function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
const fields = asset.generationInputs?.fields ?? [];
const promptField = fields.find((field) => {
const value = field.value.trim();
const value = String(field.value).trim();
return (
value &&
USER_PROMPT_INPUT_TITLES.has(
@@ -655,7 +655,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
!isBuiltInPromptText(value)
);
});
const promptValue = promptField?.value.trim();
const promptValue = promptField ? String(promptField.value).trim() : '';
if (promptValue) {
return promptValue;
}
@@ -668,7 +668,7 @@ function resolveUserGenerationPrompt(asset: EditorAssetSnapshot) {
) {
return [];
}
const value = field.value.trim();
const value = String(field.value).trim();
return value && !isBuiltInPromptText(value)
? [`${field.title.trim()}${value}`]
: [];
@@ -824,7 +824,7 @@ function inferVideoResolution(
const fields = asset.generationInputs?.fields ?? [];
const fieldValue = fields
.flatMap((field) => [field.title, field.value])
.map((value) => value.trim())
.map((value) => String(value).trim())
.find((value) => /\b(?:480p|720p|1080p)\b/iu.test(value));
const matchedValue = fieldValue?.match(/\b(480p|720p|1080p)\b/iu)?.[1];
if (matchedValue && allowedValues.includes(matchedValue)) {
@@ -874,8 +874,14 @@ describe('ImageCanvasEditorView generation integration', () => {
});
const submittedGenerationInputs =
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
'pixelArt',
expect(submittedGenerationInputs).toEqual(
expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
expect.objectContaining({ id: 'style', value: 'pixelArt' }),
]),
}),
);
await waitFor(() => {
@@ -2485,18 +2491,23 @@ describe('ImageCanvasEditorView generation integration', () => {
const characterGenerationInput = generateEditorImageMock.mock.calls[0]?.[0];
expect(characterGenerationInput?.generationInputs).toEqual(
expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '角色设定',
value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
}),
]),
references: expect.arrayContaining([
expect.objectContaining({
id: 'specReference',
title: '角色规范',
label: '角色规范',
}),
expect.objectContaining({
id: 'reference',
title: '常规参考图 1',
label: '大鱼素材',
}),
@@ -2664,8 +2675,11 @@ describe('ImageCanvasEditorView generation integration', () => {
projectId: 'editor-project-icons',
assetFolderId: 'project',
generationInputs: expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '素材描述',
value: '返回按钮\n设置按钮',
}),
@@ -3255,7 +3269,6 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByText('角色')).toBeTruthy();
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
expect(
screen.queryByRole('dialog', { name: '角色动画生成面板' }),
).toBeNull();
@@ -3266,7 +3279,6 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
expect(
@@ -3536,8 +3548,11 @@ describe('ImageCanvasEditorView generation integration', () => {
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
generationInputs: expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
@@ -3545,6 +3560,7 @@ describe('ImageCanvasEditorView generation integration', () => {
]),
references: expect.arrayContaining([
expect.objectContaining({
id: 'source',
title: 'UI设计图',
label: '战斗UI设计图',
}),
@@ -64,19 +64,39 @@ export type EditorAsset = {
uploadMessage?: string;
};
export type CanvasGenerationAction =
| 'image.generate'
| 'spec.generate'
| 'character.generate'
| 'icon.generate'
| 'ui-design.generate'
| 'publication.generate'
| 'video.generate'
| 'audio.sound-effect.generate'
| 'audio.background-music.generate'
| 'character-animation.generate'
| 'image.edit'
| 'ui-design.extract-assets';
export type CanvasGenerationInputValue = string | number | boolean;
export type CanvasGenerationInputField = {
id?: string;
title: string;
value: string;
value: CanvasGenerationInputValue;
};
export type CanvasGenerationInputReference = {
id?: string;
title: string;
label: string;
label?: string;
refType: 'project-resource' | 'asset';
refId: string;
};
export type CanvasGenerationInputs = {
version?: 2;
action?: CanvasGenerationAction;
fields: CanvasGenerationInputField[];
references: CanvasGenerationInputReference[];
};
@@ -201,6 +221,7 @@ export type GenerateDialogState = {
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
isRedrawAsNewArtifact?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
specType?: SpecGenerationType;
@@ -1278,7 +1278,9 @@ describe('ImageCanvasEditorView', () => {
expect(
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
).toBeNull();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '改造' }),
).toBeNull();
});
it('hydrates canvas images from Resolution instead of saved Size', async () => {
@@ -1794,7 +1796,9 @@ describe('ImageCanvasEditorView', () => {
expect(
screen.getByRole('button', { name: '查看大鱼素材图片信息' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '改造' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '改造' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
@@ -1737,6 +1737,7 @@ export function ImageCanvasEditorView({
layersRef.current,
layer,
),
availableLayers: layersRef.current,
});
if (!restoredDraft) {
return false;
@@ -8,6 +8,7 @@ import type {
import {
DEFAULT_ICON_DESCRIPTIONS,
formatLayerImageType,
formatGenerationInputValue,
getEditorLayerModelDisplayName,
isEditorUserVisibleGenerationInputField,
UI_DESIGN_ASSET_EXTRACTION_PROMPT,
@@ -540,7 +541,7 @@ const BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE = new Map(
function isBuiltInGenerationInputField(field: CanvasGenerationInputField) {
const normalizedTitle = field.title.trim();
const normalizedValue = field.value.trim();
const normalizedValue = formatGenerationInputValue(field.value).trim();
const builtInValues =
BUILT_IN_GENERATION_INPUT_VALUES_BY_TITLE.get(normalizedTitle);
return (
@@ -557,12 +558,12 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) {
.filter((field) => !isBuiltInGenerationInputField(field))
.map((field) => ({
title: field.title,
value: field.value,
value: formatGenerationInputValue(field.value),
})) ?? [];
const references =
layer.generationInputs?.references.map((reference) => ({
title: reference.title,
label: reference.label,
label: reference.label ?? '参考素材',
refType: reference.refType,
refId: reference.refId,
})) ?? [];
@@ -825,6 +825,191 @@ describe('ImageCanvasGenerationDialogModel', () => {
});
});
it('restores V2 video and sound parameters by stable ids instead of display labels', () => {
const imageReference = createLayer({
id: 'layer-image-reference',
resourceId: 'resource-image-reference',
title: '视觉参考',
});
const audioReference = createLayer({
id: 'layer-audio-reference',
resourceId: 'resource-audio-reference',
title: '音轨参考',
mediaType: 'audio',
});
const context = {
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
availableLayers: [imageReference, audioReference],
};
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
sourceType: 'generated',
mediaType: 'video',
generationInputs: {
version: 2,
action: 'video.generate',
fields: [
{ id: 'prompt', title: '可随时改名', value: '角色追逐镜头' },
{ id: 'model', title: '不用作键', value: 'seedance2.0-fast' },
{ id: 'aspectRatio', title: '显示快照', value: '9:16' },
{ id: 'resolution', title: '显示快照', value: '1080p' },
{ id: 'durationSeconds', title: '显示快照', value: 8 },
{ id: 'sound', title: '显示快照', value: 'off' },
],
references: [
{
id: 'imageReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-image-reference',
},
{
id: 'audioReference',
title: '任意标题',
label: '旧显示名',
refType: 'project-resource',
refId: 'resource-audio-reference',
},
],
},
}),
}),
).toMatchObject({
mode: 'video',
prompt: '角色追逐镜头',
videoModel: 'seedance2.0-fast',
videoAspectRatio: '9:16',
videoResolution: '1080p',
videoDurationSeconds: 8,
videoSound: 'off',
generationReferences: [
expect.objectContaining({
id: 'canvas-layer-image-reference',
label: '视觉参考',
}),
expect.objectContaining({
id: 'canvas-layer-audio-reference',
label: '音轨参考',
mediaType: 'audio',
}),
],
});
expect(
createLayerGenerationDialogDraft({
...context,
sourceLayer: createLayer({
sourceType: 'generated',
mediaType: 'audio',
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ id: 'prompt', title: '非 prompt 标题', value: '短促点击音' },
{ id: 'model', title: '非 model 标题', value: 'audio1.0' },
{ id: 'durationSeconds', title: '非 duration 标题', value: 7 },
],
references: [],
},
}),
}),
).toMatchObject({
mode: 'audio-sound-effect',
prompt: '短促点击音',
soundModel: 'audio1.0',
soundDurationSeconds: 7,
});
});
it('keeps valid V2 fields ahead of conflicting legacy dialog and resource fallbacks', () => {
expect(
createLayerGenerationDialogDraft({
sourceLayer: createLayer({
sourceType: 'generated',
prompt: '资源旧提示词',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [
{ id: 'model', title: '任意标题', value: IMAGE_MODEL_GPT_IMAGE_2 },
{ id: 'aspectRatio', title: '任意标题', value: '2:3' },
{ id: 'imageSize', title: '任意标题', value: '2K' },
],
references: [],
},
}),
sourceDialog: {
id: 'legacy-dialog',
mode: 'generate',
prompt: '历史对话框提示词',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
},
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
}),
).toMatchObject({
mode: 'generate',
prompt: '',
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
aspectRatio: '2:3',
imageSize: '2K',
});
});
it('uses the V2 source reference ahead of a conflicting legacy animation source', () => {
const v2Source = createLayer({
id: 'layer-v2-character',
resourceId: 'resource-v2-character',
assetKind: 'character',
});
const legacySource = createLayer({
id: 'layer-legacy-character',
resourceId: 'resource-legacy-character',
assetKind: 'character',
});
expect(
createSameSourceGenerationDialogDraft({
sourceLayer: createLayer({
id: 'layer-animation-result',
sourceType: 'generated',
assetKind: 'character-animation',
sourceResourceId: 'resource-legacy-character',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '任意标题', value: '循环奔跑' }],
references: [
{
id: 'source',
title: '任意标题',
refType: 'project-resource',
refId: 'resource-v2-character',
},
],
},
}),
canvasSize: { width: 960, height: 720 },
viewport: { x: 0, y: 0, scale: 1 },
mode: 'redraw',
sourceAnimationLayer: legacySource,
availableLayers: [legacySource, v2Source],
}),
).toMatchObject({
mode: 'character-animation',
prompt: '循环奔跑',
sourceLayerId: 'layer-v2-character',
});
});
it('inherits and normalizes quick edit model parameters from the source', () => {
expect(
createQuickEditPanelDraft(
File diff suppressed because it is too large Load Diff
@@ -18,6 +18,7 @@ import {
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignAssetExtractionGenerationInputs,
canOpenRedrawPanel,
calculateCharacterAnimationPrice,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
@@ -236,7 +237,19 @@ describe('ImageCanvasGenerationModel', () => {
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
version: 2,
action: 'image.generate',
fields: [
{ id: 'prompt', title: '生成提示词', value: '一张明亮主视觉' },
{
id: 'model',
title: '模型',
value: 'gemini-3.1-flash-image-preview',
},
{ id: 'style', title: '风格', value: 'none' },
{ id: 'aspectRatio', title: '比例', value: '1:1' },
{ id: 'imageSize', title: '尺寸', value: '1K' },
],
references: [],
});
@@ -248,15 +261,18 @@ describe('ImageCanvasGenerationModel', () => {
characterView: '右向三分之二侧身',
customPrompt: '',
}),
).toEqual({
).toEqual(expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: [
{ title: '玩法设定', value: '平台跳跃' },
{ title: '美术风格', value: '像素风' },
{ title: '头身比', value: '3' },
{ title: '角色视角', value: '右向三分之二侧身' },
{ id: 'specType', title: '规范类型', value: 'character' },
{ id: 'playSetting', title: '玩法设定', value: '平台跳跃' },
{ id: 'artStyle', title: '美术风格', value: '像素风' },
{ id: 'bodyRatio', title: '头身比', value: '3' },
{ id: 'characterView', title: '角色视角', value: '右向三分之二侧身' },
],
references: [],
});
}));
});
it('builds character, icon and edit reference snapshots', () => {
@@ -280,23 +296,29 @@ describe('ImageCanvasGenerationModel', () => {
},
],
),
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
).toEqual(expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '主角骑士' },
]),
references: [
{
id: 'specReference',
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
});
}));
expect(
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
@@ -305,31 +327,41 @@ describe('ImageCanvasGenerationModel', () => {
src: '/icon-spec.png',
resourceId: 'resource-icon-spec',
}),
).toEqual({
fields: [{ title: '素材描述', value: '返回按钮\n设置按钮' }],
).toEqual(expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
],
});
}));
expect(
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
).toEqual({
fields: [{ title: '修改要求', value: '换成夜晚' }],
).toEqual(expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '修改要求', value: '换成夜晚' },
]),
references: [
{
id: 'source',
title: '参考图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
],
});
}));
expect(
buildUiDesignAssetExtractionGenerationInputs(sourceLayer, [
{
@@ -339,29 +371,32 @@ describe('ImageCanvasGenerationModel', () => {
resourceId: 'resource-ui-style',
},
]),
).toEqual({
fields: [
{
).toEqual(expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
fields: expect.arrayContaining([
expect.objectContaining({
id: 'prompt',
title: '提取提示词',
value:
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
},
],
}),
]),
references: [
{
id: 'source',
title: 'UI设计图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: 'UI风格参考',
refType: 'project-resource',
refId: 'resource-ui-style',
},
],
});
}));
expect(
buildQuickEditGenerationInputs(
'快速编辑提示词',
@@ -376,23 +411,86 @@ describe('ImageCanvasGenerationModel', () => {
},
],
),
).toEqual({
fields: [{ title: '快速编辑提示词', value: '让图2下雨' }],
).toEqual(expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '快速编辑提示词', value: '让图2下雨' },
]),
references: [
{
id: 'source',
title: '原图',
label: '原图',
refType: 'project-resource',
refId: 'resource-source',
},
{
id: 'reference',
title: '参考图 1',
label: '天空',
refType: 'project-resource',
refId: 'resource-sky',
},
],
});
}));
});
it('only exposes redraw for executable generation metadata', () => {
const generatedLayer = {
...buildSourceLayer(),
sourceType: 'generated' as const,
};
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: buildImageGenerationInputs('生成城堡'),
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
fields: [{ title: '生成提示词', value: '旧版城堡' }],
references: [],
},
}),
).toBe(true);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: null,
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
fields: [{ title: '生成提示词', value: '公开展示文本' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
generationInputs: {
version: 2,
action: 'unknown.generate' as never,
fields: [{ id: 'prompt', title: '提示词', value: '未知动作' }],
references: [],
},
}),
).toBe(false);
expect(
canOpenRedrawPanel({
...generatedLayer,
sourceType: 'uploaded',
generationInputs: buildImageGenerationInputs('上传文件'),
}),
).toBe(false);
});
it('keeps generated prompts and quick edit options stable', () => {
@@ -472,9 +570,19 @@ describe('ImageCanvasGenerationModel', () => {
},
),
).toEqual({
fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }],
version: 2,
action: 'spec.generate',
fields: [
{ id: 'specType', title: '规范类型', value: 'custom' },
{
id: 'customPrompt',
title: '自定义规范提示词',
value: '生成一张怪兽规范图',
},
],
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
@@ -530,10 +638,12 @@ describe('ImageCanvasGenerationModel', () => {
expect(
buildSoundEffectGenerationInputs('金币掉落叮当声', 'audio1.0', 7),
).toEqual({
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ id: 'prompt', title: 'prompt', value: '金币掉落叮当声' },
{ id: 'model', title: 'model', value: 'audio1.0' },
{ id: 'durationSeconds', title: 'duration', value: 7 },
],
references: [],
});
File diff suppressed because it is too large Load Diff
@@ -53,10 +53,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
result: {
title: '生成图片 3',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张发光主视觉' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '生成提示词', value: '一张发光主视觉' },
]),
references: [],
},
}),
},
rememberImageModel: 'gemini-3.1-flash-image-preview',
});
@@ -124,17 +128,22 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
kind: 'edit',
normalizedPrompt: '修改当前图片',
sourceLayer,
generationInputs: {
fields: [{ title: '修改要求', value: '修改当前图片' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '修改要求', value: '修改当前图片' },
]),
references: [
{
id: 'source',
title: '参考图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
}),
});
});
@@ -178,17 +187,26 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
title: '源图 快速编辑',
assetKind: 'character',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '把当前图改成雨天' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{
id: 'prompt',
title: '快速编辑提示词',
value: '把当前图改成雨天',
},
]),
references: [
{
id: 'source',
title: '原图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
}),
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
@@ -255,20 +273,23 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
{
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
expect.objectContaining({
version: 2,
action: 'spec.generate',
fields: expect.arrayContaining([
{ id: 'playSetting', title: '玩法设定', value: '休闲消除' },
{ id: 'artStyle', title: '美术风格', value: '清爽卡通' },
]),
references: [
{
id: 'reference',
title: '参考图',
label: '参考.png',
refType: 'project-resource',
refId: 'resource-spec-ref',
},
],
},
}),
);
});
@@ -359,25 +380,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
rememberImageModel: 'gpt-image-2',
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual(
{
fields: [
{ title: '角色设定', value: '白发骑士' },
],
expect.objectContaining({
version: 2,
action: 'character.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '角色设定', value: '白发骑士' },
]),
references: [
{
id: 'specReference',
title: '角色规范',
label: '角色规范',
refType: 'project-resource',
refId: 'resource-character-spec',
},
{
id: 'reference',
title: '常规参考图 1',
label: '盔甲参考',
refType: 'project-resource',
refId: 'resource-armor',
},
],
},
}),
);
});
@@ -425,23 +450,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
assetKind: 'ui-design',
title: 'UI设计图 6',
generationInputs: {
fields: [{ title: '用户输入', value: '设计移动端商城首页' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'ui-design.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '用户输入', value: '设计移动端商城首页' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: 'UI图标规范',
refType: 'project-resource',
refId: 'resource-ui-spec',
},
{
id: 'reference',
title: '参考图 1',
label: '界面风格参考',
refType: 'project-resource',
refId: 'resource-ui-ref',
},
],
},
}),
},
rememberImageModel: IMAGE_MODEL_GPT_IMAGE_2,
});
@@ -491,24 +522,32 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
assetKind: 'publication-material',
title: '10 宣发素材',
generationInputs: {
fields: [
{ title: '游戏名', value: '重庆洪崖洞火锅' },
{ title: '游戏分类', value: '抓大鹅、休闲、治愈、手绘风' },
generationInputs: expect.objectContaining({
version: 2,
action: 'publication.generate',
fields: expect.arrayContaining([
{ id: 'gameName', title: '游戏名', value: '重庆洪崖洞火锅' },
{
id: 'gameCategories',
title: '游戏分类',
value: '抓大鹅、休闲、治愈、手绘风',
},
{
id: 'gameDescription',
title: '一句话描述游戏',
value: '在一堆物品里点击,找到三个一样的物品将其消除',
},
],
]),
references: [
{
id: 'reference',
title: '宣发参考图 1',
label: '参考图',
refType: 'project-resource',
refId: 'resource-publication-ref',
},
],
},
}),
},
});
const prompt = plan.kind === 'image' ? plan.input.prompt : '';
@@ -722,23 +761,29 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
aspectRatio: '3:2',
imageSize: '2K',
},
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮\n\n设置按钮' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'icon.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '素材描述', value: '返回按钮\n\n设置按钮' },
]),
references: [
{
id: 'specReference',
title: '图标规范',
label: '图标规范',
refType: 'project-resource',
refId: 'resource-icon-spec',
},
{
id: 'reference',
title: '参考图 1',
label: '按钮风格参考',
refType: 'project-resource',
refId: 'resource-icon-ref',
},
],
},
}),
rememberImageModel: 'gpt-image-2',
resultTitle: '冒险游戏图标',
});
@@ -850,28 +895,33 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
],
},
result: {
generationInputs: {
generationInputs: expect.objectContaining({
version: 2,
action: 'video.generate',
references: [
{
id: 'imageReference',
title: '参考图 1',
label: '参考图.png',
refType: 'project-resource',
refId: 'resource-video-image-ref',
},
{
id: 'videoReference',
title: '参考视频 1',
label: '参考视频.mp4',
refType: 'project-resource',
refId: 'resource-video-ref',
},
{
id: 'audioReference',
title: '参考音频 1',
label: '参考音频.mp3',
refType: 'project-resource',
refId: 'resource-audio-ref',
},
],
},
}),
},
});
});
@@ -1028,10 +1078,12 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
title: '游戏音效 4',
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ title: 'prompt', value: '金币掉落叮当声' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '7秒' },
{ id: 'prompt', title: 'prompt', value: '金币掉落叮当声' },
{ id: 'model', title: 'model', value: 'audio1.0' },
{ id: 'durationSeconds', title: 'duration', value: 7 },
],
references: [],
},
@@ -1060,11 +1112,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
},
result: {
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ title: 'prompt', value: '按钮确认短促音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '5秒' },
{ id: 'prompt', title: 'prompt', value: '按钮确认短促音' },
{ id: 'model', title: 'model', value: 'audio1.0' },
{ id: 'durationSeconds', title: 'duration', value: 5 },
],
references: [],
},
},
});
@@ -1092,8 +1147,11 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
result: {
title: '游戏背景音乐 5',
generationInputs: {
version: 2,
action: 'audio.background-music.generate',
fields: [
{
id: 'prompt',
title: 'gpt_description_prompt',
value: '温暖轻快的森林冒险背景音乐',
},
@@ -251,6 +251,11 @@ export function buildImageGenerationSubmissionPlan({
'修改要求',
normalizedPrompt,
sourceLayer,
{
model: dialog.imageModel,
aspectRatio: dialog.aspectRatio,
imageSize: dialog.imageSize,
},
),
};
}
@@ -260,7 +265,7 @@ export function buildImageGenerationSubmissionPlan({
(layer) => layer.id === dialog.sourceLayerId,
);
if (!sourceLayer) {
throw new Error('未找到要改造的图');
throw new Error('未找到要改造的图');
}
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
const normalizedQuickEditPrompt = basePrompt;
@@ -284,6 +289,11 @@ export function buildImageGenerationSubmissionPlan({
normalizedQuickEditPrompt,
sourceLayer,
[],
{
model: imageModel,
aspectRatio: dialog.aspectRatio,
imageSize: dialog.imageSize,
},
),
},
rememberImageModel: imageModel,
@@ -368,6 +378,12 @@ export function buildImageGenerationSubmissionPlan({
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
},
rememberImageModel: imageModel,
@@ -405,6 +421,11 @@ export function buildImageGenerationSubmissionPlan({
normalizedPrompt,
dialog.uiDesignSpecReference,
dialog.generationReferences,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: normalizeGptImageSize(dialog.imageSize),
},
),
},
rememberImageModel: imageModel,
@@ -450,6 +471,7 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
workflow.id,
),
},
};
@@ -503,6 +525,13 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
{
model,
aspectRatio,
resolution,
durationSeconds,
sound,
},
),
},
};
@@ -585,6 +614,12 @@ export function buildImageGenerationSubmissionPlan({
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
},
rememberImageModel: imageModel,
@@ -646,6 +681,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
iconDescriptions,
dialog.iconSpecReference,
dialog.generationReferences,
{
model: rememberImageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
rememberImageModel,
resultTitle: resolveGenerationAssetLabel(
@@ -76,14 +76,16 @@ export function ImageCanvasMetadataModalView({
<div className="image-canvas-editor__metadata-reference-list">
{generationInputReferences.map((reference) => (
<div
key={`${reference.title}-${reference.label}-${reference.refType}-${reference.refId}`}
key={`${reference.title}-${reference.label ?? ''}-${reference.refType}-${reference.refId}`}
className="image-canvas-editor__metadata-reference-card"
>
<span className="image-canvas-editor__metadata-reference-copy">
<span className="image-canvas-editor__metadata-input-title">
{reference.title}
</span>
<span>{reference.label}</span>
<span>
{reference.label ?? '参考素材'}
</span>
<span className="image-canvas-editor__metadata-reference-id">
{formatReferenceType(reference.refType)} ·{' '}
{reference.refId}
@@ -25,7 +25,13 @@ function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '测试图片' }],
references: [],
},
...overrides,
};
}
@@ -85,7 +91,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
props.selectedLayer,
);
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(
props.selectedLayer,
);
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
expect(
within(toolbar).queryByRole('button', { name: '删除图片' }),
@@ -209,6 +217,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
src: 'data:audio/mpeg;base64,audio',
mediaType: 'audio',
assetKind: 'sound-effect',
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [{ id: 'prompt', title: 'prompt', value: '金币声' }],
references: [],
},
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
@@ -238,6 +252,32 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onDownloadLayer).toHaveBeenCalledWith(layer);
});
it('hides redraw for non-generative derived and public display-only layers', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
title: '手动裁扩结果',
generationInputs: null,
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
cleanup();
renderSelectedToolbar({
selectedLayer: createLayer({
title: '公开音效',
mediaType: 'audio',
assetKind: 'sound-effect',
generationInputs: {
version: 2,
fields: [{ title: 'prompt', value: '公开展示文本' }],
references: [],
},
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy();
});
it('removes raster edit actions for video and character animation layers', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
@@ -245,6 +285,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
src: '/generated/video.mp4',
mediaType: 'video',
assetKind: 'video',
generationInputs: {
version: 2,
action: 'video.generate',
fields: [{ id: 'prompt', title: '视频描述', value: '测试视频' }],
references: [],
},
}),
});
const videoToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
@@ -268,6 +314,12 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
src: '/generated/action/frame-01.png',
mediaType: 'image-sequence',
assetKind: 'character-animation',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
references: [],
},
}),
});
const actionToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
@@ -13,7 +13,10 @@ import type { CSSProperties } from 'react';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
import {
canOpenRedrawPanel,
isQuickEditUnsupportedAssetKind,
} from './ImageCanvasGenerationModel';
type ImageCanvasSelectedLayerToolbarViewProps = {
selectedLayer: CanvasLayer | null;
@@ -47,6 +50,7 @@ export function ImageCanvasSelectedLayerToolbarView({
if (!selectedLayer || !selectedToolbarStyle) {
return null;
}
const canRedraw = canOpenRedrawPanel(selectedLayer);
if (selectedLayer.mediaType === 'audio') {
return (
@@ -57,15 +61,17 @@ export function ImageCanvasSelectedLayerToolbarView({
aria-label="素材工具栏"
onPointerDown={(event) => event.stopPropagation()}
>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</PlatformIconButton>
{canRedraw ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</PlatformIconButton>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
@@ -181,19 +187,23 @@ export function ImageCanvasSelectedLayerToolbarView({
<span></span>
</PlatformIconButton>
) : null}
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</PlatformIconButton>
{canRedraw ? (
<>
<span
aria-hidden="true"
className="image-canvas-editor__floating-toolbar-divider"
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="改造"
title="改造"
icon={<WandSparkles className="h-4 w-4" />}
onClick={() => onOpenRedrawPanel(selectedLayer)}
>
<span></span>
</PlatformIconButton>
</>
) : null}
<EditorIconButton
label="下载按钮"
title="下载按钮"
@@ -2775,8 +2775,11 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
segModel: 'birefnet',
referenceImageSrcs: ['generated/ref-style.png'],
generationInputs: expect.objectContaining({
version: 2,
action: 'ui-design.extract-assets',
references: expect.arrayContaining([
expect.objectContaining({
id: 'reference',
title: '参考图 1',
label: '风格参考',
refType: 'project-resource',
@@ -1090,6 +1090,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generationInputs = buildUiDesignAssetExtractionGenerationInputs(
sourceLayer,
options.references,
{
model: options.model ?? DEFAULT_IMAGE_MODEL,
imageSize: resolveUiAssetExtractionGenerationPlan(
options.marks?.length ?? 0,
).imageSize,
},
);
const referenceImageSrcs = await Promise.all(
(options.references ?? []).map((reference) =>
@@ -1487,6 +1493,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generationInputs = buildVideoGenerationInputs(
normalizedPrompt,
quickEditReferences,
{
model: videoModel,
aspectRatio: '16:9',
resolution: videoResolution,
durationSeconds: videoDurationSeconds,
sound: 'off',
},
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorVideo({
@@ -1583,12 +1596,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
'重绘提示词',
normalizedPrompt,
quickEditSourceLayer,
{
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
},
)
: buildQuickEditGenerationInputs(
'快速编辑提示词',
normalizedPrompt,
quickEditSourceLayer,
quickEditReferences,
{
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
},
);
const isCharacterRedraw =
panelMode === 'redraw' &&
@@ -1846,6 +1869,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs: submissionPlan.generationInputs,
});
} else if (submissionPlan.kind === 'quick-edit') {
const isRedrawAsNewArtifact = Boolean(dialog.isRedrawAsNewArtifact);
const quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId ===
submissionPlan.sourceLayer.id
@@ -1886,7 +1910,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetFolderId,
assetLabel: submissionPlan.result.title,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
targetLayerId: submissionPlan.sourceLayer.id,
...(isRedrawAsNewArtifact
? {
canvasCompletion: {
dialogId: canvasDialog?.id,
title: submissionPlan.result.title,
placeholder:
getGeneratingDialogPlaceholder(dialog) ??
buildRightSideCanvasCompletionPlaceholder(
submissionPlan.sourceLayer,
{
width: submissionPlan.sourceLayer.originalWidth,
height: submissionPlan.sourceLayer.originalHeight,
originalWidth:
submissionPlan.sourceLayer.originalWidth,
originalHeight:
submissionPlan.sourceLayer.originalHeight,
},
),
},
}
: { targetLayerId: submissionPlan.sourceLayer.id }),
}),
onWalletBalanceMayHaveChanged,
);
@@ -1908,13 +1952,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
return;
}
applyQuickEditResultToSourceLayer(
generated,
submissionPlan.sourceLayer,
submissionPlan.result.generationInputs,
canvasDialog?.id,
submissionPlan.result.title,
);
if (isRedrawAsNewArtifact) {
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
title: submissionPlan.result.title,
generationInputs: submissionPlan.result.generationInputs,
});
} else {
applyQuickEditResultToSourceLayer(
generated,
submissionPlan.sourceLayer,
submissionPlan.result.generationInputs,
canvasDialog?.id,
submissionPlan.result.title,
);
}
} else if (submissionPlan.kind === 'video') {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
@@ -2203,6 +2255,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generationInputs = buildCharacterAnimationGenerationInputs(
submissionPlan.promptText,
characterAnimationSourceLayer,
{
resolution: characterAnimationPanel.resolution,
ratio: characterAnimationPanel.ratio,
frameCount: characterAnimationPanel.frameCount,
durationSeconds: characterAnimationPanel.durationSeconds,
},
);
const canvasCompletionPlaceholder = canvasDialog
? getGeneratingDialogPlaceholder(canvasDialog)
@@ -1083,14 +1083,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '打开音效改造' }));
expect(screen.getByTestId('tool').textContent).toBe('music');
expect(screen.getByTestId('selected').textContent).toBe('layer-sound');
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:-:placeholder',
);
expect(screen.getByTestId('dialog-prompt').textContent).toBe('金币跳出音');
expect(screen.getByTestId('placeholder').textContent).toBe(
'572:140:420:120',
'10:-12:420:120',
);
});
@@ -1234,10 +1234,12 @@ describe('useImageCanvasGenerationWorkflow', () => {
model: 'audio1.0',
duration: 8,
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [
{ title: 'prompt', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
{ id: 'prompt', title: 'prompt', value: '金币跳出音' },
{ id: 'model', title: 'model', value: 'audio1.0' },
{ id: 'durationSeconds', title: 'duration', value: 8 },
],
references: [],
},
@@ -1253,7 +1255,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
'audio-sound-effect:idle:open:layer-audio-1:placeholder',
);
expect(screen.getByTestId('placeholder').textContent).toBe(
'572:140:420:120',
'10:-12:420:120',
);
});
@@ -1278,10 +1280,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '生成提示词', value: '一张生成图' },
]),
references: [],
},
}),
});
});
await waitFor(() => {
@@ -1361,17 +1367,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
assetKind: undefined,
assetLabel: '生成图片 1',
referenceImageSrcs: ['resource-source'],
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.generate',
fields: expect.arrayContaining([
{ id: 'prompt', title: '生成提示词', value: '一张生成图' },
]),
references: [
{
id: 'reference',
title: '参考图 1',
label: '画布参考图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
}),
});
});
});
@@ -1772,17 +1783,22 @@ describe('useImageCanvasGenerationWorkflow', () => {
sourceImageSrc: 'resource-source',
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
generationInputs: expect.objectContaining({
version: 2,
action: 'image.edit',
fields: expect.arrayContaining([
{ id: 'prompt', title: '快速编辑提示词', value: '快速修图' },
]),
references: [
{
id: 'source',
title: '原图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
}),
}),
);
});
@@ -2052,7 +2068,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
projectId: undefined,
targetLayerId: 'layer-source',
assetKind: 'character',
generationInputs: undefined,
generationInputs: null,
assetFolderId: undefined,
assetLabel: '源图 去背景',
sourceResourceId: 'resource-source',
@@ -75,10 +75,12 @@ import {
calculateCharacterAnimationPrice,
CHARACTER_ANIMATION_DURATION_OPTIONS,
CHARACTER_ANIMATION_MODEL,
createCanvasLayerReference,
DEFAULT_IMAGE_MODEL,
EDITOR_IMAGE_DIMENSION_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
isCanvasGenerationDialog,
isNormalizedCanvasGenerationInputs,
isQuickEditUnsupportedAssetKind,
normalizeEditorImageModel,
resizeGenerationPlaceholderToImageSelection,
@@ -1276,6 +1278,81 @@ export function useImageCanvasGenerationWorkflow({
setCharacterAnimationPanel(null);
setUiAssetExtractionState(null);
setQuickEditSelectionState(null);
if (
isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs) &&
sourceLayer.generationInputs.action === 'ui-design.extract-assets'
) {
const sourceReference = sourceLayer.generationInputs.references.find(
(reference) => reference.id === 'source',
);
const extractionSource = sourceReference
? layers.find((layer) =>
sourceReference.refType === 'project-resource'
? layer.resourceId === sourceReference.refId
: layer.sourceAssetId === sourceReference.refId,
)
: null;
if (!extractionSource) {
showGenerationWarning('原 UI 设计图已不存在,无法改造。');
return;
}
const modelField = sourceLayer.generationInputs.fields.find(
(field) => field.id === 'model',
);
const extractionReferences = sourceLayer.generationInputs.references
.filter((reference) => reference.id === 'reference')
.flatMap((reference) => {
const layer = layers.find((candidate) =>
reference.refType === 'project-resource'
? candidate.resourceId === reference.refId
: candidate.sourceAssetId === reference.refId,
);
return layer ? [createCanvasLayerReference(layer)] : [];
});
setUiAssetExtractionState({
...createUiAssetExtractionState(extractionSource.id, {
initialTool: 'rect',
model:
typeof modelField?.value === 'string'
? modelField.value
: DEFAULT_IMAGE_MODEL,
}),
references: extractionReferences,
});
setQuickEditPanel(null);
selectSingleLayer(extractionSource.id);
setActiveTool('select');
return;
}
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
sourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode: 'redraw',
sourceAnimationLayer,
availableLayers: layers,
});
if (sameSourceDraft) {
openPlacedCanvasGenerationDialog(sameSourceDraft);
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
if (!isNormalizedCanvasGenerationInputs(sourceLayer.generationInputs)) {
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
}
return;
}
if (sourceLayer.mediaType === 'audio') {
const audioDraft = createAudioRedrawGenerationDialogDraft(sourceLayer);
if (!audioDraft) {
@@ -1285,6 +1362,9 @@ export function useImageCanvasGenerationWorkflow({
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(audioDraft);
setActiveTool('music');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
if (sourceLayer.mediaType === 'video') {
@@ -1296,6 +1376,9 @@ export function useImageCanvasGenerationWorkflow({
selectSingleLayer(sourceLayer.id);
openCanvasGenerationDialog(videoDraft);
setActiveTool('video');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
if (
@@ -1303,29 +1386,6 @@ export function useImageCanvasGenerationWorkflow({
sourceLayer.mediaType === 'image' ||
sourceLayer.assetKind === 'character-animation'
) {
const sourceDialog = findSourceGenerationDialog(
canvasGenerationDialogs,
sourceLayer,
);
const sourceAnimationLayer = findSourceAnimationLayer(
layers,
sourceLayer,
);
const sameSourceDraft = createSameSourceGenerationDialogDraft({
sourceLayer,
canvasSize,
viewport,
sourceDialog,
mode: 'redraw',
sourceAnimationLayer,
});
if (sameSourceDraft) {
openPlacedCanvasGenerationDialog(sameSourceDraft);
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool(getCanvasToolForGenerationMode(sameSourceDraft.mode));
return;
}
if (sourceLayer.assetKind === 'character-animation') {
showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。');
return;
@@ -1357,6 +1417,9 @@ export function useImageCanvasGenerationWorkflow({
setQuickEditPanel(null);
selectSingleLayer(null);
setActiveTool('generate');
showGenerationWarning(
'已按旧版数据恢复,部分参数可能使用当前默认值。',
);
return;
}
},
@@ -1561,7 +1624,7 @@ export function useImageCanvasGenerationWorkflow({
taskId: cropExpandSourceLayer.taskId,
sourceResourceId: cropExpandSourceLayer.resourceId,
assetKind: cropExpandSourceLayer.assetKind,
generationInputs: cropExpandSourceLayer.generationInputs,
generationInputs: null,
},
);
cropExpandResourceId = cropExpandResource.resourceId;
@@ -1596,6 +1659,7 @@ export function useImageCanvasGenerationWorkflow({
assetObjectId: cropExpandAssetObjectId,
sourceAssetId: null,
assetKind: cropExpandAssetKind,
generationInputs: null,
};
captureCanvasHistory({ type: 'expand-image', count: 1 });
appendCanvasLayersWithResources([nextLayer]);
@@ -1672,7 +1736,7 @@ export function useImageCanvasGenerationWorkflow({
projectId,
targetLayerId: sourceLayer.id,
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
generationInputs: null,
assetFolderId,
assetLabel,
sourceResourceId: sourceLayer.resourceId,

Some files were not shown because too many files have changed in this diff Show More