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