222 lines
8.4 KiB
TypeScript
222 lines
8.4 KiB
TypeScript
/**
|
|
* `compileVideoTimeline` — the pure compile pipeline.
|
|
*
|
|
* Composes the ordered passes into the `VideoTimeline` IR:
|
|
*
|
|
* normalize → probe → timeline → visuals → interactive → reflow → geometry → assets → (unsupported) → assemble
|
|
*
|
|
* Every pass is a pure function; live app state enters only through the injected
|
|
* {@link TimingProbe} / {@link AssetSource} (issue #864 DI boundary), so the whole
|
|
* compile runs — and is unit-tested — with no FFmpeg / Chrome / DOM. The result
|
|
* is the system contract; the (future) Hyperframes emitter is a downstream
|
|
* consumer of it.
|
|
*
|
|
* Diagnostics from all passes are concatenated in pass order, so the manifest
|
|
* reads as a chronological export report. Quiz/PBL become deterministic cover
|
|
* visuals; remaining scene families the compiler cannot render are represented
|
|
* with an `unsupported-scene` marker + diagnostic and a placeholder base.
|
|
*
|
|
* Pure: no IO beyond the injected dependencies.
|
|
*/
|
|
import type { SceneType, PlayVideoAction, Action } from '@openmaic/dsl';
|
|
import type {
|
|
AssetSource,
|
|
CompileConfig,
|
|
CompilerScene,
|
|
GeometryProbe,
|
|
QuizLayoutProbe,
|
|
TimingProbe,
|
|
} from './deps';
|
|
import {
|
|
CANVAS,
|
|
VIDEO_TIMELINE_COMPILER,
|
|
VIDEO_TIMELINE_SCHEMA,
|
|
VIDEO_TIMELINE_VERSION,
|
|
type Diagnostic,
|
|
type VideoTimeline,
|
|
type VideoTimelineScene,
|
|
} from './ir';
|
|
import { normalizeScenes } from './passes/normalize';
|
|
import { buildTimelineOptions } from './passes/probe';
|
|
import { buildTimeline } from './passes/timeline';
|
|
import { applyVisuals } from './passes/visuals';
|
|
import { applyInteractiveHtml } from './passes/interactive';
|
|
import { reflowQuizTimelines } from './passes/reflow';
|
|
import { applyGeometry } from './passes/geometry';
|
|
import { planAssets } from './passes/assets';
|
|
|
|
export interface CompileInput {
|
|
/** The stage/classroom being exported (only id + name are read). */
|
|
stage: { id: string; name: string };
|
|
scenes: readonly CompilerScene[];
|
|
}
|
|
|
|
export interface CompileDeps {
|
|
timing: TimingProbe;
|
|
assets: AssetSource;
|
|
/**
|
|
* Optional rendered-geometry source. When present, spotlight/laser/video
|
|
* placement uses each element's measured content-box geometry instead of the
|
|
* authored outer box, so effects align with where the element actually paints
|
|
* (issue #867 item 5). Omitted in pure/unit contexts — the compiler then uses
|
|
* the deterministic authored-box calc.
|
|
*/
|
|
geometry?: GeometryProbe;
|
|
/** Optional prepared-HTML adapter; omitted callers degrade interactive scenes. */
|
|
interactive?: import('./deps').InteractiveHtmlSource;
|
|
/** Optional app-premeasured Quiz question-list layouts. */
|
|
quizLayout?: QuizLayoutProbe;
|
|
config?: CompileConfig;
|
|
}
|
|
|
|
/** Human-readable reason a scene family is not rendered by this compiler slice. */
|
|
function unsupportedReason(type: SceneType): string {
|
|
switch (type) {
|
|
case 'interactive':
|
|
return 'Interactive HTML was not prepared for static rendering; represented by markers.';
|
|
default:
|
|
return 'This scene family is preserved as markers but is not rendered by this compiler slice.';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mark unsupported scenes: attach a placeholder `base.reason`, prepend an
|
|
* `unsupported-scene` marker spanning the scene, and record a diagnostic. Slide
|
|
* supported scenes pass through untouched.
|
|
*/
|
|
function markUnsupported(
|
|
scenes: readonly VideoTimelineScene[],
|
|
diagnostics: Diagnostic[],
|
|
): VideoTimelineScene[] {
|
|
return scenes.map((scene) => {
|
|
if (scene.supported) return scene;
|
|
const reason =
|
|
scene.base.kind === 'placeholder' && scene.base.reason
|
|
? scene.base.reason
|
|
: unsupportedReason(scene.type);
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'unsupported-scene',
|
|
sceneId: scene.id,
|
|
message: `Scene "${scene.title}" (${scene.type}) is not rendered: ${reason}`,
|
|
});
|
|
return {
|
|
...scene,
|
|
base: { ...scene.base, kind: 'placeholder', reason },
|
|
markers: [
|
|
{
|
|
actionIndex: 0,
|
|
kind: 'unsupported-scene',
|
|
startMs: scene.startMs,
|
|
durationMs: scene.durationMs,
|
|
note: reason,
|
|
},
|
|
...scene.markers,
|
|
],
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pre-resolve which `play_video` actions have available media, keyed by the
|
|
* action **object identity**. An action is "available" only when the
|
|
* {@link AssetSource} returns a meta with `present: true`. The timeline pass uses
|
|
* this to give an unavailable clip a 0ms dwell (skip), instead of letting a
|
|
* silent safety-cap shift later actions.
|
|
*
|
|
* Keyed by object reference, not `action.id`: the DSL does not enforce
|
|
* stage-wide action-id uniqueness, so two scenes could share an id (e.g.
|
|
* `duplicate`) — an id-keyed set would then conflate their availability and
|
|
* leave a contradictory IR (a 5-minute dwell on a segment later stamped
|
|
* `skipped`). `resolveActionTimeline` receives these same normalized scene
|
|
* objects and passes each action back to `getVideoDurationMs` by reference, so
|
|
* identity lookup is exact. Availability must be decided here — before
|
|
* `resolveActionTimeline` fixes dwell — because asset planning runs after the
|
|
* timeline is laid out.
|
|
*/
|
|
function resolveAvailableVideos(
|
|
scenes: readonly CompilerScene[],
|
|
assets: AssetSource,
|
|
): Set<Action> {
|
|
const available = new Set<Action>();
|
|
for (const scene of scenes) {
|
|
for (const action of scene.actions ?? []) {
|
|
if (action.type !== 'play_video') continue;
|
|
const meta = assets.media((action as PlayVideoAction).elementId, scene);
|
|
if (meta?.present) available.add(action);
|
|
}
|
|
}
|
|
return available;
|
|
}
|
|
|
|
export function compileVideoTimeline(input: CompileInput, deps: CompileDeps): VideoTimeline {
|
|
const config = deps.config ?? {};
|
|
|
|
// 1. normalize — deterministic order + action validation.
|
|
const normalized = normalizeScenes(input.scenes);
|
|
|
|
// 2. probe — adapt the TimingProbe into the choreography option shape. Video
|
|
// availability is resolved up front (via the AssetSource) so an unavailable
|
|
// play_video gets a 0ms dwell in the timeline pass — it is skipped, not
|
|
// blocked for up to MAX_VIDEO_WAIT_MS, so later actions are not shifted.
|
|
const availableVideos = resolveAvailableVideos(normalized.scenes, deps.assets);
|
|
const opts = buildTimelineOptions(deps.timing, config, (action) => availableVideos.has(action));
|
|
|
|
// 3. timeline — index→time expansion folded into per-scene buckets + subtitles.
|
|
const timeline = buildTimeline(normalized.scenes, opts);
|
|
|
|
// 4. visuals — turn Quiz/PBL authored data into whole-scene static covers.
|
|
const visuals = applyVisuals(timeline.scenes, normalized.scenes, deps.quizLayout);
|
|
|
|
// 5. interactive HTML — promote successfully prepared pages to a first-class base.
|
|
const interactive = applyInteractiveHtml(visuals.scenes, normalized.scenes, deps.interactive);
|
|
|
|
// 6. reflow — compiler-added Quiz tails shift every later absolute timestamp,
|
|
// including first-class interactive bases prepared by the preceding pass.
|
|
const reflow = reflowQuizTimelines(
|
|
interactive.scenes,
|
|
timeline.subtitles,
|
|
timeline.totalDurationMs,
|
|
visuals.extensionsMs,
|
|
);
|
|
|
|
// 7. geometry — resolve effect + video element placement (degrade on miss).
|
|
// Prefers measured content-box geometry when a GeometryProbe is supplied.
|
|
const geometry = applyGeometry(reflow.scenes, normalized.scenes, deps.geometry);
|
|
|
|
// 8. assets — dedup + naming plan; stamp asset refs onto segments.
|
|
const assets = planAssets(normalized.scenes, geometry.scenes, deps.assets);
|
|
|
|
// 9. remaining unsupported scene families → markers + diagnostics.
|
|
const unsupportedDiagnostics: Diagnostic[] = [];
|
|
const scenes = markUnsupported(assets.scenes, unsupportedDiagnostics);
|
|
|
|
const diagnostics: Diagnostic[] = [
|
|
...normalized.diagnostics,
|
|
...timeline.diagnostics,
|
|
...visuals.diagnostics,
|
|
...interactive.diagnostics,
|
|
...geometry.diagnostics,
|
|
...assets.diagnostics,
|
|
...unsupportedDiagnostics,
|
|
];
|
|
|
|
return {
|
|
schema: VIDEO_TIMELINE_SCHEMA,
|
|
version: VIDEO_TIMELINE_VERSION,
|
|
compiler: VIDEO_TIMELINE_COMPILER,
|
|
stage: { id: input.stage.id, name: input.stage.name },
|
|
canvas: CANVAS,
|
|
config: {
|
|
playbackSpeed: config.playbackSpeed ?? 1,
|
|
ttsEnabled: timeline.ttsEnabled,
|
|
whiteboardInitiallyOpen: config.whiteboardInitiallyOpen ?? false,
|
|
},
|
|
totalDurationMs: reflow.totalDurationMs,
|
|
scenes,
|
|
subtitles: reflow.subtitles,
|
|
assets: assets.plan,
|
|
diagnostics,
|
|
};
|
|
}
|