From e99f3209f14c35e9729a936048b792204c37803b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 7 Aug 2026 20:25:42 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=BB=E6=AD=A2=E6=8D=9F=E5=9D=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E9=85=8D=E6=96=B9=E9=99=8D=E7=BA=A7=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留生成配方 hydrate 来源状态 禁止损坏或未来版本配方回退 legacy 恢复 补齐资源快照与改造入口回归测试 记录已生成图层点击恢复浮层的产品待确认项 同步画布编辑器恢复契约文档 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasEditorModel.test.ts | 43 +++++++++++++++++++ .../image-editor/ImageCanvasEditorModel.ts | 27 +++++++++--- .../image-editor/ImageCanvasEditorTypes.ts | 9 ++++ .../image-editor/ImageCanvasEditorView.tsx | 17 ++++++++ .../ImageCanvasGenerationDialogModel.test.ts | 16 +++++++ .../ImageCanvasGenerationDialogModel.ts | 3 ++ .../ImageCanvasGenerationInputsModel.ts | 39 ++++++++++++++--- .../useImageCanvasGenerationWorkflow.test.tsx | 22 ++++++++++ .../useImageCanvasGenerationWorkflow.ts | 4 ++ 10 files changed, 169 insertions(+), 13 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 2b57c779c..b67df0e7f 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -22,7 +22,7 @@ - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。按钮显隐只按 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 同时决定框选覆盖层的坐标系,素材提取面板不能改绑另一张设计图。两个例外缺少 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 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 +- 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` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `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)→ 父侧阿里云 → 父侧本地键色` 链路。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 4bbbba4b1..dd2178822 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -187,6 +187,49 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('preserves an invalid versioned resource marker instead of falling back to layout legacy inputs', () => { + const hydrated = hydrateLayer( + { + layerId: 'layer-invalid-versioned-recipe', + resourceId: 'resource-invalid-versioned-recipe', + title: '损坏配方生成图', + src: '/layout/fallback.png', + x: 0, + y: 0, + width: 320, + height: 320, + originalWidth: 320, + originalHeight: 320, + zIndex: 1, + sourceType: 'generated', + generationInputs: { + fields: [{ title: '生成提示词', value: '不得当作旧版恢复' }], + references: [], + }, + }, + new Map([ + [ + 'resource-invalid-versioned-recipe', + { + imageSrc: '/resource/generated.png', + sourceType: 'generated', + generationInputs: { + version: 3, + action: 'image.generate', + fields: [], + references: [], + }, + }, + ], + ]), + ); + + expect(hydrated).toMatchObject({ + generationInputs: null, + generationInputsHydrationState: 'invalid-versioned', + }); + }); + it('keeps the resource default kind separate from a layer override', () => { const layer = { id: 'layer-shared', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 5165abaaa..7eaf500a9 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -21,7 +21,10 @@ import type { PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; -import { hydrateCanvasGenerationInputs } from './ImageCanvasGenerationInputsModel'; +import { + hydrateCanvasGenerationInputs, + hydrateCanvasGenerationInputsResult, +} from './ImageCanvasGenerationInputsModel'; export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ { @@ -215,6 +218,7 @@ export function createLayerFromAsset( assetKindOverride: null, assetKind: asset.assetKind ?? assetKind, generationInputs: asset.generationInputs, + generationInputsHydrationState: asset.generationInputsHydrationState, imageSequenceFrames: asset.imageSequenceFrames, imageSequenceDurationMs: asset.imageSequenceDurationMs, } satisfies CanvasLayer; @@ -1431,6 +1435,19 @@ export function hydrateLayer( imageSequenceDurationMs = audioDurationOrNull(snapshot.imageSequenceDurationMs) ?? undefined; } + const resourceGenerationInputs = hydrateCanvasGenerationInputsResult( + resource?.generationInputs, + ); + const snapshotGenerationInputs = hydrateCanvasGenerationInputsResult( + snapshot.generationInputs, + ); + // 资源行是正式配方来源。它若明确是损坏/未来版本的 V2,不能因布局中的旧快照或 + // legacy fallback 被掩盖;只有资源值真正 absent 时才读取布局快照。 + const generationInputsHydration = isFormalCharacterAnimation + ? resourceGenerationInputs + : resourceGenerationInputs.state === 'absent' + ? snapshotGenerationInputs + : resourceGenerationInputs; return { id: layerId, resourceId, @@ -1501,10 +1518,8 @@ export function hydrateLayer( resourceAssetKind, assetKindOverride, assetKind, - generationInputs: isFormalCharacterAnimation - ? generationInputsOrNull(resource?.generationInputs) - : (generationInputsOrNull(resource?.generationInputs) ?? - generationInputsOrNull(snapshot.generationInputs)), + generationInputs: generationInputsHydration.inputs, + generationInputsHydrationState: generationInputsHydration.state, hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -1721,6 +1736,8 @@ export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): { showcaseLikeCount: asset.showcaseLikeCount ?? null, assetKind, generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs).state, imageSequenceFrames: asset.imageSequenceFrames ?? undefined, imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, }; diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index a4b1b9276..c77f81abf 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -61,6 +61,7 @@ export type EditorAsset = { showcaseSubmitError?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; + generationInputsHydrationState?: CanvasGenerationInputsHydrationState; imageSequenceFrames?: EditorImageSequenceFrameResult[]; imageSequenceDurationMs?: number; uploadStatus?: 'uploading' | 'failed'; @@ -109,6 +110,13 @@ export type CanvasGenerationInputs = { references: CanvasGenerationInputReference[]; }; +// hydrate 后的客户端运行时来源状态;不属于 generationInputs 持久化契约。 +// 用于区分真正缺少配方的 legacy 数据与损坏/未来版本的版本化配方。 +export type CanvasGenerationInputsHydrationState = + | 'absent' + | 'valid' + | 'invalid-versioned'; + export type CanvasLayer = { id: string; resourceId: string; @@ -141,6 +149,7 @@ export type CanvasLayer = { assetKindOverride?: CanvasAssetKind | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; + generationInputsHydrationState?: CanvasGenerationInputsHydrationState; imageSequenceFrames?: EditorImageSequenceFrameResult[]; imageSequenceDurationMs?: number; previewVideoPath?: string | null; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index f8c11f085..50ec6473d 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -43,6 +43,7 @@ import { resolveContextMenuPosition, resolveLayerResourceAssetKind, } from './ImageCanvasEditorModel'; +import { hydrateCanvasGenerationInputsResult } from './ImageCanvasGenerationInputsModel'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import type { AssetPointerDragState, @@ -306,6 +307,7 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { sourceAssetId: asset.id, assetKind: asset.assetKind ?? null, generationInputs: asset.generationInputs ?? null, + generationInputsHydrationState: asset.generationInputsHydrationState, imageSequenceFrames: asset.imageSequenceFrames, imageSequenceDurationMs: asset.imageSequenceDurationMs, }; @@ -1113,6 +1115,9 @@ export function ImageCanvasEditorView({ asset.imageSequenceDurationMs ?? undefined, assetKind: persistedAssetKind, generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs) + .state, }, ]); setAssetFolders((currentFolders) => @@ -1185,6 +1190,8 @@ export function ImageCanvasEditorView({ imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), + generationInputsHydrationState: + hydrateCanvasGenerationInputsResult(asset.generationInputs).state, }, ]); setAssetFolders((currentFolders) => @@ -1854,6 +1861,12 @@ export function ImageCanvasEditorView({ }); const openLayerGenerationDialog = useCallback( (layer: CanvasLayer) => { + if (layer.generationInputsHydrationState === 'invalid-versioned') { + showGenerationWarning( + '保存的生成参数版本不受支持或数据损坏,无法改造。', + ); + return false; + } const existingDialog = [...canvasGenerationDialogsRef.current] .reverse() .find((dialog) => dialog.generatedLayerId === layer.id); @@ -1887,10 +1900,14 @@ export function ImageCanvasEditorView({ openCanvasGenerationDialog, setSelectedLayerId, setSelectedLayerIds, + showGenerationWarning, ], ); const openGeneratedLayerGenerationDialog = useCallback( (layer: CanvasLayer) => { + // TODO(产品确认): 已完成的生成结果被普通点击时,是否仍应恢复并显示其生成浮层? + // 当前实现会这样做,但现有文档只定义点击图层后的选中/工具栏行为,以及用户显式 + // 点击“改造”时的配方恢复,未定义这一隐式恢复入口。确认前保留既有行为。 if (layer.sourceType !== 'generated') { return false; } diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 984653653..10d4bc238 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -60,6 +60,22 @@ function createLayer(overrides: Partial = {}): CanvasLayer { } describe('ImageCanvasGenerationDialogModel', () => { + it('does not legacy-fallback from a hydrated invalid versioned recipe', () => { + expect( + createSameSourceGenerationDialogDraft({ + sourceLayer: createLayer({ + sourceType: 'generated', + generationInputs: null, + generationInputsHydrationState: 'invalid-versioned', + prompt: '不能误当旧版配方', + }), + canvasSize: { width: 960, height: 720 }, + viewport: { x: 0, y: 0, scale: 1 }, + mode: 'redraw', + }), + ).toBeNull(); + }); + it('creates centered drafts for image and spec generation dialogs', () => { const canvasSize = { width: 1000, height: 800 }; const viewport = { x: 100, y: 40, scale: 2 }; diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 80db386ce..d10912c12 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -1416,6 +1416,9 @@ export function createSameSourceGenerationDialogDraft({ CanvasGenerationDialogState, 'id' > | null { + if (sourceLayer.generationInputsHydrationState === 'invalid-versioned') { + return null; + } const normalizedDraft = createNormalizedGenerationDialogDraft({ sourceLayer, canvasSize, diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index e0ade4e61..7b3940c44 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -3,6 +3,7 @@ import type { CanvasGenerationInputField, CanvasGenerationInputReference, CanvasGenerationInputs, + CanvasGenerationInputsHydrationState, } from './ImageCanvasEditorTypes'; export const CANVAS_GENERATION_ACTIONS = [ @@ -250,15 +251,39 @@ function hydrateLegacyGenerationInputs( : null; } +export type CanvasGenerationInputsHydrationResult = { + state: CanvasGenerationInputsHydrationState; + inputs: CanvasGenerationInputs | null; +}; + +export function hydrateCanvasGenerationInputsResult( + value: unknown, + options: { strictWhitelist?: boolean } = {}, +): CanvasGenerationInputsHydrationResult { + if (!isRecord(value)) { + return { state: 'absent', inputs: null }; + } + if ('version' in value || 'action' in value) { + const inputs = cloneV2GenerationInputs( + value, + options.strictWhitelist === true, + ); + return inputs + ? { state: 'valid', inputs } + : { state: 'invalid-versioned', inputs: null }; + } + const inputs = hydrateLegacyGenerationInputs( + value, + options.strictWhitelist === true, + ); + return inputs + ? { state: 'valid', inputs } + : { state: 'absent', inputs: null }; +} + export function hydrateCanvasGenerationInputs( value: unknown, options: { strictWhitelist?: boolean } = {}, ): CanvasGenerationInputs | null { - if (!isRecord(value)) { - return null; - } - if ('version' in value || 'action' in value) { - return cloneV2GenerationInputs(value, options.strictWhitelist === true); - } - return hydrateLegacyGenerationInputs(value, options.strictWhitelist === true); + return hydrateCanvasGenerationInputsResult(value, options).inputs; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 3a5d761f2..d61e17f98 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -5073,6 +5073,28 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); + it('does not legacy-fallback when hydration retained an invalid versioned recipe', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '保存的生成参数版本不受支持或数据损坏,无法改造。', + ); + }); + it('warns when a legacy recipe is restored successfully', () => { render( reference.id === 'source', ); + // 同一 resourceId/sourceAssetId 的画布副本引用同一份媒体数据;UI 素材提取的 + // 框选覆盖层落在任一副本均可,恢复不依赖原 placement 的坐标、尺寸或层级,故按 + // 当前图层顺序取首个匹配项即可,无需为此持久化 canvas layer identity。 const extractionSource = sourceReference ? layers.find((layer) => sourceReference.refType === 'project-resource'