diff --git a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx index 27e840204..411f694a1 100644 --- a/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx @@ -165,6 +165,7 @@ function createTestImageAsset({ width: 320, height: 240, sourceType, + assetKind: 'image' as const, sourceResourceId, }; } @@ -344,6 +345,85 @@ describe('ImageCanvasEditorView asset library integration', () => { } }); + it('downloads a character action from the asset library as a sequence zip', async () => { + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action-download', + folderId: 'project', + label: '角色挥手', + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/frame02.png', + width: 192, + height: 256, + }, + ], + imageSequenceDurationMs: 4_000, + }, + ], + }); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn(async (..._args: Parameters) => + new Response(new Blob(['frame'], { type: 'image/png' })), + ); + globalThis.fetch = fetchMock as typeof fetch; + let downloadName = ''; + const downloadCapture: { blob: Blob | null } = { blob: null }; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + value: vi.fn((blob: Blob) => { + downloadCapture.blob = blob; + return 'blob:character-action-zip'; + }), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation( + function click(this: HTMLAnchorElement) { + downloadName = this.download; + }, + ); + + try { + render(); + fireEvent.click( + await screen.findByRole('button', { name: '下载素材角色挥手' }), + ); + + await waitFor(() => { + expect(downloadName).toBe('角色挥手-SpineJSON.zip'); + }); + const fetchedUrls = fetchMock.mock.calls.map(([url]) => url); + expect(fetchedUrls).toContain('/generated/action/frame01.png'); + expect(fetchedUrls).toContain('/generated/action/frame02.png'); + expect(downloadCapture.blob?.type).toBe('application/zip'); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('collapses folders, creates upload folders, and deletes uploaded materials', async () => { const user = userEvent.setup(); const createObjectUrlSpy = vi.fn(() => 'blob:folder-uploaded-image'); @@ -1353,6 +1433,94 @@ describe('ImageCanvasEditorView asset library integration', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('drags a character action onto the canvas as a saved playable sequence', async () => { + const frames = [ + { + imageSrc: '/generated/action/run-frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/run-frame02.png', + width: 192, + height: 256, + }, + ]; + loadEditorAssetLibraryMock.mockResolvedValueOnce({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action-run', + folderId: 'project', + label: '角色奔跑', + imageSrc: frames[0]!.imageSrc, + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + ], + }); + render(); + + const sourceAsset = await screen.findByRole('button', { + name: '添加角色奔跑', + }); + const sourceAssetRow = sourceAsset.closest( + '.image-canvas-editor__asset-row', + ); + const viewport = screen.getByLabelText('画布工作区'); + const dataTransfer = createDataTransferStub(); + if (!sourceAssetRow) { + throw new Error('asset row should exist'); + } + fireEvent.dragStart(sourceAssetRow, { dataTransfer }); + fireEvent.drop(viewport, { + clientX: 520, + clientY: 300, + dataTransfer, + }); + + expect( + await screen.findByAltText('画布序列帧:角色奔跑'), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: '暂停角色奔跑序列帧播放' }), + ).toBeTruthy(); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }), + ); + }); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + layers: expect.arrayContaining([ + expect.objectContaining({ + title: '角色奔跑', + }), + ]), + }), + ); + }); + }); + it('keeps the source resource id when dragging a generated asset onto the viewport', async () => { loadEditorAssetLibraryMock.mockResolvedValueOnce({ folders: [ diff --git a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index c307a36ee..b8c7e8285 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -3198,20 +3198,32 @@ describe('ImageCanvasEditorView generation integration', () => { resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); + const animationFrames = Array.from({ length: 48 }, (_, index) => ({ + imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, + width: 1024, + height: 1024, + })); generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', - frames: Array.from({ length: 48 }, (_, index) => ({ - frameIndex: index + 1, - imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, - width: 1024, - height: 1024, - })), + frames: animationFrames, frameCount: 48, durationSeconds: 6, fps: 8, + resource: { + resourceId: 'resource-character-animation-final', + projectId: 'editor-project-character-animation', + imageSrc: animationFrames[0]!.imageSrc, + width: 1024, + height: 1024, + sourceType: 'generated', + sourceResourceId: 'resource-character-animation-preview', + assetKind: 'character-animation', + imageSequenceFrames: animationFrames, + imageSequenceDurationMs: 6_000, + }, }); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const requestUrl = diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index c613b81d7..3e0eb519f 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -39,9 +39,9 @@ describe('ImageCanvasEditorModel', () => { expect( viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }), ).toEqual({ x: 10, y: 20, scale: 0.5 }); - expect( - canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }), - ).toEqual({ x: 10, y: 20, scale: 1 }); + expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual( + { x: 10, y: 20, scale: 1 }, + ); }); it('normalizes valid canvas background hex values and rejects invalid input', () => { @@ -83,7 +83,9 @@ describe('ImageCanvasEditorModel', () => { }); expect(layout).toEqual([]); - expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined(); + expect( + splitCanvasLayoutItems(layout).canvasBackgroundColor, + ).toBeUndefined(); expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc'); }); @@ -112,7 +114,7 @@ describe('ImageCanvasEditorModel', () => { expect(library.folders[0]?.id).toBe('project'); }); - it('infers MP3 and MP4 asset media types from persisted paths', () => { + it('uses media types published by the asset API', () => { const library = normalizeAssetLibrary({ folders: [ { @@ -128,10 +130,12 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-audio', folderId: 'project', label: '胜利音效.mp3', - imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', + imageSrc: + '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', width: 420, height: 120, sourceType: 'uploaded', + assetKind: 'audio', objectKey: 'generated-character-drafts/editor/asset-library/audio/胜利音效.mp3', }, @@ -139,12 +143,14 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-video', folderId: 'project', label: '开场动画.mp4', - imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', + imageSrc: + '/generated-character-drafts/editor/asset-library/video/开场动画.mp4', thumbnailSrc: '/generated-character-drafts/editor/asset-library/video/开场动画-cover.png', width: 560, height: 315, sourceType: 'uploaded', + assetKind: 'video', objectKey: 'generated-character-drafts/editor/asset-library/video/开场动画.mp4', }, @@ -166,10 +172,63 @@ describe('ImageCanvasEditorModel', () => { ), ).toMatchObject({ mediaType: 'audio', - assetKind: 'sound-effect', + assetKind: 'audio', }); }); + it('infers kinds for legacy persisted media assets from source extensions', () => { + const library = normalizeAssetLibrary({ + folders: [], + assets: [ + { + assetId: 'legacy-video', + folderId: 'project', + label: '旧开场动画.mp4', + imageSrc: '/legacy/opening.mp4?signature=video', + width: 1280, + height: 720, + sourceType: 'uploaded', + }, + { + assetId: 'legacy-audio', + folderId: 'project', + label: '旧背景音乐', + imageSrc: '/api/assets/read', + objectKey: 'legacy/music.mp3?version=1', + width: 420, + height: 120, + sourceType: 'uploaded', + assetKind: 'unrecognized-legacy-kind', + }, + ], + }); + + expect(library.assets[0]).toMatchObject({ + mediaType: 'video', + assetKind: 'video', + }); + expect(library.assets[1]).toMatchObject({ + mediaType: 'audio', + assetKind: 'background-music', + }); + expect( + library.assets.map((asset, index) => + createLayerFromAsset( + asset, + index, + { x: 0, y: 0, scale: 1 }, + { x: 300, y: 200 }, + ), + ), + ).toEqual([ + expect.objectContaining({ mediaType: 'video', assetKind: 'video' }), + expect.objectContaining({ + mediaType: 'audio', + assetKind: 'background-music', + }), + ]); + }); + it('creates a cascaded layer from an account asset near the requested screen point', () => { const asset: EditorAsset = { id: 'asset-1', @@ -252,10 +311,12 @@ describe('ImageCanvasEditorModel', () => { assetId: 'asset-generated', folderId: 'project', label: '生成素材', - imageSrc: '/generated-character-drafts/editor/asset-library/generated.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/generated.png', width: 640, height: 640, sourceType: 'generated', + assetKind: 'image', sourceResourceId: 'resource-generated', }, ], @@ -267,6 +328,87 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('round-trips an explicit character action into a movable sequence layer', () => { + const frames = [ + { + imageSrc: '/generated/action/frame01.png', + width: 192, + height: 256, + }, + { + imageSrc: '/generated/action/frame02.png', + width: 192, + height: 256, + }, + ]; + const library = normalizeAssetLibrary({ + folders: [ + { + folderId: 'project', + label: '项目素材', + sortOrder: 0, + collapsed: false, + systemDefault: true, + }, + ], + assets: [ + { + assetId: 'asset-action', + folderId: 'project', + label: '角色挥手', + imageSrc: frames[0]!.imageSrc, + width: 192, + height: 256, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + ], + }); + + const asset = library.assets[0] as EditorAsset; + const layer = createLayerFromAsset( + asset, + 1, + { x: 0, y: 0, scale: 1 }, + { x: 400, y: 300 }, + { applyCascadeOffset: false }, + ); + + expect(asset.mediaType).toBe('image-sequence'); + expect(layer).toMatchObject({ + mediaType: 'image-sequence', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + x: 304, + y: 172, + }); + }); + + it('rejects an explicitly typed but incomplete character action', () => { + expect(() => + createLayerFromAsset( + { + id: 'asset-corrupt-action', + label: '损坏动作', + src: '/generated/action/frame01.png', + mediaType: 'image-sequence', + width: 192, + height: 256, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'generated', + persisted: true, + assetKind: 'character-animation', + }, + 1, + { x: 0, y: 0, scale: 1 }, + { x: 400, y: 300 }, + ), + ).toThrow('缺少可用序列帧'); + }); + it('serializes and hydrates canvas layer metadata without embedding image payloads', () => { const layer: CanvasLayer = { id: 'layer-generated', @@ -323,6 +465,48 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); + it('derives audio and video media types from assetKind without persisting mediaType', () => { + for (const [mediaType, assetKind] of [ + ['video', 'video'], + ['audio', 'background-music'], + ] as const) { + const resourceId = `${mediaType}-resource`; + const resources = new Map([ + [ + resourceId, + { + imageSrc: + mediaType === 'video' + ? '/legacy/cutscene.mp4' + : '/legacy/theme.mp3', + assetKind, + }, + ], + ]); + const firstHydration = hydrateLayer( + { + layerId: `${mediaType}-layer`, + resourceId, + title: mediaType, + sourceType: 'uploaded', + assetKind, + }, + resources, + ); + + expect(firstHydration?.mediaType).toBe(mediaType); + if (!firstHydration) { + throw new Error(`${mediaType} layer should hydrate`); + } + const savedSnapshot = serializeLayer(firstHydration); + expect(savedSnapshot.mediaType).toBeUndefined(); + expect(savedSnapshot.assetKind).toBeUndefined(); + + const secondHydration = hydrateLayer(savedSnapshot, resources); + expect(secondHydration?.mediaType).toBe(mediaType); + } + }); + it('hydrates object metadata from project resources when the saved layer is lean', () => { const hydrated = hydrateLayer( { @@ -342,8 +526,10 @@ describe('ImageCanvasEditorModel', () => { [ 'resource-uploaded', { - imageSrc: '/generated-character-drafts/editor/asset-library/image.png', - objectKey: 'generated-character-drafts/editor/asset-library/image.png', + imageSrc: + '/generated-character-drafts/editor/asset-library/image.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image.png', assetObjectId: 'asset-object-uploaded', sourceResourceId: 'resource-source', }, @@ -376,7 +562,9 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', assetKind: 'publication-material', }, - new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]), + new Map([ + ['resource-publication', { imageSrc: '/read/publication.png' }], + ]), ); expect(hydrated).toMatchObject({ @@ -401,14 +589,12 @@ describe('ImageCanvasEditorModel', () => { sourceType: 'generated', mediaType: 'audio', assetKind: 'sound-effect', - durationSeconds: 2.4, }, new Map([ [ 'resource-audio', { imageSrc: '/generated-character-drafts/editor-audios/sfx.mp3', - durationSeconds: 2.4, }, ], ]), @@ -419,11 +605,10 @@ describe('ImageCanvasEditorModel', () => { src: '/generated-character-drafts/editor-audios/sfx.mp3', mediaType: 'audio', assetKind: 'sound-effect', - durationSeconds: 2.4, }); }); - it('serializes and hydrates image sequence frames for character animation layers', () => { + it('hydrates character animation sequence fields from the project resource', () => { const layer: CanvasLayer = { id: 'layer-action', resourceId: 'resource-action', @@ -433,7 +618,6 @@ describe('ImageCanvasEditorModel', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', assetObjectId: 'assetobj-frame-01', @@ -441,7 +625,6 @@ describe('ImageCanvasEditorModel', () => { height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', assetObjectId: 'assetobj-frame-02', @@ -449,7 +632,6 @@ describe('ImageCanvasEditorModel', () => { height: 1024, }, ], - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', x: 10, y: 20, width: 420, @@ -459,15 +641,14 @@ describe('ImageCanvasEditorModel', () => { zIndex: 6, sourceType: 'generated', assetKind: 'character-animation', - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }; const snapshot = serializeLayer(layer); - expect(snapshot.thumbnailSrc).toBe( - '/generated-character-drafts/editor/frame01.png', - ); - expect(snapshot.mediaType).toBe('image-sequence'); - expect(snapshot.imageSequenceFrames).toHaveLength(2); + expect(snapshot.thumbnailSrc).toBeUndefined(); + expect(snapshot.imageSequenceFrames).toBeUndefined(); + expect(snapshot.imageSequenceDurationMs).toBeUndefined(); + expect(snapshot.mediaType).toBeUndefined(); const hydrated = hydrateLayer( snapshot, @@ -477,6 +658,8 @@ describe('ImageCanvasEditorModel', () => { { imageSrc: layer.src, assetKind: 'character-animation', + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: 4_000, }, ], ]), @@ -486,12 +669,54 @@ describe('ImageCanvasEditorModel', () => { mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: layer.imageSequenceFrames, - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', assetKind: 'character-animation', - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }); }); + it('rejects an action layer when its project resource lacks formal sequence fields', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/legacy-frame01.png', + objectKey: 'generated-character-drafts/editor/legacy-frame01.png', + assetObjectId: 'assetobj-legacy-frame-01', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-character-drafts/editor/legacy-frame02.png', + objectKey: 'generated-character-drafts/editor/legacy-frame02.png', + assetObjectId: 'assetobj-legacy-frame-02', + width: 512, + height: 512, + }, + ]; + const resources = new Map([ + [ + 'resource-legacy-action', + { + imageSrc: frames[0]!.imageSrc, + assetKind: 'character-animation', + }, + ], + ]); + const hydration = hydrateLayer( + { + layerId: 'layer-legacy-action', + resourceId: 'resource-legacy-action', + title: '历史角色动作', + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 3_600, + previewVideoPath: '/generated-character-drafts/editor/legacy-preview.mp4', + }, + resources, + ); + + expect(hydration).toBeNull(); + }); + it('restores audio generation dialogs from saved layout', () => { const dialog: CanvasGenerationDialogState = { id: 'generation-dialog-audio', @@ -659,12 +884,16 @@ describe('ImageCanvasEditorModel', () => { }); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).not.toContain('data:image'); expect( JSON.stringify( - layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'), + layout.find( + (item) => item.resourceId === 'generation-dialog:generation-dialog-9', + ), ), ).toContain('ref:project-resource:resource-reference-1'); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index bbc1db6b7..91595b6de 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -140,6 +140,7 @@ export function createLayerFromAsset( screenCenter: { x: number; y: number }, options: { applyCascadeOffset?: boolean } = {}, ): CanvasLayer { + assertCompleteImageSequenceAsset(asset); const { width, height } = resolveLayerResolutionSize( asset.width, asset.height, @@ -153,19 +154,21 @@ export function createLayerFromAsset( const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; const offset = options.applyCascadeOffset === false ? 0 : index * 34; - const assetKind: CanvasAssetKind | undefined = - asset.mediaType === 'video' - ? 'video' - : asset.mediaType === 'audio' - ? inferAudioAssetKindFromLabel(asset.label) - : undefined; + const assetKind = inferEditorAssetKind( + asset.label, + asset.src, + asset.objectKey, + asset.assetKind, + asset.mediaType, + ); + const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: `layer-${asset.id}-${index}`, resourceId: `local-resource-${asset.id}-${index}`, title: asset.label, src: asset.src, - mediaType: asset.mediaType, + mediaType, x: worldCenterX - width / 2 + offset, y: worldCenterY - height / 2 + offset, width, @@ -183,13 +186,29 @@ export function createLayerFromAsset( objectKey: asset.objectKey, assetObjectId: asset.assetObjectId, sourceResourceId: asset.sourceResourceId, - durationSeconds: asset.durationSeconds, sourceAssetId: asset.id, - assetKind: asset.assetKind ?? assetKind, + assetKind, generationInputs: asset.generationInputs, + imageSequenceFrames: asset.imageSequenceFrames, + imageSequenceDurationMs: asset.imageSequenceDurationMs, } satisfies CanvasLayer; } +export function assertCompleteImageSequenceAsset(asset: EditorAsset) { + if ( + asset.assetKind === 'character-animation' && + (asset.imageSequenceFrames?.length ?? 0) < 2 + ) { + throw new Error(`角色动作素材“${asset.label}”不完整:缺少可用序列帧。`); + } + if ( + asset.assetKind === 'character-animation' && + !(asset.imageSequenceDurationMs && asset.imageSequenceDurationMs > 0) + ) { + throw new Error(`角色动作素材“${asset.label}”不完整:缺少序列播放时长。`); + } +} + export function isInlineEditorMediaSource(value: string | null | undefined) { const normalizedValue = value?.trim().toLowerCase() ?? ''; return ( @@ -233,29 +252,10 @@ function serializeOptionalMediaSource( return normalizedSource; } -function serializeImageSequenceFrames( - frames: CanvasLayer['imageSequenceFrames'], -) { - if (!frames?.length) { - return undefined; - } - const serializedFrames = frames.flatMap((frame) => { - const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey); - return imageSrc - ? [ - { - ...frame, - imageSrc, - }, - ] - : []; - }); - return serializedFrames.length ? serializedFrames : undefined; -} - export function serializeLayer( layer: CanvasLayer, ): EditorProjectLayerSnapshot { + const isCharacterAnimation = layer.assetKind === 'character-animation'; return { layerId: layer.id, resourceId: layer.resourceId, @@ -267,27 +267,25 @@ export function serializeLayer( originalWidth: layer.originalWidth, originalHeight: layer.originalHeight, zIndex: layer.zIndex, - sourceType: layer.sourceType, - mediaType: layer.mediaType, - thumbnailSrc: serializeOptionalMediaSource( - layer.thumbnailSrc, - layer.objectKey, - ), - imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames), - previewVideoPath: serializeOptionalMediaSource( - layer.previewVideoPath, - layer.objectKey, - ), - prompt: layer.prompt, - actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, - taskId: layer.taskId, - objectKey: layer.objectKey, - assetObjectId: layer.assetObjectId, - durationSeconds: layer.durationSeconds, - sourceResourceId: layer.sourceResourceId, - sourceAssetId: layer.sourceAssetId, + ...(isCharacterAnimation + ? {} + : { + sourceType: layer.sourceType, + thumbnailSrc: serializeOptionalMediaSource( + layer.thumbnailSrc, + layer.objectKey, + ), + prompt: layer.prompt, + actualPrompt: layer.actualPrompt, + model: layer.model, + provider: layer.provider, + taskId: layer.taskId, + objectKey: layer.objectKey, + assetObjectId: layer.assetObjectId, + durationSeconds: layer.durationSeconds, + sourceResourceId: layer.sourceResourceId, + sourceAssetId: layer.sourceAssetId, + }), groupId: layer.groupId, hidden: layer.hidden, locked: layer.locked, @@ -691,17 +689,40 @@ export function hydrateLayer( const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : ''; const resource = resourcesById.get(resourceId); const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : ''; - const imageSequenceFrames = hydrateImageSequenceFrames( - snapshot.imageSequenceFrames, + const resourceImageSequenceFrames = hydrateImageSequenceFrames( + resource?.imageSequenceFrames, ); + const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind); + const snapshotAssetKind = canvasAssetKindOrNull(snapshot.assetKind); + if ( + snapshotAssetKind === 'character-animation' && + resourceAssetKind !== 'character-animation' + ) { + return null; + } + const assetKind = resourceAssetKind ?? snapshotAssetKind; + const isCharacterAnimation = assetKind === 'character-animation'; + const imageSequenceFrames = isCharacterAnimation + ? resourceImageSequenceFrames + : []; const firstSequenceFrame = imageSequenceFrames[0]?.imageSrc ?? ''; - const src = snapshotSrc || firstSequenceFrame || resource?.imageSrc || ''; + const src = isCharacterAnimation + ? firstSequenceFrame || resource?.imageSrc || '' + : snapshotSrc || resource?.imageSrc || ''; const title = typeof snapshot.title === 'string' ? snapshot.title : '画布图片'; if (!resourceId || !layerId || !src) { return null; } + if ( + isCharacterAnimation && + (imageSequenceFrames.length < 2 || + !(resource?.imageSequenceDurationMs && + resource.imageSequenceDurationMs > 0)) + ) { + return null; + } return { id: layerId, resourceId, @@ -722,38 +743,60 @@ export function hydrateLayer( }; })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), - sourceType: isCanvasSourceType(snapshot.sourceType) - ? snapshot.sourceType - : 'uploaded', - mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames), - thumbnailSrc: stringOrNull(snapshot.thumbnailSrc), + sourceType: isCanvasSourceType(resource?.sourceType) + ? resource.sourceType + : isCanvasSourceType(snapshot.sourceType) + ? snapshot.sourceType + : 'uploaded', + mediaType: resolveCanvasMediaTypeFromAssetKind(assetKind), + thumbnailSrc: isCharacterAnimation + ? firstSequenceFrame || null + : stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length ? imageSequenceFrames : undefined, - previewVideoPath: stringOrNull(snapshot.previewVideoPath), - prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), - actualPrompt: - stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt), - model: stringOrNull(snapshot.model) ?? stringOrNull(resource?.model), - provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), - taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), - objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), - assetObjectId: - stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId), - durationSeconds: - audioDurationOrNull(snapshot.durationSeconds) ?? - audioDurationOrNull(resource?.durationSeconds), - sourceResourceId: - stringOrNull(snapshot.sourceResourceId) ?? - stringOrNull(resource?.sourceResourceId), - sourceAssetId: stringOrNull(snapshot.sourceAssetId), + previewVideoPath: isCharacterAnimation + ? null + : stringOrNull(snapshot.previewVideoPath), + prompt: isCharacterAnimation + ? stringOrNull(resource?.prompt) + : stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt), + actualPrompt: isCharacterAnimation + ? stringOrNull(resource?.actualPrompt) + : stringOrNull(snapshot.actualPrompt) ?? + stringOrNull(resource?.actualPrompt), + model: isCharacterAnimation + ? stringOrNull(resource?.model) + : stringOrNull(snapshot.model) ?? stringOrNull(resource?.model), + provider: isCharacterAnimation + ? stringOrNull(resource?.provider) + : stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider), + taskId: isCharacterAnimation + ? stringOrNull(resource?.taskId) + : stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId), + objectKey: isCharacterAnimation + ? stringOrNull(resource?.objectKey) + : stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey), + assetObjectId: isCharacterAnimation + ? stringOrNull(resource?.assetObjectId) + : stringOrNull(snapshot.assetObjectId) ?? + stringOrNull(resource?.assetObjectId), + imageSequenceDurationMs: isCharacterAnimation + ? audioDurationOrNull(resource?.imageSequenceDurationMs) + : undefined, + sourceResourceId: isCharacterAnimation + ? stringOrNull(resource?.sourceResourceId) + : stringOrNull(snapshot.sourceResourceId) ?? + stringOrNull(resource?.sourceResourceId), + sourceAssetId: isCharacterAnimation + ? null + : stringOrNull(snapshot.sourceAssetId), groupId: stringOrNull(snapshot.groupId), - assetKind: - canvasAssetKindOrNull(resource?.assetKind) ?? - canvasAssetKindOrNull(snapshot.assetKind), - generationInputs: - generationInputsOrNull(resource?.generationInputs) ?? - generationInputsOrNull(snapshot.generationInputs), + assetKind, + generationInputs: isCharacterAnimation + ? generationInputsOrNull(resource?.generationInputs) + : generationInputsOrNull(resource?.generationInputs) ?? + generationInputsOrNull(snapshot.generationInputs), hidden: booleanFromSnapshot(snapshot.hidden), locked: booleanFromSnapshot(snapshot.locked), flipX: booleanFromSnapshot(snapshot.flipX), @@ -776,11 +819,13 @@ export function mapAssetLibrarySnapshot( persisted: true, })), assets: library.assets.map((asset) => { - const mediaType = inferEditorAssetMediaType( + const assetKind = inferEditorAssetKind( + asset.label, asset.imageSrc, - asset.objectKey ?? undefined, + asset.objectKey, asset.assetKind, ); + const mediaType = resolveCanvasMediaTypeFromAssetKind(assetKind); return { id: asset.assetId, label: asset.label, @@ -807,59 +852,93 @@ export function mapAssetLibrarySnapshot( showcaseReviewStatus: asset.showcaseReviewStatus ?? null, showcaseDisplayEnabled: asset.showcaseDisplayEnabled ?? null, showcaseLikeCount: asset.showcaseLikeCount ?? null, - assetKind: canvasAssetKindOrNull(asset.assetKind), + assetKind, generationInputs: generationInputsOrNull(asset.generationInputs), - durationSeconds: asset.durationSeconds ?? undefined, + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, }; }), }; } +export function resolveCanvasMediaTypeFromAssetKind( + assetKind?: string | null, +): CanvasMediaType { + if (assetKind === 'character-animation') { + return 'image-sequence'; + } + if (assetKind === 'video') { + return 'video'; + } + if ( + assetKind === 'audio' || + assetKind === 'sound-effect' || + assetKind === 'background-music' + ) { + return 'audio'; + } + return 'image'; +} + export function inferEditorAssetMediaType( imageSrc: string, objectKey?: string | null, assetKind?: string | null, ): CanvasMediaType { - if (assetKind === 'video') { + const normalizedAssetKind = canvasAssetKindOrNull(assetKind); + if (normalizedAssetKind) { + return resolveCanvasMediaTypeFromAssetKind(normalizedAssetKind); + } + + const sources = [objectKey, imageSrc]; + if (sources.some((source) => hasMediaFileExtension(source, '.mp4'))) { return 'video'; } - if (assetKind === 'sound-effect' || assetKind === 'background-music') { - return 'audio'; - } - const source = `${objectKey ?? ''} ${imageSrc}`.toLowerCase(); - const sourceWithoutQuery = source.split(/[?#]/u)[0] ?? source; - if (sourceWithoutQuery.includes('.mp4')) { - return 'video'; - } - if (sourceWithoutQuery.includes('.mp3')) { + if (sources.some((source) => hasMediaFileExtension(source, '.mp3'))) { return 'audio'; } return 'image'; } -function resolveHydratedLayerMediaType( - snapshot: EditorProjectLayerSnapshot, - imageSequenceFrames: CanvasLayer['imageSequenceFrames'], -): CanvasMediaType { - if (snapshot.mediaType === 'image-sequence') { - return 'image-sequence'; +function inferEditorAssetKind( + label: string, + imageSrc: string, + objectKey?: string | null, + assetKind?: string | null, + mediaType?: CanvasMediaType, +): CanvasAssetKind { + const normalizedAssetKind = canvasAssetKindOrNull(assetKind); + if (normalizedAssetKind) { + return normalizedAssetKind; } - if ( - snapshot.assetKind === 'character-animation' && - (imageSequenceFrames?.length || stringOrNull(snapshot.thumbnailSrc)) - ) { - return 'image-sequence'; + + const inferredMediaType = + mediaType === 'video' || mediaType === 'audio' + ? mediaType + : inferEditorAssetMediaType(imageSrc, objectKey); + if (inferredMediaType === 'video') { + return 'video'; } - if (snapshot.mediaType === 'video' || snapshot.mediaType === 'audio') { - return snapshot.mediaType; + if (inferredMediaType === 'audio') { + return inferAudioAssetKindFromLabel(label); } return 'image'; } +function hasMediaFileExtension( + source: string | null | undefined, + extension: string, +) { + const normalizedSource = source?.trim().toLowerCase() ?? ''; + const sourceWithoutQuery = normalizedSource.split(/[?#]/u)[0] ?? ''; + return sourceWithoutQuery.endsWith(extension); +} + function inferAudioAssetKindFromLabel(label: string): CanvasAssetKind { - return label.includes('背景音乐') || label.toLowerCase().includes('music') - ? 'background-music' - : 'sound-effect'; + if (label.includes('背景音乐') || label.toLowerCase().includes('music')) { + return 'background-music'; + } + return 'sound-effect'; } export type CanvasLayerResourceMetadata = { @@ -871,7 +950,7 @@ export type CanvasLayerResourceMetadata = { width?: number | null; height?: number | null; sourceType?: string | null; - durationSeconds?: number | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; prompt?: string | null; @@ -880,6 +959,7 @@ export type CanvasLayerResourceMetadata = { provider?: string | null; taskId?: string | null; generationInputs?: unknown; + imageSequenceFrames?: unknown; createdAt?: string | null; updatedAt?: string | null; }; @@ -1002,7 +1082,7 @@ function hydrateImageSequenceFrames( if (!Array.isArray(value)) { return []; } - const frames = value.flatMap((frame, index) => { + const frames = value.flatMap((frame) => { if (!frame || typeof frame !== 'object') { return []; } @@ -1013,7 +1093,6 @@ function hydrateImageSequenceFrames( } return [ { - frameIndex: numberFromSnapshot(snapshot.frameIndex, index + 1), imageSrc, objectKey: stringOrUndefined(snapshot.objectKey), assetObjectId: stringOrUndefined(snapshot.assetObjectId), @@ -1149,7 +1228,9 @@ export function generationInputsOrNull( } export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null { - return value === 'spec' || + return value === 'image' || + value === 'audio' || + value === 'spec' || value === 'character' || value === 'character-animation' || value === 'icon' || diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 64b479ef9..ff6795de0 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -1,11 +1,11 @@ import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, - EditorCharacterAnimationFrameResult, EditorCharacterAnimationGenerationResult, EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, EditorImageGenerationStyle, + EditorImageSequenceFrameResult, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -15,6 +15,8 @@ import type { export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated'; export type CanvasAssetKind = + | 'image' + | 'audio' | 'spec' | 'character' | 'character-animation' @@ -58,7 +60,8 @@ export type EditorAsset = { showcaseSubmitError?: string | null; assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; - durationSeconds?: number; + imageSequenceFrames?: EditorImageSequenceFrameResult[]; + imageSequenceDurationMs?: number; uploadStatus?: 'uploading' | 'failed'; uploadProgress?: number; uploadMessage?: string; @@ -110,7 +113,8 @@ export type CanvasLayer = { assetKind?: CanvasAssetKind | null; generationInputs?: CanvasGenerationInputs | null; durationSeconds?: number; - imageSequenceFrames?: EditorCharacterAnimationFrameResult[]; + imageSequenceFrames?: EditorImageSequenceFrameResult[]; + imageSequenceDurationMs?: number; previewVideoPath?: string | null; hidden?: boolean; locked?: boolean; diff --git a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts index 2fee555f1..97e34c81f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test-utils.ts +++ b/src/components/image-editor/ImageCanvasEditorView.test-utils.ts @@ -267,6 +267,16 @@ export function setupImageCanvasEditorViewTestLifecycle({ immediateAsync(withEditorProjectCanvasRevision({ projectId: 'editor-project-default', title: '默认项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: defaultEditorProjectLayers, + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: defaultEditorProjectLayers, resources: defaultEditorProjectResources, @@ -299,7 +309,8 @@ export function setupImageCanvasEditorViewTestLifecycle({ sourceType: input.sourceType, assetKind: input.assetKind, generationInputs: input.generationInputs, - durationSeconds: input.durationSeconds, + imageSequenceFrames: input.imageSequenceFrames, + imageSequenceDurationMs: input.imageSequenceDurationMs, })); createEditorAssetFolderMock.mockResolvedValue({ folderId: 'folder-role-persisted', @@ -353,12 +364,20 @@ export function setupImageCanvasEditorViewTestLifecycle({ width: input.width, height: input.height, sourceType: input.sourceType, - durationSeconds: input.durationSeconds, assetKind: input.assetKind, generationInputs: input.generationInputs, + imageSequenceFrames: input.imageSequenceFrames, + imageSequenceDurationMs: input.imageSequenceDurationMs, + }), + ); + saveEditorProjectLayoutMock.mockImplementation( + async (projectId, input) => ({ + projectId, + canvasId: `${projectId}:canvas:default`, + revision: input.expectedRevision + 1, + updatedAt: '2026-06-12T00:00:01.000Z', }), ); - saveEditorProjectLayoutMock.mockResolvedValue({}); }); afterEach(() => { diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index ce516ccd9..8461bcff3 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -289,7 +289,8 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { sourceAssetId: asset.id, assetKind: asset.assetKind ?? null, generationInputs: asset.generationInputs ?? null, - durationSeconds: asset.durationSeconds, + imageSequenceFrames: asset.imageSequenceFrames, + imageSequenceDurationMs: asset.imageSequenceDurationMs, }; } @@ -1003,11 +1004,22 @@ export function ImageCanvasEditorView({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, assetKind: layer.assetKind, generationInputs: layer.generationInputs, }) .then((asset) => { + const persistedAssetKind = canvasAssetKindOrNull(asset.assetKind); + if ( + layer.assetKind === 'character-animation' && + (persistedAssetKind !== 'character-animation' || + (asset.imageSequenceFrames?.length ?? 0) < 2 || + !(asset.imageSequenceDurationMs && + asset.imageSequenceDurationMs > 0)) + ) { + throw new Error('服务器未返回完整的角色动作正式字段'); + } setAssets((currentAssets) => [ ...currentAssets.filter( (currentAsset) => currentAsset.id !== asset.assetId, @@ -1030,8 +1042,10 @@ export function ImageCanvasEditorView({ taskId: asset.taskId ?? undefined, objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, - durationSeconds: asset.durationSeconds ?? layer.durationSeconds, - assetKind: canvasAssetKindOrNull(asset.assetKind), + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: + asset.imageSequenceDurationMs ?? undefined, + assetKind: persistedAssetKind, generationInputs: generationInputsOrNull(asset.generationInputs), }, ]); @@ -1095,7 +1109,8 @@ export function ImageCanvasEditorView({ objectKey: asset.objectKey ?? undefined, assetObjectId: asset.assetObjectId ?? undefined, thumbnailSrc: asset.thumbnailSrc ?? undefined, - durationSeconds: asset.durationSeconds ?? undefined, + imageSequenceFrames: asset.imageSequenceFrames ?? undefined, + imageSequenceDurationMs: asset.imageSequenceDurationMs ?? undefined, assetKind: canvasAssetKindOrNull(asset.assetKind), generationInputs: generationInputsOrNull(asset.generationInputs), }, @@ -1314,7 +1329,8 @@ export function ImageCanvasEditorView({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -1381,6 +1397,7 @@ export function ImageCanvasEditorView({ isExportingAssets, exportCanvasAssets, exportLayerImage, + reportAssetError, } = useImageCanvasAssetExportWorkflow({ layers, projectId, @@ -2214,6 +2231,7 @@ export function ImageCanvasEditorView({ appendCanvasLayersWithResources, selectSingleLayer, addUploadedFiles, + onAssetError: reportAssetError, }); deleteLayerByIdRef.current = deleteLayerById; diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index 6a3eeeb06..fbfab01a1 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -98,7 +98,6 @@ describe('ImageCanvasExportModel', () => { taskId: 'sequence-task', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, @@ -217,15 +216,15 @@ describe('ImageCanvasExportModel', () => { mediaType: 'image-sequence', originalWidth: 192, originalHeight: 256, - durationSeconds: 0.5, + imageSequenceDurationMs: 500, }); const spineJson = buildSpineImageSequenceJson({ layer, frames: [ - { frameIndex: 1, fileName: 'frame-01.png', width: 192, height: 256 }, - { frameIndex: 2, fileName: 'frame-02.png', width: 192, height: 256 }, - { frameIndex: 3, fileName: 'frame-03.png', width: 192, height: 256 }, - { frameIndex: 4, fileName: 'frame-04.png', width: 192, height: 256 }, + { fileName: 'frame-01.png', width: 192, height: 256 }, + { fileName: 'frame-02.png', width: 192, height: 256 }, + { fileName: 'frame-03.png', width: 192, height: 256 }, + { fileName: 'frame-04.png', width: 192, height: 256 }, ], }); @@ -311,7 +310,6 @@ describe('ImageCanvasExportModel', () => { mediaType: 'image-sequence', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-editor-frames/frame-1.png', objectKey: 'generated/frame-1.png', width: 512, @@ -320,7 +318,6 @@ describe('ImageCanvasExportModel', () => { ], }), { - frameIndex: 1, imageSrc: '/generated-editor-frames/frame-1.png', objectKey: 'generated/frame-1.png', width: 512, diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 3c064986d..3b9d4233d 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -173,20 +173,7 @@ export async function readLayerAssetBlob(layer: CanvasLayer) { export const readLayerImageBlob = readLayerAssetBlob; export function getLayerImageSequenceFrames(layer: CanvasLayer) { - if (layer.imageSequenceFrames?.length) { - return layer.imageSequenceFrames; - } - const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); - return fallbackSrc - ? [ - { - frameIndex: 1, - imageSrc: fallbackSrc, - width: layer.originalWidth, - height: layer.originalHeight, - }, - ] - : []; + return layer.imageSequenceFrames ?? []; } export async function readLayerImageSequenceFrameBlob( @@ -196,7 +183,7 @@ export async function readLayerImageSequenceFrameBlob( return readAssetSourceBlob({ source: frame.imageSrc, objectKey: frame.objectKey, - refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.objectKey ?? frame.imageSrc}`, }); } @@ -206,12 +193,11 @@ export function getImageSequenceFrameFileName( blobType = '', ) { const extension = getImageExtensionFromTypeOrSrc(blobType, frame.imageSrc); - const frameNumber = String(frame.frameIndex || index + 1).padStart(2, '0'); + const frameNumber = String(index + 1).padStart(2, '0'); return `frame-${frameNumber}.${extension}`; } export type ExportedImageSequenceFrame = { - frameIndex: number; fileName: string; width: number; height: number; @@ -417,8 +403,9 @@ export function buildSpineImageSequenceJson({ } const durationSeconds = - typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 - ? layer.durationSeconds + typeof layer.imageSequenceDurationMs === 'number' && + layer.imageSequenceDurationMs > 0 + ? layer.imageSequenceDurationMs / 1_000 : frames.length; const frameDuration = durationSeconds / frames.length; const skeletonWidth = frames[0]?.width || layer.originalWidth; diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts index 248cbd2c9..790d4a886 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -589,24 +589,40 @@ describe('ImageCanvasGenerationLayerModel', () => { }); it('creates character animation result layers as playable image sequences', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/frame1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/frame2.png', + width: 1024, + height: 1024, + }, + ]; const layer = createCharacterAnimationResultLayer({ generated: { taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '待机动作', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', - frames: [ - { - frameIndex: 1, - imageSrc: '/generated-character-drafts/editor/frame1.png', - width: 1024, - height: 1024, - }, - ], - frameCount: 32, + frames, + frameCount: 2, durationSeconds: 4, fps: 8, priceMudPoints: 40, + resource: { + resourceId: 'resource-character-animation-15', + projectId: 'project-1', + imageSrc: frames[0]!.imageSrc, + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, }, generatedIndex: 15, title: '角色动作', @@ -628,20 +644,13 @@ describe('ImageCanvasGenerationLayerModel', () => { expect(layer).toMatchObject({ id: 'layer-character-animation-15', - resourceId: 'local-resource-character-animation-15', + resourceId: 'resource-character-animation-15', title: '角色动作', src: '/generated-character-drafts/editor/frame1.png', mediaType: 'image-sequence', thumbnailSrc: '/generated-character-drafts/editor/frame1.png', - imageSequenceFrames: [ - { - frameIndex: 1, - imageSrc: '/generated-character-drafts/editor/frame1.png', - width: 1024, - height: 1024, - }, - ], - previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + imageSequenceFrames: frames, + previewVideoPath: null, x: -222, y: -242, width: 1024, @@ -652,12 +661,76 @@ describe('ImageCanvasGenerationLayerModel', () => { sourceType: 'generated', prompt: '待机动作', actualPrompt: '待机动作', + imageSequenceDurationMs: 4_000, model: 'seedance2.0-fast', provider: 'ark', taskId: 'character-animation-task-1', assetKind: 'character-animation', - durationSeconds: 4, }); expect(layer?.generationInputs?.fields[0]?.value).toBe('待机动作'); }); + + it('links a character animation result layer to its persisted project resource', () => { + const frames = [ + { + imageSrc: '/generated-character-drafts/editor/frame1.png', + objectKey: 'generated-character-drafts/editor/frame1.png', + assetObjectId: 'asset-object-frame-1', + width: 512, + height: 768, + }, + { + imageSrc: '/generated-character-drafts/editor/frame2.png', + objectKey: 'generated-character-drafts/editor/frame2.png', + assetObjectId: 'asset-object-frame-2', + width: 512, + height: 768, + }, + ]; + const layer = createCharacterAnimationResultLayer({ + generated: { + taskId: 'character-animation-task-persisted', + model: 'seedance2.0-fast', + prompt: '奔跑动作', + previewVideoPath: '/generated-character-drafts/editor/preview.mp4', + frames, + frameCount: 2, + durationSeconds: 4, + fps: 8, + priceMudPoints: 40, + resource: { + resourceId: 'resource-character-animation-final', + projectId: 'project-1', + imageSrc: frames[0]!.imageSrc, + objectKey: frames[0]!.objectKey, + assetObjectId: frames[0]!.assetObjectId, + width: 512, + height: 768, + sourceType: 'generated', + sourceResourceId: 'resource-character-animation-preview', + assetKind: 'character-animation', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }, + }, + generatedIndex: 16, + title: '角色奔跑', + canvasSize: { width: 900, height: 640 }, + viewport: { x: 0, y: 0, scale: 1 }, + generationInputs: createGenerationInputs(), + sourceLayer: createSourceLayer({ + resourceId: 'resource-original-character', + }), + }); + + expect(layer).toMatchObject({ + resourceId: 'resource-character-animation-final', + src: frames[0]!.imageSrc, + objectKey: frames[0]!.objectKey, + assetObjectId: frames[0]!.assetObjectId, + sourceResourceId: 'resource-character-animation-preview', + imageSequenceFrames: frames, + imageSequenceDurationMs: 4_000, + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts index 612678189..8d45623a7 100644 --- a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -534,8 +534,17 @@ export function createCharacterAnimationResultLayer({ frame, sourceLayer, }: CharacterAnimationResultLayerOptions): CanvasLayer | null { - const previewFrame = generated.frames[0]; - if (!previewFrame) { + const resource = generated.resource ?? undefined; + const asset = generated.asset ?? undefined; + const persisted = resource ?? asset; + const imageSequenceFrames = persisted?.imageSequenceFrames ?? []; + const imageSequenceDurationMs = persisted?.imageSequenceDurationMs; + const previewFrame = imageSequenceFrames[0]; + if ( + imageSequenceFrames.length < 2 || + !previewFrame || + !(imageSequenceDurationMs && imageSequenceDurationMs > 0) + ) { return null; } const originalWidth = previewFrame.width || 1024; @@ -557,13 +566,15 @@ export function createCharacterAnimationResultLayer({ return { id: `layer-character-animation-${generatedIndex}`, - resourceId: `local-resource-character-animation-${generatedIndex}`, + resourceId: + resource?.resourceId ?? + `local-resource-${asset?.assetId ?? `character-animation-${generatedIndex}`}`, title, - src: previewFrame.imageSrc, + src: persisted?.imageSrc ?? previewFrame.imageSrc, mediaType: 'image-sequence', thumbnailSrc: previewFrame.imageSrc, - imageSequenceFrames: generated.frames, - previewVideoPath: generated.previewVideoPath, + imageSequenceFrames, + previewVideoPath: null, x: frameX ?? worldCenter.x - width / 2, y: frameY ?? worldCenter.y - height / 2, width, @@ -572,15 +583,19 @@ export function createCharacterAnimationResultLayer({ originalHeight, zIndex: generatedIndex + 10, sourceType: 'generated', - prompt: generated.prompt, - actualPrompt: generated.prompt, - model: generated.model, - provider: 'ark', - taskId: generated.taskId, + prompt: persisted?.prompt ?? generated.prompt, + actualPrompt: persisted?.actualPrompt ?? generated.prompt, + model: persisted?.model ?? generated.model, + provider: persisted?.provider ?? 'ark', + taskId: persisted?.taskId ?? generated.taskId, assetKind: 'character-animation', - durationSeconds: generated.durationSeconds, - sourceResourceId: sourceLayer?.resourceId, + imageSequenceDurationMs, + objectKey: persisted?.objectKey, + assetObjectId: persisted?.assetObjectId, + sourceAssetId: asset?.assetId, + sourceResourceId: resource?.sourceResourceId ?? sourceLayer?.resourceId, groupId: sourceLayer?.groupId, - generationInputs, + generationInputs: persisted?.generationInputs ?? generationInputs, + generatedAssetSnapshot: asset, }; } diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index c2e21ecbf..62b3f5beb 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -504,8 +504,21 @@ describe('ImageCanvasGenerationModel', () => { mediaType: 'image-sequence', src: '/generated-character-drafts/editor/frame01.png', thumbnailSrc: '/generated-character-drafts/editor/frame01.png', + imageSequenceFrames: [ + { + imageSrc: '/generated-character-drafts/editor/frame01.png', + objectKey: 'generated-character-drafts/editor/frame01.png', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-character-drafts/editor/frame02.png', + width: 512, + height: 512, + }, + ], }), - ).toBe('/generated-character-drafts/editor/frame01.png'); + ).toBe('generated-character-drafts/editor/frame01.png'); }); it('maps generation dialog mode and authorization errors to user-facing copy', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 463def180..4ec162fb7 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -901,8 +901,13 @@ function cloneModelPricing( } export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { - if (layer.assetKind === 'character-animation' && layer.thumbnailSrc?.trim()) { - return layer.thumbnailSrc.trim(); + if (layer.assetKind === 'character-animation') { + const firstFrame = layer.imageSequenceFrames?.[0]; + const source = firstFrame?.objectKey?.trim() || firstFrame?.imageSrc?.trim(); + if (!source) { + throw new Error('角色动作缺少正式序列首帧,无法继续生成。'); + } + return source; } // 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。 return layer.objectKey?.trim() || layer.src; diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index 25401a6f4..bd7b12a85 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -254,7 +254,6 @@ describe('ImageCanvasHistoryModel', () => { it('blocks replacing an image sequence frame', () => { const frame = { - frameIndex: 0, imageSrc: '/frames/0.png', objectKey: 'frames/0.png', width: 100, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index 8ace4aed9..ef28ce609 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -94,7 +94,6 @@ function hasSameImageSequenceFrames( const targetFrame = targetFrames[index]; return ( targetFrame !== undefined && - currentFrame.frameIndex === targetFrame.frameIndex && hasSameStableMediaReference( { src: currentFrame.imageSrc, @@ -118,7 +117,8 @@ function hasSameLayerContent( return ( (current.mediaType ?? 'image') === (target.mediaType ?? 'image') && hasSameStableMediaReference(current, target) && - hasSameImageSequenceFrames(current, target) + hasSameImageSequenceFrames(current, target) && + current.imageSequenceDurationMs === target.imageSequenceDurationMs ); } diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts index 805f0d367..fe1acf025 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.test.ts @@ -112,7 +112,6 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => { mediaType: 'image-sequence', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated/frame-1.png', objectKey: 'generated/frame-1.png', width: 320, diff --git a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts index 4b4aabba3..08ee58437 100644 --- a/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts +++ b/src/components/image-editor/ImageCanvasProjectCoverSnapshotModel.ts @@ -117,9 +117,10 @@ function resolveLayerSnapshotSource(layer: CanvasLayer) { return { src: frameSrc, objectKey: normalizeObjectKey(frame?.objectKey), - cacheVersion: `${layer.taskId ?? layer.resourceId}:${frame?.frameIndex ?? 0}`, + cacheVersion: `${layer.taskId ?? layer.resourceId}:0`, }; } + return null; } const snapshotSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); if (!snapshotSrc) { diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index fed0c5e1d..d87f810c9 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -11,6 +11,7 @@ import type { QuickEditPanelState, } from './ImageCanvasEditorTypes'; import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; +import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; @@ -25,7 +26,6 @@ type PersistedUploadAsset = { height: number; objectKey?: string | null; assetObjectId?: string | null; - durationSeconds?: number | null; }; type GenerationReferenceUploadTarget = | 'character-reference' @@ -299,14 +299,12 @@ export function createUploadingAssetPlaceholder({ folderId, mediaType = 'image', imageDimensions, - durationSeconds, }: { uploadIndex: number; fileName?: string; folderId: string; mediaType?: CanvasMediaType; imageDimensions?: UploadImageDimensions; - durationSeconds?: number; }): EditorAsset { const fallbackSize = resolveUploadFallbackSize(mediaType); const size = @@ -334,7 +332,7 @@ export function createUploadingAssetPlaceholder({ sourceKind: 'uploaded', sourceType: 'uploaded', persisted: false, - durationSeconds, + assetKind: resolveUploadedAssetKind(mediaType), uploadStatus: 'uploading', uploadProgress: 8, uploadMessage: '准备上传', @@ -438,8 +436,6 @@ export function applyPersistedUploadAsset({ height: persistedAsset.height, objectKey: persistedAsset.objectKey ?? undefined, assetObjectId: persistedAsset.assetObjectId ?? undefined, - durationSeconds: - persistedAsset.durationSeconds ?? asset.durationSeconds, persisted: true, uploadStatus: undefined, uploadProgress: undefined, @@ -510,8 +506,6 @@ export function bindUploadLayerToPersistedAsset({ mediaType: persistedAsset.mediaType ?? layer.mediaType, objectKey: persistedAsset.objectKey ?? layer.objectKey, assetObjectId: persistedAsset.assetObjectId ?? layer.assetObjectId, - durationSeconds: - persistedAsset.durationSeconds ?? layer.durationSeconds, } : layer, ); @@ -585,12 +579,7 @@ export function createUploadCanvasLayer({ : '上传图片'), src: imageSrc, mediaType, - assetKind: - mediaType === 'video' - ? 'video' - : mediaType === 'audio' - ? 'sound-effect' - : undefined, + assetKind: resolveUploadedAssetKind(mediaType), x: worldCenterX - layerSize.width / 2, y: worldCenterY - layerSize.height / 2, width: layerSize.width, diff --git a/src/components/image-editor/ImageCanvasUploadUtils.ts b/src/components/image-editor/ImageCanvasUploadUtils.ts new file mode 100644 index 000000000..da6b56967 --- /dev/null +++ b/src/components/image-editor/ImageCanvasUploadUtils.ts @@ -0,0 +1,16 @@ +import type { + CanvasAssetKind, + CanvasMediaType, +} from './ImageCanvasEditorTypes'; + +export function resolveUploadedAssetKind( + mediaType: CanvasMediaType, +): CanvasAssetKind { + switch (mediaType) { + case 'video': + return 'video'; + case 'audio': + return 'audio'; + } + return 'image'; +} diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 446a3fa26..a32b06b31 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -225,12 +225,13 @@ describe('ImageCanvasWorldView', () => { it('resolves image layers from objectKey before falling back to src', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ - resolvedUrl: - options?.objectKey === 'generated-character-drafts/editor/uploaded.png' - ? 'https://oss.example.com/uploaded.png?signature=1' - : _source, - isResolving: false, - shouldResolve: true, + resolvedUrl: + options?.objectKey === + 'generated-character-drafts/editor/uploaded.png' + ? 'https://oss.example.com/uploaded.png?signature=1' + : _source, + isResolving: false, + shouldResolve: true, }), ); @@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('生成中')).toBeTruthy(); const world = document.querySelector('.image-canvas-editor__world'); - const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + const marquee = world?.querySelector( + '.image-canvas-editor__canvas-marquee', + ); expect(world?.getAttribute('style')).toContain( 'transform: translate(10px, 20px) scale(1.5)', @@ -530,7 +533,14 @@ describe('ImageCanvasWorldView', () => { }, ])( 'renders $mode generation placeholder with its own icon and badge', - ({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => { + ({ + mode, + ariaLabel, + generatorLabel, + badgeLabel, + frameClassName, + iconClassName, + }) => { const dialog = createGenerationDialog({ id: `dialog-${mode}`, mode: mode as CanvasGenerationDialogState['mode'], @@ -614,9 +624,9 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(generatorLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( mode === 'audio-sound-effect' || mode === 'audio-background-music' @@ -630,9 +640,9 @@ describe('ImageCanvasWorldView', () => { : inverseScale, ); expect( - (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(badgeLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }, ); @@ -718,14 +728,14 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('角色') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('640 x 480 px') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( within(layerButton) @@ -733,14 +743,14 @@ describe('ImageCanvasWorldView', () => { .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('Image Generator') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('1024 x 768') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }); @@ -755,8 +765,11 @@ describe('ImageCanvasWorldView', () => { const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect( - (frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ( + frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe('4'); }); @@ -813,9 +826,9 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain( - 'lucide-info', - ); + expect( + metadataButton.querySelector('svg')?.getAttribute('class'), + ).toContain('lucide-info'); }); it('renders audio layers with an audio control card instead of an image', () => { @@ -849,7 +862,8 @@ describe('ImageCanvasWorldView', () => { within(layerButton).getAllByRole('button', { name: /播放|暂停/u }), ).toHaveLength(1); expect( - within(layerButton).getByRole('button', { name: '播放游戏音效' }) + within(layerButton) + .getByRole('button', { name: '播放游戏音效' }) .querySelector('svg') ?.getAttribute('class'), ).toContain('lucide-volume-2'); @@ -872,9 +886,7 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy(); expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy(); - expect( - within(layerButton).queryByText('420 x 120 px'), - ).toBeNull(); + expect(within(layerButton).queryByText('420 x 120 px')).toBeNull(); }); it('keeps audio controls interactive and switches icon, play and pause states', () => { @@ -895,7 +907,9 @@ describe('ImageCanvasWorldView', () => { selectedLayerIds: [layer.id], }); - const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' }); + const layerButton = screen.getByRole('button', { + name: '选择游戏背景音乐', + }); const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐'); const playbackButton = within(layerButton).getByRole('button', { name: '播放游戏背景音乐', @@ -932,9 +946,8 @@ describe('ImageCanvasWorldView', () => { ?.getAttribute('class'), ).toContain('lucide-play'); - const hoveredAudio = within(hoveredLayerButton).getByLabelText( - '画布音频:游戏背景音乐', - ); + const hoveredAudio = + within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐'); fireEvent.play(hoveredAudio); expect( within(hoveredLayerButton) @@ -1032,9 +1045,7 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); fireEvent.pointerDown(document.body); - expect( - screen.queryByRole('menu', { name: '选择素材标签' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull(); } finally { document.removeEventListener('wheel', documentWheel); } @@ -1063,7 +1074,7 @@ describe('ImageCanvasWorldView', () => { ? 'https://oss.example.com/video-7.mp4?signature=1' : source === '/generated-character-drafts/editor/video-7-poster.png' ? 'https://oss.example.com/video-7-poster.png?signature=1' - : source, + : source, isResolving: false, shouldResolve: true, })); @@ -1100,7 +1111,9 @@ describe('ImageCanvasWorldView', () => { layerButton.querySelector('.image-canvas-editor__media-preview--video') ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); - expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull(); + expect( + within(layerButton).queryByAltText('画布视频:生成视频 7'), + ).toBeNull(); expect(within(layerButton).getByText('视频')).toBeTruthy(); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), @@ -1153,19 +1166,17 @@ describe('ImageCanvasWorldView', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }); renderWorldView({ layers: [layer] }); @@ -1201,17 +1212,15 @@ describe('ImageCanvasWorldView', () => { it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { vi.useFakeTimers(); try { - useResolvedAssetReadUrlMock.mockImplementation( - (source: string) => ({ - resolvedUrl: - source === '/generated-character-drafts/editor/frame01.png' - ? 'https://oss.example.com/frame01.png?signature=1' - : '', - isResolving: - source === '/generated-character-drafts/editor/frame02.png', - shouldResolve: true, - }), - ); + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ + resolvedUrl: + source === '/generated-character-drafts/editor/frame01.png' + ? 'https://oss.example.com/frame01.png?signature=1' + : '', + isResolving: + source === '/generated-character-drafts/editor/frame02.png', + shouldResolve: true, + })); const layer = createLayer({ title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', @@ -1220,29 +1229,26 @@ describe('ImageCanvasWorldView', () => { thumbnailSrc: '/generated-character-drafts/editor/frame01.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', objectKey: 'generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: '/generated-character-drafts/editor/frame02.png', objectKey: 'generated-character-drafts/editor/frame02.png', width: 1024, height: 1024, }, ], - durationSeconds: 0.1, + imageSequenceDurationMs: 100, }); renderWorldView({ layers: [layer] }); const layerButton = screen.getByRole('button', { name: '选择角色动作' }); - const firstFrame = within(layerButton).getByAltText( - '画布序列帧:角色动作', - ); + const firstFrame = + within(layerButton).getByAltText('画布序列帧:角色动作'); fireEvent.load(firstFrame); expect( @@ -1292,8 +1298,6 @@ describe('ImageCanvasWorldView', () => { }); expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); - expect( - screen.queryByRole('button', { name: '图像生成占位图' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 97ef196df..783b7f2e4 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -691,20 +691,7 @@ function ImageCanvasImageLayer({ } function getLayerImageSequenceFrames(layer: CanvasLayer) { - if (layer.imageSequenceFrames?.length) { - return layer.imageSequenceFrames; - } - const fallbackSrc = layer.thumbnailSrc?.trim() || layer.src.trim(); - return fallbackSrc - ? [ - { - frameIndex: 1, - imageSrc: fallbackSrc, - width: layer.originalWidth, - height: layer.originalHeight, - }, - ] - : []; + return layer.imageSequenceFrames ?? []; } function getImageSequenceFrameKey( @@ -715,7 +702,6 @@ function getImageSequenceFrameKey( return [ layer.id, layer.taskId ?? layer.resourceId ?? '', - frame.frameIndex, frame.objectKey ?? '', frame.imageSrc, index, @@ -741,7 +727,7 @@ function ImageCanvasImageSequenceFrame({ }) { const { resolvedUrl } = useResolvedAssetReadUrl(frame.imageSrc, { objectKey: frame.objectKey, - refreshKey: `${layer.taskId ?? layer.resourceId}:${frame.frameIndex}`, + refreshKey: `${layer.taskId ?? layer.resourceId}:${frameKey}`, }); const frameImageRef = useRef(null); @@ -841,19 +827,17 @@ function ImageCanvasImageSequenceLayer({ if (!isPlaying || frames.length <= 1) { return undefined; } - const durationSeconds = - typeof layer.durationSeconds === 'number' && layer.durationSeconds > 0 - ? layer.durationSeconds - : frames.length / 8; - const intervalMs = Math.max( - 40, - Math.round((durationSeconds * 1000) / frames.length), - ); + const durationMs = + typeof layer.imageSequenceDurationMs === 'number' && + layer.imageSequenceDurationMs > 0 + ? layer.imageSequenceDurationMs + : (frames.length / 8) * 1_000; + const intervalMs = Math.max(40, Math.round(durationMs / frames.length)); const timer = window.setInterval(() => { setFrameIndex((currentIndex) => (currentIndex + 1) % frames.length); }, intervalMs); return () => window.clearInterval(timer); - }, [frames.length, isPlaying, layer.durationSeconds]); + }, [frames.length, isPlaying, layer.imageSequenceDurationMs]); return ( <> @@ -1003,176 +987,176 @@ export function ImageCanvasWorldView({ ) : null} {visibleLayers.map((layer) => { - const isSelected = selectedLayerIds.includes(layer.id); - const isHovered = hoveredLayerId === layer.id; - const kindTagMeta = getLayerKindTagMeta(layer); - const kindLabel = kindTagMeta.label; - const mediaTransform = - layer.flipX || layer.flipY - ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` - : undefined; - const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( - layer.id, - ) - ? '拆图中' - : generateDialog?.mode === 'edit' && - generateDialog.status === 'generating' && - generateDialog.sourceLayerId === layer.id - ? '修改中' - : null; - const isMediaContainer = isCanvasMediaContainerLayer(layer); - const LayerElement = isMediaContainer ? 'div' : 'button'; - const metadataMediaLabel = getLayerMetadataMediaLabel(layer); - return ( -
- onLayerPointerDown(event, layer)} - onClick={(event) => { - setOpenLayerKindMenuId(null); - onLayerClick(event, layer); - }} - onContextMenu={(event) => { - setOpenLayerKindMenuId(null); - onLayerContextMenu(event, layer); - }} - onKeyDown={ - isMediaContainer ? handleLayerKeyboardActivation : undefined - } - onMouseEnter={() => onLayerMouseEnter(layer.id)} - onMouseLeave={() => onLayerMouseLeave(layer.id)} - aria-label={`选择${layer.title}`} - > - {layer.mediaType === 'audio' ? ( - onSelectLayer(layer.id)} - /> - ) : layer.mediaType === 'video' ? ( - - ) : layer.mediaType === 'image-sequence' ? ( - - ) : ( - - )} - {kindLabel ? ( - event.stopPropagation()} - onClick={(event) => { - event.stopPropagation(); - setOpenLayerKindMenuId((currentId) => - currentId === layer.id ? null : layer.id, - ); - }} - onKeyDown={handleLayerTagKeyboardActivation} - > - {kindLabel} - - ) : null} - } + const isSelected = selectedLayerIds.includes(layer.id); + const isHovered = hoveredLayerId === layer.id; + const kindTagMeta = getLayerKindTagMeta(layer); + const kindLabel = kindTagMeta.label; + const mediaTransform = + layer.flipX || layer.flipY + ? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})` + : undefined; + const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has( + layer.id, + ) + ? '拆图中' + : generateDialog?.mode === 'edit' && + generateDialog.status === 'generating' && + generateDialog.sourceLayerId === layer.id + ? '修改中' + : null; + const isMediaContainer = isCanvasMediaContainerLayer(layer); + const LayerElement = isMediaContainer ? 'div' : 'button'; + const metadataMediaLabel = getLayerMetadataMediaLabel(layer); + return ( +
+ onLayerPointerDown(event, layer)} + onClick={(event) => { + setOpenLayerKindMenuId(null); + onLayerClick(event, layer); + }} + onContextMenu={(event) => { + setOpenLayerKindMenuId(null); + onLayerContextMenu(event, layer); + }} + onKeyDown={ + isMediaContainer ? handleLayerKeyboardActivation : undefined + } + onMouseEnter={() => onLayerMouseEnter(layer.id)} + onMouseLeave={() => onLayerMouseLeave(layer.id)} + aria-label={`选择${layer.title}`} + > + {layer.mediaType === 'audio' ? ( + onSelectLayer(layer.id)} + /> + ) : layer.mediaType === 'video' ? ( + + ) : layer.mediaType === 'image-sequence' ? ( + + ) : ( + + )} + {kindLabel ? ( + event.stopPropagation()} onClick={(event) => { event.stopPropagation(); - onOpenLayerMetadata(layer); - }} - onPointerDown={(event) => event.stopPropagation()} - /> - {isHovered && layer.mediaType !== 'audio' ? ( - - {getLayerMetricLabel(layer)} - - ) : null} - {layerGeneratingLabel ? ( - - {layerGeneratingLabel} - - ) : null} - - {openLayerKindMenuId === layer.id ? ( -
+ currentId === layer.id ? null : layer.id, + ); }} + onKeyDown={handleLayerTagKeyboardActivation} > - event.stopPropagation()} - onWheel={(event) => event.stopPropagation()} - > - {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( - { - event.stopPropagation(); - onUpdateLayerAssetKind(layer.id, option.value); - setOpenLayerKindMenuId(null); - }} - > - {option.label} - - ))} - -
+ {kindLabel} + ) : null} -
- ); - })} + } + style={inverseScaleStyle} + onClick={(event) => { + event.stopPropagation(); + onOpenLayerMetadata(layer); + }} + onPointerDown={(event) => event.stopPropagation()} + /> + {isHovered && layer.mediaType !== 'audio' ? ( + + {getLayerMetricLabel(layer)} + + ) : null} + {layerGeneratingLabel ? ( + + {layerGeneratingLabel} + + ) : null} +
+ {openLayerKindMenuId === layer.id ? ( +
+ event.stopPropagation()} + onWheel={(event) => event.stopPropagation()} + > + {CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => ( + { + event.stopPropagation(); + onUpdateLayerAssetKind(layer.id, option.value); + setOpenLayerKindMenuId(null); + }} + > + {option.label} + + ))} + +
+ ) : null} +
+ ); + })} {canvasMarquee ? (
void; + onAssetError?: (message: string) => void; }; export function useImageCanvasAssetLayerCleanup({ @@ -111,13 +112,13 @@ export function useImageCanvasAssetLayerCleanup({ } const currentLayer = layers.find((layer) => layer.id === currentId); return currentLayer && - deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + deletedAssets.some((asset) => + isLayerLinkedToAsset(currentLayer, asset), + ) ? null : currentId; }); - deletedLayerIds.forEach((layerId) => - onDeleteLayerSideEffects?.(layerId), - ); + deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId)); }, [ discardHistoryEntriesContainingLayer, @@ -151,21 +152,31 @@ export function useImageCanvasAssetCanvasBridge({ appendCanvasLayersWithResources, selectSingleLayer, addUploadedFiles, + onAssetError, }: UseImageCanvasAssetCanvasBridgeOptions) { const addAssetLayer = useCallback( (asset: EditorAsset, position?: CanvasPoint) => { setActiveUploadFolderId(asset.folderId); - layerCounterRef.current += 1; - const nextLayer = createLayerFromAsset( - asset, - layerCounterRef.current, - viewport, - { - x: position?.x ?? canvasSize.width / 2, - y: position?.y ?? canvasSize.height / 2, - }, - { applyCascadeOffset: position === undefined }, - ); + const nextLayerIndex = layerCounterRef.current + 1; + let nextLayer: CanvasLayer; + try { + nextLayer = createLayerFromAsset( + asset, + nextLayerIndex, + viewport, + { + x: position?.x ?? canvasSize.width / 2, + y: position?.y ?? canvasSize.height / 2, + }, + { applyCascadeOffset: position === undefined }, + ); + } catch (error) { + onAssetError?.( + error instanceof Error ? error.message : '角色动作素材不完整。', + ); + return; + } + layerCounterRef.current = nextLayerIndex; captureCanvasHistory({ type: 'add-image', count: 1 }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); @@ -177,6 +188,7 @@ export function useImageCanvasAssetCanvasBridge({ canvasSize.width, captureCanvasHistory, layerCounterRef, + onAssetError, selectSingleLayer, setActiveUploadFolderId, setHoveredLayerId, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx index 3fa2ee919..a947a250c 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.test.tsx @@ -196,19 +196,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, previewVideoPath: '/generated-character-drafts/editor/preview.mp4', sourceType: 'generated', @@ -482,19 +480,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }), ]} />, @@ -590,19 +586,17 @@ describe('useImageCanvasAssetExportWorkflow', () => { assetKind: 'character-animation', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'data:image/png;base64,c2VxMQ==', width: 1024, height: 1024, }, { - frameIndex: 2, imageSrc: 'data:image/png;base64,c2VxMg==', width: 1024, height: 1024, }, ], - durationSeconds: 4, + imageSequenceDurationMs: 4_000, }), ]} />, diff --git a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts index c011b1c54..3441e37d2 100644 --- a/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts +++ b/src/components/image-editor/useImageCanvasAssetExportWorkflow.ts @@ -152,6 +152,9 @@ async function buildImageSequenceZip( if (!frames.length) { throw new Error('序列帧为空'); } + const durationSeconds = layer.imageSequenceDurationMs + ? layer.imageSequenceDurationMs / 1_000 + : undefined; const zip = new JSZip(); const framesFolder = zip.folder('frames') ?? zip; const failedFrames: Array<{ @@ -169,7 +172,6 @@ async function buildImageSequenceZip( const fileName = getImageSequenceFrameFileName(frame, index, blob.type); framesFolder.file(fileName, await blobToUint8Array(blob)); exportedFrames.push({ - frameIndex: frame.frameIndex || index + 1, fileName, width: frame.width, height: frame.height, @@ -177,7 +179,7 @@ async function buildImageSequenceZip( successCount += 1; } catch (error) { failedFrames.push({ - frameIndex: frame.frameIndex || index + 1, + frameIndex: index + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); } @@ -191,7 +193,7 @@ async function buildImageSequenceZip( `素材:${layer.title}`, `类型:${mode === 'spine-json' ? 'Spine JSON' : '序列帧'}`, `帧数:${successCount}/${frames.length}`, - `时长:${layer.durationSeconds ? `${layer.durationSeconds}s` : '0s'}`, + `时长:${durationSeconds ? `${durationSeconds}s` : '0s'}`, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ].filter(Boolean); @@ -203,7 +205,7 @@ async function buildImageSequenceZip( width: layer.width, height: layer.height, }, - layer.durationSeconds, + durationSeconds, ); zip.file('preview.gif', await blobToUint8Array(previewBlob)); } @@ -256,7 +258,7 @@ async function buildImageSequenceZip( `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', - layer.durationSeconds ? `时长:${layer.durationSeconds}s` : null, + durationSeconds ? `时长:${durationSeconds}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] .filter(Boolean) @@ -300,9 +302,14 @@ export function useImageCanvasAssetExportWorkflow({ } }) .catch(() => { + const hasUsableFrameSource = + (layer.imageSequenceFrames?.length ?? 0) > 0 && + (layer.imageSequenceDurationMs ?? 0) > 0; setAssetExportStatus({ tone: 'error', - message: '序列帧导出失败', + message: hasUsableFrameSource + ? '序列帧导出失败' + : `角色动作素材“${layer.title}”不完整:缺少可用序列帧。`, }); }); return; @@ -411,7 +418,6 @@ export function useImageCanvasAssetExportWorkflow({ ); framesFolder.file(fileName, await blobToUint8Array(frameBlob)); exportedFrames.push({ - frameIndex: frame.frameIndex || frameArrayIndex + 1, fileName, width: frame.width, height: frame.height, @@ -419,7 +425,7 @@ export function useImageCanvasAssetExportWorkflow({ successCount += 1; } catch (error) { failedFrames.push({ - frameIndex: frame.frameIndex || frameArrayIndex + 1, + frameIndex: frameArrayIndex + 1, error: error instanceof Error ? error.message : '序列帧读取失败', }); @@ -463,8 +469,8 @@ export function useImageCanvasAssetExportWorkflow({ `类型:序列帧`, `帧数:${successCount}/${frames.length}`, 'Spine JSON:skeleton.json', - layer.durationSeconds - ? `时长:${layer.durationSeconds}s` + layer.imageSequenceDurationMs + ? `时长:${layer.imageSequenceDurationMs / 1_000}s` : null, failedFrames.length ? `失败帧数:${failedFrames.length}` : null, ] @@ -595,10 +601,14 @@ export function useImageCanvasAssetExportWorkflow({ } }, [isExportingAssets, layers, projectTitle]); + const reportAssetError = useCallback((message: string) => { + setAssetExportStatus({ tone: 'error', message }); + }, []); return { assetExportStatus, isExportingAssets, exportCanvasAssets, exportLayerImage, + reportAssetError, }; } diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx index b820c6efc..5a0e52ee8 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -62,6 +62,7 @@ function createAssetSnapshot( height: 240, folderId: 'project', sourceType: 'uploaded', + assetKind: 'image', ...overrides, }; } diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 038ecf126..9ce92fd4f 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -322,7 +322,7 @@ function SubmissionWorkflowHarness({ {layers .map( (layer) => - `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`, + `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.imageSequenceDurationMs ?? layer.durationSeconds ?? '-'}`, ) .join('|')} @@ -1365,7 +1365,6 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { thumbnailSrc: 'generated-character-drafts/editor/actions/frame.png', imageSequenceFrames: [ { - frameIndex: 1, imageSrc: 'generated-character-drafts/editor/actions/frame.png', width: 1024, height: 1024, @@ -2985,15 +2984,42 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { - frameIndex: 1, - imageSrc: 'data:image/png;base64,action-frame', + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/action-frame-2.png', width: 1024, height: 1024, }, ], - frameCount: 32, + frameCount: 2, durationSeconds: 4, fps: 8, + resource: { + resourceId: 'resource-action-final', + projectId: 'editor-project-1', + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + sourceType: 'generated', + sourceResourceId: 'resource-action-preview', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-character-drafts/editor/action-frame-1.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-character-drafts/editor/action-frame-2.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 4_000, + }, }); render( { }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( - 'layer-character-animation-1:勇者挥手:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', + 'layer-character-animation-1:勇者挥手:resource-action-preview:character-animation:image-sequence:/generated-character-drafts/editor/action-frame-1.png:/generated-character-drafts/editor/action-frame-1.png:4000', ); }); expect(screen.getByTestId('dialog').textContent).toBe( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index ff4df781d..adf9b5253 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -2252,6 +2252,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({ return; } if (canvasDialog) { + if (projectId && !result.resource) { + throw new Error('角色动作画布完成缺少正式项目资源。'); + } + if (!projectId && !result.asset) { + throw new Error('角色动作生成缺少正式账号素材。'); + } addCharacterAnimationResultLayer( result, submissionPlan.resultTitle, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index e40b1e752..3865beeec 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -174,7 +174,8 @@ function createProjectResourceSnapshotFromLayer( model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index f19245d95..45ac6326e 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -332,7 +332,8 @@ function createEditorProjectSessionCacheResource({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -946,7 +947,8 @@ export function useImageCanvasProjectPersistence({ model: layer.model, provider: layer.provider, taskId: layer.taskId, - durationSeconds: layer.durationSeconds, + imageSequenceFrames: layer.imageSequenceFrames, + imageSequenceDurationMs: layer.imageSequenceDurationMs, sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, @@ -1095,7 +1097,8 @@ export function useImageCanvasProjectPersistence({ model: resource.model, provider: resource.provider, taskId: resource.taskId, - durationSeconds: resource.durationSeconds, + imageSequenceFrames: resource.imageSequenceFrames, + imageSequenceDurationMs: resource.imageSequenceDurationMs, sourceResourceId: resource.sourceResourceId, assetKind: resource.assetKind, generationInputs: resource.generationInputs, diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index 30e2b669c..e9fef5455 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -409,7 +409,6 @@ describe('useImageCanvasUploadWorkflow', () => { sourceType: input.sourceType, objectKey: input.objectKey, assetObjectId: input.assetObjectId, - durationSeconds: input.durationSeconds, }), ); uploadEditorSeedanceReferenceFileMock.mockImplementation( @@ -643,7 +642,6 @@ describe('useImageCanvasUploadWorkflow', () => { label: '画布视频.mp4', width: 1280, height: 720, - durationSeconds: 4, }), ); }); @@ -849,7 +847,6 @@ describe('useImageCanvasUploadWorkflow', () => { width: 560, height: 315, sourceType: 'uploaded', - durationSeconds: 4, }), ); expect(createEditorAssetMock).not.toHaveBeenCalled(); @@ -891,7 +888,6 @@ describe('useImageCanvasUploadWorkflow', () => { width: 420, height: 120, sourceType: 'uploaded', - durationSeconds: 4, }), ); expect(createEditorAssetMock).not.toHaveBeenCalled(); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index d73d0fecc..b028c99e9 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -63,6 +63,7 @@ import { resizeUploadCanvasLayerToImage, resolveUploadFolderId, } from './ImageCanvasUploadModel'; +import { resolveUploadedAssetKind } from './ImageCanvasUploadUtils'; type UploadFilesOptions = { folderId?: string; @@ -205,6 +206,7 @@ export function useImageCanvasUploadWorkflow({ : null; const width = imageDimensions?.width ?? fallbackSize.width; const height = imageDimensions?.height ?? fallbackSize.height; + const assetKind = resolveUploadedAssetKind(resolvedMediaType); if (projectId) { const resource = await createEditorProjectResource(projectId, { imageSrc: persistedImageSrc, @@ -215,7 +217,7 @@ export function useImageCanvasUploadWorkflow({ width, height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind, }); return createUploadedGenerationReference({ idPrefix, @@ -248,7 +250,7 @@ export function useImageCanvasUploadWorkflow({ width, height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind, }); return createUploadedGenerationReference({ idPrefix, @@ -1044,7 +1046,7 @@ export function useImageCanvasUploadWorkflow({ width: uploadedAsset.width, height: uploadedAsset.height, sourceType: 'uploaded', - ...(durationSeconds ? { durationSeconds } : {}), + assetKind: nextLayer.assetKind, }) .then((asset) => { setAssets((currentAssets) => diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index ce9133c08..c37c1e21a 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -236,6 +236,22 @@ describe('editorProjectClient', () => { width: 512, height: 512, sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-editor-assets/public-frame-1.png', + objectKey: 'generated-editor-assets/public-frame-1.png', + width: 512, + height: 512, + }, + { + imageSrc: '/generated-editor-assets/public-frame-2.png', + objectKey: 'generated-editor-assets/public-frame-2.png', + width: 512, + height: 512, + }, + ], + imageSequenceDurationMs: 6_000, publicShowcaseEnabled: true, }, ], @@ -259,6 +275,8 @@ describe('editorProjectClient', () => { expect(page.resources[0]?.ownerUserId).toBe('user-public-author'); expect(page.resources[0]?.authorDisplayName).toBe('公开作者'); expect(page.resources[0]?.authorPublicUserCode).toBe('SY-00000042'); + expect(page.resources[0]?.imageSequenceFrames).toHaveLength(2); + expect(page.resources[0]?.imageSequenceDurationMs).toBe(6_000); expect(page.nextCursor).toBe('cursor-next'); expect(page.campaign?.title).toBe('活动精选'); expect(page.campaign?.imageWidth).toBe(900); @@ -282,7 +300,9 @@ describe('editorProjectClient', () => { nextCursor: null, }); - const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' }); + const page = await listPublicEditorProjectResources({ + cursor: ' cursor-1 ', + }); expect(page.resources).toEqual([]); expect(page.nextCursor).toBeNull(); @@ -428,6 +448,95 @@ describe('editorProjectClient', () => { ); }); + it('passes image sequence duration milliseconds without rounding', async () => { + requestJsonMock + .mockResolvedValueOnce({ + resource: { + resourceId: 'resource-action', + projectId: 'editor-project-1', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceDurationMs: 2_600, + }, + }) + .mockResolvedValueOnce({ + asset: { + assetId: 'asset-action', + folderId: 'project', + label: '角色动作', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceDurationMs: 2_400, + }, + }); + + await createEditorProjectResource('editor-project-1', { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-actions/frame-02.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 2_600, + }); + await createEditorAsset({ + folderId: 'project', + label: '角色动作', + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + sourceType: 'generated', + assetKind: 'character-animation', + imageSequenceFrames: [ + { + imageSrc: '/generated-actions/frame-01.png', + width: 1024, + height: 1024, + }, + { + imageSrc: '/generated-actions/frame-02.png', + width: 1024, + height: 1024, + }, + ], + imageSequenceDurationMs: 2_400, + }); + + expect(requestJsonMock).toHaveBeenNthCalledWith( + 1, + '/api/editor/projects/editor-project-1/resources', + expect.objectContaining({ + body: expect.stringContaining('"imageSequenceDurationMs":2600'), + }), + '创建图片画布资源失败', + ); + expect(requestJsonMock).toHaveBeenNthCalledWith( + 2, + '/api/editor/assets', + expect.objectContaining({ + body: expect.stringContaining('"imageSequenceDurationMs":2400'), + }), + '创建图片画布素材失败', + ); + }); + it('updates project resource showcase visibility', async () => { requestJsonMock.mockResolvedValueOnce({ resource: { @@ -749,8 +858,7 @@ describe('editorProjectClient', () => { taskId: 'vector-task-1', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); @@ -761,8 +869,7 @@ describe('editorProjectClient', () => { expect(result.taskId).toBe('vector-task-1'); expect(result.warning).toEqual({ code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', @@ -812,7 +919,9 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`], + referenceVideoSrcs: [ + `https://assets.example.test/${'a'.repeat(256 * 1024)}`, + ], }), ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); @@ -1295,7 +1404,6 @@ describe('editorProjectClient', () => { previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { - frameIndex: 1, imageSrc: '/generated-character-drafts/editor/frame01.png', width: 1024, height: 1024, diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 76294f98a..8d869104b 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -83,6 +83,7 @@ export type EditorAssetGenerationInputReference = { export type EditorAssetGenerationInputs = { fields: EditorAssetGenerationInputField[]; references: EditorAssetGenerationInputReference[]; + [key: string]: unknown; }; export type EditorProjectResourceSourceType = @@ -110,7 +111,8 @@ export type EditorProjectResourceSnapshot = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; showcaseCategory?: string | null; @@ -151,7 +153,8 @@ export type EditorAssetSnapshot = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; showcaseCategory?: string | null; @@ -392,8 +395,7 @@ export type EditorCharacterAnimationGenerationInput = { assetLabel?: string | null; }; -export type EditorCharacterAnimationFrameResult = { - frameIndex: number; +export type EditorImageSequenceFrameResult = { imageSrc: string; objectKey?: string | null; assetObjectId?: string | null; @@ -406,12 +408,14 @@ export type EditorCharacterAnimationGenerationResult = { model: 'seedance2.0-fast'; prompt: string; previewVideoPath: string; - frames: EditorCharacterAnimationFrameResult[]; + frames: EditorImageSequenceFrameResult[]; frameCount: number; durationSeconds: number; fps: number; priceMudPoints: number; project?: EditorProjectSnapshot | null; + resource?: EditorProjectResourceSnapshot | null; + asset?: EditorAssetSnapshot | null; queueState?: ExternalGenerationJobStatusRecord | null; }; @@ -581,7 +585,8 @@ export type EditorProjectResourceCreateInput = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; sourceResourceId?: string | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; @@ -601,7 +606,8 @@ export type EditorAssetCreateInput = { model?: string | null; provider?: string | null; taskId?: string | null; - durationSeconds?: number | null; + imageSequenceFrames?: EditorImageSequenceFrameResult[] | null; + imageSequenceDurationMs?: number | null; assetKind?: string | null; generationInputs?: EditorAssetGenerationInputs | null; };