@hyperframes/engine 0.8.225 → 0.8.227

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
@@ -54,6 +54,8 @@ export interface EngineConfig {
54
54
  * `HF_STATIC_DEDUP` in {false,0,off}. Only arms in screenshot capture mode.
55
55
  */
56
56
  staticFrameDedup: boolean;
57
+ /** Opt-in `HF_VIDEO_LAYER_PASSTHROUGH`: ffmpeg composites a full-canvas source video under the page capture. */
58
+ videoLayerPassthrough: boolean;
57
59
  /**
58
60
  * Use drawElementImage for frame capture (requires the CanvasDrawElement
59
61
  * Chrome flag, added globally in buildChromeArgs). Default ON, clamped in
package/dist/config.js CHANGED
@@ -29,6 +29,7 @@ export const DEFAULT_CONFIG = {
29
29
  protocolTimeout: 300_000,
30
30
  forceScreenshot: false,
31
31
  staticFrameDedup: true,
32
+ videoLayerPassthrough: false,
32
33
  useDrawElement: true,
33
34
  enableDrawElementWorkerEncode: true,
34
35
  // Auto-detected per host in `resolveConfig`; defaults off for the raw
@@ -72,6 +73,7 @@ const BOOLEAN_ENGINE_CONFIG_FIELDS = [
72
73
  "enableBrowserPool",
73
74
  "forceScreenshot",
74
75
  "staticFrameDedup",
76
+ "videoLayerPassthrough",
75
77
  "useDrawElement",
76
78
  "enableDrawElementWorkerEncode",
77
79
  "lowMemoryMode",
@@ -512,6 +514,7 @@ export function resolveConfig(overrides) {
512
514
  : undefined,
513
515
  forceScreenshot: envBool("PRODUCER_FORCE_SCREENSHOT", DEFAULT_CONFIG.forceScreenshot),
514
516
  staticFrameDedup: resolveStaticFrameDedup(),
517
+ videoLayerPassthrough: envBool("HF_VIDEO_LAYER_PASSTHROUGH", DEFAULT_CONFIG.videoLayerPassthrough),
515
518
  useDrawElement: envBool("PRODUCER_EXPERIMENTAL_FAST_CAPTURE", DEFAULT_CONFIG.useDrawElement),
516
519
  enableDrawElementWorkerEncode: envBool("HF_DE_WORKER_ENCODE", DEFAULT_CONFIG.enableDrawElementWorkerEncode),
517
520
  lowMemoryMode: resolveLowMemoryMode(),
package/dist/index.d.ts CHANGED
@@ -47,7 +47,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
47
47
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, type ProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
48
48
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
49
49
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
50
- export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, 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, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
50
+ export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, 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, sdrCanvasColourFilters, buildVideoLayerInput, type VideoLayerInput, } from "./services/videoFrameExtractor.js";
51
+ export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
51
52
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
52
53
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
53
54
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
package/dist/index.js CHANGED
@@ -51,7 +51,8 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
51
51
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, getCachedGpuEncoder, ENCODER_PRESETS, getEncoderPreset, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
52
52
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
53
53
  // ── Media processing ───────────────────────────────────────────────────────────
54
- export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
54
+ export { parseVideoElements, hasGradedCanvas, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, videoStartOnMediaTimeline, ffmpegSeekSeconds, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, buildVideoLayerInput, } from "./services/videoFrameExtractor.js";
55
+ export { qualifyVideoLayer } from "./services/videoLayerPassthrough.js";
55
56
  export { isSelfOrAncestorHidden } from "./services/mediaHidden.js";
56
57
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
57
58
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
@@ -105,7 +105,7 @@ export declare function appendRateCapArgs(args: string[], options: {
105
105
  maxBitrate?: string;
106
106
  bufferSize?: string;
107
107
  }): void;
108
- export declare function buildEncoderArgs(options: EncoderOptions, inputArgs: string[], outputPath: string, gpuEncoder?: GpuEncoder): string[];
108
+ export declare function buildEncoderArgs(options: EncoderOptions, inputArgs: string[], outputPath: string, gpuEncoder?: GpuEncoder, nvencPresets?: ReadonlySet<string>): string[];
109
109
  export declare function encodeFramesFromDir(framesDir: string, framePattern: string, outputPath: string, options: EncoderOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>, onFramesEncoded?: (frames: number) => void): Promise<EncodeResult>;
110
110
  export declare function buildConcatArgs(concatListPath: string, outputPath: string): string[];
111
111
  /**
@@ -9,7 +9,7 @@ import { frameSequencePath } from "./extractedFrameIndex.js";
9
9
  import { closeSync, copyFileSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync, } from "fs";
10
10
  import { join, dirname, extname, resolve } from "path";
11
11
  import { DEFAULT_CONFIG } from "../config.js";
12
- import { getCachedGpuEncoder, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
12
+ import { getCachedGpuEncoder, getCachedNvencPresets, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
13
13
  import { getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
14
14
  import { withEvenDimensionPad } from "../utils/evenDimensions.js";
15
15
  import { SDR_CAPTURE_TO_BT709_FILTER, SDR_RGB_TO_TAGGED_BT709_FILTER, } from "../utils/sdrCaptureColor.js";
@@ -169,7 +169,7 @@ export function appendRateCapArgs(args, options) {
169
169
  }
170
170
  args.push("-maxrate", String(Math.round(cap)), "-bufsize", String(Math.round(buffer)));
171
171
  }
172
- export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null) {
172
+ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null, nvencPresets) {
173
173
  const { fps, codec = "h264", preset = "medium", quality = 23, bitrate, pixelFormat = "yuv420p", vp9CpuUsed, useGpu = false, } = options;
174
174
  // libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd
175
175
  // produce a "half-HDR" file (BT.2020 container tags but a BT.709 VUI block
@@ -190,7 +190,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
190
190
  args.push("-c:v", encoderName);
191
191
  switch (gpuEncoder) {
192
192
  case "nvenc":
193
- args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
193
+ args.push("-preset", mapPresetForGpuEncoder("nvenc", preset, nvencPresets));
194
194
  if (bitrate)
195
195
  args.push("-b:v", bitrate);
196
196
  else
@@ -437,7 +437,8 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
437
437
  }
438
438
  const inputPath = frameSequencePath(framesDir, framePattern);
439
439
  const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
440
- const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
440
+ const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
441
+ const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder, nvencPresets);
441
442
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
442
443
  let framesRead = 0;
443
444
  const result = await runFfmpeg(args, {
@@ -587,7 +588,8 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
587
588
  let gpuEncoder = null;
588
589
  if (options.useGpu)
589
590
  gpuEncoder = await getCachedGpuEncoder();
590
- const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
591
+ const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
592
+ const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder, nvencPresets);
591
593
  const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
592
594
  let framesRead = 0;
593
595
  const processResult = await runFfmpeg(args, {
@@ -494,7 +494,7 @@ export declare function isStaticDedupFrameAnalysisSafe(totalFrames: number): boo
494
494
  * comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
495
495
  * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
496
496
  */
497
- export declare function computeStaticFrameSet(page: Page, fps: number): Promise<{
497
+ export declare function computeStaticFrameSet(page: Page, fps: number, ignoredVideoIds?: readonly string[]): Promise<{
498
498
  totalFrames: number;
499
499
  staticFrameSet: Set<number>;
500
500
  /** Frames whose only active motion is non-spatial (opacity, colour); safe to skip
@@ -716,7 +716,11 @@ async function initDrawElementOrTransparentBackground(session, page, logInitPhas
716
716
  }
717
717
  }
718
718
  else if (capturesAlpha(session.options)) {
719
- await initTransparentBackground(session.page);
719
+ const hideElementIds = session.options.hiddenElementIds ?? [];
720
+ await initTransparentBackground(session.page, {
721
+ clearCompositionRoot: hideElementIds.length > 0,
722
+ hideElementIds,
723
+ });
720
724
  }
721
725
  }
722
726
  /** Worker-encode pipeline: macOS hardware GPU path only, JPEG output only. */
@@ -1534,7 +1538,7 @@ export async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100
1534
1538
  return true;
1535
1539
  if (ve.networkState === HTMLMediaElement.NETWORK_NO_SOURCE)
1536
1540
  return true;
1537
- return false;
1541
+ return !ve.getAttribute("src") && !ve.querySelector("source");
1538
1542
  }));
1539
1543
  }, skipIds));
1540
1544
  };
@@ -1631,6 +1635,8 @@ export async function collectMediaReadinessWarnings(page, skipIds, timeoutMs) {
1631
1635
  const mediaElement = media;
1632
1636
  if (skipped.has(mediaElement.id))
1633
1637
  continue;
1638
+ if (!mediaElement.getAttribute("src") && !mediaElement.querySelector("source"))
1639
+ continue;
1634
1640
  const src = mediaElement.currentSrc || mediaElement.getAttribute("src") || "(no src)";
1635
1641
  const failed = Boolean(mediaElement.error) ||
1636
1642
  mediaElement.networkState === HTMLMediaElement.NETWORK_NO_SOURCE;
@@ -2447,8 +2453,8 @@ export function isStaticDedupFrameAnalysisSafe(totalFrames) {
2447
2453
  * comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
2448
2454
  * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
2449
2455
  */
2450
- export async function computeStaticFrameSet(page, fps) {
2451
- const result = await page.evaluate((spatialProps) => {
2456
+ export async function computeStaticFrameSet(page, fps, ignoredVideoIds = []) {
2457
+ const result = await page.evaluate((spatialProps, ignored) => {
2452
2458
  // Passed in from SPATIAL_TWEEN_PROPERTIES rather than declared here, so the list
2453
2459
  // stays the one this module's own tests exercise (the matching code itself must
2454
2460
  // stay inline — this closure is serialized and runs in the page realm).
@@ -2463,20 +2469,8 @@ export async function computeStaticFrameSet(page, fps) {
2463
2469
  }
2464
2470
  const intervals = [];
2465
2471
  let tweenCount = 0;
2466
- // totalDuration() (NOT duration()): a repeat/yoyo tween animates past one iteration;
2467
- // a repeating timeline is marked opaque over its whole span (conservative).
2468
- // A GSAP tl.call() is a zero-duration tween whose vars wire the callback as
2469
- // onComplete (and onReverseComplete, fired on backward crossing — GSAP has
2470
- // no separate "undo" callback, so both directions invoke the SAME forward
2471
- // side effect). A one-shot DOM mutation driven this way (e.g. a counter's
2472
- // textContent) is not seek-idempotent: crossing it during the static-dedup
2473
- // verifier's own arm-time seeking permanently mutates the page, and that
2474
- // corruption can leak into a LATER, unrelated static run's real capture
2475
- // (the verifier's mismatch check only catches drift within the run being
2476
- // checked, not contamination from a run checked afterward). No reliable
2477
- // way to tell a DOM-mutating call() from a harmless one (analytics ping,
2478
- // class toggle with no visual effect) without executing it, so disqualify
2479
- // the whole comp on ANY call() rather than risk shipping wrong pixels.
2472
+ // totalDuration(), not duration(): repeat/yoyo tweens animate past one iteration.
2473
+ // Any tl.call() disqualifies the comp: its DOM side effects are not seek-idempotent.
2480
2474
  let hasTimelineCall = false;
2481
2475
  function walk(tl, offset) {
2482
2476
  if (typeof tl.getChildren !== "function")
@@ -2500,12 +2494,7 @@ export async function computeStaticFrameSet(page, fps) {
2500
2494
  // vars below; this span is a conservative (spatial) placeholder for the
2501
2495
  // repeat/yoyo overrun, which a per-child walk doesn't otherwise cover.
2502
2496
  intervals.push({ start, end: start + total, spatial: true });
2503
- // Still descend for hasTimelineCall even though the repeating
2504
- // span is already opaque (its frames are excluded from dedup
2505
- // regardless): a call() inside it is a review-flagged detection
2506
- // gap otherwise — the arm-time verifier can still forward-seek
2507
- // through this span while checking a LATER static run, firing
2508
- // the call() and corrupting the page (review).
2497
+ // Still descend: a call() inside the repeating span would otherwise go undetected.
2509
2498
  walk(child, start);
2510
2499
  }
2511
2500
  else {
@@ -2528,7 +2517,7 @@ export async function computeStaticFrameSet(page, fps) {
2528
2517
  if (tl && typeof tl.getChildren === "function")
2529
2518
  walk(tl, 0);
2530
2519
  }
2531
- const hasVideo = !!document.querySelector("video");
2520
+ const hasVideo = Array.from(document.querySelectorAll("video")).some((v) => !ignored.includes(v.id));
2532
2521
  const hasCanvas = !!document.querySelector("canvas");
2533
2522
  const hasFrameSources = w.__hfHasFrameSources?.() ?? false;
2534
2523
  const hasIframe = !!document.querySelector("iframe");
@@ -2543,7 +2532,8 @@ export async function computeStaticFrameSet(page, fps) {
2543
2532
  // getAnimations(); any running/paused one can change content without a tween.
2544
2533
  let hasNonGsapAnim = false;
2545
2534
  try {
2546
- const docAnims = document.getAnimations;
2535
+ const docAnims = document
2536
+ .getAnimations;
2547
2537
  if (typeof docAnims === "function") {
2548
2538
  hasNonGsapAnim = docAnims.call(document).some((a) => {
2549
2539
  const t = a;
@@ -2566,7 +2556,7 @@ export async function computeStaticFrameSet(page, fps) {
2566
2556
  hasUnresolvableClipStart,
2567
2557
  hasTimelineCall,
2568
2558
  };
2569
- }, SPATIAL_TWEEN_PROPERTIES);
2559
+ }, SPATIAL_TWEEN_PROPERTIES, [...ignoredVideoIds]);
2570
2560
  const { intervals, tweenCount, duration, hasVideo, hasCanvas, hasFrameSources, hasIframe, hasNonGsapAnim, hasUnresolvableClipStart, hasTimelineCall, } = result;
2571
2561
  const totalFrames = Math.max(1, Math.ceil(duration * fps));
2572
2562
  if (!isStaticDedupFrameAnalysisSafe(totalFrames)) {
@@ -2651,12 +2641,13 @@ export async function computeStaticFrameSet(page, fps) {
2651
2641
  // what fixes long runs going nearly unverified — deliberately NOT derived from
2652
2642
  // sampleCount, so that knob's effect on density stays monotonic (see below).
2653
2643
  const STATIC_VERIFY_REFERENCE_STRIDE = 24;
2654
- // Verification uses full-page screenshots, whose cost scales with canvas size
2655
- // and page complexity rather than frame count. Bound wall time as well as the
2656
- // capture count so a long composition cannot spend minutes proving an
2657
- // optimization before the real render starts. Exhaustion fails closed: dedup is
2658
- // disabled and normal capture proceeds.
2644
+ // Wall budget for verification: 15 s is the floor; above it the budget is 1.5x
2645
+ // planned shots x the first shot's seek+capture time, capped at 300 s (nothing
2646
+ // was gained past 600 s on a 4K job). Exhaustion fails closed: dedup is
2647
+ // disabled for the unverified runs and normal capture proceeds.
2659
2648
  const STATIC_VERIFY_MAX_MS = 15_000;
2649
+ const STATIC_VERIFY_HEADROOM = 1.5;
2650
+ const STATIC_VERIFY_CEILING_MS = 300_000;
2660
2651
  // Two captures (anchor + comparison) are the minimum proof for a run. Four hundred
2661
2652
  // therefore permits 200 fully verified runs while bounding pathological schedules.
2662
2653
  const STATIC_VERIFY_MIN_SCREENSHOT_CAP = 400;
@@ -2817,7 +2808,6 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2817
2808
  const now = dependencies.now ?? Date.now;
2818
2809
  const capture = dependencies.capture ?? pageScreenshotCapture;
2819
2810
  const startedAt = now();
2820
- const deadline = startedAt + STATIC_VERIFY_MAX_MS;
2821
2811
  const verifiedFrames = new Set();
2822
2812
  const stats = {
2823
2813
  plannedRuns: plan.runs.length,
@@ -2852,14 +2842,21 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2852
2842
  }, t);
2853
2843
  };
2854
2844
  const hardCap = Math.max(STATIC_VERIFY_MIN_SCREENSHOT_CAP, plan.effectiveSampleFloor * STATIC_VERIFY_SAMPLE_CAP_MULTIPLIER, Math.ceil(plan.predictedFrames / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + plan.runs.length);
2845
+ let firstShotMs = 0;
2855
2846
  const seekCapture = async (frameIdx) => {
2856
- if (now() >= deadline)
2847
+ const spent = now() - startedAt;
2848
+ const budget = Math.max(STATIC_VERIFY_MAX_MS, Math.min(STATIC_VERIFY_CEILING_MS, STATIC_VERIFY_HEADROOM * plan.plannedScreenshots * firstShotMs));
2849
+ if (spent >= budget)
2857
2850
  return "time_budget";
2858
2851
  if (stats.screenshots >= hardCap)
2859
2852
  return "count_budget";
2853
+ const shotStart = now();
2860
2854
  await seekToFrame(frameIdx);
2861
2855
  stats.screenshots++;
2862
- return capture(page, session.options);
2856
+ const buf = await capture(page, session.options);
2857
+ if (stats.screenshots === 1)
2858
+ firstShotMs = now() - shotStart;
2859
+ return buf;
2863
2860
  };
2864
2861
  // Verify profitable runs in deterministic savings order. A run is added to the armed
2865
2862
  // set only after its anchor and every planner-selected comparison match. Budget exits
@@ -2922,7 +2919,7 @@ async function armStaticDedup(session, page, logInitPhase) {
2922
2919
  if (session.options.motionBlur &&
2923
2920
  session.options.motionBlur.samplesPerFrame === undefined &&
2924
2921
  !session.motionBlurNonSpatialFrames) {
2925
- sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps));
2922
+ sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps), session.options.hiddenElementIds);
2926
2923
  session.motionBlurNonSpatialFrames = sharedStaticFrameStats.nonSpatialOnlyFrameSet;
2927
2924
  }
2928
2925
  // Idempotent: the drawElement init path arms dedup BEFORE canvas injection
@@ -2971,7 +2968,8 @@ async function armStaticDedup(session, page, logInitPhase) {
2971
2968
  return;
2972
2969
  }
2973
2970
  const fps = fpsToNumber(session.options.fps);
2974
- const stats = sharedStaticFrameStats ?? (await computeStaticFrameSet(page, fps));
2971
+ const stats = sharedStaticFrameStats ??
2972
+ (await computeStaticFrameSet(page, fps, session.options.hiddenElementIds));
2975
2973
  if (!stats.eligible || stats.staticFrameSet.size === 0) {
2976
2974
  session.staticDedupSkipReason = "ineligible";
2977
2975
  logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
@@ -4175,6 +4173,7 @@ export function getCapturePerfSummary(session) {
4175
4173
  avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
4176
4174
  avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
4177
4175
  avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
4176
+ captureBeyondViewport: session.options.captureBeyondViewport,
4178
4177
  p50TotalMs: medianOf(session.capturePerf.frameMs),
4179
4178
  p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
4180
4179
  p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99),
@@ -80,8 +80,9 @@ export declare function captureScreenshotWithAlpha(page: Page, width: number, he
80
80
  */
81
81
  /** Whether frames must carry alpha. PNG alone does not imply it: motion blur captures opaque output as PNG. */
82
82
  export declare function capturesAlpha(options: Pick<CaptureOptions, "format" | "transparent">): boolean;
83
- export declare function initTransparentBackground(page: Page, { clearCompositionRoot }?: {
83
+ export declare function initTransparentBackground(page: Page, { clearCompositionRoot, hideElementIds, }?: {
84
84
  clearCompositionRoot?: boolean;
85
+ hideElementIds?: readonly string[];
85
86
  }): Promise<void>;
86
87
  /**
87
88
  * Capture a transparent-background PNG screenshot without setting the
@@ -239,12 +239,12 @@ export function capturesAlpha(options) {
239
239
  return options.transparent ?? options.format === "png";
240
240
  }
241
241
  const TRANSPARENT_BG_STYLE_ID = "__hf_transparent_bg__";
242
- export async function initTransparentBackground(page, { clearCompositionRoot = false } = {}) {
242
+ export async function initTransparentBackground(page, { clearCompositionRoot = false, hideElementIds = [], } = {}) {
243
243
  const client = await getCdpSession(page);
244
244
  await client.send("Emulation.setDefaultBackgroundColorOverride", {
245
245
  color: { r: 0, g: 0, b: 0, a: 0 },
246
246
  });
247
- await page.evaluate((styleId, clearCompositionRoot) => {
247
+ await page.evaluate((styleId, clearCompositionRoot, hideIds) => {
248
248
  const selector = clearCompositionRoot ? "html,body,[data-composition-id]" : "html,body";
249
249
  let style = document.getElementById(styleId);
250
250
  if (!style) {
@@ -252,8 +252,11 @@ export async function initTransparentBackground(page, { clearCompositionRoot = f
252
252
  style.id = styleId;
253
253
  document.head.appendChild(style);
254
254
  }
255
- style.textContent = `${selector}{background:transparent !important;background-color:transparent !important;background-image:none !important;}`;
256
- }, TRANSPARENT_BG_STYLE_ID, clearCompositionRoot);
255
+ const hidden = hideIds.map((id) => `#${CSS.escape(id)}`).join(",");
256
+ style.textContent =
257
+ `${selector}{background:transparent !important;background-color:transparent !important;background-image:none !important;}` +
258
+ (hidden ? `${hidden}{visibility:hidden !important;}` : "");
259
+ }, TRANSPARENT_BG_STYLE_ID, clearCompositionRoot, [...hideElementIds]);
257
260
  }
258
261
  /**
259
262
  * Capture a transparent-background PNG screenshot without setting the
@@ -14,6 +14,7 @@
14
14
  import { type GpuEncoder } from "../utils/gpuEncoder.js";
15
15
  import { type EngineConfig } from "../config.js";
16
16
  import { type Fps } from "@hyperframes/core";
17
+ import type { VideoLayerInput } from "./videoFrameExtractor.js";
17
18
  export type { EncoderOptions } from "./chunkEncoder.types.js";
18
19
  export interface FrameReorderBuffer {
19
20
  waitForFrame: (frame: number) => Promise<void>;
@@ -48,6 +49,8 @@ export interface StreamingEncoderOptions {
48
49
  hdr?: {
49
50
  transfer: import("../utils/hdr.js").HdrTransfer;
50
51
  };
52
+ /** A source video ffmpeg composites under the piped RGBA frames; see `buildVideoLayerInput`. */
53
+ videoLayer?: VideoLayerInput;
51
54
  /** When set, use rawvideo input instead of image2pipe. For HDR PQ-encoded frames. */
52
55
  rawInputFormat?: "rgb48le";
53
56
  /**
@@ -101,7 +104,7 @@ export interface StreamingEncoder {
101
104
  * Exported so unit tests can assert on the constructed CLI without spawning
102
105
  * FFmpeg — see streamingEncoder.test.ts.
103
106
  */
104
- export declare function buildStreamingArgs(options: StreamingEncoderOptions, outputPath: string, gpuEncoder?: GpuEncoder): string[];
107
+ export declare function buildStreamingArgs(options: StreamingEncoderOptions, outputPath: string, gpuEncoder?: GpuEncoder, nvencPresets?: ReadonlySet<string>): string[];
105
108
  /**
106
109
  * Spawn a streaming FFmpeg encoder that accepts frame buffers on stdin.
107
110
  */
@@ -19,7 +19,7 @@ import { trackChildProcess } from "../utils/processTracker.js";
19
19
  import { ManagedChildProcess, } from "../utils/managedChildProcess.js";
20
20
  import { existsSync, mkdirSync, statSync } from "fs";
21
21
  import { dirname } from "path";
22
- import { getCachedGpuEncoder, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
22
+ import { getCachedGpuEncoder, getCachedNvencPresets, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
23
23
  import { ffmpegStatsReader, formatFfmpegError, isExternalFfmpegInterruption, } from "../utils/runFfmpeg.js";
24
24
  import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
25
25
  import { getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
@@ -98,7 +98,7 @@ export function createFrameReorderBuffer(startFrame, endFrame) {
98
98
  * Exported so unit tests can assert on the constructed CLI without spawning
99
99
  * FFmpeg — see streamingEncoder.test.ts.
100
100
  */
101
- export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
101
+ export function buildStreamingArgs(options, outputPath, gpuEncoder = null, nvencPresets) {
102
102
  const { fps, codec = "h264", preset = "medium", quality = 23, bitrate, pixelFormat = "yuv420p", vp9CpuUsed, useGpu = false, imageFormat = "jpeg", } = options;
103
103
  // Input args: pipe from stdin
104
104
  const args = [];
@@ -119,8 +119,17 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
119
119
  const inputCodec = imageFormat === "png" ? "png" : "mjpeg";
120
120
  args.push("-f", "image2pipe", "-vcodec", inputCodec, "-framerate", fpsToFfmpegArg(fps), "-i", "-");
121
121
  }
122
+ if (options.videoLayer)
123
+ args.push(...options.videoLayer.inputArgs);
122
124
  args.push("-r", fpsToFfmpegArg(fps));
123
125
  const shouldUseGpu = useGpu && gpuEncoder !== null;
126
+ if (options.videoLayer &&
127
+ (imageFormat !== "png" ||
128
+ options.rawInputFormat ||
129
+ shouldUseGpu ||
130
+ (codec !== "h264" && codec !== "h265"))) {
131
+ throw new Error("videoLayer needs software h264/h265 and PNG frames");
132
+ }
124
133
  appendRateCapArgs(args, options);
125
134
  if (codec === "h264" || codec === "h265") {
126
135
  if (shouldUseGpu) {
@@ -128,7 +137,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
128
137
  args.push("-c:v", encoderName);
129
138
  switch (gpuEncoder) {
130
139
  case "nvenc":
131
- args.push("-preset", mapPresetForGpuEncoder("nvenc", preset));
140
+ args.push("-preset", mapPresetForGpuEncoder("nvenc", preset, nvencPresets));
132
141
  if (bitrate)
133
142
  args.push("-b:v", bitrate);
134
143
  else
@@ -266,6 +275,11 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
266
275
  args[vfIdx + 1] = `${frameFilter},${args[vfIdx + 1]}`;
267
276
  }
268
277
  }
278
+ else if (options.videoLayer) {
279
+ const video = `[1:v]${options.videoLayer.filters.join(",")},format=gbrp[v]`;
280
+ const tail = withEvenDimensionPad(SDR_RGB_TO_TAGGED_BT709_FILTER, pixelFormat, options.width, options.height);
281
+ args.push("-filter_complex", `${video};[0:v]format=rgba[o];[v][o]overlay=eof_action=endall:format=gbrp,${tail}[vout]`, "-map", "[vout]");
282
+ }
269
283
  else {
270
284
  // Pad odd dimensions up to even so 4:2:0 encoders (software and
271
285
  // nvenc/videotoolbox/qsv/amf) don't abort with "height not divisible by 2".
@@ -295,7 +309,8 @@ export async function spawnStreamingEncoder(outputPath, options, signal, config)
295
309
  if (options.useGpu) {
296
310
  gpuEncoder = await getCachedGpuEncoder();
297
311
  }
298
- const args = buildStreamingArgs(options, outputPath, gpuEncoder);
312
+ const nvencPresets = gpuEncoder === "nvenc" ? await getCachedNvencPresets() : undefined;
313
+ const args = buildStreamingArgs(options, outputPath, gpuEncoder, nvencPresets);
299
314
  const ffmpeg = spawn(getFfmpegBinary(), args, {
300
315
  stdio: ["pipe", "pipe", "pipe"],
301
316
  // See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
@@ -5,7 +5,7 @@
5
5
  * Videos are replaced with <img> elements during capture.
6
6
  */
7
7
  import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution";
8
- import { type RateSpec, type FpsInput } from "@hyperframes/core";
8
+ import { type RateSpec, type Fps, type FpsInput } from "@hyperframes/core";
9
9
  import { ffmpegSeekSeconds, resolvePlayableVideoDuration, videoStartOnMediaTimeline, type VideoMetadata } from "../utils/ffprobe.js";
10
10
  import { type HdrTransfer } from "../utils/hdr.js";
11
11
  import { type EngineConfig } from "../config.js";
@@ -195,7 +195,11 @@ export interface VideoExtractionFailure {
195
195
  error: string;
196
196
  /** ffmpeg's last stderr lines, without paths or URLs: what a producer-facing error may say. */
197
197
  ffmpegReason?: string;
198
+ /** The clip's file, project-relative; a remote source shows only its file name. Safe for producer-facing errors. */
199
+ source?: string;
198
200
  }
201
+ /** A clip's src as a producer-facing error may print it: no scheme payload, host, query, fragment or absolute path. */
202
+ export declare function videoExtractionSourceLabel(src: string, baseDir: string): string;
199
203
  export type VideoExtractionFailureStatusClass = "http_4xx" | "http_5xx" | "timeout" | "network" | "other";
200
204
  export interface VideoExtractionFailureRetry {
201
205
  phase: "download";
@@ -270,6 +274,15 @@ export declare function hasGradedCanvas(html: string): boolean;
270
274
  export declare function parseImageElements(html: string): ImageElement[];
271
275
  export declare function sdrCanvasColourFilters(metadata: VideoMetadata, toneMappedToSdr?: boolean): string[];
272
276
  export declare function extractVideoFramesRange(...args: Parameters<typeof extractVideoFramesRangeUnredacted>): Promise<ExtractedFrames>;
277
+ export interface VideoLayerInput {
278
+ inputArgs: string[];
279
+ filters: string[];
280
+ }
281
+ /** ffmpeg input and filters showing the same frames and colour as extraction; undefined if unsupported. */
282
+ export declare function buildVideoLayerInput(videoPath: string, startTime: number, duration: number, fps: Fps, canvas: {
283
+ width: number;
284
+ height: number;
285
+ }, fit?: "fill" | "cover"): Promise<VideoLayerInput | undefined>;
273
286
  declare function extractVideoFramesRangeUnredacted(videoPath: string, videoId: string, startTime: number, duration: number, options: ExtractionOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
274
287
  /** Frames go straight here, with no per-videoId subdir (the cache layer's keyed entry). */
275
288
  outputDirOverride?: string): Promise<ExtractedFrames>;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { audioGroupsById, isMemberGroupHidden, isSelfOrAncestorHidden } from "./mediaHidden.js";
9
9
  import { copyFileSync, existsSync, linkSync, mkdirSync, rmSync } from "fs";
10
- import { basename, join } from "path";
10
+ import { basename, isAbsolute, join, normalize, relative, win32 } from "path";
11
11
  import { cpus } from "os";
12
12
  import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
13
13
  import { parseHTML } from "linkedom";
@@ -168,6 +168,28 @@ async function hdrToSdrTransformKey() {
168
168
  function sdrToHdrTransformKey(transfer) {
169
169
  return `sdr2hdr-${transfer}`;
170
170
  }
171
+ const URL_SCHEME = /^[a-z][a-z0-9+.-]+:/i;
172
+ const WINDOWS_ABSOLUTE = /^([a-zA-Z]:[\\/]|\\\\)/;
173
+ /** A clip's src as a producer-facing error may print it: no scheme payload, host, query, fragment or absolute path. */
174
+ export function videoExtractionSourceLabel(src, baseDir) {
175
+ if (/^(data|blob):/i.test(src))
176
+ return "inline video";
177
+ if (URL_SCHEME.test(src)) {
178
+ try {
179
+ return basename(decodeURIComponent(new URL(src).pathname)) || "remote video";
180
+ }
181
+ catch {
182
+ return "remote video";
183
+ }
184
+ }
185
+ const path = src.split(/[?#]/)[0];
186
+ const lib = WINDOWS_ABSOLUTE.test(path) ? win32 : { isAbsolute, relative, basename, normalize };
187
+ const relativePath = lib.isAbsolute(path) ? lib.relative(baseDir, path) : lib.normalize(path);
188
+ const label = relativePath.startsWith("..") || lib.isAbsolute(relativePath)
189
+ ? lib.basename(path)
190
+ : relativePath;
191
+ return label.replace(/\\/g, "/");
192
+ }
171
193
  /** Query/fragment-free remote identity safe for extraction logs and wire metadata. */
172
194
  export function safeVideoExtractionSourceIdentity(source) {
173
195
  if (!isHttpUrl(source))
@@ -645,6 +667,87 @@ export async function extractVideoFramesRange(...args) {
645
667
  throw redactUrlQueriesInError(error);
646
668
  }
647
669
  }
670
+ /** The one place that decides which source frame a slot shows and how its colour is converted. */
671
+ function videoFilterChain(input) {
672
+ const { metadata, format, ffmpegFps, sampleAtOutputFps, keepsHdrForChrome, toneMapFrom } = input;
673
+ const toneMappedToSdr = toneMapFrom !== undefined;
674
+ const head = [];
675
+ if (keepsHdrForChrome)
676
+ head.push("format=nv12");
677
+ const tail = [];
678
+ if (metadata.sampleAspectRatio) {
679
+ tail.push("scale=trunc(iw*sar/2)*2:ih", "setsar=1");
680
+ }
681
+ // Each slot takes the last frame starting at or before its time. Ticks of 2 ms or finer
682
+ // first shift half a tick, absorbing container rounding (WebM/MKV use whole ms).
683
+ const sampling = (seek) => sampleAtOutputFps
684
+ ? [
685
+ ...(metadata.isVFR
686
+ ? []
687
+ : undoTickRoundingOnTheSourceFrameGrid(seek, videoStartOnMediaTimeline(metadata))),
688
+ "settb=intb/2",
689
+ "setpts=PTS-lte(TB\\,0.001)",
690
+ `fps=${ffmpegFps}:start_time=0:round=up`,
691
+ ]
692
+ : [];
693
+ if (input.sdrToHdrTransfer) {
694
+ // Fps sampling runs before the colorspace remap so only kept frames are converted. The
695
+ // remap is pointwise, so the result is identical either way for the SDR inputs it applies
696
+ // to (it never meets nv12, which is HDR-only); revisit if the two ever combine.
697
+ tail.push(SDR_TO_HDR_COLORSPACE_FILTER);
698
+ }
699
+ if (toneMapFrom) {
700
+ tail.push(hdrToSdrToneMapFilter(metadata.colorSpace ?? {}, toneMapFrom));
701
+ }
702
+ if (toneMappedToSdr || (!input.isHdr && !input.sdrToHdrTransfer)) {
703
+ tail.push(...sdrCanvasColourFilters(metadata, toneMappedToSdr));
704
+ if (format === "jpg") {
705
+ if (input.shownBox)
706
+ tail.push(halveToCoverScaleFilter(input.shownBox));
707
+ tail.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
708
+ }
709
+ }
710
+ return (seek) => [...head, ...sampling(seek), ...tail];
711
+ }
712
+ const VIDEO_LAYER_TAIL_SLACK_SECONDS = 0.5;
713
+ /** ffmpeg input and filters showing the same frames and colour as extraction; undefined if unsupported. */
714
+ export async function buildVideoLayerInput(videoPath, startTime, duration, fps, canvas, fit = "fill") {
715
+ const metadata = await extractMediaMetadata(videoPath);
716
+ if (startTime + duration >
717
+ resolvePlayableVideoDuration(metadata) + VIDEO_LAYER_TAIL_SLACK_SECONDS ||
718
+ isHdrColorSpaceUtil(metadata.colorSpace) ||
719
+ metadata.isVFR ||
720
+ metadata.hasAlpha ||
721
+ codecMayHaveAlpha(metadata.videoCodec)) {
722
+ return undefined;
723
+ }
724
+ const chain = videoFilterChain({
725
+ metadata,
726
+ format: "png",
727
+ ffmpegFps: fpsToFfmpegArg(fps),
728
+ sampleAtOutputFps: true,
729
+ keepsHdrForChrome: false,
730
+ isHdr: false,
731
+ });
732
+ const filters = chain(startTime);
733
+ if (metadata.width !== canvas.width || metadata.height !== canvas.height) {
734
+ filters.push(fit === "cover"
735
+ ? `scale=${canvas.width}:${canvas.height}:force_original_aspect_ratio=increase:flags=bicubic,crop=${canvas.width}:${canvas.height}`
736
+ : `scale=${canvas.width}:${canvas.height}:flags=bicubic`);
737
+ }
738
+ filters.push(`tpad=stop_mode=clone:stop_duration=${VIDEO_LAYER_TAIL_SLACK_SECONDS * 2}`);
739
+ return {
740
+ inputArgs: [
741
+ "-noaccurate_seek",
742
+ "-ss",
743
+ formatFfmpegSeconds(ffmpegSeekSeconds(metadata, startTime)),
744
+ ...urlInputArgs(videoPath),
745
+ "-i",
746
+ videoPath,
747
+ ],
748
+ filters,
749
+ };
750
+ }
648
751
  async function extractVideoFramesRangeUnredacted(videoPath, videoId, startTime, duration, options, signal, config,
649
752
  /** Frames go straight here, with no per-videoId subdir (the cache layer's keyed entry). */
650
753
  outputDirOverride) {
@@ -717,48 +820,18 @@ outputDirOverride) {
717
820
  args.push("-noaccurate_seek");
718
821
  args.push("-ss", formatFfmpegSeconds(ffmpegSeek(startTime)), "-i", videoPath, "-t", formatFfmpegSeconds(duration));
719
822
  }
720
- const vfFilters = [];
721
- if (keepsHdrForChrome) {
722
- vfFilters.push("format=nv12");
723
- }
724
- const samplingAt = vfFilters.length;
725
- if (metadata.sampleAspectRatio) {
726
- vfFilters.push("scale=trunc(iw*sar/2)*2:ih", "setsar=1");
727
- }
728
- // Each slot takes the last frame starting at or before its time. Ticks of 2 ms or finer
729
- // first shift half a tick, absorbing container rounding (WebM/MKV use whole ms).
730
- const samplingFilters = (seek) => sampleAtOutputFps
731
- ? [
732
- ...(metadata.isVFR
733
- ? []
734
- : undoTickRoundingOnTheSourceFrameGrid(seek, videoStartOnMediaTimeline(metadata))),
735
- "settb=intb/2",
736
- "setpts=PTS-lte(TB\\,0.001)",
737
- `fps=${ffmpegFps}:start_time=0:round=up`,
738
- ]
739
- : [];
740
- if (options.sdrToHdrTransfer) {
741
- // Ordering intent: fps sampling runs BEFORE the colorspace remap so only
742
- // kept frames are converted. The remap is pointwise per-frame, so the
743
- // output is identical either way for the SDR (BT.709, 8-bit) inputs this
744
- // flag is set for. If format=nv12 (macOS HDR-source decode) ever combines
745
- // with this flag, revisit: nv12 subsampling before a BT.2020 remap is an
746
- // untested interaction (today the flags are mutually exclusive — the
747
- // remap only applies to SDR sources, nv12 only to HDR sources).
748
- vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
749
- }
750
- if (toneMappedToSdr) {
751
- const frameColour = await readFirstFrameColour(videoPath);
752
- vfFilters.push(hdrToSdrToneMapFilter(metadata.colorSpace ?? {}, frameColour));
753
- }
754
- if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
755
- vfFilters.push(...sdrCanvasColourFilters(metadata, toneMappedToSdr));
756
- if (format === "jpg") {
757
- if (options.shownBox)
758
- vfFilters.push(halveToCoverScaleFilter(options.shownBox));
759
- vfFilters.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
760
- }
761
- }
823
+ const frameColour = toneMappedToSdr ? await readFirstFrameColour(videoPath) : undefined;
824
+ const filtersFor = videoFilterChain({
825
+ metadata,
826
+ format,
827
+ ffmpegFps,
828
+ sampleAtOutputFps,
829
+ keepsHdrForChrome,
830
+ toneMapFrom: frameColour,
831
+ isHdr,
832
+ sdrToHdrTransfer: options.sdrToHdrTransfer,
833
+ shownBox: options.shownBox,
834
+ });
762
835
  const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
763
836
  // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
764
837
  if (format === "png")
@@ -766,8 +839,7 @@ outputDirOverride) {
766
839
  encodeArgs.push("-y", outputPattern);
767
840
  const runOptions = { signal, timeout: ffmpegProcessTimeout };
768
841
  const filterArgsFor = (seek) => {
769
- const filters = [...vfFilters];
770
- filters.splice(samplingAt, 0, ...samplingFilters(seek));
842
+ const filters = filtersFor(seek);
771
843
  return filters.length > 0 ? ["-vf", filters.join(",")] : [];
772
844
  };
773
845
  const segmentFrames = Math.round(EXTRACTION_SEGMENT_SECONDS * fps);
@@ -1911,10 +1983,16 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1911
1983
  breakdown.cacheGcBytesFreed = gcStats.evictedBytes;
1912
1984
  breakdown.cacheAgedPartialsCleared = gcStats.agedPartialsRemoved;
1913
1985
  }
1986
+ const srcById = new Map(videos.map((video) => [video.id, video.src]));
1914
1987
  return {
1915
1988
  success: errors.length === 0,
1916
1989
  extracted,
1917
- errors,
1990
+ errors: errors.map((failure) => {
1991
+ const src = srcById.get(failure.videoId);
1992
+ return src === undefined
1993
+ ? failure
1994
+ : { ...failure, source: videoExtractionSourceLabel(src, baseDir) };
1995
+ }),
1918
1996
  totalFramesExtracted,
1919
1997
  durationMs: Date.now() - startTime,
1920
1998
  phaseBreakdown: breakdown,
@@ -0,0 +1,10 @@
1
+ import type { VideoElement } from "./videoFrameExtractor.js";
2
+ export type VideoLayerQualification = {
3
+ qualifies: true;
4
+ video: VideoElement;
5
+ } | {
6
+ qualifies: false;
7
+ reason: string;
8
+ };
9
+ /** One video that alone fills the whole timeline at normal speed, ungraded; layout is checked in the page. */
10
+ export declare function qualifyVideoLayer(videos: readonly VideoElement[], compositionDuration: number): VideoLayerQualification;
@@ -0,0 +1,24 @@
1
+ const EDGE_EPSILON_SECONDS = 1e-3;
2
+ /** One video that alone fills the whole timeline at normal speed, ungraded; layout is checked in the page. */
3
+ export function qualifyVideoLayer(videos, compositionDuration) {
4
+ if (videos.length !== 1)
5
+ return { qualifies: false, reason: "video_count" };
6
+ const video = videos[0];
7
+ if (!video)
8
+ return { qualifies: false, reason: "video_count" };
9
+ if (video.hidden)
10
+ return { qualifies: false, reason: "hidden" };
11
+ if (video.graded)
12
+ return { qualifies: false, reason: "color_grading" };
13
+ if (video.loop)
14
+ return { qualifies: false, reason: "loop" };
15
+ if (video.playbackRate !== undefined && video.playbackRate !== 1) {
16
+ return { qualifies: false, reason: "playback_rate" };
17
+ }
18
+ if (video.start > EDGE_EPSILON_SECONDS)
19
+ return { qualifies: false, reason: "late_start" };
20
+ if (video.end < compositionDuration - EDGE_EPSILON_SECONDS) {
21
+ return { qualifies: false, reason: "ends_early" };
22
+ }
23
+ return { qualifies: true, video };
24
+ }
package/dist/types.d.ts CHANGED
@@ -154,6 +154,7 @@ export interface CaptureOptions {
154
154
  fps: Fps;
155
155
  format?: "jpeg" | "png";
156
156
  transparent?: boolean;
157
+ hiddenElementIds?: string[];
157
158
  quality?: number;
158
159
  /**
159
160
  * Opt into sub-frame multi-sample motion blur (issue #4010). Absent means off and the
@@ -250,6 +251,7 @@ export interface CapturePerfSummary {
250
251
  avgSeekMs: number;
251
252
  avgBeforeCaptureMs: number;
252
253
  avgScreenshotMs: number;
254
+ captureBeyondViewport?: boolean;
253
255
  /**
254
256
  * Median per-frame capture time — warmup-robust, unlike `avgTotalMs`
255
257
  * (first frames pay font/image decode + GC that swamps short renders'
@@ -340,6 +340,10 @@ export function formatFfmpegSeconds(seconds) {
340
340
  const text = String(seconds);
341
341
  return text.includes("e") ? String(+seconds.toFixed(6)) : text;
342
342
  }
343
+ function readIntervalSpec(startSeconds, end) {
344
+ const to = typeof end === "number" ? formatFfmpegSeconds(end) : end;
345
+ return `${formatFfmpegSeconds(startSeconds)}%${to}`;
346
+ }
343
347
  export function ffmpegSeekSeconds(metadata, mediaTime) {
344
348
  return mediaTime - (metadata.mediaTimeOffsetSeconds ?? 0);
345
349
  }
@@ -864,7 +868,7 @@ async function extractFinalPacketDuration(filePath, window, signal) {
864
868
  "-select_streams",
865
869
  "v:0",
866
870
  "-read_intervals",
867
- `${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`,
871
+ readIntervalSpec(intervalStart, streamEnd),
868
872
  "-show_entries",
869
873
  "packet=pts_time,duration_time",
870
874
  "-of",
@@ -943,8 +947,7 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
943
947
  });
944
948
  return parseFinalTimestamp(stdout);
945
949
  };
946
- const timestamp = (await probe(`${formatFfmpegSeconds(intervalStart)}%${formatFfmpegSeconds(streamEnd)}`)) ??
947
- (await probe( /* full scan */));
950
+ const timestamp = (await probe(readIntervalSpec(intervalStart, streamEnd))) ?? (await probe( /* full scan */));
948
951
  if (timestamp === undefined) {
949
952
  throw new Error("[FFmpeg] ffprobe found no decodable final video frame");
950
953
  }
@@ -1089,7 +1092,8 @@ async function probeDecodedAudioDuration(filePath, audioStream, sampleRate, star
1089
1092
  }
1090
1093
  };
1091
1094
  const tailStart = Math.max(startSeconds, startSeconds + containerDurationSeconds - AUDIO_DURATION_PROBE_TAIL_SECONDS);
1092
- const lastFrame = (await probe(`${tailStart}%${FFPROBE_READ_INTERVAL_EXPLICIT_FAR_END}`)) ?? (await probe());
1095
+ const lastFrame = (await probe(readIntervalSpec(tailStart, FFPROBE_READ_INTERVAL_EXPLICIT_FAR_END))) ??
1096
+ (await probe());
1093
1097
  if (!lastFrame)
1094
1098
  return null;
1095
1099
  return lastFrame.timestamp + lastFrame.nbSamples / sampleRate - startSeconds;
@@ -1119,7 +1123,7 @@ export async function analyzeKeyframeIntervals(filePath, metadata) {
1119
1123
  export async function keyframeGapSeconds(filePath, seekSeconds, signal) {
1120
1124
  const stdout = await runFfprobe(filePath, [
1121
1125
  ...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
1122
- ...["-read_intervals", `${formatFfmpegSeconds(seekSeconds)}%+#1`],
1126
+ ...["-read_intervals", readIntervalSpec(seekSeconds, "+#1")],
1123
1127
  ], signal);
1124
1128
  const keyframe = parseFloat(stdout.split("\n")[0] ?? "");
1125
1129
  return Number.isFinite(keyframe) ? Math.max(0, seekSeconds - keyframe) : 0;
@@ -31,4 +31,8 @@ export declare function getProbeArgs(encoder: ConcreteGpuEncoder): string[];
31
31
  * - `videotoolbox`, `vaapi`, `amf`, `null`: no remap (they either ignore
32
32
  * `-preset` entirely or accept the libx264 vocabulary).
33
33
  */
34
- export declare function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string): string;
34
+ export declare function mapPresetForGpuEncoder(encoder: GpuEncoder, preset: string, nvencPresets?: ReadonlySet<string>): string;
35
+ /** The names an encoder lists under its `-preset` option in `ffmpeg -h encoder=<name>`. */
36
+ export declare function parseEncoderPresetNames(helpText: string): ReadonlySet<string>;
37
+ /** The presets this ffmpeg's h264_nvenc takes; empty when it cannot be read, which keeps the pN names. */
38
+ export declare function getCachedNvencPresets(): Promise<ReadonlySet<string>>;
@@ -44,9 +44,10 @@ export async function selectUsableGpuEncoder(candidates, isUsable) {
44
44
  }
45
45
  return null;
46
46
  }
47
- export async function detectGpuEncoder() {
48
- const ffmpeg = spawn(getFfmpegBinary(), ["-encoders"], {
49
- stdio: ["pipe", "pipe", "pipe"],
47
+ /** ffmpeg's stdout, or null when it did not exit cleanly within the deadline. */
48
+ async function ffmpegStdout(args, deadlineMs, terminationGraceMs) {
49
+ const ffmpeg = spawn(getFfmpegBinary(), args, {
50
+ stdio: ["ignore", "pipe", "pipe"],
50
51
  // See runFfmpeg.ts: keeps a console window off the user's desktop on Windows.
51
52
  windowsHide: true,
52
53
  });
@@ -56,9 +57,14 @@ export async function detectGpuEncoder() {
56
57
  stdout += data.toString();
57
58
  });
58
59
  const outcome = await new ManagedChildProcess(ffmpeg, {
59
- deadlineAtMs: Date.now() + 30_000,
60
+ deadlineAtMs: Date.now() + deadlineMs,
61
+ terminationGraceMs,
60
62
  }).wait();
61
- if (outcome.reason !== "exit" || outcome.exitCode !== 0)
63
+ return outcome.reason === "exit" && outcome.exitCode === 0 ? stdout : null;
64
+ }
65
+ export async function detectGpuEncoder() {
66
+ const stdout = await ffmpegStdout(["-encoders"], 30_000);
67
+ if (stdout === null)
62
68
  return null;
63
69
  const candidates = getCompiledGpuEncoders(stdout);
64
70
  return selectUsableGpuEncoder(candidates, canUseGpuEncoder).catch(() => null);
@@ -213,9 +219,11 @@ const QSV_PRESET_MAP = {
213
219
  * - `videotoolbox`, `vaapi`, `amf`, `null`: no remap (they either ignore
214
220
  * `-preset` entirely or accept the libx264 vocabulary).
215
221
  */
216
- export function mapPresetForGpuEncoder(encoder, preset) {
222
+ export function mapPresetForGpuEncoder(encoder, preset, nvencPresets) {
217
223
  switch (encoder) {
218
224
  case "nvenc":
225
+ if (nvencPresets && nvencPresets.size > 0 && !nvencPresets.has("p4"))
226
+ return legacyNvencPreset(preset, nvencPresets);
219
227
  if (/^p[1-7]$/.test(preset))
220
228
  return preset;
221
229
  return NVENC_PRESET_MAP[preset] ?? "p4";
@@ -225,3 +233,47 @@ export function mapPresetForGpuEncoder(encoder, preset) {
225
233
  return preset;
226
234
  }
227
235
  }
236
+ // Builds on an older NVENC SDK list `default slow medium fast hp hq ...` and reject p1..p7.
237
+ const LEGACY_NVENC_PRESET_MAP = {
238
+ ultrafast: "hp",
239
+ superfast: "hp",
240
+ veryfast: "hp",
241
+ faster: "fast",
242
+ fast: "fast",
243
+ medium: "medium",
244
+ slow: "slow",
245
+ slower: "slow",
246
+ veryslow: "slow",
247
+ placebo: "slow",
248
+ };
249
+ function legacyNvencPreset(preset, accepted) {
250
+ if (accepted.has(preset) && !/^p[1-7]$/.test(preset))
251
+ return preset;
252
+ const mapped = LEGACY_NVENC_PRESET_MAP[preset] ?? "medium";
253
+ return accepted.has(mapped) ? mapped : "default";
254
+ }
255
+ /** The names an encoder lists under its `-preset` option in `ffmpeg -h encoder=<name>`. */
256
+ export function parseEncoderPresetNames(helpText) {
257
+ const names = new Set();
258
+ let inPreset = false;
259
+ for (const line of helpText.split(/\r?\n/)) {
260
+ if (/^\s{1,3}-\S/.test(line)) {
261
+ inPreset = /^\s{1,3}-preset\s/.test(line);
262
+ continue;
263
+ }
264
+ const entry = inPreset ? /^\s{4,}([a-z][a-z0-9_]*)\s+-?\d+\s+[E.]\S{3}/.exec(line) : null;
265
+ if (entry)
266
+ names.add(entry[1]);
267
+ }
268
+ return names;
269
+ }
270
+ let cachedNvencPresets = undefined;
271
+ /** The presets this ffmpeg's h264_nvenc takes; empty when it cannot be read, which keeps the pN names. */
272
+ export function getCachedNvencPresets() {
273
+ cachedNvencPresets ??= readNvencPresets();
274
+ return cachedNvencPresets;
275
+ }
276
+ async function readNvencPresets() {
277
+ const help = await ffmpegStdout(["-hide_banner", "-h", "encoder=h264_nvenc"], GPU_PROBE_TIMEOUT_MS, GPU_PROBE_KILL_GRACE_MS).catch(() => null);
278
+ return help === null ? new Set() : parseEncoderPresetNames(help);
279
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.225",
3
+ "version": "0.8.227",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -59,8 +59,8 @@
59
59
  "puppeteer": "~25.11.0",
60
60
  "puppeteer-core": "^25.11.0",
61
61
  "undici": "7.25.0",
62
- "@hyperframes/core": "^0.8.225",
63
- "@hyperframes/parsers": "^0.8.225"
62
+ "@hyperframes/parsers": "^0.8.227",
63
+ "@hyperframes/core": "^0.8.227"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",