@hyperframes/engine 0.6.0-alpha.2 → 0.6.0-alpha.4
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.
- package/dist/config.d.ts +22 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -1
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/services/audioMixer.d.ts.map +1 -1
- package/dist/services/audioMixer.js +4 -6
- package/dist/services/audioMixer.js.map +1 -1
- package/dist/services/browserManager.d.ts +35 -0
- package/dist/services/browserManager.d.ts.map +1 -1
- package/dist/services/browserManager.js +113 -1
- package/dist/services/browserManager.js.map +1 -1
- package/dist/services/chunkEncoder.d.ts.map +1 -1
- package/dist/services/chunkEncoder.js +31 -0
- package/dist/services/chunkEncoder.js.map +1 -1
- package/dist/services/frameCapture.d.ts.map +1 -1
- package/dist/services/frameCapture.js +26 -12
- package/dist/services/frameCapture.js.map +1 -1
- package/dist/services/screenshotService.d.ts.map +1 -1
- package/dist/services/screenshotService.js +7 -0
- package/dist/services/screenshotService.js.map +1 -1
- package/dist/services/streamingEncoder.d.ts.map +1 -1
- package/dist/services/streamingEncoder.js +22 -0
- package/dist/services/streamingEncoder.js.map +1 -1
- package/dist/services/videoFrameExtractor.d.ts +20 -0
- package/dist/services/videoFrameExtractor.d.ts.map +1 -1
- package/dist/services/videoFrameExtractor.js +95 -7
- package/dist/services/videoFrameExtractor.js.map +1 -1
- package/dist/services/videoFrameInjector.d.ts +40 -1
- package/dist/services/videoFrameInjector.d.ts.map +1 -1
- package/dist/services/videoFrameInjector.js +64 -9
- package/dist/services/videoFrameInjector.js.map +1 -1
- package/dist/utils/alphaBlit.d.ts +1 -1
- package/dist/utils/alphaBlit.d.ts.map +1 -1
- package/dist/utils/alphaBlit.js +15 -6
- package/dist/utils/alphaBlit.js.map +1 -1
- package/dist/utils/ffprobe.d.ts.map +1 -1
- package/dist/utils/ffprobe.js +17 -1
- package/dist/utils/ffprobe.js.map +1 -1
- package/package.json +2 -2
- package/src/config.test.ts +7 -0
- package/src/config.ts +31 -4
- package/src/index.ts +2 -0
- package/src/services/audioMixer.ts +4 -6
- package/src/services/browserManager.test.ts +83 -2
- package/src/services/browserManager.ts +130 -1
- package/src/services/chunkEncoder.ts +36 -0
- package/src/services/frameCapture.ts +26 -11
- package/src/services/screenshotService.test.ts +92 -0
- package/src/services/screenshotService.ts +8 -0
- package/src/services/streamingEncoder.ts +28 -0
- package/src/services/videoFrameExtractor.test.ts +117 -1
- package/src/services/videoFrameExtractor.ts +100 -7
- package/src/services/videoFrameInjector.test.ts +145 -0
- package/src/services/videoFrameInjector.ts +89 -11
- package/src/utils/alphaBlit.test.ts +10 -0
- package/src/utils/alphaBlit.ts +15 -7
- package/src/utils/ffprobe.test.ts +40 -0
- 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
|
-
|
|
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
|
|
392
|
-
//
|
|
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 >=
|
|
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
|
|
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
|
-
//
|
|
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 >=
|
|
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 {
|
|
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>');
|