@hyperframes/engine 0.8.224 → 0.8.226

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";
@@ -5,7 +5,7 @@ import { renameWithRetry } from "@hyperframes/core/rename-retry";
5
5
  import { removeScratch } from "@hyperframes/core/remove-scratch";
6
6
  import { join, dirname, isAbsolute, relative } from "path";
7
7
  import { parseHTML } from "linkedom";
8
- import { extractAudioMetadata, ffmpegSeekSeconds } from "../utils/ffprobe.js";
8
+ import { extractAudioMetadata, ffmpegSeekSeconds, formatFfmpegSeconds, } from "../utils/ffprobe.js";
9
9
  import { isNotMediaPayload } from "../utils/notMediaPayload.js";
10
10
  import { urlInputArgs } from "../utils/urlInput.js";
11
11
  import { clampAudioGain, clipGain } from "@hyperframes/core/audio-gain";
@@ -550,7 +550,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
550
550
  "-stream_loop",
551
551
  "-1",
552
552
  "-t",
553
- String(sourceDuration),
553
+ formatFfmpegSeconds(sourceDuration),
554
554
  "-i",
555
555
  cyclePath,
556
556
  "-vn",
@@ -560,7 +560,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
560
560
  "48000",
561
561
  ...outputArgs,
562
562
  "-t",
563
- String(duration),
563
+ formatFfmpegSeconds(duration),
564
564
  "-y",
565
565
  outputPath,
566
566
  ], { signal, timeout: ffmpegProcessTimeout });
@@ -598,14 +598,19 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
598
598
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
599
599
  ...urlInputArgs(readFrom),
600
600
  ...(inputSeek
601
- ? ["-ss", String(ffmpegStart - warmUp), "-t", String(sourceDuration + warmUp)]
601
+ ? [
602
+ "-ss",
603
+ formatFfmpegSeconds(ffmpegStart - warmUp),
604
+ "-t",
605
+ formatFfmpegSeconds(sourceDuration + warmUp),
606
+ ]
602
607
  : []),
603
608
  "-i",
604
609
  readFrom,
605
610
  ];
606
611
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
607
612
  if (normalizedPlaybackRate !== 1 || options?.padToDuration)
608
- args.push("-t", String(duration));
613
+ args.push("-t", formatFfmpegSeconds(duration));
609
614
  args.push("-y", outputPath);
610
615
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
611
616
  if (signal?.aborted) {
@@ -660,7 +665,7 @@ async function generateSilence(outputPath, duration, signal, config) {
660
665
  "-i",
661
666
  "anullsrc=r=48000:cl=stereo",
662
667
  "-t",
663
- String(duration),
668
+ formatFfmpegSeconds(duration),
664
669
  "-acodec",
665
670
  "pcm_s16le",
666
671
  "-y",
@@ -761,7 +766,7 @@ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, con
761
766
  "-ar",
762
767
  "48000",
763
768
  "-t",
764
- String(totalDuration),
769
+ formatFfmpegSeconds(totalDuration),
765
770
  "-y",
766
771
  outputPath,
767
772
  ];
@@ -923,7 +928,7 @@ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, sign
923
928
  "-ar",
924
929
  "48000",
925
930
  "-t",
926
- String(totalDuration),
931
+ formatFfmpegSeconds(totalDuration),
927
932
  "-y",
928
933
  outputPath,
929
934
  ];
@@ -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. */
@@ -2447,8 +2451,8 @@ export function isStaticDedupFrameAnalysisSafe(totalFrames) {
2447
2451
  * comp on any signal the tween-walker can't see: video / canvas / webgl (redraw without
2448
2452
  * a tween), zero tweens (non-GSAP animation), or a running CSS/WAAPI animation.
2449
2453
  */
2450
- export async function computeStaticFrameSet(page, fps) {
2451
- const result = await page.evaluate((spatialProps) => {
2454
+ export async function computeStaticFrameSet(page, fps, ignoredVideoIds = []) {
2455
+ const result = await page.evaluate((spatialProps, ignored) => {
2452
2456
  // Passed in from SPATIAL_TWEEN_PROPERTIES rather than declared here, so the list
2453
2457
  // stays the one this module's own tests exercise (the matching code itself must
2454
2458
  // stay inline — this closure is serialized and runs in the page realm).
@@ -2463,20 +2467,8 @@ export async function computeStaticFrameSet(page, fps) {
2463
2467
  }
2464
2468
  const intervals = [];
2465
2469
  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.
2470
+ // totalDuration(), not duration(): repeat/yoyo tweens animate past one iteration.
2471
+ // Any tl.call() disqualifies the comp: its DOM side effects are not seek-idempotent.
2480
2472
  let hasTimelineCall = false;
2481
2473
  function walk(tl, offset) {
2482
2474
  if (typeof tl.getChildren !== "function")
@@ -2500,12 +2492,7 @@ export async function computeStaticFrameSet(page, fps) {
2500
2492
  // vars below; this span is a conservative (spatial) placeholder for the
2501
2493
  // repeat/yoyo overrun, which a per-child walk doesn't otherwise cover.
2502
2494
  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).
2495
+ // Still descend: a call() inside the repeating span would otherwise go undetected.
2509
2496
  walk(child, start);
2510
2497
  }
2511
2498
  else {
@@ -2528,7 +2515,7 @@ export async function computeStaticFrameSet(page, fps) {
2528
2515
  if (tl && typeof tl.getChildren === "function")
2529
2516
  walk(tl, 0);
2530
2517
  }
2531
- const hasVideo = !!document.querySelector("video");
2518
+ const hasVideo = Array.from(document.querySelectorAll("video")).some((v) => !ignored.includes(v.id));
2532
2519
  const hasCanvas = !!document.querySelector("canvas");
2533
2520
  const hasFrameSources = w.__hfHasFrameSources?.() ?? false;
2534
2521
  const hasIframe = !!document.querySelector("iframe");
@@ -2543,7 +2530,8 @@ export async function computeStaticFrameSet(page, fps) {
2543
2530
  // getAnimations(); any running/paused one can change content without a tween.
2544
2531
  let hasNonGsapAnim = false;
2545
2532
  try {
2546
- const docAnims = document.getAnimations;
2533
+ const docAnims = document
2534
+ .getAnimations;
2547
2535
  if (typeof docAnims === "function") {
2548
2536
  hasNonGsapAnim = docAnims.call(document).some((a) => {
2549
2537
  const t = a;
@@ -2566,7 +2554,7 @@ export async function computeStaticFrameSet(page, fps) {
2566
2554
  hasUnresolvableClipStart,
2567
2555
  hasTimelineCall,
2568
2556
  };
2569
- }, SPATIAL_TWEEN_PROPERTIES);
2557
+ }, SPATIAL_TWEEN_PROPERTIES, [...ignoredVideoIds]);
2570
2558
  const { intervals, tweenCount, duration, hasVideo, hasCanvas, hasFrameSources, hasIframe, hasNonGsapAnim, hasUnresolvableClipStart, hasTimelineCall, } = result;
2571
2559
  const totalFrames = Math.max(1, Math.ceil(duration * fps));
2572
2560
  if (!isStaticDedupFrameAnalysisSafe(totalFrames)) {
@@ -2651,12 +2639,13 @@ export async function computeStaticFrameSet(page, fps) {
2651
2639
  // what fixes long runs going nearly unverified — deliberately NOT derived from
2652
2640
  // sampleCount, so that knob's effect on density stays monotonic (see below).
2653
2641
  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.
2642
+ // Wall budget for verification: 15 s is the floor; above it the budget is 1.5x
2643
+ // planned shots x the first shot's seek+capture time, capped at 300 s (nothing
2644
+ // was gained past 600 s on a 4K job). Exhaustion fails closed: dedup is
2645
+ // disabled for the unverified runs and normal capture proceeds.
2659
2646
  const STATIC_VERIFY_MAX_MS = 15_000;
2647
+ const STATIC_VERIFY_HEADROOM = 1.5;
2648
+ const STATIC_VERIFY_CEILING_MS = 300_000;
2660
2649
  // Two captures (anchor + comparison) are the minimum proof for a run. Four hundred
2661
2650
  // therefore permits 200 fully verified runs while bounding pathological schedules.
2662
2651
  const STATIC_VERIFY_MIN_SCREENSHOT_CAP = 400;
@@ -2817,7 +2806,6 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2817
2806
  const now = dependencies.now ?? Date.now;
2818
2807
  const capture = dependencies.capture ?? pageScreenshotCapture;
2819
2808
  const startedAt = now();
2820
- const deadline = startedAt + STATIC_VERIFY_MAX_MS;
2821
2809
  const verifiedFrames = new Set();
2822
2810
  const stats = {
2823
2811
  plannedRuns: plan.runs.length,
@@ -2852,14 +2840,21 @@ export async function verifyStaticFramesSafe(session, page, staticFrames, fps, s
2852
2840
  }, t);
2853
2841
  };
2854
2842
  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);
2843
+ let firstShotMs = 0;
2855
2844
  const seekCapture = async (frameIdx) => {
2856
- if (now() >= deadline)
2845
+ const spent = now() - startedAt;
2846
+ const budget = Math.max(STATIC_VERIFY_MAX_MS, Math.min(STATIC_VERIFY_CEILING_MS, STATIC_VERIFY_HEADROOM * plan.plannedScreenshots * firstShotMs));
2847
+ if (spent >= budget)
2857
2848
  return "time_budget";
2858
2849
  if (stats.screenshots >= hardCap)
2859
2850
  return "count_budget";
2851
+ const shotStart = now();
2860
2852
  await seekToFrame(frameIdx);
2861
2853
  stats.screenshots++;
2862
- return capture(page, session.options);
2854
+ const buf = await capture(page, session.options);
2855
+ if (stats.screenshots === 1)
2856
+ firstShotMs = now() - shotStart;
2857
+ return buf;
2863
2858
  };
2864
2859
  // Verify profitable runs in deterministic savings order. A run is added to the armed
2865
2860
  // set only after its anchor and every planner-selected comparison match. Budget exits
@@ -2922,7 +2917,7 @@ async function armStaticDedup(session, page, logInitPhase) {
2922
2917
  if (session.options.motionBlur &&
2923
2918
  session.options.motionBlur.samplesPerFrame === undefined &&
2924
2919
  !session.motionBlurNonSpatialFrames) {
2925
- sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps));
2920
+ sharedStaticFrameStats = await computeStaticFrameSet(page, fpsToNumber(session.options.fps), session.options.hiddenElementIds);
2926
2921
  session.motionBlurNonSpatialFrames = sharedStaticFrameStats.nonSpatialOnlyFrameSet;
2927
2922
  }
2928
2923
  // Idempotent: the drawElement init path arms dedup BEFORE canvas injection
@@ -2971,7 +2966,8 @@ async function armStaticDedup(session, page, logInitPhase) {
2971
2966
  return;
2972
2967
  }
2973
2968
  const fps = fpsToNumber(session.options.fps);
2974
- const stats = sharedStaticFrameStats ?? (await computeStaticFrameSet(page, fps));
2969
+ const stats = sharedStaticFrameStats ??
2970
+ (await computeStaticFrameSet(page, fps, session.options.hiddenElementIds));
2975
2971
  if (!stats.eligible || stats.staticFrameSet.size === 0) {
2976
2972
  session.staticDedupSkipReason = "ineligible";
2977
2973
  logInitPhase(`static-frame dedup: disabled (${stats.reason})`);
@@ -4175,6 +4171,7 @@ export function getCapturePerfSummary(session) {
4175
4171
  avgSeekMs: Math.round(session.capturePerf.seekMs / frames),
4176
4172
  avgBeforeCaptureMs: Math.round(session.capturePerf.beforeCaptureMs / frames),
4177
4173
  avgScreenshotMs: Math.round(session.capturePerf.screenshotMs / frames),
4174
+ captureBeyondViewport: session.options.captureBeyondViewport,
4178
4175
  p50TotalMs: medianOf(session.capturePerf.frameMs),
4179
4176
  p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95),
4180
4177
  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
  /**
@@ -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) {
@@ -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=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".
@@ -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>;
@@ -313,6 +326,7 @@ export interface TimelineExtractionWindow {
313
326
  }
314
327
  type TimelineWindowVideo = Pick<VideoElement, "start" | "end" | "mediaStart"> & Partial<Pick<VideoElement, "playbackRate">> & Partial<Pick<VideoElement, "loop">>;
315
328
  export declare function isVideoPastTimelineEnd(video: Pick<VideoElement, "start">, timelineEnd: number): boolean;
329
+ export declare function alignClipStartToFirstOutputFrame(video: VideoElement, fps: number): void;
316
330
  /** Move a clip to its extraction window; a rate lane is shifted so it keeps integrating from the new origin. */
317
331
  export declare function rebaseVideoToWindow(video: VideoElement, window: TimelineExtractionWindow): void;
318
332
  /**
@@ -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";
@@ -18,7 +18,7 @@ import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
18
18
  import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, mediaElementTime, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
19
19
  import { resolveReferencedStart } from "./referenceResolver.js";
20
20
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
21
- import { extractFinalVideoFrameTimestamp, extractMediaMetadata, ffmpegSeekSeconds, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, videoStartOnMediaTimeline, writeMetadataCache, } from "../utils/ffprobe.js";
21
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, ffmpegSeekSeconds, formatFfmpegSeconds, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, videoStartOnMediaTimeline, writeMetadataCache, } from "../utils/ffprobe.js";
22
22
  import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
23
23
  import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
24
24
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
@@ -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))
@@ -593,12 +615,12 @@ async function runSegmentedExtraction(job) {
593
615
  ...decodeArgs,
594
616
  "-noaccurate_seek",
595
617
  "-ss",
596
- String(job.ffmpegSeek(seek)),
618
+ formatFfmpegSeconds(job.ffmpegSeek(seek)),
597
619
  "-i",
598
620
  videoPath,
599
621
  // Read one frame past the segment and keep exactly its own frames.
600
622
  "-t",
601
- String((frames + 1) / fps),
623
+ formatFfmpegSeconds((frames + 1) / fps),
602
624
  "-frames:v",
603
625
  String(frames),
604
626
  "-start_number",
@@ -645,6 +667,86 @@ 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
+ /** ffmpeg input and filters showing the same frames and colour as extraction; undefined if unsupported. */
713
+ export async function buildVideoLayerInput(videoPath, startTime, duration, fps, canvas, fit = "fill") {
714
+ const metadata = await extractMediaMetadata(videoPath);
715
+ if (startTime + duration > resolvePlayableVideoDuration(metadata) + 1e-3 ||
716
+ isHdrColorSpaceUtil(metadata.colorSpace) ||
717
+ metadata.isVFR ||
718
+ metadata.hasAlpha ||
719
+ codecMayHaveAlpha(metadata.videoCodec)) {
720
+ return undefined;
721
+ }
722
+ const chain = videoFilterChain({
723
+ metadata,
724
+ format: "png",
725
+ ffmpegFps: fpsToFfmpegArg(fps),
726
+ sampleAtOutputFps: true,
727
+ keepsHdrForChrome: false,
728
+ isHdr: false,
729
+ });
730
+ const filters = chain(startTime);
731
+ if (metadata.width !== canvas.width || metadata.height !== canvas.height) {
732
+ filters.push(fit === "cover"
733
+ ? `scale=${canvas.width}:${canvas.height}:force_original_aspect_ratio=increase:flags=bicubic,crop=${canvas.width}:${canvas.height}`
734
+ : `scale=${canvas.width}:${canvas.height}:flags=bicubic`);
735
+ }
736
+ return {
737
+ inputArgs: [
738
+ "-noaccurate_seek",
739
+ "-ss",
740
+ formatFfmpegSeconds(ffmpegSeekSeconds(metadata, startTime)),
741
+ ...urlInputArgs(videoPath),
742
+ "-i",
743
+ videoPath,
744
+ "-t",
745
+ formatFfmpegSeconds(duration),
746
+ ],
747
+ filters,
748
+ };
749
+ }
648
750
  async function extractVideoFramesRangeUnredacted(videoPath, videoId, startTime, duration, options, signal, config,
649
751
  /** Frames go straight here, with no per-videoId subdir (the cache layer's keyed entry). */
650
752
  outputDirOverride) {
@@ -709,56 +811,26 @@ outputDirOverride) {
709
811
  // sample. This is intentionally reserved for the one-frame path: input
710
812
  // seeking is faster, but valid unindexed transports (notably MPEG-TS with
711
813
  // a negative timestamp base) can seek to EOF and emit zero frames.
712
- args.push("-i", videoPath, "-ss", String(Math.max(0, ffmpegSeek(startTime))), "-frames:v", "1");
814
+ args.push("-i", videoPath, "-ss", formatFfmpegSeconds(Math.max(0, ffmpegSeek(startTime))), "-frames:v", "1");
713
815
  }
714
816
  else {
715
817
  // An accurate seek drops the frame on screen at startTime; the fps sampling below needs it.
716
818
  if (sampleAtOutputFps)
717
819
  args.push("-noaccurate_seek");
718
- args.push("-ss", String(ffmpegSeek(startTime)), "-i", videoPath, "-t", String(duration));
719
- }
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
- }
820
+ args.push("-ss", formatFfmpegSeconds(ffmpegSeek(startTime)), "-i", videoPath, "-t", formatFfmpegSeconds(duration));
761
821
  }
822
+ const frameColour = toneMappedToSdr ? await readFirstFrameColour(videoPath) : undefined;
823
+ const filtersFor = videoFilterChain({
824
+ metadata,
825
+ format,
826
+ ffmpegFps,
827
+ sampleAtOutputFps,
828
+ keepsHdrForChrome,
829
+ toneMapFrom: frameColour,
830
+ isHdr,
831
+ sdrToHdrTransfer: options.sdrToHdrTransfer,
832
+ shownBox: options.shownBox,
833
+ });
762
834
  const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
763
835
  // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
764
836
  if (format === "png")
@@ -766,8 +838,7 @@ outputDirOverride) {
766
838
  encodeArgs.push("-y", outputPattern);
767
839
  const runOptions = { signal, timeout: ffmpegProcessTimeout };
768
840
  const filterArgsFor = (seek) => {
769
- const filters = [...vfFilters];
770
- filters.splice(samplingAt, 0, ...samplingFilters(seek));
841
+ const filters = filtersFor(seek);
771
842
  return filters.length > 0 ? ["-vf", filters.join(",")] : [];
772
843
  };
773
844
  const segmentFrames = Math.round(EXTRACTION_SEGMENT_SECONDS * fps);
@@ -917,14 +988,17 @@ const FINAL_FRAME_LOGICAL_DURATION_SECONDS = 1e-6;
917
988
  export function isVideoPastTimelineEnd(video, timelineEnd) {
918
989
  return video.start >= timelineEnd;
919
990
  }
920
- function alignClipStartToFirstOutputFrame(video, fps) {
991
+ export function alignClipStartToFirstOutputFrame(video, fps) {
921
992
  if (video.loop || !Number.isFinite(video.start) || video.start < 0 || !(fps > 0))
922
993
  return;
923
994
  const position = exportClipWindow(video.start, Infinity, fps).start * fps;
924
995
  const firstFrame = Math.abs(position - Math.round(position)) < 1e-6 ? Math.round(position) : Math.ceil(position);
925
996
  const shift = firstFrame / fps - video.start;
926
- if (shift === 0)
997
+ // Float dust (a sum landing a hair off the grid) snaps the start; it never reaches mediaStart.
998
+ if (Math.abs(shift) < 1e-9) {
999
+ video.start = firstFrame / fps;
927
1000
  return;
1001
+ }
928
1002
  const playbackRate = normalizeRateSpec(video.playbackRate);
929
1003
  video.mediaStart += sourceTimeAt(playbackRate, shift);
930
1004
  video.playbackRate = shiftRateLane(playbackRate, shift);
@@ -1908,10 +1982,16 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1908
1982
  breakdown.cacheGcBytesFreed = gcStats.evictedBytes;
1909
1983
  breakdown.cacheAgedPartialsCleared = gcStats.agedPartialsRemoved;
1910
1984
  }
1985
+ const srcById = new Map(videos.map((video) => [video.id, video.src]));
1911
1986
  return {
1912
1987
  success: errors.length === 0,
1913
1988
  extracted,
1914
- errors,
1989
+ errors: errors.map((failure) => {
1990
+ const src = srcById.get(failure.videoId);
1991
+ return src === undefined
1992
+ ? failure
1993
+ : { ...failure, source: videoExtractionSourceLabel(src, baseDir) };
1994
+ }),
1915
1995
  totalFramesExtracted,
1916
1996
  durationMs: Date.now() - startTime,
1917
1997
  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'
@@ -128,6 +128,7 @@ export declare function readProbeJson(filePath: string, signal?: AbortSignal): P
128
128
  /** What a site stores beside a video: its ffprobe JSON and the final-frame facts a plan would otherwise probe for. */
129
129
  export declare function buildProbeFactsText(filePath: string, signal?: AbortSignal): Promise<string>;
130
130
  export declare function videoStartOnMediaTimeline(metadata: Pick<VideoMetadata, "videoStreamLeadSeconds" | "mediaTimeOffsetSeconds">): number;
131
+ export declare function formatFfmpegSeconds(seconds: number): string;
131
132
  export declare function ffmpegSeekSeconds(metadata: Pick<VideoMetadata, "mediaTimeOffsetSeconds">, mediaTime: number): number;
132
133
  export declare function resolvePlayableVideoDuration(metadata: VideoMetadata): number;
133
134
  /**
@@ -336,6 +336,14 @@ export async function buildProbeFactsText(filePath, signal) {
336
336
  export function videoStartOnMediaTimeline(metadata) {
337
337
  return (metadata.videoStreamLeadSeconds ?? 0) + (metadata.mediaTimeOffsetSeconds ?? 0);
338
338
  }
339
+ export function formatFfmpegSeconds(seconds) {
340
+ const text = String(seconds);
341
+ return text.includes("e") ? String(+seconds.toFixed(6)) : text;
342
+ }
343
+ function readIntervalSpec(startSeconds, end) {
344
+ const to = typeof end === "number" ? formatFfmpegSeconds(end) : end;
345
+ return `${formatFfmpegSeconds(startSeconds)}%${to}`;
346
+ }
339
347
  export function ffmpegSeekSeconds(metadata, mediaTime) {
340
348
  return mediaTime - (metadata.mediaTimeOffsetSeconds ?? 0);
341
349
  }
@@ -860,7 +868,7 @@ async function extractFinalPacketDuration(filePath, window, signal) {
860
868
  "-select_streams",
861
869
  "v:0",
862
870
  "-read_intervals",
863
- `${intervalStart}%${streamEnd}`,
871
+ readIntervalSpec(intervalStart, streamEnd),
864
872
  "-show_entries",
865
873
  "packet=pts_time,duration_time",
866
874
  "-of",
@@ -939,7 +947,7 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
939
947
  });
940
948
  return parseFinalTimestamp(stdout);
941
949
  };
942
- const timestamp = (await probe(`${intervalStart}%${streamEnd}`)) ?? (await probe( /* full scan */));
950
+ const timestamp = (await probe(readIntervalSpec(intervalStart, streamEnd))) ?? (await probe( /* full scan */));
943
951
  if (timestamp === undefined) {
944
952
  throw new Error("[FFmpeg] ffprobe found no decodable final video frame");
945
953
  }
@@ -1084,7 +1092,8 @@ async function probeDecodedAudioDuration(filePath, audioStream, sampleRate, star
1084
1092
  }
1085
1093
  };
1086
1094
  const tailStart = Math.max(startSeconds, startSeconds + containerDurationSeconds - AUDIO_DURATION_PROBE_TAIL_SECONDS);
1087
- 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());
1088
1097
  if (!lastFrame)
1089
1098
  return null;
1090
1099
  return lastFrame.timestamp + lastFrame.nbSamples / sampleRate - startSeconds;
@@ -1114,7 +1123,7 @@ export async function analyzeKeyframeIntervals(filePath, metadata) {
1114
1123
  export async function keyframeGapSeconds(filePath, seekSeconds, signal) {
1115
1124
  const stdout = await runFfprobe(filePath, [
1116
1125
  ...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
1117
- ...["-read_intervals", `${seekSeconds}%+#1`],
1126
+ ...["-read_intervals", readIntervalSpec(seekSeconds, "+#1")],
1118
1127
  ], signal);
1119
1128
  const keyframe = parseFloat(stdout.split("\n")[0] ?? "");
1120
1129
  return Number.isFinite(keyframe) ? Math.max(0, seekSeconds - keyframe) : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.224",
3
+ "version": "0.8.226",
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.224",
63
- "@hyperframes/parsers": "^0.8.224"
62
+ "@hyperframes/parsers": "^0.8.226",
63
+ "@hyperframes/core": "^0.8.226"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/node": "^25.0.10",