补齐画板视频封面持久化

生成画板视频时截取首帧并上传为封面

将视频生成结果写入素材库并保留 thumbnailSrc

同步后端契约、SpacetimeDB 绑定、前端素材库和画布层映射

补充相关测试与文档说明
This commit is contained in:
2026-06-28 00:22:59 +08:00
parent 5976c2b337
commit 63790e22ef
24 changed files with 348 additions and 65 deletions
@@ -102,6 +102,8 @@ describe('ImageCanvasEditorModel', () => {
folderId: 'project',
label: '开场动画.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',
@@ -112,7 +114,11 @@ describe('ImageCanvasEditorModel', () => {
});
expect(library.assets[0]).toMatchObject({ mediaType: 'audio' });
expect(library.assets[1]).toMatchObject({ mediaType: 'video' });
expect(library.assets[1]).toMatchObject({
mediaType: 'video',
thumbnailSrc:
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
});
expect(
createLayerFromAsset(
library.assets[0]!,
@@ -139,6 +145,7 @@ describe('ImageCanvasEditorModel', () => {
persisted: true,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
sourceResourceId: 'resource-source-1',
};
@@ -158,6 +165,7 @@ describe('ImageCanvasEditorModel', () => {
originalHeight: 480,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
thumbnailSrc: '/generated-character-drafts/editor/asset-1-cover.png',
sourceAssetId: 'asset-1',
sourceResourceId: 'resource-source-1',
});
@@ -172,6 +172,7 @@ export function createLayerFromAsset(
originalHeight: asset.height,
zIndex: index + 10,
sourceType: asset.sourceType,
thumbnailSrc: asset.thumbnailSrc,
prompt: asset.prompt,
actualPrompt: asset.actualPrompt,
model: asset.model,
@@ -710,6 +711,7 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
thumbnailSrc: asset.thumbnailSrc ?? undefined,
sourceResourceId: asset.sourceResourceId ?? null,
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
assetKind: canvasAssetKindOrNull(asset.assetKind),
@@ -647,6 +647,7 @@ export function ImageCanvasEditorView() {
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
thumbnailSrc: asset.thumbnailSrc ?? undefined,
durationSeconds: asset.durationSeconds ?? undefined,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
@@ -519,6 +519,21 @@ describe('ImageCanvasGenerationLayerModel', () => {
provider: 'VectorEngine',
taskId: 'video-task-1',
priceMudPoints: 40,
durationSeconds: 5,
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
asset: {
assetId: 'asset-video-bff',
folderId: 'user-1:asset-folder:project',
label: '生成视频 15',
imageSrc: '/generated-character-drafts/editor-videos/video.mp4',
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
width: 1280,
height: 720,
sourceType: 'generated',
assetKind: 'video',
},
},
generatedIndex: 15,
title: '生成视频 15',
@@ -534,7 +549,10 @@ describe('ImageCanvasGenerationLayerModel', () => {
assetKind: 'video',
objectKey: 'generated-character-drafts/editor-videos/video.mp4',
assetObjectId: 'assetobj-video-1',
thumbnailSrc: '/generated-character-drafts/editor-videos/cover.png',
durationSeconds: 5,
});
expect(layer.generatedAssetSnapshot?.assetId).toBe('asset-video-bff');
});
it('creates character animation result layers as playable image sequences', () => {
@@ -438,9 +438,12 @@ export function createVideoResultLayer({
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
thumbnailSrc: generated.thumbnailSrc ?? generated.asset?.thumbnailSrc ?? undefined,
sourceResourceId: sourceLayer?.resourceId,
groupId: sourceLayer?.groupId,
generationInputs,
durationSeconds: generated.durationSeconds,
generatedAssetSnapshot: generated.asset ?? undefined,
};
}
@@ -161,6 +161,7 @@ function SubmissionWorkflowHarness({
initialCharacterAnimationPanel = null,
initialLayers = [createLayer()],
projectId = undefined,
upsertGeneratedAsset = undefined,
applyProjectSnapshot = undefined,
onWalletBalanceMayHaveChanged = undefined,
}: {
@@ -170,6 +171,9 @@ function SubmissionWorkflowHarness({
initialCharacterAnimationPanel?: CharacterAnimationPanelState | null;
initialLayers?: CanvasLayer[];
projectId?: string | null;
upsertGeneratedAsset?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['upsertGeneratedAsset'];
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationSubmissionWorkflow
>[0]['applyProjectSnapshot'];
@@ -245,6 +249,7 @@ function SubmissionWorkflowHarness({
},
projectId,
assetFolderId: projectId ? 'project' : undefined,
upsertGeneratedAsset,
applyProjectSnapshot,
onWalletBalanceMayHaveChanged,
});
@@ -758,11 +763,39 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
});
it('submits video quick edits through the video API with the source video', async () => {
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
generateEditorVideoMock.mockResolvedValueOnce(
createVideoGenerated({ prompt: '把当前图改成雨天' }),
createVideoGenerated({
prompt: '把当前图改成雨天',
project: {
projectId: 'editor-project-1',
title: '项目',
coverImageSrc: null,
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
createdAt: '2026-06-27T00:00:00.000Z',
updatedAt: '2026-06-27T00:00:00.000Z',
},
asset: {
assetId: 'asset-video-quick-edit',
folderId: 'user-1:asset-folder:project',
label: '源视频 快速编辑',
imageSrc: 'https://assets.example.test/generated-video.mp4',
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
width: 854,
height: 480,
sourceType: 'generated',
assetKind: 'video',
},
}),
);
render(
<SubmissionWorkflowHarness
projectId="editor-project-1"
applyProjectSnapshot={applyProjectSnapshot}
upsertGeneratedAsset={upsertGeneratedAsset}
initialLayers={[
createLayer({
title: '源视频',
@@ -811,14 +844,22 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
referenceVideoSrcs: [
'generated-character-drafts/editor/videos/source.mp4',
],
assetFolderId: 'project',
assetLabel: '源视频 快速编辑',
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-video-1:源视频 快速编辑:resource-source:video:video:https://assets.example.test/generated-video.mp4',
expect(applyProjectSnapshot).toHaveBeenCalledWith(
expect.objectContaining({ projectId: 'editor-project-1' }),
);
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
expect.objectContaining({
assetId: 'asset-video-quick-edit',
thumbnailSrc: 'https://assets.example.test/generated-video-cover.png',
}),
);
});
});
@@ -1136,6 +1136,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
generationInputs,
sourceResourceId: quickEditSourceLayer.resourceId,
assetKind: quickEditSourceLayer.assetKind,
assetFolderId,
assetLabel: `${quickEditSourceLayer.title} 快速编辑`,
...(projectId && quickEditCanvasCompletionPlaceholder
? {
canvasCompletion: {
@@ -1160,6 +1162,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addVideoResultLayer(
@@ -1324,6 +1329,7 @@ export function useImageCanvasGenerationSubmissionWorkflow({
openCanvasGenerationDialog,
projectId,
assetFolderId,
upsertGeneratedAsset,
quickEditPanel,
quickEditSourceLayer,
quickEditSelectionState,
@@ -1476,6 +1482,8 @@ export function useImageCanvasGenerationSubmissionWorkflow({
...submissionPlan.input,
projectId,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
...(projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
@@ -1500,6 +1508,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
}
if (generated.project && applyProjectSnapshot) {
applyProjectSnapshot(generated.project);
if (generated.asset) {
upsertGeneratedAsset?.(generated.asset);
}
return;
}
addVideoResultLayer(
@@ -1097,6 +1097,18 @@ describe('editorProjectClient', () => {
taskId: 'video-task-1',
durationSeconds: 5,
resolution: '480p',
thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png',
asset: {
assetId: 'asset-video-1',
folderId: 'user-1:asset-folder:project',
label: '生成视频 1',
imageSrc: '/generated-editor-videos/video-task-1/preview.mp4',
thumbnailSrc: '/generated-editor-videos/video-task-1/cover.png',
width: 1280,
height: 720,
sourceType: 'generated',
assetKind: 'video',
},
});
const result = await generateEditorVideo({
@@ -1107,9 +1119,18 @@ describe('editorProjectClient', () => {
resolution: '480p',
mode: 'std',
sound: 'off',
assetFolderId: 'project',
assetLabel: '生成视频 1',
});
expect(result.taskId).toBe('video-task-1');
expect(result.thumbnailSrc).toBe(
'/generated-editor-videos/video-task-1/cover.png',
);
expect(result.asset?.assetId).toBe('asset-video-1');
expect(result.asset?.thumbnailSrc).toBe(
'/generated-editor-videos/video-task-1/cover.png',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/videos/generations',
expect.objectContaining({
@@ -1124,6 +1145,8 @@ describe('editorProjectClient', () => {
mode: 'std',
sound: 'off',
webSearchEnabled: true,
assetFolderId: 'project',
assetLabel: '生成视频 1',
}),
}),
'生成视频失败',
@@ -97,6 +97,7 @@ export type EditorAssetSnapshot = {
folderId: string;
label: string;
imageSrc: string;
thumbnailSrc?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
@@ -329,10 +330,13 @@ export type EditorVideoGenerationInput = {
generationInputs?: EditorAssetGenerationInputs | null;
sourceResourceId?: string | null;
assetKind?: string | null;
assetFolderId?: string | null;
assetLabel?: string | null;
};
export type EditorVideoGenerationResult = {
videoSrc: string;
thumbnailSrc?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
@@ -343,8 +347,10 @@ export type EditorVideoGenerationResult = {
model: string;
provider: string;
taskId: string;
durationSeconds: number;
priceMudPoints?: number;
project?: EditorProjectSnapshot | null;
asset?: EditorAssetSnapshot | null;
queueState?: ExternalGenerationJobStatusRecord | null;
};
@@ -912,6 +918,8 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) {
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
}),
'生成视频失败',
{