Merge branch 'codex/editor-asset-metadata-migration' into codex/editor-asset-library

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/api-server/src/editor_project.rs
#	src/components/image-editor/ImageCanvasEditorModel.ts
#	src/components/image-editor/ImageCanvasEditorView.test-utils.ts
#	src/components/image-editor/ImageCanvasEditorView.tsx
#	src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts
#	src/services/image-editor/editorProjectClient.test.ts
This commit is contained in:
2026-06-19 16:55:41 +08:00
31 changed files with 1586 additions and 224 deletions
File diff suppressed because it is too large Load Diff
@@ -164,10 +164,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({
@@ -178,6 +192,7 @@ describe('ImageCanvasEditorModel', () => {
objectKey: 'generated/object.png',
locked: true,
});
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
});
it('hydrates manually selected publication material tags from saved layout', () => {
@@ -144,7 +144,8 @@ export function createLayerFromAsset(
assetObjectId: asset.assetObjectId,
durationSeconds: asset.durationSeconds,
sourceAssetId: asset.id,
assetKind,
assetKind: asset.assetKind ?? assetKind,
generationInputs: asset.generationInputs,
} satisfies CanvasLayer;
}
@@ -175,8 +176,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,
@@ -364,13 +363,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) {
@@ -413,16 +413,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),
@@ -467,6 +467,8 @@ 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,
};
}),
@@ -494,6 +496,13 @@ function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind {
: 'sound-effect';
}
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;
@@ -1,4 +1,5 @@
import type {
EditorAssetSnapshot,
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
@@ -41,6 +42,8 @@ export type EditorAsset = {
taskId?: string;
objectKey?: string;
assetObjectId?: string;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
@@ -86,6 +89,7 @@ export type CanvasLayer = {
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
generatedAssetSnapshot?: EditorAssetSnapshot | null;
groupId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
@@ -266,6 +266,8 @@ export function setupImageCanvasEditorViewTestLifecycle({
width: input.width,
height: input.height,
sourceType: input.sourceType,
assetKind: input.assetKind,
generationInputs: input.generationInputs,
durationSeconds: input.durationSeconds,
}));
createEditorAssetFolderMock.mockResolvedValue({
@@ -11,10 +11,15 @@ import {
import {
createEditorAsset,
createEditorProjectResource,
type EditorAssetSnapshot,
} 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,
@@ -343,6 +348,8 @@ export function ImageCanvasEditorView() {
provider: layer.provider,
taskId: layer.taskId,
durationSeconds: layer.durationSeconds,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
})
.then((asset) => {
setAssets((currentAssets) => [
@@ -368,6 +375,8 @@ export function ImageCanvasEditorView() {
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
durationSeconds: asset.durationSeconds ?? layer.durationSeconds,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
},
]);
setAssetFolders((currentFolders) =>
@@ -406,6 +415,55 @@ export function ImageCanvasEditorView() {
},
[activeUploadFolderId, openEditorLoginModal, setAssetFolders, setAssets],
);
const upsertGeneratedAsset = useCallback(
(asset: EditorAssetSnapshot) => {
setAssets((currentAssets) => [
...currentAssets.filter(
(currentAsset) => currentAsset.id !== asset.assetId,
),
{
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
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) =>
currentFolders.some((folder) => folder.id === asset.folderId)
? currentFolders.map((folder) =>
folder.id === asset.folderId
? { ...folder, collapsed: false }
: folder,
)
: [
...currentFolders,
{
id: asset.folderId,
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
},
],
);
},
[setAssetFolders, setAssets],
);
const projectPersistenceRefs = useMemo(
() => ({
layersRef,
@@ -548,6 +606,9 @@ export function ImageCanvasEditorView() {
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
});
const {
setQuickEditPanel,
@@ -98,6 +98,57 @@ describe('ImageCanvasGenerationLayerModel', () => {
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
});
it('uses backend-persisted resource and asset snapshots for generated layers', () => {
const generationInputs = createGenerationInputs();
const layer = createGeneratedResultLayer({
generated: createGenerated({
resource: {
resourceId: 'resource-generated-bff',
projectId: 'editor-project-1',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
asset: {
assetId: 'asset-generated-bff',
folderId: 'user-1:asset-folder:project',
label: '角色形象',
imageSrc: 'data:image/png;base64,generated',
objectKey: 'generated/from-asset.png',
assetObjectId: 'asset-object-from-asset',
width: 1024,
height: 1024,
sourceType: 'generated',
assetKind: 'character',
generationInputs,
},
}),
generatedIndex: 9,
canvasSize: { width: 900, height: 640 },
viewport: { x: 0, y: 0, scale: 1 },
assetKind: 'spec',
generationInputs: {
fields: [{ title: '旧输入', value: '本地兜底' }],
references: [],
},
});
expect(layer).toMatchObject({
resourceId: 'resource-generated-bff',
sourceAssetId: 'asset-generated-bff',
objectKey: 'generated/from-resource.png',
assetObjectId: 'asset-object-from-resource',
assetKind: 'character',
});
expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词');
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-generated-bff');
});
it('creates edit result layers beside the source image', () => {
const sourceLayer = createSourceLayer();
const layer = createGeneratedResultLayer({
@@ -101,16 +101,24 @@ function applyGeneratedMetadata(
generated: EditorImageGenerationResult,
generationInputs: CanvasGenerationInputs | undefined,
): CanvasLayer {
const resource = generated.resource;
const asset = generated.asset;
return {
...layer,
resourceId: resource?.resourceId ?? layer.resourceId,
sourceAssetId: asset?.assetId ?? layer.sourceAssetId,
prompt: generated.prompt,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
generationInputs,
objectKey: resource?.objectKey ?? generated.objectKey,
assetObjectId: resource?.assetObjectId ?? generated.assetObjectId,
assetKind:
(resource?.assetKind as CanvasLayer['assetKind'] | undefined) ??
layer.assetKind,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset,
};
}
@@ -232,6 +240,8 @@ export function createIconSpritesheetResultLayers({
frame,
spritesheetTitle,
}: IconSpritesheetResultLayerOptions): CanvasLayer[] {
const spritesheetResource = generated.spritesheetResource;
const spritesheetAsset = generated.spritesheetAsset;
const worldCenter = getViewportWorldCenter({ canvasSize, viewport });
const startX =
frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2;
@@ -250,7 +260,9 @@ export function createIconSpritesheetResultLayers({
);
const spritesheetLayer: CanvasLayer = {
id: `layer-icon-spritesheet-${startIndex}`,
resourceId: `local-resource-icon-spritesheet-${startIndex}`,
resourceId:
spritesheetResource?.resourceId ??
`local-resource-icon-spritesheet-${startIndex}`,
title: spritesheetTitle ?? `图标素材图集 ${startIndex}`,
src: generated.spritesheetImageSrc,
x: startX,
@@ -267,7 +279,12 @@ export function createIconSpritesheetResultLayers({
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon-spritesheet',
generationInputs,
sourceAssetId: spritesheetAsset?.assetId,
objectKey: spritesheetResource?.objectKey ?? undefined,
assetObjectId: spritesheetResource?.assetObjectId ?? undefined,
generationInputs:
spritesheetResource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: spritesheetAsset,
};
const iconStartX = startX + spritesheetDisplaySize.width + 32;
let cursorX = iconStartX;
@@ -275,6 +292,8 @@ export function createIconSpritesheetResultLayers({
let rowHeight = 0;
const iconLayers = iconResults.map((icon, index) => {
const resource = icon.resource;
const asset = icon.asset;
const originalWidth = icon.width || 128;
const originalHeight = icon.height || 128;
const { width, height } = resolveLayerResolutionSize(
@@ -291,7 +310,7 @@ export function createIconSpritesheetResultLayers({
const generatedIndex = startIndex + index + 1;
const layer: CanvasLayer = {
id: `layer-icon-${generatedIndex}`,
resourceId: `local-resource-icon-${generatedIndex}`,
resourceId: resource?.resourceId ?? `local-resource-icon-${generatedIndex}`,
title: icon.name,
src: icon.imageSrc,
x: cursorX,
@@ -308,7 +327,11 @@ export function createIconSpritesheetResultLayers({
provider: generated.provider,
taskId: generated.taskId,
assetKind: 'icon',
generationInputs,
sourceAssetId: asset?.assetId,
objectKey: resource?.objectKey ?? undefined,
assetObjectId: resource?.assetObjectId ?? undefined,
generationInputs: resource?.generationInputs ?? generationInputs,
generatedAssetSnapshot: asset,
};
cursorX += width + spacing;
@@ -58,6 +58,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
imageSize: '1K',
},
result: {
title: '生成图片 3',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张发光主视觉' }],
references: [],
@@ -453,6 +453,7 @@ export function buildImageGenerationSubmissionPlan({
: {}),
},
result: {
title: `生成图片 ${nextGeneratedIndex}`,
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
@@ -20,6 +20,7 @@ import {
generateEditorSoundEffect,
generateEditorVideo,
} from '../../services/image-editor/editorProjectClient';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
@@ -96,7 +97,9 @@ type GenerationSubmissionWorkflowOptions = {
setActiveTool: Dispatch<SetStateAction<CanvasTool>>;
setActiveSidebarPanel: Dispatch<SetStateAction<SidebarPanel | null>>;
rememberImageModel: (imageModel: string) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
projectId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
};
async function normalizePublicationReferenceImages(
@@ -142,14 +145,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({
setActiveTool,
setActiveSidebarPanel,
rememberImageModel,
persistGeneratedAsset,
projectId,
assetFolderId,
upsertGeneratedAsset,
}: GenerationSubmissionWorkflowOptions) {
const addGeneratedLayersToCanvas = useCallback(
(nextLayers: CanvasLayer[]) => {
appendCanvasLayersWithResources(nextLayers);
nextLayers.forEach((layer) => persistGeneratedAsset?.(layer));
nextLayers.forEach((layer) => {
const asset = layer.generatedAssetSnapshot;
if (asset) {
upsertGeneratedAsset?.(asset);
}
});
},
[appendCanvasLayersWithResources, persistGeneratedAsset],
[appendCanvasLayersWithResources, upsertGeneratedAsset],
);
const addGeneratedResultLayer = useCallback(
@@ -510,8 +520,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const sourceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.objectKey?.trim() || sourceLayer.src,
);
const generationInputs =
buildUiDesignAssetExtractionGenerationInputs(sourceLayer);
const generated = await extractEditorUiDesignAssets({
sourceImageSrc,
projectId,
generationInputs,
assetFolderId,
spritesheetLabel: `${sourceLayer.title} 素材图集`,
});
const extractionFrame = {
x: sourceLayer.x + sourceLayer.width + 32,
@@ -524,7 +540,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const nextLayers = addIconSpritesheetResultLayers(
generated,
generated.iconImageSrcs,
buildUiDesignAssetExtractionGenerationInputs(sourceLayer),
generationInputs,
extractionFrame,
undefined,
{
@@ -542,7 +558,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
);
}
},
[addIconSpritesheetResultLayers, fitLayers],
[addIconSpritesheetResultLayers, fitLayers, projectId, assetFolderId],
);
const submitIconSpritesheetGeneration = useCallback(
@@ -583,9 +599,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
try {
const generated = await generateEditorIconSpritesheet(
submissionPlan.input,
);
const generated = await generateEditorIconSpritesheet({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
});
rememberImageModel(submissionPlan.rememberImageModel);
addIconSpritesheetResultLayers(
generated,
@@ -608,6 +627,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addIconSpritesheetResultLayers,
getGeneratingDialogPlaceholder,
rememberImageModel,
projectId,
assetFolderId,
updateCanvasGenerationDialogById,
],
);
@@ -663,19 +684,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const sourceReferenceImageSrc = await resolveEditorImageReferenceDataUrl(
quickEditSourceLayer.objectKey?.trim() || quickEditSourceLayer.src,
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
const generationInputs =
panelMode === 'redraw'
? buildEditGenerationInputs(
'重绘提示词',
@@ -687,7 +696,29 @@ export function useImageCanvasGenerationSubmissionWorkflow({
normalizedPrompt,
quickEditSourceLayer,
quickEditReferences,
),
);
const generated = await generateEditorImage({
prompt: normalizedPrompt,
size: quickEditPanel.size,
kind: 'quick-edit',
model: normalizeEditorImageModel(quickEditPanel.model),
referenceImageSrcs: [
...extraReferenceImageSrcs,
sourceReferenceImageSrc,
],
projectId,
assetKind: quickEditSourceLayer.assetKind,
generationInputs,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} ${
panelMode === 'redraw' ? '重绘' : '快速编辑'
}`,
sourceResourceId: quickEditSourceLayer.resourceId,
});
addQuickEditResultLayer(
generated,
quickEditSourceLayer,
generationInputs,
panelMode,
quickEditDialogId
? {
@@ -722,6 +753,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
addQuickEditResultLayer,
getGeneratingDialogPlaceholder,
openCanvasGenerationDialog,
projectId,
assetFolderId,
quickEditPanel,
quickEditSourceLayer,
setQuickEditPanel,
@@ -764,11 +797,18 @@ export function useImageCanvasGenerationSubmissionWorkflow({
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
submissionPlan.sourceLayer.src,
submissionPlan.sourceLayer.objectKey?.trim() ||
submissionPlan.sourceLayer.src,
);
const generated = await editEditorImage({
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
projectId,
assetKind: submissionPlan.sourceLayer.assetKind,
generationInputs: submissionPlan.generationInputs,
assetFolderId,
assetLabel: `${submissionPlan.sourceLayer.title} 修改结果`,
sourceResourceId: submissionPlan.sourceLayer.resourceId,
});
addGeneratedResultLayer(generated, {
sourceLayer: submissionPlan.sourceLayer,
@@ -800,7 +840,14 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.input.kind === 'publication-material'
? await normalizePublicationReferenceImages(submissionPlan.input)
: submissionPlan.input;
const generated = await generateEditorImage(imageGenerationInput);
const generated = await generateEditorImage({
...imageGenerationInput,
projectId,
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
});
if (submissionPlan.rememberImageModel) {
rememberImageModel(submissionPlan.rememberImageModel);
}
@@ -840,6 +887,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
layerCounterRef,
layers,
rememberImageModel,
projectId,
assetFolderId,
setGenerateDialog,
updateCanvasGenerationDialogById,
],
@@ -15,6 +15,7 @@ import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -87,6 +88,9 @@ type ImageCanvasGenerationSurfaceOptions = {
requestUpload: (target: UploadTarget) => void;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
projectId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
};
function renderEditorPortal(node: ReactNode) {
@@ -160,6 +164,9 @@ export function useImageCanvasGenerationSurface({
requestUpload,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId,
upsertGeneratedAsset,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
typeof setTimeout
@@ -190,6 +197,9 @@ export function useImageCanvasGenerationSurface({
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId,
upsertGeneratedAsset,
});
const activeGenerationLayer = activeCanvasGenerationDialog?.generatedLayerId
@@ -603,6 +603,14 @@ describe('useImageCanvasGenerationWorkflow', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [],
},
});
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:generating:closed:-:placeholder',
@@ -644,7 +652,21 @@ describe('useImageCanvasGenerationWorkflow', () => {
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '生成图片 1',
referenceImageSrcs: ['data:image/png;base64,source'],
generationInputs: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [
{
title: '参考图 1',
label: '画布参考图',
src: 'data:image/png;base64,source',
},
],
},
});
});
});
@@ -706,6 +728,21 @@ describe('useImageCanvasGenerationWorkflow', () => {
kind: 'quick-edit',
model: 'gemini-3.1-flash-image-preview',
referenceImageSrcs: ['data:image/png;base64,source'],
projectId: undefined,
assetFolderId: undefined,
assetKind: undefined,
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
references: [
{
title: '参考图 1',
label: '源图',
src: 'data:image/png;base64,source',
},
],
},
});
});
@@ -10,6 +10,7 @@ import {
useState,
} from 'react';
import type { EditorAssetSnapshot } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
@@ -183,6 +184,9 @@ type GenerationWorkflowOptions = {
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
persistGeneratedAsset?: (layer: CanvasLayer) => void;
persistUpdatedLayerResource?: (layer: CanvasLayer) => void;
projectId?: string | null;
assetFolderId?: string | null;
upsertGeneratedAsset?: (asset: EditorAssetSnapshot) => void;
};
export function useImageCanvasGenerationWorkflow({
@@ -211,6 +215,9 @@ export function useImageCanvasGenerationWorkflow({
setImageContextMenu,
persistGeneratedAsset,
persistUpdatedLayerResource,
projectId,
assetFolderId,
upsertGeneratedAsset,
}: GenerationWorkflowOptions) {
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
@@ -966,7 +973,9 @@ export function useImageCanvasGenerationWorkflow({
setActiveTool,
setActiveSidebarPanel,
rememberImageModel: setLastImageModel,
persistGeneratedAsset,
projectId,
assetFolderId,
upsertGeneratedAsset,
});
const setEffectiveCharacterAnimationPanel: Dispatch<
@@ -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,38 @@ function ProjectPersistenceHarness({
>
append
</button>
<button
type="button"
onClick={() => {
persistence.appendCanvasLayersWithResources([
createLayer('layer-server-resource', {
resourceId: 'resource-from-generation-bff',
title: '后端已保存图片',
sourceType: 'generated',
}),
]);
}}
>
append server resource
</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 +261,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 +284,111 @@ describe('useImageCanvasProjectPersistence', () => {
});
});
it('does not create a project resource again for backend-persisted generated layers', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'append server resource' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe(
'layer-server-resource:resource-from-generation-bff:-:-',
);
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-server-resource',
resourceId: 'resource-from-generation-bff',
sourceType: 'generated',
}),
]),
}),
);
});
});
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,
@@ -53,6 +54,10 @@ function isEditorAuthError(error: unknown) {
);
}
function isLocalProjectResourceId(resourceId: string) {
return resourceId.startsWith('local-');
}
export function useImageCanvasProjectPersistence({
refs,
setters,
@@ -108,6 +113,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);
@@ -168,7 +175,9 @@ export function useImageCanvasProjectPersistence({
refs.layersRef.current = snapshotLayers;
setLayers(snapshotLayers);
nextLayers.forEach((layer) =>
createProjectResourceForLayer(layer, { snapshotLayers }),
isLocalProjectResourceId(layer.resourceId)
? createProjectResourceForLayer(layer, { snapshotLayers })
: undefined,
);
},
[createProjectResourceForLayer, refs, setLayers],
@@ -205,12 +214,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: [],
},
}),
}),
'创建图片画布素材失败',
@@ -551,6 +571,15 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
assetKind: 'character',
assetFolderId: 'project',
assetLabel: '角色形象',
sourceResourceId: 'resource-spec-1',
generationInputs: {
fields: [{ title: '角色设定', value: '角色设定' }],
references: [],
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -564,6 +593,15 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '角色设定' }],
references: [],
},
assetFolderId: 'project',
assetLabel: '角色形象',
sourceResourceId: 'resource-spec-1',
}),
}),
'生成图片失败',
@@ -643,6 +681,12 @@ describe('editorProjectClient', () => {
aspectRatio: '1:1',
imageSize: '2K',
priceMudPoints: 20,
projectId: 'editor-project-1',
assetFolderId: 'project',
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮' }],
references: [],
},
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -657,6 +701,12 @@ describe('editorProjectClient', () => {
aspectRatio: '1:1',
imageSize: '2K',
priceMudPoints: 20,
projectId: 'editor-project-1',
generationInputs: {
fields: [{ title: '素材描述', value: '返回按钮' }],
references: [],
},
assetFolderId: 'project',
}),
}),
'生成图标素材失败',
@@ -690,6 +740,13 @@ describe('editorProjectClient', () => {
const result = await extractEditorUiDesignAssets({
sourceImageSrc: 'data:image/png;base64,ui-design',
projectId: 'editor-project-1',
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
generationInputs: {
fields: [{ title: '来源', value: '战斗UI设计图' }],
references: [],
},
});
expect(result.taskId).toBe('ui-asset-extraction-1');
@@ -700,6 +757,13 @@ describe('editorProjectClient', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceImageSrc: 'data:image/png;base64,ui-design',
projectId: 'editor-project-1',
generationInputs: {
fields: [{ title: '来源', value: '战斗UI设计图' }],
references: [],
},
assetFolderId: 'project',
spritesheetLabel: '战斗UI设计图 素材图集',
}),
}),
'提取UI设计图素材失败',
@@ -1108,6 +1172,15 @@ describe('editorProjectClient', () => {
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
projectId: 'editor-project-1',
assetKind: 'character',
assetFolderId: 'project',
assetLabel: '角色形象 修改结果',
sourceResourceId: 'resource-character-1',
generationInputs: {
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
references: [],
},
});
expect(result.taskId).toBe('vector-edit-1');
@@ -1121,6 +1194,15 @@ describe('editorProjectClient', () => {
sourceImageSrc: 'data:image/png;base64,source',
size: '1024x1024',
model: 'gpt-image-2',
projectId: 'editor-project-1',
assetKind: 'character',
generationInputs: {
fields: [{ title: '修改提示词', value: '把画面改成黄昏光线' }],
references: [],
},
assetFolderId: 'project',
assetLabel: '角色形象 修改结果',
sourceResourceId: 'resource-character-1',
}),
}),
'修改图片失败',
@@ -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;
};
@@ -102,6 +122,12 @@ export type EditorImageGenerationInput = {
aspectRatio?: string;
imageSize?: string;
referenceImageSrcs?: string[];
projectId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
sourceResourceId?: string | null;
};
export type EditorIconSpritesheetGenerationInput = {
@@ -111,10 +137,17 @@ export type EditorIconSpritesheetGenerationInput = {
aspectRatio?: string;
imageSize?: string;
priceMudPoints: number;
projectId?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
};
export type EditorUiDesignAssetExtractionInput = {
sourceImageSrc: string;
projectId?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
spritesheetLabel?: string | null;
};
export type EditorImageEditInput = {
@@ -122,6 +155,12 @@ export type EditorImageEditInput = {
sourceImageSrc: string;
size?: string;
model?: string;
projectId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
sourceResourceId?: string | null;
};
export type EditorImageGenerationResult = {
@@ -136,6 +175,8 @@ export type EditorImageGenerationResult = {
model: string;
provider: string;
taskId: string;
resource?: EditorProjectResourceSnapshot | null;
asset?: EditorAssetSnapshot | null;
};
export type EditorIconSpritesheetIconResult = {
@@ -143,6 +184,8 @@ export type EditorIconSpritesheetIconResult = {
imageSrc: string;
width: number;
height: number;
resource?: EditorProjectResourceSnapshot | null;
asset?: EditorAssetSnapshot | null;
};
export type EditorIconSpritesheetGenerationResult = {
@@ -156,6 +199,8 @@ export type EditorIconSpritesheetGenerationResult = {
provider: string;
taskId: string;
priceMudPoints: number;
spritesheetResource?: EditorProjectResourceSnapshot | null;
spritesheetAsset?: EditorAssetSnapshot | null;
};
export type EditorCharacterAnimationResolution = '480p' | '720p';
@@ -315,6 +360,8 @@ export type EditorProjectResourceCreateInput = {
taskId?: string | null;
durationSeconds?: number | null;
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
};
export type EditorAssetCreateInput = {
@@ -332,6 +379,8 @@ export type EditorAssetCreateInput = {
provider?: string | null;
taskId?: string | null;
durationSeconds?: number | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
};
export type EditorAssetUpdateInput = {
@@ -558,6 +607,16 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
}),
'生成图片失败',
{
@@ -581,6 +640,11 @@ export async function generateEditorIconSpritesheet(
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
...(input.imageSize ? { imageSize: input.imageSize } : {}),
priceMudPoints: input.priceMudPoints,
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
}),
'生成图标素材失败',
{
@@ -599,6 +663,14 @@ export async function extractEditorUiDesignAssets(
EDITOR_UI_DESIGN_ASSET_EXTRACTION_API,
jsonRequest('POST', {
sourceImageSrc: input.sourceImageSrc,
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.spritesheetLabel
? { spritesheetLabel: input.spritesheetLabel }
: {}),
}),
'提取UI设计图素材失败',
{
@@ -618,6 +690,16 @@ export async function editEditorImage(input: EditorImageEditInput) {
sourceImageSrc: input.sourceImageSrc,
...(input.size ? { size: input.size } : {}),
...(input.model ? { model: input.model } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
}),
'修改图片失败',
{