补齐画板视频封面持久化
生成画板视频时截取首帧并上传为封面 将视频生成结果写入素材库并保留 thumbnailSrc 同步后端契约、SpacetimeDB 绑定、前端素材库和画布层映射 补充相关测试与文档说明
This commit is contained in:
@@ -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 } : {}),
|
||||
}),
|
||||
'生成视频失败',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user