240 lines
9.0 KiB
TypeScript
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' }),
|
|
);
|
|
});
|
|
});
|