后端写入画布生成完成态
后端在图片生成完成后直接写入画布布局和生成器状态 前端提交canvasCompletion并只应用后端项目快照刷新显示 移除项目加载时根据资源行推断生成完成态的路径 更新OpenAPI、图片画布文档和共享排障记忆
This commit is contained in:
@@ -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