后端写入画布生成完成态

后端在图片生成完成后直接写入画布布局和生成器状态

前端提交canvasCompletion并只应用后端项目快照刷新显示

移除项目加载时根据资源行推断生成完成态的路径

更新OpenAPI、图片画布文档和共享排障记忆
This commit is contained in:
2026-06-21 22:30:03 +08:00
parent 9914e9f23c
commit fc49ba2a61
15 changed files with 1131 additions and 50 deletions
@@ -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 }
: {}),
}),
'生成图片失败',
{