From 946308b75e184c61347cba3a56e2bc8173d8fdb4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 23:54:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=94=9F=E6=88=90=E5=99=A8=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将生成器对话框作为画布布局项序列化和恢复 生成成功后保留生成器快照并锚定到成品图层 图片类生成结果同步写入账号素材库 补充生成器持久化测试和浏览器回归相关文档 --- .../shared-memory/decision-log.md | 8 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 9 + ...CanvasEditorGenerationIntegration.test.tsx | 89 +++++++ .../ImageCanvasEditorModel.test.ts | 74 +++++- .../image-editor/ImageCanvasEditorModel.ts | 232 +++++++++++++++++ .../image-editor/ImageCanvasEditorView.tsx | 96 ++++++- .../ImageCanvasGenerationComposerView.tsx | 5 +- .../ImageCanvasWorldView.test.tsx | 24 ++ .../image-editor/ImageCanvasWorldView.tsx | 12 +- .../useCanvasGenerationDialogs.test.tsx | 83 ++++++ .../useCanvasGenerationDialogs.ts | 36 +++ ...anvasGenerationSubmissionWorkflow.test.tsx | 58 ++++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 60 +++-- .../useImageCanvasGenerationSurface.test.tsx | 1 - .../useImageCanvasGenerationSurface.tsx | 6 +- .../useImageCanvasGenerationWorkflow.test.tsx | 3 +- .../useImageCanvasGenerationWorkflow.ts | 6 +- .../useImageCanvasProjectPersistence.test.tsx | 236 ++++++++++++++++-- .../useImageCanvasProjectPersistence.ts | 82 ++++-- 20 files changed, 1044 insertions(+), 80 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f4a522a0..505ad2e0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2326,3 +2326,11 @@ - 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。 - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx -t "opens the bottom generate video panel"`、`npm run test -- src/components/image-editor/ImageCanvasMetadataModalView.test.tsx`、`npm run test -- src/services/image-editor/editorProjectClient.test.ts`、`cargo test -p shared-contracts editor_video --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_video --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 - 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-06-17 图片画布生成器快照纳入画布布局 + +- 背景:生成占位图和生成器对话框里包含用户输入、参数、参考图、占位框位置和生成结果绑定,刷新后丢失会让已生成图片无法回到 Lovart 式跟随编辑状态。 +- 决策:生成器对象统一作为 `editor_canvas` 布局 JSON 的 `itemType: "generation-dialog"` 项保存,不新增表;成功生成后仍保留生成器快照和最后占位框位置,并通过 `generatedLayerId` 锚定到成品图层,渲染时不重复显示灰色占位框。图片类生成结果同步写入账号级素材库;视频结果当前只作为画布视频资源保存。 +- 影响范围:图片画布 layout 序列化 / hydrate、生成工作流、生成器渲染、项目自动保存、素材库回填和编辑器技术方案。 +- 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`、浏览器刷新 smoke。 +- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 67c0ecec..ddf90980 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -41,7 +41,8 @@ - `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。 - 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。 - 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。 -- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 资源表只保存资源元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 +- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;视频结果当前只保存为画布视频资源,不进入图片素材库。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -85,6 +86,7 @@ - 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。 - 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。 - 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。 +- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。 - 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。 diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index d617fa79..4348dc1c 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -62,6 +62,14 @@ - `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。 - 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。 +## 画布保存 + +- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。 +- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。 +- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。 +- 图片类生成结果还要写入账号级素材库;视频结果先只作为画布资源和视频图层保存。 +- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。 + ## 第一版计费配置 ```text @@ -99,3 +107,4 @@ - 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。 - 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。 - 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。 +- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。 diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 6577a72d..72715b23 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -80,6 +80,75 @@ describe('ImageCanvasEditorView generation integration', () => { saveEditorProjectLayoutMock, }); + it('restores generated image composer from saved generator snapshots', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-generated-dialog', + title: '已生成画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-generated-restored', + resourceId: 'resource-generated-restored', + title: '生成图片 1', + x: 236, + y: 66, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + zIndex: 11, + sourceType: 'generated', + generationInputs: { + fields: [{ title: '生成提示词', value: '刷新后恢复生成器' }], + references: [], + }, + }, + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '刷新后恢复生成器', + status: 'idle', + composerOpen: true, + generatedLayerId: 'layer-generated-restored', + placeholder: { + x: 282, + y: 112, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }, + }, + ], + resources: [ + { + resourceId: 'resource-generated-restored', + projectId: 'editor-project-generated-dialog', + imageSrc: 'data:image/png;base64,cmVzdG9yZWQ=', + width: 512, + height: 512, + sourceType: 'generated', + }, + ], + updatedAt: '2026-06-17T00:00:00.000Z', + }); + + render(); + + expect(await screen.findByAltText('画布图片:生成图片 1')).toBeTruthy(); + expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); + const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); + expect(generateDialog).toBeTruthy(); + expect((screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value).toBe( + '刷新后恢复生成器', + ); + }); + it('opens a canvas generation frame and composer before creating a generated layer', async () => { generateEditorImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', @@ -151,6 +220,26 @@ describe('ImageCanvasEditorView generation integration', () => { await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); + await waitFor(() => { + expect(createEditorAssetMock).toHaveBeenCalledWith( + expect.objectContaining({ + folderId: 'project', + label: expect.stringMatching(/生成图片/u), + imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', + width: 1024, + height: 1024, + sourceType: 'generated', + prompt: '一张明亮的拼图主视觉', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'editor-real-task-1', + }), + ); + }); + fireEvent.click(screen.getByRole('button', { name: '打开素材' })); + expect( + screen.getByRole('button', { name: /添加生成图片/u }), + ).toBeTruthy(); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 47ce7f6a..b027ef77 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -7,9 +7,15 @@ import { normalizeAssetLibrary, normalizeCanvasBackgroundHex, resolveSnappedLayerPosition, + serializeCanvasLayout, serializeLayer, + splitCanvasLayoutItems, } from './ImageCanvasEditorModel'; -import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + EditorAsset, +} from './ImageCanvasEditorTypes'; describe('ImageCanvasEditorModel', () => { it('normalizes valid canvas background hex values and rejects invalid input', () => { @@ -122,6 +128,72 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('serializes generation dialogs beside layers and splits them on load', () => { + const layer: CanvasLayer = { + id: 'layer-generated', + resourceId: 'resource-generated', + title: '生成图', + src: 'data:image/png;base64,heavy', + x: 10, + y: 20, + width: 1024, + height: 768, + originalWidth: 1024, + originalHeight: 768, + zIndex: 9, + sourceType: 'generated', + }; + const dialog: CanvasGenerationDialogState = { + id: 'generation-dialog-9', + mode: 'generate', + prompt: '刷新后要继续保留', + status: 'generating', + composerOpen: false, + generatedLayerId: 'layer-generated', + imageModel: 'gpt-image-2', + placeholder: { + x: 100, + y: 120, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + generationReferences: [ + { + id: 'reference-1', + label: '参考图', + src: 'data:image/png;base64,ref', + }, + ], + }; + + const layout = serializeCanvasLayout({ + layers: [layer], + canvasGenerationDialogs: [dialog], + }); + const { layerItems, generationDialogs } = splitCanvasLayoutItems(layout); + + expect(layerItems).toHaveLength(1); + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: 'generation-dialog-9', + mode: 'generate', + prompt: '刷新后要继续保留', + status: 'generating', + generatedLayerId: 'layer-generated', + imageModel: 'gpt-image-2', + placeholder: { + x: 100, + y: 120, + width: 420, + }, + }); + expect(generationDialogs[0]?.generationReferences?.[0]).toMatchObject({ + label: '参考图', + }); + }); + it('snaps moving layers to nearby canvas and layer guides', () => { const movingLayer: CanvasLayer = { id: 'moving', diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index c2dde13b..567610e4 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -4,6 +4,7 @@ import type { } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, + CanvasGenerationDialogState, CanvasGenerationInputs, CanvasLayer, CanvasContextMenuState, @@ -170,6 +171,145 @@ export function serializeLayer( }; } +type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & { + itemType: 'generation-dialog'; + dialog: CanvasGenerationDialogState; +}; + +export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; + +export function serializeCanvasGenerationDialog( + dialog: CanvasGenerationDialogState, +): CanvasGenerationDialogSnapshot { + return { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialog.id}`, + resourceId: `generation-dialog:${dialog.id}`, + dialog, + }; +} + +export function serializeCanvasLayout({ + layers, + canvasGenerationDialogs, +}: { + layers: CanvasLayer[]; + canvasGenerationDialogs: CanvasGenerationDialogState[]; +}): CanvasLayoutItems { + return [ + ...layers.map(serializeLayer), + ...canvasGenerationDialogs.map(serializeCanvasGenerationDialog), + ]; +} + +export function isCanvasGenerationDialogLayoutItem( + item: EditorProjectLayerSnapshot, +): item is CanvasGenerationDialogSnapshot { + return ( + item.itemType === 'generation-dialog' && + Boolean( + hydrateCanvasGenerationDialog( + (item as { dialog?: unknown }).dialog, + ), + ) + ); +} + +export function splitCanvasLayoutItems( + items: EditorProjectLayerSnapshot[], +): { + layerItems: EditorProjectLayerSnapshot[]; + generationDialogs: CanvasGenerationDialogState[]; +} { + const layerItems: EditorProjectLayerSnapshot[] = []; + const generationDialogs: CanvasGenerationDialogState[] = []; + + items.forEach((item) => { + if (isCanvasGenerationDialogLayoutItem(item)) { + const dialog = hydrateCanvasGenerationDialog(item.dialog); + if (dialog) { + generationDialogs.push(dialog); + } + return; + } + layerItems.push(item); + }); + + return { layerItems, generationDialogs }; +} + +export function hydrateCanvasGenerationDialog( + value: unknown, +): CanvasGenerationDialogState | null { + if (!value || typeof value !== 'object') { + return null; + } + const snapshot = value as Partial; + const id = stringOrNull(snapshot.id); + const prompt = typeof snapshot.prompt === 'string' ? snapshot.prompt : ''; + if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { + return null; + } + + return { + id, + mode: snapshot.mode, + prompt, + status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', + composerOpen: + typeof snapshot.composerOpen === 'boolean' + ? snapshot.composerOpen + : true, + sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), + generatedLayerId: stringOrUndefined(snapshot.generatedLayerId), + specType: isSpecGenerationType(snapshot.specType) + ? snapshot.specType + : undefined, + specValues: hydrateSpecFormValues(snapshot.specValues), + specReference: hydrateCharacterReference(snapshot.specReference), + generationReferences: hydrateCharacterReferences( + snapshot.generationReferences, + ), + characterSpecReference: hydrateCharacterReference( + snapshot.characterSpecReference, + ), + characterReferences: hydrateCharacterReferences( + snapshot.characterReferences, + ), + iconSpecReference: hydrateCharacterReference(snapshot.iconSpecReference), + iconDescriptions: Array.isArray(snapshot.iconDescriptions) + ? snapshot.iconDescriptions.filter( + (description): description is string => + typeof description === 'string', + ) + : undefined, + uiDesignSpecReference: hydrateCharacterReference( + snapshot.uiDesignSpecReference, + ), + imageModel: stringOrUndefined(snapshot.imageModel), + videoModel: + typeof snapshot.videoModel === 'string' + ? snapshot.videoModel + : undefined, + videoAspectRatio: stringOrUndefined(snapshot.videoAspectRatio), + videoResolution: + snapshot.videoResolution === '480p' || snapshot.videoResolution === '720p' + ? snapshot.videoResolution + : undefined, + videoDurationSeconds: + snapshot.videoDurationSeconds === 4 || + snapshot.videoDurationSeconds === 5 + ? snapshot.videoDurationSeconds + : undefined, + videoMode: snapshot.videoMode === 'std' ? 'std' : undefined, + videoSound: snapshot.videoSound === 'off' ? 'off' : undefined, + aspectRatio: stringOrUndefined(snapshot.aspectRatio), + imageSize: stringOrUndefined(snapshot.imageSize), + errorMessage: stringOrUndefined(snapshot.errorMessage), + placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), + }; +} + export function hydrateLayer( snapshot: EditorProjectLayerSnapshot, resourcesById: Map, @@ -298,6 +438,10 @@ export function stringOrNull(value: unknown) { return typeof value === 'string' && value.trim() ? value : null; } +export function stringOrUndefined(value: unknown) { + return typeof value === 'string' && value.trim() ? value : undefined; +} + export function booleanFromSnapshot(value: unknown) { return value === true; } @@ -429,6 +573,94 @@ export function isGeneratedLayer(layer: CanvasLayer) { ); } +function isCanvasGenerationDialogMode( + value: unknown, +): value is CanvasGenerationDialogState['mode'] { + return ( + value === 'generate' || + value === 'spec' || + value === 'character' || + value === 'icon' || + value === 'ui-design' || + value === 'video' + ); +} + +function isGenerationStatus( + value: unknown, +): value is CanvasGenerationDialogState['status'] { + return value === 'idle' || value === 'generating' || value === 'failed'; +} + +function isSpecGenerationType( + value: unknown, +): value is NonNullable { + return ( + value === 'character' || + value === 'ui' || + value === 'icon' || + value === 'custom' + ); +} + +function hydrateSpecFormValues( + value: unknown, +): CanvasGenerationDialogState['specValues'] { + if (!value || typeof value !== 'object') { + return undefined; + } + const snapshot = value as Record; + return { + playSetting: + typeof snapshot.playSetting === 'string' ? snapshot.playSetting : '', + artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '', + bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '', + characterView: + typeof snapshot.characterView === 'string' + ? snapshot.characterView + : '', + customPrompt: + typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '', + }; +} + +function hydrateCharacterReference(value: unknown) { + if (!value || typeof value !== 'object') { + return null; + } + const snapshot = value as Record; + const id = stringOrNull(snapshot.id); + const label = stringOrNull(snapshot.label); + const src = stringOrNull(snapshot.src); + return id && label && src ? { id, label, src } : null; +} + +function hydrateCharacterReferences(value: unknown) { + return Array.isArray(value) + ? value.flatMap((reference) => { + const hydrated = hydrateCharacterReference(reference); + return hydrated ? [hydrated] : []; + }) + : undefined; +} + +function hydrateGenerationPlaceholder( + value: unknown, +): CanvasGenerationDialogState['placeholder'] { + if (!value || typeof value !== 'object') { + return undefined; + } + const snapshot = value as Record; + return { + x: numberFromSnapshot(snapshot.x, 0), + y: numberFromSnapshot(snapshot.y, 0), + width: numberFromSnapshot(snapshot.width, 320), + height: numberFromSnapshot(snapshot.height, 320), + originalWidth: numberFromSnapshot(snapshot.originalWidth, 320), + originalHeight: numberFromSnapshot(snapshot.originalHeight, 320), + }; +} + export function getLayerBounds(targetLayers: CanvasLayer[]) { if (targetLayers.length === 0) { return null; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index c61a0f54..0b31c291 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -1,11 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createEditorAsset } from '../../services/image-editor/editorProjectClient'; import { useAuthUi } from '../auth/AuthUiContext'; import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView'; import { resolveContextMenuPosition } from './ImageCanvasEditorModel'; import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel'; import type { AssetPointerDragState, + CanvasGenerationDialogState, CanvasContextMenuState, CanvasLayer, CanvasTool, @@ -43,6 +45,7 @@ export function ImageCanvasEditorView() { const hasRequestedLoginRef = useRef(false); const layerCounterRef = useRef(0); const layersRef = useRef([]); + const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); @@ -219,13 +222,14 @@ export function ImageCanvasEditorView() { canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, activateCanvasGenerationDialog, + restoreCanvasGenerationDialogs, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, } = useCanvasGenerationDialogs({ onActivate: handleActivateCanvasGenerationDialog, }); + canvasGenerationDialogsRef.current = canvasGenerationDialogs; const canvasHistoryRefs = useMemo( () => ({ layersRef, @@ -277,16 +281,98 @@ export function ImageCanvasEditorView() { isCanvasGenerationComposerVisible(currentDialog) ? { ...currentDialog, - composerOpen: false, + composerOpen: currentDialog.generatedLayerId === layerId, } : currentDialog, ); } }, []); + const persistGeneratedAsset = useCallback( + (layer: CanvasLayer) => { + if (authUiRef.current && !authUiRef.current.canAccessProtectedData) { + openEditorLoginModal(); + return; + } + createEditorAsset({ + folderId: activeUploadFolderId, + label: layer.title, + imageSrc: layer.src, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + width: layer.originalWidth, + height: layer.originalHeight, + sourceType: 'generated', + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + }) + .then((asset) => { + setAssets((currentAssets) => [ + ...currentAssets.filter( + (currentAsset) => currentAsset.id !== asset.assetId, + ), + { + id: asset.assetId, + label: asset.label, + src: asset.imageSrc, + width: asset.width, + height: asset.height, + folderId: asset.folderId, + sourceKind: 'uploaded', + sourceType: asset.sourceType, + persisted: true, + prompt: asset.prompt ?? undefined, + actualPrompt: asset.actualPrompt ?? undefined, + model: asset.model ?? undefined, + provider: asset.provider ?? undefined, + taskId: asset.taskId ?? undefined, + objectKey: asset.objectKey ?? undefined, + assetObjectId: asset.assetObjectId ?? undefined, + }, + ]); + setAssetFolders((currentFolders) => + currentFolders.map((folder) => + folder.id === asset.folderId + ? { + ...folder, + collapsed: false, + } + : folder, + ), + ); + setLayers((currentLayers) => + currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + sourceAssetId: asset.assetId, + objectKey: asset.objectKey ?? currentLayer.objectKey, + assetObjectId: + asset.assetObjectId ?? currentLayer.assetObjectId, + } + : currentLayer, + ), + ); + }) + .catch((error: unknown) => { + if ( + error instanceof Error && + 'status' in error && + (error.status === 401 || error.status === 403) + ) { + openEditorLoginModal(); + } + }); + }, + [activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets], + ); const projectPersistenceRefs = useMemo( () => ({ layersRef, viewportRef, + canvasGenerationDialogsRef, }), [], ); @@ -300,14 +386,16 @@ export function ImageCanvasEditorView() { setLayerCounter: (value: number) => { layerCounterRef.current = value; }, + restoreCanvasGenerationDialogs, }), - [selectSingleLayer], + [restoreCanvasGenerationDialogs, selectSingleLayer], ); const { projectId, appendCanvasLayersWithResources } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, layers, + canvasGenerationDialogs, viewport, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, openEditorLoginModal, @@ -364,7 +452,6 @@ export function ImageCanvasEditorView() { canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, @@ -375,6 +462,7 @@ export function ImageCanvasEditorView() { setMetadataLayer, setImageContextMenu, requestUpload, + persistGeneratedAsset, }); const { quickEditPanel, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 5cb012d9..96f0cc0d 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -260,7 +260,10 @@ function ImageCanvasVideoGenerationComposerView({ EDITOR_VIDEO_MODEL_OPTIONS[ (Math.max(currentIndex, 0) + 1) % EDITOR_VIDEO_MODEL_OPTIONS.length - ]; + ] ?? EDITOR_VIDEO_MODEL_OPTIONS[0]; + if (!nextModel) { + return currentDialog; + } return { ...currentDialog, videoModel: nextModel.value, diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index c030e766..d01ad1f8 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -218,4 +218,28 @@ describe('ImageCanvasWorldView', () => { expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog); expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); + + it('keeps saved generator placeholders hidden after a result layer exists', () => { + renderWorldView({ + layers: [createLayer({ id: 'layer-generated', title: '生成图片' })], + canvasGenerationDialogs: [ + createGenerationDialog({ + generatedLayerId: 'layer-generated', + placeholder: { + x: 80, + y: 90, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }), + ], + }); + + expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); + expect( + screen.queryByRole('button', { name: '图像生成占位图' }), + ).toBeNull(); + }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 69b8a999..25f1b977 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -216,8 +216,12 @@ export function ImageCanvasWorldView({ }} /> ) : null} - {canvasGenerationDialogs.map((dialog) => - dialog.placeholder ? ( + {canvasGenerationDialogs.map((dialog) => { + const hasGeneratedLayer = Boolean( + dialog.generatedLayerId && + layers.some((layer) => layer.id === dialog.generatedLayerId), + ); + return dialog.placeholder && !hasGeneratedLayer ? (
) : null}
- ) : null, - )} + ) : null; + })} {(generateDialog?.mode === 'generate' || generateDialog?.mode === 'spec' || generateDialog?.mode === 'character' || diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index 7c2ab16a..b933c82c 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -128,6 +128,53 @@ function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) { > remove layer dialogs + + ); } @@ -174,4 +221,40 @@ describe('useCanvasGenerationDialogs', () => { expect(screen.getByTestId('active-prompt').textContent).toBe('-'); expect(screen.getByTestId('inactive').textContent).toBe(''); }); + + it('restores saved dialogs and continues ids after the saved maximum', () => { + const onActivate = vi.fn(); + render(); + + act(() => screen.getByRole('button', { name: 'restore saved' }).click()); + expect(screen.getByTestId('active-id').textContent).toBe( + 'generation-dialog-12', + ); + expect(screen.getByTestId('active-prompt').textContent).toBe('restored'); + + act(() => screen.getByRole('button', { name: 'open second' }).click()); + expect(screen.getByTestId('active-id').textContent).toBe( + 'generation-dialog-13', + ); + expect(screen.getByTestId('inactive').textContent).toContain( + 'generation-dialog-12:restored:false', + ); + }); + + it('keeps the saved open dialog active when restoring multiple dialogs', () => { + const onActivate = vi.fn(); + render(); + + act(() => screen.getByRole('button', { name: 'restore active' }).click()); + + expect(screen.getByTestId('active-id').textContent).toBe( + 'generation-dialog-3', + ); + expect(screen.getByTestId('active-prompt').textContent).toBe( + 'active saved', + ); + expect(screen.getByTestId('inactive').textContent).toContain( + 'generation-dialog-2:inactive saved:false', + ); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index d2bc52fb..8313df4b 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -127,6 +127,41 @@ export function useCanvasGenerationDialogs({ [onActivate], ); + const restoreCanvasGenerationDialogs = useCallback( + (dialogs: CanvasGenerationDialogState[]) => { + const nextCounter = dialogs.reduce((maxCounter, dialog) => { + const match = /^generation-dialog-(\d+)$/u.exec(dialog.id); + const numericId = match ? Number.parseInt(match[1] ?? '0', 10) : 0; + return Math.max(maxCounter, Number.isFinite(numericId) ? numericId : 0); + }, 0); + generationDialogCounterRef.current = Math.max( + generationDialogCounterRef.current, + nextCounter, + ); + const activeDialog = + [...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ?? + dialogs[dialogs.length - 1] ?? + null; + setGenerateDialog( + activeDialog + ? { + ...activeDialog, + composerOpen: activeDialog.composerOpen !== false, + } + : null, + ); + setInactiveGenerateDialogs( + dialogs + .filter((dialog) => dialog.id !== activeDialog?.id) + .map((dialog) => ({ + ...dialog, + composerOpen: false, + })), + ); + }, + [], + ); + const removeCanvasGenerationDialogsByLayerId = useCallback( (targetLayerId: string) => { const keepDialog = (dialog: CanvasGenerationDialogState) => @@ -182,6 +217,7 @@ export function useCanvasGenerationDialogs({ updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, activateCanvasGenerationDialog, + restoreCanvasGenerationDialogs, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, }; diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 1269eda8..fc18e764 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -133,7 +133,6 @@ function SubmissionWorkflowHarness({ setCharacterAnimationPanel, setGenerateDialog: dialogs.setGenerateDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), @@ -412,7 +411,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect(screen.getByTestId('dialog').textContent).toBe( - 'generate:idle:open:layer-generated-1:-:-', + 'generate:idle:open:layer-generated-1:placeholder:-', ); }); @@ -468,6 +467,14 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { src: 'data:image/png;base64,spec', }, iconDescriptions: [' 返回按钮 ', '', '设置按钮'], + placeholder: { + x: 140, + y: 160, + width: 360, + height: 360, + originalWidth: 512, + originalHeight: 512, + }, }} />, ); @@ -493,12 +500,57 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { 'layer-icon-2:设置按钮:-:icon', ); expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1'); - expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('dialog').textContent).toBe( + 'icon:idle:open:layer-icon-1:placeholder:-', + ); expect(screen.getByTestId('remembered-model').textContent).toBe( 'gpt-image-2', ); }); + it('keeps character generation dialog data after the result is created', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ width: 768, height: 768 }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated-1:角色形象 1:-:character', + ); + }); + expect(screen.getByTestId('dialog').textContent).toBe( + 'character:idle:open:layer-generated-1:placeholder:-', + ); + }); + it('moves character animation panels from generating to completed', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ frames: [{ imageSrc: 'data:image/png;base64,frame' }], diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 322b8196..90580a12 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -66,7 +66,6 @@ type GenerationSubmissionWorkflowOptions = { dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; - removeCanvasGenerationDialogById: (dialogId: string) => void; getGeneratingDialogPlaceholder: ( dialog: GenerateDialogState, ) => GenerateDialogState['placeholder']; @@ -76,6 +75,7 @@ type GenerationSubmissionWorkflowOptions = { setActiveTool: Dispatch>; setActiveSidebarPanel: Dispatch>; rememberImageModel: (imageModel: string) => void; + persistGeneratedAsset?: (layer: CanvasLayer) => void; }; export function useImageCanvasGenerationSubmissionWorkflow({ @@ -91,7 +91,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setCharacterAnimationPanel, setGenerateDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, selectSingleLayer, @@ -99,7 +98,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({ setActiveTool, setActiveSidebarPanel, rememberImageModel, + persistGeneratedAsset, }: GenerationSubmissionWorkflowOptions) { + const addGeneratedLayersToCanvas = useCallback( + (nextLayers: CanvasLayer[]) => { + appendCanvasLayersWithResources(nextLayers); + nextLayers + .filter((layer) => layer.mediaType !== 'video') + .forEach((layer) => persistGeneratedAsset?.(layer)); + }, + [appendCanvasLayersWithResources, persistGeneratedAsset], + ); + const addGeneratedResultLayer = useCallback( ( generated: Parameters[0]['generated'], @@ -126,32 +136,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generationInputs: options.generationInputs, }); - appendCanvasLayersWithResources([nextLayer]); + addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (options.sourceLayer) { setGenerateDialog(null); setActiveTool('select'); } else if (options.dialogId) { - updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => - currentDialog.mode === 'character' || currentDialog.mode === 'icon' - ? null - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - generatedLayerId: nextLayer.id, - placeholder: undefined, - errorMessage: undefined, - }, - ); + updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'idle', + composerOpen: true, + generatedLayerId: nextLayer.id, + errorMessage: undefined, + })); } if (options.sourceLayer) { fitLayers([options.sourceLayer, nextLayer]); } }, [ - appendCanvasLayersWithResources, + addGeneratedLayersToCanvas, canvasSize, fitLayers, layerCounterRef, @@ -179,7 +184,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ generationInputs, }); - appendCanvasLayersWithResources([nextLayer]); + addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); setQuickEditPanel(null); @@ -187,7 +192,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ fitLayers([sourceLayer, nextLayer]); }, [ - appendCanvasLayersWithResources, + addGeneratedLayersToCanvas, fitLayers, layerCounterRef, selectSingleLayer, @@ -224,22 +229,28 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } layerCounterRef.current += nextLayers.length; - appendCanvasLayersWithResources(nextLayers); + addGeneratedLayersToCanvas(nextLayers); selectSingleLayer(nextLayers[0]?.id ?? null); setActiveSidebarPanel('layers'); if (dialogId) { - removeCanvasGenerationDialogById(dialogId); + updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({ + ...currentDialog, + status: 'idle', + composerOpen: true, + generatedLayerId: nextLayers[0]?.id, + errorMessage: undefined, + })); } setActiveTool('select'); }, [ - appendCanvasLayersWithResources, + addGeneratedLayersToCanvas, canvasSize, layerCounterRef, - removeCanvasGenerationDialogById, selectSingleLayer, setActiveSidebarPanel, setActiveTool, + updateCanvasGenerationDialogById, viewport, ], ); @@ -264,7 +275,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({ frame, }); - appendCanvasLayersWithResources([nextLayer]); + addGeneratedLayersToCanvas([nextLayer]); selectSingleLayer(nextLayer.id); setActiveSidebarPanel('layers'); if (dialogId) { @@ -273,14 +284,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({ status: 'idle', composerOpen: true, generatedLayerId: nextLayer.id, - placeholder: undefined, errorMessage: undefined, })); } setActiveTool('select'); }, [ - appendCanvasLayersWithResources, + addGeneratedLayersToCanvas, canvasSize, layerCounterRef, selectSingleLayer, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index 23c8bab9..2e988200 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -88,7 +88,6 @@ function GenerationSurfaceHarness() { canvasGenerationDialogs: dialogs.canvasGenerationDialogs, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index a0b822a1..a1372c88 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -52,7 +52,6 @@ type ImageCanvasGenerationSurfaceOptions = { dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; - removeCanvasGenerationDialogById: (dialogId: string) => void; removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void; getGeneratingDialogPlaceholder: ( dialog: GenerateDialogState, @@ -65,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = { setMetadataLayer: Dispatch>; setImageContextMenu: Dispatch>; requestUpload: (target: UploadTarget) => void; + persistGeneratedAsset?: (layer: CanvasLayer) => void; }; export function useImageCanvasGenerationSurface({ @@ -84,7 +84,6 @@ export function useImageCanvasGenerationSurface({ canvasGenerationDialogs, openCanvasGenerationDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, @@ -95,6 +94,7 @@ export function useImageCanvasGenerationSurface({ setMetadataLayer, setImageContextMenu, requestUpload, + persistGeneratedAsset, }: ImageCanvasGenerationSurfaceOptions) { const generationWorkflow = useImageCanvasGenerationWorkflow({ layers, @@ -107,7 +107,6 @@ export function useImageCanvasGenerationSurface({ setGenerateDialog, openCanvasGenerationDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, @@ -117,6 +116,7 @@ export function useImageCanvasGenerationSurface({ setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, + persistGeneratedAsset, }); const activeGenerationLayer = diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 95b62d8c..d3483149 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -110,7 +110,6 @@ function GenerationWorkflowHarness({ setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, @@ -383,7 +382,7 @@ describe('useImageCanvasGenerationWorkflow', () => { 'layer-generated-1', ); expect(screen.getByTestId('dialog').textContent).toBe( - 'generate:idle:open:layer-generated-1:-', + 'generate:idle:open:layer-generated-1:placeholder', ); }); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 2308a2c5..8a1bf3ac 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -77,7 +77,6 @@ type GenerationWorkflowOptions = { dialogId: string, updater: CanvasGenerationDialogUpdater, ) => void; - removeCanvasGenerationDialogById: (dialogId: string) => void; removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void; getGeneratingDialogPlaceholder: ( dialog: GenerateDialogState, @@ -89,6 +88,7 @@ type GenerationWorkflowOptions = { setActiveSidebarPanel: Dispatch>; setMetadataLayer: Dispatch>; setImageContextMenu: Dispatch>; + persistGeneratedAsset?: (layer: CanvasLayer) => void; }; export function useImageCanvasGenerationWorkflow({ @@ -102,7 +102,6 @@ export function useImageCanvasGenerationWorkflow({ setGenerateDialog, openCanvasGenerationDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, @@ -112,6 +111,7 @@ export function useImageCanvasGenerationWorkflow({ setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, + persistGeneratedAsset, }: GenerationWorkflowOptions) { const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] = @@ -476,7 +476,6 @@ export function useImageCanvasGenerationWorkflow({ setCharacterAnimationPanel, setGenerateDialog, updateCanvasGenerationDialogById, - removeCanvasGenerationDialogById, getGeneratingDialogPlaceholder, appendCanvasLayersWithResources, selectSingleLayer, @@ -484,6 +483,7 @@ export function useImageCanvasGenerationWorkflow({ setActiveTool, setActiveSidebarPanel, rememberImageModel: setLastImageModel, + persistGeneratedAsset, }); const { submitCharacterAnimation, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index ca9c0b88..b916c92b 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -1,10 +1,14 @@ /* @vitest-environment jsdom */ import { act, render, screen, waitFor } from '@testing-library/react'; -import { useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, +} from './ImageCanvasEditorTypes'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); @@ -45,10 +49,15 @@ function createLayer(id: string): CanvasLayer { function ProjectPersistenceHarness({ canAccessProtectedData = true, + initialGenerationDialogs = [], }: { canAccessProtectedData?: boolean; + initialGenerationDialogs?: CanvasGenerationDialogState[]; }) { const [layers, setLayers] = useState([]); + const [generationDialogs, setGenerationDialogs] = useState< + CanvasGenerationDialogState[] + >(initialGenerationDialogs); const [viewport, setViewport] = useState({ x: 0, y: 0, @@ -58,33 +67,49 @@ function ProjectPersistenceHarness({ const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); const viewportRef = useRef(viewport); + const canvasGenerationDialogsRef = useRef(generationDialogs); const selectedLayerRef = useRef(null); const layerCounterRef = useRef(0); + const openEditorLoginModalRef = useRef(vi.fn()); layersRef.current = layers; viewportRef.current = viewport; - - const persistence = useImageCanvasProjectPersistence({ - refs: { + canvasGenerationDialogsRef.current = generationDialogs; + const selectSingleLayer = useCallback((layerId: string | null) => { + selectedLayerRef.current = layerId; + }, []); + const setLayerCounter = useCallback((value: number) => { + layerCounterRef.current = value; + }, []); + const persistenceRefs = useMemo( + () => ({ layersRef, viewportRef, - }, - setters: { + canvasGenerationDialogsRef, + }), + [], + ); + const persistenceSetters = useMemo( + () => ({ setProjectTitle, setProjectRenameValue, setViewport, setLayers, - selectSingleLayer: (layerId) => { - selectedLayerRef.current = layerId; - }, - setLayerCounter: (value) => { - layerCounterRef.current = value; - }, - }, + selectSingleLayer, + setLayerCounter, + restoreCanvasGenerationDialogs: setGenerationDialogs, + }), + [selectSingleLayer, setLayerCounter], + ); + + const persistence = useImageCanvasProjectPersistence({ + refs: persistenceRefs, + setters: persistenceSetters, layers, + canvasGenerationDialogs: generationDialogs, viewport, canAccessProtectedData, - openEditorLoginModal: vi.fn(), + openEditorLoginModal: openEditorLoginModalRef.current, }); return ( @@ -97,6 +122,11 @@ function ProjectPersistenceHarness({ {selectedLayerRef.current ?? '-'} {layerCounterRef.current} + + {generationDialogs + .map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`) + .join(',')} + + + ); } @@ -175,6 +241,146 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('saves generation dialogs in the project layout and restores them on load', async () => { + const savedDialog: CanvasGenerationDialogState = { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '刷新后继续生成', + status: 'generating', + composerOpen: false, + placeholder: { + x: 42, + y: 56, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }; + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: savedDialog, + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId('generation-dialogs').textContent).toBe( + 'generation-dialog-1:刷新后继续生成:42', + ); + }); + + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move generation' }).click(); + }); + expect(screen.getByTestId('generation-dialogs').textContent).toBe( + 'generation-dialog-1:移动后的生成器:88', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + id: 'generation-dialog-1', + prompt: '移动后的生成器', + placeholder: expect.objectContaining({ x: 88 }), + }), + }), + ]), + }), + ); + vi.useRealTimers(); + }); + + it('saves generated layers together with their generator snapshots', async () => { + const savedDialog: CanvasGenerationDialogState = { + id: 'generation-dialog-1', + mode: 'generate', + prompt: '已生成也要保存生成器', + status: 'idle', + composerOpen: true, + generatedLayerId: 'layer-generated', + placeholder: { + x: 70, + y: 80, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }; + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'generation-dialog', + layerId: 'generation-dialog:generation-dialog-1', + resourceId: 'generation-dialog:generation-dialog-1', + dialog: savedDialog, + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + expect(screen.getByTestId('generation-dialogs').textContent).toBe( + 'generation-dialog-1:已生成也要保存生成器:70', + ); + act(() => { + screen.getByRole('button', { name: 'append generated' }).click(); + }); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-generated', + sourceType: 'generated', + }), + expect.objectContaining({ + itemType: 'generation-dialog', + dialog: expect.objectContaining({ + id: 'generation-dialog-1', + generatedLayerId: 'layer-generated', + prompt: '已生成也要保存生成器', + placeholder: expect.objectContaining({ + x: 70, + width: 420, + }), + }), + }), + ]), + }), + ); + }); + }); + it('does not load protected project data before login is available', () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 0592bbd8..96f08555 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -7,8 +7,16 @@ import { loadOrCreateRecentEditorProject, saveEditorProjectLayout, } from '../../services/image-editor/editorProjectClient'; -import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel'; -import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes'; +import { + hydrateLayer, + serializeCanvasLayout, + splitCanvasLayoutItems, +} from './ImageCanvasEditorModel'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, +} from './ImageCanvasEditorTypes'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -23,6 +31,7 @@ type PendingProjectResourceLayer = { type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; + canvasGenerationDialogsRef: RefObject; }; type ImageCanvasProjectPersistenceSetters = { @@ -32,6 +41,9 @@ type ImageCanvasProjectPersistenceSetters = { setLayers: (layers: CanvasLayer[]) => void; selectSingleLayer: (layerId: string | null) => void; setLayerCounter: (value: number) => void; + restoreCanvasGenerationDialogs: ( + dialogs: CanvasGenerationDialogState[], + ) => void; }; function isEditorAuthError(error: unknown) { @@ -45,6 +57,7 @@ export function useImageCanvasProjectPersistence({ refs, setters, layers, + canvasGenerationDialogs, viewport, canAccessProtectedData, openEditorLoginModal, @@ -52,6 +65,7 @@ export function useImageCanvasProjectPersistence({ refs: ImageCanvasProjectPersistenceRefs; setters: ImageCanvasProjectPersistenceSetters; layers: CanvasLayer[]; + canvasGenerationDialogs: CanvasGenerationDialogState[]; viewport: CanvasViewport; canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; @@ -63,6 +77,15 @@ export function useImageCanvasProjectPersistence({ const saveTimerRef = useRef(null); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); + const { + setProjectTitle, + setProjectRenameValue, + setViewport, + setLayers, + selectSingleLayer, + setLayerCounter, + restoreCanvasGenerationDialogs, + } = setters; const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { @@ -110,11 +133,15 @@ export function useImageCanvasProjectPersistence({ ) : currentLayers; refs.layersRef.current = nextLayers; - setters.setLayers(nextLayers); + setLayers(nextLayers); if (nextLayers.length) { void saveEditorProjectLayout(readyProjectId, { viewport: refs.viewportRef.current, - layers: nextLayers.map(serializeLayer), + layers: serializeCanvasLayout({ + layers: nextLayers, + canvasGenerationDialogs: + refs.canvasGenerationDialogsRef.current, + }), }).catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); @@ -128,7 +155,7 @@ export function useImageCanvasProjectPersistence({ } }); }, - [openEditorLoginModal, refs, setters], + [openEditorLoginModal, refs, setLayers], ); const appendCanvasLayersWithResources = useCallback( @@ -138,12 +165,12 @@ export function useImageCanvasProjectPersistence({ } const snapshotLayers = [...refs.layersRef.current, ...nextLayers]; refs.layersRef.current = snapshotLayers; - setters.setLayers(snapshotLayers); + setLayers(snapshotLayers); nextLayers.forEach((layer) => createProjectResourceForLayer(layer, { snapshotLayers }), ); }, - [createProjectResourceForLayer, refs, setters], + [createProjectResourceForLayer, refs, setLayers], ); useEffect(() => { @@ -170,26 +197,30 @@ export function useImageCanvasProjectPersistence({ projectIdRef.current = project.projectId; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; - setters.setProjectTitle(nextProjectTitle); - setters.setProjectRenameValue(nextProjectTitle); + setProjectTitle(nextProjectTitle); + setProjectRenameValue(nextProjectTitle); const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); pendingLayers.forEach(({ layer, options }) => { createProjectResourceForLayer(layer, options); }); - setters.setViewport(project.viewport); + setViewport(project.viewport); const resourcesById = new Map( project.resources.map((resource) => [ resource.resourceId, { imageSrc: resource.imageSrc }, ]), ); - const hydratedLayers = project.layers + const { layerItems, generationDialogs } = splitCanvasLayoutItems( + project.layers, + ); + const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); + restoreCanvasGenerationDialogs(generationDialogs); if (hydratedLayers.length > 0) { - setters.setLayerCounter(hydratedLayers.length); - setters.setLayers(hydratedLayers); - setters.selectSingleLayer(hydratedLayers[0]?.id ?? null); + setLayerCounter(hydratedLayers.length); + setLayers(hydratedLayers); + selectSingleLayer(hydratedLayers[0]?.id ?? null); } setIsProjectReady(true); }) @@ -212,7 +243,13 @@ export function useImageCanvasProjectPersistence({ canAccessProtectedData, createProjectResourceForLayer, openEditorLoginModal, - setters, + restoreCanvasGenerationDialogs, + selectSingleLayer, + setLayerCounter, + setLayers, + setProjectRenameValue, + setProjectTitle, + setViewport, ]); useEffect(() => { @@ -226,7 +263,10 @@ export function useImageCanvasProjectPersistence({ saveTimerRef.current = window.setTimeout(() => { saveEditorProjectLayout(projectId, { viewport, - layers: layers.map(serializeLayer), + layers: serializeCanvasLayout({ + layers, + canvasGenerationDialogs, + }), }).catch((error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); @@ -239,7 +279,15 @@ export function useImageCanvasProjectPersistence({ window.clearTimeout(saveTimerRef.current); } }; - }, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]); + }, [ + isProjectReady, + canvasGenerationDialogs, + layers, + openEditorLoginModal, + projectId, + refs, + viewport, + ]); return { projectId,