@bitmagic/cli 0.1.64-dev.7 → 0.1.64-dev.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +12 -2
  2. package/dist/commands/generate.d.ts +2 -0
  3. package/dist/commands/generate.js +6 -0
  4. package/dist/commands/generate.js.map +1 -1
  5. package/dist/commands/trailer.js +18 -4
  6. package/dist/commands/trailer.js.map +1 -1
  7. package/dist/editor/hud-replay-page.js +317 -22
  8. package/dist/editor/hud-replay-page.js.map +1 -1
  9. package/dist/render/qr-png.d.ts +6 -3
  10. package/dist/render/qr-png.js +8 -8
  11. package/dist/render/qr-png.js.map +1 -1
  12. package/dist/scaffold/skill-files.js +1 -0
  13. package/dist/scaffold/skill-files.js.map +1 -1
  14. package/dist/trailer/hud-raster.d.ts +12 -12
  15. package/dist/trailer/hud-raster.js +85 -32
  16. package/dist/trailer/hud-raster.js.map +1 -1
  17. package/dist/trailer/hud-state.d.ts +75 -4
  18. package/dist/trailer/hud-state.js +132 -17
  19. package/dist/trailer/hud-state.js.map +1 -1
  20. package/dist/trailer/io.d.ts +2 -0
  21. package/dist/trailer/io.js +6 -1
  22. package/dist/trailer/io.js.map +1 -1
  23. package/dist/trailer/link.d.ts +1 -0
  24. package/dist/trailer/link.js +15 -0
  25. package/dist/trailer/link.js.map +1 -0
  26. package/dist/trailer/make.d.ts +2 -0
  27. package/dist/trailer/make.js +1 -0
  28. package/dist/trailer/make.js.map +1 -1
  29. package/dist/trailer/merge.js +32 -11
  30. package/dist/trailer/merge.js.map +1 -1
  31. package/dist/trailer/render.d.ts +2 -0
  32. package/dist/trailer/render.js +94 -48
  33. package/dist/trailer/render.js.map +1 -1
  34. package/dist/trailer/timeline-types.d.ts +19 -0
  35. package/dist/trailer/timeline-types.js +42 -2
  36. package/dist/trailer/timeline-types.js.map +1 -1
  37. package/dist/trailer/title-raster.js +2 -1
  38. package/dist/trailer/title-raster.js.map +1 -1
  39. package/dist/trailer/titles.js +6 -1
  40. package/dist/trailer/titles.js.map +1 -1
  41. package/dist/trailer/video-chain.d.ts +96 -8
  42. package/dist/trailer/video-chain.js +183 -0
  43. package/dist/trailer/video-chain.js.map +1 -1
  44. package/package.json +4 -4
@@ -4,12 +4,20 @@
4
4
  * The harness page (`/hud-replay.html` on the running `bitmagic dev` sidecar) owns a real GameHUD,
5
5
  * so the overlay is the game's own UI rather than a reimplementation. Contract:
6
6
  * window.__hudReplayReady === true when the page can accept states
7
- * window.__hudReplay.render(state) synchronous; applies a replay state
7
+ * window.__hudReplay.render(state, ms) synchronous; applies a replay state,
8
+ * `ms` seeding animation phase on the
9
+ * source clock (older harness: ignored)
8
10
  * window.__hudReplay.setViewport(w, h) sizes the HUD root
11
+ * window.__hudReplay.settle() optional; resolves when fonts/images
12
+ * the state started loading are in (or
13
+ * a short deadline passed)
9
14
  * plus a transparent page background, so `omitBackground` yields real alpha.
10
15
  *
11
- * Every failure path here degrades to "no overlays" (returns null) instead of failing the trailer:
12
- * a UI-less video still beats no video. Chrome is the creator's installed browser
16
+ * Failure here degrades instead of failing the trailer: a keyframe that will
17
+ * not rasterize becomes a null slot (the caller holds the previous overlay over
18
+ * its window), and only an unusable page — navigation failed, the page died, or
19
+ * every single keyframe threw — degrades to "no overlays" (null). A UI-less
20
+ * video still beats no video. Chrome is the creator's installed browser
13
21
  * (`channel: 'chrome'`), the same one `bitmagic verify` drives — nothing is downloaded.
14
22
  */
15
23
  import * as fs from 'fs';
@@ -18,20 +26,29 @@ import { chromium } from 'playwright-core';
18
26
  import { out, warn } from './io.js';
19
27
  const NAV_TIMEOUT_MS = 20000;
20
28
  const READY_TIMEOUT_MS = 20000;
29
+ /** Consecutive keyframe failures after which the run gives up on UI entirely. */
30
+ const MAX_CONSECUTIVE_FAILURES = 3;
21
31
  export const CHROME_MISSING_HINT = 'Google Chrome could not be launched — install it from https://www.google.com/chrome/';
22
32
  /**
23
33
  * The state is embedded in the expression rather than passed as an argument:
24
34
  * Playwright treats a string pageFunction as a plain expression and drops the
25
35
  * arg, so an IIFE is the reliable form.
26
36
  */
27
- function applyStateExpression(state, width, height) {
37
+ function applyStateExpression(state, width, height, frameTimeMs, clipIndex) {
28
38
  return ('(() => {' +
29
39
  ' const replay = window.__hudReplay;' +
30
40
  ' if (!replay || typeof replay.render !== "function") throw new Error("window.__hudReplay.render is missing");' +
31
41
  ' if (typeof replay.setViewport === "function") replay.setViewport(' +
32
42
  `${JSON.stringify(width)}, ${JSON.stringify(height)});` +
33
- ` replay.render(${JSON.stringify(state)});` +
34
- ' return true;' +
43
+ // The second and third arguments seed animation phase (source time, and
44
+ // the clip so the harness can tell a new clip's first keyframe from a
45
+ // mid-clip one); an older harness ignores them.
46
+ ` replay.render(${JSON.stringify(state)}, ${JSON.stringify(frameTimeMs)}, ${JSON.stringify(clipIndex)});` +
47
+ // settle() is awaited here rather than in a second round trip: Playwright
48
+ // awaits a thenable returned from a string expression, so this is one
49
+ // protocol call per keyframe instead of two, with identical ordering. An
50
+ // older harness has no settle() and simply returns true.
51
+ ' return typeof replay.settle === "function" ? replay.settle() : true;' +
35
52
  '})()');
36
53
  }
37
54
  /** Headless system Chrome for offscreen rasterization; null (with a warning) when it cannot start. */
@@ -47,36 +64,90 @@ export async function launchRasterBrowser(purpose) {
47
64
  export function uiPngPath(outDir, index) {
48
65
  return path.join(outDir, `ui_${String(index).padStart(4, '0')}.png`);
49
66
  }
67
+ /**
68
+ * Cheap liveness probe. A crashed target or closed browser rejects every
69
+ * protocol call immediately, so this distinguishes "that one state was bad"
70
+ * from "there is no page any more".
71
+ */
72
+ async function pageIsAlive(page) {
73
+ try {
74
+ await page.evaluate('1');
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
50
81
  /**
51
82
  * Render one transparent PNG per keyframe, reusing a single browser + page.
52
- * Returns paths in keyframe order, or null when UI overlays must be skipped
53
- * (no Chrome, harness unreachable, or the harness rejected a state).
83
+ * Returns paths in keyframe order — a slot is null when that one keyframe
84
+ * failed (the caller holds the previous overlay across its window) — or null
85
+ * overall when UI must be skipped entirely (no Chrome, harness unreachable, the
86
+ * page died mid-run, or every keyframe failed).
54
87
  */
55
88
  export async function rasterizeUiKeyframes(keyframes, options) {
56
89
  if (keyframes.length === 0)
57
90
  return [];
91
+ // Before the browser exists: a throwing mkdir must not leak a Chrome.
92
+ fs.mkdirSync(options.outDir, { recursive: true });
58
93
  const browser = await launchRasterBrowser('ui');
59
94
  if (browser === null) {
60
95
  warn('ui: rendering without HUD overlays');
61
96
  return null;
62
97
  }
63
- fs.mkdirSync(options.outDir, { recursive: true });
64
98
  try {
65
99
  const context = await browser.newContext({
66
100
  viewport: { width: options.width, height: options.height },
67
- deviceScaleFactor: 1
101
+ deviceScaleFactor: options.deviceScaleFactor
68
102
  });
69
103
  const page = await context.newPage();
70
104
  await page.goto(options.harnessUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
71
105
  await page.waitForFunction('window.__hudReplayReady === true', undefined, { timeout: READY_TIMEOUT_MS });
72
106
  const paths = [];
107
+ let failed = 0;
108
+ let consecutiveFailures = 0;
73
109
  for (let i = 0; i < keyframes.length; i++) {
74
110
  const pngPath = uiPngPath(options.outDir, i);
75
- await page.evaluate(applyStateExpression(keyframes[i].state, options.width, options.height));
76
- await page.screenshot({ path: pngPath, omitBackground: true, type: 'png' });
77
- paths.push(pngPath);
111
+ try {
112
+ await page.evaluate(applyStateExpression(keyframes[i].state, options.width, options.height, Math.round(keyframes[i].frameTimeMs), keyframes[i].clipIndex));
113
+ await page.screenshot({ path: pngPath, omitBackground: true, type: 'png' });
114
+ paths.push(pngPath);
115
+ consecutiveFailures = 0;
116
+ }
117
+ catch (err) {
118
+ // One bad state must not throw away the overlays already on disk.
119
+ failed++;
120
+ consecutiveFailures++;
121
+ warn(`ui: keyframe ${i} failed to rasterize ` +
122
+ `(${err instanceof Error ? err.message : String(err)}) — holding the previous overlay`);
123
+ paths.push(null);
124
+ // A dead page fails every REMAINING keyframe too, and each null slot
125
+ // extends the last good overlay — which would freeze one stale HUD
126
+ // over the whole rest of the trailer. That is worse than no UI, so
127
+ // probe liveness and bail out whole rather than degrade per keyframe.
128
+ if (!(await pageIsAlive(page))) {
129
+ warn(`ui: the replay page died after ${paths.length - failed} overlay(s) — rendering without UI ` +
130
+ 'rather than freezing the last one over the remaining clips');
131
+ return null;
132
+ }
133
+ // A page that answers evaluate('1') but fails every screenshot (a
134
+ // stalled compositor, an unwritable .ui) would otherwise pay the full
135
+ // screenshot timeout once per remaining keyframe and then freeze the
136
+ // last good overlay across them — hours of waiting for a worse video.
137
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
138
+ warn(`ui: ${consecutiveFailures} keyframes in a row failed to rasterize — rendering without UI ` +
139
+ 'rather than holding one overlay over everything that follows');
140
+ return null;
141
+ }
142
+ }
143
+ }
144
+ if (failed === keyframes.length) {
145
+ warn(`ui: every keyframe failed to rasterize against ${options.harnessUrl} — rendering without UI`);
146
+ return null;
78
147
  }
79
- out(`ui: rasterized ${paths.length} overlay(s) at ${options.width}x${options.height} from ${options.harnessUrl}`);
148
+ out(`ui: rasterized ${paths.length - failed} overlay(s) at ${options.width}x${options.height}` +
149
+ `${options.deviceScaleFactor !== 1 ? `@${options.deviceScaleFactor.toFixed(2)}x` : ''} ` +
150
+ `from ${options.harnessUrl}${failed > 0 ? ` (${failed} failed, held over)` : ''}`);
80
151
  return paths;
81
152
  }
82
153
  catch (err) {
@@ -93,22 +164,4 @@ export async function rasterizeUiKeyframes(keyframes, options) {
93
164
  }
94
165
  }
95
166
  }
96
- /**
97
- * Width/height straight out of a PNG's IHDR chunk, so the filtergraph only
98
- * pays for a scale filter when the overlay really is the wrong size.
99
- */
100
- export function readPngSize(filePath) {
101
- const header = Buffer.alloc(24);
102
- const fd = fs.openSync(filePath, 'r');
103
- try {
104
- if (fs.readSync(fd, header, 0, 24, 0) < 24)
105
- return null;
106
- }
107
- finally {
108
- fs.closeSync(fd);
109
- }
110
- if (header.readUInt32BE(0) !== 0x89504e47 || header.toString('ascii', 12, 16) !== 'IHDR')
111
- return null;
112
- return { width: header.readUInt32BE(16), height: header.readUInt32BE(20) };
113
- }
114
167
  //# sourceMappingURL=hud-raster.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"hud-raster.js","sourceRoot":"","sources":["../../src/trailer/hud-raster.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"hud-raster.js","sourceRoot":"","sources":["../../src/trailer/hud-raster.ts"],"names":[],"mappings":"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"}
@@ -24,6 +24,8 @@ export interface HudHealthState {
24
24
  current: number;
25
25
  max: number;
26
26
  visible: boolean;
27
+ /** Pixel width from showHealth({width}); absent = the engine default. */
28
+ width?: number;
27
29
  }
28
30
  /**
29
31
  * One HUD element. Keys beyond id/type/anchor/visible are per-kind and are
@@ -52,6 +54,8 @@ export interface HudElementState {
52
54
  export interface HudToastState {
53
55
  message: string;
54
56
  variant?: string;
57
+ /** IGameHUD anchor; absent on recordings that predate anchor capture. */
58
+ anchor?: string;
55
59
  }
56
60
  export interface HudReplayState {
57
61
  version: 1;
@@ -63,7 +67,48 @@ export interface HudReplayState {
63
67
  /** Creation order (= stacking order within an anchor); removed ones dropped. */
64
68
  elements: HudElementState[];
65
69
  toasts: HudToastState[];
70
+ /**
71
+ * Game-authored head stylesheets, first-logged order, last write per id.
72
+ * The harness injects these before drawing — they are what turn
73
+ * custom-element class names into pixels.
74
+ *
75
+ * Shared between snapshots while unchanged, so treat it as frozen: never
76
+ * mutate the array or its entries in place.
77
+ */
78
+ stylesheets: ReadonlyArray<HudStylesheetState>;
79
+ }
80
+ export interface HudStylesheetState {
81
+ id: string;
82
+ css: string;
83
+ }
84
+ export interface RasterGeometry {
85
+ /** CSS-pixel viewport for the harness browser context. */
86
+ viewportWidth: number;
87
+ viewportHeight: number;
88
+ deviceScaleFactor: number;
66
89
  }
90
+ /**
91
+ * Where to lay the HUD out for rasterization.
92
+ *
93
+ * During capture only the CANVAS is resized to the recording resolution — the
94
+ * DOM HUD keeps laying out against the player's window. Rastering at the
95
+ * recording size therefore renders every fixed-pixel HUD metric too small
96
+ * relative to the footage (and flips vw-clamps and media queries). With the
97
+ * recorded window known, raster at an ASPECT-CORRECTED viewport: the window's
98
+ * height (so element size relative to screen height matches what the player
99
+ * saw) at the recording's aspect (raw window dimensions would stretch glyphs
100
+ * non-uniformly under the final scale whenever the aspects differ). The
101
+ * deviceScaleFactor keeps the PNG at or above the recording resolution so
102
+ * ffmpeg only ever downscales.
103
+ *
104
+ * No recorded window → the recording resolution at 1x, bit-for-bit the old
105
+ * behavior.
106
+ */
107
+ export declare function computeRasterGeometry(recordingWidth: number, recordingHeight: number, window?: {
108
+ innerWidth: number;
109
+ innerHeight: number;
110
+ devicePixelRatio: number;
111
+ }): RasterGeometry;
67
112
  /** The engine records `frame` on a 60fps source clock. */
68
113
  export declare const DEFAULT_HUD_FPS = 60;
69
114
  /** IGameHUD.showToast's default duration. */
@@ -78,6 +123,12 @@ export declare function isBlankHudState(state: HudReplayState): boolean;
78
123
  export interface UiKeyframe {
79
124
  /** Source frame whose state this is. */
80
125
  sourceFrame: number;
126
+ /**
127
+ * `sourceFrame` as milliseconds on the SOURCE clock. The harness seeds
128
+ * animation phase from it, so motion stays locked to the footage through a
129
+ * speed ramp — output time would run it fast or slow against the frames.
130
+ */
131
+ frameTimeMs: number;
81
132
  clipIndex: number;
82
133
  /** Output-timeline seconds this overlay becomes visible. */
83
134
  outputStartSec: number;
@@ -88,17 +139,37 @@ export interface UiKeyframe {
88
139
  export interface CollectUiKeyframesOptions {
89
140
  /** Source-clock fps. Default 60. */
90
141
  fps?: number;
91
- /** Ceiling on keyframes per second of OUTPUT time. Default 10. */
142
+ /** Ceiling on keyframes per second of OUTPUT time. Default 30. 0 disables. */
92
143
  maxPerSecond?: number;
93
- /** Hard ceiling per clip. Default 240. */
144
+ /** Hard ceiling per clip. Defaults to what the clip's duration allows at `maxPerSecond`. */
94
145
  maxPerClip?: number;
95
146
  /** Per-clip opt-out; defaults to every clip. */
96
147
  clipEnabled?: (clipIndex: number) => boolean;
97
148
  /** Where truncation notices go. Defaults to stderr. */
98
149
  log?: (line: string) => void;
99
150
  }
100
- export declare const DEFAULT_MAX_KEYFRAMES_PER_SECOND = 10;
101
- export declare const DEFAULT_MAX_KEYFRAMES_PER_CLIP = 240;
151
+ /**
152
+ * 30, not 15: measured on a real cut, the selected footage held 227 distinct
153
+ * HUD states across 15.3 s of output (14.8/s average, four of seven clips over
154
+ * 15/s), so a 15/s ceiling was discarding roughly 45% of the HUD motion the
155
+ * game actually rendered.
156
+ *
157
+ * ffmpeg used to be the reason for a low ceiling — one PNG input plus one
158
+ * serial overlay node per keyframe cost ~23 MB of RSS each and corrupted the
159
+ * render above ~400. The overlays now ride one densified image2 sequence whose
160
+ * cost is flat in keyframe count (measured 2.83 s / 0.94 GB at both 126 and
161
+ * 902 distinct rasters), so the remaining cost is ~25 ms of Playwright per
162
+ * keyframe plus its PNG on disk. 0 disables the limit entirely.
163
+ */
164
+ export declare const DEFAULT_MAX_KEYFRAMES_PER_SECOND = 30;
165
+ /**
166
+ * Sole per-clip ceiling, a backstop for `--ui-keyframe-rate 0`: at any real
167
+ * rate the limiter below admits at most rate·duration+1 keyframes per clip on
168
+ * its own, so nothing else can bind. A fixed 240 used to freeze the overlay
169
+ * after 16 s of any clip busy enough to sustain the default rate — exactly the
170
+ * clip a trailer is cut from. 60 s of output at 60/s.
171
+ */
172
+ export declare const MAX_KEYFRAMES_PER_CLIP_CEILING = 3600;
102
173
  /**
103
174
  * Walk every enabled clip's source range and emit a keyframe wherever the
104
175
  * reduced HUD state changes.
@@ -5,7 +5,33 @@ import { warn } from './io.js';
5
5
  * reach a trailer: interactive controls the viewer can't click in a video.
6
6
  * `mute-button` is `MuteControl`'s custom element (engine/ui/MuteControl.ts).
7
7
  */
8
- export const CHROME_ELEMENT_IDS = new Set(['mute-button']);
8
+ export const CHROME_ELEMENT_IDS = new Set(['mute-button', 'pause-button']);
9
+ /**
10
+ * Where to lay the HUD out for rasterization.
11
+ *
12
+ * During capture only the CANVAS is resized to the recording resolution — the
13
+ * DOM HUD keeps laying out against the player's window. Rastering at the
14
+ * recording size therefore renders every fixed-pixel HUD metric too small
15
+ * relative to the footage (and flips vw-clamps and media queries). With the
16
+ * recorded window known, raster at an ASPECT-CORRECTED viewport: the window's
17
+ * height (so element size relative to screen height matches what the player
18
+ * saw) at the recording's aspect (raw window dimensions would stretch glyphs
19
+ * non-uniformly under the final scale whenever the aspects differ). The
20
+ * deviceScaleFactor keeps the PNG at or above the recording resolution so
21
+ * ffmpeg only ever downscales.
22
+ *
23
+ * No recorded window → the recording resolution at 1x, bit-for-bit the old
24
+ * behavior.
25
+ */
26
+ export function computeRasterGeometry(recordingWidth, recordingHeight, window) {
27
+ if (window === undefined) {
28
+ return { viewportWidth: recordingWidth, viewportHeight: recordingHeight, deviceScaleFactor: 1 };
29
+ }
30
+ const viewportHeight = Math.max(1, Math.round(window.innerHeight));
31
+ const viewportWidth = Math.max(1, Math.round(viewportHeight * (recordingWidth / recordingHeight)));
32
+ const deviceScaleFactor = Math.max(1, recordingHeight / viewportHeight);
33
+ return { viewportWidth, viewportHeight, deviceScaleFactor };
34
+ }
9
35
  // ---------------------------------------------------------------------------
10
36
  // Defaults
11
37
  // ---------------------------------------------------------------------------
@@ -47,6 +73,19 @@ function round(value) {
47
73
  * `reduceHudState`; `collectUiKeyframes` drives one instance forward across a
48
74
  * clip so a frame-by-frame walk stays linear in the op count.
49
75
  */
76
+ /**
77
+ * Whether a container's recorded cssText hides it outright.
78
+ *
79
+ * Visibility has two records: the show/hide ops, and the `display` inside the
80
+ * cssText snapshot the recorder takes on create and on every update (a game
81
+ * that toggles `container.style.display` itself, as the docs' Weapon Wheel
82
+ * does, never logs a hide op). Neither is authoritative alone — the most
83
+ * recent one in frame order is, and only the reducer sees that order — so a
84
+ * css snapshot sets `visible` here and later show/hide ops override it.
85
+ */
86
+ function cssHidesElement(css) {
87
+ return /(^|;)\s*display\s*:\s*none\b/i.test(css);
88
+ }
50
89
  class HudReducer {
51
90
  ops;
52
91
  fps;
@@ -56,6 +95,12 @@ class HudReducer {
56
95
  theme = null;
57
96
  elements = [];
58
97
  toasts = [];
98
+ /** Insertion-ordered; a re-logged id updates in place (last write wins). */
99
+ stylesheets = new Map();
100
+ /** The emitted stylesheet array, rebuilt only when a sheet actually changes. */
101
+ stylesheetsEmitted = [];
102
+ /** Bumped with every real change to the set above; `stateKey` identifies the set by it. */
103
+ stylesheetsVersion = 0;
59
104
  constructor(ops, fps) {
60
105
  // Stable sort: entries recorded on the same frame keep emission order,
61
106
  // which IS the stacking order within an anchor.
@@ -73,8 +118,11 @@ class HudReducer {
73
118
  return this.elements.find((el) => el.id === id);
74
119
  }
75
120
  apply(op) {
76
- // Engine chrome is part of the HUD but never part of a trailer.
77
- if ('id' in op && CHROME_ELEMENT_IDS.has(op.id))
121
+ // Engine chrome is part of the HUD but never part of a trailer. A
122
+ // stylesheet op's `id` is a head <style> element's id, a different
123
+ // namespace from HUD element ids, so it must not be filtered here — a game
124
+ // sheet named after a chrome element would otherwise vanish silently.
125
+ if (op.op !== 'stylesheet' && 'id' in op && CHROME_ELEMENT_IDS.has(op.id))
78
126
  return;
79
127
  switch (op.op) {
80
128
  case 'create':
@@ -85,8 +133,16 @@ class HudReducer {
85
133
  break;
86
134
  case 'custom-html': {
87
135
  const el = this.find(op.id);
88
- if (el !== undefined)
136
+ if (el !== undefined) {
89
137
  el.html = op.html;
138
+ // The container's own cssText rides updates too (onUpdate mutates it
139
+ // as freely as the markup); absent on older recordings. Its display
140
+ // is a visibility verdict of its own — see cssHidesElement.
141
+ if (op.css !== undefined) {
142
+ el.css = op.css;
143
+ el.visible = !cssHidesElement(op.css);
144
+ }
145
+ }
90
146
  break;
91
147
  }
92
148
  case 'timer':
@@ -105,16 +161,24 @@ class HudReducer {
105
161
  this.elements.splice(index, 1);
106
162
  break;
107
163
  }
108
- case 'health':
164
+ case 'health': {
165
+ const width = this.health?.width;
109
166
  this.health = { current: op.current, max: op.max, visible: this.health?.visible ?? false };
167
+ if (width !== undefined)
168
+ this.health.width = width;
110
169
  break;
111
- case 'health-visible':
170
+ }
171
+ case 'health-visible': {
172
+ const width = op.width ?? this.health?.width;
112
173
  this.health = {
113
174
  current: this.health?.current ?? 0,
114
175
  max: this.health?.max ?? 100,
115
176
  visible: op.visible
116
177
  };
178
+ if (width !== undefined)
179
+ this.health.width = width;
117
180
  break;
181
+ }
118
182
  case 'toast': {
119
183
  const durationMs = op.durationMs ?? DEFAULT_TOAST_MS;
120
184
  const toast = {
@@ -124,6 +188,8 @@ class HudReducer {
124
188
  };
125
189
  if (op.variant !== undefined)
126
190
  toast.variant = op.variant;
191
+ if (op.anchor !== undefined)
192
+ toast.anchor = op.anchor;
127
193
  this.toasts.push(toast);
128
194
  break;
129
195
  }
@@ -133,6 +199,19 @@ class HudReducer {
133
199
  case 'theme':
134
200
  this.theme = op.theme;
135
201
  break;
202
+ case 'stylesheet': {
203
+ // An empty css body is a tombstone: the recorder logs it when a sheet
204
+ // it had captured disappears from the head, so a skin swapped by
205
+ // removal does not replay with both skins applied at once.
206
+ const removed = op.css === '';
207
+ if (removed ? !this.stylesheets.delete(op.id) : this.stylesheets.get(op.id) === op.css)
208
+ break;
209
+ if (!removed)
210
+ this.stylesheets.set(op.id, op.css);
211
+ this.stylesheetsEmitted = [...this.stylesheets].map(([id, css]) => ({ id, css }));
212
+ this.stylesheetsVersion++;
213
+ break;
214
+ }
136
215
  default:
137
216
  // Forward compatibility: a newer engine may log ops we predate.
138
217
  break;
@@ -144,11 +223,12 @@ class HudReducer {
144
223
  const existing = this.elements.findIndex((el) => el.id === id);
145
224
  if (existing >= 0)
146
225
  this.elements.splice(existing, 1);
226
+ const css = readString(params, 'css');
147
227
  const el = {
148
228
  id,
149
229
  type: elType,
150
230
  anchor: readString(params, 'anchor') ?? DEFAULT_ANCHOR[elType] ?? 'top-left',
151
- visible: true
231
+ visible: css === undefined || !cssHidesElement(css)
152
232
  };
153
233
  const label = readString(params, 'label');
154
234
  const color = readString(params, 'color');
@@ -161,7 +241,6 @@ class HudReducer {
161
241
  const countDown = readBoolean(params, 'countDown');
162
242
  const format = readString(params, 'format');
163
243
  const html = readString(params, 'html');
164
- const css = readString(params, 'css');
165
244
  if (label !== undefined)
166
245
  el.label = label;
167
246
  if (color !== undefined)
@@ -237,7 +316,15 @@ class HudReducer {
237
316
  elements: this.elements.map((el) => emitElement(el, frame, this.fps)),
238
317
  toasts: this.toasts
239
318
  .filter((t) => t.startFrame <= frame)
240
- .map((t) => (t.variant === undefined ? { message: t.message } : { message: t.message, variant: t.variant }))
319
+ .map((t) => {
320
+ const toast = { message: t.message };
321
+ if (t.variant !== undefined)
322
+ toast.variant = t.variant;
323
+ if (t.anchor !== undefined)
324
+ toast.anchor = t.anchor;
325
+ return toast;
326
+ }),
327
+ stylesheets: this.stylesheetsEmitted
241
328
  };
242
329
  }
243
330
  }
@@ -297,8 +384,28 @@ export function isBlankHudState(state) {
297
384
  return false;
298
385
  return !state.elements.some((el) => el.visible);
299
386
  }
300
- export const DEFAULT_MAX_KEYFRAMES_PER_SECOND = 10;
301
- export const DEFAULT_MAX_KEYFRAMES_PER_CLIP = 240;
387
+ /**
388
+ * 30, not 15: measured on a real cut, the selected footage held 227 distinct
389
+ * HUD states across 15.3 s of output (14.8/s average, four of seven clips over
390
+ * 15/s), so a 15/s ceiling was discarding roughly 45% of the HUD motion the
391
+ * game actually rendered.
392
+ *
393
+ * ffmpeg used to be the reason for a low ceiling — one PNG input plus one
394
+ * serial overlay node per keyframe cost ~23 MB of RSS each and corrupted the
395
+ * render above ~400. The overlays now ride one densified image2 sequence whose
396
+ * cost is flat in keyframe count (measured 2.83 s / 0.94 GB at both 126 and
397
+ * 902 distinct rasters), so the remaining cost is ~25 ms of Playwright per
398
+ * keyframe plus its PNG on disk. 0 disables the limit entirely.
399
+ */
400
+ export const DEFAULT_MAX_KEYFRAMES_PER_SECOND = 30;
401
+ /**
402
+ * Sole per-clip ceiling, a backstop for `--ui-keyframe-rate 0`: at any real
403
+ * rate the limiter below admits at most rate·duration+1 keyframes per clip on
404
+ * its own, so nothing else can bind. A fixed 240 used to freeze the overlay
405
+ * after 16 s of any clip busy enough to sustain the default rate — exactly the
406
+ * clip a trailer is cut from. 60 s of output at 60/s.
407
+ */
408
+ export const MAX_KEYFRAMES_PER_CLIP_CEILING = 3600;
302
409
  /**
303
410
  * Frame times are exact ratios that land just under a spacing boundary in
304
411
  * binary (frame 18 at 60fps is 0.09999999999999998s after frame 12), which
@@ -309,12 +416,19 @@ const SPACING_EPSILON = 1e-9;
309
416
  * Structural identity of a state. Timer seconds collapse to whole displayed
310
417
  * seconds (floor — where both floor- and ceil-based formatters change), so a
311
418
  * ticking clock yields ~1 keyframe per second instead of one per frame.
419
+ *
420
+ * Stylesheets are keyed by the reducer's change counter, not their bytes: the
421
+ * payload can run to the recorder's 128 KB budget and would otherwise be
422
+ * re-serialized once per source frame. Keys are only ever compared within one
423
+ * reducer's clip, so a per-reducer counter is exact — a set that changes and
424
+ * changes back emits one extra byte-identical keyframe, which is harmless.
312
425
  */
313
- function stateKey(state) {
314
- return JSON.stringify({
315
- ...state,
426
+ function stateKey(state, stylesheetsVersion) {
427
+ const { stylesheets, ...rest } = state;
428
+ return (JSON.stringify({
429
+ ...rest,
316
430
  elements: state.elements.map((el) => el.seconds === undefined ? el : { ...el, seconds: Math.floor(el.seconds) })
317
- });
431
+ }) + (stylesheets.length === 0 ? '|none' : `|s${stylesheetsVersion}`));
318
432
  }
319
433
  /**
320
434
  * Walk every enabled clip's source range and emit a keyframe wherever the
@@ -327,7 +441,6 @@ function stateKey(state) {
327
441
  export function collectUiKeyframes(ops, clips, opts = {}) {
328
442
  const fps = opts.fps ?? DEFAULT_HUD_FPS;
329
443
  const maxPerSecond = opts.maxPerSecond ?? DEFAULT_MAX_KEYFRAMES_PER_SECOND;
330
- const maxPerClip = opts.maxPerClip ?? DEFAULT_MAX_KEYFRAMES_PER_CLIP;
331
444
  const clipEnabled = opts.clipEnabled ?? (() => true);
332
445
  const log = opts.log ?? warn;
333
446
  const minSpacingSec = maxPerSecond > 0 ? 1 / maxPerSecond : 0;
@@ -338,6 +451,7 @@ export function collectUiKeyframes(ops, clips, opts = {}) {
338
451
  const timing = clips[index];
339
452
  const windowEnd = exclusiveOutputEnd(clips, index);
340
453
  const reducer = new HudReducer(ops, fps);
454
+ const maxPerClip = opts.maxPerClip ?? MAX_KEYFRAMES_PER_CLIP_CEILING;
341
455
  const clipFrames = [];
342
456
  let lastEmittedKey = null;
343
457
  let lastEmittedSec = Number.NEGATIVE_INFINITY;
@@ -353,7 +467,7 @@ export function collectUiKeyframes(ops, clips, opts = {}) {
353
467
  break;
354
468
  reducer.advanceTo(frame);
355
469
  const state = reducer.snapshot(frame);
356
- const key = stateKey(state);
470
+ const key = stateKey(state, reducer.stylesheetsVersion);
357
471
  if (key === lastEmittedKey)
358
472
  continue;
359
473
  if (clipFrames.length >= maxPerClip) {
@@ -376,6 +490,7 @@ export function collectUiKeyframes(ops, clips, opts = {}) {
376
490
  coalesced++;
377
491
  clipFrames.push({
378
492
  sourceFrame: frame,
493
+ frameTimeMs: (frame / fps) * 1000,
379
494
  clipIndex: index,
380
495
  outputStartSec: outputSec,
381
496
  outputEndSec: windowEnd,