From de9eaef28f31df54a60bed49937e28da059e0593 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 21 Jun 2026 17:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=B8=BA=E8=A1=8C=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=94=BB=E5=B8=83=E5=8F=82=E8=80=83=E5=9B=BE?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成输入参考图只保存 refType/refId 行引用 生成器布局快照只保存参考图行指针并在加载时恢复临时源 上传参考图优先创建项目资源行并回写 resourceId 元数据弹窗改为展示参考图行引用信息 更新图片画布技术文档和项目决策记录 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 8 +- .../ImageCanvasEditorModel.test.ts | 60 ++- .../image-editor/ImageCanvasEditorModel.ts | 163 ++++++- .../image-editor/ImageCanvasEditorTypes.ts | 5 +- .../image-editor/ImageCanvasEditorView.tsx | 1 + .../ImageCanvasGenerationDialogModel.test.ts | 5 +- .../ImageCanvasGenerationModel.test.ts | 82 +++- .../ImageCanvasGenerationModel.ts | 148 +++--- ...ageCanvasGenerationSubmissionModel.test.ts | 48 +- .../ImageCanvasMetadataModalView.test.tsx | 4 +- .../ImageCanvasMetadataModalView.tsx | 11 +- .../image-editor/ImageCanvasUploadModel.ts | 6 + .../useImageCanvasGenerationWorkflow.test.tsx | 6 +- .../useImageCanvasProjectPersistence.ts | 1 + .../useImageCanvasUploadWorkflow.test.tsx | 79 +++- .../useImageCanvasUploadWorkflow.ts | 434 +++++++++++++----- src/index.css | 14 +- .../image-editor/editorProjectClient.ts | 3 +- 19 files changed, 822 insertions(+), 264 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7ca033878..165c7343f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-21 图片画布参考图元数据只保存项目内行引用 + +- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。 +- 决策:`generationInputs.references` 只保存 `{ title, label, refType, refId }`,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`。生成器 `itemType="generation-dialog"` 布局快照中的参考图也只保存 `resourceId/sourceAssetId` 和展示 label,不保存图片 Data URL、signed URL 或 `objectKey`;提交生成请求前的内存态可以临时持有 `src/objectKey`,刷新恢复时从 `editor_project_resource` / `editor_asset` 行补回请求所需图片源。不兼容旧 `src` 型参考图元数据。 +- 影响范围:图片画布生成输入快照、生成器布局保存 / 恢复、参考图上传工作流、元数据弹窗和图片画布技术文档。 +- 验证方式:运行图片画布生成模型、生成提交、上传工作流、项目持久化、元数据弹窗相关前端测试,执行 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-06-19 外部 OpenAPI 与 API Key 管理走 server-rs 正式链路 - 背景:外部调用方需要稳定调用图片画布项目创建、画布布局保存和编辑器美术生图能力,同时需要可撤销的开发者凭据,不能依赖前端临时状态或人工分发密钥。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 80f3f8a7c..c6e6da28c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -16,7 +16,7 @@ - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层或画板的边缘与中心线。 -- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 +- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;快速编辑按原图模型提交;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材必须按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 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;首期固定文字转视频、`16:9`、标准模式、静音,生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。快速编辑和重绘在调用后端前必须把当前图层图片源读取为图片 Data URL,来源可以是本地上传 Data URL、站内 public 图片、历史 `/generated-*` 路径或可读取的 OSS generated URL;后端仍只接收图片 Data URL,不把普通 URL 直接透传到 VectorEngine edits。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图自动作为最后一张参考图展示和提交。尺寸、模型沿用原图配置,只以禁用态参数胶囊展示,不可点击修改。提交前将提示词里对原图的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张原图编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 @@ -42,8 +42,8 @@ - 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。 - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 -- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 -- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 +- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -96,7 +96,7 @@ - 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9 · 2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。 - 快速编辑面板底部必须以禁用态参数按钮显示原图尺寸和原图模型,视觉对齐可编辑面板参数控件;参考图顺序为额外参考图在前、原图在最后。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 -- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。 +- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景使用前端轻量去背并回写为新的工程资源快照;前端轻量去背必须复用共享 `chromaKey` 的绿幕 / 近白背景模型,不再只按四角颜色扩散,避免纯白角色主体或主体内部浅色区域被扣碎。UI设计图的提取素材固定走 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 287ba71d1..25895a4a2 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -348,9 +348,8 @@ describe('ImageCanvasEditorModel', () => { { id: 'publication-reference-1', label: '首图参考', - src: '/generated-character-drafts/editor/reference.png', - objectKey: 'generated-character-drafts/editor/reference.png', - assetObjectId: 'asset-object-publication-reference', + src: 'data:image/png;base64,publication-ref', + resourceId: 'resource-publication-reference', }, ], imageModel: 'gpt-image-2', @@ -367,11 +366,27 @@ describe('ImageCanvasEditorModel', () => { }, }; + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [dialog], + }); + expect(JSON.stringify(layout)).not.toContain('data:image'); + expect(JSON.stringify(layout)).toContain( + 'ref:project-resource:resource-publication-reference', + ); + const { generationDialogs } = splitCanvasLayoutItems( - serializeCanvasLayout({ - layers: [], - canvasGenerationDialogs: [dialog], - }), + layout, + new Map([ + [ + 'resource-publication-reference', + { + imageSrc: '/read/publication-reference.png', + objectKey: 'generated-character-drafts/editor/reference.png', + assetObjectId: 'asset-object-publication-reference', + }, + ], + ]), ); expect(generationDialogs).toHaveLength(1); @@ -390,9 +405,10 @@ describe('ImageCanvasEditorModel', () => { expect(generationDialogs[0]?.publicationReferences?.[0]).toMatchObject({ id: 'publication-reference-1', label: '首图参考', - src: '/generated-character-drafts/editor/reference.png', + src: '/read/publication-reference.png', objectKey: 'generated-character-drafts/editor/reference.png', assetObjectId: 'asset-object-publication-reference', + resourceId: 'resource-publication-reference', }); }); @@ -432,6 +448,7 @@ describe('ImageCanvasEditorModel', () => { id: 'reference-1', label: '参考图', src: 'data:image/png;base64,ref', + resourceId: 'resource-reference-1', }, ], }; @@ -440,7 +457,29 @@ describe('ImageCanvasEditorModel', () => { layers: [layer], canvasGenerationDialogs: [dialog], }); - const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout); + expect( + JSON.stringify( + layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + ), + ).not.toContain('data:image'); + expect( + JSON.stringify( + layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + ), + ).toContain('ref:project-resource:resource-reference-1'); + + const { layerItems, generationDialogs } = splitCanvasLayoutItems( + layout, + new Map([ + [ + 'resource-reference-1', + { + imageSrc: '/read/reference-1.png', + objectKey: 'generated/reference-1.png', + }, + ], + ]), + ); expect(layerItems).toHaveLength(1); expect(generationDialogs).toHaveLength(1); @@ -459,6 +498,9 @@ describe('ImageCanvasEditorModel', () => { }); expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({ label: '参考图', + src: '/read/reference-1.png', + resourceId: 'resource-reference-1', + objectKey: 'generated/reference-1.png', }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index f3cd0ee7d..46047668f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -190,6 +190,100 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & { export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; +function isPersistedReferenceResourceId(resourceId: string | null | undefined) { + const normalizedResourceId = resourceId?.trim(); + return Boolean( + normalizedResourceId && + !normalizedResourceId.startsWith('local-') && + !normalizedResourceId.startsWith('generation-dialog:'), + ); +} + +function isPersistedReferenceAssetId(assetId: string | null | undefined) { + const normalizedAssetId = assetId?.trim(); + return Boolean(normalizedAssetId && !normalizedAssetId.startsWith('upload-')); +} + +function resolveReferencePointer(reference: CharacterReferenceImage) { + const resourceId = reference.resourceId?.trim(); + if (isPersistedReferenceResourceId(resourceId)) { + return { + src: `ref:project-resource:${resourceId}`, + resourceId, + sourceAssetId: reference.sourceAssetId, + }; + } + const sourceAssetId = reference.sourceAssetId?.trim(); + if (isPersistedReferenceAssetId(sourceAssetId)) { + return { + src: `ref:asset:${sourceAssetId}`, + resourceId: reference.resourceId, + sourceAssetId, + }; + } + return null; +} + +function serializeGenerationReference( + reference: CharacterReferenceImage | null | undefined, +): CharacterReferenceImage | null { + if (!reference) { + return null; + } + const pointer = resolveReferencePointer(reference); + if (!pointer) { + return null; + } + return { + id: reference.id, + label: reference.label, + src: pointer.src, + mediaType: reference.mediaType, + mimeType: reference.mimeType, + sizeBytes: reference.sizeBytes, + durationSeconds: reference.durationSeconds, + resourceId: pointer.resourceId, + sourceAssetId: pointer.sourceAssetId, + }; +} + +function serializeGenerationReferences( + references: CharacterReferenceImage[] | undefined, +) { + if (!references) { + return undefined; + } + return references.flatMap((reference) => { + const serialized = serializeGenerationReference(reference); + return serialized ? [serialized] : []; + }); +} + +function serializeDialogReferences( + dialog: CanvasGenerationDialogState, +): CanvasGenerationDialogState { + return { + ...dialog, + specReference: serializeGenerationReference(dialog.specReference), + generationReferences: serializeGenerationReferences( + dialog.generationReferences, + ), + characterSpecReference: serializeGenerationReference( + dialog.characterSpecReference, + ), + characterReferences: serializeGenerationReferences( + dialog.characterReferences, + ), + iconSpecReference: serializeGenerationReference(dialog.iconSpecReference), + publicationReferences: serializeGenerationReferences( + dialog.publicationReferences, + ), + uiDesignSpecReference: serializeGenerationReference( + dialog.uiDesignSpecReference, + ), + }; +} + export function serializeCanvasGenerationDialog( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogSnapshot { @@ -197,7 +291,7 @@ export function serializeCanvasGenerationDialog( itemType: 'generation-dialog', layerId: `generation-dialog:${dialog.id}`, resourceId: `generation-dialog:${dialog.id}`, - dialog, + dialog: serializeDialogReferences(dialog), }; } @@ -229,6 +323,7 @@ export function isCanvasGenerationDialogLayoutItem( export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], + resourcesById: Map = new Map(), ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; @@ -238,7 +333,7 @@ export function splitCanvasLayoutItems( items.forEach((item) => { if (isCanvasGenerationDialogLayoutItem(item)) { - const dialog = hydrateCanvasGenerationDialog(item.dialog); + const dialog = hydrateCanvasGenerationDialog(item.dialog, resourcesById); if (dialog) { generationDialogs.push(dialog); } @@ -252,6 +347,7 @@ export function splitCanvasLayoutItems( export function hydrateCanvasGenerationDialog( value: unknown, + resourcesById: Map = new Map(), ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; @@ -278,17 +374,26 @@ export function hydrateCanvasGenerationDialog( ? snapshot.specType : undefined, specValues: hydrateSpecFormValues(snapshot.specValues), - specReference: hydrateCharacterReference(snapshot.specReference), + specReference: hydrateCharacterReference( + snapshot.specReference, + resourcesById, + ), generationReferences: hydrateCharacterReferences( snapshot.generationReferences, + resourcesById, ), characterSpecReference: hydrateCharacterReference( snapshot.characterSpecReference, + resourcesById, ), characterReferences: hydrateCharacterReferences( snapshot.characterReferences, + resourcesById, + ), + iconSpecReference: hydrateCharacterReference( + snapshot.iconSpecReference, + resourcesById, ), - iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference), iconDescriptions: Array.isArray(snapshot.iconDescriptions) ? snapshot.iconDescriptions.filter( (description): description is string => @@ -303,9 +408,11 @@ export function hydrateCanvasGenerationDialog( ), publicationReferences: hydrateCharacterReferences( snapshot.publicationReferences, + resourcesById, ), uiDesignSpecReference: hydrateCharacterReference( snapshot.uiDesignSpecReference, + resourcesById, ), imageModel: stringOrUndefined(snapshot.imageModel), videoModel: @@ -707,12 +814,19 @@ export function generationInputsOrNull( const item = reference as { title?: unknown; label?: unknown; - src?: unknown; + refType?: unknown; + refId?: unknown; }; const title = stringOrNull(item.title); const label = stringOrNull(item.label); - const src = stringOrNull(item.src); - return title && label && src ? [{ title, label, src }] : []; + const refType: 'project-resource' | 'asset' | null = + item.refType === 'project-resource' || item.refType === 'asset' + ? item.refType + : null; + const refId = stringOrNull(item.refId); + return title && label && refType && refId + ? [{ title, label, refType, refId }] + : []; }) : []; @@ -829,7 +943,20 @@ function hydrateSpecFormValues( }; } -function hydrateCharacterReference(value: unknown): CharacterReferenceImage | null { +function resolveHydratedReferenceResource( + resourceId: string | undefined, + resourcesById: Map, +) { + if (resourceId) { + return resourcesById.get(resourceId); + } + return undefined; +} + +function hydrateCharacterReference( + value: unknown, + resourcesById: Map = new Map(), +): CharacterReferenceImage | null { if (!value || typeof value !== 'object') { return null; } @@ -837,6 +964,8 @@ function hydrateCharacterReference(value: unknown): CharacterReferenceImage | nu const id = stringOrNull(snapshot.id); const label = stringOrNull(snapshot.label); const src = stringOrNull(snapshot.src); + const resourceId = stringOrUndefined(snapshot.resourceId); + const sourceAssetId = stringOrUndefined(snapshot.sourceAssetId); const mediaType: CanvasMediaType = snapshot.mediaType === 'video' || snapshot.mediaType === 'audio' ? snapshot.mediaType @@ -845,29 +974,37 @@ function hydrateCharacterReference(value: unknown): CharacterReferenceImage | nu const sizeBytes = numberFromSnapshot(snapshot.sizeBytes, 0) || undefined; const durationSeconds = numberFromSnapshot(snapshot.durationSeconds, 0) || undefined; - const objectKey = stringOrUndefined(snapshot.objectKey); - const assetObjectId = stringOrUndefined(snapshot.assetObjectId); - return id && label && src + const resource = resolveHydratedReferenceResource(resourceId, resourcesById); + const objectKey = + stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey); + const assetObjectId = + stringOrUndefined(snapshot.assetObjectId) ?? + stringOrUndefined(resource?.assetObjectId); + const resolvedSrc = resource?.imageSrc || objectKey || src; + return id && label && resolvedSrc ? { id, label, - src, + src: resolvedSrc, mediaType, mimeType, sizeBytes, durationSeconds, objectKey, assetObjectId, + resourceId, + sourceAssetId, } : null; } function hydrateCharacterReferences( value: unknown, + resourcesById: Map = new Map(), ): CharacterReferenceImage[] | undefined { return Array.isArray(value) ? value.flatMap((reference) => { - const hydrated = hydrateCharacterReference(reference); + const hydrated = hydrateCharacterReference(reference, resourcesById); return hydrated ? [hydrated] : []; }) : undefined; diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index c9e23ef47..adebbf227 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -59,7 +59,8 @@ export type CanvasGenerationInputField = { export type CanvasGenerationInputReference = { title: string; label: string; - src: string; + refType: 'project-resource' | 'asset'; + refId: string; }; export type CanvasGenerationInputs = { @@ -151,6 +152,8 @@ export type CharacterReferenceImage = { durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; + resourceId?: string | null; + sourceAssetId?: string | null; }; export type PublicationMaterialsGameInfo = { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d556f5cce..f6641bd49 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -558,6 +558,7 @@ export function ImageCanvasEditorView() { } = useImageCanvasUploadWorkflow({ canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, + projectId, assetFolders, activeUploadFolderId, canvasSize, diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index 32c300b02..1148c92f1 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -454,11 +454,12 @@ describe('ImageCanvasGenerationDialogModel', () => { status: 'idle', errorMessage: undefined, composerOpen: true, - characterSpecReference: { + characterSpecReference: expect.objectContaining({ id: 'canvas-layer-source', label: '参考图', src: 'data:image/png;base64,source', - }, + resourceId: 'resource-source', + }), }), ); expect( diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index e5f22bdec..2a6beec95 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -163,14 +163,36 @@ describe('ImageCanvasGenerationModel', () => { expect( buildCharacterGenerationInputs( '主角骑士', - { id: 'spec', label: '角色规范', src: '/spec.png' }, - [{ id: 'ref-1', label: '盔甲参考', src: '/armor.png' }], + { + id: 'spec', + label: '角色规范', + src: '/spec.png', + resourceId: 'resource-spec', + }, + [ + { + id: 'ref-1', + label: '盔甲参考', + src: '/armor.png', + resourceId: 'resource-armor', + }, + ], ), ).toEqual({ fields: [{ title: '角色设定', value: '主角骑士' }], references: [ - { title: '角色规范', label: '角色规范', src: '/spec.png' }, - { title: '常规参考图 1', label: '盔甲参考', src: '/armor.png' }, + { + title: '角色规范', + label: '角色规范', + refType: 'project-resource', + refId: 'resource-spec', + }, + { + title: '常规参考图 1', + label: '盔甲参考', + refType: 'project-resource', + refId: 'resource-armor', + }, ], }); @@ -179,13 +201,19 @@ describe('ImageCanvasGenerationModel', () => { id: 'icon-spec', label: '图标规范', src: '/icon-spec.png', + resourceId: 'resource-icon-spec', }), ).toEqual({ fields: [ { title: '素材描述', value: '返回按钮\n设置按钮' }, ], references: [ - { title: '图标规范', label: '图标规范', src: '/icon-spec.png' }, + { + title: '图标规范', + label: '图标规范', + refType: 'project-resource', + refId: 'resource-icon-spec', + }, ], }); @@ -194,18 +222,38 @@ describe('ImageCanvasGenerationModel', () => { ).toEqual({ fields: [{ title: '修改要求', value: '换成夜晚' }], references: [ - { title: '参考图', label: '原图', src: '/source.png' }, + { + title: '参考图', + label: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, ], }); expect( buildQuickEditGenerationInputs('快速编辑提示词', '让图2下雨', sourceLayer, [ - { id: 'ref-1', label: '天空', src: '/sky.png' }, + { + id: 'ref-1', + label: '天空', + src: '/sky.png', + resourceId: 'resource-sky', + }, ]), ).toEqual({ fields: [{ title: '快速编辑提示词', value: '让图2下雨' }], references: [ - { title: '参考图 1', label: '天空', src: '/sky.png' }, - { title: '参考图 2', label: '原图', src: '/source.png' }, + { + title: '参考图 1', + label: '天空', + refType: 'project-resource', + refId: 'resource-sky', + }, + { + title: '参考图 2', + label: '原图', + refType: 'project-resource', + refId: 'resource-source', + }, ], }); }); @@ -278,11 +326,23 @@ describe('ImageCanvasGenerationModel', () => { buildSpecGenerationInputs( 'custom', { ...blankSpecValues, customPrompt: '生成一张怪兽规范图' }, - { id: 'spec-ref', label: '参考.png', src: '/ref.png' }, + { + id: 'spec-ref', + label: '参考.png', + src: '/ref.png', + resourceId: 'resource-ref', + }, ), ).toEqual({ fields: [{ title: '自定义规范提示词', value: '生成一张怪兽规范图' }], - references: [{ title: '参考图', label: '参考.png', src: '/ref.png' }], + references: [ + { + title: '参考图', + label: '参考.png', + refType: 'project-resource', + refId: 'resource-ref', + }, + ], }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 1b2a1809a..8fedca382 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -7,6 +7,7 @@ import { isGeneratedLayer } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputField, + CanvasGenerationInputReference, CanvasGenerationInputs, CanvasLayer, CharacterReferenceImage, @@ -543,13 +544,7 @@ export function buildCharacterAnimationGenerationInputs( ): CanvasGenerationInputs { return { fields: createGenerationInputField('动作描述', promptText), - references: [ - { - title: '角色图片', - label: sourceLayer.title, - src: sourceLayer.objectKey?.trim() || sourceLayer.src, - }, - ], + references: createLayerGenerationInputReference('角色图片', sourceLayer), }; } @@ -710,9 +705,57 @@ export function createCanvasLayerReference( ...(mediaType ? { mediaType } : {}), objectKey: layer.objectKey ?? undefined, assetObjectId: layer.assetObjectId ?? undefined, + resourceId: layer.resourceId, + sourceAssetId: layer.sourceAssetId ?? undefined, }; } +export function createGenerationInputReference( + title: string, + reference: Pick< + CharacterReferenceImage, + 'label' | 'resourceId' | 'sourceAssetId' + >, +): CanvasGenerationInputReference[] { + const resourceId = reference.resourceId?.trim(); + if ( + resourceId && + !resourceId.startsWith('local-') && + !resourceId.startsWith('generation-dialog:') + ) { + return [ + { + title, + label: reference.label, + refType: 'project-resource', + refId: resourceId, + }, + ]; + } + const sourceAssetId = reference.sourceAssetId?.trim(); + return sourceAssetId && !sourceAssetId.startsWith('upload-') + ? [ + { + title, + label: reference.label, + refType: 'asset', + refId: sourceAssetId, + }, + ] + : []; +} + +export function createLayerGenerationInputReference( + title: string, + layer: CanvasLayer, +): CanvasGenerationInputReference[] { + return createGenerationInputReference(title, { + label: layer.title, + resourceId: layer.resourceId, + sourceAssetId: layer.sourceAssetId, + }); +} + export function appendLimitedQuickEditReferences( references: CharacterReferenceImage[] | undefined, nextReferences: CharacterReferenceImage[], @@ -751,11 +794,9 @@ export function buildImageGenerationInputs( ): CanvasGenerationInputs { return { fields: createGenerationInputField('生成提示词', prompt), - references: (references ?? []).map((reference, index) => ({ - title: `参考图 ${index + 1}`, - label: reference.label, - src: reference.src, - })), + references: (references ?? []).flatMap((reference, index) => + createGenerationInputReference(`参考图 ${index + 1}`, reference), + ), }; } @@ -770,7 +811,7 @@ export function buildVideoGenerationInputs( }; return { fields: createGenerationInputField('视频描述', prompt), - references: (references ?? []).map((reference) => { + references: (references ?? []).flatMap((reference) => { const mediaType = reference.mediaType ?? 'image'; mediaIndexes[mediaType] += 1; const title = @@ -779,11 +820,7 @@ export function buildVideoGenerationInputs( : mediaType === 'audio' ? `参考音频 ${mediaIndexes.audio}` : `参考图 ${mediaIndexes.image}`; - return { - title, - label: reference.label, - src: reference.src, - }; + return createGenerationInputReference(title, reference); }), }; } @@ -821,7 +858,7 @@ export function buildSpecGenerationInputs( reference?: CharacterReferenceImage | null, ): CanvasGenerationInputs { const references = reference - ? [{ title: '参考图', label: reference.label, src: reference.src }] + ? createGenerationInputReference('参考图', reference) : []; if (specType === 'custom') { return { @@ -855,19 +892,11 @@ export function buildCharacterGenerationInputs( fields: createGenerationInputField('角色设定', prompt), references: [ ...(specReference - ? [ - { - title: '角色规范', - label: specReference.label, - src: specReference.src, - }, - ] + ? createGenerationInputReference('角色规范', specReference) : []), - ...(references ?? []).map((reference, index) => ({ - title: `常规参考图 ${index + 1}`, - label: reference.label, - src: reference.src, - })), + ...(references ?? []).flatMap((reference, index) => + createGenerationInputReference(`常规参考图 ${index + 1}`, reference), + ), ], }; } @@ -879,13 +908,7 @@ export function buildUiDesignGenerationInputs( return { fields: createGenerationInputField('用户输入', prompt), references: specReference - ? [ - { - title: '图标规范', - label: specReference.label, - src: specReference.src, - }, - ] + ? createGenerationInputReference('图标规范', specReference) : [], }; } @@ -947,11 +970,9 @@ export function buildPublicationMaterialsGenerationInputs( normalizedGameInfo.gameDescription, ), ], - references: (references ?? []).map((reference, index) => ({ - title: `宣发参考图 ${index + 1}`, - label: reference.label, - src: reference.src, - })), + references: (references ?? []).flatMap((reference, index) => + createGenerationInputReference(`宣发参考图 ${index + 1}`, reference), + ), }; } @@ -966,13 +987,7 @@ export function buildIconGenerationInputs( value: iconDescriptions.join('\n'), }, ], - references: [ - { - title: '图标规范', - label: specReference.label, - src: specReference.src, - }, - ], + references: createGenerationInputReference('图标规范', specReference), }; } @@ -984,13 +999,7 @@ export function buildUiDesignAssetExtractionGenerationInputs( '提取提示词', UI_DESIGN_ASSET_EXTRACTION_PROMPT, ), - references: [ - { - title: 'UI设计图', - label: sourceLayer.title, - src: sourceLayer.objectKey?.trim() || sourceLayer.src, - }, - ], + references: createLayerGenerationInputReference('UI设计图', sourceLayer), }; } @@ -1001,13 +1010,7 @@ export function buildEditGenerationInputs( ): CanvasGenerationInputs { return { fields: createGenerationInputField(title, prompt), - references: [ - { - title: '参考图', - label: sourceLayer.title, - src: sourceLayer.src, - }, - ], + references: createLayerGenerationInputReference('参考图', sourceLayer), }; } @@ -1024,16 +1027,13 @@ export function buildQuickEditGenerationInputs( return { fields: createGenerationInputField(title, prompt), references: [ - ...limitedReferences.map((reference, index) => ({ - title: `参考图 ${index + 1}`, - label: reference.label, - src: reference.src, - })), - { - title: `参考图 ${limitedReferences.length + 1}`, - label: sourceLayer.title, - src: sourceLayer.objectKey?.trim() || sourceLayer.src, - }, + ...limitedReferences.flatMap((reference, index) => + createGenerationInputReference(`参考图 ${index + 1}`, reference), + ), + ...createLayerGenerationInputReference( + `参考图 ${limitedReferences.length + 1}`, + sourceLayer, + ), ], }; } diff --git a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts index 8c633a021..bbba26d12 100644 --- a/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts @@ -117,7 +117,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => { { title: '参考图', label: '源图', - src: 'data:image/png;base64,source', + refType: 'project-resource', + refId: 'resource-source', }, ], }, @@ -142,6 +143,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { id: 'spec-ref', label: '参考.png', src: 'data:image/png;base64,ref', + resourceId: 'resource-spec-ref', }, }, layers: [], @@ -174,7 +176,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => { { title: '参考图', label: '参考.png', - src: 'data:image/png;base64,ref', + refType: 'project-resource', + refId: 'resource-spec-ref', }, ], }); @@ -221,12 +224,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { id: 'spec', label: '角色规范', src: 'data:image/png;base64,spec', + resourceId: 'resource-character-spec', }, characterReferences: [ { id: 'ref-1', label: '盔甲参考', src: 'data:image/png;base64,armor', + resourceId: 'resource-armor', }, ], }, @@ -260,12 +265,14 @@ describe('ImageCanvasGenerationSubmissionModel', () => { { title: '角色规范', label: '角色规范', - src: 'data:image/png;base64,spec', + refType: 'project-resource', + refId: 'resource-character-spec', }, { title: '常规参考图 1', label: '盔甲参考', - src: 'data:image/png;base64,armor', + refType: 'project-resource', + refId: 'resource-armor', }, ], }); @@ -287,6 +294,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { id: 'ref-1', label: '参考图', src: 'data:image/png;base64,ref', + resourceId: 'resource-publication-ref', }, ], }, @@ -325,7 +333,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => { { title: '宣发参考图 1', label: '参考图', - src: 'data:image/png;base64,ref', + refType: 'project-resource', + refId: 'resource-publication-ref', }, ], }, @@ -490,6 +499,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { id: 'icon-spec', label: '图标规范', src: 'data:image/png;base64,spec', + resourceId: 'resource-icon-spec', }, iconDescriptions: [' ', '\n'], }); @@ -512,6 +522,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { id: 'icon-spec', label: '图标规范', src: 'data:image/png;base64,spec', + resourceId: 'resource-icon-spec', }, iconDescriptions: ['旧返回', '旧设置'], }); @@ -533,7 +544,8 @@ describe('ImageCanvasGenerationSubmissionModel', () => { { title: '图标规范', label: '图标规范', - src: 'data:image/png;base64,spec', + refType: 'project-resource', + refId: 'resource-icon-spec', }, ], }, @@ -604,6 +616,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { mediaType: 'image', mimeType: 'image/png', sizeBytes: 1024, + resourceId: 'resource-video-image-ref', }, { id: 'video-ref', @@ -615,6 +628,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { durationSeconds: 4, objectKey: 'generated-character-drafts/editor/seedance-references/video/video.mp4', + resourceId: 'resource-video-ref', }, { id: 'audio-ref', @@ -626,6 +640,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => { durationSeconds: 3, objectKey: 'generated-character-drafts/editor/seedance-references/audio/audio.mp3', + resourceId: 'resource-audio-ref', }, ], }, @@ -648,9 +663,24 @@ describe('ImageCanvasGenerationSubmissionModel', () => { result: { generationInputs: { references: [ - { title: '参考图 1', label: '参考图.png' }, - { title: '参考视频 1', label: '参考视频.mp4' }, - { title: '参考音频 1', label: '参考音频.mp3' }, + { + title: '参考图 1', + label: '参考图.png', + refType: 'project-resource', + refId: 'resource-video-image-ref', + }, + { + title: '参考视频 1', + label: '参考视频.mp4', + refType: 'project-resource', + refId: 'resource-video-ref', + }, + { + title: '参考音频 1', + label: '参考音频.mp3', + refType: 'project-resource', + refId: 'resource-audio-ref', + }, ], }, }, diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index b02424d18..5c91d2b68 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -39,7 +39,8 @@ describe('ImageCanvasMetadataModalView', () => { { title: '参考图', label: '角色立绘', - src: 'data:image/png;base64,reference', + refType: 'project-resource', + refId: 'resource-reference', }, ], }, @@ -56,6 +57,7 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('清爽游戏按钮')).toBeTruthy(); expect(within(dialog).getByText('参考图')).toBeTruthy(); expect(within(dialog).getByText('角色立绘')).toBeTruthy(); + expect(within(dialog).getByText('项目资源 · resource-reference')).toBeTruthy(); expect(within(dialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(dialog).getByText('1024 x 768 px')).toBeTruthy(); expect(within(dialog).queryByText('Provider')).toBeNull(); diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.tsx index af9191ca3..f7e5f0f0c 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.tsx @@ -13,6 +13,10 @@ function formatTaskIdForDisplay(taskId?: string | null) { return numericParts?.length ? numericParts[numericParts.length - 1] : '-'; } +function formatReferenceType(refType: 'project-resource' | 'asset') { + return refType === 'project-resource' ? '项目资源' : '素材'; +} + export function ImageCanvasMetadataModalView({ layer, onClose, @@ -57,15 +61,18 @@ export function ImageCanvasMetadataModalView({
{layer.generationInputs.references.map((reference) => (
- {reference.title} {reference.label} + + {formatReferenceType(reference.refType)} ·{' '} + {reference.refId} +
))} diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index ca56be713..c4d04c9c7 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -95,6 +95,8 @@ export function createUploadedGenerationReference({ durationSeconds, objectKey, assetObjectId, + resourceId, + sourceAssetId, }: { idPrefix: string; index?: number; @@ -107,6 +109,8 @@ export function createUploadedGenerationReference({ durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; + resourceId?: string | null; + sourceAssetId?: string | null; }): CharacterReferenceImage { return { id: @@ -121,6 +125,8 @@ export function createUploadedGenerationReference({ durationSeconds, objectKey, assetObjectId, + resourceId, + sourceAssetId, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 1a56e97fe..aecca849e 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -663,7 +663,8 @@ describe('useImageCanvasGenerationWorkflow', () => { { title: '参考图 1', label: '画布参考图', - src: 'data:image/png;base64,source', + refType: 'project-resource', + refId: 'resource-source', }, ], }, @@ -739,7 +740,8 @@ describe('useImageCanvasGenerationWorkflow', () => { { title: '参考图 1', label: '源图', - src: 'data:image/png;base64,source', + refType: 'project-resource', + refId: 'resource-source', }, ], }, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index c2cd7fa3c..d492728f3 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -230,6 +230,7 @@ export function useImageCanvasProjectPersistence({ ); const { layerItems, generationDialogs } = splitCanvasLayoutItems( project.layers, + resourcesById, ); const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index c46846e06..f92087cd2 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -23,6 +23,7 @@ import type { import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; const createEditorAssetMock = vi.hoisted(() => vi.fn()); +const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); let createdMediaElements: HTMLMediaElement[] = []; @@ -34,6 +35,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { return { ...actual, createEditorAsset: createEditorAssetMock, + createEditorProjectResource: createEditorProjectResourceMock, }; }); @@ -112,10 +114,12 @@ function UploadWorkflowHarness({ canAccessProtectedData = true, openEditorLoginModal = vi.fn(), activeTool = 'select', + projectId = 'project-1', }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; activeTool?: CanvasTool; + projectId?: string | null; }) { const [assetFolders, setAssetFolders] = useState([ createDefaultFolder(), @@ -132,6 +136,7 @@ function UploadWorkflowHarness({ const workflow = useImageCanvasUploadWorkflow({ canAccessProtectedData, openEditorLoginModal, + projectId, assetFolders, activeUploadFolderId: 'project', canvasSize: { width: 900, height: 640 }, @@ -189,7 +194,7 @@ function UploadWorkflowHarness({ {selectedLayerId ?? '-'} {generateDialog - ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}`).join(',') || '-'}` + ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}:${reference.sourceAssetId ?? '-'}`).join(',') || '-'}` : '-'}