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