@hyperframes/engine 0.5.0-alpha.8 → 0.5.0

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 (76) hide show
  1. package/dist/config.d.ts +11 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +11 -1
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/services/audioMixer.d.ts.map +1 -1
  10. package/dist/services/audioMixer.js +4 -6
  11. package/dist/services/audioMixer.js.map +1 -1
  12. package/dist/services/browserManager.d.ts +3 -1
  13. package/dist/services/browserManager.d.ts.map +1 -1
  14. package/dist/services/browserManager.js +44 -3
  15. package/dist/services/browserManager.js.map +1 -1
  16. package/dist/services/chunkEncoder.d.ts.map +1 -1
  17. package/dist/services/chunkEncoder.js +31 -0
  18. package/dist/services/chunkEncoder.js.map +1 -1
  19. package/dist/services/fileServer.d.ts.map +1 -1
  20. package/dist/services/fileServer.js +1 -60
  21. package/dist/services/fileServer.js.map +1 -1
  22. package/dist/services/frameCapture.d.ts.map +1 -1
  23. package/dist/services/frameCapture.js +103 -13
  24. package/dist/services/frameCapture.js.map +1 -1
  25. package/dist/services/screenshotService.d.ts.map +1 -1
  26. package/dist/services/screenshotService.js +7 -5
  27. package/dist/services/screenshotService.js.map +1 -1
  28. package/dist/services/streamingEncoder.d.ts.map +1 -1
  29. package/dist/services/streamingEncoder.js +20 -0
  30. package/dist/services/streamingEncoder.js.map +1 -1
  31. package/dist/services/videoFrameExtractor.d.ts +20 -0
  32. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  33. package/dist/services/videoFrameExtractor.js +95 -7
  34. package/dist/services/videoFrameExtractor.js.map +1 -1
  35. package/dist/services/videoFrameInjector.d.ts +4 -1
  36. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  37. package/dist/services/videoFrameInjector.js +5 -2
  38. package/dist/services/videoFrameInjector.js.map +1 -1
  39. package/dist/types.d.ts +27 -6
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/utils/alphaBlit.d.ts +1 -1
  42. package/dist/utils/alphaBlit.d.ts.map +1 -1
  43. package/dist/utils/alphaBlit.js +15 -6
  44. package/dist/utils/alphaBlit.js.map +1 -1
  45. package/dist/utils/ffprobe.d.ts.map +1 -1
  46. package/dist/utils/ffprobe.js +17 -1
  47. package/dist/utils/ffprobe.js.map +1 -1
  48. package/dist/utils/htmlTemplate.d.ts.map +1 -1
  49. package/dist/utils/htmlTemplate.js +1 -8
  50. package/dist/utils/htmlTemplate.js.map +1 -1
  51. package/dist/utils/parityContract.d.ts +1 -2
  52. package/dist/utils/parityContract.d.ts.map +1 -1
  53. package/dist/utils/parityContract.js +1 -34
  54. package/dist/utils/parityContract.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/config.test.ts +38 -0
  57. package/src/config.ts +27 -1
  58. package/src/index.ts +2 -0
  59. package/src/services/audioMixer.ts +4 -6
  60. package/src/services/browserManager.test.ts +79 -0
  61. package/src/services/browserManager.ts +55 -4
  62. package/src/services/chunkEncoder.ts +36 -0
  63. package/src/services/fileServer.ts +1 -68
  64. package/src/services/frameCapture.ts +130 -12
  65. package/src/services/screenshotService.ts +9 -7
  66. package/src/services/streamingEncoder.ts +25 -0
  67. package/src/services/videoFrameExtractor.test.ts +117 -1
  68. package/src/services/videoFrameExtractor.ts +100 -7
  69. package/src/services/videoFrameInjector.ts +15 -3
  70. package/src/types.ts +28 -6
  71. package/src/utils/alphaBlit.test.ts +10 -0
  72. package/src/utils/alphaBlit.ts +15 -7
  73. package/src/utils/ffprobe.test.ts +40 -0
  74. package/src/utils/ffprobe.ts +16 -1
  75. package/src/utils/htmlTemplate.ts +1 -9
  76. package/src/utils/parityContract.ts +1 -35
@@ -234,16 +234,48 @@ export async function releaseBrowser(
234
234
  await browser.close().catch(() => {});
235
235
  }
236
236
 
237
+ export function forceReleaseBrowser(browser: Browser): void {
238
+ if (pooledBrowser && pooledBrowser === browser) {
239
+ pooledBrowserRefCount = 0;
240
+ pooledBrowser = null;
241
+ }
242
+ const proc = (
243
+ browser as unknown as {
244
+ process?: () => { kill: (signal?: NodeJS.Signals) => boolean; killed?: boolean } | null;
245
+ }
246
+ ).process?.();
247
+ if (proc && !proc.killed) {
248
+ try {
249
+ proc.kill("SIGKILL");
250
+ } catch {
251
+ // Best-effort cleanup.
252
+ }
253
+ }
254
+ try {
255
+ browser.disconnect();
256
+ } catch {
257
+ // Best-effort cleanup.
258
+ }
259
+ }
260
+
237
261
  export interface BuildChromeArgsOptions {
238
262
  width: number;
239
263
  height: number;
240
264
  captureMode?: CaptureMode;
265
+ platform?: NodeJS.Platform;
241
266
  }
242
267
 
268
+ const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
269
+
243
270
  export function buildChromeArgs(
244
271
  options: BuildChromeArgsOptions,
245
- config?: Partial<Pick<EngineConfig, "disableGpu" | "chromePath">>,
272
+ config?: Partial<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
246
273
  ): string[] {
274
+ const platform = options.platform ?? process.platform;
275
+ const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
276
+ const browserGpuMode = gpuDisabled
277
+ ? "software"
278
+ : (config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode);
247
279
  // Chrome flags tuned for headless rendering performance. The set below is a
248
280
  // fairly standard "headless-for-capture" configuration — similar profiles
249
281
  // appear in Puppeteer's defaults, Playwright, Remotion, and Chrome's own
@@ -252,10 +284,10 @@ export function buildChromeArgs(
252
284
  "--no-sandbox",
253
285
  "--disable-setuid-sandbox",
254
286
  "--disable-dev-shm-usage",
287
+ CANVAS_DRAW_ELEMENT_FEATURE_FLAG,
255
288
  "--enable-webgl",
256
289
  "--ignore-gpu-blocklist",
257
- "--use-gl=angle",
258
- "--use-angle=swiftshader",
290
+ ...getBrowserGpuArgs(browserGpuMode, platform),
259
291
  "--font-render-hinting=none",
260
292
  "--force-color-profile=srgb",
261
293
  `--window-size=${options.width},${options.height}`,
@@ -301,9 +333,28 @@ export function buildChromeArgs(
301
333
  );
302
334
  }
303
335
 
304
- const gpuDisabled = config?.disableGpu ?? DEFAULT_CONFIG.disableGpu;
305
336
  if (gpuDisabled) {
306
337
  chromeArgs.push("--disable-gpu");
307
338
  }
308
339
  return chromeArgs;
309
340
  }
341
+
342
+ function getBrowserGpuArgs(
343
+ mode: EngineConfig["browserGpuMode"],
344
+ platform: NodeJS.Platform,
345
+ ): string[] {
346
+ if (mode === "software") {
347
+ return ["--use-gl=angle", "--use-angle=swiftshader"];
348
+ }
349
+
350
+ switch (platform) {
351
+ case "darwin":
352
+ return ["--use-gl=angle", "--use-angle=metal", "--enable-gpu-rasterization"];
353
+ case "win32":
354
+ return ["--use-gl=angle", "--use-angle=d3d11", "--enable-gpu-rasterization"];
355
+ case "linux":
356
+ return ["--use-gl=egl", "--enable-gpu-rasterization"];
357
+ default:
358
+ return ["--enable-gpu-rasterization"];
359
+ }
360
+ }
@@ -139,12 +139,43 @@ export function buildEncoderArgs(
139
139
  else args.push("-global_quality", String(quality));
140
140
  break;
141
141
  }
142
+
143
+ // Same B-frame story as the SW branch below — nvenc emits B-frames
144
+ // by default (qsv via b_strategy, vaapi too), and the negative-DTS
145
+ // freeze hits the same downstream players. The unconditional
146
+ // `-avoid_negative_ts make_zero` near the bottom of this function
147
+ // covers the mux level, but we belt-and-suspenders the encoder too
148
+ // so even tools that consume the chunk file directly (without going
149
+ // through our mux step) play correctly. videotoolbox doesn't accept
150
+ // `-bf` so it's skipped — videotoolbox h264 also doesn't emit
151
+ // negative DTS in practice on macOS Sonoma+.
152
+ if (
153
+ codec === "h264" &&
154
+ (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
155
+ ) {
156
+ args.push("-bf", "0");
157
+ if (gpuEncoder === "qsv") {
158
+ args.push("-b_strategy", "0");
159
+ }
160
+ }
142
161
  } else {
143
162
  const encoderName = codec === "h264" ? "libx264" : "libx265";
144
163
  args.push("-c:v", encoderName, "-preset", preset);
145
164
  if (bitrate) args.push("-b:v", bitrate);
146
165
  else args.push("-crf", String(quality));
147
166
 
167
+ // Disable B-frames. Standard h264 with B-frames produces negative DTS
168
+ // at the start of the stream (the first B-frame's decode order is
169
+ // "before" the first I-frame's presentation time). VS Code's video
170
+ // preview, several browser <video> pipelines, and some HW decoders
171
+ // freeze on the first frame when DTS is negative, so audio plays alone.
172
+ // -bf 0 makes PTS == DTS at every frame, eliminating the issue at the
173
+ // source. Quality cost is ~5–10% larger files at the same CRF — a
174
+ // worthwhile trade for "the file plays everywhere".
175
+ if (codec === "h264") {
176
+ args.push("-bf", "0");
177
+ }
178
+
148
179
  // Encoder-specific params: anti-banding + color space tagging.
149
180
  // aq-mode=3 redistributes bits to dark flat areas (gradients).
150
181
  // For HDR x265 paths we additionally embed BT.2020 + transfer + HDR static
@@ -239,6 +270,8 @@ export function buildEncoderArgs(
239
270
  args.push("-pix_fmt", pixelFormat);
240
271
  }
241
272
 
273
+ args.push("-avoid_negative_ts", "make_zero");
274
+
242
275
  args.push("-y", outputPath);
243
276
  return args;
244
277
  }
@@ -510,6 +543,9 @@ export async function muxVideoWithAudio(
510
543
  } else {
511
544
  args.push("-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart");
512
545
  }
546
+ // PTS bases can diverge during mux and reintroduce negative DTS. See
547
+ // buildEncoderArgs for the full reasoning on why that breaks playback.
548
+ args.push("-avoid_negative_ts", "make_zero");
513
549
  args.push("-shortest", "-y", outputPath);
514
550
 
515
551
  const processTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
@@ -10,6 +10,7 @@ import { Hono } from "hono";
10
10
  import { serve } from "@hono/node-server";
11
11
  import { readFileSync, existsSync, statSync } from "node:fs";
12
12
  import { join, extname } from "node:path";
13
+ import { injectScriptsIntoHtml } from "@hyperframes/core/compiler";
13
14
 
14
15
  const MIME_TYPES: Record<string, string> = {
15
16
  ".html": "text/html; charset=utf-8",
@@ -34,74 +35,6 @@ const MIME_TYPES: Record<string, string> = {
34
35
  ".otf": "font/otf",
35
36
  };
36
37
 
37
- function stripEmbeddedRuntimeScripts(html: string): string {
38
- if (!html) return html;
39
- const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
40
- const runtimeSrcMarkers = [
41
- "hyperframe.runtime.iife.js",
42
- "hyperframes-runtime.modular.inline.js",
43
- "data-hyperframes-preview-runtime",
44
- ];
45
- const runtimeInlineMarkers = [
46
- "__hyperframeRuntimeBootstrapped",
47
- "__hyperframeRuntime",
48
- "__hyperframeRuntimeTeardown",
49
- "window.__player =",
50
- "window.__playerReady",
51
- "window.__renderReady",
52
- ];
53
-
54
- const shouldStrip = (block: string): boolean => {
55
- const lowered = block.toLowerCase();
56
- for (const marker of runtimeSrcMarkers) {
57
- if (lowered.includes(marker.toLowerCase())) {
58
- return true;
59
- }
60
- }
61
- for (const marker of runtimeInlineMarkers) {
62
- if (block.includes(marker)) {
63
- return true;
64
- }
65
- }
66
- return false;
67
- };
68
-
69
- return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block));
70
- }
71
-
72
- function injectScriptsIntoHtml(
73
- html: string,
74
- headScripts: string[],
75
- bodyScripts: string[],
76
- stripEmbedded: boolean,
77
- ): string {
78
- if (stripEmbedded) {
79
- html = stripEmbeddedRuntimeScripts(html);
80
- }
81
-
82
- if (headScripts.length > 0) {
83
- const headTags = headScripts.map((src) => `<script>${src}</script>`).join("\n");
84
- if (html.includes("</head>")) {
85
- html = html.replace("</head>", () => `${headTags}\n</head>`);
86
- } else if (html.includes("<body")) {
87
- html = html.replace("<body", () => `${headTags}\n<body`);
88
- } else {
89
- html = headTags + "\n" + html;
90
- }
91
- }
92
-
93
- if (bodyScripts.length > 0) {
94
- const bodyTags = bodyScripts.map((src) => `<script>${src}</script>`).join("\n");
95
- if (html.includes("</body>")) {
96
- html = html.replace("</body>", () => `${bodyTags}\n</body>`);
97
- } else {
98
- html = html + "\n" + bodyTags;
99
- }
100
- }
101
-
102
- return html;
103
- }
104
-
105
38
  export interface FileServerOptions {
106
39
  projectDir: string;
107
40
  compiledDir?: string;
@@ -16,6 +16,7 @@ import { quantizeTimeToFrame } from "@hyperframes/core";
16
16
  import {
17
17
  acquireBrowser,
18
18
  releaseBrowser,
19
+ forceReleaseBrowser,
19
20
  buildChromeArgs,
20
21
  resolveHeadlessShellPath,
21
22
  type CaptureMode,
@@ -29,6 +30,7 @@ import {
29
30
  import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
30
31
  import type {
31
32
  CaptureOptions,
33
+ CaptureVideoMetadataHint,
32
34
  CaptureResult,
33
35
  CaptureBufferResult,
34
36
  CapturePerfSummary,
@@ -73,6 +75,26 @@ export interface CaptureSession {
73
75
  // Circular buffer for browser console messages dumped on render failure diagnostics.
74
76
  // Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
75
77
  const BROWSER_CONSOLE_BUFFER_SIZE = 200;
78
+ const CAPTURE_SESSION_CLOSE_TIMEOUT_MS = 5_000;
79
+
80
+ async function waitForCloseWithTimeout(promise: Promise<unknown>): Promise<boolean> {
81
+ let timedOut = false;
82
+ let timer: ReturnType<typeof setTimeout> | undefined;
83
+ await Promise.race([
84
+ promise.then(
85
+ () => undefined,
86
+ () => undefined,
87
+ ),
88
+ new Promise<void>((resolve) => {
89
+ timer = setTimeout(() => {
90
+ timedOut = true;
91
+ resolve();
92
+ }, CAPTURE_SESSION_CLOSE_TIMEOUT_MS);
93
+ }),
94
+ ]);
95
+ if (timer) clearTimeout(timer);
96
+ return !timedOut;
97
+ }
76
98
 
77
99
  export async function createCaptureSession(
78
100
  serverUrl: string,
@@ -133,6 +155,22 @@ export async function createCaptureSession(
133
155
  w.__name = <T>(fn: T, _name: string): T => fn;
134
156
  }
135
157
  });
158
+ // Inject render-time variable overrides before any page script runs, so the
159
+ // runtime helper `getVariables()` returns the merged result on its first
160
+ // call. Pass the JSON string and parse inside the page so we don't require
161
+ // any JSON-incompatible value to round-trip through Puppeteer's serializer.
162
+ if (options.variables && Object.keys(options.variables).length > 0) {
163
+ const variablesJson = JSON.stringify(options.variables);
164
+ await page.evaluateOnNewDocument((json: string) => {
165
+ type WindowWithVariables = Window & { __hfVariables?: Record<string, unknown> };
166
+ try {
167
+ (window as WindowWithVariables).__hfVariables = JSON.parse(json);
168
+ } catch {
169
+ // The CLI validated the JSON before this point — a parse failure here
170
+ // means the page swapped JSON.parse, which is the page's problem.
171
+ }
172
+ }, variablesJson);
173
+ }
136
174
  const browserVersion = await browser.version();
137
175
  const expectedMajor = config?.expectedChromiumMajor;
138
176
  if (Number.isFinite(expectedMajor)) {
@@ -221,6 +259,64 @@ async function pollPageExpression(
221
259
  return Boolean(await page.evaluate(expression));
222
260
  }
223
261
 
262
+ async function applyVideoMetadataHints(
263
+ page: Page,
264
+ hints: readonly CaptureVideoMetadataHint[] | undefined,
265
+ ): Promise<void> {
266
+ if (!hints || hints.length === 0) return;
267
+
268
+ await page.evaluate(
269
+ (metadataHints: CaptureVideoMetadataHint[]) => {
270
+ for (const hint of metadataHints) {
271
+ if (
272
+ !hint.id ||
273
+ !Number.isFinite(hint.width) ||
274
+ !Number.isFinite(hint.height) ||
275
+ hint.width <= 0 ||
276
+ hint.height <= 0
277
+ ) {
278
+ continue;
279
+ }
280
+
281
+ const video = document.getElementById(hint.id) as HTMLVideoElement | null;
282
+ if (!video) continue;
283
+
284
+ if (!video.hasAttribute("width")) video.setAttribute("width", String(hint.width));
285
+ if (!video.hasAttribute("height")) video.setAttribute("height", String(hint.height));
286
+
287
+ const computed = window.getComputedStyle(video);
288
+ if (
289
+ !video.style.aspectRatio &&
290
+ (!computed.aspectRatio || computed.aspectRatio === "auto")
291
+ ) {
292
+ video.style.aspectRatio = `${hint.width} / ${hint.height}`;
293
+ }
294
+ }
295
+ },
296
+ [...hints],
297
+ );
298
+ }
299
+
300
+ async function waitForOptionalTailwindReady(page: Page, timeoutMs: number): Promise<void> {
301
+ const hasTailwindReady = await page.evaluate(
302
+ `(() => { const ready = window.__tailwindReady; return !!ready && typeof ready.then === "function"; })()`,
303
+ );
304
+ if (!hasTailwindReady) return;
305
+
306
+ const ready = await Promise.race([
307
+ page.evaluate(
308
+ `Promise.resolve(window.__tailwindReady).then(() => true, () => false)`,
309
+ ) as Promise<boolean>,
310
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
311
+ ]);
312
+
313
+ if (!ready) {
314
+ throw new Error(
315
+ `[FrameCapture] window.__tailwindReady not resolved after ${timeoutMs}ms. Tailwind browser runtime must finish before frame capture starts.`,
316
+ );
317
+ }
318
+ }
319
+
224
320
  export async function initializeSession(session: CaptureSession): Promise<void> {
225
321
  const { page, serverUrl } = session;
226
322
 
@@ -290,24 +386,34 @@ export async function initializeSession(session: CaptureSession): Promise<void>
290
386
  );
291
387
  }
292
388
 
293
- // Wait for all video elements to have loaded metadata (dimensions + duration)
294
- // Without this, frame 0 captures videos at their 300x150 default size.
389
+ await applyVideoMetadataHints(page, session.options.videoMetadataHints);
390
+
391
+ // Wait for all video elements to have decoded their CURRENT frame, not
392
+ // just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is
393
+ // actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we
394
+ // only know the dimensions, and the first <video> screenshot can come
395
+ // back as a black/blank rectangle. This bites compositions with two
396
+ // <video> elements of different codecs (h264 mp4 + VP9 webm) where the
397
+ // faster decoder lets the readiness check pass while the slower one
398
+ // hasn't painted, producing a black "first frame" for the slower clip.
295
399
  // skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
296
- // sources) whose frames come from ffmpeg out-of-band — Chromium may not be
297
- // able to decode them at all (e.g. HEVC on Linux headless-shell).
400
+ // sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
401
+ // supply intrinsic dimensions for skipped videos whose layout depends on
402
+ // aspect ratio, while Chromium may still fail to decode/load metadata.
298
403
  const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
299
404
  const videosReady = await pollPageExpression(
300
405
  page,
301
- `(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
406
+ `(() => { const skip = new Set(${skipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
302
407
  pageReadyTimeout,
303
408
  );
304
409
  if (!videosReady) {
305
410
  throw new Error(
306
- `[FrameCapture] video metadata not ready after ${pageReadyTimeout}ms. Video elements must load metadata before capture starts.`,
411
+ `[FrameCapture] video first frame not decoded after ${pageReadyTimeout}ms. Video elements must reach readyState >= 2 (HAVE_CURRENT_DATA) before capture starts.`,
307
412
  );
308
413
  }
309
414
 
310
415
  await page.evaluate(`document.fonts?.ready`);
416
+ await waitForOptionalTailwindReady(page, pageReadyTimeout);
311
417
 
312
418
  // For PNG captures, force the page background fully transparent so the
313
419
  // captured screenshots carry a real alpha channel. Must run AFTER
@@ -382,15 +488,15 @@ export async function initializeSession(session: CaptureSession): Promise<void>
382
488
  );
383
489
  }
384
490
 
385
- // Wait for all video elements to have loaded metadata (dimensions + duration).
386
- // Without this, frame 0 captures videos at their 300x150 default size.
387
- // See screenshot-mode comment above for why skipReadinessVideoIds exists.
491
+ await applyVideoMetadataHints(page, session.options.videoMetadataHints);
492
+
493
+ // Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
388
494
  const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
389
495
  const videoDeadline =
390
496
  Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
391
497
  while (Date.now() < videoDeadline) {
392
498
  const videosReady = await page.evaluate(
393
- `(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 1); })()`,
499
+ `(() => { const skip = new Set(${beginframeSkipIdsLiteral}); const vids = Array.from(document.querySelectorAll("video")).filter(v => !skip.has(v.id)); return vids.length === 0 || vids.every(v => v.readyState >= 2); })()`,
394
500
  );
395
501
  if (videosReady) break;
396
502
  await new Promise((r) => setTimeout(r, 100));
@@ -398,6 +504,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
398
504
 
399
505
  // Font check (no rAF dependency — uses fonts.ready API directly)
400
506
  await page.evaluate(`document.fonts?.ready`);
507
+ await waitForOptionalTailwindReady(page, pageReadyTimeout);
401
508
 
402
509
  // Stop warmup
403
510
  warmupRunning = false;
@@ -607,11 +714,22 @@ export async function closeCaptureSession(session: CaptureSession): Promise<void
607
714
  // but browserReleased=false → second call no-ops on page and retries browser.
608
715
  // This matches the orchestrator's intent for HDR cleanup.
609
716
  if (!session.pageReleased && session.page) {
610
- await session.page.close().catch(() => {});
717
+ const pageClosed = await waitForCloseWithTimeout(session.page.close());
718
+ if (!pageClosed) {
719
+ console.warn("[FrameCapture] Timed out closing page; forcing browser process shutdown");
720
+ forceReleaseBrowser(session.browser);
721
+ session.browserReleased = true;
722
+ }
611
723
  session.pageReleased = true;
612
724
  }
613
725
  if (!session.browserReleased && session.browser) {
614
- await releaseBrowser(session.browser, session.config);
726
+ const browserClosed = await waitForCloseWithTimeout(
727
+ releaseBrowser(session.browser, session.config),
728
+ );
729
+ if (!browserClosed) {
730
+ console.warn("[FrameCapture] Timed out closing browser; forcing browser process shutdown");
731
+ forceReleaseBrowser(session.browser);
732
+ }
615
733
  session.browserReleased = true;
616
734
  }
617
735
  session.isInitialized = false;
@@ -446,13 +446,15 @@ export async function injectVideoFramesBatch(
446
446
  }
447
447
  }
448
448
  img.decoding = "sync";
449
- img.src = item.dataUri;
450
- pendingDecodes.push(
451
- img
452
- .decode()
453
- .catch(() => undefined)
454
- .then(() => undefined),
455
- );
449
+ if (img.getAttribute("src") !== item.dataUri) {
450
+ img.src = item.dataUri;
451
+ pendingDecodes.push(
452
+ img
453
+ .decode()
454
+ .catch(() => undefined)
455
+ .then(() => undefined),
456
+ );
457
+ }
456
458
  img.style.opacity = String(computedOpacity);
457
459
  img.style.visibility = "visible";
458
460
  // Hide the native <video> with visibility only — never clobber inline
@@ -221,12 +221,33 @@ export function buildStreamingArgs(
221
221
  else args.push("-global_quality", String(quality));
222
222
  break;
223
223
  }
224
+
225
+ // Mirror SW branch: GPU h264 paths emit B-frames by default (nvenc, qsv,
226
+ // vaapi) and produce the same negative-DTS freeze for downstream players.
227
+ // See chunkEncoder.buildEncoderArgs for the full explanation.
228
+ if (
229
+ codec === "h264" &&
230
+ (gpuEncoder === "nvenc" || gpuEncoder === "qsv" || gpuEncoder === "vaapi")
231
+ ) {
232
+ args.push("-bf", "0");
233
+ if (gpuEncoder === "qsv") {
234
+ args.push("-b_strategy", "0");
235
+ }
236
+ }
224
237
  } else {
225
238
  const encoderName = codec === "h264" ? "libx264" : "libx265";
226
239
  args.push("-c:v", encoderName, "-preset", preset);
227
240
  if (bitrate) args.push("-b:v", bitrate);
228
241
  else args.push("-crf", String(quality));
229
242
 
243
+ // Mirrors chunkEncoder: disable B-frames for h264 so PTS == DTS, no
244
+ // negative DTS at stream start. Without this, files freeze on the
245
+ // first frame in VS Code preview, several browsers, and some HW
246
+ // decoders. See chunkEncoder.buildEncoderArgs for the full reasoning.
247
+ if (codec === "h264") {
248
+ args.push("-bf", "0");
249
+ }
250
+
230
251
  // Encoder-specific params: anti-banding + color space tagging.
231
252
  // For HDR, getHdrEncoderColorParams also emits the SMPTE ST 2086
232
253
  // mastering-display and CTA-861.3 MaxCLL/MaxFALL SEI messages —
@@ -313,6 +334,10 @@ export function buildStreamingArgs(
313
334
  args.push("-pix_fmt", pixelFormat);
314
335
  }
315
336
 
337
+ // Belt-and-suspenders against negative DTS at stream start. See chunkEncoder
338
+ // for the full explanation; same playback compatibility class.
339
+ args.push("-avoid_negative_ts", "make_zero");
340
+
316
341
  args.push("-y", outputPath);
317
342
  return args;
318
343
  }
@@ -1,5 +1,13 @@
1
1
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
3
11
  import { createHash } from "node:crypto";
4
12
  import { join } from "node:path";
5
13
  import { tmpdir } from "node:os";
@@ -9,6 +17,9 @@ import {
9
17
  parseImageElements,
10
18
  extractAllVideoFrames,
11
19
  createFrameLookupTable,
20
+ resolveProjectRelativeSrc,
21
+ codecMayHaveAlpha,
22
+ decoderForCodec,
12
23
  type VideoElement,
13
24
  type ExtractedFrames,
14
25
  } from "./videoFrameExtractor.js";
@@ -23,6 +34,111 @@ import { runFfmpeg } from "../utils/runFfmpeg.js";
23
34
  // synthesized VFR fixture.
24
35
  const HAS_FFMPEG = spawnSync("ffmpeg", ["-version"]).status === 0;
25
36
 
37
+ // Codec-based alpha defaulting replaces tag-based detection (the
38
+ // alpha_mode/ALPHA_MODE case bug — see ffprobe.test.ts for the regression
39
+ // pin on that). The extractor uses these helpers for two decisions:
40
+ // 1. whether to force the alpha-aware decoder (libvpx-vp9 for VP9, libvpx
41
+ // for VP8)
42
+ // 2. whether to default the cached frame format to PNG (with alpha) vs JPG
43
+ // The "default to capable" trade is small file-size growth on opaque VP9
44
+ // content for correctness on alpha-having content even when the sidecar tag
45
+ // is missing or muxed with the wrong case.
46
+ describe("codec alpha capability", () => {
47
+ it("flags VP9, VP8, and ProRes as alpha-capable", () => {
48
+ expect(codecMayHaveAlpha("vp9")).toBe(true);
49
+ expect(codecMayHaveAlpha("VP9")).toBe(true);
50
+ expect(codecMayHaveAlpha("vp8")).toBe(true);
51
+ expect(codecMayHaveAlpha("prores")).toBe(true);
52
+ });
53
+
54
+ it("does not flag h264 / h265 / mpeg4 (no alpha in their bitstreams)", () => {
55
+ expect(codecMayHaveAlpha("h264")).toBe(false);
56
+ expect(codecMayHaveAlpha("h265")).toBe(false);
57
+ expect(codecMayHaveAlpha("hevc")).toBe(false);
58
+ expect(codecMayHaveAlpha("mpeg4")).toBe(false);
59
+ });
60
+
61
+ it("treats undefined / empty input as non-alpha", () => {
62
+ expect(codecMayHaveAlpha(undefined)).toBe(false);
63
+ expect(codecMayHaveAlpha("")).toBe(false);
64
+ });
65
+
66
+ it("returns the alpha-aware decoder name for VP9 and VP8", () => {
67
+ expect(decoderForCodec("vp9")).toBe("libvpx-vp9");
68
+ expect(decoderForCodec("VP9")).toBe("libvpx-vp9");
69
+ expect(decoderForCodec("vp8")).toBe("libvpx");
70
+ });
71
+ });
72
+
73
+ // Regression: a long-standing footgun where `<video src="../assets/foo">`
74
+ // inside a sub-composition silently dropped the video from extraction. The
75
+ // browser's URL resolver clamps `..` at the served origin's root (so the
76
+ // page renders fine in the studio), but `path.join(projectDir, "../assets/foo")`
77
+ // normalizes to <parentOfProjectDir>/assets/foo, which doesn't exist —
78
+ // extraction skipped, no frame injection, rendered output shows the video's
79
+ // first decoded frame for the whole clip duration. The resolver now mirrors
80
+ // browser semantics by clamping any traversal that escapes the project root.
81
+ describe("resolveProjectRelativeSrc — sub-composition path clamping", () => {
82
+ let tmp: string;
83
+
84
+ beforeAll(() => {
85
+ tmp = mkdtempSync(join(tmpdir(), "hf-resolver-"));
86
+ mkdirSync(join(tmp, "project", "assets"), { recursive: true });
87
+ writeFileSync(join(tmp, "project", "assets", "foo.mp4"), "");
88
+ });
89
+ afterAll(() => {
90
+ rmSync(tmp, { recursive: true, force: true });
91
+ });
92
+
93
+ it("returns the literal join when the file exists at projectDir/src", () => {
94
+ const projectDir = join(tmp, "project");
95
+ expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir)).toBe(
96
+ join(projectDir, "assets/foo.mp4"),
97
+ );
98
+ });
99
+
100
+ it("clamps a leading `../` so `../assets/foo.mp4` resolves to assets/foo.mp4", () => {
101
+ const projectDir = join(tmp, "project");
102
+ expect(resolveProjectRelativeSrc("../assets/foo.mp4", projectDir)).toBe(
103
+ join(projectDir, "assets/foo.mp4"),
104
+ );
105
+ });
106
+
107
+ it("clamps multiple leading `../../../` segments", () => {
108
+ const projectDir = join(tmp, "project");
109
+ expect(resolveProjectRelativeSrc("../../../assets/foo.mp4", projectDir)).toBe(
110
+ join(projectDir, "assets/foo.mp4"),
111
+ );
112
+ });
113
+
114
+ it("clamps mid-path traversal that escapes baseDir (not just leading `..`)", () => {
115
+ // `assets/../../foo.mp4` collapses past projectDir via path.join — this
116
+ // case used to silently escape; the resolver now strips embedded `..`
117
+ // segments and re-anchors at the project root.
118
+ const projectDir = join(tmp, "project");
119
+ expect(resolveProjectRelativeSrc("assets/../../assets/foo.mp4", projectDir)).toBe(
120
+ join(projectDir, "assets/foo.mp4"),
121
+ );
122
+ });
123
+
124
+ it("returns the (non-existent) base-dir path on miss so callers get a stable error message", () => {
125
+ const projectDir = join(tmp, "project");
126
+ expect(resolveProjectRelativeSrc("../assets/missing.mp4", projectDir)).toBe(
127
+ join(projectDir, "../assets/missing.mp4"),
128
+ );
129
+ });
130
+
131
+ it("prefers compiled-dir over base-dir when the file exists in both", () => {
132
+ const projectDir = join(tmp, "project");
133
+ const compiledDir = join(tmp, "compiled");
134
+ mkdirSync(join(compiledDir, "assets"), { recursive: true });
135
+ writeFileSync(join(compiledDir, "assets", "foo.mp4"), "");
136
+ expect(resolveProjectRelativeSrc("assets/foo.mp4", projectDir, compiledDir)).toBe(
137
+ join(compiledDir, "assets/foo.mp4"),
138
+ );
139
+ });
140
+ });
141
+
26
142
  describe("parseVideoElements", () => {
27
143
  it("parses videos without an id or data-start attribute", () => {
28
144
  const videos = parseVideoElements('<video src="clip.mp4"></video>');