后端写入画布生成完成态

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

前端提交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
@@ -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 }
: {}),
}),
'生成图片失败',
{