110 lines
3.8 KiB
TypeScript
110 lines
3.8 KiB
TypeScript
/**
|
|
* `normalize` pass — deterministic scene ordering + action validation.
|
|
*
|
|
* The first compile pass. It (1) puts scenes in a stable, deterministic order
|
|
* (playback order == export order), and (2) validates each action, dropping any
|
|
* that the later passes could not interpret — with a first-class diagnostic so
|
|
* the drop is auditable, never silent (issue AC). Structural failures the
|
|
* compiler cannot degrade past (no scenes at all) throw.
|
|
*
|
|
* Pure: no IO, deterministic; does not mutate its input.
|
|
*/
|
|
import type { Action } from '@openmaic/dsl';
|
|
import { isActionType } from '@openmaic/dsl';
|
|
import type { CompilerScene } from '../deps';
|
|
import { type Diagnostic, VideoTimelineCompileError } from '../ir';
|
|
|
|
export interface NormalizeResult {
|
|
/** Scenes in deterministic order, each with only interpretable actions. */
|
|
scenes: CompilerScene[];
|
|
diagnostics: Diagnostic[];
|
|
}
|
|
|
|
/**
|
|
* Fields the later passes require. A missing one means the action can't be laid
|
|
* on the timeline / resolved, so it is dropped with an `invalid-action`
|
|
* diagnostic rather than crashing a downstream pass. Returns the missing field
|
|
* name, or null when the action is well-formed.
|
|
*/
|
|
function missingRequiredField(action: Action): string | null {
|
|
switch (action.type) {
|
|
case 'spotlight':
|
|
case 'laser':
|
|
case 'play_video':
|
|
case 'wb_delete':
|
|
case 'wb_edit_code':
|
|
return typeof action.elementId === 'string' && action.elementId.length > 0
|
|
? null
|
|
: 'elementId';
|
|
case 'speech':
|
|
// Empty text is legal (a dwell beat); only a non-string is malformed.
|
|
return typeof action.text === 'string' ? null : 'text';
|
|
case 'wb_draw_code':
|
|
return typeof action.code === 'string' ? null : 'code';
|
|
case 'discussion':
|
|
return typeof action.topic === 'string' && action.topic.length > 0 ? null : 'topic';
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Validate one scene's actions, collecting diagnostics for any that are dropped. */
|
|
function validateActions(scene: CompilerScene, diagnostics: Diagnostic[]): Action[] {
|
|
const actions = scene.actions ?? [];
|
|
const kept: Action[] = [];
|
|
|
|
for (const action of actions) {
|
|
if (!isActionType((action as Action).type)) {
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'unknown-action',
|
|
sceneId: scene.id,
|
|
actionId: (action as { id?: string }).id,
|
|
message: `Dropped action with unknown type "${String((action as { type?: unknown }).type)}".`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const missing = missingRequiredField(action as Action);
|
|
if (missing) {
|
|
diagnostics.push({
|
|
severity: 'warn',
|
|
code: 'invalid-action',
|
|
sceneId: scene.id,
|
|
actionId: (action as Action).id,
|
|
message: `Dropped ${(action as Action).type} action missing required field "${missing}".`,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
kept.push(action as Action);
|
|
}
|
|
|
|
return kept;
|
|
}
|
|
|
|
/**
|
|
* Order scenes deterministically and validate their actions.
|
|
*
|
|
* Ordering mirrors the app's export planner: by `order` (falling back to the
|
|
* input index when absent), tie-broken by the original input index so equal
|
|
* `order` values stay stable. Throws when there are no scenes to compile.
|
|
*/
|
|
export function normalizeScenes(scenes: readonly CompilerScene[]): NormalizeResult {
|
|
if (scenes.length === 0) {
|
|
throw new VideoTimelineCompileError('No scenes to compile into a VideoTimeline.');
|
|
}
|
|
|
|
const diagnostics: Diagnostic[] = [];
|
|
|
|
const ordered = scenes
|
|
.map((scene, inputIndex) => ({ scene, inputIndex }))
|
|
.sort((a, b) => {
|
|
const orderDiff = (a.scene.order ?? a.inputIndex) - (b.scene.order ?? b.inputIndex);
|
|
return orderDiff === 0 ? a.inputIndex - b.inputIndex : orderDiff;
|
|
})
|
|
.map(({ scene }) => ({ ...scene, actions: validateActions(scene, diagnostics) }));
|
|
|
|
return { scenes: ordered, diagnostics };
|
|
}
|