统一角色动作画布持久化与交互
前端只从正式 resource 或 asset 的图片序列字段恢复、复用、播放和导出角色动作。 动作 layout 只保存资源引用与 placement,不复制帧、时长、生成输入或 mediaType。 项目生成必须绑定正式资源,无项目放置必须绑定正式账号素材,缺失时失败关闭。 补齐素材库拖放、序列 ZIP、画布播放、保存恢复与生成工作流测试。
This commit is contained in:
@@ -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 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user