@hyperframes/engine 0.8.154 → 0.8.156
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/services/audioMixer.d.ts +2 -2
- package/dist/services/audioMixer.js +27 -16
- package/dist/services/browserManager.d.ts +11 -0
- package/dist/services/browserManager.js +99 -20
- package/dist/services/extractionCache.d.ts +1 -1
- package/dist/services/extractionCache.js +1 -1
- package/dist/services/frameCapture.js +5 -2
- package/dist/services/hdrCapture.js +2 -24
- package/dist/services/parallelCoordinator.d.ts +1 -5
- package/dist/services/parallelCoordinator.js +6 -13
- package/dist/services/videoFrameExtractor.d.ts +5 -5
- package/dist/services/videoFrameExtractor.js +29 -106
- package/dist/services/wslHost.d.ts +30 -0
- package/dist/services/wslHost.js +102 -0
- package/dist/utils/runFfmpeg.d.ts +0 -5
- package/dist/utils/runFfmpeg.js +0 -35
- package/dist/utils/sdrCaptureColor.d.ts +1 -1
- package/dist/utils/sdrCaptureColor.js +1 -1
- package/package.json +7 -3
|
@@ -8,8 +8,8 @@ export type { AudioElement, MixResult } from "./audioMixer.types.js";
|
|
|
8
8
|
export declare const MIXED_AUDIO_FILENAME = "audio.m4a";
|
|
9
9
|
/** afade stages after the volume filter (leading comma), or "" when none. Stream time 0 is the clip start. */
|
|
10
10
|
export declare function buildFadeFilters(track: Pick<AudioTrack, "start" | "end" | "fadeIn" | "fadeOut">): string;
|
|
11
|
-
/** One track: trim, volume, afade, delay
|
|
12
|
-
export declare function buildTrackInputFilter(track: Pick<AudioTrack, "start" | "end" | "fadeIn" | "fadeOut" | "tailSeconds">, index: number, volumeFilter: string, totalDuration: number): string;
|
|
11
|
+
/** One track: trim, volume, afade, delay. Legacy and group mixes also pad each branch. */
|
|
12
|
+
export declare function buildTrackInputFilter(track: Pick<AudioTrack, "start" | "end" | "fadeIn" | "fadeOut" | "tailSeconds">, index: number, volumeFilter: string, totalDuration: number, padToDuration?: boolean): string;
|
|
13
13
|
/**
|
|
14
14
|
* A volume lane as keyframes for the PCM baker.
|
|
15
15
|
*
|
|
@@ -250,11 +250,12 @@ export function buildFadeFilters(track) {
|
|
|
250
250
|
}
|
|
251
251
|
return stages.length ? `,${stages.join(",")}` : "";
|
|
252
252
|
}
|
|
253
|
-
/** One track: trim, volume, afade, delay
|
|
254
|
-
export function buildTrackInputFilter(track, index, volumeFilter, totalDuration) {
|
|
253
|
+
/** One track: trim, volume, afade, delay. Legacy and group mixes also pad each branch. */
|
|
254
|
+
export function buildTrackInputFilter(track, index, volumeFilter, totalDuration, padToDuration = true) {
|
|
255
255
|
const delayMs = Math.round(track.start * 1000);
|
|
256
256
|
const trimDuration = track.end - track.start + (track.tailSeconds ?? 0);
|
|
257
|
-
|
|
257
|
+
const filters = `[${index}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter}${buildFadeFilters(track)},adelay=${delayMs}|${delayMs}`;
|
|
258
|
+
return `${filters}${padToDuration ? `,apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}` : ""}[a${index}]`;
|
|
258
259
|
}
|
|
259
260
|
// Absolute paths are omitted: boundedDetail redacts them to end-of-line and they name the host.
|
|
260
261
|
function missingSourceMessage(elementId, src, baseDir, resolvedPath) {
|
|
@@ -598,18 +599,20 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
598
599
|
const outputDir = dirname(outputPath);
|
|
599
600
|
if (!existsSync(outputDir))
|
|
600
601
|
mkdirSync(outputDir, { recursive: true });
|
|
601
|
-
const buildFilterComplex = (ignoreAutomation) => {
|
|
602
|
+
const buildFilterComplex = (ignoreAutomation, useNormalize) => {
|
|
602
603
|
const filterParts = [];
|
|
603
604
|
tracks.forEach((track, i) => {
|
|
604
605
|
const volumeFilter = buildVolumeExpression(track, ignoreAutomation);
|
|
605
|
-
filterParts.push(buildTrackInputFilter(track, i, volumeFilter, totalDuration));
|
|
606
|
+
filterParts.push(buildTrackInputFilter(track, i, volumeFilter, totalDuration, !useNormalize));
|
|
606
607
|
});
|
|
607
608
|
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
|
|
608
|
-
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0[mixed]`;
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
const
|
|
612
|
-
const postMixGainFilter =
|
|
609
|
+
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0${useNormalize ? ":normalize=0" : ""}[mixed]`;
|
|
610
|
+
// Older FFmpeg builds lack normalize=0. Their default amix divides by the
|
|
611
|
+
// active input count, so keep all branches padded and compensate by N.
|
|
612
|
+
const gain = masterOutputGain * (useNormalize ? 1 : tracks.length);
|
|
613
|
+
const postMixGainFilter = useNormalize
|
|
614
|
+
? `[mixed]volume=${formatFilterNumber(gain)},apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}[out]`
|
|
615
|
+
: `[mixed]volume=${formatFilterNumber(gain)}[out]`;
|
|
613
616
|
return [...filterParts, mixFilter, postMixGainFilter].join(";");
|
|
614
617
|
};
|
|
615
618
|
// A large track count (100+) makes the inline `-filter_complex <string>`
|
|
@@ -623,14 +626,14 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
623
626
|
// alias from nightly builds; older stable builds do not understand the new
|
|
624
627
|
// spelling. Prefer the legacy spelling for broad compatibility and retry
|
|
625
628
|
// only when FFmpeg explicitly says that option is unavailable.
|
|
626
|
-
const runMix = async (ignoreAutomation) => {
|
|
629
|
+
const runMix = async (ignoreAutomation, useNormalize) => {
|
|
627
630
|
const inputs = [];
|
|
628
631
|
tracks.forEach((track) => inputs.push("-i", track.srcPath));
|
|
629
632
|
const scriptDir = mkdtempSync(join(outputDir, ".filter-complex-"));
|
|
630
633
|
const scriptPath = join(scriptDir, "graph.txt");
|
|
631
634
|
const fd = openSync(scriptPath, "wx", 0o600);
|
|
632
635
|
try {
|
|
633
|
-
writeFileSync(fd, buildFilterComplex(ignoreAutomation));
|
|
636
|
+
writeFileSync(fd, buildFilterComplex(ignoreAutomation, useNormalize));
|
|
634
637
|
}
|
|
635
638
|
finally {
|
|
636
639
|
closeSync(fd);
|
|
@@ -660,7 +663,15 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
660
663
|
rmSync(scriptDir, { recursive: true, force: true });
|
|
661
664
|
}
|
|
662
665
|
};
|
|
663
|
-
let
|
|
666
|
+
let useNormalize = true;
|
|
667
|
+
let result = await runMix(false, useNormalize);
|
|
668
|
+
if (!result.success &&
|
|
669
|
+
result.failureReason !== "external_interruption" &&
|
|
670
|
+
!signal?.aborted &&
|
|
671
|
+
groupNormalizeOptionUnsupported(result.stderr)) {
|
|
672
|
+
useNormalize = false;
|
|
673
|
+
result = await runMix(false, useNormalize);
|
|
674
|
+
}
|
|
664
675
|
// Defense in depth: volume automation is folded into an FFmpeg `volume`
|
|
665
676
|
// expression whose evaluator limits are build-dependent (see
|
|
666
677
|
// MAX_VOLUME_SEGMENTS). If that ever fails the mix, retry once without the
|
|
@@ -672,7 +683,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
672
683
|
result.failureReason !== "external_interruption" &&
|
|
673
684
|
!signal?.aborted &&
|
|
674
685
|
hasAutomation) {
|
|
675
|
-
const retry = await runMix(true);
|
|
686
|
+
const retry = await runMix(true, useNormalize);
|
|
676
687
|
if (retry.success) {
|
|
677
688
|
result = retry;
|
|
678
689
|
degradedAutomation = true;
|
|
@@ -731,8 +742,8 @@ function groupNormalizeOptionUnsupported(stderr) {
|
|
|
731
742
|
}
|
|
732
743
|
/**
|
|
733
744
|
* Sub-mix one group's members into a single PCM WAV at full composition
|
|
734
|
-
* length
|
|
735
|
-
*
|
|
745
|
+
* length. Unlike the main mix, this still pads each member (atrim, volume,
|
|
746
|
+
* adelay, `apad` — RULES 6), writing PCM instead of AAC so the
|
|
736
747
|
* result can feed straight into `applyAudioFxChain` (which reads raw WAV
|
|
737
748
|
* samples, not a decoded container).
|
|
738
749
|
*
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Browser, LaunchOptions, Page, PuppeteerNode } from "puppeteer-core";
|
|
8
8
|
import { type EngineConfig } from "../config.js";
|
|
9
|
+
import { type WslHostFacts } from "./wslHost.js";
|
|
9
10
|
import { type BrowserLaunchFingerprint, type BrowserLease, type CaptureMode } from "./browserLeasePool.js";
|
|
10
11
|
export { BrowserLeasePool } from "./browserLeasePool.js";
|
|
11
12
|
export type { BrowserLaunchFingerprint, BrowserLease, BrowserPoolState, CaptureMode, } from "./browserLeasePool.js";
|
|
@@ -23,6 +24,9 @@ interface GpuProbeOptions {
|
|
|
23
24
|
browserTimeout?: number;
|
|
24
25
|
platform?: NodeJS.Platform;
|
|
25
26
|
probeLifecycle?: GpuProbeLifecycle;
|
|
27
|
+
wslHost?: WslHostFacts;
|
|
28
|
+
/** Finds full Chrome for the WSL fallback; tests pass a fake. */
|
|
29
|
+
findFullChrome?: () => string | undefined;
|
|
26
30
|
}
|
|
27
31
|
export type AcquiredBrowser = BrowserLease;
|
|
28
32
|
/**
|
|
@@ -31,6 +35,8 @@ export type AcquiredBrowser = BrowserLease;
|
|
|
31
35
|
* then the CLI browser override, HyperFrames' managed cache, and Puppeteer's cache.
|
|
32
36
|
*/
|
|
33
37
|
export declare function resolveHeadlessShellPath(config?: Partial<Pick<EngineConfig, "chromePath">>): string | undefined;
|
|
38
|
+
/** Full Chrome for Testing in Puppeteer's cache (`@puppeteer/browsers install chrome@...`), newest first. */
|
|
39
|
+
export declare function resolveFullChromePath(): string | undefined;
|
|
34
40
|
export declare const ENABLE_BROWSER_POOL: boolean;
|
|
35
41
|
/**
|
|
36
42
|
* Probe the complete HeadlessExperimental.beginFrame runtime contract.
|
|
@@ -53,6 +59,11 @@ export declare const _probeBeginFrameSupportForTests: typeof probeBeginFrameSupp
|
|
|
53
59
|
export declare const _closeBrowserAfterFailedProbeForTests: typeof closeBrowserAfterFailedProbe;
|
|
54
60
|
/** Test-only: reset the cached probe result. */
|
|
55
61
|
export declare function _resetAutoBrowserGpuModeCacheForTests(): void;
|
|
62
|
+
/**
|
|
63
|
+
* The browser a render launches for `resolvedMode`: under WSL, the full Chrome
|
|
64
|
+
* the GPU probe fell back to; otherwise the headless shell.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolveCaptureBrowserPath(resolvedMode: "software" | "hardware", config?: Partial<Pick<EngineConfig, "chromePath">>): Promise<string | undefined>;
|
|
56
67
|
/**
|
|
57
68
|
* Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
|
|
58
69
|
*
|
|
@@ -13,6 +13,7 @@ import { homedir } from "os";
|
|
|
13
13
|
import { chromeMajorCeiling, exceedsChromeCeiling } from "./chromeHostCeiling.js";
|
|
14
14
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
15
15
|
import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
|
|
16
|
+
import { describeWslGpuAccess, detectWsl, } from "./wslHost.js";
|
|
16
17
|
import { BrowserLeasePool, } from "./browserLeasePool.js";
|
|
17
18
|
export { BrowserLeasePool } from "./browserLeasePool.js";
|
|
18
19
|
let _puppeteer;
|
|
@@ -198,6 +199,30 @@ export function resolveHeadlessShellPath(config) {
|
|
|
198
199
|
const home = homedir();
|
|
199
200
|
return (findCachedHeadlessShell(join(home, ".cache", "hyperframes", "chrome", "chrome-headless-shell")) ?? findCachedHeadlessShell(join(home, ".cache", "puppeteer", "chrome-headless-shell")));
|
|
200
201
|
}
|
|
202
|
+
/** Full Chrome for Testing in Puppeteer's cache (`@puppeteer/browsers install chrome@...`), newest first. */
|
|
203
|
+
export function resolveFullChromePath() {
|
|
204
|
+
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
|
|
205
|
+
let versions;
|
|
206
|
+
try {
|
|
207
|
+
versions = readdirSync(baseDir).sort(compareBrowserVersionsDescending);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
for (const version of versions) {
|
|
213
|
+
const candidates = [
|
|
214
|
+
join(baseDir, version, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
215
|
+
join(baseDir, version, "chrome-mac-x64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
216
|
+
join(baseDir, version, "chrome-linux64", "chrome"),
|
|
217
|
+
join(baseDir, version, "chrome-win64", "chrome.exe"),
|
|
218
|
+
];
|
|
219
|
+
for (const binary of candidates) {
|
|
220
|
+
if (existsSync(binary))
|
|
221
|
+
return binary;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
201
226
|
// Preserve the producer-era export so re-export shims keep the same public API.
|
|
202
227
|
export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
|
|
203
228
|
// Flags only meaningful when Chrome's compositor is driven by
|
|
@@ -366,6 +391,7 @@ let _autoBrowserGpuModeCache;
|
|
|
366
391
|
export function _resetAutoBrowserGpuModeCacheForTests() {
|
|
367
392
|
_autoBrowserGpuModeCache = undefined;
|
|
368
393
|
_unverifiedHardwareGpuWarned = false;
|
|
394
|
+
_wslSoftwareNoteLogged = false;
|
|
369
395
|
}
|
|
370
396
|
async function getPuppeteerOrNull() {
|
|
371
397
|
try {
|
|
@@ -401,30 +427,57 @@ function formatProbeFailure(err) {
|
|
|
401
427
|
return `probe failed (${err instanceof Error ? err.message : String(err)})`;
|
|
402
428
|
}
|
|
403
429
|
async function probeAutoBrowserGpuMode(options) {
|
|
404
|
-
const platform = options.platform ?? process.platform;
|
|
405
|
-
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
|
|
406
430
|
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
|
|
407
431
|
const ppt = await getPuppeteerOrNull();
|
|
408
432
|
if (ppt === null) {
|
|
409
433
|
logResolvedBrowserGpuMode("software", "puppeteer unavailable");
|
|
410
434
|
return { mode: "software", cause: "probe-error" };
|
|
411
435
|
}
|
|
436
|
+
const probed = await probeWebGlMode(ppt, executablePath, options);
|
|
437
|
+
if (probed.mode === "hardware" || !detectWsl(options.wslHost))
|
|
438
|
+
return probed;
|
|
439
|
+
return probeWslFullChrome(ppt, executablePath, options, probed);
|
|
440
|
+
}
|
|
441
|
+
// heygen-com/hyperframes#5548: under WSL, chrome-headless-shell can fail EGL
|
|
442
|
+
// setup on Mesa's D3D12 driver while full Chrome (--headless=new) gets the GPU.
|
|
443
|
+
async function probeWslFullChrome(ppt, shellPath, options, shellOutcome) {
|
|
444
|
+
const fullChrome = (options.findFullChrome ?? resolveFullChromePath)();
|
|
445
|
+
let route = { state: "missing" };
|
|
446
|
+
if (fullChrome) {
|
|
447
|
+
const viaFullChrome = fullChrome === shellPath
|
|
448
|
+
? shellOutcome
|
|
449
|
+
: await probeWebGlMode(ppt, fullChrome, options, "full Chrome: ");
|
|
450
|
+
if (viaFullChrome.mode === "hardware")
|
|
451
|
+
return { mode: "hardware", browserPath: fullChrome };
|
|
452
|
+
route = { state: viaFullChrome.cause ?? "no-gpu", path: fullChrome };
|
|
453
|
+
}
|
|
454
|
+
return { ...shellOutcome, wslAccess: describeWslGpuAccess(options.wslHost, route) ?? undefined };
|
|
455
|
+
}
|
|
456
|
+
async function probeWebGlMode(ppt, executablePath, options, label = "") {
|
|
412
457
|
try {
|
|
413
458
|
const info = await probeHardwareWebGlInfo(ppt, {
|
|
414
|
-
args: getHardwareGpuProbeArgs(platform),
|
|
415
|
-
browserTimeout,
|
|
459
|
+
args: getHardwareGpuProbeArgs(options.platform ?? process.platform),
|
|
460
|
+
browserTimeout: options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout,
|
|
416
461
|
executablePath,
|
|
417
462
|
probeLifecycle: options.probeLifecycle,
|
|
418
463
|
});
|
|
419
464
|
const resolved = resolveWebGlProbeMode(info);
|
|
420
|
-
logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info));
|
|
465
|
+
logResolvedBrowserGpuMode(resolved, label + describeWebGlProbe(info));
|
|
421
466
|
return resolved === "hardware" ? { mode: "hardware" } : { mode: "software", cause: "no-gpu" };
|
|
422
467
|
}
|
|
423
468
|
catch (err) {
|
|
424
|
-
logResolvedBrowserGpuMode("software", formatProbeFailure(err));
|
|
469
|
+
logResolvedBrowserGpuMode("software", label + formatProbeFailure(err));
|
|
425
470
|
return { mode: "software", cause: "probe-error" };
|
|
426
471
|
}
|
|
427
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* The browser a render launches for `resolvedMode`: under WSL, the full Chrome
|
|
475
|
+
* the GPU probe fell back to; otherwise the headless shell.
|
|
476
|
+
*/
|
|
477
|
+
export async function resolveCaptureBrowserPath(resolvedMode, config) {
|
|
478
|
+
const probed = resolvedMode === "hardware" ? await _autoBrowserGpuModeCache : undefined;
|
|
479
|
+
return probed?.browserPath ?? resolveHeadlessShellPath(config);
|
|
480
|
+
}
|
|
428
481
|
/**
|
|
429
482
|
* Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
|
|
430
483
|
*
|
|
@@ -455,15 +508,22 @@ export function resolveBrowserGpuMode(mode, options = {}) {
|
|
|
455
508
|
if (mode === "software")
|
|
456
509
|
return Promise.resolve(mode);
|
|
457
510
|
_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options);
|
|
458
|
-
if (mode === "auto")
|
|
459
|
-
return _autoBrowserGpuModeCache.then((probed) =>
|
|
511
|
+
if (mode === "auto") {
|
|
512
|
+
return _autoBrowserGpuModeCache.then((probed) => {
|
|
513
|
+
if (probed.mode === "software" && probed.wslAccess && !_wslSoftwareNoteLogged) {
|
|
514
|
+
_wslSoftwareNoteLogged = true;
|
|
515
|
+
console.error(buildWslSoftwareNote(probed));
|
|
516
|
+
}
|
|
517
|
+
return probed.mode;
|
|
518
|
+
});
|
|
519
|
+
}
|
|
460
520
|
return _autoBrowserGpuModeCache.then((probed) => {
|
|
461
521
|
// Warn once per cache lifetime, not once per caller: `createCaptureSession`
|
|
462
522
|
// resolves the mode for the probe browser AND every parallel worker, so
|
|
463
523
|
// an un-deduplicated warning prints N+1 times and buries itself.
|
|
464
524
|
if (probed.mode === "software" && !_unverifiedHardwareGpuWarned) {
|
|
465
525
|
_unverifiedHardwareGpuWarned = true;
|
|
466
|
-
console.warn(buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed
|
|
526
|
+
console.warn(buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed));
|
|
467
527
|
}
|
|
468
528
|
return "hardware";
|
|
469
529
|
});
|
|
@@ -473,6 +533,19 @@ export function resolveBrowserGpuMode(mode, options = {}) {
|
|
|
473
533
|
* cache lifetime (re-armed by `_resetAutoBrowserGpuModeCacheForTests`).
|
|
474
534
|
*/
|
|
475
535
|
let _unverifiedHardwareGpuWarned = false;
|
|
536
|
+
let _wslSoftwareNoteLogged = false;
|
|
537
|
+
/** Auto mode under WSL selected software: say so, with what we checked. */
|
|
538
|
+
function buildWslSoftwareNote({ cause, wslAccess = [] }) {
|
|
539
|
+
const check = cause === "probe-error"
|
|
540
|
+
? "the GPU check could not finish (see the probe line above)"
|
|
541
|
+
: "the GPU check found no hardware GPU";
|
|
542
|
+
return [
|
|
543
|
+
`[hyperframes] WSL: ${check}, so this render uses software WebGL (SwiftShader) and runs ` +
|
|
544
|
+
"at CPU speed.",
|
|
545
|
+
...wslAccess,
|
|
546
|
+
" Pass --no-browser-gpu to skip the GPU check and render in software.",
|
|
547
|
+
].join("\n");
|
|
548
|
+
}
|
|
476
549
|
/**
|
|
477
550
|
* Warning text for "you asked for hardware GPU and we could not confirm it".
|
|
478
551
|
*
|
|
@@ -482,7 +555,7 @@ let _unverifiedHardwareGpuWarned = false;
|
|
|
482
555
|
* at `--gpus all` would send them chasing a phantom while their Chrome
|
|
483
556
|
* install is the actual fault.
|
|
484
557
|
*/
|
|
485
|
-
function buildUnverifiedHardwareGpuWarning(platform, cause) {
|
|
558
|
+
function buildUnverifiedHardwareGpuWarning(platform, { cause, wslAccess }) {
|
|
486
559
|
if (cause === "probe-error") {
|
|
487
560
|
return ("[hyperframes] browserGpuMode=hardware was requested, but the GPU probe could not run, " +
|
|
488
561
|
"so hardware acceleration is UNVERIFIED. If Chrome falls back to software WebGL the " +
|
|
@@ -491,19 +564,25 @@ function buildUnverifiedHardwareGpuWarning(platform, cause) {
|
|
|
491
564
|
"`browserGpuMode probe → software (probe failed ...)` line above for the underlying " +
|
|
492
565
|
"error, which usually means Chrome could not launch (bad HYPERFRAMES_BROWSER_PATH, " +
|
|
493
566
|
"missing shared libraries, or a denied sandbox) rather than a GPU problem.\n" +
|
|
494
|
-
" Run `hyperframes doctor` to check the Chrome install."
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
567
|
+
" Run `hyperframes doctor` to check the Chrome install." +
|
|
568
|
+
(wslAccess
|
|
569
|
+
? ["", " Under WSL, a GPU path that is slow to start or unreachable can also cause it:"]
|
|
570
|
+
.concat(wslAccess)
|
|
571
|
+
.join("\n")
|
|
572
|
+
: ""));
|
|
573
|
+
}
|
|
574
|
+
const remediation = wslAccess?.join("\n") ??
|
|
575
|
+
(platform === "linux"
|
|
576
|
+
? " Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " +
|
|
577
|
+
"Container Toolkit installed, or `--device /dev/dri` for Mesa/AMD/Intel. The image " +
|
|
578
|
+
"also needs the matching userspace driver + libEGL. Verify with " +
|
|
579
|
+
"`hyperframes render --browser-gpu` and watch for this warning disappearing."
|
|
580
|
+
: " Check that the host exposes a GPU to this process and that the graphics drivers " +
|
|
581
|
+
"are installed.");
|
|
503
582
|
return ("[hyperframes] browserGpuMode=hardware was requested, but the WebGL probe found no " +
|
|
504
583
|
"hardware GPU, so Chrome will silently fall back to software WebGL and the capture will " +
|
|
505
584
|
"run at CPU speed. Honouring the explicit request anyway.\n" +
|
|
506
|
-
|
|
585
|
+
`${remediation}\n` +
|
|
507
586
|
" Pass --no-browser-gpu to select deterministic SwiftShader instead of waiting on a " +
|
|
508
587
|
"hardware path that is not there.");
|
|
509
588
|
}
|
|
@@ -42,7 +42,7 @@ export declare const GC_MARKER = ".hf-last-gc";
|
|
|
42
42
|
* Current schema version. Bump it whenever extraction writes different frames for the same key,
|
|
43
43
|
* or warm entries keep serving the old frames across a deploy. Each bump's commit says why.
|
|
44
44
|
*/
|
|
45
|
-
export declare const SCHEMA_PREFIX = "hfcache-
|
|
45
|
+
export declare const SCHEMA_PREFIX = "hfcache-v7-";
|
|
46
46
|
export type CacheFrameFormat = "jpg" | "png";
|
|
47
47
|
export interface CacheKeyInput {
|
|
48
48
|
/** Absolute path to the source video file. Part of the key so moved files
|
|
@@ -45,7 +45,7 @@ export const GC_MARKER = ".hf-last-gc";
|
|
|
45
45
|
* Current schema version. Bump it whenever extraction writes different frames for the same key,
|
|
46
46
|
* or warm entries keep serving the old frames across a deploy. Each bump's commit says why.
|
|
47
47
|
*/
|
|
48
|
-
export const SCHEMA_PREFIX = "hfcache-
|
|
48
|
+
export const SCHEMA_PREFIX = "hfcache-v7-";
|
|
49
49
|
/** Truncated hex chars of SHA-256 used for the entry directory name. */
|
|
50
50
|
const KEY_HEX_CHARS = 16;
|
|
51
51
|
export async function readContentSha256(path, signal) {
|
|
@@ -15,7 +15,7 @@ import { DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
|
15
15
|
import { encodePng } from "../utils/alphaBlit.js";
|
|
16
16
|
import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
|
|
17
17
|
// ── Extracted modules ───────────────────────────────────────────────────────
|
|
18
|
-
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
|
|
18
|
+
import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, resolveCaptureBrowserPath, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
|
|
19
19
|
import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
|
|
20
20
|
import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
|
|
21
21
|
import { initThreeDProjection, detectCssEffectRisk } from "./threeDProjection.js";
|
|
@@ -786,7 +786,10 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
|
|
|
786
786
|
// boolean `forceScreenshot === false` is otherwise ambiguous between
|
|
787
787
|
// default and explicit opt-out).
|
|
788
788
|
const effectiveForceScreenshot = applyConcreteGpuScreenshotClamp(forceScreenshot, resolvedGpuMode, config);
|
|
789
|
+
const browserPath = await resolveCaptureBrowserPath(resolvedGpuMode, config);
|
|
790
|
+
const wslFullChromeHasNoBeginFrame = browserPath !== headlessShell;
|
|
789
791
|
const preMode = headlessShell &&
|
|
792
|
+
!wslFullChromeHasNoBeginFrame &&
|
|
790
793
|
isLinux &&
|
|
791
794
|
!effectiveForceScreenshot &&
|
|
792
795
|
!supersampling &&
|
|
@@ -804,7 +807,7 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
|
|
|
804
807
|
.catch(() => false));
|
|
805
808
|
const gpuConfig = { ...config, browserGpuMode: resolvedGpuMode };
|
|
806
809
|
const chromeArgs = buildChromeArgs({ width: options.width, height: options.height, captureMode: preMode, requiresWebGpu }, gpuConfig);
|
|
807
|
-
const browserLease = await acquireBrowser(chromeArgs, config);
|
|
810
|
+
const browserLease = await acquireBrowser(chromeArgs, { ...config, chromePath: browserPath });
|
|
808
811
|
return constructCaptureSessionWithRollback({
|
|
809
812
|
browserLease,
|
|
810
813
|
serverUrl,
|
|
@@ -18,10 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Performance: ~6 fps at 1080x1920 via base64 transfer.
|
|
20
20
|
*/
|
|
21
|
-
import {
|
|
22
|
-
import { join } from "path";
|
|
23
|
-
import { homedir } from "os";
|
|
24
|
-
import { sigintLaunchOptions } from "./browserManager.js";
|
|
21
|
+
import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js";
|
|
25
22
|
// ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
|
|
26
23
|
const PQ_M1 = 0.1593017578125;
|
|
27
24
|
const PQ_M2 = 78.84375;
|
|
@@ -159,25 +156,6 @@ export function float16ToPqRgb(rawBuffer, bytesPerRow, width, height) {
|
|
|
159
156
|
return output;
|
|
160
157
|
}
|
|
161
158
|
// ── Chrome launch ─────────────────────────────────────────────────────────────
|
|
162
|
-
function resolveHeadedChromePath() {
|
|
163
|
-
const baseDir = join(homedir(), ".cache", "puppeteer", "chrome");
|
|
164
|
-
if (!existsSync(baseDir))
|
|
165
|
-
return undefined;
|
|
166
|
-
const versions = readdirSync(baseDir).sort().reverse();
|
|
167
|
-
for (const version of versions) {
|
|
168
|
-
const candidates = [
|
|
169
|
-
join(baseDir, version, "chrome-mac-arm64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
170
|
-
join(baseDir, version, "chrome-mac-x64", "Google Chrome for Testing.app", "Contents", "MacOS", "Google Chrome for Testing"),
|
|
171
|
-
join(baseDir, version, "chrome-linux64", "chrome"),
|
|
172
|
-
join(baseDir, version, "chrome-win64", "chrome.exe"),
|
|
173
|
-
];
|
|
174
|
-
for (const binary of candidates) {
|
|
175
|
-
if (existsSync(binary))
|
|
176
|
-
return binary;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return undefined;
|
|
180
|
-
}
|
|
181
159
|
/**
|
|
182
160
|
* Launch a headed Chrome browser with WebGPU enabled.
|
|
183
161
|
*/
|
|
@@ -197,7 +175,7 @@ export async function launchHdrBrowser(width, height) {
|
|
|
197
175
|
}
|
|
198
176
|
if (!ppt)
|
|
199
177
|
throw new Error("Neither puppeteer nor puppeteer-core found");
|
|
200
|
-
const chromePath =
|
|
178
|
+
const chromePath = resolveFullChromePath();
|
|
201
179
|
if (!chromePath) {
|
|
202
180
|
throw new Error("[HDR] No Chrome binary found. Install: npx @puppeteer/browsers install chrome@stable");
|
|
203
181
|
}
|
|
@@ -76,12 +76,8 @@ export interface WorkerSizingConfig extends Partial<Pick<EngineConfig, "concurre
|
|
|
76
76
|
}
|
|
77
77
|
type WorkerBrowserPoolDecision = {
|
|
78
78
|
parallel?: boolean;
|
|
79
|
-
platform: NodeJS.Platform;
|
|
80
|
-
forceScreenshot?: boolean;
|
|
81
|
-
deviceScaleFactor?: number;
|
|
82
|
-
headlessShellPath?: string;
|
|
83
79
|
};
|
|
84
|
-
export declare function shouldDisableBrowserPoolForParallelWorker({ parallel,
|
|
80
|
+
export declare function shouldDisableBrowserPoolForParallelWorker({ parallel, }: WorkerBrowserPoolDecision): boolean;
|
|
85
81
|
export declare function selectWorkerDiagnostics(lines: readonly string[], maxLines?: number): string[];
|
|
86
82
|
/**
|
|
87
83
|
* Expected frame count for a worker task, honoring its stride. Contiguous
|
|
@@ -51,12 +51,11 @@ function defaultSafeMaxWorkers() {
|
|
|
51
51
|
return Math.max(6, Math.min(16, Math.floor(cpus().length / 8)));
|
|
52
52
|
}
|
|
53
53
|
const MIN_FRAMES_PER_WORKER = 30;
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return Boolean(parallel && platform === "linux" && headlessShellPath && (deviceScaleFactor ?? 1) <= 1);
|
|
54
|
+
// Every parallel worker gets its own browser: pages in one browser share six
|
|
55
|
+
// connections to the file server, which a media-heavy page holds (#5548), and
|
|
56
|
+
// sharing also crashes BeginFrame and serializes screenshots.
|
|
57
|
+
export function shouldDisableBrowserPoolForParallelWorker({ parallel, }) {
|
|
58
|
+
return Boolean(parallel);
|
|
60
59
|
}
|
|
61
60
|
export function selectWorkerDiagnostics(lines, maxLines = MAX_WORKER_DIAGNOSTIC_LINES) {
|
|
62
61
|
return lines
|
|
@@ -555,13 +554,7 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
|
|
|
555
554
|
mkdirSync(task.outputDir, { recursive: true });
|
|
556
555
|
let session = null;
|
|
557
556
|
let perf;
|
|
558
|
-
const needsSeparateBrowsers = shouldDisableBrowserPoolForParallelWorker({
|
|
559
|
-
parallel,
|
|
560
|
-
platform: process.platform,
|
|
561
|
-
forceScreenshot: config?.forceScreenshot,
|
|
562
|
-
deviceScaleFactor: captureOptions.deviceScaleFactor,
|
|
563
|
-
headlessShellPath: resolveHeadlessShellPath(config),
|
|
564
|
-
});
|
|
557
|
+
const needsSeparateBrowsers = shouldDisableBrowserPoolForParallelWorker({ parallel });
|
|
565
558
|
const workerConfig = needsSeparateBrowsers
|
|
566
559
|
? { ...config, enableBrowserPool: false }
|
|
567
560
|
: config;
|
|
@@ -53,9 +53,9 @@ export declare function isVideoFrameFormat(value: unknown): value is VideoFrameF
|
|
|
53
53
|
/**
|
|
54
54
|
* Resolve the frame count produced for a requested extraction duration.
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* Both paths sample with FFmpeg's fps filter. A CFR window ends at the nearest
|
|
57
|
+
* frame boundary; a VFR window rounds up, keeping the slot a trailing still
|
|
58
|
+
* covers. Keep this calculation shared by superset slicing and
|
|
59
59
|
* producer coverage accounting so a complete VFR extraction cannot be
|
|
60
60
|
* rejected because the two paths disagree by one frame.
|
|
61
61
|
*/
|
|
@@ -123,8 +123,8 @@ export declare const EXTRACT_CACHE_MIN_AGE_MS: number;
|
|
|
123
123
|
* reflects max-across-parallel-workers, not sum.
|
|
124
124
|
* - hdrPreflightMs includes its probe-time sibling (hdrProbeMs); the
|
|
125
125
|
* probe-only field is a finer decomposition, not a separate carve-out.
|
|
126
|
-
* - vfrPreflightCount reports sources classified as VFR and
|
|
127
|
-
*
|
|
126
|
+
* - vfrPreflightCount reports sources classified as VFR and extracted with
|
|
127
|
+
* a rounded-up frame count. DEFINITION CHANGE:
|
|
128
128
|
* before the one-pass refactor, vfrPreflightMs timed a per-source
|
|
129
129
|
* VFR-to-CFR re-encode and could reach seconds; it now times only the
|
|
130
130
|
* (promise-cached) classification probe and is expected to be ~0.
|
|
@@ -20,7 +20,7 @@ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, } from "../utils
|
|
|
20
20
|
import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
|
|
21
21
|
import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
|
|
22
22
|
import { downloadToTemp, isHttpUrl, safeDownloadUrlIdentity, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
|
|
23
|
-
import { runFfmpeg
|
|
23
|
+
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
24
24
|
import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
|
|
25
25
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
26
26
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
@@ -41,9 +41,9 @@ export function isVideoFrameFormat(value) {
|
|
|
41
41
|
/**
|
|
42
42
|
* Resolve the frame count produced for a requested extraction duration.
|
|
43
43
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Both paths sample with FFmpeg's fps filter. A CFR window ends at the nearest
|
|
45
|
+
* frame boundary; a VFR window rounds up, keeping the slot a trailing still
|
|
46
|
+
* covers. Keep this calculation shared by superset slicing and
|
|
47
47
|
* producer coverage accounting so a complete VFR extraction cannot be
|
|
48
48
|
* rejected because the two paths disagree by one frame.
|
|
49
49
|
*/
|
|
@@ -114,7 +114,7 @@ const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv"
|
|
|
114
114
|
const HDR_TO_SDR_ZSCALE_TRANSFORM_KEY = "hdr2sdr-hable-srgb";
|
|
115
115
|
const HDR_TO_SDR_VIDEOTOOLBOX_TRANSFORM_KEY = "hdr2sdr-videotoolbox-hdr";
|
|
116
116
|
const SDR_CANVAS_PASSTHROUGH_FILTER = "setparams=color_primaries=bt709:color_trc=iec61966-2-1";
|
|
117
|
-
const SDR_JPEG_AS_BT601_FULL_RANGE_FILTER = "scale=flags=neighbor,format=gbrp,scale=out_color_matrix=bt601:out_range=pc:flags=neighbor,format=yuv420p";
|
|
117
|
+
const SDR_JPEG_AS_BT601_FULL_RANGE_FILTER = "scale=flags=neighbor+accurate_rnd,format=gbrp,scale=out_color_matrix=bt601:out_range=pc:flags=neighbor,format=yuv420p";
|
|
118
118
|
let warnedMissingZscale = false;
|
|
119
119
|
async function toneMapsWithZscale() {
|
|
120
120
|
return process.platform !== "darwin" || isFfmpegFilterAvailable("zscale");
|
|
@@ -430,59 +430,6 @@ function chromeGuessForUntaggedMatrix(metadata) {
|
|
|
430
430
|
const hd = metadata.height >= 720 && !CHROME_BT601_UNTAGGED_CODECS.has(metadata.videoCodec);
|
|
431
431
|
return [`setparams=colorspace=${hd ? "bt709" : "smpte170m"}`];
|
|
432
432
|
}
|
|
433
|
-
/** Pixel formats nut carries as raw video unchanged on ffmpeg 5.1 to 8.1 (yuvj only loses its range). */
|
|
434
|
-
const NUT_RAW_PIXEL_FORMATS = new Set([
|
|
435
|
-
"yuv420p",
|
|
436
|
-
"yuvj420p",
|
|
437
|
-
"yuv422p",
|
|
438
|
-
"yuvj422p",
|
|
439
|
-
"yuv444p",
|
|
440
|
-
"yuvj444p",
|
|
441
|
-
"yuva420p",
|
|
442
|
-
"yuva444p",
|
|
443
|
-
"yuv440p",
|
|
444
|
-
"yuv411p",
|
|
445
|
-
"yuv410p",
|
|
446
|
-
"yuva422p",
|
|
447
|
-
"yuv420p10le",
|
|
448
|
-
"yuv422p10le",
|
|
449
|
-
"yuv444p10le",
|
|
450
|
-
"yuva420p10le",
|
|
451
|
-
"yuva444p10le",
|
|
452
|
-
"yuv420p12le",
|
|
453
|
-
"nv12",
|
|
454
|
-
"nv21",
|
|
455
|
-
"yuyv422",
|
|
456
|
-
"uyvy422",
|
|
457
|
-
"gray",
|
|
458
|
-
"gray10le",
|
|
459
|
-
"ya8",
|
|
460
|
-
"gbrp",
|
|
461
|
-
"gbrap",
|
|
462
|
-
"gbrp10le",
|
|
463
|
-
"gbrp12le",
|
|
464
|
-
"gbrap10le",
|
|
465
|
-
"rgb24",
|
|
466
|
-
"bgr24",
|
|
467
|
-
"rgba",
|
|
468
|
-
"bgra",
|
|
469
|
-
"argb",
|
|
470
|
-
"abgr",
|
|
471
|
-
"rgb48le",
|
|
472
|
-
"rgba64le",
|
|
473
|
-
]);
|
|
474
|
-
/** nut drops colour tags, so raw frames get the source's back before any other filter reads them. */
|
|
475
|
-
function restoreSourceColourFilter(metadata) {
|
|
476
|
-
const tags = [
|
|
477
|
-
["range", metadata.colorRange],
|
|
478
|
-
["colorspace", metadata.colorSpace?.colorSpace],
|
|
479
|
-
["color_primaries", metadata.colorSpace?.colorPrimaries],
|
|
480
|
-
["color_trc", metadata.colorSpace?.colorTransfer],
|
|
481
|
-
].filter(([, value]) => value && value !== "unknown" && value !== "reserved");
|
|
482
|
-
return tags.length > 0
|
|
483
|
-
? [`setparams=${tags.map(([key, value]) => `${key}=${value}`).join(":")}`]
|
|
484
|
-
: [];
|
|
485
|
-
}
|
|
486
433
|
/**
|
|
487
434
|
* Longest stretch of output one ffmpeg process extracts. `ffmpegProcessTimeout` bounds a
|
|
488
435
|
* single process, so one process over a long clip timed out however much time the render
|
|
@@ -595,8 +542,8 @@ outputDirOverride) {
|
|
|
595
542
|
const isMacOS = process.platform === "darwin";
|
|
596
543
|
const toneMappedToSdr = options.toneMapHdrToSdr === true && isHdr && (await toneMapsWithZscale());
|
|
597
544
|
const decodeWithVideoToolbox = isHdr && isMacOS && !toneMappedToSdr;
|
|
598
|
-
const
|
|
599
|
-
if (options.frameRanges && !
|
|
545
|
+
const sampleAtOutputFps = !options.finalFrameOnly;
|
|
546
|
+
if (options.frameRanges && (!sampleAtOutputFps || metadata.isVFR)) {
|
|
600
547
|
throw new Error("frameRanges extraction needs a constant-frame-rate source");
|
|
601
548
|
}
|
|
602
549
|
const args = [];
|
|
@@ -625,7 +572,7 @@ outputDirOverride) {
|
|
|
625
572
|
}
|
|
626
573
|
else {
|
|
627
574
|
// An accurate seek drops the frame on screen at startTime; the fps sampling below needs it.
|
|
628
|
-
if (
|
|
575
|
+
if (sampleAtOutputFps)
|
|
629
576
|
args.push("-noaccurate_seek");
|
|
630
577
|
args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
|
|
631
578
|
}
|
|
@@ -633,7 +580,7 @@ outputDirOverride) {
|
|
|
633
580
|
if (decodeWithVideoToolbox) {
|
|
634
581
|
vfFilters.push("format=nv12");
|
|
635
582
|
}
|
|
636
|
-
if (
|
|
583
|
+
if (sampleAtOutputFps) {
|
|
637
584
|
// Each slot takes the last frame starting at or before its time. Ticks of 2 ms or finer
|
|
638
585
|
// first shift half a tick, absorbing container rounding (WebM/MKV use whole ms).
|
|
639
586
|
vfFilters.push("settb=intb/2", "setpts=PTS-lte(TB\\,0.001)", `fps=${ffmpegFps}:start_time=0:round=up`);
|
|
@@ -663,45 +610,24 @@ outputDirOverride) {
|
|
|
663
610
|
encodeArgs.push("-compression_level", "1");
|
|
664
611
|
encodeArgs.push("-y", outputPattern);
|
|
665
612
|
const runOptions = { signal, timeout: ffmpegProcessTimeout };
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
"passthrough",
|
|
685
|
-
...encodeArgs,
|
|
686
|
-
], runOptions), signal);
|
|
687
|
-
}
|
|
688
|
-
else {
|
|
689
|
-
const filterArgs = vfFilters.length > 0 ? ["-vf", vfFilters.join(",")] : [];
|
|
690
|
-
const segmentFrames = Math.round(EXTRACTION_SEGMENT_SECONDS * fps);
|
|
691
|
-
const totalFrames = extractionFrameCountForDuration(duration, normalizedFps, false);
|
|
692
|
-
processResult =
|
|
693
|
-
sampleCfrAtOutputFps && (options.frameRanges || totalFrames > segmentFrames)
|
|
694
|
-
? await runSegmentedExtraction({
|
|
695
|
-
decodeArgs,
|
|
696
|
-
filterAndEncodeArgs: [...filterArgs, ...encodeArgs],
|
|
697
|
-
videoPath,
|
|
698
|
-
startTime,
|
|
699
|
-
fps,
|
|
700
|
-
segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
|
|
701
|
-
runOptions,
|
|
702
|
-
})
|
|
703
|
-
: await inExtractionSlot(() => runFfmpeg([...args, ...filterArgs, ...encodeArgs], runOptions), signal);
|
|
704
|
-
}
|
|
613
|
+
const filterArgs = vfFilters.length > 0 ? ["-vf", vfFilters.join(",")] : [];
|
|
614
|
+
const segmentFrames = Math.round(EXTRACTION_SEGMENT_SECONDS * fps);
|
|
615
|
+
const totalFrames = extractionFrameCountForDuration(duration, normalizedFps, metadata.isVFR);
|
|
616
|
+
// A VFR window rounds its frame count up, and only the segments' `-frames:v` bound
|
|
617
|
+
// emits that last slot: the fps filter ends a `-t` window at the nearest slot. A
|
|
618
|
+
// decimal rate counts zero frames here and keeps the `-t` window.
|
|
619
|
+
const boundVfrFrameCount = metadata.isVFR && totalFrames > 0;
|
|
620
|
+
const processResult = sampleAtOutputFps && (options.frameRanges || boundVfrFrameCount || totalFrames > segmentFrames)
|
|
621
|
+
? await runSegmentedExtraction({
|
|
622
|
+
decodeArgs,
|
|
623
|
+
filterAndEncodeArgs: [...filterArgs, ...encodeArgs],
|
|
624
|
+
videoPath,
|
|
625
|
+
startTime,
|
|
626
|
+
fps,
|
|
627
|
+
segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
|
|
628
|
+
runOptions,
|
|
629
|
+
})
|
|
630
|
+
: await inExtractionSlot(() => runFfmpeg([...args, ...filterArgs, ...encodeArgs], runOptions), signal);
|
|
705
631
|
if (processResult.failureReason === "external_interruption") {
|
|
706
632
|
throw new VideoSourceExtractionError("external_interruption", true, "Video frame extraction interrupted by host lifecycle", `FFmpeg exited with code ${processResult.exitCode}: ${processResult.stderr.slice(-500)}`);
|
|
707
633
|
}
|
|
@@ -1044,11 +970,8 @@ function buildSupersetGroup(groupId, misses, fps) {
|
|
|
1044
970
|
return null;
|
|
1045
971
|
if (misses.some(({ work }) => work.finalFrameOnly))
|
|
1046
972
|
return null;
|
|
1047
|
-
// VFR
|
|
1048
|
-
//
|
|
1049
|
-
// sliced into the same frames as independently sought member ranges, even
|
|
1050
|
-
// when their offsets land on an integral output-frame boundary. Keep VFR
|
|
1051
|
-
// ranges direct until the extractor has a proven absolute timestamp phase.
|
|
973
|
+
// VFR ranges round their frame counts up, so a union extraction's slices
|
|
974
|
+
// are not proven to match independently sought member ranges. Keep them direct.
|
|
1052
975
|
if (misses.some(({ work }) => work.metadata.isVFR))
|
|
1053
976
|
return null;
|
|
1054
977
|
const baseStart = Math.min(...misses.map(({ work }) => work.video.mediaStart));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL host detection and what we can check about its GPU path when Chrome's
|
|
3
|
+
* GPU probe does not confirm a hardware GPU (heygen-com/hyperframes#5548).
|
|
4
|
+
*/
|
|
5
|
+
export type RenderNodeAccess = {
|
|
6
|
+
state: "missing" | "read-write";
|
|
7
|
+
} | {
|
|
8
|
+
state: "denied";
|
|
9
|
+
group: string;
|
|
10
|
+
userInGroup: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** The host facts these checks read; tests pass a fake. */
|
|
13
|
+
export interface WslHostFacts {
|
|
14
|
+
platform: NodeJS.Platform;
|
|
15
|
+
release: string;
|
|
16
|
+
readProcVersion(): string;
|
|
17
|
+
renderNodeAccess(path: string): RenderNodeAccess;
|
|
18
|
+
}
|
|
19
|
+
export declare function detectWsl(host?: WslHostFacts): boolean;
|
|
20
|
+
export type FullChromeRoute = {
|
|
21
|
+
state: "missing";
|
|
22
|
+
} | {
|
|
23
|
+
state: "no-gpu" | "probe-error";
|
|
24
|
+
path: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Indented lines saying what we checked on the WSL GPU path and what to try,
|
|
28
|
+
* or null outside WSL. Says nothing about the render mode: callers own that.
|
|
29
|
+
*/
|
|
30
|
+
export declare function describeWslGpuAccess(host?: WslHostFacts, fullChrome?: FullChromeRoute): string[] | null;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WSL host detection and what we can check about its GPU path when Chrome's
|
|
3
|
+
* GPU probe does not confirm a hardware GPU (heygen-com/hyperframes#5548).
|
|
4
|
+
*/
|
|
5
|
+
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { platform, release } from "node:os";
|
|
7
|
+
function groupName(gid) {
|
|
8
|
+
try {
|
|
9
|
+
const line = readFileSync("/etc/group", "utf-8")
|
|
10
|
+
.split("\n")
|
|
11
|
+
.find((entry) => entry.split(":")[2] === String(gid));
|
|
12
|
+
return line?.split(":")[0] || String(gid);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return String(gid);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const realHost = {
|
|
19
|
+
get platform() {
|
|
20
|
+
return platform();
|
|
21
|
+
},
|
|
22
|
+
get release() {
|
|
23
|
+
return release();
|
|
24
|
+
},
|
|
25
|
+
readProcVersion: () => readFileSync("/proc/version", "utf-8"),
|
|
26
|
+
renderNodeAccess: (path) => {
|
|
27
|
+
if (!existsSync(path))
|
|
28
|
+
return { state: "missing" };
|
|
29
|
+
try {
|
|
30
|
+
accessSync(path, constants.R_OK | constants.W_OK);
|
|
31
|
+
return { state: "read-write" };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
const gid = statSync(path, { throwIfNoEntry: false })?.gid ?? -1;
|
|
35
|
+
return {
|
|
36
|
+
state: "denied",
|
|
37
|
+
group: groupName(gid),
|
|
38
|
+
userInGroup: process.getgroups?.().includes(gid) ?? false,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
export function detectWsl(host = realHost) {
|
|
44
|
+
if (host.platform !== "linux")
|
|
45
|
+
return false;
|
|
46
|
+
try {
|
|
47
|
+
const osRelease = host.release.toLowerCase();
|
|
48
|
+
if (osRelease.includes("microsoft") || osRelease.includes("wsl"))
|
|
49
|
+
return true;
|
|
50
|
+
const procVersion = host.readProcVersion().toLowerCase();
|
|
51
|
+
return procVersion.includes("microsoft") || procVersion.includes("wsl");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const RENDER_NODE = "/dev/dri/renderD128";
|
|
58
|
+
const NATIVE_WINDOWS = "render from native Windows (project copied to a Windows drive, run from PowerShell), " +
|
|
59
|
+
"where Chrome uses the GPU through Direct3D.";
|
|
60
|
+
function routeToGpu(fullChrome) {
|
|
61
|
+
const viaFullChrome = fullChrome.state === "missing"
|
|
62
|
+
? [
|
|
63
|
+
" Under WSL, Chrome's headless shell often cannot reach the GPU while full Chrome can. " +
|
|
64
|
+
"Install full Chrome with `npx @puppeteer/browsers install chrome@stable --path " +
|
|
65
|
+
"~/.cache/puppeteer` and HyperFrames will try it when the headless shell finds no GPU " +
|
|
66
|
+
"(or point HYPERFRAMES_BROWSER_PATH at a full Chrome).",
|
|
67
|
+
]
|
|
68
|
+
: fullChrome.state === "no-gpu"
|
|
69
|
+
? [` Full Chrome (${fullChrome.path}) found no hardware GPU either.`]
|
|
70
|
+
: [` Full Chrome (${fullChrome.path}) could not finish its GPU check either.`];
|
|
71
|
+
return [...viaFullChrome, ` Or ${NATIVE_WINDOWS}`];
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Indented lines saying what we checked on the WSL GPU path and what to try,
|
|
75
|
+
* or null outside WSL. Says nothing about the render mode: callers own that.
|
|
76
|
+
*/
|
|
77
|
+
export function describeWslGpuAccess(host = realHost, fullChrome = { state: "missing" }) {
|
|
78
|
+
if (!detectWsl(host))
|
|
79
|
+
return null;
|
|
80
|
+
const access = host.renderNodeAccess(RENDER_NODE);
|
|
81
|
+
if (access.state === "denied" && !access.userInGroup) {
|
|
82
|
+
return [
|
|
83
|
+
` Your Linux user cannot read and write ${RENDER_NODE} and is not in its group ` +
|
|
84
|
+
`("${access.group}"), which can stop Mesa from reaching the GPU.`,
|
|
85
|
+
` First run \`sudo usermod -aG ${access.group} $USER\`, then \`wsl --shutdown\` from ` +
|
|
86
|
+
"Windows and reopen WSL. On its own that may not be enough:",
|
|
87
|
+
...routeToGpu(fullChrome),
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
if (access.state === "denied") {
|
|
91
|
+
return [
|
|
92
|
+
` Your Linux user is in the "${access.group}" group but still cannot read and write ` +
|
|
93
|
+
`${RENDER_NODE}; check its permissions with \`ls -l ${RENDER_NODE}\`.`,
|
|
94
|
+
...routeToGpu(fullChrome),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
const seen = access.state === "missing" ? "does not exist" : "is readable and writable";
|
|
98
|
+
return [
|
|
99
|
+
` ${RENDER_NODE} ${seen}, so file permissions are not the cause.`,
|
|
100
|
+
...routeToGpu(fullChrome),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
@@ -50,8 +50,3 @@ export declare function formatFfmpegError(exitCode: number | null, stderr: strin
|
|
|
50
50
|
/** formatFfmpegError, led by the timeout when this process was killed at its deadline. */
|
|
51
51
|
export declare function describeFfmpegFailure(result: Pick<RunFfmpegResult, "exitCode" | "stderr" | "terminationReason">, timeoutMs: number): string;
|
|
52
52
|
export declare function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult>;
|
|
53
|
-
/**
|
|
54
|
-
* Runs `ffmpeg <producerArgs> | ffmpeg <consumerArgs>`. Fails when either side
|
|
55
|
-
* fails and reports the first failure, its stderr last so error tails show it.
|
|
56
|
-
*/
|
|
57
|
-
export declare function runFfmpegPipeline(producerArgs: string[], consumerArgs: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult>;
|
package/dist/utils/runFfmpeg.js
CHANGED
|
@@ -149,38 +149,3 @@ export async function runFfmpeg(args, opts) {
|
|
|
149
149
|
});
|
|
150
150
|
return toResult(await managed.wait());
|
|
151
151
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Runs `ffmpeg <producerArgs> | ffmpeg <consumerArgs>`. Fails when either side
|
|
154
|
-
* fails and reports the first failure, its stderr last so error tails show it.
|
|
155
|
-
*/
|
|
156
|
-
export async function runFfmpegPipeline(producerArgs, consumerArgs, opts) {
|
|
157
|
-
if (opts?.signal?.aborted)
|
|
158
|
-
return { ...ABORTED_BEFORE_START };
|
|
159
|
-
const deadlineAtMs = Date.now() + (opts?.timeout ?? DEFAULT_TIMEOUT);
|
|
160
|
-
const producer = spawnFfmpeg(producerArgs);
|
|
161
|
-
const consumer = spawnFfmpeg(consumerArgs);
|
|
162
|
-
// Either side ending early, or failing to start, must release the other from the pipe.
|
|
163
|
-
consumer.stdin?.on("error", () => { });
|
|
164
|
-
for (const event of ["close", "error"]) {
|
|
165
|
-
consumer.once(event, () => producer.stdout?.destroy());
|
|
166
|
-
producer.once(event, () => consumer.stdin?.end());
|
|
167
|
-
}
|
|
168
|
-
if (consumer.stdin)
|
|
169
|
-
producer.stdout?.pipe(consumer.stdin);
|
|
170
|
-
const settleOrder = [];
|
|
171
|
-
const outcomes = await Promise.all([producer, consumer].map((child) => new ManagedChildProcess(child, {
|
|
172
|
-
signal: opts?.signal,
|
|
173
|
-
deadlineAtMs,
|
|
174
|
-
onStderr: opts?.onStderr,
|
|
175
|
-
})
|
|
176
|
-
.wait()
|
|
177
|
-
.then((outcome) => {
|
|
178
|
-
settleOrder.push(outcome);
|
|
179
|
-
return outcome;
|
|
180
|
-
})));
|
|
181
|
-
const reported = settleOrder.find((outcome) => !succeeded(outcome)) ?? outcomes[1];
|
|
182
|
-
const stderr = [...outcomes.filter((outcome) => outcome !== reported), reported]
|
|
183
|
-
.map((outcome) => outcome.stderr)
|
|
184
|
-
.join("");
|
|
185
|
-
return toResult(reported, stderr);
|
|
186
|
-
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export declare const SDR_RGB_TO_BT709_FILTER = "scale=out_color_matrix=bt709:out_range=tv:flags=neighbor";
|
|
2
|
-
export declare const SDR_CAPTURE_TO_BT709_FILTER = "scale=in_color_matrix=bt601:in_range=pc:flags=neighbor,format=gbrp,scale=out_color_matrix=bt709:out_range=tv:flags=neighbor";
|
|
2
|
+
export declare const SDR_CAPTURE_TO_BT709_FILTER = "scale=in_color_matrix=bt601:in_range=pc:flags=neighbor+accurate_rnd,format=gbrp,scale=out_color_matrix=bt709:out_range=tv:flags=neighbor";
|
|
3
3
|
export declare const SDR_RGB_TO_TAGGED_BT709_FILTER = "scale=out_color_matrix=bt709:out_range=tv:flags=neighbor,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export const SDR_RGB_TO_BT709_FILTER = "scale=out_color_matrix=bt709:out_range=tv:flags=neighbor";
|
|
2
|
-
export const SDR_CAPTURE_TO_BT709_FILTER = `scale=in_color_matrix=bt601:in_range=pc:flags=neighbor,format=gbrp,${SDR_RGB_TO_BT709_FILTER}`;
|
|
2
|
+
export const SDR_CAPTURE_TO_BT709_FILTER = `scale=in_color_matrix=bt601:in_range=pc:flags=neighbor+accurate_rnd,format=gbrp,${SDR_RGB_TO_BT709_FILTER}`;
|
|
3
3
|
export const SDR_RGB_TO_TAGGED_BT709_FILTER = `${SDR_RGB_TO_BT709_FILTER},setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.156",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -31,6 +31,10 @@
|
|
|
31
31
|
"import": "./dist/services/systemMemory.js",
|
|
32
32
|
"types": "./dist/services/systemMemory.d.ts"
|
|
33
33
|
},
|
|
34
|
+
"./wsl-host": {
|
|
35
|
+
"import": "./dist/services/wslHost.js",
|
|
36
|
+
"types": "./dist/services/wslHost.d.ts"
|
|
37
|
+
},
|
|
34
38
|
"./shader-transitions": {
|
|
35
39
|
"import": "./dist/utils/shaderTransitions.js",
|
|
36
40
|
"types": "./dist/utils/shaderTransitions.d.ts"
|
|
@@ -46,8 +50,8 @@
|
|
|
46
50
|
"linkedom": "^0.18.12",
|
|
47
51
|
"puppeteer": "^25.8.0",
|
|
48
52
|
"puppeteer-core": "^25.8.0",
|
|
49
|
-
"@hyperframes/
|
|
50
|
-
"@hyperframes/
|
|
53
|
+
"@hyperframes/parsers": "^0.8.156",
|
|
54
|
+
"@hyperframes/core": "^0.8.156"
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
57
|
"@types/node": "^25.0.10",
|