统一角色动作画布持久化与交互

前端只从正式 resource 或 asset 的图片序列字段恢复、复用、播放和导出角色动作。
动作 layout 只保存资源引用与 placement,不复制帧、时长、生成输入或 mediaType。
项目生成必须绑定正式资源,无项目放置必须绑定正式账号素材,缺失时失败关闭。
补齐素材库拖放、序列 ZIP、画布播放、保存恢复与生成工作流测试。
This commit is contained in:
2026-08-04 10:21:46 +08:00
parent 8a4ba6283b
commit 8c2f48d816
33 changed files with 1344 additions and 566 deletions
@@ -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<typeof fetch>) =>
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(<ImageCanvasEditorView />);
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(<ImageCanvasEditorView />);
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: [
@@ -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 =
@@ -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');
@@ -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' ||
@@ -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;
@@ -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(() => {
@@ -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;
@@ -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,
@@ -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;
@@ -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,
});
});
});
@@ -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,
};
}
@@ -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', () => {
@@ -901,8 +901,13 @@ function cloneModelPricing<T extends EditorGenerationPricingConfig['models']>(
}
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;
@@ -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,
@@ -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
);
}
@@ -112,7 +112,6 @@ describe('ImageCanvasProjectCoverSnapshotModel', () => {
mediaType: 'image-sequence',
imageSequenceFrames: [
{
frameIndex: 1,
imageSrc: '/generated/frame-1.png',
objectKey: 'generated/frame-1.png',
width: 320,
@@ -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) {
@@ -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,
@@ -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';
}
@@ -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();
});
});
@@ -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<HTMLImageElement | null>(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 (
<div key={layer.id}>
<LayerElement
{...(isMediaContainer
? { role: 'button', tabIndex: 0 }
: { type: 'button' })}
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : undefined,
}}
onPointerDown={(event) => 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' ? (
<ImageCanvasAudioLayerCard
layer={layer}
isHovered={isHovered}
onSelectLayer={() => onSelectLayer(layer.id)}
/>
) : layer.mediaType === 'video' ? (
<ImageCanvasVideoLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : layer.mediaType === 'image-sequence' ? (
<ImageCanvasImageSequenceLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : (
<ImageCanvasImageLayer
layer={layer}
mediaTransform={mediaTransform}
/>
)}
{kindLabel ? (
<span
ref={
openLayerKindMenuId === layer.id
? openLayerKindTriggerRef
: undefined
}
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${metadataMediaLabel}信息`}
icon={<Info className="h-3 w-3" />}
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 (
<div key={layer.id}>
<LayerElement
{...(isMediaContainer
? { role: 'button', tabIndex: 0 }
: { type: 'button' })}
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : undefined,
}}
onPointerDown={(event) => 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' ? (
<ImageCanvasAudioLayerCard
layer={layer}
isHovered={isHovered}
onSelectLayer={() => onSelectLayer(layer.id)}
/>
) : layer.mediaType === 'video' ? (
<ImageCanvasVideoLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : layer.mediaType === 'image-sequence' ? (
<ImageCanvasImageSequenceLayer
layer={layer}
mediaTransform={mediaTransform}
/>
) : (
<ImageCanvasImageLayer
layer={layer}
mediaTransform={mediaTransform}
/>
)}
{kindLabel ? (
<span
ref={
openLayerKindMenuId === layer.id
? openLayerKindTriggerRef
: undefined
}
role="button"
tabIndex={0}
aria-label={`素材标签:${kindLabel}`}
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${kindTagMeta.className} image-canvas-editor__kind-badge--beside-info image-canvas-editor__kind-badge--editable`}
style={inverseScaleStyle}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered && layer.mediaType !== 'audio' ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
style={inverseScaleStyle}
>
{getLayerMetricLabel(layer)}
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</LayerElement>
{openLayerKindMenuId === layer.id ? (
<div
ref={openLayerKindMenuRef}
className="image-canvas-editor__kind-menu-anchor"
style={{
left: layer.x + layer.width,
top: layer.y,
zIndex: layer.zIndex + 30,
...inverseScaleStyle,
setOpenLayerKindMenuId((currentId) =>
currentId === layer.id ? null : layer.id,
);
}}
onKeyDown={handleLayerTagKeyboardActivation}
>
<PlatformFloatingMenu
label="选择素材标签"
placement="bottom-end"
className="image-canvas-editor__kind-menu"
onWheelCapture={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => (
<PlatformFloatingMenuItem
key={option.value ?? 'unknown'}
className={
option.value === (layer.assetKind ?? null)
? 'image-canvas-editor__kind-menu-item--active'
: undefined
}
onClick={(event) => {
event.stopPropagation();
onUpdateLayerAssetKind(layer.id, option.value);
setOpenLayerKindMenuId(null);
}}
>
{option.label}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>
</div>
{kindLabel}
</span>
) : null}
</div>
);
})}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className="image-canvas-editor__metadata-corner"
label={`查看${layer.title}${metadataMediaLabel}信息`}
icon={<Info className="h-3 w-3" />}
style={inverseScaleStyle}
onClick={(event) => {
event.stopPropagation();
onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered && layer.mediaType !== 'audio' ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
style={inverseScaleStyle}
>
{getLayerMetricLabel(layer)}
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</LayerElement>
{openLayerKindMenuId === layer.id ? (
<div
ref={openLayerKindMenuRef}
className="image-canvas-editor__kind-menu-anchor"
style={{
left: layer.x + layer.width,
top: layer.y,
zIndex: layer.zIndex + 30,
...inverseScaleStyle,
}}
>
<PlatformFloatingMenu
label="选择素材标签"
placement="bottom-end"
className="image-canvas-editor__kind-menu"
onWheelCapture={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
{CANVAS_ASSET_KIND_TAG_OPTIONS.map((option) => (
<PlatformFloatingMenuItem
key={option.value ?? 'unknown'}
className={
option.value === (layer.assetKind ?? null)
? 'image-canvas-editor__kind-menu-item--active'
: undefined
}
onClick={(event) => {
event.stopPropagation();
onUpdateLayerAssetKind(layer.id, option.value);
setOpenLayerKindMenuId(null);
}}
>
{option.label}
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>
</div>
) : null}
</div>
);
})}
{canvasMarquee ? (
<div
className="image-canvas-editor__canvas-marquee"
@@ -65,6 +65,7 @@ type UseImageCanvasAssetCanvasBridgeOptions = {
files: FileList | File[],
options: UploadFilesToCanvasOptions,
) => 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,
@@ -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,
}),
]}
/>,
@@ -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 JSONskeleton.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 JSONskeleton.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,
};
}
@@ -62,6 +62,7 @@ function createAssetSnapshot(
height: 240,
folderId: 'project',
sourceType: 'uploaded',
assetKind: 'image',
...overrides,
};
}

Some files were not shown because too many files have changed in this diff Show More