@hyperframes/engine 0.6.0-alpha.1 → 0.6.0-alpha.10

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 (62) hide show
  1. package/dist/config.d.ts +22 -3
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +3 -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 +2 -2
  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 +35 -0
  13. package/dist/services/browserManager.d.ts.map +1 -1
  14. package/dist/services/browserManager.js +113 -1
  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/frameCapture.d.ts.map +1 -1
  20. package/dist/services/frameCapture.js +26 -12
  21. package/dist/services/frameCapture.js.map +1 -1
  22. package/dist/services/screenshotService.d.ts.map +1 -1
  23. package/dist/services/screenshotService.js +7 -0
  24. package/dist/services/screenshotService.js.map +1 -1
  25. package/dist/services/streamingEncoder.d.ts.map +1 -1
  26. package/dist/services/streamingEncoder.js +22 -0
  27. package/dist/services/streamingEncoder.js.map +1 -1
  28. package/dist/services/videoFrameExtractor.d.ts +20 -0
  29. package/dist/services/videoFrameExtractor.d.ts.map +1 -1
  30. package/dist/services/videoFrameExtractor.js +95 -7
  31. package/dist/services/videoFrameExtractor.js.map +1 -1
  32. package/dist/services/videoFrameInjector.d.ts +40 -1
  33. package/dist/services/videoFrameInjector.d.ts.map +1 -1
  34. package/dist/services/videoFrameInjector.js +64 -9
  35. package/dist/services/videoFrameInjector.js.map +1 -1
  36. package/dist/utils/alphaBlit.d.ts +1 -1
  37. package/dist/utils/alphaBlit.d.ts.map +1 -1
  38. package/dist/utils/alphaBlit.js +15 -6
  39. package/dist/utils/alphaBlit.js.map +1 -1
  40. package/dist/utils/ffprobe.d.ts.map +1 -1
  41. package/dist/utils/ffprobe.js +17 -1
  42. package/dist/utils/ffprobe.js.map +1 -1
  43. package/package.json +2 -2
  44. package/src/config.test.ts +7 -0
  45. package/src/config.ts +31 -4
  46. package/src/index.ts +2 -0
  47. package/src/services/audioMixer.ts +4 -6
  48. package/src/services/browserManager.test.ts +83 -2
  49. package/src/services/browserManager.ts +130 -1
  50. package/src/services/chunkEncoder.ts +36 -0
  51. package/src/services/frameCapture.ts +26 -11
  52. package/src/services/screenshotService.test.ts +92 -0
  53. package/src/services/screenshotService.ts +8 -0
  54. package/src/services/streamingEncoder.ts +28 -0
  55. package/src/services/videoFrameExtractor.test.ts +117 -1
  56. package/src/services/videoFrameExtractor.ts +100 -7
  57. package/src/services/videoFrameInjector.test.ts +145 -0
  58. package/src/services/videoFrameInjector.ts +89 -11
  59. package/src/utils/alphaBlit.test.ts +10 -0
  60. package/src/utils/alphaBlit.ts +15 -7
  61. package/src/utils/ffprobe.test.ts +40 -0
  62. package/src/utils/ffprobe.ts +16 -1
@@ -136,6 +136,119 @@ async function probeBeginFrameSupport(browser: Browser): Promise<boolean> {
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
141
+ *
142
+ * Caching the Promise (rather than the resolved value) deduplicates concurrent
143
+ * callers — the parallel coordinator runs N workers via `Promise.all`, so a
144
+ * `--workers 4` render against a no-GPU host would otherwise fire 4
145
+ * simultaneous probe Chromes. The first call assigns the Promise and every
146
+ * other concurrent caller awaits the same one, paying the ~240 ms probe cost
147
+ * exactly once per process lifetime.
148
+ *
149
+ * Exported for tests; production callers go through `resolveBrowserGpuMode`.
150
+ */
151
+ export let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;
152
+
153
+ /** Test-only: reset the cached probe result. */
154
+ export function _resetAutoBrowserGpuModeCacheForTests(): void {
155
+ _autoBrowserGpuModeCache = undefined;
156
+ }
157
+
158
+ /**
159
+ * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
160
+ *
161
+ * For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"`
162
+ * it launches a tiny Chrome with the platform's hardware GPU args, runs a
163
+ * one-shot WebGL availability probe, and falls back to `"software"` if
164
+ * hardware-mode WebGL is unavailable. The Promise is cached for the process
165
+ * lifetime, so concurrent callers (parallel workers) share the same probe.
166
+ *
167
+ * Any failure (Chrome launch error, navigation timeout, missing canvas API,
168
+ * etc.) is treated as a `"software"` fallback. The render path with
169
+ * SwiftShader always works, so a misclassification toward software is the
170
+ * safe failure mode; misclassifying toward hardware would error on the real
171
+ * render.
172
+ */
173
+ export function resolveBrowserGpuMode(
174
+ mode: EngineConfig["browserGpuMode"],
175
+ options: {
176
+ chromePath?: string;
177
+ browserTimeout?: number;
178
+ platform?: NodeJS.Platform;
179
+ } = {},
180
+ ): Promise<"software" | "hardware"> {
181
+ if (mode !== "auto") return Promise.resolve(mode);
182
+ if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache;
183
+
184
+ _autoBrowserGpuModeCache = (async () => {
185
+ const platform = options.platform ?? process.platform;
186
+ const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
187
+ const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
188
+
189
+ const probeArgs = [
190
+ "--no-sandbox",
191
+ "--disable-setuid-sandbox",
192
+ "--disable-dev-shm-usage",
193
+ "--enable-webgl",
194
+ "--ignore-gpu-blocklist",
195
+ ...getBrowserGpuArgs("hardware", platform),
196
+ ];
197
+
198
+ const ppt = await getPuppeteer().catch(() => null);
199
+ if (!ppt) {
200
+ logResolvedBrowserGpuMode("software", "puppeteer unavailable");
201
+ return "software" as const;
202
+ }
203
+
204
+ let probeBrowser: Browser | undefined;
205
+ try {
206
+ probeBrowser = await ppt.launch({
207
+ headless: true,
208
+ args: probeArgs,
209
+ defaultViewport: { width: 64, height: 64 },
210
+ executablePath,
211
+ timeout: browserTimeout,
212
+ });
213
+ const page = await probeBrowser.newPage();
214
+ const hasWebGL = await page.evaluate(() => {
215
+ try {
216
+ const c = document.createElement("canvas");
217
+ const gl =
218
+ c.getContext("webgl") ||
219
+ (c.getContext("experimental-webgl") as RenderingContext | null);
220
+ return gl !== null;
221
+ } catch {
222
+ return false;
223
+ }
224
+ });
225
+ const resolved = hasWebGL ? ("hardware" as const) : ("software" as const);
226
+ logResolvedBrowserGpuMode(resolved, hasWebGL ? "WebGL probe succeeded" : "WebGL unavailable");
227
+ return resolved;
228
+ } catch (err) {
229
+ logResolvedBrowserGpuMode(
230
+ "software",
231
+ `probe failed (${err instanceof Error ? err.message : String(err)})`,
232
+ );
233
+ return "software" as const;
234
+ } finally {
235
+ await probeBrowser?.close().catch(() => {});
236
+ }
237
+ })();
238
+
239
+ return _autoBrowserGpuModeCache;
240
+ }
241
+
242
+ /**
243
+ * Single observability surface for the auto-detect outcome. Logged exactly
244
+ * once per process (the probe runs once); without this line, a regression
245
+ * to "always software even with a GPU present" would be invisible in
246
+ * production. Goes to stderr to stay out of stdout pipelines.
247
+ */
248
+ function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void {
249
+ console.error(`[hyperframes] browserGpuMode auto → ${resolved} (${reason})`);
250
+ }
251
+
139
252
  export async function acquireBrowser(
140
253
  chromeArgs: string[],
141
254
  config?: Partial<
@@ -265,6 +378,8 @@ export interface BuildChromeArgsOptions {
265
378
  platform?: NodeJS.Platform;
266
379
  }
267
380
 
381
+ const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
382
+
268
383
  export function buildChromeArgs(
269
384
  options: BuildChromeArgsOptions,
270
385
  config?: Partial<Pick<EngineConfig, "browserGpuMode" | "disableGpu" | "chromePath">>,
@@ -282,6 +397,7 @@ export function buildChromeArgs(
282
397
  "--no-sandbox",
283
398
  "--disable-setuid-sandbox",
284
399
  "--disable-dev-shm-usage",
400
+ CANVAS_DRAW_ELEMENT_FEATURE_FLAG,
285
401
  "--enable-webgl",
286
402
  "--ignore-gpu-blocklist",
287
403
  ...getBrowserGpuArgs(browserGpuMode, platform),
@@ -341,7 +457,20 @@ function getBrowserGpuArgs(
341
457
  platform: NodeJS.Platform,
342
458
  ): string[] {
343
459
  if (mode === "software") {
344
- return ["--use-gl=angle", "--use-angle=swiftshader"];
460
+ // Chrome 120+ deprecated implicit SwiftShader fallback; the explicit
461
+ // path (--use-angle=swiftshader) keeps working but Chrome emits a
462
+ // deprecation warning unless --enable-unsafe-swiftshader is also set.
463
+ // Despite the name, this is exactly the behaviour Chrome had before;
464
+ // the flag exists to make CPU rasterisation an explicit opt-in rather
465
+ // than an implicit fallback for end users on the open web.
466
+ return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
467
+ }
468
+
469
+ if (mode === "auto") {
470
+ // Should not reach here — `resolveBrowserGpuMode` collapses "auto" to
471
+ // "software" or "hardware" before args are built. Be defensive: software
472
+ // is the always-safe fallback.
473
+ return ["--use-gl=angle", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"];
345
474
  }
346
475
 
347
476
  switch (platform) {
@@ -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;
@@ -18,6 +18,7 @@ import {
18
18
  releaseBrowser,
19
19
  forceReleaseBrowser,
20
20
  buildChromeArgs,
21
+ resolveBrowserGpuMode,
21
22
  resolveHeadlessShellPath,
22
23
  type CaptureMode,
23
24
  } from "./browserManager.js";
@@ -113,11 +114,22 @@ export async function createCaptureSession(
113
114
  const headlessShell = resolveHeadlessShellPath(config);
114
115
  const isLinux = process.platform === "linux";
115
116
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
117
+ // BeginFrame's screenshot does not honor a viewport `deviceScaleFactor`
118
+ // (the captured surface is sized by the OS window in CSS pixels regardless
119
+ // of `Emulation.setDeviceMetricsOverride`'s DPR). When supersampling we
120
+ // need explicit clip+scale on `Page.captureScreenshot`, so fall back to
121
+ // the screenshot path for any DPR > 1.
122
+ const supersampling = (options.deviceScaleFactor ?? 1) > 1;
116
123
  const preMode: CaptureMode =
117
- headlessShell && isLinux && !forceScreenshot ? "beginframe" : "screenshot";
124
+ headlessShell && isLinux && !forceScreenshot && !supersampling ? "beginframe" : "screenshot";
125
+ const requestedGpuMode = config?.browserGpuMode ?? DEFAULT_CONFIG.browserGpuMode;
126
+ const resolvedGpuMode = await resolveBrowserGpuMode(requestedGpuMode, {
127
+ chromePath: headlessShell ?? undefined,
128
+ browserTimeout: config?.browserTimeout,
129
+ });
118
130
  const chromeArgs = buildChromeArgs(
119
131
  { width: options.width, height: options.height, captureMode: preMode },
120
- config,
132
+ { ...config, browserGpuMode: resolvedGpuMode },
121
133
  );
122
134
 
123
135
  const { browser, captureMode } = await acquireBrowser(chromeArgs, config);
@@ -388,8 +400,14 @@ export async function initializeSession(session: CaptureSession): Promise<void>
388
400
 
389
401
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
390
402
 
391
- // Wait for all video elements to have loaded metadata (dimensions + duration)
392
- // Without this, frame 0 captures videos at their 300x150 default size.
403
+ // Wait for all video elements to have decoded their CURRENT frame, not
404
+ // just metadata. readyState >= 2 (HAVE_CURRENT_DATA) means a frame is
405
+ // actually rasterized and ready to paint — at >= 1 (HAVE_METADATA) we
406
+ // only know the dimensions, and the first <video> screenshot can come
407
+ // back as a black/blank rectangle. This bites compositions with two
408
+ // <video> elements of different codecs (h264 mp4 + VP9 webm) where the
409
+ // faster decoder lets the readiness check pass while the slower one
410
+ // hasn't painted, producing a black "first frame" for the slower clip.
393
411
  // skipReadinessVideoIds excludes natively-extracted videos (e.g. HDR HEVC
394
412
  // sources) whose frames come from ffmpeg out-of-band. videoMetadataHints
395
413
  // supply intrinsic dimensions for skipped videos whose layout depends on
@@ -397,12 +415,12 @@ export async function initializeSession(session: CaptureSession): Promise<void>
397
415
  const skipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
398
416
  const videosReady = await pollPageExpression(
399
417
  page,
400
- `(() => { 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); })()`,
418
+ `(() => { 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); })()`,
401
419
  pageReadyTimeout,
402
420
  );
403
421
  if (!videosReady) {
404
422
  throw new Error(
405
- `[FrameCapture] video metadata not ready after ${pageReadyTimeout}ms. Video elements must load metadata before capture starts.`,
423
+ `[FrameCapture] video first frame not decoded after ${pageReadyTimeout}ms. Video elements must reach readyState >= 2 (HAVE_CURRENT_DATA) before capture starts.`,
406
424
  );
407
425
  }
408
426
 
@@ -484,16 +502,13 @@ export async function initializeSession(session: CaptureSession): Promise<void>
484
502
 
485
503
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
486
504
 
487
- // Wait for all video elements to have loaded metadata (dimensions + duration).
488
- // Without this, frame 0 captures videos at their 300x150 default size.
489
- // See screenshot-mode comment above for why skipReadinessVideoIds and
490
- // videoMetadataHints are paired.
505
+ // Same readyState contract as the screenshot path above (>= 2 / HAVE_CURRENT_DATA).
491
506
  const beginframeSkipIdsLiteral = JSON.stringify(session.options.skipReadinessVideoIds ?? []);
492
507
  const videoDeadline =
493
508
  Date.now() + (session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout);
494
509
  while (Date.now() < videoDeadline) {
495
510
  const videosReady = await page.evaluate(
496
- `(() => { 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); })()`,
511
+ `(() => { 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); })()`,
497
512
  );
498
513
  if (videosReady) break;
499
514
  await new Promise((r) => setTimeout(r, 100));
@@ -0,0 +1,92 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { type Page } from "puppeteer-core";
4
+ import { pageScreenshotCapture, cdpSessionCache } from "./screenshotService.js";
5
+
6
+ // Stub a Page + CDPSession just enough that pageScreenshotCapture can call
7
+ // `client.send("Page.captureScreenshot", ...)` and we can inspect the args.
8
+ function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{ data: string }>) {
9
+ const fakeSession = { send } as unknown as import("puppeteer-core").CDPSession;
10
+ // Stub a Page object — the WeakMap cache is the only Page-thing used in the
11
+ // path under test, so we can pre-seed it and skip page.createCDPSession().
12
+ const fakePage = {} as Page;
13
+ cdpSessionCache.set(fakePage, fakeSession);
14
+ return fakePage;
15
+ }
16
+
17
+ describe("pageScreenshotCapture supersample plumbing", () => {
18
+ // Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
19
+ // and we never inspect the bytes — only the params we pass to client.send.
20
+ const ONE_PIXEL_PNG_B64 =
21
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";
22
+
23
+ it("omits `clip` when deviceScaleFactor is undefined (default 1)", async () => {
24
+ const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
25
+ const page = makeFakePageWithCdp(send);
26
+
27
+ await pageScreenshotCapture(page, {
28
+ width: 1920,
29
+ height: 1080,
30
+ fps: 30,
31
+ format: "jpeg",
32
+ quality: 80,
33
+ });
34
+
35
+ expect(send).toHaveBeenCalledWith(
36
+ "Page.captureScreenshot",
37
+ expect.not.objectContaining({ clip: expect.anything() }),
38
+ );
39
+ });
40
+
41
+ it("omits `clip` when deviceScaleFactor is exactly 1", async () => {
42
+ const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
43
+ const page = makeFakePageWithCdp(send);
44
+
45
+ await pageScreenshotCapture(page, {
46
+ width: 1920,
47
+ height: 1080,
48
+ fps: 30,
49
+ format: "jpeg",
50
+ deviceScaleFactor: 1,
51
+ });
52
+
53
+ const params = send.mock.calls[0]?.[1] as { clip?: unknown };
54
+ expect(params.clip).toBeUndefined();
55
+ });
56
+
57
+ it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
58
+ const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
59
+ const page = makeFakePageWithCdp(send);
60
+
61
+ await pageScreenshotCapture(page, {
62
+ width: 1920,
63
+ height: 1080,
64
+ fps: 30,
65
+ format: "jpeg",
66
+ deviceScaleFactor: 2,
67
+ });
68
+
69
+ expect(send).toHaveBeenCalledWith(
70
+ "Page.captureScreenshot",
71
+ expect.objectContaining({
72
+ clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 2 },
73
+ }),
74
+ );
75
+ });
76
+
77
+ it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
78
+ const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
79
+ const page = makeFakePageWithCdp(send);
80
+
81
+ await pageScreenshotCapture(page, {
82
+ width: 1280,
83
+ height: 720,
84
+ fps: 30,
85
+ format: "jpeg",
86
+ deviceScaleFactor: 3,
87
+ });
88
+
89
+ const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
90
+ expect(params.clip?.scale).toBe(3);
91
+ });
92
+ });
@@ -129,12 +129,20 @@ export async function beginFrameCapture(
129
129
  export async function pageScreenshotCapture(page: Page, options: CaptureOptions): Promise<Buffer> {
130
130
  const client = await getCdpSession(page);
131
131
  const isPng = options.format === "png";
132
+ const dpr = options.deviceScaleFactor ?? 1;
133
+ // When supersampling, pass an explicit clip with `scale` so Chrome emits a
134
+ // screenshot at device-pixel dimensions (`width × height × dpr`). Without
135
+ // this, `Page.captureScreenshot` returns at CSS dimensions regardless of
136
+ // the viewport's deviceScaleFactor.
137
+ const clip =
138
+ dpr > 1 ? { x: 0, y: 0, width: options.width, height: options.height, scale: dpr } : undefined;
132
139
  const result = await client.send("Page.captureScreenshot", {
133
140
  format: isPng ? "png" : "jpeg",
134
141
  quality: isPng ? undefined : (options.quality ?? 80),
135
142
  fromSurface: true,
136
143
  captureBeyondViewport: false,
137
144
  optimizeForSpeed: !isPng,
145
+ ...(clip ? { clip } : {}),
138
146
  });
139
147
  return Buffer.from(result.data, "base64");
140
148
  }
@@ -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
  }
@@ -364,6 +389,9 @@ export async function spawnStreamingEncoder(
364
389
  exitPromiseResolve?.();
365
390
  });
366
391
 
392
+ ffmpeg.stdin?.on("error", () => {});
393
+ ffmpeg.stdout?.on("error", () => {});
394
+
367
395
  // Handle abort signal
368
396
  const onAbort = () => {
369
397
  if (exitStatus === "running") {
@@ -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>');