172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { Action } from '@openmaic/dsl';
|
|
import {
|
|
compileVideoTimeline,
|
|
reflowQuizTimelines,
|
|
type CompilerScene,
|
|
type QuizLayoutProbe,
|
|
type SubtitleCue,
|
|
type VideoTimelineScene,
|
|
} from '@/lib/video-export';
|
|
import { NO_ASSETS, stubProbe } from './helpers';
|
|
|
|
function timedScene(id: string, index: number, startMs: number, durationMs: number) {
|
|
return {
|
|
id,
|
|
index,
|
|
title: id,
|
|
type: index === 0 ? 'quiz' : 'slide',
|
|
startMs,
|
|
durationMs,
|
|
supported: true,
|
|
base: { kind: 'visual-segments' },
|
|
visuals: [
|
|
{
|
|
kind: 'quiz-cover',
|
|
startMs,
|
|
durationMs,
|
|
title: id,
|
|
questionCount: 1,
|
|
totalPoints: 1,
|
|
},
|
|
],
|
|
narration: [
|
|
{
|
|
actionIndex: 0,
|
|
startMs: startMs + 10,
|
|
durationMs: 100,
|
|
text: id,
|
|
audio: { durationMs: 100, source: 'stored', present: false },
|
|
},
|
|
],
|
|
effects: [
|
|
{
|
|
actionIndex: 1,
|
|
type: 'spotlight',
|
|
descriptorId: 'spotlight.v1',
|
|
startMs: startMs + 20,
|
|
durationMs: 100,
|
|
elementId: 'element',
|
|
geometry: null,
|
|
params: {},
|
|
degraded: true,
|
|
},
|
|
],
|
|
videos: [
|
|
{
|
|
actionIndex: 2,
|
|
startMs: startMs + 30,
|
|
durationMs: 100,
|
|
elementId: 'element',
|
|
geometry: null,
|
|
rotate: 0,
|
|
present: false,
|
|
degraded: true,
|
|
durationSource: 'skipped',
|
|
},
|
|
],
|
|
markers: [{ actionIndex: 3, kind: 'beat', startMs: startMs + 40, durationMs: 100 }],
|
|
} as VideoTimelineScene;
|
|
}
|
|
|
|
describe('pure Quiz absolute-time reflow', () => {
|
|
it('extends the owner and shifts every absolute field and subtitle in later scenes', () => {
|
|
const scenes = [timedScene('quiz', 0, 0, 1000), timedScene('slide', 1, 1000, 2000)];
|
|
const subtitles: SubtitleCue[] = [
|
|
{ index: 0, sceneId: 'quiz', startMs: 10, endMs: 110, text: 'quiz' },
|
|
{ index: 1, sceneId: 'slide', startMs: 1010, endMs: 1110, text: 'slide' },
|
|
];
|
|
|
|
const result = reflowQuizTimelines(scenes, subtitles, 3000, [3000, 0]);
|
|
|
|
expect(result.totalDurationMs).toBe(6000);
|
|
expect(result.scenes[0]).toMatchObject({
|
|
startMs: 0,
|
|
durationMs: 4000,
|
|
narration: [{ startMs: 10 }],
|
|
effects: [{ startMs: 20 }],
|
|
videos: [{ startMs: 30 }],
|
|
markers: [{ startMs: 40 }],
|
|
});
|
|
expect(result.scenes[1]).toMatchObject({
|
|
startMs: 4000,
|
|
durationMs: 2000,
|
|
visuals: [{ startMs: 4000 }],
|
|
narration: [{ startMs: 4010 }],
|
|
effects: [{ startMs: 4020 }],
|
|
videos: [{ startMs: 4030 }],
|
|
markers: [{ startMs: 4040 }],
|
|
});
|
|
expect(result.subtitles).toEqual([
|
|
{ index: 0, sceneId: 'quiz', startMs: 10, endMs: 110, text: 'quiz' },
|
|
{ index: 1, sceneId: 'slide', startMs: 4010, endMs: 4110, text: 'slide' },
|
|
]);
|
|
});
|
|
|
|
it('accumulates extensions from consecutive Quiz scenes without changing relative action timing', () => {
|
|
const scenes = [
|
|
timedScene('quiz-1', 0, 0, 1000),
|
|
timedScene('quiz-2', 1, 1000, 1000),
|
|
timedScene('slide', 2, 2000, 1000),
|
|
];
|
|
const result = reflowQuizTimelines(scenes, [], 3000, [3000, 7000, 0]);
|
|
|
|
expect(result.scenes.map((scene) => [scene.startMs, scene.durationMs])).toEqual([
|
|
[0, 4000],
|
|
[4000, 8000],
|
|
[12_000, 1000],
|
|
]);
|
|
expect(result.scenes[2].narration[0].startMs - result.scenes[2].startMs).toBe(10);
|
|
expect(result.totalDurationMs).toBe(13_000);
|
|
});
|
|
});
|
|
|
|
function quiz(id: string, order: number): CompilerScene {
|
|
return {
|
|
id,
|
|
stageId: 'stage',
|
|
title: id,
|
|
order,
|
|
type: 'quiz',
|
|
content: { type: 'quiz', questions: [{ id: `${id}-q`, type: 'single', question: 'Q' }] },
|
|
actions: [{ id: `${id}-speech`, type: 'speech', text: id } as Action],
|
|
} as CompilerScene;
|
|
}
|
|
|
|
describe('Quiz scroll-duration bounds and cumulative compiler reflow', () => {
|
|
it('clamps one-pixel overflow to 4s and extreme overflow to 24s', () => {
|
|
const probe: QuizLayoutProbe = {
|
|
measureQuestionList: (scene) =>
|
|
scene.id === 'min'
|
|
? { contentHeightPx: 401, viewportHeightPx: 400, frameHeightPx: 720 }
|
|
: { contentHeightPx: 100_000, viewportHeightPx: 400, frameHeightPx: 720 },
|
|
};
|
|
const ir = compileVideoTimeline(
|
|
{ stage: { id: 'stage', name: 'Bounds' }, scenes: [quiz('min', 0), quiz('max', 1)] },
|
|
{
|
|
timing: stubProbe({ 'min-speech': 1000, 'max-speech': 1000 }),
|
|
assets: NO_ASSETS,
|
|
quizLayout: probe,
|
|
},
|
|
);
|
|
const lists = ir.scenes.map((scene) => scene.visuals[1]);
|
|
|
|
expect(lists[0]).toMatchObject({
|
|
scrollDistancePx: 1,
|
|
scrollDurationMs: 4000,
|
|
pixelsPerSecond: 0.25,
|
|
});
|
|
expect(lists[1]).toMatchObject({
|
|
scrollDistancePx: 99_600,
|
|
scrollDurationMs: 24_000,
|
|
pixelsPerSecond: 4150,
|
|
});
|
|
expect(ir.scenes.map((scene) => [scene.startMs, scene.durationMs])).toEqual([
|
|
[0, 8000],
|
|
[8000, 28_000],
|
|
]);
|
|
expect(ir.subtitles.map((cue) => cue.startMs)).toEqual([0, 8000]);
|
|
expect(ir.totalDurationMs).toBe(36_000);
|
|
});
|
|
});
|