迁移画布素材生成元数据

扩展 editor_project_resource 和 editor_asset 保存素材类别与生成输入快照

调整前端资源与素材读写链路并停止把素材元数据写入新 layout

在服务端保存 layout 时回填并清洗旧图层元数据

更新图片画布契约文档、共享决策和相关测试期望
This commit is contained in:
2026-06-19 15:11:32 +08:00
parent fdcccec385
commit 76445da9b4
22 changed files with 556 additions and 55 deletions
@@ -905,6 +905,15 @@ describe('ImageCanvasEditorView generation integration', () => {
sourceType: 'generated',
width: 2048,
height: 1152,
assetKind: 'spec',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '玩法设定',
value: '平台跳跃玩法',
}),
]),
}),
}),
);
});
@@ -915,7 +924,6 @@ describe('ImageCanvasEditorView generation integration', () => {
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(//u),
assetKind: 'spec',
}),
]),
}),
@@ -1791,7 +1799,6 @@ describe('ImageCanvasEditorView generation integration', () => {
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(//u),
assetKind: 'character',
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
@@ -1807,6 +1814,16 @@ describe('ImageCanvasEditorView generation integration', () => {
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
assetKind: 'character',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '角色设定',
value:
'银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
}),
]),
}),
}),
);
});
@@ -1990,22 +2007,27 @@ describe('ImageCanvasEditorView generation integration', () => {
expect(within(iconInfoPanel).getByText('图标规范')).toBeTruthy();
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-icons',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '返回按钮',
assetKind: 'icon',
}),
expect.objectContaining({
title: '设置按钮',
assetKind: 'icon',
}),
]),
assetKind: 'icon',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '素材描述',
value: '返回按钮\n设置按钮',
}),
]),
}),
}),
);
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-icons',
expect.objectContaining({
assetKind: 'icon',
}),
);
});
it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => {
@@ -2064,15 +2086,18 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(//u),
assetKind: 'icon-spec',
}),
]),
assetKind: 'icon-spec',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '玩法设定',
value: '背包整理玩法',
}),
]),
}),
}),
);
});
@@ -112,10 +112,24 @@ describe('ImageCanvasEditorModel', () => {
const snapshot = serializeLayer(layer);
expect(snapshot).not.toHaveProperty('src');
expect(snapshot).not.toHaveProperty('assetKind');
expect(snapshot).not.toHaveProperty('generationInputs');
const hydrated = hydrateLayer(
snapshot,
new Map([['resource-generated', { imageSrc: '/read/generated.png' }]]),
new Map([
[
'resource-generated',
{
imageSrc: '/read/generated.png',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
references: [],
},
},
],
]),
);
expect(hydrated).toMatchObject({
@@ -126,6 +140,7 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'generated/object.png',
locked: true,
});
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
@@ -137,6 +137,8 @@ export function createLayerFromAsset(
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind: asset.assetKind,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
@@ -167,8 +169,6 @@ export function serializeLayer(
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
groupId: layer.groupId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
@@ -356,13 +356,14 @@ export function hydrateCanvasGenerationDialog(
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>,
resourcesById: Map<string, CanvasLayerResourceMetadata>,
): CanvasLayer | null {
const resourceId =
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
const resource = resourcesById.get(resourceId);
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
const src = snapshotSrc || resource?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
@@ -405,16 +406,16 @@ export function hydrateLayer(
assetObjectId: stringOrNull(snapshot.assetObjectId),
durationSeconds:
audioDurationOrNull(snapshot.durationSeconds) ??
audioDurationOrNull(
resourcesById.get(resourceId) as
| ({ imageSrc: string } & { durationSeconds?: unknown })
| undefined,
),
audioDurationOrNull(resource?.durationSeconds),
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
generationInputs: generationInputsOrNull(snapshot.generationInputs),
assetKind:
canvasAssetKindOrNull(resource?.assetKind) ??
canvasAssetKindOrNull(snapshot.assetKind),
generationInputs:
generationInputsOrNull(resource?.generationInputs) ??
generationInputsOrNull(snapshot.generationInputs),
hidden: booleanFromSnapshot(snapshot.hidden),
locked: booleanFromSnapshot(snapshot.locked),
flipX: booleanFromSnapshot(snapshot.flipX),
@@ -453,11 +454,20 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
durationSeconds: asset.durationSeconds ?? undefined,
})),
};
}
export type CanvasLayerResourceMetadata = {
imageSrc: string;
durationSeconds?: number | null;
assetKind?: string | null;
generationInputs?: unknown;
};
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
const mapped = mapAssetLibrarySnapshot(library);
let hasDefaultFolder = false;
@@ -666,6 +676,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'icon' ||
value === 'icon-spritesheet' ||
value === 'icon-spec' ||
value === 'publication-material' ||
value === 'ui-design' ||
value === 'video' ||
value === 'sound-effect' ||
@@ -41,6 +41,8 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
@@ -264,6 +264,9 @@ export function setupImageCanvasEditorViewTestLifecycle({
width: input.width,
height: input.height,
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
}));
createEditorAssetFolderMock.mockResolvedValue({
folderId: 'folder-role-persisted',
@@ -14,7 +14,11 @@ import {
} from '../../services/image-editor/editorProjectClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import {
canvasAssetKindOrNull,
generationInputsOrNull,
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type {
AssetPointerDragState,
@@ -340,6 +344,9 @@ export function ImageCanvasEditorView() {
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((asset) => {
setAssets((currentAssets) => [
@@ -363,6 +370,9 @@ export function ImageCanvasEditorView() {
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
},
]);
setAssetFolders((currentFolders) =>
@@ -29,7 +29,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
};
});
function createLayer(id: string): CanvasLayer {
function createLayer(
id: string,
overrides: Partial<CanvasLayer> = {},
): CanvasLayer {
return {
id,
resourceId: `local-${id}`,
@@ -44,6 +47,7 @@ function createLayer(id: string): CanvasLayer {
zIndex: 3,
sourceType: 'uploaded',
sourceAssetId: 'asset-a',
...overrides,
};
}
@@ -118,7 +122,14 @@ function ProjectPersistenceHarness({
<span data-testid="project-title">{projectTitle}</span>
<span data-testid="project-rename">{projectRenameValue}</span>
<span data-testid="layers">
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
{layers
.map(
(layer) =>
`${layer.id}:${layer.resourceId}:${layer.assetKind ?? '-'}:${
layer.generationInputs?.fields[0]?.value ?? '-'
}`,
)
.join(',')}
</span>
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
<span data-testid="counter">{layerCounterRef.current}</span>
@@ -135,6 +146,24 @@ function ProjectPersistenceHarness({
>
append
</button>
<button
type="button"
onClick={() => {
persistence.appendCanvasLayersWithResources([
createLayer('layer-character', {
title: '角色形象',
sourceType: 'generated',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '银发游侠' }],
references: [],
},
}),
]);
}}
>
append character
</button>
<button
type="button"
onClick={() => {
@@ -218,11 +247,11 @@ describe('useImageCanvasProjectPersistence', () => {
});
expect(screen.getByTestId('layers').textContent).toBe(
'layer-a:local-layer-a',
'layer-a:local-layer-a:-:-',
);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toBe(
'layer-a:resource-added-asset-a',
'layer-a:resource-added-asset-a:-:-',
);
});
await waitFor(() => {
@@ -241,6 +270,83 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('persists generated layer metadata on the project resource and restores it from resources', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '已有角色项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-character',
resourceId: 'resource-character',
title: '角色形象',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 3,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-character',
projectId: 'editor-project-default',
imageSrc: '/generated/character.png',
width: 320,
height: 240,
sourceType: 'generated',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '银发游侠' }],
references: [],
},
},
],
updatedAt: '2026-06-12T00:00:00.000Z',
});
createEditorProjectResourceMock.mockResolvedValueOnce({
resourceId: 'resource-character-new',
projectId: 'editor-project-default',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'generated',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '银发游侠' }],
references: [],
},
});
render(<ProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toBe(
'layer-character:resource-character:character:银发游侠',
);
});
act(() => {
screen.getByRole('button', { name: 'append character' }).click();
});
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
sourceType: 'generated',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '银发游侠' }],
references: [],
},
}),
);
});
});
it('saves generation dialogs in the project layout and restores them on load', async () => {
const savedDialog: CanvasGenerationDialogState = {
id: 'generation-dialog-1',
@@ -8,6 +8,7 @@ import {
saveEditorProjectLayout,
} from '../../services/image-editor/editorProjectClient';
import {
type CanvasLayerResourceMetadata,
hydrateLayer,
serializeCanvasLayout,
splitCanvasLayoutItems,
@@ -108,6 +109,8 @@ export function useImageCanvasProjectPersistence({
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
sourceResourceId: layer.sourceResourceId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((resource) => {
options.onCreated?.(resource.resourceId);
@@ -205,12 +208,14 @@ export function useImageCanvasProjectPersistence({
createProjectResourceForLayer(layer, options);
});
setViewport(project.viewport);
const resourcesById = new Map(
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
project.resources.map((resource) => [
resource.resourceId,
{
imageSrc: resource.imageSrc,
durationSeconds: resource.durationSeconds,
assetKind: resource.assetKind,
generationInputs: resource.generationInputs,
},
]),
);
@@ -279,6 +279,11 @@ describe('editorProjectClient', () => {
prompt: 'dragon knight',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: 'dragon knight' }],
references: [],
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -295,6 +300,11 @@ describe('editorProjectClient', () => {
prompt: 'dragon knight',
model: 'gpt-image-2',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: 'dragon knight' }],
references: [],
},
}),
}),
'创建图片画布资源失败',
@@ -425,6 +435,11 @@ describe('editorProjectClient', () => {
width: 640,
height: 480,
sourceType: 'uploaded',
assetKind: 'icon',
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮' }],
references: [],
},
});
await updateEditorAsset('asset-1', {
label: '角色主视觉.png',
@@ -444,6 +459,11 @@ describe('editorProjectClient', () => {
width: 640,
height: 480,
sourceType: 'uploaded',
assetKind: 'icon',
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮' }],
references: [],
},
}),
}),
'创建图片画布素材失败',
@@ -29,6 +29,22 @@ export type EditorProjectLayerSnapshot = Record<string, unknown> & {
resourceId: string;
};
export type EditorAssetGenerationInputField = {
title: string;
value: string;
};
export type EditorAssetGenerationInputReference = {
title: string;
label: string;
src: string;
};
export type EditorAssetGenerationInputs = {
fields: EditorAssetGenerationInputField[];
references: EditorAssetGenerationInputReference[];
};
export type EditorProjectResourceSourceType =
| 'uploaded'
| 'generated'
@@ -50,6 +66,8 @@ export type EditorProjectResourceSnapshot = {
taskId?: string | null;
durationSeconds?: number | null;
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
createdAt?: string;
updatedAt?: string;
};
@@ -80,6 +98,8 @@ export type EditorAssetSnapshot = {
provider?: string | null;
taskId?: string | null;
durationSeconds?: number | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
createdAt?: string;
updatedAt?: string;
};
@@ -311,6 +331,8 @@ export type EditorProjectResourceCreateInput = {
taskId?: string | null;
durationSeconds?: number | null;
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
};
export type EditorAssetCreateInput = {
@@ -328,6 +350,8 @@ export type EditorAssetCreateInput = {
provider?: string | null;
taskId?: string | null;
durationSeconds?: number | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
};
export type EditorAssetUpdateInput = {