@hyperframes/engine 0.8.200 → 0.8.202

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -163,9 +163,9 @@ export interface EngineConfig {
163
163
  * renders stream. Env: PRODUCER_STREAMING_ENCODE_DURATION_CAP_ENABLED.
164
164
  */
165
165
  streamingEncodeDurationCapEnabled: boolean;
166
- /** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */
166
+ /** How long a disk-frame encode may go without a new frame before it is stopped (ms). Default: 600_000 */
167
167
  ffmpegEncodeTimeout: number;
168
- /** FFmpeg mux/faststart timeout (ms); extraction waits this long for each new frame. Default: 300_000 */
168
+ /** How long any other FFmpeg step (audio, mux, extraction) may go without progress (ms). Default: 300_000 */
169
169
  ffmpegProcessTimeout: number;
170
170
  /**
171
171
  * Inactivity timeout for FFmpeg streaming encode (ms). The timer resets on
package/dist/index.d.ts CHANGED
@@ -42,11 +42,12 @@ export { createCaptureSession, initializeSession, closeCaptureSession, captureFr
42
42
  export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type CaptureFailureKind, type CaptureWorkerDiagnostic, } from "./services/captureFailure.js";
43
43
  export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
44
44
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
45
- export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
45
+ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
46
46
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
47
47
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
48
48
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
49
49
  export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
50
+ export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
50
51
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
51
52
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
52
53
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
package/dist/index.js CHANGED
@@ -45,12 +45,13 @@ export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, } from "
45
45
  export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
46
46
  // ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
47
47
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
48
- export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
48
+ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
49
49
  // ── Encoding ───────────────────────────────────────────────────────────────────
50
50
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
51
51
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
52
52
  // ── Media processing ───────────────────────────────────────────────────────────
53
53
  export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
54
+ export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
54
55
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
55
56
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
56
57
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
@@ -339,7 +339,7 @@ function ffmpegFailure(stage, result, timeoutMs, elementId) {
339
339
  reason = "cancelled";
340
340
  owner = "user";
341
341
  }
342
- else if (result.terminationReason === "deadline" || result.terminationReason === "inactivity") {
342
+ else if (result.terminationReason === "inactivity") {
343
343
  reason = "ffmpeg_timeout";
344
344
  retryable = true;
345
345
  }
@@ -692,14 +692,8 @@ async function generateSilence(outputPath, duration, signal, config) {
692
692
  failure,
693
693
  };
694
694
  }
695
- /** A mix graph's work grows with the film length (about 11 ms per film second at 96 inputs); so does its deadline. */
696
- const MIX_TIMEOUT_MS_PER_FILM_SECOND = 100;
697
- function mixProcessTimeout(config, totalDuration) {
698
- const configured = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
699
- return Math.max(configured, Math.ceil(totalDuration * MIX_TIMEOUT_MS_PER_FILM_SECOND));
700
- }
701
695
  async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, config) {
702
- const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
696
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
703
697
  const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
704
698
  if (tracks.length === 0) {
705
699
  const result = await generateSilence(outputPath, totalDuration, signal, config);
@@ -876,7 +870,7 @@ function groupNormalizeOptionUnsupported(stderr) {
876
870
  * only runs when this ffmpeg build's `amix` rejects the option.
877
871
  */
878
872
  async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, signal, config) {
879
- const ffmpegProcessTimeout = mixProcessTimeout(config, totalDuration);
873
+ const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
880
874
  const outputDir = dirname(outputPath);
881
875
  if (!existsSync(outputDir))
882
876
  mkdirSync(outputDir, { recursive: true });
@@ -124,13 +124,13 @@ export declare function usesSoftwareWebGpu(requiresWebGpu: boolean | undefined,
124
124
  export declare function buildChromeArgs(options: BuildChromeArgsOptions, config?: GpuConfig): string[];
125
125
  /** Does the root, or a mounted root (data-hf-inner-root once bundled), declare `data-requires-webgpu`? */
126
126
  export declare function compositionRequiresWebGpu(html: string): boolean;
127
- /** No hardware WebGPU adapter on this host; distinct from a browser or navigation failure. */
127
+ /** No usable WebGPU device on this launch; distinct from a browser or navigation failure. */
128
128
  export declare class WebGpuUnavailableError extends Error {
129
129
  constructor();
130
130
  }
131
131
  /**
132
- * Confirms the already-navigated page can obtain a hardware WebGPU adapter, or any adapter when the launch runs
133
- * software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
132
+ * Confirms the already-navigated page can create a device on a hardware WebGPU adapter, or any adapter when
133
+ * the launch runs software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
134
134
  * `page` must be on a secure-context origin, or navigator.gpu always reads absent.
135
135
  */
136
136
  export declare function assertWebGpuAdapterAvailable(page: Page, requiresWebGpu: boolean, softwareWebGpu?: boolean): Promise<void>;
@@ -666,7 +666,7 @@ async function launchBrowser(fingerprint) {
666
666
  });
667
667
  const browserVersion = await browser.version().catch(() => "unknown");
668
668
  const gpuFlags = fingerprint.args.filter((a) => a.startsWith("--use-gl=") || a.startsWith("--use-angle="));
669
- console.log(`[BrowserManager] Browser launched (${browserVersion}, ${captureMode}, gl=${gpuFlags.join(" ") || "default"}, headlessShell=${!!fingerprint.executablePath}, platform=${process.platform})`);
669
+ console.error(`[BrowserManager] Browser launched (${browserVersion}, ${captureMode}, gl=${gpuFlags.join(" ") || "default"}, headlessShell=${!!fingerprint.executablePath}, platform=${process.platform})`);
670
670
  if (captureMode === "beginframe") {
671
671
  const probe = await probeBeginFrameSupport(browser).catch((error) => ({
672
672
  supported: true,
@@ -909,43 +909,63 @@ export function compositionRequiresWebGpu(html) {
909
909
  }
910
910
  return false;
911
911
  }
912
- /** No hardware WebGPU adapter on this host; distinct from a browser or navigation failure. */
912
+ /** No usable WebGPU device on this launch; distinct from a browser or navigation failure. */
913
913
  export class WebGpuUnavailableError extends Error {
914
914
  constructor() {
915
- super("This composition declares data-requires-webgpu, but no hardware WebGPU adapter could be " +
916
- "obtained on this browser launch. Run on a host with a GPU, set " +
915
+ super("This composition declares data-requires-webgpu, but a usable WebGPU device could not be " +
916
+ "created on this browser launch. Run on a host with a GPU, set " +
917
917
  "PRODUCER_ALLOW_SOFTWARE_WEBGPU=true to render it on SwiftShader (slow), or remove " +
918
918
  "data-requires-webgpu.");
919
919
  this.name = "WebGpuUnavailableError";
920
920
  }
921
921
  }
922
- const WEBGPU_ADAPTER_PROBE_TIMEOUT_MS = 10_000;
922
+ const WEBGPU_DEVICE_PROBE_TIMEOUT_MS = 10_000;
923
923
  /**
924
- * Confirms the already-navigated page can obtain a hardware WebGPU adapter, or any adapter when the launch runs
925
- * software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
924
+ * Confirms the already-navigated page can create a device on a hardware WebGPU adapter, or any adapter when
925
+ * the launch runs software WebGPU ({@link usesSoftwareWebGpu}); no-op otherwise.
926
926
  * `page` must be on a secure-context origin, or navigator.gpu always reads absent.
927
927
  */
928
928
  export async function assertWebGpuAdapterAvailable(page, requiresWebGpu, softwareWebGpu = false) {
929
929
  if (!requiresWebGpu)
930
930
  return;
931
- // requestAdapter() has no native timeout; a broken driver can hang it
932
- // indefinitely. Race it in-page so a stuck adapter reads as "unavailable"
933
- // instead of hanging the caller.
934
- const hasUsableAdapter = await page.evaluate(async (timeoutMs, acceptFallback) => {
931
+ // An adapter can exist even when missing driver libraries make device creation fail.
932
+ // Both requests can hang; share one deadline and release late devices as well.
933
+ const hasUsableDevice = await page.evaluate(async (timeoutMs, acceptFallback) => {
935
934
  if (typeof navigator === "undefined" || !navigator.gpu)
936
935
  return false;
936
+ let expired = false;
937
+ let timer;
937
938
  try {
938
- const adapter = await Promise.race([
939
- navigator.gpu.requestAdapter(),
940
- new Promise((resolve) => setTimeout(() => resolve(null), timeoutMs)),
939
+ const probe = async () => {
940
+ const adapter = await navigator.gpu.requestAdapter();
941
+ if (!adapter || expired || (!acceptFallback && adapter.info.isFallbackAdapter))
942
+ return false;
943
+ const device = await adapter.requestDevice();
944
+ try {
945
+ return !expired;
946
+ }
947
+ finally {
948
+ device.destroy();
949
+ }
950
+ };
951
+ return await Promise.race([
952
+ probe(),
953
+ new Promise((resolve) => {
954
+ timer = setTimeout(() => {
955
+ expired = true;
956
+ resolve(false);
957
+ }, timeoutMs);
958
+ }),
941
959
  ]);
942
- return !!adapter && (acceptFallback || !adapter.info.isFallbackAdapter);
943
960
  }
944
961
  catch {
945
962
  return false;
946
963
  }
947
- }, WEBGPU_ADAPTER_PROBE_TIMEOUT_MS, softwareWebGpu);
948
- if (!hasUsableAdapter)
964
+ finally {
965
+ clearTimeout(timer);
966
+ }
967
+ }, WEBGPU_DEVICE_PROBE_TIMEOUT_MS, softwareWebGpu);
968
+ if (!hasUsableDevice)
949
969
  throw new WebGpuUnavailableError();
950
970
  }
951
971
  function getBrowserGpuArgs(mode, platform) {
@@ -27,15 +27,9 @@ export const ENCODER_PRESETS = {
27
27
  function appendEncodeTimeoutMessage(error, timedOut, timeoutMs) {
28
28
  if (!timedOut)
29
29
  return error;
30
- // Two independent reports of this exact timeout, both resolved by env vars
31
- // that already exist but aren't named anywhere the user would see them at
32
- // the point of failure — they had to go find FFMPEG_ENCODE_TIMEOUT_MS and
33
- // PRODUCER_ENABLE_CHUNKED_ENCODE themselves. Name both here instead of
34
- // just stating what happened.
35
- return (`${error}\nFFmpeg killed after exceeding ffmpegEncodeTimeout (${timeoutMs} ms). ` +
36
- "Long or high-frame-count renders may need more time: set FFMPEG_ENCODE_TIMEOUT_MS " +
37
- "to a higher value (ms), or set PRODUCER_ENABLE_CHUNKED_ENCODE=true to encode in " +
38
- "smaller chunks instead of one long-running ffmpeg process.");
30
+ return (`${error}\nFFmpeg killed after writing no new frame for ffmpegEncodeTimeout (${timeoutMs} ms). ` +
31
+ "An encode that is still writing frames is never stopped; if this encoder legitimately " +
32
+ "pauses longer between frames, raise FFMPEG_ENCODE_TIMEOUT_MS (ms).");
39
33
  }
40
34
  function isAacSidecar(audioPath) {
41
35
  return extname(audioPath).toLowerCase() === ".aac";
@@ -430,7 +424,7 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
430
424
  framesEncoded: 0,
431
425
  fileSize: 0,
432
426
  error: appendEncodeTimeoutMessage(frameReadFailure(result.stderr, framePattern, 0, framesRead, frameCount) +
433
- formatFfmpegError(result.exitCode, result.stderr), result.terminationReason === "deadline", encodeTimeout),
427
+ formatFfmpegError(result.exitCode, result.stderr), result.terminationReason === "inactivity", encodeTimeout),
434
428
  failureReason: isExternalFfmpegInterruption(result) ? "external_interruption" : undefined,
435
429
  };
436
430
  }
@@ -492,7 +486,7 @@ export async function concatVideoFiles(inputPaths, outputPath, signal, config) {
492
486
  return { success: true };
493
487
  return {
494
488
  success: false,
495
- error: appendEncodeTimeoutMessage(`Chunk concat failed: ${result.stderr.slice(-400)}`, result.terminationReason === "deadline", encodeTimeout),
489
+ error: appendEncodeTimeoutMessage(`Chunk concat failed: ${result.stderr.slice(-400)}`, result.terminationReason === "inactivity", encodeTimeout),
496
490
  externalInterruption: isExternalFfmpegInterruption(result),
497
491
  };
498
492
  }
@@ -566,7 +560,7 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
566
560
  success: processResult.success,
567
561
  error: processResult.success
568
562
  ? undefined
569
- : appendEncodeTimeoutMessage(frameReadFailure(processResult.stderr, framePattern, startNumber, framesRead, framesInChunk) + `Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`, processResult.terminationReason === "deadline", encodeTimeout),
563
+ : appendEncodeTimeoutMessage(frameReadFailure(processResult.stderr, framePattern, startNumber, framesRead, framesInChunk) + `Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`, processResult.terminationReason === "inactivity", encodeTimeout),
570
564
  };
571
565
  if (!chunkResult.success) {
572
566
  return {
@@ -17,7 +17,7 @@ import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERT
17
17
  import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
18
18
  // ── Extracted modules ───────────────────────────────────────────────────────
19
19
  import { acquireBrowser, releaseBrowser, forceReleaseBrowser, buildChromeArgs, resolveBrowserGpuMode, captureWithoutWaylandDisplay, resolveHeadlessShellPath, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, } from "./browserManager.js";
20
- import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
20
+ import { beginFrameCapture, ensureRenderFrameSiblings, getCdpSession, pageContentExceedsCaptureHeight, pageScreenshotCapture, initTransparentBackground, capturesAlpha, shouldDefaultCaptureBeyondViewport, } from "./screenshotService.js";
21
21
  import { classifyGpuRenderer, detectGpuBackend, injectDrawElementCanvas, captureDrawElementFrame, resolveDrawElementCaptureMode, instrumentAcceleratedCanvases, initDrawElementWorkerEncode, cleanupDrawElementWorkerEncode, produceDrawElementFrame, produceDrawElementFrameBatch, DE_CANVAS_NOT_INITIALIZED_CODE, } from "./drawElementService.js";
22
22
  import { initThreeDProjection, scanCssEffects } from "./threeDProjection.js";
23
23
  import { planRiskFrames, scanCssEffectLiveFrames, riskSessionFits, withFramesAfterCallback, MAX_RISK_FRAME_FRACTION, } from "./riskFrames.js";
@@ -474,20 +474,20 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
474
474
  if ((session.config?.useDrawElement ?? false) && supersampling) {
475
475
  session.deGateReason = "supersampling";
476
476
  session.deFallbackTrigger = "supersampling";
477
- console.log("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
477
+ console.error("[engine] --experimental-fast-capture disabled for this render: drawElementImage " +
478
478
  "ignores deviceScaleFactor, so scaled (DPR other than 1) output uses screenshot capture.");
479
479
  }
480
480
  if ((session.config?.useDrawElement ?? false) && !supersampling && forceScreenshot) {
481
481
  session.deGateReason = "render_mode_hint";
482
482
  session.deFallbackTrigger = "render_mode_hint";
483
- console.log("[engine] fast capture falls back to screenshot: render-mode compatibility " +
483
+ console.error("[engine] fast capture falls back to screenshot: render-mode compatibility " +
484
484
  "hint forced screenshot capture (e.g. raw requestAnimationFrame composition).");
485
485
  }
486
486
  if (useDrawElement) {
487
487
  const gpuBackend = await detectGpuBackend(page);
488
488
  session.isSwiftShader = gpuBackend.isSwiftShader;
489
489
  session.gpuRenderer = classifyGpuRenderer(gpuBackend.renderer);
490
- const transparent = session.options.format === "png";
490
+ const transparent = capturesAlpha(session.options);
491
491
  async function routeToFallback() {
492
492
  session.captureMode = session.launchCaptureMode;
493
493
  if (transparent) {
@@ -528,7 +528,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
528
528
  if (!supportsDrawElement) {
529
529
  session.deGateReason = "unsupported_chrome";
530
530
  session.deFallbackTrigger = "unsupported_chrome";
531
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
531
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
532
532
  "this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
533
533
  "feature); run `hyperframes browser ensure --force` to fetch a supported " +
534
534
  "build, or set HYPERFRAMES_BROWSER_PATH to one.");
@@ -576,7 +576,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
576
576
  // "screenshot": on a BeginFrame-launched browser (Linux fast capture)
577
577
  // Page.captureScreenshot hangs for the full protocol timeout, while
578
578
  // beginFrameCapture is the platform's normal baseline path.
579
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
579
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
580
580
  "SwiftShader (software rasterizer, no GPU egress to skip, drawElement is " +
581
581
  "parity-or-slower; see fast-capture-limitations.md)");
582
582
  await routeToFallback();
@@ -598,7 +598,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
598
598
  const routeWholeRender = async (reason, trigger, message) => {
599
599
  session.deGateReason = reason;
600
600
  session.deFallbackTrigger = trigger;
601
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ${message}`);
601
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ${message}`);
602
602
  await routeToFallback();
603
603
  };
604
604
  if (!forceDE && process.env.HF_FAST_CAPTURE_CSSFX !== "true") {
@@ -682,7 +682,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
682
682
  if (!forceDE && !threeD.ok) {
683
683
  session.deGateReason = "3d_init_failed";
684
684
  session.deFallbackTrigger = "3d_init_failed";
685
- console.log(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
685
+ console.error(`[engine] fast capture falls back to ${session.launchCaptureMode} capture: ` +
686
686
  `3D projection init failed (${threeD.reason ?? "unknown"})`);
687
687
  await routeToFallback();
688
688
  return;
@@ -715,7 +715,7 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
715
715
  await finalizeDrawElementInit(session, page, logInitPhase, { transparent, forceDE });
716
716
  }
717
717
  }
718
- else if (session.options.format === "png") {
718
+ else if (capturesAlpha(session.options)) {
719
719
  await initTransparentBackground(session.page);
720
720
  }
721
721
  }
@@ -747,7 +747,7 @@ async function cssEffectRiskFrames(session, totalFrames) {
747
747
  return live && withFramesAfterCallback(live, scan.callbackFrom, fps, totalFrames);
748
748
  }
749
749
  catch (error) {
750
- console.log(`[engine] fast capture: effect scan failed (${error instanceof Error ? error.message : String(error)})`);
750
+ console.error(`[engine] fast capture: effect scan failed (${error instanceof Error ? error.message : String(error)})`);
751
751
  return null;
752
752
  }
753
753
  finally {
@@ -817,9 +817,9 @@ export async function completeDeferredDrawElementInit(session) {
817
817
  if (!session.deInitDeferred || !session.onBeforeCapture)
818
818
  return;
819
819
  const page = session.page;
820
- const logInitPhase = (phase) => console.log(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
820
+ const logInitPhase = (phase) => console.error(`[initSession:${session.captureMode}] ${phase} (deferred drawElement init)`);
821
821
  await finalizeDrawElementInit(session, page, logInitPhase, {
822
- transparent: session.options.format === "png",
822
+ transparent: capturesAlpha(session.options),
823
823
  forceDE: process.env.HF_FORCE_DRAWELEMENT === "1",
824
824
  });
825
825
  session.deInitDeferred = false;
@@ -852,7 +852,7 @@ export async function createCaptureSession(serverUrl, outputDir, options, onBefo
852
852
  const isLinux = process.platform === "linux";
853
853
  const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot;
854
854
  const useDrawElement = config?.useDrawElement ?? false;
855
- const drawElementTransparent = useDrawElement && options.format === "png";
855
+ const drawElementTransparent = useDrawElement && capturesAlpha(options);
856
856
  // drawElement and page-side shader compositing are mutually incompatible
857
857
  // capture strategies: drawElement reads the composition root's paint records
858
858
  // directly and skips the prepare→micro-screenshot→resolve protocol (the
@@ -1849,14 +1849,14 @@ export function classifyPageError(details, projectScripts) {
1849
1849
  export async function initializeSession(session) {
1850
1850
  const { page, serverUrl } = session;
1851
1851
  // Forward browser console to host. HyperFrames runtime logs get a dedicated
1852
- // prefix so page-context observability is visible in producer stdout.
1852
+ // prefix so page-context observability is visible in producer stderr.
1853
1853
  page.on("console", (msg) => {
1854
1854
  const type = msg.type();
1855
1855
  const text = msg.text();
1856
1856
  const locationUrl = msg.location()?.url ?? "";
1857
1857
  const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
1858
1858
  if (!diagnostic.suppressHostLog && !loggedEarlierInRender(session, text)) {
1859
- console.log(diagnostic.text);
1859
+ console.error(diagnostic.text);
1860
1860
  }
1861
1861
  appendBrowserDiagnostic(session, diagnostic.text);
1862
1862
  // A blocked script can never register its timeline (#3352); a thrown one may still, so it only counts on timeout.
@@ -1921,7 +1921,7 @@ export async function initializeSession(session) {
1921
1921
  const pageNavigationTimeout = session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
1922
1922
  const initStart = Date.now();
1923
1923
  const logInitPhase = (phase) => {
1924
- console.log(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
1924
+ console.error(`[initSession:${session.captureMode}] ${phase} (${Date.now() - initStart}ms)`);
1925
1925
  };
1926
1926
  const gotoEntryPage = async () => {
1927
1927
  appendBrowserDiagnostic(session, formatNavigationStartDiagnostic({
@@ -2673,7 +2673,7 @@ export async function createStaticVerificationPage(session) {
2673
2673
  page.evaluate(`document.fonts?.ready`),
2674
2674
  waitForOptionalTailwindReady(page, pageReadyTimeout),
2675
2675
  ]);
2676
- if (session.options.format === "png")
2676
+ if (capturesAlpha(session.options))
2677
2677
  await initTransparentBackground(page);
2678
2678
  return page;
2679
2679
  }
@@ -3377,6 +3377,13 @@ async function captureAccumulatedFrame(session, frameIndex, absFrameIndex, plan)
3377
3377
  ...totals,
3378
3378
  };
3379
3379
  }
3380
+ function framesAreStatic(staticFrames, from, to) {
3381
+ for (let frame = from; frame <= to; frame++) {
3382
+ if (!staticFrames.has(frame))
3383
+ return false;
3384
+ }
3385
+ return true;
3386
+ }
3380
3387
  async function captureFrameCore(session, frameIndex, time) {
3381
3388
  const { options } = session;
3382
3389
  const startTime = Date.now();
@@ -3397,7 +3404,8 @@ async function captureFrameCore(session, frameIndex, time) {
3397
3404
  const absFrameIndex = Math.floor(time * fpsToNumber(options.fps) + 1e-9);
3398
3405
  if (session.staticFrames?.has(absFrameIndex) &&
3399
3406
  session.lastFrameBuffer &&
3400
- session.lastFrameAbsoluteIndex === absFrameIndex - 1 &&
3407
+ session.lastFrameAbsoluteIndex !== undefined &&
3408
+ framesAreStatic(session.staticFrames, session.lastFrameAbsoluteIndex + 1, absFrameIndex) &&
3401
3409
  (!session.motionBlur ||
3402
3410
  motionBlurWindowIsStatic(session.motionBlur, absFrameIndex, session.staticFrames))) {
3403
3411
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
@@ -3425,7 +3433,7 @@ async function captureFrameCore(session, frameIndex, time) {
3425
3433
  if (session.capturePerf.frames === 1) {
3426
3434
  const blur = await describePageMotionBlur(session.page).catch(() => null);
3427
3435
  if (blur)
3428
- console.log(blur);
3436
+ console.error(blur);
3429
3437
  }
3430
3438
  // Retain this freshly-captured buffer so the following static frames can reuse it.
3431
3439
  if (session.staticFrames) {
@@ -3484,7 +3492,7 @@ export async function captureFrameToBuffer(session, frameIndex, time) {
3484
3492
  // works. Counted here rather than in captureFrameCore's catch: the
3485
3493
  // deadline rejects from outside it, so that catch never runs.
3486
3494
  session.deFrameTimeouts = (session.deFrameTimeouts ?? 0) + 1;
3487
- console.log(`[engine] fast capture frame ${frameIndex}: capture exceeded ` +
3495
+ console.error(`[engine] fast capture frame ${frameIndex}: capture exceeded ` +
3488
3496
  `${DE_FRAME_TIMEOUT_MS}ms; renderer stalled after drawElementImage ` +
3489
3497
  `(PRINFRA-488). Failing the drawElement attempt so the whole render ` +
3490
3498
  `retries via screenshot.`);
@@ -3527,13 +3535,7 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
3527
3535
  // same pixels. Sequential capture reduces to has(i) (gap = {i}); the
3528
3536
  // interleaved parallel stride makes the gap N frames wide.
3529
3537
  const lastIdx = session.lastEncodeResultFrame ?? frameIndex - 1;
3530
- let gapStatic = true;
3531
- for (let j = lastIdx + 1; j <= frameIndex; j++) {
3532
- if (!session.staticFrames.has(j)) {
3533
- gapStatic = false;
3534
- break;
3535
- }
3536
- }
3538
+ const gapStatic = framesAreStatic(session.staticFrames, lastIdx + 1, frameIndex);
3537
3539
  if (gapStatic) {
3538
3540
  session.staticDedupCount = (session.staticDedupCount ?? 0) + 1;
3539
3541
  session.capturePerf.frames += 1;
@@ -3689,7 +3691,7 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
3689
3691
  throw new DrawElementCaptureError(frameIndices[failedAt] ?? failedAt, reason, error);
3690
3692
  }
3691
3693
  else {
3692
- console.log(`[engine] fast capture: batch produce failed at frame ` +
3694
+ console.error(`[engine] fast capture: batch produce failed at frame ` +
3693
3695
  `${frameIndices[failedAt] ?? "?"} (${error ?? "?"}); ` +
3694
3696
  `re-capturing ${frameIndices.length - failedAt} frame(s) per-frame`);
3695
3697
  for (let i = failedAt; i < frameIndices.length; i++) {
@@ -3768,7 +3770,7 @@ export async function closeCaptureSession(session) {
3768
3770
  if (session.page && !session.pageReleased) {
3769
3771
  const blur = await describePageMotionBlurAtEnd(session.page);
3770
3772
  if (blur)
3771
- console.log(blur);
3773
+ console.error(blur);
3772
3774
  }
3773
3775
  // Realized static-dedup telemetry: how much the cache actually helped this
3774
3776
  // render (vs the prediction logged at arm time). Both capture paths
@@ -3781,7 +3783,7 @@ export async function closeCaptureSession(session) {
3781
3783
  const total = captured + reused;
3782
3784
  const pct = total > 0 ? Math.round((reused / total) * 100) : 0;
3783
3785
  const avgTotalMs = captured > 0 ? Math.round(session.capturePerf.totalMs / captured) : 0;
3784
- console.log(`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
3786
+ console.error(`[static-dedup] reused ${reused}/${total} frame(s) (${pct}%), ` +
3785
3787
  `est. ~${reused * avgTotalMs}ms saved (avg ${avgTotalMs}ms/frame)`);
3786
3788
  session.staticDedupCount = 0;
3787
3789
  }
@@ -358,7 +358,7 @@ async function captureFrameRange(session, task, captureOptions, signal, onFrameC
358
358
  throw new Error("Parallel worker cancelled");
359
359
  const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
360
360
  if (dbg && i < task.startFrame + dbgWin) {
361
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
361
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
362
362
  }
363
363
  const { encodeResult } = await runCaptureOperation("frame_capture", i, () => captureFrameToBufferPipelined(session, i - outputOffset, time));
364
364
  // Marks the promise "handled" for Node's unhandled-rejection detector
@@ -368,19 +368,19 @@ async function captureFrameRange(session, task, captureOptions, signal, onFrameC
368
368
  // and would otherwise surface as an unhandled rejection during teardown.
369
369
  encodeResult.catch(() => { });
370
370
  if (dbg && i < task.startFrame + dbgWin) {
371
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} kicked`);
371
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} kicked`);
372
372
  }
373
373
  if (prev) {
374
374
  if (dbg && prev.idx < task.startFrame + dbgWin) {
375
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} await-encode`);
375
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} await-encode`);
376
376
  }
377
377
  const buf = await awaitEncode(prev.idx, prev.encodeResult);
378
378
  if (dbg && prev.idx < task.startFrame + dbgWin) {
379
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} encoded ${buf.length}B`);
379
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} encoded ${buf.length}B`);
380
380
  }
381
381
  await onFrameBuffer(prev.idx, buf, session);
382
382
  if (dbg && prev.idx < task.startFrame + dbgWin) {
383
- console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} written`);
383
+ console.error(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms drain ${prev.idx} written`);
384
384
  }
385
385
  framesCaptured++;
386
386
  if (onFrameCaptured)
@@ -453,7 +453,7 @@ export function selectVerifySampleIndicesForTask(sampleIndices, task) {
453
453
  /** HF_DE_PAR_DEBUG=1 gated per-worker trace line (message built lazily). */
454
454
  function logParDebug(message) {
455
455
  if (process.env.HF_DE_PAR_DEBUG === "1")
456
- console.log(message());
456
+ console.error(message());
457
457
  }
458
458
  /**
459
459
  * Throw the verification error for a sample below the PSNR floor; log the
@@ -466,7 +466,7 @@ function assertDiskSampleAboveFloor(db, verifyMinDb, idx, workerId) {
466
466
  throw new DrawElementVerificationError(`drawElement self-verify failed at frame ${idx} (disk path, worker ${workerId}): ` +
467
467
  `${db.toFixed(1)}dB < ${verifyMinDb}dB vs pre-injection screenshot`, { kind: "psnr", frameIndex: idx, failedDb: db, verifyThresholdDb: verifyMinDb });
468
468
  }
469
- console.log(`[Parallel] drawElement disk self-verify passed (worker ${workerId}, frame ${idx}, ` +
469
+ console.error(`[Parallel] drawElement disk self-verify passed (worker ${workerId}, frame ${idx}, ` +
470
470
  `${db === Infinity ? "inf" : db.toFixed(1)}dB)`);
471
471
  }
472
472
  /**
@@ -73,6 +73,13 @@ export declare function pageScreenshotCapture(page: Page, options: CaptureOption
73
73
  * round-trip overhead.
74
74
  */
75
75
  export declare function captureScreenshotWithAlpha(page: Page, width: number, height: number): Promise<Buffer>;
76
+ /**
77
+ * Make the page transparent for alpha capture, then use captureAlphaPng() per frame. html/body always clear;
78
+ * [data-composition-id] roots (nested too) keep their background unless clearCompositionRoot (HDR layered pass).
79
+ * A repeat call rewrites the rule, so the last call wins.
80
+ */
81
+ /** Whether frames must carry alpha. PNG alone does not imply it: motion blur captures opaque output as PNG. */
82
+ export declare function capturesAlpha(options: Pick<CaptureOptions, "format" | "transparent">): boolean;
76
83
  export declare function initTransparentBackground(page: Page, { clearCompositionRoot }?: {
77
84
  clearCompositionRoot?: boolean;
78
85
  }): Promise<void>;
@@ -234,6 +234,10 @@ export async function captureScreenshotWithAlpha(page, width, height) {
234
234
  * [data-composition-id] roots (nested too) keep their background unless clearCompositionRoot (HDR layered pass).
235
235
  * A repeat call rewrites the rule, so the last call wins.
236
236
  */
237
+ /** Whether frames must carry alpha. PNG alone does not imply it: motion blur captures opaque output as PNG. */
238
+ export function capturesAlpha(options) {
239
+ return options.transparent ?? options.format === "png";
240
+ }
237
241
  const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
238
242
  export async function initTransparentBackground(page, { clearCompositionRoot = false } = {}) {
239
243
  const client = await getCdpSession(page);
@@ -23,7 +23,7 @@ import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
23
23
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
24
24
  import { downloadToTemp, isHttpUrl, safeDownloadUrlIdentity, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
25
25
  import { runFfmpeg } from "../utils/runFfmpeg.js";
26
- import { isFfmpegFilterAvailable } from "../utils/psnrFilterAvailability.js";
26
+ import { isFfmpegFilterAvailable, isFfmpegHwaccelAvailable, } from "../utils/psnrFilterAvailability.js";
27
27
  import { DEFAULT_CONFIG } from "../config.js";
28
28
  import { unwrapTemplate } from "../utils/htmlTemplate.js";
29
29
  import { FRAME_FILENAME_PREFIX, gcExtractionCache, gcSweepDue, lookupCacheEntry, partialCacheEntryDir, publishCacheEntry, readContentSha256, readKeyStat, rehydrateCacheEntry, touchCacheEntry, } from "./extractionCache.js";
@@ -606,7 +606,12 @@ outputDirOverride) {
606
606
  const isHdr = isHdrColorSpaceUtil(metadata.colorSpace);
607
607
  const isMacOS = process.platform === "darwin";
608
608
  const toneMappedToSdr = options.toneMapHdrToSdr === true && isHdr && (await toneMapsWithZscale());
609
- const decodeWithVideoToolbox = isHdr && isMacOS && !toneMappedToSdr;
609
+ const keepsHdrForChrome = isHdr && isMacOS && !toneMappedToSdr;
610
+ const decodeWithVideoToolbox = keepsHdrForChrome ||
611
+ (isMacOS &&
612
+ !metadata.hasAlpha &&
613
+ !codecMayHaveAlpha(metadata.videoCodec) &&
614
+ (await isFfmpegHwaccelAvailable("videotoolbox")));
610
615
  const sampleAtOutputFps = !options.finalFrameOnly;
611
616
  if (options.frameRanges && (!sampleAtOutputFps || metadata.isVFR)) {
612
617
  throw new Error("frameRanges extraction needs a constant-frame-rate source");
@@ -642,7 +647,7 @@ outputDirOverride) {
642
647
  args.push("-ss", String(startTime), "-i", videoPath, "-t", String(duration));
643
648
  }
644
649
  const vfFilters = [];
645
- if (decodeWithVideoToolbox) {
650
+ if (keepsHdrForChrome) {
646
651
  vfFilters.push("format=nv12");
647
652
  }
648
653
  const samplingAt = vfFilters.length;
@@ -685,8 +690,7 @@ outputDirOverride) {
685
690
  if (format === "png")
686
691
  encodeArgs.push("-compression_level", "1");
687
692
  encodeArgs.push("-y", outputPattern);
688
- // A long 4K run can take far longer than the timeout and still be healthy; only a stall is a hang.
689
- const runOptions = { signal, stallTimeout: ffmpegProcessTimeout };
693
+ const runOptions = { signal, timeout: ffmpegProcessTimeout };
690
694
  const filterArgsFor = (seek) => {
691
695
  const filters = [...vfFilters];
692
696
  filters.splice(samplingAt, 0, ...samplingFilters(seek));
package/dist/types.d.ts CHANGED
@@ -150,6 +150,7 @@ export interface CaptureOptions {
150
150
  */
151
151
  fps: Fps;
152
152
  format?: "jpeg" | "png";
153
+ transparent?: boolean;
153
154
  quality?: number;
154
155
  /**
155
156
  * Opt into sub-frame multi-sample motion blur (issue #4010). Absent means off and the
@@ -9,5 +9,6 @@
9
9
  */
10
10
  export declare function isPsnrFilterAvailable(): Promise<boolean>;
11
11
  export declare function isFfmpegFilterAvailable(name: string): Promise<boolean>;
12
+ export declare function isFfmpegHwaccelAvailable(name: string): Promise<boolean>;
12
13
  /** Test-only: drop the memoized probe result. */
13
14
  export declare function resetPsnrFilterAvailabilityCache(): void;
@@ -14,7 +14,14 @@ import { getFfmpegBinary } from "./ffmpegBinaries.js";
14
14
  * not change across renders within the same CLI invocation, and re-probing
15
15
  * per session would burn ~50-100ms of subprocess spawn per capture worker.
16
16
  */
17
- let cachedFilterList = null;
17
+ const cachedLists = new Map();
18
+ function listed(flag) {
19
+ let list = cachedLists.get(flag);
20
+ if (!list)
21
+ cachedLists.set(flag, (list = listFfmpeg(flag)));
22
+ return list;
23
+ }
24
+ const listsWord = (stdout, name) => stdout !== null && new RegExp(`(^|\\s)${name}(\\s|$)`, "m").test(stdout);
18
25
  /**
19
26
  * Returns true when the resident ffmpeg exposes the `psnr` filter. False on
20
27
  * any probe failure — missing binary (ENOENT), non-zero exit, timeout,
@@ -27,25 +34,25 @@ let cachedFilterList = null;
27
34
  export function isPsnrFilterAvailable() {
28
35
  return isFfmpegFilterAvailable("psnr");
29
36
  }
37
+ // `-filters` and `-hwaccels` list one name per line; a whole-word match ignores longer names
38
+ // and banner prose that mentions it.
30
39
  export async function isFfmpegFilterAvailable(name) {
31
- if (cachedFilterList === null)
32
- cachedFilterList = listFilters();
33
- const stdout = await cachedFilterList;
34
- // `-filters` lists one filter per line; a whole-word match ignores longer names
35
- // and banner prose that mentions the filter.
36
- return stdout !== null && new RegExp(`(^|\\s)${name}(\\s|$)`, "m").test(stdout);
40
+ return listsWord(await listed("-filters"), name);
41
+ }
42
+ export async function isFfmpegHwaccelAvailable(name) {
43
+ return listsWord(await listed("-hwaccels"), name);
37
44
  }
38
45
  /** Test-only: drop the memoized probe result. */
39
46
  export function resetPsnrFilterAvailabilityCache() {
40
- cachedFilterList = null;
47
+ cachedLists.clear();
41
48
  }
42
- async function listFilters() {
49
+ async function listFfmpeg(flag) {
43
50
  // Match `psnr.ts`: promisify lazily so a partial `node:child_process` mock
44
51
  // (test that omits `execFile`) doesn't crash at module load — it fails at
45
52
  // call time instead, and the try/catch below reports no filters at all.
46
53
  const execFileP = promisify(execFile);
47
54
  try {
48
- const { stdout } = await execFileP(getFfmpegBinary(), ["-hide_banner", "-filters"], {
55
+ const { stdout } = await execFileP(getFfmpegBinary(), ["-hide_banner", flag], {
49
56
  maxBuffer: 4 * 1024 * 1024,
50
57
  timeout: 5_000,
51
58
  windowsHide: true,
@@ -7,9 +7,8 @@
7
7
  import { type ManagedProcessTerminationReason } from "./managedChildProcess.js";
8
8
  export interface RunFfmpegOptions {
9
9
  signal?: AbortSignal;
10
+ /** Stop after this long without a higher frame count or output time (counted from spawn until one). */
10
11
  timeout?: number;
11
- /** Replaces `timeout`: stop only once this long passes without a new output frame. */
12
- stallTimeout?: number;
13
12
  onStderr?: (line: string) => void;
14
13
  }
15
14
  export interface RunFfmpegResult {
@@ -49,6 +48,6 @@ export declare function ffmpegStatsReader(onStats: (stats: FfmpegStats) => void)
49
48
  * exactly which option to fix.
50
49
  */
51
50
  export declare function formatFfmpegError(exitCode: number | null, stderr: string, tailLines?: number): string;
52
- /** formatFfmpegError, led by the timeout when this process was killed at its deadline. */
51
+ /** formatFfmpegError, led by the timeout when this process was killed for making no progress. */
53
52
  export declare function describeFfmpegFailure(result: Pick<RunFfmpegResult, "exitCode" | "signal" | "stderr" | "terminationReason">, timeoutMs: number): string;
54
53
  export declare function runFfmpeg(args: string[], opts?: RunFfmpegOptions): Promise<RunFfmpegResult>;
@@ -94,11 +94,11 @@ export function formatFfmpegError(exitCode, stderr, tailLines = DEFAULT_STDERR_T
94
94
  ? `FFmpeg exited with code ${exitCode}\nffmpeg stderr (tail):\n${tail}`
95
95
  : `FFmpeg exited with code ${exitCode}`;
96
96
  }
97
- /** formatFfmpegError, led by the timeout when this process was killed at its deadline. */
97
+ /** formatFfmpegError, led by the timeout when this process was killed for making no progress. */
98
98
  export function describeFfmpegFailure(result, timeoutMs) {
99
99
  const error = formatFfmpegError(result.exitCode, result.stderr);
100
- if (result.terminationReason === "deadline") {
101
- return (`FFmpeg timed out after ${timeoutMs} ms (ffmpegProcessTimeout; long renders can raise ` +
100
+ if (result.terminationReason === "inactivity") {
101
+ return (`FFmpeg timed out after ${timeoutMs} ms without progress (ffmpegProcessTimeout, ` +
102
102
  `FFMPEG_PROCESS_TIMEOUT_MS). ${error}`);
103
103
  }
104
104
  if (result.exitCode !== null)
@@ -147,25 +147,22 @@ const ABORTED_BEFORE_START = {
147
147
  export async function runFfmpeg(args, opts) {
148
148
  if (opts?.signal?.aborted)
149
149
  return { ...ABORTED_BEFORE_START };
150
- const stallTimeout = opts?.stallTimeout;
151
150
  let framesWritten = 0;
152
- const readStats = ffmpegStatsReader(({ frames }) => {
153
- if (frames === undefined || frames <= framesWritten)
151
+ let secondsWritten = 0;
152
+ const readStats = ffmpegStatsReader(({ frames = 0, seconds = 0 }) => {
153
+ if (frames <= framesWritten && seconds <= secondsWritten)
154
154
  return;
155
- framesWritten = frames;
155
+ framesWritten = Math.max(frames, framesWritten);
156
+ secondsWritten = Math.max(seconds, secondsWritten);
156
157
  managed.markActivity();
157
158
  });
158
159
  const managed = new ManagedChildProcess(spawnFfmpeg(args), {
159
160
  signal: opts?.signal,
160
- ...(stallTimeout === undefined
161
- ? { deadlineAtMs: Date.now() + (opts?.timeout ?? DEFAULT_TIMEOUT) }
162
- : { inactivityTimeoutMs: stallTimeout }),
163
- onStderr: stallTimeout === undefined
164
- ? opts?.onStderr
165
- : (chunk) => {
166
- readStats(chunk);
167
- opts?.onStderr?.(chunk);
168
- },
161
+ inactivityTimeoutMs: opts?.timeout ?? DEFAULT_TIMEOUT,
162
+ onStderr: (chunk) => {
163
+ readStats(chunk);
164
+ opts?.onStderr?.(chunk);
165
+ },
169
166
  });
170
167
  return toResult(await managed.wait());
171
168
  }
@@ -586,7 +586,7 @@ function checksumMismatchError(source, status, telemetry) {
586
586
  // Response protocol, streamed byte accounting, hashes, and payload validation
587
587
  // share one lifecycle so no validation can happen after publication.
588
588
  // fallow-ignore-next-line complexity
589
- async function fetchToPartial(url, partialPath, controller, options) {
589
+ async function fetchToPartial(url, partialPath, controller, options, onSizedBodyBytes) {
590
590
  const { response, finalUrl } = await fetchWithValidatedRedirects(url, controller);
591
591
  const finalIdentity = safeDownloadUrlIdentity(finalUrl);
592
592
  const rangeDisposition = classifyRangeDisposition(response);
@@ -643,6 +643,8 @@ async function fetchToPartial(url, partialPath, controller, options) {
643
643
  const inspector = new Transform({
644
644
  transform(chunk, _encoding, callback) {
645
645
  const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
646
+ if (declaredLength !== undefined)
647
+ onSizedBodyBytes();
646
648
  receivedBytes += bytes.length;
647
649
  sha256.update(bytes);
648
650
  md5.update(bytes);
@@ -771,16 +773,26 @@ async function runDownloadAttempt(url, localPath, timeoutMs, attempt, options, s
771
773
  controller.abort();
772
774
  };
773
775
  signal?.addEventListener("abort", onCallerAbort, { once: true });
774
- const timeoutId = setTimeout(() => {
775
- timedOut = true;
776
- controller.abort();
777
- }, timeoutMs);
776
+ let timeoutId;
777
+ const armTimeout = () => {
778
+ clearTimeout(timeoutId);
779
+ timeoutId = setTimeout(() => {
780
+ timedOut = true;
781
+ controller.abort();
782
+ }, timeoutMs);
783
+ };
784
+ armTimeout();
785
+ let sizedBodyStarted = false;
786
+ const extendTimeout = () => {
787
+ sizedBodyStarted = true;
788
+ armTimeout();
789
+ };
778
790
  const identity = safeDownloadUrlIdentity(url);
779
791
  try {
780
792
  if (callerAborted) {
781
793
  throw new UrlDownloadError("cancelled", false, "Download cancelled");
782
794
  }
783
- const integrity = await fetchToPartial(url, partialPath, controller, options);
795
+ const integrity = await fetchToPartial(url, partialPath, controller, options, extendTimeout);
784
796
  let outcome = syncAndPublishPartial(partialPath, localPath);
785
797
  let publishedIntegrity = integrity;
786
798
  if (outcome === "race_reused") {
@@ -827,7 +839,9 @@ async function runDownloadAttempt(url, localPath, timeoutMs, attempt, options, s
827
839
  throw classified;
828
840
  }
829
841
  if (timedOut) {
830
- const classified = new UrlDownloadError("timeout", true, `Download timeout after ${timeoutMs / 1000}s`);
842
+ const classified = new UrlDownloadError("timeout", true, sizedBodyStarted
843
+ ? `Download stalled: no data for ${timeoutMs / 1000}s`
844
+ : `Download timeout after ${timeoutMs / 1000}s`);
831
845
  emitDownloadTelemetry(options, {
832
846
  urlFingerprint: identity.urlFingerprint,
833
847
  initialHost: identity.host,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.200",
3
+ "version": "0.8.202",
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.200",
54
- "@hyperframes/parsers": "^0.8.200"
53
+ "@hyperframes/core": "^0.8.202",
54
+ "@hyperframes/parsers": "^0.8.202"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",