/** * Dependency-injection boundary for the video-timeline compiler. * * The compiler is pure (issue #864 / #854 AC: the IR must be produced and * inspected without FFmpeg / Chrome / DOM). Everything it needs from live app * state — audio/video durations, which assets actually exist — enters through * these **synchronous** interfaces. The app provides Dexie/DOM-backed * implementations that pre-resolve the data (durations are stored at TTS time, * #861); tests provide plain in-memory stubs. Neither reaches into the compiler. * * Sync by design: with durations pre-resolved (#861) the whole compile is a pure * synchronous fold, which is what makes it trivially unit-testable — a stub is a * literal object, not a Promise-returning mock. * * Pure: types only, from `@openmaic/dsl`. */ import type { SpeechAction, PlayVideoAction, WbClearAction, WbEditCodeAction, DiscussionAction, SceneCore, SceneType, PPTElement, } from '@openmaic/dsl'; import type { PercentageGeometry } from './ir'; import type { InteractiveHtmlFailure } from './interactive-static'; /** * The compiler's scene input — the structural slice it reads. Deliberately * looser than the app's fully-instantiated `Scene`: it needs `SceneCore` * (id/title/order/actions), the `type` discriminant, and — for slide scenes — * the canvas elements the geometry pass resolves against. An app `Scene` * (slide/quiz/interactive/pbl) is structurally assignable to this, so callers * pass their scenes without casting; non-slide content simply lacks `canvas`. */ export interface CompilerSceneContent { type?: string; canvas?: { elements?: PPTElement[] }; /** Quiz authored content; kept structural so the pure compiler needs no app types. */ questions?: readonly unknown[]; /** App-owned PBL payloads enter as unknown and are narrowed by the pure visual pass. */ projectV2?: unknown; projectConfig?: unknown; /** Embedded interactive HTML; prepared by the app-side adapter before compile. */ html?: string; } export type CompilerScene = SceneCore & { type: SceneType; content?: CompilerSceneContent; }; /** * Timing source — the durations the pure {@link resolveActionTimeline} cannot * derive on its own. Each method mirrors a `ResolveTimelineOptions` callback; the * `probe` pass adapts this interface into that option shape. All synchronous: * the app resolves stored durations up front (#861) and hands over a table. */ export interface TimingProbe { /** * Natural (1×) narration duration in ms for a speech action with stored audio, * or `null` to fall back to the deterministic no-audio estimate. The timeline * divides a returned value by `playbackSpeed`, matching the live audio path. */ audioDurationMs(action: SpeechAction): number | null; /** * Video duration in ms for a `play_video` action, or `null` when unknown (the * `onUnresolvedVideoDuration` policy then decides). Capped at the shared * `MAX_VIDEO_WAIT_MS`. */ videoDurationMs(action: PlayVideoAction): number | null; /** Live whiteboard element count when a `wb_clear` runs (the clear anim scales with it). Default 0. */ clearElementCount?(action: WbClearAction): number; /** Whether a discussion is skipped by the engine (consumed / agent not selected) → no dwell. */ isDiscussionSkipped?(action: DiscussionAction): boolean; /** Whether a `wb_edit_code` is a no-op the engine skips without delay. */ isEditCodeNoop?(action: WbEditCodeAction): boolean; } /** Metadata about one bundleable asset — enough to plan layout/naming, not the bytes. */ export interface AssetMeta { /** Stable id used for dedup (e.g. the stored audio/media record id). */ id: string; mimeType?: string; /** File extension hint (e.g. `mp3`, `png`, `mp4`); the planner falls back from `mimeType`. */ format?: string; durationMs?: number; /** Whether the source actually has the bytes. False → `skipped-media` diagnostic. */ present: boolean; } /** * Asset source — resolves which narration/media assets exist and their metadata, * so the `assets` pass can build a dedup + naming plan. Returns descriptors only; * the browser-side collector fetches the blobs in the next phase (P1d). `null` * means "no asset referenced" (distinct from an asset that is referenced but * `present: false`). */ export interface AssetSource { /** Audio asset backing a speech action, or null when it has none. */ audio(action: SpeechAction): AssetMeta | null; /** Media asset (image/video) for an element on a scene, or null when none. */ media(elementId: string, scene: SceneCore): AssetMeta | null; } /** Prepared interactive HTML metadata exposed synchronously to the pure compiler. */ export interface InteractiveHtmlMeta { /** Stable id used by the asset plan and byte collector. */ id: string; present: boolean; /** SHA-256 of the exact packaged HTML, present only on success. */ contentHash?: string; /** Stable failure category when `present` is false. */ failure?: InteractiveHtmlFailure; /** Bounded, human-readable detail for the export report. */ message?: string; } /** Synchronous adapter over HTML that the app prepared before pure compilation. */ export interface InteractiveHtmlSource { html(scene: SceneCore): InteractiveHtmlMeta | null; } /** * Geometry source — resolves a slide element's **rendered** percentage geometry * (0–100 space) so spotlight/laser/video placement matches where the element * actually paints, not just its authored box. * * The pure geometry helper ({@link findElementGeometry}) reads the element's * authored `left/top/width/height` against a fixed 1000×562.5 base. That is the * element's **outer box**, but the live overlays (and the slide-snapshot frame) * measure the element's `.element-content` box — which differs for auto-sized * text (horizontal text has `height:auto`) and its 10px content padding. So a * spotlight placed on the authored box sits offset from the rendered text * (issue #867 item 5). When the app can measure the real content box (an * off-screen render), it supplies this probe; the geometry pass prefers it and * falls back to the pure authored-box calc on a miss. Tests omit it entirely — * the compiler stays pure and deterministic without it. */ export interface GeometryProbe { /** * The rendered content-box geometry (0–100) for an element on a scene, or * null when it could not be measured (element absent / not a slide) — the * caller then falls back to the authored-box calc. */ contentGeometry(elementId: string, scene: SceneCore): PercentageGeometry | null; } /** Stable app-side layout facts consumed by the pure Quiz timing planner. */ export interface QuizLayoutMeasurement { /** Full static question-list content height at the target resolution. */ contentHeightPx: number; /** Visible list viewport height at the target resolution. */ viewportHeightPx: number; /** Target video frame height, used to scale the 720p scroll-speed baseline. */ frameHeightPx: number; } /** * Synchronous Quiz layout dependency. The app measures all Quiz scenes before * compile and exposes the resulting table through this pure lookup boundary. */ export interface QuizLayoutProbe { measureQuestionList(scene: SceneCore): QuizLayoutMeasurement | null; } /** Compiler configuration — the determinism inputs recorded into the IR's `config`. */ export interface CompileConfig { /** Playback speed multiplier applied to speech dwell. Default 1. */ playbackSpeed?: number; /** Whether the whiteboard is already open when the timeline starts. Default false. */ whiteboardInitiallyOpen?: boolean; /** * Policy when a `play_video` duration is unresolved. Default `'cap'` (assume the * safety cap) — unlike the choreography default of `'throw'`, the exporter * prefers to degrade with a diagnostic over failing the whole compile. */ onUnresolvedVideoDuration?: 'throw' | 'cap' | 'zero'; }