/** * HUD replay: turn the recorded `hud[]` op track back into UI state. * * Captured frames are UI-less (the HUD is DOM, not WebGL), so the engine logs * semantic HUD *state changes* instead of pixels. This module reduces that * sparse op stream back to the complete UI state at any source frame * (`reduceHudState`), and picks the frames worth rasterizing per EDL clip * (`collectUiKeyframes`). Output-timeline placement comes from edl-time.ts — * this file never re-derives crossfade/speed math. * * Timers are the one derived value: they are logged as start/pause/reset ops, * never per tick, and the displayed value is computed from FRAME time (which * is more correct than the widget's wall clock under a dilated recording). */ import type { ClipTiming } from './edl-time.js'; import type { HudElementKind, HudOp } from './timeline-types.js'; /** * Engine-owned chrome that lives in the HUD element registry but must never * reach a trailer: interactive controls the viewer can't click in a video. * `mute-button` is `MuteControl`'s custom element (engine/ui/MuteControl.ts). */ export declare const CHROME_ELEMENT_IDS: ReadonlySet; export interface HudHealthState { current: number; max: number; visible: boolean; /** Pixel width from showHealth({width}); absent = the engine default. */ width?: number; } /** * One HUD element. Keys beyond id/type/anchor/visible are per-kind and are * omitted when the recording never set them. */ export interface HudElementState { id: string; type: HudElementKind; anchor: string; visible: boolean; label?: string; color?: string; width?: number; showText?: boolean; percent?: number; icon?: string; text?: string; iconSize?: number; /** Timer only: the DERIVED displayed seconds; the harness formats it. */ seconds?: number; countDown?: boolean; format?: string; html?: string; css?: string; } export interface HudToastState { message: string; variant?: string; /** IGameHUD anchor; absent on recordings that predate anchor capture. */ anchor?: string; } export interface HudReplayState { version: 1; hudVisible: boolean; /** null until the recording touches health at all. */ health: HudHealthState | null; /** Verbatim from the `theme` op; null when the recording carried none. */ theme: unknown; /** Creation order (= stacking order within an anchor); removed ones dropped. */ elements: HudElementState[]; toasts: HudToastState[]; /** * Game-authored head stylesheets, first-logged order, last write per id. * The harness injects these before drawing — they are what turn * custom-element class names into pixels. * * Shared between snapshots while unchanged, so treat it as frozen: never * mutate the array or its entries in place. */ stylesheets: ReadonlyArray; } export interface HudStylesheetState { id: string; css: string; } export interface RasterGeometry { /** CSS-pixel viewport for the harness browser context. */ viewportWidth: number; viewportHeight: number; deviceScaleFactor: number; } /** * Where to lay the HUD out for rasterization. * * During capture only the CANVAS is resized to the recording resolution — the * DOM HUD keeps laying out against the player's window. Rastering at the * recording size therefore renders every fixed-pixel HUD metric too small * relative to the footage (and flips vw-clamps and media queries). With the * recorded window known, raster at an ASPECT-CORRECTED viewport: the window's * height (so element size relative to screen height matches what the player * saw) at the recording's aspect (raw window dimensions would stretch glyphs * non-uniformly under the final scale whenever the aspects differ). The * deviceScaleFactor keeps the PNG at or above the recording resolution so * ffmpeg only ever downscales. * * No recorded window → the recording resolution at 1x, bit-for-bit the old * behavior. */ export declare function computeRasterGeometry(recordingWidth: number, recordingHeight: number, window?: { innerWidth: number; innerHeight: number; devicePixelRatio: number; }): RasterGeometry; /** The engine records `frame` on a 60fps source clock. */ export declare const DEFAULT_HUD_FPS = 60; /** IGameHUD.showToast's default duration. */ export declare const DEFAULT_TOAST_MS = 2500; /** * The complete HUD state at `frame`. Ops after `frame` are ignored; unknown * op names are skipped rather than throwing. */ export declare function reduceHudState(ops: readonly HudOp[], frame: number, fps?: number): HudReplayState; /** True when a state would rasterize to a fully transparent frame. */ export declare function isBlankHudState(state: HudReplayState): boolean; export interface UiKeyframe { /** Source frame whose state this is. */ sourceFrame: number; /** * `sourceFrame` as milliseconds on the SOURCE clock. The harness seeds * animation phase from it, so motion stays locked to the footage through a * speed ramp — output time would run it fast or slow against the frames. */ frameTimeMs: number; clipIndex: number; /** Output-timeline seconds this overlay becomes visible. */ outputStartSec: number; /** Output-timeline seconds it stops being visible (next keyframe / clip end). */ outputEndSec: number; state: HudReplayState; } export interface CollectUiKeyframesOptions { /** Source-clock fps. Default 60. */ fps?: number; /** Ceiling on keyframes per second of OUTPUT time. Default 30. 0 disables. */ maxPerSecond?: number; /** Hard ceiling per clip. Defaults to what the clip's duration allows at `maxPerSecond`. */ maxPerClip?: number; /** Per-clip opt-out; defaults to every clip. */ clipEnabled?: (clipIndex: number) => boolean; /** Where truncation notices go. Defaults to stderr. */ log?: (line: string) => void; } /** * 30, not 15: measured on a real cut, the selected footage held 227 distinct * HUD states across 15.3 s of output (14.8/s average, four of seven clips over * 15/s), so a 15/s ceiling was discarding roughly 45% of the HUD motion the * game actually rendered. * * ffmpeg used to be the reason for a low ceiling — one PNG input plus one * serial overlay node per keyframe cost ~23 MB of RSS each and corrupted the * render above ~400. The overlays now ride one densified image2 sequence whose * cost is flat in keyframe count (measured 2.83 s / 0.94 GB at both 126 and * 902 distinct rasters), so the remaining cost is ~25 ms of Playwright per * keyframe plus its PNG on disk. 0 disables the limit entirely. */ export declare const DEFAULT_MAX_KEYFRAMES_PER_SECOND = 30; /** * Sole per-clip ceiling, a backstop for `--ui-keyframe-rate 0`: at any real * rate the limiter below admits at most rate·duration+1 keyframes per clip on * its own, so nothing else can bind. A fixed 240 used to freeze the overlay * after 16 s of any clip busy enough to sustain the default rate — exactly the * clip a trailer is cut from. 60 s of output at 60/s. */ export declare const MAX_KEYFRAMES_PER_CLIP_CEILING = 3600; /** * Walk every enabled clip's source range and emit a keyframe wherever the * reduced HUD state changes. * * A clip's overlays stop at `exclusiveOutputEnd`, so during a crossfade the * incoming clip's UI takes over at the fade start and exactly one overlay is * ever active — two stacked HUDs would double-draw every element. */ export declare function collectUiKeyframes(ops: readonly HudOp[], clips: readonly ClipTiming[], opts?: CollectUiKeyframesOptions): UiKeyframe[];