Files
2026-08-16 14:58:47 +08:00

182 lines
7.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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
* (0100 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 (0100) 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';
}