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,