移除视频快速编辑支持 (#157)
Project CI / Repository checks (push) Successful in 1m20s
Project CI / Backend tests (push) Successful in 3m56s
Project CI / Native shell tests (push) Successful in 14m1s
Project CI / Frontend tests (push) Successful in 2m53s

当前实现: 视频快速编辑错误地复用了图片快速编辑的panel, 然后发起一个实际是生成视频的请求

策划建议删除.

因为后端没有实际意义上的视频快速编辑, 所以api 后端代码没有改动, 只在前端删除入口和处理

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/157
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
This commit was merged in pull request #157.
This commit is contained in:
2026-08-14 18:52:56 +08:00
committed by 段舒康
parent d155e303bd
commit d901945c77
19 changed files with 160 additions and 341 deletions
@@ -2593,7 +2593,7 @@ async fn runtime_v11_closure_repository_context_drift_replans_before_auto_mutati
}
assert_eq!(
fs::read_to_string(root.join("AGENTS.md")).expect("read drifted rules"),
"drifted rules\\n"
"drifted rules\n"
);
fs::remove_dir_all(root).ok();
@@ -6821,7 +6821,7 @@
- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`V2 以稳定 `action``fields[].id``references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。
- 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。
- 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType
@@ -13789,14 +13789,16 @@
## 2026-08-04 图片画布快速编辑改用统一正向白名单(2026-08-05 修订)
- 背景:画布生成结果统一显示快速编辑,但部分角色动作 / 序列帧和音频结果会进入不受支持的图片编辑链路;不同入口各自判断时也容易继续漂移。
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图游戏场景图视频单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁,视频快速编辑只走视频生成接口
- 决策:快速编辑只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图游戏场景图视频单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持;新增媒体或素材类型默认不开放。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁统一调用同一个正向白名单;后端图片编辑 BFF 基于目标图层的有效素材类型与媒体类型执行同一正向门禁。
- 边界:角色动作继续通过对应的动作生成链路处理,不再把当前帧当作可快速编辑图片。
- 2026-08-06 修订:画布 Agent 的 `edit_image` 只接受图片输入,新任务以 `assetKind=null` 表示普通静态图片,不再使用 synthetic `editor_agent_edit_image`。worker 仅按服务端生成的 `editor-agent:` dedupe namespace 识别并归一历史排队 payload;普通调用伪造旧值继续被拒绝。已持久化资源中的旧值只有在后端从真实目标图层 / 项目资源解析后才兼容为空类型,避免历史 Agent 结果失去快速编辑能力,同时不扩大请求白名单。
- 2026-08-07 修订:站内与 External v1 图片编辑请求统一只接受必填 `sourceReferenceId`,且该值必须是当前账号已登记的项目资源 ID 或素材 IDobjectKey、URL、Data URL、Blob URL 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段全部返回 400,不提供兼容别名。后端用共享窄查询分别按两张表主键定点解析,双表同 ID、未命中、跨账号、对象缺失或越权均失败关闭;权威类型完全来自业务记录,只允许普通静态图片、规范图、角色图、完整图标图集、图标规范、宣发图和 UI 设计图。请求带 `targetLayerId` 时必须同时带 `projectId`,来源与目标优先比较 `assetObjectId`,任一方缺失才比较 canonical `(bucket, objectKey)`,且来源默认类型必须与目标资源默认类型一致;最终类型取目标覆盖值或目标资源类型。HTTP 入队写入版本化服务端解析快照,worker 执行前按同一业务 ID 再次定点解析,身份或类型漂移即失败关闭。旧任务只把已有资源 ID 或旧来源字符串本身当业务 ID 迁移,绝不按 objectKey 反查。Canvas Agent 必须从 `ImageMetadata.reference_id` 取主来源;红框标注上传图只作为辅助 `referenceImageSrcs`,不能冒充被编辑资源。仅以素材 ID 编辑时,队列审计与 `generationInputs.references` 保留素材 ID,不伪造项目资源关系。
- 2026-08-08 修订:`scene` 是单张静态图片素材,加入前端快速编辑正向白名单和 api-server 权威来源白名单;编辑结果继续保留 `scene`。用户标签覆盖侧的前端菜单与 SpacetimeDB 结构化布局白名单也必须显式覆盖 `scene`。通用图片生成接口仍拒绝 `scene`,避免绕过结构化场景生成契约。
- 2026-08-08 修订:画布 Agent 的 `edit_image` 工具内部可先形成待确认的 `EditorImageEditRequest`,但确认接口必须在通用入队前复用站内图片编辑的来源解析与目标预检,写入 `{ version, request, source }` 服务端快照。worker 只解析正式 versioned payload 与既有历史 payload,不接受当前 direct request 作为 fallback;这样未上线的 Agent 路径在生产端原位修正,不扩大消费端协议。
- 2026-08-08 修订:图片编辑业务引用按 `objectKey` 找到权威 `asset_object` 后,若资源或素材记录同时保存了 `assetObjectId`,必须验证两者指向同一对象;不一致时在 SpacetimeDB resolver 边界失败关闭,不能把未验证的记录 ID 与已验证的对象路径组合进 snapshot。缺少 ID 的历史记录继续以 canonical `(bucket, objectKey)` 作为对象身份。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单测试覆盖单个拆分图标、角色动作及音频不展示,提交工作流测试覆盖单个拆分图标和角色动作绕过入口时仍拒绝;后端表驱动测试覆盖全部现役素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭
- 2026-08-10 永久修订:画布视频快速编辑永久下线,上传、生成和历史视频图层均不再展示入口,程序化打开与直接提交同样失败关闭;不设置 feature flag、兼容桥或数据迁移。普通视频生成、视频“改造”、下载、`referenceVideoSrcs` 以及 V2 `videoReference` 恢复保持不变。External v1 从未正式声明“视频快速编辑”,只提供带可选 `referenceVideoSrcs` 的通用视频生成,因此不修改 `/api/external/v1` 路由、DTO、异步语义或 OpenAPI。当前服务已完全停止且无在途 / 可重试任务,不新增旧任务收口逻辑
- 2026-08-14 改造边界澄清:`image.edit` 是图片快速编辑通过 `targetLayerId` 原位替换后的配方记录,只属于已知 action,不属于可改造 action;结果图继续显示快速编辑,不显示改造。其它可改造生成产物按原 `action` 恢复对应 generation dialog 并生成新产物,不恢复旧 quick-edit 改造面板,也不额外承诺提交成功后保持面板打开。
- 验证:模型测试覆盖允许与拒绝类型,工具栏和两类右键菜单覆盖视频、单个拆分图标、角色动作及音频不展示,打开与提交工作流覆盖视频等不支持类型绕过入口时仍拒绝;后端表驱动测试覆盖全部现役图片素材 / 媒体类型与未知类型,锁定图片编辑端点失败关闭。
- 关联:`src/components/image-editor/ImageCanvasGenerationModel.ts``ImageCanvasSelectedLayerToolbarView.tsx``ImageCanvasContextMenusView.tsx``useImageCanvasGenerationWorkflow.ts``useImageCanvasGenerationSubmissionWorkflow.ts`
## 2026-08-05 game-chat Canvas 区间证明与 parent wake 持久收口
@@ -13836,7 +13838,7 @@
- 决策:用户动作 `改造` 的语义是恢复原生成输入、编辑并生成新产物。沿用 `generation_inputs_json`V2 以稳定 `action``fields[].id``references[].id/refType/refId` 作为唯一执行契约,`title` / `label` 只用于展示,字段值保留基础类型。引用只匹配当前已 hydrate 的画布图层,媒体类型取匹配图层的运行时数据,不重复写入快照,也不新增 owner-only 工程资源 / 素材库 resolver。面板直接上传引用和已移出画布的引用不恢复:可重新选择的槽位留空并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时点击后显示明确错误并拒绝改造,运行期来源变化时仍必须复检。有效 V2 不因引用缺失降级到 legacy adapter。提交前参数只归一一次,请求与持久快照共用同一归一值。
- 兼容:恢复优先级为有效 V2 → 完整历史生成对话框 → legacy adapter。legacy 允许使用 `assetKind/mediaType`、历史标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 和当前默认值,但必须显示恢复告警;不回填存量数据,不做 SpacetimeDB schema 迁移。
- 边界:独立裁扩、手动去背景和手动图集拆分结果不继承生成输入,不显示 `改造`;原生成任务内自动后处理产物可保留原输入。Owner 读取保留 V2 执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,不沿用 owner 可执行配方 DTO。
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作、生成型图片编辑和 UI 素材提取;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
- 影响范围:图片、规范、角色、图标、UI、宣发、视频、音效、背景音乐、角色动作和 UI 素材提取;`image.edit` 只保留为历史已知配方读取,不提供改造;不影响作品详情“作品改造”、`AI重绘` 或常规 `快速编辑`
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】画板音乐生成入口设计-2026-06-18.md`
## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType
@@ -844,11 +844,11 @@
- 验证:`npm run test -- src/components/image-editor/ImageCanvasOverlayModel.test.ts src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`
- 关联:`src/components/image-editor/ImageCanvasOverlayModel.ts``src/components/image-editor/useImageCanvasGenerationSurface.tsx``docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`
## 图片画布重绘创建独立占位,快速编辑不要新建生成器
## 图片画布改造按原 action 生成新产物,快速编辑不要新建生成器
- 现象:用户点击图片素材的“快速编辑”后,画布上额外出现 `Quick Edit Generator` 占位,像是新建了一个生成器;但用户预期是在原图下方框选区域、填写一个提示词和模型,然后直接修改当前图。
- 原因:快速编辑入口和提交链路误用了 `createQuickEditGenerationDialogDraft(...)` / `CanvasGenerationDialogState`,把“覆盖源图”的快速编辑伪装成会产出新图层的生成器占位。
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId``sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。图片重绘、去背景、视频快速编辑等会产出新图层或异步占位的入口仍可走 generation dialog / placement 链路
- 处理:图片快速编辑必须走 `QuickEditPanelState`,打开时归档当前 active generation dialog 但不创建新的 `mode="quick-edit"` dialog;提交时调用 `/api/editor/images/edits`,主来源始终使用当前图片已登记的 `resourceId``sourceAssetId`。带编号标注的图片上传后只作为辅助 `referenceImageSrcs`,不能替换主来源身份;成功后覆盖源图,失败时保留快速编辑面板。快速编辑任务进入 `generating` 后必须移除框选工具和覆盖层,禁止继续新增框选;失败恢复面板后可继续调整框选再重试。`image.edit` 结果不允许改造;其它可改造产物按原 `action` 恢复 generation dialog 并生成新产物,去背景等异步入口继续使用各自现役 dialog / placement 链路,不恢复独立 quick-edit 改造面板
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx -- --runInBand`,以及按需运行 `npm run test -- src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx -t "快速编辑|quick edit" -- --runInBand`
- 关联:`src/components/image-editor/ImageCanvasEditorView.tsx``src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/services/image-editor/editorImageReference.ts`
File diff suppressed because one or more lines are too long
@@ -205,7 +205,7 @@ controller 配置:
- `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。
- `editor_ui_design_asset_extraction`UI 设计图红框素材提取。
- `editor_character_animation_generation`:角色动作视频和帧素材生成。
- `editor_video_generation`:画布视频生成和视频素材快速编辑
- `editor_video_generation`:画布视频生成。
- `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。
画板结果的业务真相仍是 `asset_object``editor_project_resource`、账号级 `editor_asset`、可选 `asset_entity_binding` 和对应的 legacy / structured canvas 表;`editor_generation_operation` 只是提交回执,不替代这些 read model。worker 在 Provider 与 OSS 完成后只做 prepare:使用 owner + operation kind + job ID + stable slot 派生 resource/asset ID,构造可选 object/binding、候选 layout 和 compact job result,然后一次调用 `persist_editor_generation_result_and_return`。该 procedure 必须在当前事务快照校验 owner、job kind、由 `request_payload_json` 重算的 fingerprint 与未过期 lease,最后与业务记录一起完成 job 和 receipt。任一验证、binding 或 canvas CAS 失败都回滚全部数据库事实;worker 不得随后再调用 `complete_external_generation_job_and_return`。前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload、receipt 或本地临时响应重建正式图层。
@@ -17,7 +17,7 @@
- `生成角色动作`
- `快速编辑`
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部重绘或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
`角色动画生成面板` 同步纳入本次生成类面板交互统一:点击角色图只聚焦图层,不自动弹出底部改造或角色动画面板;点击 `生成动画` 后像新建图片一样创建 `角色动作` 画布占位,面板跟随占位底部,参考图首行、单文本无边界、参数按钮向上弹出、生成按钮明确展示泥点。
`快速编辑` 由选中图片后的浮动工具栏显式打开,图片类素材统一进入框选区域 + 单提示词 + 比例 / 尺寸 + 模型选择的修改面板,不再恢复原来源生成器,也不展示额外参考图。单个拆分 `icon` 不支持快速编辑,完整 `icon-spritesheet``icon-spec` 图标规范仍按普通图片支持快速编辑。
## 统一布局
@@ -42,7 +42,7 @@
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本、Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 CSS。
11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;可选参考图字段为 `referenceId`,只允许当前 owner 的项目资源 ID 或素材 ID。后端固定以 `kind=spec / assetKind=icon-spec / gpt-image-2 / 16:9·2K` 执行图片生成。inline 路径校验业务字段和 `referenceId` 后,补全 `ExtraParam` 与最终 prompt 并交给共享图片生成执行器;queue 路径在预校验后按 `gpt-image-2 / 2K` 运行时定价冻结价格,再以独立 `editor_icon_spec_generation` job kind 入队原始业务载荷。worker 使用入队价格和当前 claim attempt 的计费上下文,重新解析载荷、校验当前 owner 与引用事实,再补全 `ExtraParam` 并调用同一共享图片生成执行器,避免排队期间状态变化产生 TOCTOU。
12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId``sourceAssetId` 始终作为 `/api/editor/images/edits``sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs`从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图也继续作为辅助引用,不得替代主来源身份
12. 图片快速编辑不展示用户可添加的额外参考图入口;原图已登记的 `resourceId``sourceAssetId` 始终作为 `/api/editor/images/edits``sourceReferenceId`,绘制了红框和序号的标注图上传后只作为辅助 `referenceImageSrcs``image.edit` 是原位替换记录,不提供“改造”恢复入口;用户需要继续修改时重新打开当前图片的快速编辑
13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
## 参数交互
@@ -72,7 +72,7 @@
- 生成规范类图片固定使用 `16:9·2K · gpt-image-2`。这三个参数在面板底部沿用可编辑参数按钮的胶囊样式展示,但控件保持禁用不可点击,不提供比例、尺寸或模型修改入口。
- 宣发素材的 `游戏首图``详情五图``运营海报` 固定使用 `gpt-image-2`。面板底部只显示禁用态 `gpt-image-2` 模型胶囊和生成按钮,不出现 `nanobanana2` 选项;后端收到 `publication-material` 旧请求时也必须强制归一为 `gpt-image-2`
- 图片快速编辑保留一个提示词输入框,并展示与常规图片生成一致的比例 / 尺寸和模型选择;提示词 placeholder 为 `你希望素材如何修改?`,提交按钮显示 `修改`,不展示额外参考图控件。打开面板时优先继承原图关联生成器记录的模型、比例和尺寸;没有关联生成器时使用图层模型,并按原图真实分辨率推导比例和尺寸;模型缺失或已不受支持时回落到当前默认图片模型。切换模型后只展示该模型支持的参数,不兼容的当前值回落到该模型默认值,按钮泥点按选定模型和尺寸同步刷新。提交时必须同时传递 `model / aspectRatio / imageSize`;后端按模型选择 provider 协议:`nanobanana2` 使用 `generateContent + inline_data``gpt-image-2` 使用 `/v1/images/edits` multipart,不能把 nanobanana 模型 ID 发往 GPT edits 端点。
- 图片改造入口也要保持同样约束:恢复历史参数时优先使用 `generationInputs` 中保存的模型、比例和尺寸,失败回退到当前关联参数后再映射为实际 `size`,并一并回传到编辑请求;`canvasCompletion` 的落位尺寸也应与实际输出目标分辨率一致,避免使用源图尺寸伪造改造结果的参数
- 生成类产物的“改造”按原 `action` 恢复对应 generation dialog,并沿用该 action 的参数校验、生成请求、占位和完成回填合同;它生成新产物,不转入 `/api/editor/images/edits` 原位替换链路。`image.edit` 只属于已知配方,不属于可改造 action
- 不再在底部常驻展开全部可选项。
## 泥点显示
@@ -81,7 +81,7 @@
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点``生成40泥点`;不使用泥点图标替代文字。
- 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。
- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost``/api/editor/icon-specs/generations``/api/editor/images/generations``/api/editor/images/edits``/api/editor/icon-spritesheets/generations``/api/editor/ui-designs/assets/extractions``/api/editor/videos/generations``/api/editor/character-animations/generations``/api/editor/audios/sound-effects/generations``/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2``2K` 定价。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、图片类改造、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2``2K` 定价。
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
- 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。
- 进入外部生成队列的任务在入队时冻结后端计算出的价格;worker 的预扣、退款、响应 `priceMudPoints` 和资产 `generationCostMudPoints` 必须使用同一入队价格,后台修改模型定价只影响之后入队的任务。
@@ -99,11 +99,11 @@
- 入口状态:新入口打开后设置对应 activeTool,清空画布选中、右键菜单、快速编辑、角色动画和其它生成来源选择状态;只切换当前面板,不删除已有生成占位。
- 生成中的占位图允许通过键盘 `Delete` / `Backspace` 删除;不额外增加画布上的可见删除按钮。用户删除后,后续异步成功或失败回写不得重新创建该生成对象。
- 待生成占位的空白样式按生成类型区分:视频使用视频图标和视频角标,角色形象使用角色图标和角色角标,角色动作使用角色动作图标和动作角标,音效使用音效图标和音效角标,背景音乐使用音乐图标和背景音乐角标。
- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,快速编辑用闪光图标,不能默认全部回退为图片图标。
- 待生成占位的图标语义必须与底部入口或触发入口保持一致:生成图片用图片图标,生成规范用规范图标,生成角色形象用角色图标,生成图标素材用图标网格,生成 UI 设计图用应用窗口,宣发素材用宣发图标,不能默认全部回退为图片图标。快速编辑不创建生成占位,因此不定义快速编辑占位图标。
- 图片类待生成占位尺寸必须与面板当前比例和尺寸同步:普通图片、角色形象、图标素材、UI 设计图按当前 `aspectRatio + imageSize` 的统一业务像素矩阵计算;所有共用规格都不因模型不同改变画布占地,例如 `nanobanana2``gpt-image-2``16:9·2K` 占位和普通最终结果都为 `2048 x 1152`。provider 请求尺寸可因接口合法值不同,但不得泄漏为正常完成的画布尺寸;回图更大时只允许缩小和轻微裁切,回图低于目标时保留实际像素并告警,禁止放大伪造所选档位;生成规范固定为 `16:9·2K`,占位为 `2048 x 1152`;宣发素材按 workflow 输出尺寸创建占位。
- 视频待生成占位必须与面板当前比例和清晰度同步:默认 `16:9 · 480p``854 x 480`,切换比例、`720p``1080p` 后按比例和清晰度重算偶数宽度;调整参数时保持占位中心点不变。
- 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。
- 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。用户选定的比例和尺寸是新的业务目标分辨率,覆盖旧的“始终保持源图精确分辨率”约束;替换时更新图层原始分辨率,并保持图层中心位置不跳动。需要新建占位的是生成图片、生成视频、按原 action 执行的改造、去背景和角色动作等会产出新图层的入口。
- 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。
- 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。
@@ -113,10 +113,10 @@
- 占位图的生成器名称 / 原始尺寸、图片图层右上角素材类型标签、查看信息按钮和悬浮尺寸标签都按 viewport 反向缩放,画布缩小时保持屏幕可读尺寸。
- 查看信息按钮固定使用圆形 `i` 图标,不使用中括号、花括号或文本符号样式。
- 视频 / 角色 / 角色动作 / 音效 / 背景音乐待生成占位的角标同样按 viewport 反向缩放,不随画布缩放变小。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;重绘、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、UI 设计图、宣发图和视频单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,提交层也必须拒绝绕过入口的调用。
- 已生成角色图、角色动作图或其它生成结果图被点击时只选中图层并收起已有生成输入框;改造、快速编辑和生成动画面板必须由对应工具栏按钮或右键菜单显式打开。快速编辑使用统一正向白名单,只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图和游戏场景图;视频单个拆分图标(backend reject)、角色动作 / 序列帧、音效与背景音乐不显示快速编辑入口,程序化打开和提交层也必须拒绝绕过入口的调用。
- 角色图层打开“生成动作”后再点击“改造”,必须重新打开角色形象生成器;动作生成对话框只把角色图层作为输入来源,不得被识别为该角色图层自身的来源生成器。
- 角色动作结果图层点击“改造”时,V2 必须通过 `references[id="source"]` 找回原角色图层,legacy 数据才允许以 `sourceResourceId` 回退;关联原角色已不存在时应显示明确提示,不得无响应或降级成图片生成器。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐角色动作和生成型图片编辑。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。生成型图片编辑 V2 中已持久化的附加 `reference` 应恢复到可见参考槽,并让再次提交的模型、比例、尺寸、像素尺寸、参考图和新快照保持一致;普通快速编辑不得提交未展示的隐藏参考图。视频快速编辑必须把实际送入请求的源视频同步保存为 `references[id="videoReference"]`,不能只依赖 `sourceResourceId`;视频 V2 同步保存并恢复 `webSearchEnabled`。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version``action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。
- `改造` 覆盖图片、规范、角色、图标、UI、宣发、游戏场景、视频、音效、背景音乐角色动作等可改造生成 action;它按原 `action` 恢复对应 generation dialog 并生成新产物。`image.edit` 是图片快速编辑的原位替换配方,只属于已知 action,不属于可改造 action;其结果继续通过快速编辑修改,不恢复成 generation dialog。有效 V2 只按 `action + fields[].id + references[].id` 恢复,引用只匹配当前画布图层;面板直接上传引用和已移出画布的引用不恢复。V2 不新增后续版本,读取时统一经 action 级 runtime decoder 原地收紧:已存在但未知、非法、已下线或与当前模型能力不兼容的参数统一回落到该 action 当前默认值,历史 Veo 也回落到当前默认视频模型;图片比例 / 尺寸按回落后的模型联动校验,角色动作帧数 / 时长按完整档位成对校验。发生参数回落时显示明确告警,再次提交和新快照只使用规范值并继续保存为 `version: 2`。服务端以实际媒体时长覆盖 V2 配方时必须保留 `fields[id="durationSeconds"]`,并写入归一后的有限数值,不能改写为无 `id` 的 legacy 展示字符串。可重新选择的引用缺失时打开面板、留空槽位并提示,提交门禁继续校验必填槽位;必须依赖原 `source` 图层才能构造面板的 action 也始终按 capability 保留改造入口,source 缺失时点击后显示不可替换原因并拒绝改造,运行期间来源变化时仍必须复检并拒绝。有效 V2 不得因引用缺失降级到 legacy。普通快速编辑不得提交未展示的隐藏参考图。普通视频生成继续按现有 V2 合同保存并恢复 `references[id="videoReference"]` `webSearchEnabled`,该合同不表示支持视频快速编辑。历史对话框和 legacy 数据保留 `assetKind/mediaType`、标题别名、资源尺寸 / 模型 / 时长 / `sourceResourceId` 回退,并对默认值恢复显示告警。V2 结构水合必须完整保留 `version/action`、字段与引用 `id`、有限数字、布尔值和无标签引用;一旦出现 `version``action` 却不满足 V2 合同,必须失败关闭,禁止降级成 legacy。
- 配方元数据不等于改造 capability。完美像素、手动去背景、裁扩、手动图集拆分以及图标 / UI 自动切片分别保存 `image.perfect-pixel``image.remove-background``image.crop-expand``spritesheet.split`,统一使用 `fields: []`;有正式来源行时只保留不可编辑的 `references[id="source"]` 权威来源,没有正式行时保留空引用。这四个确定性 action 永不显示或执行“改造”,历史 `pixel-art-snap-*` 结果也按 task identity 拒绝改造。整张生成图集仍保留原生成 action;生成任务内部自动抠图仍属于同源后处理,不提升为独立用户 action。
- 任何会移除画布图层的入口,包括删除、右键剪切和素材库删除关联素材,都必须同步清理该图层关联的生成面板和派生状态,不得在保存或刷新后恢复成孤立占位。
@@ -125,7 +125,7 @@
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色重绘遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
- 一次生成任务产生多个可复用产物时,已实际生成的产物都必须由后端登记为项目资源并随同一次完成快照加入画布,不能由前端临时追加。角色形象、图标 spritesheet 和 UI 素材提取在透明背景处理正常成功时同时回填纯色背景原图与透明后处理结果,UI 素材提取继续一并回填拆分成功的素材;`generatedLayerId` 锚定透明后处理主结果,附属产物从主结果右侧开始错开放置。透明背景处理最终失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图,图标和 UI 也不继续拆分;角色改造遵循同一规则。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。
- 多产物任务的可恢复中间产物还必须进入账号素材库,未传 `assetFolderId` 时落默认“项目”文件夹,并在抠图、尺寸恢复、抽帧或拆分前完成登记。图片修改保存模型对齐尺寸的原始输出;角色动作把绿幕预览视频保存为一个素材,逐帧绿幕源图只保留在同一任务 OSS 路径,避免素材库一次新增 32 至 48 张帧图。普通图片、去背景和音频等没有独立上游中间产物的任务不重复复制最终结果。
- 图标和 UI 图集自动拆分只在透明图集成功后执行,属于非阻断附加动作;识别或切片持久化失败时整张透明图集仍完成并回填,前端通过 `sliceWarning` toast 提示用户可手动重试。透明背景最终失败使用通用 `warning.code/reason`,该情况不会进入拆分,因此与 `sliceWarning` 互斥;风格归一化或像素规整产生的通用 `warning` 则可与 `sliceWarning` 并存,inline 与队列两条链路都必须把两者拼成同一条提示展示,不得只取通用告警。`sliceWarning` 只表示透明图集成功但自动拆分失败,其 `reason` 原始契约保持不变。inline 响应、worker 队列终态和刷新后的任务列表必须使用同一 warning 语义,不能把已完成或降级完成的任务标记为失败。
- 画布顶部的生成 / 参考图选择 warning toast 保留手动关闭按钮,并在每次 warning 事件进入显示态后 `3` 秒自动消失,避免一次错误提示持续遮挡画布。同样文案在未消失时再次触发也必须重新计时,不能沿用上一次事件的剩余时间。
@@ -194,12 +194,12 @@
- 图片快速编辑底部左侧展示比例 / 尺寸组合选择,右侧展示模型选择和 `修改` 按钮;原图已登记业务 ID 作为 `sourceReferenceId`,红框序号标注图作为内部辅助 `referenceImageSrcs`,不展示额外参考图条。单个拆分图标不展示快速编辑入口,完整图标图集与图标规范仍可快速编辑。
- 快速编辑打开后画布自动缩放平移到原图完整展示,并让面板位于原图下方且不遮挡原图;原图右侧出现竖向矩形 / 椭圆 / 画笔自由框选按钮。进入快速编辑不默认启用框选,点击工具启用并保持高亮,再点同一工具取消;完成框选后画布红色细框显示连续序号,输入框同步追加 `对N号红色圈选框里的内容做以下修改:`
- 快速编辑提交前保留提示词里对原图的 `原图``当前图片``当前图``图1` 引用,不再改写成 `图N`
- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图,以及从生成型图片编辑 V2 快照恢复且在面板中可见的附加参考图,均作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。
- 快速编辑提交给后端时主来源只使用原图已登记的 `sourceReferenceId`;已绘制红框和序号的标注图作为辅助 `referenceImageSrcs`,不得冒充目标图层主来源。`image.edit` 不通过“改造”恢复历史附加引用。
- 生成中的占位图聚焦后可用 `Delete` / `Backspace` 删除;删除后异步结果不再落回画布,也不显示额外删除 UI。
- 快速编辑不创建生成中占位图;提交后当前面板显示修改中,异步结果只允许回填到源图。
- 生成视频 / 角色形象 / 角色动作 / 音效 / 背景音乐新建后,画布占位空白样式和右上角标签均与对应生成类型一致,不再统一使用图片占位 icon。
- 新建空白待生成占位的尺寸必须和面板参数一致;图片类修改比例 / 尺寸、视频修改清晰度后,画布空白占位同步变更且保持中心点。
- 点击角色图只选中图层并显示工具栏,不自动弹出重绘、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击角色图只选中图层并显示工具栏,不自动弹出改造、快速编辑或角色动画面板;点击工具栏或右键菜单中的 `生成动画` 才创建角色动作占位和面板。
- 点击 UI 设计图只选中图层并显示工具栏;工具栏在 `去除背景按钮` 后显示 `提取素材`,点击后画布自动缩放平移到素材完整展示,并在素材下方显示 UI 素材提取面板。UI 素材提取默认启用矩形框选,右侧工具栏与快速编辑统一,当前启用工具按钮保持高亮,点击同一工具可取消启用态;面板提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选截图预览、固定模型 `gpt-image-2`、计划规格和提取按钮泥点,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。用户至少框选一个区域后才能点击 `提取`,前端把红色轮廓绘入原图作为参考图,再固定用自动决策纯色背景素材提取提示词生成 spritesheet。框选数量不超过阈值时提交 `1:1·1K` 参数,超过阈值时提交 `1:1·2K` 参数;后端按 gpt-image-2 对应尺寸计算扣费,保存纯色背景源图后调用 BgFilter 按默认抠图模型 `birefnet` 透明化。正常透明化成功时复用图标素材拆分流程,把透明 spritesheet 图集和拆分成功的素材放到画布;透明背景处理最终失败时只把 provider 原图放到画布,不继续拆分,并显示通用 warning。
- 生成游戏音效面板底部不显示字段标题,左下角只有一个时长参数按钮,选项为 Vidu duration `2-10` 秒;右下角固定模型胶囊显示 `Vidu` 并紧贴生成按钮。
- 生成游戏背景音乐面板右下角固定模型胶囊显示 `Suno` 并紧贴生成按钮;`make_instrumental` 不在 UI 中展示。
@@ -371,6 +371,11 @@ describe('ImageCanvasContextMenusView', () => {
mediaType: 'audio' as const,
assetKind: 'background-music' as const,
},
{
title: '视频',
mediaType: 'video' as const,
assetKind: 'video' as const,
},
])('hides quick edit from both menus for $title', (overrides) => {
const layer = createLayer(overrides);
renderContextMenus({
@@ -1795,7 +1795,6 @@ export function ImageCanvasGenerationComposerView({
{quickEditPanel && quickEditSourceLayer && quickEditPanelStyle ? (
<ImageCanvasQuickEditPanelView
panel={quickEditPanel}
sourceLayer={quickEditSourceLayer}
style={quickEditPanelStyle}
referenceButtonRef={generationReferenceButtonRef}
isReferenceMenuOpen={isGenerationReferenceMenuOpen}
@@ -964,9 +964,16 @@ describe('ImageCanvasGenerationModel', () => {
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'video',
assetKind: null,
}),
).toBe(false);
expect(
isQuickEditSupportedLayer({
...buildSourceLayer(),
mediaType: 'image',
assetKind: 'video',
}),
).toBe(true);
).toBe(false);
});
it('adds reference image semantics and snapshots for spec generation references', () => {
@@ -712,7 +712,6 @@ export function buildQuickEditModelOptions(currentModel: string) {
const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set<CanvasLayer['mediaType']>([
undefined,
'image',
'video',
]);
const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
@@ -725,7 +724,6 @@ const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
'publication-material',
'ui-design',
'scene',
'video',
]);
export function isQuickEditSupportedLayer(layer: CanvasLayer) {
@@ -424,6 +424,30 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('rejects persisted video quick-edit dialogs during submission planning', () => {
const sourceLayer = createLayer({
title: '历史视频',
src: 'https://assets.example.test/history.mp4',
mediaType: 'video',
assetKind: 'video',
originalWidth: 854,
originalHeight: 480,
});
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'quick-edit',
prompt: '把当前画面改成雨天',
status: 'idle',
sourceLayerId: sourceLayer.id,
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
}),
).toThrow('当前素材类型不支持快速编辑');
});
it('does not send image style from an incomplete quick-edit snapshot', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -54,6 +54,7 @@ import {
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isQuickEditSupportedLayer,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
@@ -577,6 +578,9 @@ export function buildImageGenerationSubmissionPlan({
if (!sourceLayer) {
throw new Error('未找到要改造的原图');
}
if (!isQuickEditSupportedLayer(sourceLayer)) {
throw new Error('当前素材类型不支持快速编辑');
}
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
const normalizedQuickEditPrompt = basePrompt;
const requestedImageModel = normalizeEditorImageModel(
@@ -6,30 +6,12 @@ import { describe, expect, it, vi } from 'vitest';
import { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
import type {
CanvasLayer,
CharacterReferenceImage,
QuickEditPanelState,
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasQuickEditPanelView } from './ImageCanvasQuickEditPanelView';
function createLayer(): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '源图',
src: 'data:image/png;base64,c291cmNl',
x: 0,
y: 0,
width: 320,
height: 180,
originalWidth: 320,
originalHeight: 180,
zIndex: 1,
sourceType: 'uploaded',
};
}
function QuickEditPanelHarness({
initialPanel,
onSubmit = vi.fn(),
@@ -50,7 +32,6 @@ function QuickEditPanelHarness({
<div>
<ImageCanvasQuickEditPanelView
panel={panel}
sourceLayer={createLayer()}
style={{ left: 10, top: 20 }}
referenceButtonRef={referenceButtonRef}
isReferenceMenuOpen={isReferenceMenuOpen}
@@ -8,7 +8,6 @@ import {
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
import type {
CanvasLayer,
GenerateDialogState,
QuickEditPanelState,
UploadTarget,
@@ -20,7 +19,6 @@ import {
export type ImageCanvasQuickEditPanelViewProps = {
panel: QuickEditPanelState;
sourceLayer: CanvasLayer;
style: CSSProperties;
referenceButtonRef?: RefObject<HTMLButtonElement | null>;
isReferenceMenuOpen?: boolean;
@@ -92,7 +90,6 @@ function syncPanelFromDialogUpdate(
export function ImageCanvasQuickEditPanelView({
panel,
sourceLayer,
style,
referenceButtonRef,
isReferenceMenuOpen = false,
@@ -106,7 +103,6 @@ export function ImageCanvasQuickEditPanelView({
hasPendingImageReferenceUploads = false,
onSubmit,
}: ImageCanvasQuickEditPanelViewProps) {
const isImageQuickEdit = sourceLayer.mediaType !== 'video';
const referenceCount = panel.quickEditReferences?.length ?? 0;
const canAddReferences =
referenceCount <
@@ -148,7 +144,7 @@ export function ImageCanvasQuickEditPanelView({
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
onSubmit={onSubmit}
dialogLabel="快速编辑图片"
includeDimensions={isImageQuickEdit}
includeDimensions={true}
includeModel={true}
includeReferences={false}
promptLabel="快速编辑提示词"
@@ -444,7 +444,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
within(videoToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['快速编辑', '改造', '下载按钮']);
).toEqual(['改造', '下载按钮']);
cleanup();
renderSelectedToolbar({
@@ -337,14 +337,12 @@ function SubmissionWorkflowHarness({
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState: initialQuickEditSelectionState,
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog: activeCanvasDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog: dialogs.setGenerateDialog,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
@@ -1496,47 +1494,15 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
});
it('submits video quick edits through the video API with the source video', async () => {
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({
prompt: '把当前图改成雨天',
project: {
projectId: 'editor-project-1',
title: '项目',
coverImageSrc: null,
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
createdAt: '2026-06-27T00:00:00.000Z',
updatedAt: '2026-06-27T00:00:00.000Z',
},
asset: {
assetId: 'asset-video-quick-edit',
folderId: 'user-1:asset-folder:project',
label: '源视频 快速编辑',
imageSrc: 'https://assets.example.test/generated-video.mp4',
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
width: 854,
height: 480,
sourceType: 'generated',
assetKind: 'video',
},
}),
);
it('rejects direct video quick-edit submission without calling generation APIs', () => {
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
upsertGeneratedAsset={upsertGeneratedAsset}
initialLayers={[
createLayer({
title: '源视频',
src: 'data:video/mp4;base64,c291cmNlLXZpZGVv',
src: 'https://assets.example.test/source-video.mp4',
mediaType: 'video',
assetKind: 'video',
model: 'gpt-image-2',
originalWidth: 854,
originalHeight: 480,
width: 427,
@@ -1549,66 +1515,58 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
size: '854x480',
model: 'gpt-image-2',
status: 'idle',
quickEditReferences: [
{
id: 'ref-style',
label: '风格参考',
src: 'https://assets.example.test/style.png',
},
],
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' }));
await waitFor(() => {
expect(generateEditorVideoMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '把当前图改成雨天',
model: 'seedance2.0-fast',
aspectRatio: '16:9',
durationSeconds: 4,
resolution: '480p',
mode: 'std',
sound: 'off',
referenceVideoSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
generationInputs: expect.objectContaining({
version: 2,
action: 'video.generate',
references: [
{
id: 'videoReference',
title: '参考视频 1',
label: '源视频',
refType: 'project-resource',
refId: 'resource-source',
},
],
}),
assetFolderId: 'project',
assetLabel: '源视频 快速编辑',
}),
);
});
expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty(
'referenceImageSrcs',
);
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
expect(screen.getByTestId('quick-edit').textContent).toContain(
'failed:把当前图改成雨天:当前素材类型不支持快速编辑',
);
});
it('rejects a hydrated video quick-edit dialog before calling generation APIs', async () => {
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({
title: '历史视频',
src: 'https://assets.example.test/history.mp4',
mediaType: 'video',
assetKind: 'video',
originalWidth: 854,
originalHeight: 480,
width: 427,
height: 240,
}),
]}
initialDialog={{
id: 'generation-dialog-hydrated-video-quick-edit',
mode: 'quick-edit',
prompt: '把当前画面改成雨天',
status: 'idle',
composerOpen: true,
sourceLayerId: 'layer-source',
}}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'editor-project-1' }),
);
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
expect.objectContaining({
assetId: 'asset-video-quick-edit',
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
}),
expect(screen.getByTestId('tracked-dialog').textContent).toContain(
'quick-edit:把当前画面改成雨天:failed:open:当前素材类型不支持快速编辑',
);
});
expect(editEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorVideoMock).not.toHaveBeenCalled();
});
it('uploads inline video references and keeps image objectKey and asset URLs', async () => {
@@ -50,7 +50,6 @@ import type {
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { createQuickEditGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
import {
applyImageEditResultToSourceLayer,
createAudioResultLayer,
@@ -63,12 +62,9 @@ import {
buildCharacterAnimationGenerationInputs,
buildQuickEditGenerationInputs,
buildUiDesignAssetExtractionGenerationInputs,
buildVideoGenerationInputs,
createCanvasLayerReference,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_VIDEO_MODEL,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
isCanvasGenerationDialog,
@@ -77,7 +73,6 @@ import {
resolveEditorImageGenerationPixelSize,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import { chooseGenerationPlacement } from './ImageCanvasGenerationPlacementModel';
import {
buildCharacterAnimationSubmissionPlan,
buildIconSpritesheetGenerationSubmissionPlan,
@@ -338,7 +333,6 @@ type GenerationSubmissionWorkflowOptions = {
quickEditPanel: QuickEditPanelState | null;
quickEditSourceLayer: CanvasLayer | null;
quickEditSelectionState: UiAssetExtractionState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
setQuickEditPanel: Dispatch<SetStateAction<QuickEditPanelState | null>>;
characterAnimationPanel: CharacterAnimationPanelState | null;
characterAnimationDialog: CanvasGenerationDialogState | null;
@@ -347,9 +341,6 @@ type GenerationSubmissionWorkflowOptions = {
SetStateAction<CharacterAnimationPanelState | null>
>;
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => string;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
@@ -650,10 +641,6 @@ function buildRightSideCanvasCompletionPlaceholder(
};
}
function isVideoQuickEditSource(layer: CanvasLayer) {
return layer.mediaType === 'video' || layer.assetKind === 'video';
}
export function useImageCanvasGenerationSubmissionWorkflow({
layers,
canvasSize,
@@ -662,14 +649,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
canvasGenerationDialogs,
setQuickEditPanel,
characterAnimationPanel,
characterAnimationDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
@@ -1451,63 +1436,22 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const normalizedQuickEditModel = normalizeEditorImageModel(
quickEditPanel.model,
);
const quickEditOutputSize = isVideoQuickEditSource(quickEditSourceLayer)
? {
width: quickEditSourceLayer.originalWidth,
height: quickEditSourceLayer.originalHeight,
}
: resolveEditorImageGenerationPixelSize({
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
});
const quickEditOutputSize = resolveEditorImageGenerationPixelSize({
model: normalizedQuickEditModel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
});
const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`;
const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片';
let quickEditDialogId: string | undefined;
const shouldUseCanvasPlaceholder =
isVideoQuickEditSource(quickEditSourceLayer);
if (shouldUseCanvasPlaceholder) {
const quickEditDraft = createQuickEditGenerationDialogDraft({
sourceLayer: quickEditSourceLayer,
prompt: normalizedPrompt,
status: 'generating',
references: quickEditReferences,
model: quickEditPanel.model,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
frame: {
width: quickEditOutputSize.width,
height: quickEditOutputSize.height,
},
assetLabel: quickEditResultTitle,
});
const quickEditPlacement = quickEditDraft.placeholder
? chooseGenerationPlacement({
canvasSize,
viewport,
frame: quickEditDraft.placeholder,
layers,
generationDialogs: canvasGenerationDialogs,
})
: quickEditDraft.placeholder;
quickEditDialogId = openCanvasGenerationDialog({
...quickEditDraft,
// 中文注释:视频快速编辑仍落到独立视频结果占位,图片快速编辑直接替换源图。
placeholder: quickEditPlacement,
});
setQuickEditPanel(null);
selectSingleLayer(null);
} else {
setQuickEditPanel({
...quickEditPanel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
size: quickEditSize,
prompt: normalizedPrompt,
status: 'generating',
errorMessage: undefined,
});
}
setQuickEditPanel({
...quickEditPanel,
aspectRatio: quickEditAspectRatio,
imageSize: quickEditImageSize,
size: quickEditSize,
prompt: normalizedPrompt,
status: 'generating',
errorMessage: undefined,
});
try {
const extraReferenceImageSrcs = await Promise.all(
@@ -1515,105 +1459,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
resolveEditorGenerationMediaReference(reference, 'image', projectId),
),
);
const quickEditFrame = quickEditDialogId
? getGeneratingDialogPlaceholder({
id: quickEditDialogId,
mode: 'quick-edit',
prompt: normalizedPrompt,
status: 'generating',
})
: undefined;
const quickEditCanvasCompletionPlaceholder =
quickEditFrame ??
buildRightSideCanvasCompletionPlaceholder(quickEditSourceLayer, {
width: quickEditOutputSize.width,
height: quickEditOutputSize.height,
originalWidth: quickEditOutputSize.width,
originalHeight: quickEditOutputSize.height,
});
if (isVideoQuickEditSource(quickEditSourceLayer)) {
const videoModel = DEFAULT_VIDEO_MODEL;
const videoResolution = '480p' as const;
const videoDurationSeconds = 4 as const;
const sourceVideoSrc = await resolveEditorGenerationMediaReference(
quickEditSourceLayer,
'video',
projectId,
{ allowRegisteredIds: false },
);
const generationInputs = buildVideoGenerationInputs(
normalizedPrompt,
[createCanvasLayerReference(quickEditSourceLayer)],
{
model: videoModel,
aspectRatio: '16:9',
resolution: videoResolution,
durationSeconds: videoDurationSeconds,
sound: 'off',
},
);
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorVideo({
prompt: normalizedPrompt,
model: videoModel,
aspectRatio: '16:9',
durationSeconds: videoDurationSeconds,
resolution: videoResolution,
mode: 'std',
sound: 'off',
...(extraReferenceImageSrcs.length
? { referenceImageSrcs: extraReferenceImageSrcs }
: {}),
referenceVideoSrcs: [sourceVideoSrc],
projectId,
generationInputs,
sourceResourceId: quickEditSourceLayer.resourceId,
assetKind: quickEditSourceLayer.assetKind,
assetFolderId,
assetLabel: quickEditResultTitle,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: quickEditDialogId,
title: quickEditResultTitle,
placeholder: quickEditCanvasCompletionPlaceholder,
},
}
: {}),
}),
onWalletBalanceMayHaveChanged,
);
if (
await applyQueuedEditorGenerationProject(
generated,
projectId,
applyProjectSnapshot,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
)
) {
return;
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addVideoResultLayer(
generated,
quickEditResultTitle,
generationInputs,
quickEditFrame,
quickEditDialogId,
{
sourceLayer: quickEditSourceLayer,
assetKind: quickEditSourceLayer.assetKind,
},
);
return;
}
const quickEditSelectionMarks =
quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id
? quickEditSelectionState.marks
@@ -1700,22 +1545,10 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generated,
quickEditSourceLayer,
generationInputs,
quickEditDialogId,
undefined,
quickEditResultTitle,
);
} catch (error) {
if (quickEditDialogId) {
updateCanvasGenerationDialogById(
quickEditDialogId,
(currentDialog) => ({
...currentDialog,
prompt: normalizedPrompt,
status: 'failed',
composerOpen: false,
errorMessage: resolveImageGenerationErrorMessage(error),
}),
);
}
setQuickEditPanel({
...quickEditPanel,
aspectRatio: quickEditAspectRatio,
@@ -1727,26 +1560,16 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
}
}, [
addVideoResultLayer,
applyQuickEditResultToSourceLayer,
applyProjectSnapshot,
canvasGenerationDialogs,
canvasSize,
getGeneratingDialogPlaceholder,
layers,
openCanvasGenerationDialog,
projectId,
assetFolderId,
upsertGeneratedAsset,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
selectSingleLayer,
setActiveSidebarPanel,
setActiveTool,
setQuickEditPanel,
updateCanvasGenerationDialogById,
viewport,
onGenerationWarning,
onQueuedGenerationTask,
onWalletBalanceMayHaveChanged,
@@ -1558,6 +1558,28 @@ describe('useImageCanvasGenerationWorkflow', () => {
);
});
it('does not open quick edit programmatically for video layers', () => {
render(
<GenerationWorkflowHarness
initialLayers={[
createLayer({
title: '源视频',
src: '/generated/video.mp4',
mediaType: 'video',
assetKind: 'video',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('selected').textContent).toBe('-');
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-');
});
it('calculates character animation button price from the model pricing config', () => {
render(<GenerationWorkflowHarness />);
@@ -3871,14 +3871,12 @@ export function useImageCanvasGenerationWorkflow({
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
canvasGenerationDialogs,
setQuickEditPanel,
characterAnimationPanel: effectiveCharacterAnimationPanel,
characterAnimationDialog,
characterAnimationSourceLayer,
setCharacterAnimationPanel,
setGenerateDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,