后端写入画布生成完成态
后端在图片生成完成后直接写入画布布局和生成器状态 前端提交canvasCompletion并只应用后端项目快照刷新显示 移除项目加载时根据资源行推断生成完成态的路径 更新OpenAPI、图片画布文档和共享排障记忆
This commit is contained in:
@@ -2555,6 +2555,74 @@
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"canvasCompletion": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "带项目上下文生成时,服务端据此直接写入画布完成态并返回最新项目快照。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorCanvasGenerationCompletion": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"dialogId",
|
||||
"title",
|
||||
"placeholder"
|
||||
],
|
||||
"properties": {
|
||||
"dialogId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"placeholder": {
|
||||
"$ref": "#/components/schemas/EditorCanvasGenerationPlaceholder"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EditorCanvasGenerationPlaceholder": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"width",
|
||||
"height",
|
||||
"originalWidth",
|
||||
"originalHeight"
|
||||
],
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"type": "number"
|
||||
},
|
||||
"width": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"height": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"originalWidth": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"originalHeight": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -2687,6 +2755,17 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"project": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EditorProject"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "当请求携带 canvasCompletion 且服务端成功写入画布布局时返回最新项目快照。"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-06-21 图片画布生成完成态由后端写入画布布局
|
||||
|
||||
- 背景:图片画布角色形象等长耗时生成在服务端完成后,如果浏览器已刷新或原 HTTP 回调丢失,前端无法再把生成结果图层和 `generation-dialog` 完成态写回 `editor_canvas.layers_json`,用户会继续看到“生成中”卡片。
|
||||
- 决策:图片生成请求在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、结果标题和占位框);`api-server` 在生成成功并创建 `editor_project_resource` / `editor_asset` 后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层,把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存 layout 后返回最新项目快照。前端只应用后端快照刷新显示,不再把生成完成态作为正式业务真相,也不在项目加载时根据资源行推断完成态。
|
||||
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs`、图片画布生成提交工作流、项目快照 hydrate / persistence、图片画布技术方案和排障记录。
|
||||
- 验证方式:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand`、`npm run check:encoding`、`git diff --check`。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-06-21 图片画布参考图元数据只保存项目内行引用
|
||||
|
||||
- 背景:参考图如果把 Data URL、signed URL 或 `objectKey` 写入 `generationInputs` 或生成器布局快照,会撑大资源 / 素材 / 画布 JSON,也无法稳定索引到项目内用户可见行数据。
|
||||
|
||||
@@ -87,6 +87,14 @@
|
||||
- 验证:`cargo test -p api-server editor_character_animation --manifest-path server-rs/Cargo.toml`,其中 `editor_character_animation_extracts_final_sample_from_short_video` 应覆盖本机 FFmpeg 8 的 0 帧回归。
|
||||
- 关联:`server-rs/crates/api-server/src/character_animation_assets.rs`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`。
|
||||
|
||||
## 图片编辑器生成长请求完成态必须由后端写入画布
|
||||
|
||||
- 现象:画板角色形象等生成请求已经在服务端返回 `200`,OSS 中也已有 `generated-character-drafts/.../image.png`,但用户刷新或页面重载后仍看到旧生成卡片停在“生成中”。
|
||||
- 原因:生成是一次长 HTTP 请求,浏览器在请求完成前刷新或重新挂载时会丢失原页面的成功回调;如果完成态只靠前端回调把结果图层写回 `editor_canvas.layers_json`,服务端虽然已经创建 `editor_project_resource` / `editor_asset`,但布局里的 `generation-dialog` 仍可能停在 `status="generating"` 且没有 `generatedLayerId`。如果之后从素材库把同一私有素材加回画布,前端再次创建项目资源时若提交 signed URL / Data URL,还会触发 `413`,进一步阻断资源行绑定。
|
||||
- 处理:图片生成提交必须在有项目上下文时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框);`api-server` 生成成功并创建资源后,直接读取当前项目布局,只有当前布局仍存在对应生成器时才插入轻量结果图层、把生成器改回 `idle` 并写入 `generatedLayerId`,再沿用后端当前 viewport 保存 layout 并返回最新项目快照。前端只应用该快照刷新显示,不在加载时根据资源行推断完成态;有项目上下文但后端没有返回快照时也不得本地补结果图层。为已有 `objectKey` 的图层创建项目资源时,`imageSrc` 只提交 `/<objectKey>`,不要提交 signed URL / Data URL。
|
||||
- 验证:`cargo test -p api-server editor_canvas_generation_completion --manifest-path server-rs/Cargo.toml` 覆盖后端完成态写 layout;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/services/image-editor/editorProjectClient.test.ts src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 覆盖前端提交 `canvasCompletion`、应用后端快照、项目加载不推断完成态和 `objectKey` 资源创建不提交大 URL。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 图片编辑器 Seedance 2.0 参考媒体不要提交视频 Data URL
|
||||
|
||||
- 现象:画板生成视频选择 Seedance 2.0 并上传参考视频后,请求体暴涨、可能返回 `413` 或上游拒绝 `video_url.url`;文档示例或测试如果写 `data:video/mp4;base64,...`,后续实现很容易照抄。
|
||||
|
||||
@@ -40,11 +40,11 @@
|
||||
- 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。
|
||||
- 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。
|
||||
- 新增 `editor_asset` 表保存账号级素材:`assetId`、`ownerUserId`、`folderId`、名称、图片读取地址、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、`assetKind`、`generationInputs`、创建时间和更新时间。素材只跟账号走,不跟 project 走;角色、图标、UI 设计图、视频和音频等生成结果的用户可见输入快照随素材保存。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。
|
||||
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId`、`projectId`、`ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind`、`generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框),BFF 在生成成功后必须直接读取当前项目布局,只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
|
||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||
- 当前 MVP 的本地上传先以 data URL 持久化在素材记录中,保证刷新和跨项目可见;后续接入正式 OSS 上传时,只替换 `imageSrc/objectKey/assetObjectId` 的写入方式,账号级素材表和画布资源表不变。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。
|
||||
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成,前端只把响应中的 resource / asset 快照用于落画布和更新素材栏,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 图片类生成结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。带 `canvasCompletion` 的图片生成以 BFF 返回的 `project` 快照刷新画布;无项目上下文或没有返回快照时,前端才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。视频结果当前只保存为画布视频资源,不进入图片素材库。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -624,13 +624,24 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
|
||||
}
|
||||
|
||||
export type CanvasLayerResourceMetadata = {
|
||||
resourceId?: string | null;
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
sourceType?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
prompt?: string | null;
|
||||
actualPrompt?: string | null;
|
||||
model?: string | null;
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
generationInputs?: unknown;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
|
||||
|
||||
@@ -493,7 +493,7 @@ export function ImageCanvasEditorView() {
|
||||
setViewport,
|
||||
],
|
||||
);
|
||||
const { projectId, appendCanvasLayersWithResources } =
|
||||
const { projectId, appendCanvasLayersWithResources, applyProjectSnapshot } =
|
||||
useImageCanvasProjectPersistence({
|
||||
refs: projectPersistenceRefs,
|
||||
setters: projectPersistenceSetters,
|
||||
@@ -615,6 +615,7 @@ export function ImageCanvasEditorView() {
|
||||
projectId,
|
||||
assetFolderId: activeUploadFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
});
|
||||
const {
|
||||
setQuickEditPanel,
|
||||
|
||||
@@ -100,11 +100,17 @@ function SubmissionWorkflowHarness({
|
||||
initialQuickEditPanel = null,
|
||||
initialCharacterAnimationPanel = null,
|
||||
initialLayers = [createLayer()],
|
||||
projectId = undefined,
|
||||
applyProjectSnapshot = undefined,
|
||||
}: {
|
||||
initialDialog?: GenerateDialogState | null;
|
||||
initialQuickEditPanel?: QuickEditPanelState | null;
|
||||
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
|
||||
initialLayers?: CanvasLayer[];
|
||||
projectId?: string | null;
|
||||
applyProjectSnapshot?: Parameters<
|
||||
typeof useImageCanvasGenerationSubmissionWorkflow
|
||||
>[0]['applyProjectSnapshot'];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>('generate');
|
||||
@@ -164,6 +170,9 @@ function SubmissionWorkflowHarness({
|
||||
rememberImageModel: (imageModel) => {
|
||||
rememberedImageModelRef.current = imageModel;
|
||||
},
|
||||
projectId,
|
||||
assetFolderId: projectId ? 'project' : undefined,
|
||||
applyProjectSnapshot,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -456,7 +465,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('uses the latest moved canvas placeholder when a generation completes', async () => {
|
||||
it('uses the latest moved canvas placeholder for local-only generation fallback', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({ width: 512, height: 512 }),
|
||||
);
|
||||
@@ -494,6 +503,144 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes canvas completion context and refreshes from the backend project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
generateEditorImageMock.mockResolvedValueOnce({
|
||||
...createGenerated({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: '银发游侠',
|
||||
}),
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '角色项目',
|
||||
viewport: { x: 12, y: 24, scale: 1.5 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-editor-resource-character',
|
||||
resourceId: 'editor-resource-character',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-21T12:16:22.000Z',
|
||||
},
|
||||
});
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
initialDialog={{
|
||||
id: 'dialog-character',
|
||||
mode: 'character',
|
||||
prompt: ' 银发游侠 ',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 560,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1536,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: '银发游侠',
|
||||
kind: 'character',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
assetFolderId: 'project',
|
||||
canvasCompletion: {
|
||||
dialogId: 'dialog-character',
|
||||
title: '角色形象 1',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 560,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1536,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-1',
|
||||
title: '角色项目',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-generated-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not add a local layer when project generation returns without a backend snapshot', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
prompt: '银发游侠',
|
||||
project: null,
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
projectId="editor-project-1"
|
||||
initialDialog={{
|
||||
id: 'dialog-character',
|
||||
mode: 'character',
|
||||
prompt: '银发游侠',
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 200,
|
||||
y: 160,
|
||||
width: 420,
|
||||
height: 560,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1536,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置初始对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-1',
|
||||
canvasCompletion: expect.objectContaining({
|
||||
dialogId: 'dialog-character',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('dialog').textContent).toBe(
|
||||
'character:idle:open:-:placeholder:-',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-generated-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits publication detail materials as one billable editor image and appends the result', async () => {
|
||||
generateEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
generateEditorSoundEffect,
|
||||
generateEditorVideo,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasGenerationInputs,
|
||||
@@ -100,6 +103,7 @@ type GenerationSubmissionWorkflowOptions = {
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
|
||||
};
|
||||
|
||||
async function normalizePublicationReferenceImages(
|
||||
@@ -148,6 +152,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
}: GenerationSubmissionWorkflowOptions) {
|
||||
const addGeneratedLayersToCanvas = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
@@ -849,10 +854,39 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
generationInputs: submissionPlan.result.generationInputs,
|
||||
assetFolderId,
|
||||
assetLabel: submissionPlan.result.title,
|
||||
...(canvasDialog && projectId
|
||||
? {
|
||||
canvasCompletion: {
|
||||
dialogId: canvasDialog.id,
|
||||
title: submissionPlan.result.title,
|
||||
placeholder: getGeneratingDialogPlaceholder(dialog),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (submissionPlan.rememberImageModel) {
|
||||
rememberImageModel(submissionPlan.rememberImageModel);
|
||||
}
|
||||
if (generated.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(generated.project);
|
||||
if (generated.asset) {
|
||||
upsertGeneratedAsset?.(generated.asset);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (canvasDialog && projectId) {
|
||||
updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) =>
|
||||
currentDialog.generatedLayerId
|
||||
? currentDialog
|
||||
: {
|
||||
...currentDialog,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
errorMessage: undefined,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
addGeneratedResultLayer(generated, {
|
||||
frame: getGeneratingDialogPlaceholder(dialog),
|
||||
assetKind: submissionPlan.result.assetKind,
|
||||
@@ -891,8 +925,11 @@ export function useImageCanvasGenerationSubmissionWorkflow({
|
||||
rememberImageModel,
|
||||
projectId,
|
||||
assetFolderId,
|
||||
applyProjectSnapshot,
|
||||
setGenerateDialog,
|
||||
updateCanvasGenerationDialogById,
|
||||
upsertGeneratedAsset,
|
||||
viewport,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -91,6 +94,7 @@ type ImageCanvasGenerationSurfaceOptions = {
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
|
||||
};
|
||||
|
||||
function renderEditorPortal(node: ReactNode) {
|
||||
@@ -167,6 +171,7 @@ export function useImageCanvasGenerationSurface({
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
}: ImageCanvasGenerationSurfaceOptions) {
|
||||
const toolbarOptionCloseTimerRef = useRef<ReturnType<
|
||||
typeof setTimeout
|
||||
@@ -200,6 +205,7 @@ export function useImageCanvasGenerationSurface({
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
});
|
||||
|
||||
const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -187,6 +190,7 @@ type GenerationWorkflowOptions = {
|
||||
projectId?: string | null;
|
||||
assetFolderId?: string | null;
|
||||
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
|
||||
applyProjectSnapshot?: (project: EditorProjectSnapshot) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasGenerationWorkflow({
|
||||
@@ -218,6 +222,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
}: GenerationWorkflowOptions) {
|
||||
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
|
||||
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
|
||||
@@ -976,6 +981,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
projectId,
|
||||
assetFolderId,
|
||||
upsertGeneratedAsset,
|
||||
applyProjectSnapshot,
|
||||
});
|
||||
|
||||
const setEffectiveCharacterAnimationPanel: Dispatch<
|
||||
|
||||
@@ -178,6 +178,21 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
append character
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
persistence.appendCanvasLayersWithResources([
|
||||
createLayer('layer-private-object', {
|
||||
src: 'https://signed.example.test/private.png?expires=1',
|
||||
objectKey: 'generated-character-drafts/editor/private.png',
|
||||
title: '私有对象素材',
|
||||
sourceType: 'generated',
|
||||
}),
|
||||
]);
|
||||
}}
|
||||
>
|
||||
append private object
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -391,6 +406,35 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('creates project resources from objectKey without posting heavy signed URLs', async () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-private-object',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated-character-drafts/editor/private.png',
|
||||
objectKey: 'generated-character-drafts/editor/private.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: 'append private object' }).click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
imageSrc: '/generated-character-drafts/editor/private.png',
|
||||
objectKey: 'generated-character-drafts/editor/private.png',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves generation dialogs in the project layout and restores them on load', async () => {
|
||||
const savedDialog: CanvasGenerationDialogState = {
|
||||
id: 'generation-dialog-1',
|
||||
@@ -531,6 +575,71 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps backend layout as the project truth when loading generating dialogs', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '角色画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: 'generation-dialog:generation-dialog-4',
|
||||
resourceId: 'generation-dialog:generation-dialog-4',
|
||||
dialog: {
|
||||
id: 'generation-dialog-4',
|
||||
mode: 'character',
|
||||
prompt: '挥舞武器',
|
||||
status: 'generating',
|
||||
composerOpen: false,
|
||||
imageModel: 'gpt-image-2',
|
||||
placeholder: {
|
||||
x: 2851,
|
||||
y: -521,
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'editor-resource-character',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/api/assets/read-url?objectKey=character.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/character-images/vector-engine-edit/image.png',
|
||||
assetObjectId: 'asset-object-character',
|
||||
width: 1254,
|
||||
height: 1254,
|
||||
sourceType: 'generated',
|
||||
prompt: '挥舞武器',
|
||||
actualPrompt: '挥舞武器',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-engine-edit-1',
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '挥舞武器' }],
|
||||
references: [],
|
||||
},
|
||||
updatedAt: '2026-06-21T12:16:22.000Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-21T12:15:00.000Z',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
expect(screen.getByTestId('layers').textContent).toBe('');
|
||||
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
||||
'generation-dialog-4:挥舞武器:2851',
|
||||
);
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not load protected project data before login is available', () => {
|
||||
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
loadEditorProject,
|
||||
loadOrCreateRecentEditorProject,
|
||||
saveEditorProjectLayout,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import {
|
||||
type CanvasLayerResourceMetadata,
|
||||
@@ -58,6 +59,14 @@ function isLocalProjectResourceId(resourceId: string) {
|
||||
return resourceId.startsWith('local-');
|
||||
}
|
||||
|
||||
function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) {
|
||||
const objectKey = layer.objectKey?.trim();
|
||||
if (objectKey) {
|
||||
return `/${objectKey.replace(/^\/+/u, '')}`;
|
||||
}
|
||||
return layer.src;
|
||||
}
|
||||
|
||||
export function useImageCanvasProjectPersistence({
|
||||
refs,
|
||||
setters,
|
||||
@@ -100,7 +109,7 @@ export function useImageCanvasProjectPersistence({
|
||||
return;
|
||||
}
|
||||
createEditorProjectResource(readyProjectId, {
|
||||
imageSrc: layer.src,
|
||||
imageSrc: resolveProjectResourceCreateImageSrc(layer),
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
width: layer.originalWidth,
|
||||
@@ -166,6 +175,66 @@ export function useImageCanvasProjectPersistence({
|
||||
[openEditorLoginModal, refs, setLayers],
|
||||
);
|
||||
|
||||
const applyProjectSnapshot = useCallback(
|
||||
(project: EditorProjectSnapshot) => {
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
setProjectTitle(nextProjectTitle);
|
||||
setProjectRenameValue(nextProjectTitle);
|
||||
setViewport(project.viewport);
|
||||
refs.viewportRef.current = project.viewport;
|
||||
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{
|
||||
imageSrc: resource.imageSrc,
|
||||
resourceId: resource.resourceId,
|
||||
objectKey: resource.objectKey,
|
||||
assetObjectId: resource.assetObjectId,
|
||||
width: resource.width,
|
||||
height: resource.height,
|
||||
sourceType: resource.sourceType,
|
||||
prompt: resource.prompt,
|
||||
actualPrompt: resource.actualPrompt,
|
||||
model: resource.model,
|
||||
provider: resource.provider,
|
||||
taskId: resource.taskId,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
sourceResourceId: resource.sourceResourceId,
|
||||
assetKind: resource.assetKind,
|
||||
generationInputs: resource.generationInputs,
|
||||
createdAt: resource.createdAt,
|
||||
updatedAt: resource.updatedAt,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||
project.layers,
|
||||
resourcesById,
|
||||
);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
setLayerCounter(hydratedLayers.length);
|
||||
refs.layersRef.current = hydratedLayers;
|
||||
setLayers(hydratedLayers);
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
refs.canvasGenerationDialogsRef.current = generationDialogs;
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
},
|
||||
[
|
||||
refs,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
setLayers,
|
||||
setProjectRenameValue,
|
||||
setProjectTitle,
|
||||
setViewport,
|
||||
],
|
||||
);
|
||||
|
||||
const appendCanvasLayersWithResources = useCallback(
|
||||
(nextLayers: CanvasLayer[]) => {
|
||||
if (!nextLayers.length) {
|
||||
@@ -204,43 +273,11 @@ export function useImageCanvasProjectPersistence({
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
projectIdRef.current = project.projectId;
|
||||
setProjectId(project.projectId);
|
||||
const nextProjectTitle = project.title?.trim() || '未命名画布';
|
||||
setProjectTitle(nextProjectTitle);
|
||||
setProjectRenameValue(nextProjectTitle);
|
||||
applyProjectSnapshot(project);
|
||||
const pendingLayers = pendingProjectResourceLayersRef.current.splice(0);
|
||||
pendingLayers.forEach(({ layer, options }) => {
|
||||
createProjectResourceForLayer(layer, options);
|
||||
});
|
||||
setViewport(project.viewport);
|
||||
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
|
||||
project.resources.map((resource) => [
|
||||
resource.resourceId,
|
||||
{
|
||||
imageSrc: resource.imageSrc,
|
||||
objectKey: resource.objectKey,
|
||||
assetObjectId: resource.assetObjectId,
|
||||
durationSeconds: resource.durationSeconds,
|
||||
sourceResourceId: resource.sourceResourceId,
|
||||
assetKind: resource.assetKind,
|
||||
generationInputs: resource.generationInputs,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||
project.layers,
|
||||
resourcesById,
|
||||
);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
if (hydratedLayers.length > 0) {
|
||||
setLayerCounter(hydratedLayers.length);
|
||||
setLayers(hydratedLayers);
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
}
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
setIsProjectReady(true);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -260,15 +297,9 @@ export function useImageCanvasProjectPersistence({
|
||||
};
|
||||
}, [
|
||||
canAccessProtectedData,
|
||||
applyProjectSnapshot,
|
||||
createProjectResourceForLayer,
|
||||
openEditorLoginModal,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
setLayers,
|
||||
setProjectRenameValue,
|
||||
setProjectTitle,
|
||||
setViewport,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -314,5 +345,6 @@ export function useImageCanvasProjectPersistence({
|
||||
projectIdRef,
|
||||
createProjectResourceForLayer,
|
||||
appendCanvasLayersWithResources,
|
||||
applyProjectSnapshot,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -612,6 +612,69 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('passes canvas completion context to editor image generation', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,character',
|
||||
width: 1024,
|
||||
height: 1536,
|
||||
sourceType: 'generated',
|
||||
prompt: '角色设定',
|
||||
actualPrompt: '角色设定',
|
||||
model: 'gpt-image-2',
|
||||
provider: 'VectorEngine',
|
||||
taskId: 'vector-character-1',
|
||||
project: null,
|
||||
});
|
||||
|
||||
await generateEditorImage({
|
||||
prompt: '角色设定',
|
||||
kind: 'character',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
canvasCompletion: {
|
||||
dialogId: 'generation-dialog-1',
|
||||
title: '角色形象 1',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 420,
|
||||
height: 560,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1536,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/images/generations',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
prompt: '角色设定',
|
||||
kind: 'character',
|
||||
projectId: 'editor-project-1',
|
||||
assetKind: 'character',
|
||||
canvasCompletion: {
|
||||
dialogId: 'generation-dialog-1',
|
||||
title: '角色形象 1',
|
||||
placeholder: {
|
||||
x: 120,
|
||||
y: 140,
|
||||
width: 420,
|
||||
height: 560,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1536,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
'生成图片失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
retry: { maxRetries: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates icon spritesheets through the dedicated backend BFF', async () => {
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||||
|
||||
@@ -99,6 +99,7 @@ export type EditorAssetSnapshot = {
|
||||
provider?: string | null;
|
||||
taskId?: string | null;
|
||||
durationSeconds?: number | null;
|
||||
sourceResourceId?: string | null;
|
||||
assetKind?: string | null;
|
||||
generationInputs?: EditorAssetGenerationInputs | null;
|
||||
createdAt?: string;
|
||||
@@ -129,6 +130,20 @@ export type EditorImageGenerationInput = {
|
||||
assetFolderId?: string | null;
|
||||
assetLabel?: string | null;
|
||||
sourceResourceId?: string | null;
|
||||
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
|
||||
};
|
||||
|
||||
export type EditorCanvasGenerationCompletionInput = {
|
||||
dialogId: string;
|
||||
title: string;
|
||||
placeholder: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
originalWidth: number;
|
||||
originalHeight: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetGenerationInput = {
|
||||
@@ -178,6 +193,7 @@ export type EditorImageGenerationResult = {
|
||||
taskId: string;
|
||||
resource?: EditorProjectResourceSnapshot | null;
|
||||
asset?: EditorAssetSnapshot | null;
|
||||
project?: EditorProjectSnapshot | null;
|
||||
};
|
||||
|
||||
export type EditorIconSpritesheetIconResult = {
|
||||
@@ -623,6 +639,9 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
|
||||
...(input.sourceResourceId
|
||||
? { sourceResourceId: input.sourceResourceId }
|
||||
: {}),
|
||||
...(input.canvasCompletion
|
||||
? { canvasCompletion: input.canvasCompletion }
|
||||
: {}),
|
||||
}),
|
||||
'生成图片失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user