保存图片画布生成器快照
将生成器对话框作为画布布局项序列化和恢复 生成成功后保留生成器快照并锚定到成品图层 图片类生成结果同步写入账号素材库 补充生成器持久化测试和浏览器回归相关文档
This commit is contained in:
@@ -2326,3 +2326,11 @@
|
|||||||
- 影响范围:图片画布生成工作流、前端 editorProjectClient、`shared-contracts`、`api-server` 视频生成 BFF、编辑器技术方案和生成类面板方案。
|
- 影响范围:图片画布生成工作流、前端 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`。
|
- 验证方式:`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`。
|
- 关联文档:`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`。
|
||||||
|
|||||||
@@ -41,7 +41,8 @@
|
|||||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
|
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId。
|
||||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||||
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。
|
- 当前 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 读写。
|
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
|
- 默认选择模式;底部工具栏能切换工具;中键拖拽和 Space 临时抓手都能平移画布。
|
||||||
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
|
- 拖拽图片接近其它图片边缘或中心时显示吸附线,并保存吸附后的最终布局。
|
||||||
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
- 生成工具点击后显示画布内 `Image Generator` 占位框和跟随占位框的生成输入框,生成失败保留占位和输入状态,生成成功后在占位位置创建真实图层,并让输入框继续跟随该生成图。
|
||||||
|
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。
|
||||||
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
|
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色形象规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。
|
||||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
|
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、provider、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图,不包含后端拼接 Prompt,不再展示独立 Size 字段。
|
||||||
|
|||||||
@@ -62,6 +62,14 @@
|
|||||||
- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。
|
- `泥点` 文本不在 UI 中显示,改用图标 + 数值,例如 `生成 ✦ 12`。
|
||||||
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
|
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。
|
||||||
|
|
||||||
|
## 画布保存
|
||||||
|
|
||||||
|
- 生成占位图和生成器对话框不是临时浮层,必须作为画布布局数据保存。
|
||||||
|
- 保存时在现有画布布局数组中追加 `itemType: "generation-dialog"` 项,记录生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和 `generatedLayerId`。
|
||||||
|
- 生成成功后仍保留生成器快照;画布渲染优先用 `generatedLayerId` 锚定到成品图层,不再重复显示灰色占位框。
|
||||||
|
- 图片类生成结果还要写入账号级素材库;视频结果先只作为画布资源和视频图层保存。
|
||||||
|
- 刷新项目后,画布需要同时恢复图层、生成器快照和生成输入框跟随关系。
|
||||||
|
|
||||||
## 第一版计费配置
|
## 第一版计费配置
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -99,3 +107,4 @@
|
|||||||
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
- 规范面板比图片生成面板更紧凑,字段间距和输入高度更小,但外层 shell、首行参考图和底部按钮区必须继续对齐生成图片 / 生成角色 / 生成视频。
|
||||||
- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。
|
- 后端存在独立编辑器生成计费配置文件,角色动画价格校验使用该配置。
|
||||||
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
- 生成视频结果以视频图层加入画布,画布媒体元素标记为 `画布视频:生成视频 N`。
|
||||||
|
- 生成器输入、参数、参考图和占位框在刷新后仍存在;已生成对象的生成器面板继续跟随成品图层。
|
||||||
|
|||||||
@@ -80,6 +80,75 @@ describe('ImageCanvasEditorView generation integration', () => {
|
|||||||
saveEditorProjectLayoutMock,
|
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(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
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 () => {
|
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
|
||||||
generateEditorImageMock.mockResolvedValueOnce({
|
generateEditorImageMock.mockResolvedValueOnce({
|
||||||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||||||
@@ -151,6 +220,26 @@ describe('ImageCanvasEditorView generation integration', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
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
|
const generatedLayer = screen
|
||||||
.getByAltText(/画布图片:生成图片/)
|
.getByAltText(/画布图片:生成图片/)
|
||||||
.closest('button')!;
|
.closest('button')!;
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import {
|
|||||||
normalizeAssetLibrary,
|
normalizeAssetLibrary,
|
||||||
normalizeCanvasBackgroundHex,
|
normalizeCanvasBackgroundHex,
|
||||||
resolveSnappedLayerPosition,
|
resolveSnappedLayerPosition,
|
||||||
|
serializeCanvasLayout,
|
||||||
serializeLayer,
|
serializeLayer,
|
||||||
|
splitCanvasLayoutItems,
|
||||||
} from './ImageCanvasEditorModel';
|
} from './ImageCanvasEditorModel';
|
||||||
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
EditorAsset,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
describe('ImageCanvasEditorModel', () => {
|
describe('ImageCanvasEditorModel', () => {
|
||||||
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
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', () => {
|
it('snaps moving layers to nearby canvas and layer guides', () => {
|
||||||
const movingLayer: CanvasLayer = {
|
const movingLayer: CanvasLayer = {
|
||||||
id: 'moving',
|
id: 'moving',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
import type {
|
import type {
|
||||||
CanvasAssetKind,
|
CanvasAssetKind,
|
||||||
|
CanvasGenerationDialogState,
|
||||||
CanvasGenerationInputs,
|
CanvasGenerationInputs,
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasContextMenuState,
|
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<CanvasGenerationDialogState>;
|
||||||
|
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(
|
export function hydrateLayer(
|
||||||
snapshot: EditorProjectLayerSnapshot,
|
snapshot: EditorProjectLayerSnapshot,
|
||||||
resourcesById: Map<string, { imageSrc: string }>,
|
resourcesById: Map<string, { imageSrc: string }>,
|
||||||
@@ -298,6 +438,10 @@ export function stringOrNull(value: unknown) {
|
|||||||
return typeof value === 'string' && value.trim() ? value : null;
|
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) {
|
export function booleanFromSnapshot(value: unknown) {
|
||||||
return value === true;
|
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<CanvasGenerationDialogState['specType']> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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[]) {
|
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
||||||
if (targetLayers.length === 0) {
|
if (targetLayers.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { createEditorAsset } from '../../services/image-editor/editorProjectClient';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||||
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
|
||||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||||
import type {
|
import type {
|
||||||
AssetPointerDragState,
|
AssetPointerDragState,
|
||||||
|
CanvasGenerationDialogState,
|
||||||
CanvasContextMenuState,
|
CanvasContextMenuState,
|
||||||
CanvasLayer,
|
CanvasLayer,
|
||||||
CanvasTool,
|
CanvasTool,
|
||||||
@@ -43,6 +45,7 @@ export function ImageCanvasEditorView() {
|
|||||||
const hasRequestedLoginRef = useRef(false);
|
const hasRequestedLoginRef = useRef(false);
|
||||||
const layerCounterRef = useRef(0);
|
const layerCounterRef = useRef(0);
|
||||||
const layersRef = useRef<CanvasLayer[]>([]);
|
const layersRef = useRef<CanvasLayer[]>([]);
|
||||||
|
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||||
@@ -219,13 +222,14 @@ export function ImageCanvasEditorView() {
|
|||||||
canvasGenerationDialogs,
|
canvasGenerationDialogs,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
activateCanvasGenerationDialog,
|
activateCanvasGenerationDialog,
|
||||||
|
restoreCanvasGenerationDialogs,
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
} = useCanvasGenerationDialogs({
|
} = useCanvasGenerationDialogs({
|
||||||
onActivate: handleActivateCanvasGenerationDialog,
|
onActivate: handleActivateCanvasGenerationDialog,
|
||||||
});
|
});
|
||||||
|
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
|
||||||
const canvasHistoryRefs = useMemo(
|
const canvasHistoryRefs = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
layersRef,
|
layersRef,
|
||||||
@@ -277,16 +281,98 @@ export function ImageCanvasEditorView() {
|
|||||||
isCanvasGenerationComposerVisible(currentDialog)
|
isCanvasGenerationComposerVisible(currentDialog)
|
||||||
? {
|
? {
|
||||||
...currentDialog,
|
...currentDialog,
|
||||||
composerOpen: false,
|
composerOpen: currentDialog.generatedLayerId === layerId,
|
||||||
}
|
}
|
||||||
: currentDialog,
|
: 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(
|
const projectPersistenceRefs = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
layersRef,
|
layersRef,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
|
canvasGenerationDialogsRef,
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
@@ -300,14 +386,16 @@ export function ImageCanvasEditorView() {
|
|||||||
setLayerCounter: (value: number) => {
|
setLayerCounter: (value: number) => {
|
||||||
layerCounterRef.current = value;
|
layerCounterRef.current = value;
|
||||||
},
|
},
|
||||||
|
restoreCanvasGenerationDialogs,
|
||||||
}),
|
}),
|
||||||
[selectSingleLayer],
|
[restoreCanvasGenerationDialogs, selectSingleLayer],
|
||||||
);
|
);
|
||||||
const { projectId, appendCanvasLayersWithResources } =
|
const { projectId, appendCanvasLayersWithResources } =
|
||||||
useImageCanvasProjectPersistence({
|
useImageCanvasProjectPersistence({
|
||||||
refs: projectPersistenceRefs,
|
refs: projectPersistenceRefs,
|
||||||
setters: projectPersistenceSetters,
|
setters: projectPersistenceSetters,
|
||||||
layers,
|
layers,
|
||||||
|
canvasGenerationDialogs,
|
||||||
viewport,
|
viewport,
|
||||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
@@ -364,7 +452,6 @@ export function ImageCanvasEditorView() {
|
|||||||
canvasGenerationDialogs,
|
canvasGenerationDialogs,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
@@ -375,6 +462,7 @@ export function ImageCanvasEditorView() {
|
|||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
requestUpload,
|
requestUpload,
|
||||||
|
persistGeneratedAsset,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
quickEditPanel,
|
quickEditPanel,
|
||||||
|
|||||||
@@ -260,7 +260,10 @@ function ImageCanvasVideoGenerationComposerView({
|
|||||||
EDITOR_VIDEO_MODEL_OPTIONS[
|
EDITOR_VIDEO_MODEL_OPTIONS[
|
||||||
(Math.max(currentIndex, 0) + 1) %
|
(Math.max(currentIndex, 0) + 1) %
|
||||||
EDITOR_VIDEO_MODEL_OPTIONS.length
|
EDITOR_VIDEO_MODEL_OPTIONS.length
|
||||||
];
|
] ?? EDITOR_VIDEO_MODEL_OPTIONS[0];
|
||||||
|
if (!nextModel) {
|
||||||
|
return currentDialog;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...currentDialog,
|
...currentDialog,
|
||||||
videoModel: nextModel.value,
|
videoModel: nextModel.value,
|
||||||
|
|||||||
@@ -218,4 +218,28 @@ describe('ImageCanvasWorldView', () => {
|
|||||||
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
expect(props.onActivateGenerationDialog).toHaveBeenCalledWith(dialog);
|
||||||
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -216,8 +216,12 @@ export function ImageCanvasWorldView({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{canvasGenerationDialogs.map((dialog) =>
|
{canvasGenerationDialogs.map((dialog) => {
|
||||||
dialog.placeholder ? (
|
const hasGeneratedLayer = Boolean(
|
||||||
|
dialog.generatedLayerId &&
|
||||||
|
layers.some((layer) => layer.id === dialog.generatedLayerId),
|
||||||
|
);
|
||||||
|
return dialog.placeholder && !hasGeneratedLayer ? (
|
||||||
<div
|
<div
|
||||||
key={dialog.id}
|
key={dialog.id}
|
||||||
className={`image-canvas-editor__generation-frame ${
|
className={`image-canvas-editor__generation-frame ${
|
||||||
@@ -276,8 +280,8 @@ export function ImageCanvasWorldView({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null;
|
||||||
)}
|
})}
|
||||||
{(generateDialog?.mode === 'generate' ||
|
{(generateDialog?.mode === 'generate' ||
|
||||||
generateDialog?.mode === 'spec' ||
|
generateDialog?.mode === 'spec' ||
|
||||||
generateDialog?.mode === 'character' ||
|
generateDialog?.mode === 'character' ||
|
||||||
|
|||||||
@@ -128,6 +128,53 @@ function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) {
|
|||||||
>
|
>
|
||||||
remove layer dialogs
|
remove layer dialogs
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dialogs.restoreCanvasGenerationDialogs([
|
||||||
|
{
|
||||||
|
id: 'generation-dialog-12',
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: 'restored',
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
|
placeholder: {
|
||||||
|
x: 12,
|
||||||
|
y: 24,
|
||||||
|
width: 320,
|
||||||
|
height: 240,
|
||||||
|
originalWidth: 320,
|
||||||
|
originalHeight: 240,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
restore saved
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dialogs.restoreCanvasGenerationDialogs([
|
||||||
|
{
|
||||||
|
id: 'generation-dialog-2',
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: 'inactive saved',
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'generation-dialog-3',
|
||||||
|
mode: 'character',
|
||||||
|
prompt: 'active saved',
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
restore active
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,4 +221,40 @@ describe('useCanvasGenerationDialogs', () => {
|
|||||||
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
|
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
|
||||||
expect(screen.getByTestId('inactive').textContent).toBe('');
|
expect(screen.getByTestId('inactive').textContent).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('restores saved dialogs and continues ids after the saved maximum', () => {
|
||||||
|
const onActivate = vi.fn();
|
||||||
|
render(<GenerationDialogsHarness onActivate={onActivate} />);
|
||||||
|
|
||||||
|
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(<GenerationDialogsHarness onActivate={onActivate} />);
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -127,6 +127,41 @@ export function useCanvasGenerationDialogs({
|
|||||||
[onActivate],
|
[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(
|
const removeCanvasGenerationDialogsByLayerId = useCallback(
|
||||||
(targetLayerId: string) => {
|
(targetLayerId: string) => {
|
||||||
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
||||||
@@ -182,6 +217,7 @@ export function useCanvasGenerationDialogs({
|
|||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
removeCanvasGenerationDialogById,
|
||||||
activateCanvasGenerationDialog,
|
activateCanvasGenerationDialog,
|
||||||
|
restoreCanvasGenerationDialogs,
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ function SubmissionWorkflowHarness({
|
|||||||
setCharacterAnimationPanel,
|
setCharacterAnimationPanel,
|
||||||
setGenerateDialog: dialogs.setGenerateDialog,
|
setGenerateDialog: dialogs.setGenerateDialog,
|
||||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
|
||||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources: (nextLayers) =>
|
appendCanvasLayersWithResources: (nextLayers) =>
|
||||||
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
|
||||||
@@ -412,7 +411,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
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',
|
src: 'data:image/png;base64,spec',
|
||||||
},
|
},
|
||||||
iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
|
iconDescriptions: [' 返回按钮 ', '', '设置按钮'],
|
||||||
|
placeholder: {
|
||||||
|
x: 140,
|
||||||
|
y: 160,
|
||||||
|
width: 360,
|
||||||
|
height: 360,
|
||||||
|
originalWidth: 512,
|
||||||
|
originalHeight: 512,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
@@ -493,12 +500,57 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
|||||||
'layer-icon-2:设置按钮:-:icon',
|
'layer-icon-2:设置按钮:-:icon',
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('selected').textContent).toBe('layer-icon-1');
|
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(
|
expect(screen.getByTestId('remembered-model').textContent).toBe(
|
||||||
'gpt-image-2',
|
'gpt-image-2',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps character generation dialog data after the result is created', async () => {
|
||||||
|
generateEditorImageMock.mockResolvedValueOnce(
|
||||||
|
createGenerated({ width: 768, height: 768 }),
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
<SubmissionWorkflowHarness
|
||||||
|
initialDialog={{
|
||||||
|
id: 'dialog-character',
|
||||||
|
mode: 'character',
|
||||||
|
prompt: '骑士角色',
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
|
imageModel: 'gpt-image-2',
|
||||||
|
characterSpecReference: {
|
||||||
|
id: 'spec-character',
|
||||||
|
label: '角色规范',
|
||||||
|
src: 'data:image/png;base64,spec',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
x: 120,
|
||||||
|
y: 140,
|
||||||
|
width: 420,
|
||||||
|
height: 420,
|
||||||
|
originalWidth: 2048,
|
||||||
|
originalHeight: 2048,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 () => {
|
it('moves character animation panels from generating to completed', async () => {
|
||||||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||||||
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
|
frames: [{ imageSrc: 'data:image/png;base64,frame' }],
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ type GenerationSubmissionWorkflowOptions = {
|
|||||||
dialogId: string,
|
dialogId: string,
|
||||||
updater: CanvasGenerationDialogUpdater,
|
updater: CanvasGenerationDialogUpdater,
|
||||||
) => void;
|
) => void;
|
||||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
|
||||||
getGeneratingDialogPlaceholder: (
|
getGeneratingDialogPlaceholder: (
|
||||||
dialog: GenerateDialogState,
|
dialog: GenerateDialogState,
|
||||||
) => GenerateDialogState['placeholder'];
|
) => GenerateDialogState['placeholder'];
|
||||||
@@ -76,6 +75,7 @@ type GenerationSubmissionWorkflowOptions = {
|
|||||||
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
|
||||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||||
rememberImageModel: (imageModel: string) => void;
|
rememberImageModel: (imageModel: string) => void;
|
||||||
|
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useImageCanvasGenerationSubmissionWorkflow({
|
export function useImageCanvasGenerationSubmissionWorkflow({
|
||||||
@@ -91,7 +91,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
setCharacterAnimationPanel,
|
setCharacterAnimationPanel,
|
||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
@@ -99,7 +98,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
setActiveTool,
|
setActiveTool,
|
||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
rememberImageModel,
|
rememberImageModel,
|
||||||
|
persistGeneratedAsset,
|
||||||
}: GenerationSubmissionWorkflowOptions) {
|
}: GenerationSubmissionWorkflowOptions) {
|
||||||
|
const addGeneratedLayersToCanvas = useCallback(
|
||||||
|
(nextLayers: CanvasLayer[]) => {
|
||||||
|
appendCanvasLayersWithResources(nextLayers);
|
||||||
|
nextLayers
|
||||||
|
.filter((layer) => layer.mediaType !== 'video')
|
||||||
|
.forEach((layer) => persistGeneratedAsset?.(layer));
|
||||||
|
},
|
||||||
|
[appendCanvasLayersWithResources, persistGeneratedAsset],
|
||||||
|
);
|
||||||
|
|
||||||
const addGeneratedResultLayer = useCallback(
|
const addGeneratedResultLayer = useCallback(
|
||||||
(
|
(
|
||||||
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
|
generated: Parameters<typeof createGeneratedResultLayer>[0]['generated'],
|
||||||
@@ -126,32 +136,27 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
generationInputs: options.generationInputs,
|
generationInputs: options.generationInputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
appendCanvasLayersWithResources([nextLayer]);
|
addGeneratedLayersToCanvas([nextLayer]);
|
||||||
selectSingleLayer(nextLayer.id);
|
selectSingleLayer(nextLayer.id);
|
||||||
setActiveSidebarPanel('layers');
|
setActiveSidebarPanel('layers');
|
||||||
if (options.sourceLayer) {
|
if (options.sourceLayer) {
|
||||||
setGenerateDialog(null);
|
setGenerateDialog(null);
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
} else if (options.dialogId) {
|
} else if (options.dialogId) {
|
||||||
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) =>
|
updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => ({
|
||||||
currentDialog.mode === 'character' || currentDialog.mode === 'icon'
|
|
||||||
? null
|
|
||||||
: {
|
|
||||||
...currentDialog,
|
...currentDialog,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
generatedLayerId: nextLayer.id,
|
generatedLayerId: nextLayer.id,
|
||||||
placeholder: undefined,
|
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
},
|
}));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (options.sourceLayer) {
|
if (options.sourceLayer) {
|
||||||
fitLayers([options.sourceLayer, nextLayer]);
|
fitLayers([options.sourceLayer, nextLayer]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendCanvasLayersWithResources,
|
addGeneratedLayersToCanvas,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
fitLayers,
|
fitLayers,
|
||||||
layerCounterRef,
|
layerCounterRef,
|
||||||
@@ -179,7 +184,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
generationInputs,
|
generationInputs,
|
||||||
});
|
});
|
||||||
|
|
||||||
appendCanvasLayersWithResources([nextLayer]);
|
addGeneratedLayersToCanvas([nextLayer]);
|
||||||
selectSingleLayer(nextLayer.id);
|
selectSingleLayer(nextLayer.id);
|
||||||
setActiveSidebarPanel('layers');
|
setActiveSidebarPanel('layers');
|
||||||
setQuickEditPanel(null);
|
setQuickEditPanel(null);
|
||||||
@@ -187,7 +192,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
fitLayers([sourceLayer, nextLayer]);
|
fitLayers([sourceLayer, nextLayer]);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendCanvasLayersWithResources,
|
addGeneratedLayersToCanvas,
|
||||||
fitLayers,
|
fitLayers,
|
||||||
layerCounterRef,
|
layerCounterRef,
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
@@ -224,22 +229,28 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
layerCounterRef.current += nextLayers.length;
|
layerCounterRef.current += nextLayers.length;
|
||||||
appendCanvasLayersWithResources(nextLayers);
|
addGeneratedLayersToCanvas(nextLayers);
|
||||||
selectSingleLayer(nextLayers[0]?.id ?? null);
|
selectSingleLayer(nextLayers[0]?.id ?? null);
|
||||||
setActiveSidebarPanel('layers');
|
setActiveSidebarPanel('layers');
|
||||||
if (dialogId) {
|
if (dialogId) {
|
||||||
removeCanvasGenerationDialogById(dialogId);
|
updateCanvasGenerationDialogById(dialogId, (currentDialog) => ({
|
||||||
|
...currentDialog,
|
||||||
|
status: 'idle',
|
||||||
|
composerOpen: true,
|
||||||
|
generatedLayerId: nextLayers[0]?.id,
|
||||||
|
errorMessage: undefined,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendCanvasLayersWithResources,
|
addGeneratedLayersToCanvas,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
layerCounterRef,
|
layerCounterRef,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
setActiveTool,
|
setActiveTool,
|
||||||
|
updateCanvasGenerationDialogById,
|
||||||
viewport,
|
viewport,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -264,7 +275,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
frame,
|
frame,
|
||||||
});
|
});
|
||||||
|
|
||||||
appendCanvasLayersWithResources([nextLayer]);
|
addGeneratedLayersToCanvas([nextLayer]);
|
||||||
selectSingleLayer(nextLayer.id);
|
selectSingleLayer(nextLayer.id);
|
||||||
setActiveSidebarPanel('layers');
|
setActiveSidebarPanel('layers');
|
||||||
if (dialogId) {
|
if (dialogId) {
|
||||||
@@ -273,14 +284,13 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
generatedLayerId: nextLayer.id,
|
generatedLayerId: nextLayer.id,
|
||||||
placeholder: undefined,
|
|
||||||
errorMessage: undefined,
|
errorMessage: undefined,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
setActiveTool('select');
|
setActiveTool('select');
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
appendCanvasLayersWithResources,
|
addGeneratedLayersToCanvas,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
layerCounterRef,
|
layerCounterRef,
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ function GenerationSurfaceHarness() {
|
|||||||
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
|
||||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId:
|
removeCanvasGenerationDialogsByLayerId:
|
||||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ type ImageCanvasGenerationSurfaceOptions = {
|
|||||||
dialogId: string,
|
dialogId: string,
|
||||||
updater: CanvasGenerationDialogUpdater,
|
updater: CanvasGenerationDialogUpdater,
|
||||||
) => void;
|
) => void;
|
||||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
|
||||||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||||
getGeneratingDialogPlaceholder: (
|
getGeneratingDialogPlaceholder: (
|
||||||
dialog: GenerateDialogState,
|
dialog: GenerateDialogState,
|
||||||
@@ -65,6 +64,7 @@ type ImageCanvasGenerationSurfaceOptions = {
|
|||||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||||
requestUpload: (target: UploadTarget) => void;
|
requestUpload: (target: UploadTarget) => void;
|
||||||
|
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useImageCanvasGenerationSurface({
|
export function useImageCanvasGenerationSurface({
|
||||||
@@ -84,7 +84,6 @@ export function useImageCanvasGenerationSurface({
|
|||||||
canvasGenerationDialogs,
|
canvasGenerationDialogs,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
@@ -95,6 +94,7 @@ export function useImageCanvasGenerationSurface({
|
|||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
requestUpload,
|
requestUpload,
|
||||||
|
persistGeneratedAsset,
|
||||||
}: ImageCanvasGenerationSurfaceOptions) {
|
}: ImageCanvasGenerationSurfaceOptions) {
|
||||||
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
const generationWorkflow = useImageCanvasGenerationWorkflow({
|
||||||
layers,
|
layers,
|
||||||
@@ -107,7 +107,6 @@ export function useImageCanvasGenerationSurface({
|
|||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
@@ -117,6 +116,7 @@ export function useImageCanvasGenerationSurface({
|
|||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
|
persistGeneratedAsset,
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeGenerationLayer =
|
const activeGenerationLayer =
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ function GenerationWorkflowHarness({
|
|||||||
setGenerateDialog: dialogs.setGenerateDialog,
|
setGenerateDialog: dialogs.setGenerateDialog,
|
||||||
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId:
|
removeCanvasGenerationDialogsByLayerId:
|
||||||
dialogs.removeCanvasGenerationDialogsByLayerId,
|
dialogs.removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
|
||||||
@@ -383,7 +382,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
|||||||
'layer-generated-1',
|
'layer-generated-1',
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||||
'generate:idle:open:layer-generated-1:-',
|
'generate:idle:open:layer-generated-1:placeholder',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ type GenerationWorkflowOptions = {
|
|||||||
dialogId: string,
|
dialogId: string,
|
||||||
updater: CanvasGenerationDialogUpdater,
|
updater: CanvasGenerationDialogUpdater,
|
||||||
) => void;
|
) => void;
|
||||||
removeCanvasGenerationDialogById: (dialogId: string) => void;
|
|
||||||
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void;
|
||||||
getGeneratingDialogPlaceholder: (
|
getGeneratingDialogPlaceholder: (
|
||||||
dialog: GenerateDialogState,
|
dialog: GenerateDialogState,
|
||||||
@@ -89,6 +88,7 @@ type GenerationWorkflowOptions = {
|
|||||||
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
|
||||||
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
setMetadataLayer: Dispatch<SetStateAction<CanvasLayer | null>>;
|
||||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||||
|
persistGeneratedAsset?: (layer: CanvasLayer) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useImageCanvasGenerationWorkflow({
|
export function useImageCanvasGenerationWorkflow({
|
||||||
@@ -102,7 +102,6 @@ export function useImageCanvasGenerationWorkflow({
|
|||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
openCanvasGenerationDialog,
|
openCanvasGenerationDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
removeCanvasGenerationDialogsByLayerId,
|
removeCanvasGenerationDialogsByLayerId,
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
@@ -112,6 +111,7 @@ export function useImageCanvasGenerationWorkflow({
|
|||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
setMetadataLayer,
|
setMetadataLayer,
|
||||||
setImageContextMenu,
|
setImageContextMenu,
|
||||||
|
persistGeneratedAsset,
|
||||||
}: GenerationWorkflowOptions) {
|
}: GenerationWorkflowOptions) {
|
||||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||||
@@ -476,7 +476,6 @@ export function useImageCanvasGenerationWorkflow({
|
|||||||
setCharacterAnimationPanel,
|
setCharacterAnimationPanel,
|
||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
updateCanvasGenerationDialogById,
|
updateCanvasGenerationDialogById,
|
||||||
removeCanvasGenerationDialogById,
|
|
||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
@@ -484,6 +483,7 @@ export function useImageCanvasGenerationWorkflow({
|
|||||||
setActiveTool,
|
setActiveTool,
|
||||||
setActiveSidebarPanel,
|
setActiveSidebarPanel,
|
||||||
rememberImageModel: setLastImageModel,
|
rememberImageModel: setLastImageModel,
|
||||||
|
persistGeneratedAsset,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
submitCharacterAnimation,
|
submitCharacterAnimation,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasViewport,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
|
|
||||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -45,10 +49,15 @@ function createLayer(id: string): CanvasLayer {
|
|||||||
|
|
||||||
function ProjectPersistenceHarness({
|
function ProjectPersistenceHarness({
|
||||||
canAccessProtectedData = true,
|
canAccessProtectedData = true,
|
||||||
|
initialGenerationDialogs = [],
|
||||||
}: {
|
}: {
|
||||||
canAccessProtectedData?: boolean;
|
canAccessProtectedData?: boolean;
|
||||||
|
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
||||||
}) {
|
}) {
|
||||||
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
||||||
|
const [generationDialogs, setGenerationDialogs] = useState<
|
||||||
|
CanvasGenerationDialogState[]
|
||||||
|
>(initialGenerationDialogs);
|
||||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -58,33 +67,49 @@ function ProjectPersistenceHarness({
|
|||||||
const [projectRenameValue, setProjectRenameValue] = useState('');
|
const [projectRenameValue, setProjectRenameValue] = useState('');
|
||||||
const layersRef = useRef(layers);
|
const layersRef = useRef(layers);
|
||||||
const viewportRef = useRef(viewport);
|
const viewportRef = useRef(viewport);
|
||||||
|
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||||
const selectedLayerRef = useRef<string | null>(null);
|
const selectedLayerRef = useRef<string | null>(null);
|
||||||
const layerCounterRef = useRef(0);
|
const layerCounterRef = useRef(0);
|
||||||
|
const openEditorLoginModalRef = useRef(vi.fn());
|
||||||
|
|
||||||
layersRef.current = layers;
|
layersRef.current = layers;
|
||||||
viewportRef.current = viewport;
|
viewportRef.current = viewport;
|
||||||
|
canvasGenerationDialogsRef.current = generationDialogs;
|
||||||
const persistence = useImageCanvasProjectPersistence({
|
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||||
refs: {
|
selectedLayerRef.current = layerId;
|
||||||
|
}, []);
|
||||||
|
const setLayerCounter = useCallback((value: number) => {
|
||||||
|
layerCounterRef.current = value;
|
||||||
|
}, []);
|
||||||
|
const persistenceRefs = useMemo(
|
||||||
|
() => ({
|
||||||
layersRef,
|
layersRef,
|
||||||
viewportRef,
|
viewportRef,
|
||||||
},
|
canvasGenerationDialogsRef,
|
||||||
setters: {
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const persistenceSetters = useMemo(
|
||||||
|
() => ({
|
||||||
setProjectTitle,
|
setProjectTitle,
|
||||||
setProjectRenameValue,
|
setProjectRenameValue,
|
||||||
setViewport,
|
setViewport,
|
||||||
setLayers,
|
setLayers,
|
||||||
selectSingleLayer: (layerId) => {
|
selectSingleLayer,
|
||||||
selectedLayerRef.current = layerId;
|
setLayerCounter,
|
||||||
},
|
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
||||||
setLayerCounter: (value) => {
|
}),
|
||||||
layerCounterRef.current = value;
|
[selectSingleLayer, setLayerCounter],
|
||||||
},
|
);
|
||||||
},
|
|
||||||
|
const persistence = useImageCanvasProjectPersistence({
|
||||||
|
refs: persistenceRefs,
|
||||||
|
setters: persistenceSetters,
|
||||||
layers,
|
layers,
|
||||||
|
canvasGenerationDialogs: generationDialogs,
|
||||||
viewport,
|
viewport,
|
||||||
canAccessProtectedData,
|
canAccessProtectedData,
|
||||||
openEditorLoginModal: vi.fn(),
|
openEditorLoginModal: openEditorLoginModalRef.current,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +122,11 @@ function ProjectPersistenceHarness({
|
|||||||
</span>
|
</span>
|
||||||
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
||||||
<span data-testid="counter">{layerCounterRef.current}</span>
|
<span data-testid="counter">{layerCounterRef.current}</span>
|
||||||
|
<span data-testid="generation-dialogs">
|
||||||
|
{generationDialogs
|
||||||
|
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`)
|
||||||
|
.join(',')}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -105,6 +135,42 @@ function ProjectPersistenceHarness({
|
|||||||
>
|
>
|
||||||
append
|
append
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const generatedLayer = createLayer('layer-generated');
|
||||||
|
persistence.appendCanvasLayersWithResources([
|
||||||
|
{
|
||||||
|
...generatedLayer,
|
||||||
|
title: '生成图片',
|
||||||
|
sourceType: 'generated',
|
||||||
|
sourceAssetId: undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
append generated
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setGenerationDialogs((currentDialogs) =>
|
||||||
|
currentDialogs.map((dialog) =>
|
||||||
|
dialog.id === 'generation-dialog-1'
|
||||||
|
? {
|
||||||
|
...dialog,
|
||||||
|
prompt: '移动后的生成器',
|
||||||
|
placeholder: dialog.placeholder
|
||||||
|
? { ...dialog.placeholder, x: 88 }
|
||||||
|
: dialog.placeholder,
|
||||||
|
}
|
||||||
|
: dialog,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
move generation
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(<ProjectPersistenceHarness />);
|
||||||
|
|
||||||
|
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(<ProjectPersistenceHarness />);
|
||||||
|
|
||||||
|
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', () => {
|
it('does not load protected project data before login is available', () => {
|
||||||
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ import {
|
|||||||
loadOrCreateRecentEditorProject,
|
loadOrCreateRecentEditorProject,
|
||||||
saveEditorProjectLayout,
|
saveEditorProjectLayout,
|
||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
import { hydrateLayer, serializeLayer } from './ImageCanvasEditorModel';
|
import {
|
||||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
hydrateLayer,
|
||||||
|
serializeCanvasLayout,
|
||||||
|
splitCanvasLayoutItems,
|
||||||
|
} from './ImageCanvasEditorModel';
|
||||||
|
import type {
|
||||||
|
CanvasGenerationDialogState,
|
||||||
|
CanvasLayer,
|
||||||
|
CanvasViewport,
|
||||||
|
} from './ImageCanvasEditorTypes';
|
||||||
|
|
||||||
type ProjectResourceOptions = {
|
type ProjectResourceOptions = {
|
||||||
onCreated?: (resourceId: string) => void;
|
onCreated?: (resourceId: string) => void;
|
||||||
@@ -23,6 +31,7 @@ type PendingProjectResourceLayer = {
|
|||||||
type ImageCanvasProjectPersistenceRefs = {
|
type ImageCanvasProjectPersistenceRefs = {
|
||||||
layersRef: RefObject<CanvasLayer[]>;
|
layersRef: RefObject<CanvasLayer[]>;
|
||||||
viewportRef: RefObject<CanvasViewport>;
|
viewportRef: RefObject<CanvasViewport>;
|
||||||
|
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImageCanvasProjectPersistenceSetters = {
|
type ImageCanvasProjectPersistenceSetters = {
|
||||||
@@ -32,6 +41,9 @@ type ImageCanvasProjectPersistenceSetters = {
|
|||||||
setLayers: (layers: CanvasLayer[]) => void;
|
setLayers: (layers: CanvasLayer[]) => void;
|
||||||
selectSingleLayer: (layerId: string | null) => void;
|
selectSingleLayer: (layerId: string | null) => void;
|
||||||
setLayerCounter: (value: number) => void;
|
setLayerCounter: (value: number) => void;
|
||||||
|
restoreCanvasGenerationDialogs: (
|
||||||
|
dialogs: CanvasGenerationDialogState[],
|
||||||
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isEditorAuthError(error: unknown) {
|
function isEditorAuthError(error: unknown) {
|
||||||
@@ -45,6 +57,7 @@ export function useImageCanvasProjectPersistence({
|
|||||||
refs,
|
refs,
|
||||||
setters,
|
setters,
|
||||||
layers,
|
layers,
|
||||||
|
canvasGenerationDialogs,
|
||||||
viewport,
|
viewport,
|
||||||
canAccessProtectedData,
|
canAccessProtectedData,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
@@ -52,6 +65,7 @@ export function useImageCanvasProjectPersistence({
|
|||||||
refs: ImageCanvasProjectPersistenceRefs;
|
refs: ImageCanvasProjectPersistenceRefs;
|
||||||
setters: ImageCanvasProjectPersistenceSetters;
|
setters: ImageCanvasProjectPersistenceSetters;
|
||||||
layers: CanvasLayer[];
|
layers: CanvasLayer[];
|
||||||
|
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||||
viewport: CanvasViewport;
|
viewport: CanvasViewport;
|
||||||
canAccessProtectedData: boolean;
|
canAccessProtectedData: boolean;
|
||||||
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
openEditorLoginModal: (postLoginAction?: (() => void) | null) => void;
|
||||||
@@ -63,6 +77,15 @@ export function useImageCanvasProjectPersistence({
|
|||||||
const saveTimerRef = useRef<number | null>(null);
|
const saveTimerRef = useRef<number | null>(null);
|
||||||
const [projectId, setProjectId] = useState<string | null>(null);
|
const [projectId, setProjectId] = useState<string | null>(null);
|
||||||
const [isProjectReady, setIsProjectReady] = useState(false);
|
const [isProjectReady, setIsProjectReady] = useState(false);
|
||||||
|
const {
|
||||||
|
setProjectTitle,
|
||||||
|
setProjectRenameValue,
|
||||||
|
setViewport,
|
||||||
|
setLayers,
|
||||||
|
selectSingleLayer,
|
||||||
|
setLayerCounter,
|
||||||
|
restoreCanvasGenerationDialogs,
|
||||||
|
} = setters;
|
||||||
|
|
||||||
const createProjectResourceForLayer = useCallback(
|
const createProjectResourceForLayer = useCallback(
|
||||||
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
|
(layer: CanvasLayer, options: ProjectResourceOptions = {}) => {
|
||||||
@@ -110,11 +133,15 @@ export function useImageCanvasProjectPersistence({
|
|||||||
)
|
)
|
||||||
: currentLayers;
|
: currentLayers;
|
||||||
refs.layersRef.current = nextLayers;
|
refs.layersRef.current = nextLayers;
|
||||||
setters.setLayers(nextLayers);
|
setLayers(nextLayers);
|
||||||
if (nextLayers.length) {
|
if (nextLayers.length) {
|
||||||
void saveEditorProjectLayout(readyProjectId, {
|
void saveEditorProjectLayout(readyProjectId, {
|
||||||
viewport: refs.viewportRef.current,
|
viewport: refs.viewportRef.current,
|
||||||
layers: nextLayers.map(serializeLayer),
|
layers: serializeCanvasLayout({
|
||||||
|
layers: nextLayers,
|
||||||
|
canvasGenerationDialogs:
|
||||||
|
refs.canvasGenerationDialogsRef.current,
|
||||||
|
}),
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
if (isEditorAuthError(error)) {
|
if (isEditorAuthError(error)) {
|
||||||
openEditorLoginModal();
|
openEditorLoginModal();
|
||||||
@@ -128,7 +155,7 @@ export function useImageCanvasProjectPersistence({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[openEditorLoginModal, refs, setters],
|
[openEditorLoginModal, refs, setLayers],
|
||||||
);
|
);
|
||||||
|
|
||||||
const appendCanvasLayersWithResources = useCallback(
|
const appendCanvasLayersWithResources = useCallback(
|
||||||
@@ -138,12 +165,12 @@ export function useImageCanvasProjectPersistence({
|
|||||||
}
|
}
|
||||||
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
|
const snapshotLayers = [...refs.layersRef.current, ...nextLayers];
|
||||||
refs.layersRef.current = snapshotLayers;
|
refs.layersRef.current = snapshotLayers;
|
||||||
setters.setLayers(snapshotLayers);
|
setLayers(snapshotLayers);
|
||||||
nextLayers.forEach((layer) =>
|
nextLayers.forEach((layer) =>
|
||||||
createProjectResourceForLayer(layer, { snapshotLayers }),
|
createProjectResourceForLayer(layer, { snapshotLayers }),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[createProjectResourceForLayer, refs, setters],
|
[createProjectResourceForLayer, refs, setLayers],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,26 +197,30 @@ export function useImageCanvasProjectPersistence({
|
|||||||
projectIdRef.current = project.projectId;
|
projectIdRef.current = project.projectId;
|
||||||
setProjectId(project.projectId);
|
setProjectId(project.projectId);
|
||||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||||
setters.setProjectTitle(nextProjectTitle);
|
setProjectTitle(nextProjectTitle);
|
||||||
setters.setProjectRenameValue(nextProjectTitle);
|
setProjectRenameValue(nextProjectTitle);
|
||||||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||||||
pendingLayers.forEach(({ layer, options }) => {
|
pendingLayers.forEach(({ layer, options }) => {
|
||||||
createProjectResourceForLayer(layer, options);
|
createProjectResourceForLayer(layer, options);
|
||||||
});
|
});
|
||||||
setters.setViewport(project.viewport);
|
setViewport(project.viewport);
|
||||||
const resourcesById = new Map(
|
const resourcesById = new Map(
|
||||||
project.resources.map((resource) => [
|
project.resources.map((resource) => [
|
||||||
resource.resourceId,
|
resource.resourceId,
|
||||||
{ imageSrc: resource.imageSrc },
|
{ imageSrc: resource.imageSrc },
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const hydratedLayers = project.layers
|
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||||
|
project.layers,
|
||||||
|
);
|
||||||
|
const hydratedLayers = layerItems
|
||||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||||
|
restoreCanvasGenerationDialogs(generationDialogs);
|
||||||
if (hydratedLayers.length > 0) {
|
if (hydratedLayers.length > 0) {
|
||||||
setters.setLayerCounter(hydratedLayers.length);
|
setLayerCounter(hydratedLayers.length);
|
||||||
setters.setLayers(hydratedLayers);
|
setLayers(hydratedLayers);
|
||||||
setters.selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||||
}
|
}
|
||||||
setIsProjectReady(true);
|
setIsProjectReady(true);
|
||||||
})
|
})
|
||||||
@@ -212,7 +243,13 @@ export function useImageCanvasProjectPersistence({
|
|||||||
canAccessProtectedData,
|
canAccessProtectedData,
|
||||||
createProjectResourceForLayer,
|
createProjectResourceForLayer,
|
||||||
openEditorLoginModal,
|
openEditorLoginModal,
|
||||||
setters,
|
restoreCanvasGenerationDialogs,
|
||||||
|
selectSingleLayer,
|
||||||
|
setLayerCounter,
|
||||||
|
setLayers,
|
||||||
|
setProjectRenameValue,
|
||||||
|
setProjectTitle,
|
||||||
|
setViewport,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,7 +263,10 @@ export function useImageCanvasProjectPersistence({
|
|||||||
saveTimerRef.current = window.setTimeout(() => {
|
saveTimerRef.current = window.setTimeout(() => {
|
||||||
saveEditorProjectLayout(projectId, {
|
saveEditorProjectLayout(projectId, {
|
||||||
viewport,
|
viewport,
|
||||||
layers: layers.map(serializeLayer),
|
layers: serializeCanvasLayout({
|
||||||
|
layers,
|
||||||
|
canvasGenerationDialogs,
|
||||||
|
}),
|
||||||
}).catch((error: unknown) => {
|
}).catch((error: unknown) => {
|
||||||
if (isEditorAuthError(error)) {
|
if (isEditorAuthError(error)) {
|
||||||
openEditorLoginModal();
|
openEditorLoginModal();
|
||||||
@@ -239,7 +279,15 @@ export function useImageCanvasProjectPersistence({
|
|||||||
window.clearTimeout(saveTimerRef.current);
|
window.clearTimeout(saveTimerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isProjectReady, layers, openEditorLoginModal, projectId, viewport]);
|
}, [
|
||||||
|
isProjectReady,
|
||||||
|
canvasGenerationDialogs,
|
||||||
|
layers,
|
||||||
|
openEditorLoginModal,
|
||||||
|
projectId,
|
||||||
|
refs,
|
||||||
|
viewport,
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
Reference in New Issue
Block a user