From 1e18965adb95936209c13dadf1147159c408a45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 22:45:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E5=B7=A5=E5=85=B7=E6=81=A2=E5=A4=8D=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改造按 V2 原 action 恢复对应工具,image.edit 保持原位快速编辑且不提供改造入口 旧版图片按 assetKind 与 mediaType 选择工具,未知图片回落默认生成工具并保留旧版告警 移除 QuickEditPanelState 的 redraw 模式、旧提交分支及无调用结果层构造器 补充能力显隐、恢复面板、旧 source 缺失与工作流回归测试 同步更新图片画布编辑器技术方案 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../image-editor/ImageCanvasEditorTypes.ts | 14 +- .../image-editor/ImageCanvasEditorView.tsx | 7 +- .../ImageCanvasGenerationDialogModel.test.ts | 102 +++--- .../ImageCanvasGenerationDialogModel.ts | 78 ++--- .../ImageCanvasGenerationLayerModel.test.ts | 52 --- .../ImageCanvasGenerationLayerModel.ts | 71 ---- .../ImageCanvasQuickEditPanelView.test.tsx | 30 +- .../ImageCanvasQuickEditPanelView.tsx | 37 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 6 +- .../image-editor/ImageCanvasUploadModel.ts | 2 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 71 ---- ...ImageCanvasGenerationSubmissionWorkflow.ts | 323 ++++-------------- .../useImageCanvasGenerationWorkflow.test.tsx | 57 +++- .../useImageCanvasGenerationWorkflow.ts | 61 ++-- 15 files changed, 242 insertions(+), 671 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index f1b999b56..57dac1f1d 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 -- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 2ef85dda5..56f3937a4 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -130,7 +130,10 @@ export type CanvasLayer = { id: string; resourceId: string; resourcePersistenceState?: - 'pending' | 'registered' | 'self-contained-local' | 'unresolved-local'; + | 'pending' + | 'registered' + | 'self-contained-local' + | 'unresolved-local'; title: string; src: string; mediaType?: CanvasMediaType; @@ -263,7 +266,6 @@ export type GenerateDialogState = { assetLabel?: string; status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; composerOpen?: boolean; - isRedrawAsNewArtifact?: boolean; sourceLayerId?: string; generatedLayerId?: string; specType?: SpecGenerationType; @@ -442,7 +444,6 @@ export type CanvasContextMenuState = export type QuickEditPanelState = { referenceUploadContextId?: string; - mode?: 'quick-edit' | 'redraw'; sourceLayerId: string; prompt: string; assetLabel?: string; @@ -469,7 +470,12 @@ export type CropExpandPanelState = { }; export type CropExpandRatioValue = - 'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16'; + | 'free' + | '1:1' + | '4:3' + | '3:4' + | '16:9' + | '9:16'; export type CropExpandResizeHandle = | 'left' diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 288ceca5e..3ff601c19 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1169,8 +1169,9 @@ export function ImageCanvasEditorView({ imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), - generationInputsHydrationState: - hydrateCanvasGenerationInputsResult(asset.generationInputs).state, + generationInputsHydrationState: hydrateCanvasGenerationInputsResult( + asset.generationInputs, + ).state, }, ]); setAssetFolders((currentFolders) => @@ -1750,7 +1751,7 @@ export function ImageCanvasEditorView({ : null; } if (target === 'quick-edit-reference') { - return quickEditPanel && quickEditPanel.mode !== 'redraw' + return quickEditPanel ? { label: '快速编辑参考图', currentCount: imageReferenceCount( diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 235b3f282..7782a2ba6 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -21,10 +21,10 @@ import { createGenerateDialogDraft, createIconGenerationDialogDraft, createLayerGenerationDialogDraft, + createLegacyImageRedrawGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditGenerationDialogDraft, createQuickEditPanelDraft, - createRedrawPanelDraft, createSameSourceGenerationDialogDraft, createSceneGenerationDialogDraft, createSoundEffectGenerationDialogDraft, @@ -469,7 +469,6 @@ describe('ImageCanvasGenerationDialogModel', () => { imageSize: '1K', }); expect(createQuickEditPanelDraft(sourceLayer)).toEqual({ - mode: 'quick-edit', sourceLayerId: 'layer-source', prompt: '', size: '1024x768', @@ -479,7 +478,13 @@ describe('ImageCanvasGenerationDialogModel', () => { quickEditReferences: [], status: 'idle', }); - expect(createRedrawPanelDraft(sourceLayer).prompt).toBe('原图提示'); + expect( + createLegacyImageRedrawGenerationDialogDraft({ + sourceLayer, + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }), + ).toMatchObject({ mode: 'generate', prompt: '原图提示' }); expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({ sourceLayerId: 'layer-source', promptText: '', @@ -590,21 +595,23 @@ describe('ImageCanvasGenerationDialogModel', () => { for (const testCase of cases) { expect( - createRedrawPanelDraft( - createLayer({ + createLegacyImageRedrawGenerationDialogDraft({ + sourceLayer: createLayer({ prompt: '【系统内置】完整拼接提示词,包含默认规则和模型约束。', generationInputs: { fields: testCase.fields, references: [], }, }), - ).prompt, + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }).prompt, ).toBe(testCase.expectedPrompt); } expect( - createRedrawPanelDraft( - createLayer({ + createLegacyImageRedrawGenerationDialogDraft({ + sourceLayer: createLayer({ prompt: '【系统内置】固定素材提取提示词', generationInputs: { fields: [ @@ -618,7 +625,9 @@ describe('ImageCanvasGenerationDialogModel', () => { references: [], }, }), - ).prompt, + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + }).prompt, ).toBe(''); }); @@ -1269,7 +1278,7 @@ describe('ImageCanvasGenerationDialogModel', () => { }); }); - it('restores quick-edit references and the persisted image model', () => { + it('does not restore in-place image edits as remodel generators', () => { const editSource = createLayer({ id: 'layer-edit-source', resourceId: 'resource-edit-source', @@ -1279,55 +1288,44 @@ describe('ImageCanvasGenerationDialogModel', () => { resourceId: 'resource-style-reference', title: '风格参考', }); + const editResult = createLayer({ + id: 'layer-edit-result', + resourceId: 'resource-edit-result', + sourceType: 'generated', + generationInputs: { + version: 2, + action: 'image.edit', + fields: [ + { id: 'prompt', title: '提示词', value: '改成雨天' }, + { id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 }, + { id: 'aspectRatio', title: '比例', value: '16:9' }, + { id: 'imageSize', title: '尺寸', value: '2K' }, + ], + references: [ + { + id: 'source', + title: '原图', + refType: 'project-resource', + refId: 'resource-edit-source', + }, + { + id: 'reference', + title: '参考图 1', + refType: 'project-resource', + refId: 'resource-style-reference', + }, + ], + }, + }); expect( createLayerGenerationDialogDraft({ - sourceLayer: createLayer({ - id: 'layer-edit-result', - resourceId: 'resource-edit-result', - sourceType: 'generated', - generationInputs: { - version: 2, - action: 'image.edit', - fields: [ - { id: 'prompt', title: '提示词', value: '改成雨天' }, - { id: 'model', title: '模型', value: IMAGE_MODEL_NANOBANANA2 }, - { id: 'aspectRatio', title: '比例', value: '16:9' }, - { id: 'imageSize', title: '尺寸', value: '2K' }, - ], - references: [ - { - id: 'source', - title: '原图', - refType: 'project-resource', - refId: 'resource-edit-source', - }, - { - id: 'reference', - title: '参考图 1', - refType: 'project-resource', - refId: 'resource-style-reference', - }, - ], - }, - }), + sourceLayer: editResult, availableLayers: [editSource, styleReference], canvasSize: { width: 960, height: 720 }, viewport: { x: 0, y: 0, scale: 1 }, }), - ).toMatchObject({ - mode: 'quick-edit', - prompt: '改成雨天', - imageModel: IMAGE_MODEL_NANOBANANA2, - aspectRatio: '16:9', - imageSize: '2K', - generationReferences: [ - expect.objectContaining({ - id: 'canvas-layer-style-reference', - label: '风格参考', - }), - ], - }); + ).toBeNull(); }); it('uses the V2 source reference ahead of a conflicting legacy animation source', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index b13778570..9c2bcf69e 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -1315,40 +1315,6 @@ function createNormalizedGenerationDialogDraft({ : null; } - if (inputs.action === 'image.edit') { - const source = resolveNormalizedReferences( - sourceLayer, - 'source', - availableLayers, - )[0]; - const sourceLayerForEdit = source - ? (availableLayers ?? []).find( - (layer) => createCanvasLayerReference(layer).id === source.id, - ) - : undefined; - if (!sourceLayerForEdit) { - return null; - } - const size = resolveEditorImageGenerationPixelSize({ - model: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), - aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), - imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), - }); - return { - ...createQuickEditGenerationDialogDraft({ - sourceLayer: sourceLayerForEdit, - prompt, - references, - model: getNormalizedString(sourceLayer, 'model', DEFAULT_IMAGE_MODEL), - aspectRatio: getNormalizedString(sourceLayer, 'aspectRatio', '1:1'), - imageSize: getNormalizedString(sourceLayer, 'imageSize', '1K'), - frame: size, - }), - composerOpen: true, - isRedrawAsNewArtifact: true, - }; - } - return null; } @@ -1956,7 +1922,6 @@ export function createQuickEditPanelDraft( dimensionOptions.imageSizes[0] ?? '1K'); return { - mode: 'quick-edit', sourceLayerId: sourceLayer.id, prompt: '', size: formatImageSizeValue( @@ -2021,20 +1986,29 @@ export function createQuickEditGenerationDialogDraft({ }; } -export function createRedrawPanelDraft( - sourceLayer: CanvasLayer, - options: { - imageModel?: string; - aspectRatio?: string; - imageSize?: string; - } = {}, -): QuickEditPanelState { - return { - ...createQuickEditPanelDraft(sourceLayer, options), - mode: 'redraw', - model: normalizeEditorImageModel(options.imageModel ?? sourceLayer.model), - prompt: resolveUserGenerationPromptSnapshot(sourceLayer), - }; +export function createLegacyImageRedrawGenerationDialogDraft({ + sourceLayer, + canvasSize, + viewport, + sourceDialog, +}: { + sourceLayer: CanvasLayer; + canvasSize: CanvasSize; + viewport: CanvasViewport; + sourceDialog?: CanvasGenerationDialogState | null; +}): Omit { + return placeDraftBesideSourceLayer( + restoreSharedImageOptions( + { + ...createGenerateDialogDraft({ canvasSize, viewport }), + prompt: resolveUserGenerationPromptSnapshot(sourceLayer), + sourceLayerId: sourceLayer.id, + }, + sourceLayer, + sourceDialog, + ), + sourceLayer, + ); } export function createCharacterAnimationPanelDraft( @@ -2261,11 +2235,7 @@ export function appendQuickEditReference( panel: QuickEditPanelState | null, layer: CanvasLayer, ): QuickEditPanelState | null { - if ( - !panel || - panel.mode === 'redraw' || - getReferenceMediaType(layer) !== 'image' - ) { + if (!panel || getReferenceMediaType(layer) !== 'image') { return panel; } return { diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 10e8464cd..9d5e8a4ef 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -10,7 +10,6 @@ import { createCharacterAnimationResultLayer, createGeneratedResultLayer, createIconSpritesheetResultLayers, - createQuickEditResultLayer, createVideoResultLayer, } from './ImageCanvasGenerationLayerModel'; @@ -232,57 +231,6 @@ describe('ImageCanvasGenerationLayerModel', () => { }); }); - it('creates quick edit layers centered on the generating placeholder', () => { - const sourceLayer = createSourceLayer(); - const layer = createQuickEditResultLayer({ - generated: createGenerated({ width: 1536, height: 1024 }), - generatedIndex: 10, - sourceLayer, - generationInputs: createGenerationInputs(), - frame: { - x: 600, - y: 300, - width: 320, - height: 240, - originalWidth: 1536, - originalHeight: 1024, - }, - }); - - expect(layer).toMatchObject({ - id: 'layer-quick-edit-10', - x: -8, - y: -92, - width: 1536, - height: 1024, - }); - }); - - it('creates quick edit layers with source group and asset kind preserved', () => { - const sourceLayer = createSourceLayer(); - const layer = createQuickEditResultLayer({ - generated: createGenerated({ width: 1536, height: 1024 }), - generatedIndex: 9, - sourceLayer, - generationInputs: createGenerationInputs(), - }); - - expect(layer).toMatchObject({ - id: 'layer-quick-edit-9', - resourceId: 'local-resource-quick-edit-9', - title: '源图 快速编辑', - x: 472, - y: 140, - width: 1536, - height: 1024, - originalWidth: 1536, - originalHeight: 1024, - sourceResourceId: 'resource-source', - groupId: 'group-a', - assetKind: 'spec', - }); - }); - it('applies quick edits to the source layer in place', () => { const sourceLayer = createSourceLayer({ width: 320, diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index ee0806117..5dc1e1a31 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -32,16 +32,6 @@ type GeneratedResultLayerOptions = { generationInputs?: CanvasGenerationInputs; }; -type QuickEditResultLayerOptions = { - generated: EditorImageGenerationResult; - generatedIndex: number; - sourceLayer: CanvasLayer; - generationInputs: CanvasGenerationInputs; - mode?: 'quick-edit' | 'redraw'; - frame?: GenerateDialogState['placeholder']; - title?: string; -}; - type IconSpritesheetResultLayerOptions = { generated: EditorIconSpritesheetGenerationResult; iconResults?: EditorIconSpritesheetIconResult[] | null; @@ -183,67 +173,6 @@ export function createGeneratedResultLayer({ ); } -export function createQuickEditResultLayer({ - generated, - generatedIndex, - sourceLayer, - generationInputs, - mode = 'quick-edit', - frame, - title, -}: QuickEditResultLayerOptions): CanvasLayer { - const originalWidth = - frame?.originalWidth || - generated.width || - sourceLayer.originalWidth || - 1024; - const originalHeight = - frame?.originalHeight || - generated.height || - sourceLayer.originalHeight || - 1024; - const { width, height } = resolveLayerResolutionSize( - originalWidth, - originalHeight, - { - width: sourceLayer.width, - height: sourceLayer.height, - }, - ); - const frameX = - frame && frame.width > 0 - ? frame.x + frame.width / 2 - width / 2 - : undefined; - const frameY = - frame && frame.height > 0 - ? frame.y + frame.height / 2 - height / 2 - : undefined; - - return applyGeneratedMetadata( - { - id: `layer-quick-edit-${generatedIndex}`, - resourceId: `local-resource-quick-edit-${generatedIndex}`, - title: - title ?? - `${sourceLayer.title} ${mode === 'redraw' ? '重绘' : '快速编辑'}`, - src: generated.imageSrc, - x: frameX ?? sourceLayer.x + sourceLayer.width + 32, - y: frameY ?? sourceLayer.y, - width, - height, - originalWidth, - originalHeight, - zIndex: generatedIndex + 10, - sourceType: generated.sourceType, - sourceResourceId: sourceLayer.resourceId, - groupId: sourceLayer.groupId, - assetKind: sourceLayer.assetKind, - }, - generated, - generationInputs, - ); -} - export function applyImageEditResultToSourceLayer({ generated, sourceLayer, diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx index 211c9f4c3..a7d9e6b28 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx @@ -100,10 +100,7 @@ describe('ImageCanvasQuickEditPanelView', () => { />, ); - setPlainTextEditorValue( - screen.getByLabelText('快速编辑提示词'), - '新提示', - ); + setPlainTextEditorValue(screen.getByLabelText('快速编辑提示词'), '新提示'); expect(screen.getByLabelText('当前提示词').textContent).toBe('新提示'); expect(screen.getByLabelText('当前尺寸').textContent).toBe('1024x1024'); @@ -141,9 +138,7 @@ describe('ImageCanvasQuickEditPanelView', () => { />, ); - expect(screen.getByRole('alert').textContent).toContain( - '图集快速编辑失败', - ); + expect(screen.getByRole('alert').textContent).toContain('图集快速编辑失败'); }); it('submits without rendering a standalone close button', () => { @@ -171,27 +166,6 @@ describe('ImageCanvasQuickEditPanelView', () => { ).toBeNull(); }); - it('shows configured mud point price inside redraw submit button', () => { - render( - , - ); - - expect(screen.getByRole('button', { name: '生成' }).textContent).toBe( - '生成3泥点', - ); - }); - it('hides quick edit references from the simplified panel', () => { render( diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index 7561bcfd3..3172f3999 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -98,9 +98,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { ); expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer); expect(props.onPerfectPixel).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: '删除图片' }), @@ -391,7 +389,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(screen.queryByRole('button', { name: '改造' })).toBeNull(); }); - it('hides redraw for in-place quick-edit results', () => { + it('keeps quick edit but hides redraw for in-place quick-edit results', () => { renderSelectedToolbar({ selectedLayer: createLayer({ generationInputs: { diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index 80c2730b7..02077fd62 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -285,7 +285,7 @@ export function applyQuickEditReferenceUpload({ panel: QuickEditPanelState | null; references: CharacterReferenceImage[]; }): QuickEditPanelState | null { - if (!panel || panel.mode === 'redraw') { + if (!panel) { return panel; } return { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index fb1e8ec49..95f2c5ab5 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -901,7 +901,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { render( { render( { { { render( { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); - it('submits character redraws through the character image prompt contract', async () => { - generateEditorImageMock.mockResolvedValueOnce( - createGenerated({ - prompt: '角色换成蓝色披风', - warning: { - code: 'postprocess-failed-source-preserved', - reason: '生成任务成功,后处理失败。', - }, - }), - ); - render( - , - ); - - fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); - - await waitFor(() => { - expect(generateEditorImageMock).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: '角色换成蓝色披风', - size: '1024x768', - aspectRatio: '4:3', - imageSize: '1K', - kind: 'character', - model: 'gpt-image-2', - referenceImageSrcs: ['resource-source'], - assetKind: 'character', - assetLabel: '蓝披风勇者', - canvasCompletion: expect.objectContaining({ - title: '蓝披风勇者', - }), - }), - ); - }); - await waitFor(() => { - expect(screen.getByTestId('layers').textContent).toContain( - 'layer-quick-edit-1:蓝披风勇者:resource-source:character', - ); - }); - expect(screen.getByTestId('quick-edit').textContent).toBe( - 'layer-source:idle:角色换成蓝色披风:-', - ); - expect(screen.getByTestId('generation-warning').textContent).toBe( - '生成任务成功,后处理失败。', - ); - }); - it('submits video quick edits through the video API with the source video', async () => { const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); @@ -1523,7 +1454,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }), ]} initialQuickEditPanel={{ - mode: 'quick-edit', sourceLayerId: 'layer-source', prompt: '把当前图改成雨天', size: '854x480', @@ -1683,7 +1613,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { }), ]} initialQuickEditPanel={{ - mode: 'quick-edit', sourceLayerId: 'layer-source', prompt: '让当前图跳得更高', size: '1024x1024', diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 526fe16ce..38fbc9b95 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -57,12 +57,10 @@ import { createCharacterAnimationResultLayer, createGeneratedResultLayer, createIconSpritesheetResultLayers, - createQuickEditResultLayer, createVideoResultLayer, } from './ImageCanvasGenerationLayerModel'; import { buildCharacterAnimationGenerationInputs, - buildEditGenerationInputs, buildQuickEditGenerationInputs, buildUiDesignAssetExtractionGenerationInputs, buildVideoGenerationInputs, @@ -872,81 +870,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ], ); - const addQuickEditResultLayer = useCallback( - ( - generated: Parameters[0]['generated'], - sourceLayer: CanvasLayer, - generationInputs: CanvasGenerationInputs, - mode: QuickEditPanelState['mode'] = 'quick-edit', - options: { - frame?: GenerateDialogState['placeholder']; - dialogId?: string; - title?: string; - } = {}, - ) => { - if ( - options.dialogId && - !hasCanvasGenerationDialogById(options.dialogId) - ) { - return; - } - layerCounterRef.current += 1; - const generatedIndex = layerCounterRef.current; - const nextLayer = createQuickEditResultLayer({ - generated, - generatedIndex, - sourceLayer, - generationInputs, - mode, - frame: options.frame, - title: options.title, - }); - - addGeneratedLayersToCanvas([nextLayer]); - selectSingleLayer(mode === 'redraw' ? sourceLayer.id : nextLayer.id); - setActiveSidebarPanel('layers'); - if (mode === 'redraw') { - setQuickEditPanel((currentPanel) => - currentPanel?.sourceLayerId === sourceLayer.id - ? { - ...currentPanel, - mode: 'redraw', - status: 'idle', - errorMessage: undefined, - } - : currentPanel, - ); - } else { - setQuickEditPanel(null); - setActiveTool('select'); - if (options.dialogId) { - updateCanvasGenerationDialogById( - options.dialogId, - (currentDialog) => ({ - ...currentDialog, - status: 'idle', - composerOpen: false, - generatedLayerId: nextLayer.id, - errorMessage: undefined, - }), - ); - } - } - fitLayers([sourceLayer, nextLayer], { captureHistory: false }); - }, - [ - addGeneratedLayersToCanvas, - fitLayers, - layerCounterRef, - selectSingleLayer, - setActiveSidebarPanel, - setActiveTool, - setQuickEditPanel, - updateCanvasGenerationDialogById, - hasCanvasGenerationDialogById, - ], - ); - const applyQuickEditResultToSourceLayer = useCallback( ( generated: Parameters< @@ -1507,15 +1430,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } - const panelMode = quickEditPanel.mode ?? 'quick-edit'; const quickEditResultTitle = resolveGenerationAssetLabel( quickEditPanel.assetLabel, - `${quickEditSourceLayer.title} ${panelMode === 'redraw' ? '重绘' : '快速编辑'}`, + `${quickEditSourceLayer.title} 快速编辑`, ); - const quickEditReferences = - panelMode === 'redraw' || panelMode === 'quick-edit' - ? [] - : (quickEditPanel.quickEditReferences ?? []); + const quickEditReferences: CharacterReferenceImage[] = []; const quickEditAspectRatio = quickEditPanel.aspectRatio ?? inferEditorImageAspectRatio( @@ -1542,13 +1461,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ imageSize: quickEditImageSize, }); const quickEditSize = `${quickEditOutputSize.width}x${quickEditOutputSize.height}`; - const basePrompt = - quickEditPanel.prompt.trim() || - (panelMode === 'redraw' ? '重绘图片' : '快速编辑图片'); - const normalizedPrompt = panelMode === 'redraw' ? basePrompt : basePrompt; + const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; let quickEditDialogId: string | undefined; const shouldUseCanvasPlaceholder = - panelMode === 'quick-edit' && isVideoQuickEditSource(quickEditSourceLayer); if (shouldUseCanvasPlaceholder) { const quickEditDraft = createQuickEditGenerationDialogDraft({ @@ -1594,21 +1509,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } try { - const needsExtraReferenceImageSrcs = - panelMode === 'quick-edit' || - panelMode === 'redraw' || - isVideoQuickEditSource(quickEditSourceLayer); - const extraReferenceImageSrcs = needsExtraReferenceImageSrcs - ? await Promise.all( - quickEditReferences.map((reference) => - resolveEditorGenerationMediaReference( - reference, - 'image', - projectId, - ), - ), - ) - : []; + const extraReferenceImageSrcs = await Promise.all( + quickEditReferences.map((reference) => + resolveEditorGenerationMediaReference(reference, 'image', projectId), + ), + ); const quickEditFrame = quickEditDialogId ? getGeneratingDialogPlaceholder({ id: quickEditDialogId, @@ -1625,10 +1530,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ originalWidth: quickEditOutputSize.width, originalHeight: quickEditOutputSize.height, }); - if ( - panelMode === 'quick-edit' && - isVideoQuickEditSource(quickEditSourceLayer) - ) { + if (isVideoQuickEditSource(quickEditSourceLayer)) { const videoModel = DEFAULT_VIDEO_MODEL; const videoResolution = '480p' as const; const videoDurationSeconds = 4 as const; @@ -1712,7 +1614,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } const quickEditSelectionMarks = - panelMode === 'quick-edit' && quickEditSelectionState?.sourceLayerId === quickEditSourceLayer.id ? quickEditSelectionState.marks : []; @@ -1738,85 +1639,38 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, ); } - const generationInputs = - panelMode === 'redraw' - ? buildEditGenerationInputs( - '重绘提示词', - normalizedPrompt, - quickEditSourceLayer, - { - model: normalizedQuickEditModel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - }, - ) - : buildQuickEditGenerationInputs( - '快速编辑提示词', - normalizedPrompt, - quickEditSourceLayer, - quickEditReferences, - { - model: normalizedQuickEditModel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - }, - ); - const isCharacterRedraw = - panelMode === 'redraw' && - quickEditSourceLayer.assetKind === 'character'; - const generated = - panelMode === 'quick-edit' - ? await runEditorGenerationWithWalletRefresh( - editEditorImage({ - prompt: normalizedPrompt, - sourceImageSrc: sourceReferenceImageSrc, - size: quickEditSize, - model: normalizedQuickEditModel, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - ...(extraReferenceImageSrcs.length - ? { referenceImageSrcs: extraReferenceImageSrcs } - : {}), - projectId, - assetKind: quickEditSourceLayer.assetKind, - generationInputs, - assetFolderId, - assetLabel: quickEditResultTitle, - sourceResourceId: quickEditSourceLayer.resourceId, - targetLayerId: quickEditSourceLayer.id, - }), - onWalletBalanceMayHaveChanged, - ) - : await runEditorGenerationWithWalletRefresh( - generateEditorImage({ - prompt: normalizedPrompt, - size: quickEditSize, - aspectRatio: quickEditAspectRatio, - imageSize: quickEditImageSize, - kind: isCharacterRedraw ? 'character' : 'quick-edit', - model: normalizedQuickEditModel, - referenceImageSrcs: [ - ...extraReferenceImageSrcs, - sourceReferenceImageSrc, - ], - projectId, - assetKind: quickEditSourceLayer.assetKind, - generationInputs, - assetFolderId, - assetLabel: quickEditResultTitle, - sourceResourceId: quickEditSourceLayer.resourceId, - ...(projectId && quickEditCanvasCompletionPlaceholder - ? { - canvasCompletion: { - dialogId: quickEditDialogId, - title: quickEditResultTitle, - placeholder: quickEditCanvasCompletionPlaceholder, - }, - } - : {}), - }), - onWalletBalanceMayHaveChanged, - ); + const generationInputs = buildQuickEditGenerationInputs( + '快速编辑提示词', + normalizedPrompt, + quickEditSourceLayer, + quickEditReferences, + { + model: normalizedQuickEditModel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + }, + ); + const generated = await runEditorGenerationWithWalletRefresh( + editEditorImage({ + prompt: normalizedPrompt, + sourceImageSrc: sourceReferenceImageSrc, + size: quickEditSize, + model: normalizedQuickEditModel, + aspectRatio: quickEditAspectRatio, + imageSize: quickEditImageSize, + ...(extraReferenceImageSrcs.length + ? { referenceImageSrcs: extraReferenceImageSrcs } + : {}), + projectId, + assetKind: quickEditSourceLayer.assetKind, + generationInputs, + assetFolderId, + assetLabel: quickEditResultTitle, + sourceResourceId: quickEditSourceLayer.resourceId, + targetLayerId: quickEditSourceLayer.id, + }), + onWalletBalanceMayHaveChanged, + ); notifyEditorGenerationWarning( generated.warning?.reason, onGenerationWarning, @@ -1831,45 +1685,25 @@ export function useImageCanvasGenerationSubmissionWorkflow({ onGenerationWarning, ) ) { - if (panelMode === 'quick-edit') { - setQuickEditPanel(null); - setActiveTool('select'); - setActiveSidebarPanel('layers'); - } + setQuickEditPanel(null); + setActiveTool('select'); + setActiveSidebarPanel('layers'); return; } if (generated.project && applyProjectSnapshot) { applyProjectSnapshot(generated.project); - if (panelMode === 'quick-edit') { - setQuickEditPanel(null); - setActiveTool('select'); - setActiveSidebarPanel('layers'); - } + setQuickEditPanel(null); + setActiveTool('select'); + setActiveSidebarPanel('layers'); return; } - if (panelMode === 'quick-edit') { - applyQuickEditResultToSourceLayer( - generated, - quickEditSourceLayer, - generationInputs, - quickEditDialogId, - quickEditResultTitle, - ); - } else { - addQuickEditResultLayer( - generated, - quickEditSourceLayer, - generationInputs, - panelMode, - quickEditDialogId - ? { - frame: quickEditFrame, - dialogId: quickEditDialogId, - title: quickEditResultTitle, - } - : { title: quickEditResultTitle }, - ); - } + applyQuickEditResultToSourceLayer( + generated, + quickEditSourceLayer, + generationInputs, + quickEditDialogId, + quickEditResultTitle, + ); } catch (error) { if (quickEditDialogId) { updateCanvasGenerationDialogById( @@ -1894,7 +1728,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ }); } }, [ - addQuickEditResultLayer, addVideoResultLayer, applyQuickEditResultToSourceLayer, applyProjectSnapshot, @@ -2121,7 +1954,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generationInputs: submissionPlan.generationInputs, }); } else if (submissionPlan.kind === 'quick-edit') { - const isRedrawAsNewArtifact = Boolean(dialog.isRedrawAsNewArtifact); const quickEditSelectionMarks = quickEditSelectionState?.sourceLayerId === submissionPlan.sourceLayer.id @@ -2166,11 +1998,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ ), ), ); - const quickEditPlaceholderSize = - getCanvasCompletionPlaceholderSizeFromPlan({ - sourceLayer: submissionPlan.sourceLayer, - outputSize: submissionPlan.editInput.size, - }); const generated = await runEditorGenerationWithWalletRefresh( editEditorImage({ prompt: submissionPlan.normalizedPrompt, @@ -2185,25 +2012,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ assetFolderId, assetLabel: submissionPlan.result.title, sourceResourceId: submissionPlan.sourceLayer.resourceId, - ...(isRedrawAsNewArtifact - ? { - canvasCompletion: { - dialogId: canvasDialog?.id, - title: submissionPlan.result.title, - placeholder: - getGeneratingDialogPlaceholder(dialog) ?? - buildRightSideCanvasCompletionPlaceholder( - submissionPlan.sourceLayer, - { - width: quickEditPlaceholderSize.width, - height: quickEditPlaceholderSize.height, - originalWidth: quickEditPlaceholderSize.width, - originalHeight: quickEditPlaceholderSize.height, - }, - ), - }, - } - : { targetLayerId: submissionPlan.sourceLayer.id }), + targetLayerId: submissionPlan.sourceLayer.id, }), onWalletBalanceMayHaveChanged, ); @@ -2225,21 +2034,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({ } return; } - 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, - ); - } + applyQuickEditResultToSourceLayer( + generated, + submissionPlan.sourceLayer, + submissionPlan.result.generationInputs, + canvasDialog?.id, + submissionPlan.result.title, + ); } else if (submissionPlan.kind === 'icon-spec') { const iconSpecInput = submissionPlan.input; const canvasCompletionPlaceholder = diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 339c46c7d..60bda118e 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -563,7 +563,7 @@ function GenerationWorkflowHarness({ {workflow.quickEditPanel - ? `${workflow.quickEditPanel.mode ?? 'quick-edit'}:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}` + ? `quick-edit:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}` : '-'} @@ -1563,14 +1563,14 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('opens generated layer remodel with user input snapshot instead of internal prompt', () => { + it('opens legacy image remodel with user input snapshot in the default image tool', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( - 'quick-edit:idle:open:-:placeholder', + 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe( '用户的视频描述', @@ -1592,19 +1592,59 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('opens image redraw as a new canvas generator next to the source layer', () => { + it('opens image remodel without an origin action in the default image tool', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( - 'quick-edit:idle:open:-:placeholder', + 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); expect(screen.getByTestId('generation-dialogs').textContent).toBe( - 'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open', + 'generation-dialog-1:generate:closed|generation-dialog-2:generate:open', + ); + }); + + it('rejects image.edit remodel even when the internal callback is invoked', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '快速编辑结果不支持改造,请继续使用快速编辑。', ); }); @@ -5150,7 +5190,10 @@ describe('useImageCanvasGenerationWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); - expect(screen.getByTestId('dialog').textContent).not.toBe('-'); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:idle:open:-:placeholder', + ); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '已按旧版数据恢复,部分参数可能使用当前默认值。', ); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 98c6cbfaf..8ada24591 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -69,10 +69,10 @@ import { createEditDialogDraft, createGenerateDialogDraft, createIconGenerationDialogDraft, + createLegacyImageRedrawGenerationDialogDraft, createPublicationGenerationDialogDraft, createQuickEditGenerationDialogDraft, createQuickEditPanelDraft, - createRedrawPanelDraft, createSameSourceGenerationDialogDraft, createSceneGenerationDialogDraft, createSoundEffectGenerationDialogDraft, @@ -1084,14 +1084,15 @@ export function useImageCanvasGenerationWorkflow({ const refreshTaskList = useCallback(() => { setTaskListRefreshKey((key) => key + 1); }, []); - const backgroundMusicPromptAssist = - useImageCanvasBackgroundMusicPromptAssist({ + const backgroundMusicPromptAssist = useImageCanvasBackgroundMusicPromptAssist( + { canvasGenerationDialogs, getCanvasGenerationDialogById, updateCanvasGenerationDialogById, currentUserId, projectId, - }); + }, + ); const soundEffectPromptAssist = useImageCanvasSoundEffectPromptAssist({ canvasGenerationDialogs, getCanvasGenerationDialogById, @@ -1850,10 +1851,10 @@ export function useImageCanvasGenerationWorkflow({ ); const hasInvalidVersionedGenerationInputs = Boolean( sourceLayer.generationInputsHydrationState === 'invalid-versioned' || - sourceLayer.generationInputs && - (sourceLayer.generationInputs.version !== undefined || - sourceLayer.generationInputs.action !== undefined) && - !decodedGenerationInputs.ok, + (sourceLayer.generationInputs && + (sourceLayer.generationInputs.version !== undefined || + sourceLayer.generationInputs.action !== undefined) && + !decodedGenerationInputs.ok), ); if (hasInvalidVersionedGenerationInputs) { showGenerationWarning( @@ -1865,6 +1866,10 @@ export function useImageCanvasGenerationWorkflow({ ? decodedGenerationInputs.inputs : null; const isNormalizedGenerationInputs = normalizedGenerationInputs !== null; + if (normalizedGenerationInputs?.action === 'image.edit') { + showGenerationWarning('快速编辑结果不支持改造,请继续使用快速编辑。'); + return; + } const hasParameterFallbacks = decodedGenerationInputs.ok ? decodedGenerationInputs.warnings.length > 0 : false; @@ -1994,9 +1999,7 @@ export function useImageCanvasGenerationWorkflow({ selectSingleLayer(sourceLayer.id); openCanvasGenerationDialog(audioDraft); setActiveTool('music'); - showGenerationWarning( - '已按旧版数据恢复,部分参数可能使用当前默认值。', - ); + showGenerationWarning('已按旧版数据恢复,部分参数可能使用当前默认值。'); return; } if (sourceLayer.mediaType === 'video') { @@ -2008,9 +2011,7 @@ export function useImageCanvasGenerationWorkflow({ selectSingleLayer(sourceLayer.id); openCanvasGenerationDialog(videoDraft); setActiveTool('video'); - showGenerationWarning( - '已按旧版数据恢复,部分参数可能使用当前默认值。', - ); + showGenerationWarning('已按旧版数据恢复,部分参数可能使用当前默认值。'); return; } if ( @@ -2022,36 +2023,18 @@ export function useImageCanvasGenerationWorkflow({ showGenerationWarning('未找到角色动作关联的原角色图层,无法改造。'); return; } - const redrawPanel = createRedrawPanelDraft(sourceLayer, { - imageModel: sourceDialog?.imageModel, - aspectRatio: sourceDialog?.aspectRatio, - imageSize: sourceDialog?.imageSize, - }); - const redrawSize = resolveEditorImageGenerationPixelSize({ - model: redrawPanel.model, - aspectRatio: redrawPanel.aspectRatio, - imageSize: redrawPanel.imageSize, - }); - openPlacedCanvasGenerationDialog({ - ...createQuickEditGenerationDialogDraft({ + openPlacedCanvasGenerationDialog( + createLegacyImageRedrawGenerationDialogDraft({ sourceLayer, - prompt: redrawPanel.prompt, - model: redrawPanel.model, - aspectRatio: redrawPanel.aspectRatio, - imageSize: redrawPanel.imageSize, - frame: { - width: redrawSize.width, - height: redrawSize.height, - }, + canvasSize, + viewport, + sourceDialog, }), - composerOpen: true, - }); + ); setQuickEditPanel(null); selectSingleLayer(null); setActiveTool('generate'); - showGenerationWarning( - '已按旧版数据恢复,部分参数可能使用当前默认值。', - ); + showGenerationWarning('已按旧版数据恢复,部分参数可能使用当前默认值。'); return; } }, -- 2.52.0 From 00785fc0c7b80b6f08f5c4271ceb2413a685afdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Mon, 10 Aug 2026 19:11:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E5=83=8F?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E7=94=9F=E6=88=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除未使用的图像生成像素尺寸解析函数导入 --- src/components/image-editor/useImageCanvasGenerationWorkflow.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 8ada24591..0bb8a9662 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -105,7 +105,6 @@ import { normalizeEditorImageModel, resizeGenerationPlaceholderToImageSelection, resolveDialogExtraImageReferenceLimit, - resolveEditorImageGenerationPixelSize, resolveExtraImageReferenceLimit, UI_EXTRA_REFERENCE_LIMIT, } from './ImageCanvasGenerationModel'; -- 2.52.0 From 3b1652721fa079ee142a5761b5c242945d66e10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 11 Aug 2026 10:35:44 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=97=A7=E9=87=8D=E7=BB=98=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一 legacy 改造按 assetKind/mediaType 恢复原 action 与工具 明确禁止普通生成自动附加当前图片或改写请求类型 移除旧重绘入口、源图携带和面板常驻描述 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 57dac1f1d..c8b2185db 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,12 +18,12 @@ - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 -- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 +- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 -- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。legacy adapter 只恢复映射后目标 action / 工具声明的输入,不得额外把当前图片附加为参考图,也不得把普通图片生成改写为 `quick-edit` 或 `character` 请求。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 -- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 +- 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成和生成规范保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 - 请求与可见性边界:同源画布 BFF 的角色、图标 spritesheet 和 UI 设计图素材提取 request DTO 保留 `segModel`,前端固定自动填充 `birefnet`,该字段不是用户选择或用户可见生成输入。External OpenAPI 是否接受 `segModel` 是独立契约,不能由站内 BFF 自动外推。`background_mode`、`cross_check` 以及角色动作的 `seg_model` 只由 api-server 决定并通过 loopback RPC 传给 worker。 @@ -106,9 +106,9 @@ - 封面图层读取与栅格化集中在 `ImageCanvasProjectCoverSnapshotRenderer`:先排除不与封面输出区相交的图层,再以最多 6 路并发加载实际可绘制图层,最终仍按 z-index 顺序绘制。封面换签必须复用画布预览的 `taskId ?? resourceId` cache version,序列帧和视频 poster 继续使用同源后缀,以命中已有 pending / 内存 / session signed-URL 缓存并尽量复用同一 OSS URL 的浏览器缓存。封面上传只需 `objectKey / assetObjectId / legacyPublicPath`,使用 object-only 上传路径,不得为从未使用的立即预览 `src` 强制 `bypassCache` 换签。 - 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。 - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。 -- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表保存资源和素材级元数据,其中 `editor_project_resource.asset_kind` 是资源默认类型;图层位置、层级、分组选中所需 ID、groupId 和可空 `assetKindOverride` 保存在画布布局。legacy 布局 JSON 是混合数组:普通图层按 `layerId/resourceId/assetKindOverride` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存;结构化模式下 `asset_kind_override` 必须是 `editor_canvas_layer` 的 typed 列,不得塞回 `item_json`。普通图层的新保存不再把资源默认 `assetKind` 或 `generationInputs` 写入布局;刷新时从 `editor_project_resource` 恢复资源默认类型,再与图层覆盖合并为 `effectiveAssetKind`,不得把合并结果反写为新的资源或覆盖。旧布局中的 `assetKind` 只用于迁移:与资源默认类型相同则归一为无 override,不同则迁移为图层 override;资源尚无默认值且允许补齐时先补资源默认值,不制造新的资源行。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存对话框快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。V2 `generationInputs` 沿用现有 JSON 列,不新增表或字段:顶层为 `{ version: 2, action, fields, references }`,`fields` 只保存 `{ id, title, value }`,其中 `value` 为 `string | number | boolean`;`references` 只保存 `{ id, title, label?, refType, refId }`,`refType="project-resource"` 和 `refType="asset"` 仅作为当前画布图层的稳定匹配键,媒体类型从匹配图层获得,不据此额外查询资源行或素材库。提交前对面板值只归一一次,同一份归一值同时进入生成请求与 `fields/references`;Data URL、Blob URL、signed URL 和 `objectKey` 均不进入元数据。面板直接上传的引用即使为生成请求创建了工程资源,也不作为可长期恢复的素材库或画布引用。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 -- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、重绘、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 +- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。图片生成、快速编辑、修改图片、视频、音频、角色、图标、UI / 自定义规范、角色动画和宣发素材描述统一直接使用公共 `AutoGrowTextArea`,不经 `PlatformTextField` 的 textarea 分支;`AutoGrowTextArea` 负责尺寸、溢出及统一的 disabled 光标 / 透明度反馈,画布内生成面板统一由 `image-canvas-editor__generation-prompt` 承载边框、背景、padding、圆角和 focus chrome,独立修改图片弹窗继续使用 `image-canvas-editor__generate-prompt`,用途专属类只追加最小高度等差异,不得新建第三套 textarea chrome。两套 chrome 均通过 `--auto-grow-scrollbar-radius` 与内部滚动条共用圆角;WebKit 轨道按该圆角上下内缩,滑块在透明横向 gutter 内保持浏览器原生长度比例,拉至顶部或底部不得越过输入框边界。Enter 继续输入换行而不提交表单。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀;图标拆分素材按全连通域视觉顺序自动命名为 `素材 N`。 - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 @@ -147,7 +147,7 @@ - `POST /api/editor/assets`:批量或单个创建账号级素材,登录态上传必须写入 OSS / asset object 引用和 `/` 轻量路径,不允许把 Data URL / signed URL 写入素材库。 - `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。 - `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。 -- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。普通重绘继续走该接口并把当前图层图片作为参考图;图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 +- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片。带 `model / aspectRatio / imageSize` 的用户生成以统一业务像素矩阵创建前端占位和最终画布资源,例如两种图片模型的 `2K·16:9` 都交付 `2048x1152`;不得先请求固定 1K 再放大为 2K。`gpt-image-2` 在 provider 边界使用其接口支持的对齐请求尺寸,该尺寸不是业务交付尺寸;`nanobanana2` 仍把比例和清晰度档位写入 `generateContent`。provider 回图大于业务目标且比例偏差在允许范围内时,在内存中缩小并轻微裁切到业务尺寸后只上传最终结果。任意一边小于业务目标或比例偏差过大时禁止放大或大幅裁切,只上传 provider 实际回图,以实际尺寸写入结果并通过通用 `warning` 提示用户。主结果只写一次 OSS 且不额外创建“原始输出”。角色生成可携带 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`;父流程在持久化带纯色背景原图前先将回图归一到业务交付尺寸,再以该原图的 object key 向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;子 worker 在每次真实 provider attempt 前签发短期 OSS URL,并向 BgFilter 传入 `screen_color=`、`seg_model=`。父流程不直连 BgFilter、不签发该 URL,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。带背景原图和透明结果必须使用同一实际像素尺寸,1K 的长边固定为 `1024`;若 provider 回图不允许无放大地恢复到业务尺寸,两张图一同保留 provider 实际尺寸并返回通用 `warning`。透明处理结果发生尺寸漂移时,只允许在宽高比偏差不超过 `5%` 时重采样 alpha 蒙版并应用回已归一原图 RGB;蒙版比例超限、回贴失败或尺寸验证失败时不保存透明图,只以已保存原图和同时保留尺寸原因的通用 `warning` 完成画布。最终失败时按前述多产物降级规则以原图主结果和通用 `warning` 收口。图标图集和 UI 图集的透明处理正常成功但返回尺寸与 provider 原图不同时,同样只重采样 alpha 蒙版并应用回 provider 原图,不放大低分辨率后处理成品。宣发素材携带 `kind: "publication-material"` 时固定归一为 `gpt-image-2`,不支持 `nanobanana2`,并继续按固定交付像素处理。从既有图层重新打开生成器且没有仍存活的对话框快照时,前端按该图层真实 `originalWidth / originalHeight` 恢复比例和清晰度,不得回落到新建面板的 1K 默认值。图片快速编辑不走该接口。请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成完成后在响应中返回实际产物的 project / resource / asset 快照。 - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:主图标规范使用必填 `referenceId`,只接受当前 owner 的项目资源 ID 或素材 ID,不接受 `objectKey`、URL、临时 key 或 `referenceImageSrc` 作为主规范引用;普通附加参考图仍可使用独立 `referenceImageSrcs`。画布前端把完整用户需求作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`projectId`、`assetFolderId` 和 `generationInputs`,不接受客户端 `priceMudPoints`;后端按归一化后的模型和尺寸从运行时定价配置计算价格,queue 入队时冻结该价格,worker 的预扣、退款和结果投影均使用同一入队价格;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 @@ -197,15 +197,14 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 - 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 - 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 - 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,其新请求配方固定为 `version: 2 / action: image.perfect-pixel / fields: []`。账本外层版本不升级;嵌套 generationInputs 以共享严格白名单恢复,V2 完整接受稳定 `id`、有限数字、布尔值和可省略 `label`,旧 `{fields,references}` 请求继续按原形恢复以维持历史 fingerprint,未知键或畸形 V2 失败关闭。`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 - 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 - 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 - legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 -- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 -- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 +- 快速编辑站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 - 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。 - 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;上传视频同样在创建素材和图层前读取视频 metadata 宽高,保证单层下载或 ZIP 导出的真实视频文件重新导入后仍按文件自身尺寸入画布;仅在无法解析尺寸时才使用对应媒体兜底尺寸。OSS 直传确认成功后,即时占位素材、即时画布图层和补充图片尺寸探测必须使用本次换签得到的 signed URL,不得把私有对象的裸 `/generated-*` 兼容路径交给浏览器读取;账号素材持久化仍只写 `legacyPublicPath`、`objectKey` 和 `assetObjectId`,禁止把会过期的 signed URL 写入资源或素材记录。 -- 2.52.0 From 49abc064e871d311ae992a6e7f5262ece02fb018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 11 Aug 2026 11:30:21 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E6=94=B9=E9=80=A0=E6=8F=90=E4=BA=A4=E9=97=AD=E7=8E=AF=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖未知 legacy 图片回退普通生成的请求、配方、落图和面板终态 覆盖已知角色素材恢复原工具并提交角色规范引用的完整链路 同步图片画布架构文档中的定向测试门禁 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../useImageCanvasGenerationWorkflow.test.tsx | 133 +++++++++++++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index c8b2185db..cc92e5014 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -21,7 +21,7 @@ - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 action capability 判断:确定性派生等不支持改造的 action 隐藏;支持改造的 action 即使参数损坏或必需来源丢失也保留按钮,点击后再显示准确错误,禁止用恢复状态静默隐藏入口。`image.edit` 明确不属于可改造 action:图片快速编辑使用 `targetLayerId` 原位替换画布层,结果层就是被覆盖后的原层,其配方 source 指向替换前的编辑目标,不能再可靠恢复为“改造”新产物;这类结果继续显示“快速编辑”,不显示“改造”。 - V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。**引用在提交时是否必填,不是恢复时拒绝打开面板的依据**:凡目标面板提供重新选择入口的槽位,无论可选还是必填,缺失时都必须 best effort 打开面板、留空槽位并提示用户重新选择,最终由提交门禁校验;例如角色生成缺少必填角色规范、图标生成缺少必填图标规范、普通图片 / 视频缺少参考图时,都必须打开原 action 面板让用户补选。只有已支持改造但目标面板没有手动替换入口、缺少 source 就无法构造请求时才允许拒绝,当前严格限定为两个例外:`character-animation.generate` 的 source 决定角色动作面板绑定的原角色图层,面板内部不能换角色;`ui-design.extract-assets` 的 source 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外仍按 action capability 保留改造按钮;source 缺失时点击按钮才显示“无法改造”并分别说明不可替换原因,点击后 source 变化也必须在恢复路径再次校验并拒绝;该失败规则不得用于任何可手动补选的槽位。 -- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。legacy adapter 只恢复映射后目标 action / 工具声明的输入,不得额外把当前图片附加为参考图,也不得把普通图片生成改写为 `quick-edit` 或 `character` 请求。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- 改造恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示“已按旧版数据恢复,部分参数可能使用当前默认值。”。legacy adapter 必须先按 `assetKind/mediaType` 映射角色、图标、UI、场景、视频、音频等原工具;只有图片族且 `assetKind` 为空或未知时才使用默认的普通图片生成工具。legacy adapter 只恢复映射后目标 action / 工具声明的输入,不得额外把当前图片附加为参考图,也不得把普通图片生成改写为 `quick-edit` 或 `character` 请求。无法映射到可用工具时准确提示无法改造。一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。定向测试必须至少覆盖未知图片回退普通 `image.generate` 和一种已知 `assetKind` 恢复原工具的真实提交闭环,并校验请求输入、V2 action / references、结果落点及 dialog / composer 终态。 - V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。hydrate 结果必须保留 `absent | valid | invalid-versioned` 运行时来源状态:只有 `absent` 可走 legacy adapter;结构损坏或版本不支持的版本化配方标为 `invalid-versioned`,所有恢复入口都必须拒绝自动降级,并提示“保存的生成参数版本不受支持或数据损坏,无法可靠恢复”。该标记不属于持久化 generationInputs 契约,也不得把原始损坏 JSON 回写。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成和生成规范保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材的 `快速编辑` 按当前帧图片走图片编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 60bda118e..635e9d74c 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -500,6 +500,14 @@ function GenerationWorkflowHarness({ ) .join('|')} + + {layers + .map( + (layer) => + `${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`, + ) + .join('|')} + {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` @@ -1592,10 +1600,26 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); - it('opens image remodel without an origin action in the default image tool', () => { - render(); + it('submits legacy image remodel without an origin action through the default image tool', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '旧版森林场景' }), + ); + render( + , + ); - fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( @@ -1603,8 +1627,109 @@ describe('useImageCanvasGenerationWorkflow', () => { ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); + const expectedResultPlacement = + screen.getByTestId('placeholder').textContent; + fireEvent.click(screen.getByRole('button', { name: '提交生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '旧版森林场景', + model: IMAGE_MODEL_GPT_IMAGE_2, + generationInputs: expect.objectContaining({ + version: 2, + action: 'image.generate', + references: [], + }), + }), + ); + }); + const request = generateEditorImageMock.mock.calls[0]?.[0]; + expect(request).not.toHaveProperty('referenceImageSrcs'); + expect(request).not.toHaveProperty('sourceResourceId'); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated-1:生成图片 1:-:-', + ); + }); + expect(screen.getByTestId('layer-placements').textContent).toContain( + `layer-generated-1:${expectedResultPlacement}`, + ); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:idle:open:layer-generated-1:placeholder', + ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( - 'generation-dialog-1:generate:closed|generation-dialog-2:generate:open', + 'generation-dialog-1:generate:open', + ); + }); + + it('submits legacy character remodel through the character tool selected by asset kind', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '红发骑士' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toBe( + 'character:idle:open:-:placeholder', + ); + expect(screen.getByTestId('dialog-prompt').textContent).toBe('红发骑士'); + const expectedResultPlacement = + screen.getByTestId('placeholder').textContent; + fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); + fireEvent.click(screen.getByRole('button', { name: '提交生成' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: '红发骑士', + kind: 'character', + model: IMAGE_MODEL_GPT_IMAGE_2, + referenceImageSrcs: ['resource-character-spec'], + generationInputs: expect.objectContaining({ + version: 2, + action: 'character.generate', + references: [ + expect.objectContaining({ + id: 'specReference', + refType: 'project-resource', + refId: 'resource-character-spec', + }), + ], + }), + }), + ); + }); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated-1:角色形象 1:-:character', + ); + }); + expect(screen.getByTestId('layer-placements').textContent).toContain( + `layer-generated-1:${expectedResultPlacement}`, + ); + expect(screen.getByTestId('dialog').textContent).toBe( + 'character:idle:open:layer-generated-1:placeholder', + ); + expect(screen.getByTestId('generation-dialogs').textContent).toBe( + 'generation-dialog-1:character:open', ); }); -- 2.52.0