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

240 lines
9.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { MAX_VIDEO_WAIT_MS } from '@/lib/choreography';
import { compileVideoTimeline, emitManifest, VideoTimelineCompileError } from '@/lib/video-export';
import {
NO_ASSETS,
NO_PROBE,
el,
playVideo,
quiz,
slide,
speech,
spotlight,
stubAssets,
stubProbe,
} from './helpers';
describe('compileVideoTimeline — end-to-end golden', () => {
const scenes = [
slide('s0', [speech('a', 'Welcome'), spotlight('sp', 'e1'), speech('b', 'Focus here')], {
title: 'Intro',
elements: [el('e1', { left: 100, top: 100, width: 200, height: 100 })],
}),
quiz('q0', [speech('qa', 'Answer this')], 1),
];
const deps = {
timing: stubProbe({ a: 2000, b: 3000, qa: 2500 }),
assets: stubAssets({
a: { id: 'aud-a', present: true, format: 'mp3' },
b: { id: 'aud-b', present: true, format: 'mp3' },
qa: { id: 'aud-qa', present: true, format: 'mp3' },
}),
};
const ir = compileVideoTimeline({ stage: { id: 'stg', name: 'Demo' }, scenes }, deps);
it('stamps the envelope + config', () => {
expect(ir).toMatchObject({
schema: 'openmaic.videoTimeline',
version: 4,
compiler: 'openmaic-video-timeline',
stage: { id: 'stg', name: 'Demo' },
config: { playbackSpeed: 1, ttsEnabled: true, whiteboardInitiallyOpen: false },
totalDurationMs: 7500,
});
expect(ir.canvas.aspectRatio).toBe('16:9');
});
it('buckets the slide scene: base frame, narration, resolved-geometry effect', () => {
const s0 = ir.scenes[0];
expect(s0).toMatchObject({ index: 0, supported: true, startMs: 0, durationMs: 5000 });
expect(s0.base).toMatchObject({ kind: 'slide-snapshot', assetRef: 'frames/001-intro.png' });
expect(s0.narration).toHaveLength(2);
expect(s0.effects).toHaveLength(1);
expect(s0.effects[0]).toMatchObject({
descriptorId: 'spotlight.v1',
startMs: 2000,
durationMs: 3000,
degraded: false,
});
expect(s0.effects[0].geometry).not.toBeNull();
});
it('represents the quiz scene as a deterministic cover visual, never dropped', () => {
const q0 = ir.scenes[1];
expect(q0).toMatchObject({ index: 1, type: 'quiz', supported: true, startMs: 5000 });
expect(q0.base.kind).toBe('visual-segments');
expect(q0.visuals).toEqual([
{
kind: 'quiz-cover',
startMs: 5000,
durationMs: 2500,
title: 'q0',
questionCount: 0,
totalPoints: 0,
},
]);
expect(q0.markers).not.toContainEqual(expect.objectContaining({ kind: 'unsupported-scene' }));
// quiz narration is still on the timeline
expect(q0.narration.map((n) => n.text)).toEqual(['Answer this']);
expect(ir.diagnostics).toContainEqual(
expect.objectContaining({ severity: 'info', code: 'cover-card', sceneId: 'q0' }),
);
expect(ir.diagnostics).not.toContainEqual(
expect.objectContaining({ code: 'unsupported-scene', sceneId: 'q0' }),
);
});
it('derives the subtitle track across scenes in order', () => {
expect(ir.subtitles.map((c) => [c.text, c.startMs, c.endMs])).toEqual([
['Welcome', 0, 2000],
['Focus here', 2000, 5000],
['Answer this', 5000, 7500],
]);
});
it('plans deduped assets (a frame + one entry per distinct audio clip)', () => {
expect(ir.assets.entries.map((e) => e.path)).toEqual([
'frames/001-intro.png',
'audio/001-intro/speech-001.mp3',
'audio/001-intro/speech-002.mp3',
'audio/002-q0/speech-001.mp3',
]);
});
it('emits a manifest that satisfies the schema', () => {
expect(() => emitManifest(ir)).not.toThrow();
});
});
describe('compileVideoTimeline — degradation & edges', () => {
it('degrades a spotlight whose element is missing, with a diagnostic (no throw)', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stg', name: 'x' },
scenes: [slide('s', [speech('a', 'hi'), spotlight('sp', 'ghost')])],
},
{ timing: stubProbe({ a: 1000 }), assets: NO_ASSETS },
);
expect(ir.scenes[0].effects[0].degraded).toBe(true);
expect(ir.scenes[0].effects[0].geometry).toBeNull();
expect(ir.diagnostics).toContainEqual(expect.objectContaining({ code: 'unresolved-element' }));
});
it('records estimated-duration when narration has no stored audio', () => {
const ir = compileVideoTimeline(
{ stage: { id: 'stg', name: 'x' }, scenes: [slide('s', [speech('a', 'hello world')])] },
{ timing: NO_PROBE, assets: NO_ASSETS },
);
expect(ir.config.ttsEnabled).toBe(false);
expect(ir.diagnostics.map((d) => d.code)).toEqual(
expect.arrayContaining(['estimated-duration', 'missing-audio']),
);
});
it('is deterministic — same input yields identical IR', () => {
const input = { stage: { id: 'stg', name: 'x' }, scenes: [slide('s', [speech('a', 'hi')])] };
const deps = { timing: stubProbe({ a: 1000 }), assets: NO_ASSETS };
expect(compileVideoTimeline(input, deps)).toEqual(compileVideoTimeline(input, deps));
});
it('throws on no scenes', () => {
expect(() =>
compileVideoTimeline(
{ stage: { id: 'stg', name: 'x' }, scenes: [] },
{ timing: NO_PROBE, assets: NO_ASSETS },
),
).toThrow(VideoTimelineCompileError);
});
});
describe('compileVideoTimeline — play_video placement & availability', () => {
const scene = () =>
slide('s', [speech('a', 'x'), playVideo('v', 'clip'), speech('b', 'y')], {
elements: [el('clip', { left: 100, top: 100, width: 400, height: 300, rotate: 15 })],
});
it('carries the target element placement (geometry + rotate) into the video segment', () => {
const ir = compileVideoTimeline(
{ stage: { id: 'stg', name: 'x' }, scenes: [scene()] },
{
timing: stubProbe({ a: 1000, b: 1000 }, { v: 4000 }),
assets: stubAssets({}, { clip: { id: 'm', present: true, format: 'mp4' } }),
},
);
const video = ir.scenes[0].videos[0];
expect(video.rotate).toBe(15);
expect(video.geometry).toMatchObject({ x: 10, w: 40 });
expect(video.degraded).toBe(false);
expect(video.assetRef).toBe('media/clip.mp4');
expect(video.durationSource).toBe('stored');
});
it('skips an unavailable video (0ms) so later actions are NOT shifted by the safety cap', () => {
const deps = {
timing: stubProbe({ a: 1000, b: 1000 }),
assets: stubAssets({}, { clip: { id: 'm', present: false, format: 'mp4' } }),
};
const ir = compileVideoTimeline({ stage: { id: 'stg', name: 'x' }, scenes: [scene()] }, deps);
const video = ir.scenes[0].videos[0];
expect(video.durationMs).toBe(0);
expect(video.durationSource).toBe('skipped');
expect(video.present).toBe(false);
// The trailing speech starts right after the first (1000), not 1000 + cap.
expect(ir.scenes[0].narration[1].startMs).toBe(1000);
expect(ir.totalDurationMs).toBe(2000);
expect(ir.totalDurationMs).toBeLessThan(MAX_VIDEO_WAIT_MS);
});
it('does not conflate availability across scenes that share a play_video action id', () => {
// The DSL does not enforce stage-wide action-id uniqueness. Two scenes both
// use id `dup`: the first video's media is absent (and its duration unknown),
// the second's is present. An id-keyed availability set would let the second
// mark `dup` available and leave the first with a 300000ms cap dwell while
// assets stamp it `skipped` — a contradictory IR that shifts scene 2 by 5min.
const ir = compileVideoTimeline(
{
stage: { id: 'stg', name: 'x' },
scenes: [
slide('s0', [playVideo('dup', 'missing')], { elements: [] }),
slide('s1', [playVideo('dup', 'good')], { order: 1, elements: [] }),
],
},
{
timing: stubProbe({}, { dup: 4000 }),
assets: stubAssets({}, { good: { id: 'm', present: true, format: 'mp4' } }),
},
);
const first = ir.scenes[0].videos[0];
// The absent-media clip is skipped (0ms), not held for the safety cap — even
// though a stored 4000ms duration exists for the shared id `dup`.
expect(first.durationMs).toBe(0);
expect(first.durationSource).toBe('skipped');
expect(first.present).toBe(false);
// Scene 2 (whose video IS available) starts at 0, not 300000, and plays 4000ms.
expect(ir.scenes[1].startMs).toBe(0);
expect(ir.scenes[1].videos[0].durationMs).toBe(4000);
expect(ir.totalDurationMs).toBe(4000);
});
it('degrades placement (geometry null, rotate 0) when the target element is missing', () => {
const ir = compileVideoTimeline(
{
stage: { id: 'stg', name: 'x' },
scenes: [slide('s', [playVideo('v', 'ghost')], { elements: [] })],
},
{
timing: stubProbe({}, { v: 4000 }),
assets: stubAssets({}, { ghost: { id: 'm', present: true, format: 'mp4' } }),
},
);
const video = ir.scenes[0].videos[0];
expect(video.geometry).toBeNull();
expect(video.rotate).toBe(0);
expect(video.degraded).toBe(true);
expect(ir.diagnostics).toContainEqual(
expect.objectContaining({ code: 'unresolved-element', actionId: 'v' }),
);
});
});