@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 +2 -0
- package/dist/config.js +3 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/services/audioMixer.js +13 -8
- package/dist/services/frameCapture.d.ts +1 -1
- package/dist/services/frameCapture.js +33 -36
- package/dist/services/screenshotService.d.ts +2 -1
- package/dist/services/screenshotService.js +7 -4
- package/dist/services/streamingEncoder.d.ts +3 -0
- package/dist/services/streamingEncoder.js +14 -0
- package/dist/services/videoFrameExtractor.d.ts +15 -1
- package/dist/services/videoFrameExtractor.js +133 -53
- package/dist/services/videoLayerPassthrough.d.ts +10 -0
- package/dist/services/videoLayerPassthrough.js +24 -0
- package/dist/types.d.ts +2 -0
- package/dist/utils/ffprobe.d.ts +1 -0
- package/dist/utils/ffprobe.js +13 -4
- package/package.json +3 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
? [
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
2467
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
2655
|
-
//
|
|
2656
|
-
//
|
|
2657
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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 =
|
|
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
|
-
|
|
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'
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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",
|
|
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.
|
|
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/
|
|
63
|
-
"@hyperframes/
|
|
62
|
+
"@hyperframes/parsers": "^0.8.226",
|
|
63
|
+
"@hyperframes/core": "^0.8.226"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@types/node": "^25.0.10",
|