@hyperframes/engine 0.8.155 → 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.
@@ -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/pad. asetpts after apad so FFmpeg 5-8 delayed branches do not land at t=0. */
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/pad. asetpts after apad so FFmpeg 5-8 delayed branches do not land at t=0. */
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
- return `[${index}:a]atrim=0:${formatFilterNumber(trimDuration)},${volumeFilter}${buildFadeFilters(track)},adelay=${delayMs}|${delayMs},apad,asetpts=N/SR/TB,atrim=0:${formatFilterNumber(totalDuration)}[a${index}]`;
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
- // amix divides output by inputs count (default normalize=true). Multiply master
610
- // gain by track count so per-track volumes authored in data-volume are preserved.
611
- const compensatedGain = masterOutputGain * tracks.length;
612
- const postMixGainFilter = `[mixed]volume=${formatFilterNumber(compensatedGain)}[out]`;
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 result = await runMix(false);
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 — the same per-input treatment `mixAudioTracks` gives every track
735
- * (atrim, volume, adelay, `apad` — RULES 6), writing PCM instead of AAC so the
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
  *
@@ -25,6 +25,8 @@ interface GpuProbeOptions {
25
25
  platform?: NodeJS.Platform;
26
26
  probeLifecycle?: GpuProbeLifecycle;
27
27
  wslHost?: WslHostFacts;
28
+ /** Finds full Chrome for the WSL fallback; tests pass a fake. */
29
+ findFullChrome?: () => string | undefined;
28
30
  }
29
31
  export type AcquiredBrowser = BrowserLease;
30
32
  /**
@@ -33,6 +35,8 @@ export type AcquiredBrowser = BrowserLease;
33
35
  * then the CLI browser override, HyperFrames' managed cache, and Puppeteer's cache.
34
36
  */
35
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;
36
40
  export declare const ENABLE_BROWSER_POOL: boolean;
37
41
  /**
38
42
  * Probe the complete HeadlessExperimental.beginFrame runtime contract.
@@ -55,6 +59,11 @@ export declare const _probeBeginFrameSupportForTests: typeof probeBeginFrameSupp
55
59
  export declare const _closeBrowserAfterFailedProbeForTests: typeof closeBrowserAfterFailedProbe;
56
60
  /** Test-only: reset the cached probe result. */
57
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>;
58
67
  /**
59
68
  * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
60
69
  *
@@ -13,7 +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 } from "./wslHost.js";
16
+ import { describeWslGpuAccess, detectWsl, } from "./wslHost.js";
17
17
  import { BrowserLeasePool, } from "./browserLeasePool.js";
18
18
  export { BrowserLeasePool } from "./browserLeasePool.js";
19
19
  let _puppeteer;
@@ -199,6 +199,30 @@ export function resolveHeadlessShellPath(config) {
199
199
  const home = homedir();
200
200
  return (findCachedHeadlessShell(join(home, ".cache", "hyperframes", "chrome", "chrome-headless-shell")) ?? findCachedHeadlessShell(join(home, ".cache", "puppeteer", "chrome-headless-shell")));
201
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
+ }
202
226
  // Preserve the producer-era export so re-export shims keep the same public API.
203
227
  export const ENABLE_BROWSER_POOL = DEFAULT_CONFIG.enableBrowserPool;
204
228
  // Flags only meaningful when Chrome's compositor is driven by
@@ -403,35 +427,56 @@ function formatProbeFailure(err) {
403
427
  return `probe failed (${err instanceof Error ? err.message : String(err)})`;
404
428
  }
405
429
  async function probeAutoBrowserGpuMode(options) {
406
- const platform = options.platform ?? process.platform;
407
- const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
408
430
  const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
409
431
  const ppt = await getPuppeteerOrNull();
410
432
  if (ppt === null) {
411
433
  logResolvedBrowserGpuMode("software", "puppeteer unavailable");
412
434
  return { mode: "software", cause: "probe-error" };
413
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 = "") {
414
457
  try {
415
458
  const info = await probeHardwareWebGlInfo(ppt, {
416
- args: getHardwareGpuProbeArgs(platform),
417
- browserTimeout,
459
+ args: getHardwareGpuProbeArgs(options.platform ?? process.platform),
460
+ browserTimeout: options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout,
418
461
  executablePath,
419
462
  probeLifecycle: options.probeLifecycle,
420
463
  });
421
464
  const resolved = resolveWebGlProbeMode(info);
422
- logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info));
423
- if (resolved === "hardware")
424
- return { mode: "hardware" };
425
- return { mode: "software", cause: "no-gpu", ...wslAccess(options) };
465
+ logResolvedBrowserGpuMode(resolved, label + describeWebGlProbe(info));
466
+ return resolved === "hardware" ? { mode: "hardware" } : { mode: "software", cause: "no-gpu" };
426
467
  }
427
468
  catch (err) {
428
- logResolvedBrowserGpuMode("software", formatProbeFailure(err));
429
- return { mode: "software", cause: "probe-error", ...wslAccess(options) };
469
+ logResolvedBrowserGpuMode("software", label + formatProbeFailure(err));
470
+ return { mode: "software", cause: "probe-error" };
430
471
  }
431
472
  }
432
- function wslAccess(options) {
433
- const lines = describeWslGpuAccess(options.wslHost);
434
- return lines ? { wslAccess: lines } : {};
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);
435
480
  }
436
481
  /**
437
482
  * Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
@@ -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-v6-";
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-v6-";
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 { existsSync, readdirSync } from "fs";
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 = resolveHeadedChromePath();
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
  }
@@ -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
- * CFR extraction uses FFmpeg's fps filter, whose end boundary rounds to the
57
- * nearest frame. The VFR path normalizes with `-fps_mode cfr -r`, whose end
58
- * boundary rounds up. Keep this calculation shared by superset slicing and
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 routed through
127
- * the one-pass `-fps_mode cfr -r` extraction path. DEFINITION CHANGE:
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, runFfmpegPipeline } from "../utils/runFfmpeg.js";
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
- * CFR extraction uses FFmpeg's fps filter, whose end boundary rounds to the
45
- * nearest frame. The VFR path normalizes with `-fps_mode cfr -r`, whose end
46
- * boundary rounds up. Keep this calculation shared by superset slicing and
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
  */
@@ -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 sampleCfrAtOutputFps = !options.finalFrameOnly && !metadata.isVFR;
599
- if (options.frameRanges && !sampleCfrAtOutputFps) {
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 (sampleCfrAtOutputFps)
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 (sampleCfrAtOutputFps) {
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 resampleVfrToCfr = !options.finalFrameOnly && metadata.isVFR;
667
- if (resampleVfrToCfr)
668
- args.push("-fps_mode", "cfr", "-r", ffmpegFps);
669
- let processResult;
670
- if (resampleVfrToCfr &&
671
- vfFilters.length > 0 &&
672
- !isHdr &&
673
- NUT_RAW_PIXEL_FORMATS.has(metadata.pixelFormat ?? "")) {
674
- // ffmpeg <6.1 ignores frame durations in CFR resampling once any -vf is set,
675
- // cutting a trailing still short, so the SDR filters run in a second process.
676
- processResult = await inExtractionSlot(() => runFfmpegPipeline([...args, "-an", "-sn", "-dn", "-c:v", "rawvideo", "-f", "nut", "pipe:1"], [
677
- "-f",
678
- "nut",
679
- "-i",
680
- "pipe:0",
681
- "-vf",
682
- [...restoreSourceColourFilter(metadata), ...vfFilters].join(","),
683
- "-fps_mode",
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 normalization (`-fps_mode cfr -r`) establishes its duplicate/drop
1048
- // phase relative to each seek. A union extraction therefore cannot be
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));
@@ -17,8 +17,14 @@ export interface WslHostFacts {
17
17
  renderNodeAccess(path: string): RenderNodeAccess;
18
18
  }
19
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
+ };
20
26
  /**
21
27
  * Indented lines saying what we checked on the WSL GPU path and what to try,
22
28
  * or null outside WSL. Says nothing about the render mode: callers own that.
23
29
  */
24
- export declare function describeWslGpuAccess(host?: WslHostFacts): string[] | null;
30
+ export declare function describeWslGpuAccess(host?: WslHostFacts, fullChrome?: FullChromeRoute): string[] | null;
@@ -57,11 +57,24 @@ export function detectWsl(host = realHost) {
57
57
  const RENDER_NODE = "/dev/dri/renderD128";
58
58
  const NATIVE_WINDOWS = "render from native Windows (project copied to a Windows drive, run from PowerShell), " +
59
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
+ }
60
73
  /**
61
74
  * Indented lines saying what we checked on the WSL GPU path and what to try,
62
75
  * or null outside WSL. Says nothing about the render mode: callers own that.
63
76
  */
64
- export function describeWslGpuAccess(host = realHost) {
77
+ export function describeWslGpuAccess(host = realHost, fullChrome = { state: "missing" }) {
65
78
  if (!detectWsl(host))
66
79
  return null;
67
80
  const access = host.renderNodeAccess(RENDER_NODE);
@@ -69,21 +82,21 @@ export function describeWslGpuAccess(host = realHost) {
69
82
  return [
70
83
  ` Your Linux user cannot read and write ${RENDER_NODE} and is not in its group ` +
71
84
  `("${access.group}"), which can stop Mesa from reaching the GPU.`,
72
- ` Fix: run \`sudo usermod -aG ${access.group} $USER\`, then \`wsl --shutdown\` from ` +
73
- "Windows, reopen WSL and render again.",
74
- ` Or ${NATIVE_WINDOWS}`,
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),
75
88
  ];
76
89
  }
77
90
  if (access.state === "denied") {
78
91
  return [
79
92
  ` Your Linux user is in the "${access.group}" group but still cannot read and write ` +
80
93
  `${RENDER_NODE}; check its permissions with \`ls -l ${RENDER_NODE}\`.`,
81
- ` Or ${NATIVE_WINDOWS}`,
94
+ ...routeToGpu(fullChrome),
82
95
  ];
83
96
  }
84
97
  const seen = access.state === "missing" ? "does not exist" : "is readable and writable";
85
98
  return [
86
99
  ` ${RENDER_NODE} ${seen}, so file permissions are not the cause.`,
87
- ` To use the GPU, ${NATIVE_WINDOWS}`,
100
+ ...routeToGpu(fullChrome),
88
101
  ];
89
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>;
@@ -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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.155",
3
+ "version": "0.8.156",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "linkedom": "^0.18.12",
51
51
  "puppeteer": "^25.8.0",
52
52
  "puppeteer-core": "^25.8.0",
53
- "@hyperframes/core": "^0.8.155",
54
- "@hyperframes/parsers": "^0.8.155"
53
+ "@hyperframes/parsers": "^0.8.156",
54
+ "@hyperframes/core": "^0.8.156"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",