Files
openmaic/OpenMAIC/tests/video-export/timeline-deps.test.ts
2026-08-16 14:58:47 +08:00

218 lines
8.0 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
/**
* Guards the elementId → mediaRef bridge in the app-side compiler deps.
*
* A `play_video` action targets a slide element by its `.id` (e.g. `video_abc`),
* but generated-media records are keyed by the element's media ref (`gen_vid_…`).
* Without the bridge, `assets.media()` / `timing.videoDurationMs()` miss for every
* generated video, which cascades to a dropped clip in the export (regression the
* user reported as "视频元素丢失"). These tests mock Dexie (no Dexie-in-node
* harness, per the repo pattern) and run the real factory.
*/
const mediaToArray = vi.fn();
const audioGet = vi.fn((..._args: unknown[]) => Promise.resolve(undefined));
vi.mock('@/lib/utils/database', () => ({
db: {
audioFiles: { get: (...args: unknown[]) => audioGet(...args) },
mediaFiles: {
where: () => ({ equals: () => ({ toArray: () => mediaToArray() }) }),
},
},
}));
// Stub the off-screen geometry measurement (needs a real browser/layout). It
// records the calls so tests can assert which elements were measured, and
// returns a canned content-box geometry.
const measureCalls: Array<{ elementIds: string[] }> = [];
vi.mock('@openmaic/renderer/snapshot', () => ({
measureSlideElementGeometry: vi.fn(async (_slide: unknown, elementIds: string[]) => {
measureCalls.push({ elementIds: [...elementIds] });
return new Map(
elementIds.map((id) => [id, { x: 11, y: 19, w: 18, h: 25, centerX: 20, centerY: 31.5 }]),
);
}),
}));
import { createVideoTimelineDeps } from '@/lib/video-export-app/timeline-deps';
import { resolveVideoMediaForElement } from '@/lib/media/media-task-resolution';
import { useMediaGenerationStore } from '@/lib/store/media-generation';
import type { MediaFileRecord } from '@/lib/utils/database';
import type { Scene } from '@/lib/types/stage';
const STAGE_ID = 'stage-1';
const MEDIA_REF = 'gen_vid_abc123';
const ELEMENT_ID = 'video_element_1';
/** A media record present via ossKey (no local bytes → no DOM probe in Node). */
function videoRecord(over: Partial<MediaFileRecord> = {}): MediaFileRecord {
return {
id: `${STAGE_ID}:${MEDIA_REF}`,
stageId: STAGE_ID,
type: 'video',
blob: new Blob([], { type: 'video/mp4' }),
mimeType: 'video/mp4',
ossKey: 'https://cdn.example/v.mp4',
size: 0,
prompt: '',
params: '',
createdAt: 0,
...over,
} as MediaFileRecord;
}
/** A media record for an arbitrary ref (id is `stageId:ref`). */
function videoRecordFor(ref: string, over: Partial<MediaFileRecord> = {}): MediaFileRecord {
return videoRecord({ id: `${STAGE_ID}:${ref}`, ...over });
}
/** A slide scene whose element points at the media ref via `mediaRef`. */
function slideScene(el: Record<string, unknown>, actions: unknown[] = []): Scene {
return {
id: 'scene-1',
stageId: STAGE_ID,
title: 'Scene',
order: 0,
type: 'slide',
content: { type: 'slide', canvas: { elements: [el] } },
actions,
} as unknown as Scene;
}
/** A slide scene with an explicit id, one element, and optional actions. */
function slideSceneWithId(
sceneId: string,
el: Record<string, unknown>,
actions: unknown[] = [],
): Scene {
return {
id: sceneId,
stageId: STAGE_ID,
title: sceneId,
order: 0,
type: 'slide',
content: { type: 'slide', canvas: { elements: [el] } },
actions,
} as unknown as Scene;
}
const spotlight = (elementId: string) => ({ type: 'spotlight', elementId });
beforeEach(() => {
mediaToArray.mockReset();
audioGet.mockReset().mockImplementation(() => Promise.resolve(undefined));
measureCalls.length = 0;
useMediaGenerationStore.setState({ tasks: {} });
});
describe('createVideoTimelineDeps — media ref bridge', () => {
it('resolves a play_video element id to its media record via mediaRef', async () => {
mediaToArray.mockResolvedValue([videoRecord()]);
const scene = slideScene({ id: ELEMENT_ID, type: 'video', mediaRef: MEDIA_REF });
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
const meta = deps.assets.media(ELEMENT_ID, scene);
expect(meta).not.toBeNull();
expect(meta?.id).toBe(`${STAGE_ID}:${MEDIA_REF}`);
expect(meta?.present).toBe(true);
expect(meta?.format).toBe('mp4');
});
it('bridges a legacy placeholder `src` (gen_vid_…) when no explicit mediaRef', async () => {
mediaToArray.mockResolvedValue([videoRecord()]);
const scene = slideScene({ id: ELEMENT_ID, type: 'video', src: MEDIA_REF });
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
expect(deps.assets.media(ELEMENT_ID, scene)?.id).toBe(`${STAGE_ID}:${MEDIA_REF}`);
});
it('returns null for an element with no generated media', async () => {
mediaToArray.mockResolvedValue([videoRecord()]);
const scene = slideScene({ id: 'plain_text', type: 'text' });
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
expect(deps.assets.media('plain_text', scene)).toBeNull();
});
it('plans a concrete src instead of a stale opaque mediaRef', async () => {
mediaToArray.mockResolvedValue([]);
const concreteSrc = 'https://cdn.example/direct.mp4';
const element = {
id: ELEMENT_ID,
type: 'video',
src: concreteSrc,
mediaRef: 'ast_stale_video',
};
const action = { type: 'play_video', elementId: ELEMENT_ID };
const scene = slideScene(element, [action]);
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
const rendererRef = resolveVideoMediaForElement({}, element as never, STAGE_ID, [
element as never,
]).sourceRef;
expect(rendererRef).toBe(concreteSrc);
expect(deps.assets.media(ELEMENT_ID, scene)).toMatchObject({
id: rendererRef,
present: true,
format: 'mp4',
});
});
it('scopes the bridge by scene: a shared element id resolves per-scene, not last-writer-wins', async () => {
// Two slides reuse the slide-local element id `video_001`, each pointing at a
// different generated video. A deck-wide bridge would collapse both to the
// last scene's ref; the scene-scoped bridge must keep them distinct.
const SHARED_ID = 'video_001';
const REF_A = 'gen_vid_aaa';
const REF_B = 'gen_vid_bbb';
mediaToArray.mockResolvedValue([videoRecordFor(REF_A), videoRecordFor(REF_B)]);
const play = (elementId: string) => ({ type: 'play_video', elementId });
const sceneA = slideSceneWithId('scene-a', { id: SHARED_ID, type: 'video', mediaRef: REF_A }, [
play(SHARED_ID),
]);
const sceneB = slideSceneWithId('scene-b', { id: SHARED_ID, type: 'video', mediaRef: REF_B }, [
play(SHARED_ID),
]);
const deps = await createVideoTimelineDeps({
stage: { id: STAGE_ID },
scenes: [sceneA, sceneB],
});
expect(deps.assets.media(SHARED_ID, sceneA)?.id).toBe(`${STAGE_ID}:${REF_A}`);
expect(deps.assets.media(SHARED_ID, sceneB)?.id).toBe(`${STAGE_ID}:${REF_B}`);
});
});
describe('createVideoTimelineDeps — geometry probe', () => {
it('pre-measures the content box of spotlight/laser/video targets and serves it', async () => {
mediaToArray.mockResolvedValue([]);
const scene = slideScene({ id: 'text_1', type: 'text' }, [spotlight('text_1')]);
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
expect(measureCalls).toHaveLength(1);
expect(measureCalls[0].elementIds).toContain('text_1');
expect(deps.geometry.contentGeometry('text_1', scene)).toEqual({
x: 11,
y: 19,
w: 18,
h: 25,
centerX: 20,
centerY: 31.5,
});
});
it('does not render a scene with no effect/video targets', async () => {
mediaToArray.mockResolvedValue([]);
const scene = slideScene({ id: 'text_1', type: 'text' }, []);
const deps = await createVideoTimelineDeps({ stage: { id: STAGE_ID }, scenes: [scene] });
expect(measureCalls).toHaveLength(0);
expect(deps.geometry.contentGeometry('text_1', scene)).toBeNull();
});
});