@hyperframes/engine 0.8.209 → 0.8.211

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/index.d.ts CHANGED
@@ -43,7 +43,7 @@ export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type Cap
43
43
  export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
44
44
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
45
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
- export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
46
+ 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";
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, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
@@ -55,7 +55,7 @@ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCo
55
55
  export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
56
56
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
57
57
  export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
58
- export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, type ParallelWorkerPhaseDiagnostic, } from "./services/parallelCoordinator.js";
58
+ export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type WorkerSizingConfig, type LandedFrame, type ParallelProgress, type ParallelWorkerPhaseDiagnostic, } from "./services/parallelCoordinator.js";
59
59
  export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
60
60
  export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
61
61
  export { frameFileExtension } from "./services/frameCapture.js";
@@ -64,6 +64,7 @@ export { frameSequencePath } from "./services/extractedFrameIndex.js";
64
64
  export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionBlur.js";
65
65
  export type { PageMotionBlurOff } from "./types.js";
66
66
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
67
+ export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, } from "./utils/browserSafePort.js";
67
68
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
68
69
  export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
69
70
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
47
47
  export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
48
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
- export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
50
+ 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";
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, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
@@ -68,6 +68,7 @@ export { frameFileExtension } from "./services/frameCapture.js";
68
68
  export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThreads.js";
69
69
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
70
70
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
71
+ export { isBrowserSafePort, listenOnBrowserSafePort, settleOnBrowserSafePort, } from "./utils/browserSafePort.js";
71
72
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
72
73
  export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, runFfprobe, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
73
74
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
@@ -7,12 +7,17 @@ export async function listenOnLoopback(server) {
7
7
  throw new Error("No test server address");
8
8
  return `http://127.0.0.1:${address.port}`;
9
9
  }
10
- export function launchTestChrome(executablePath) {
11
- return puppeteer.launch({
10
+ export async function launchTestChrome(executablePath) {
11
+ const startedAt = Date.now();
12
+ const browser = await puppeteer.launch({
12
13
  executablePath,
13
14
  headless: true,
14
15
  args: ["--no-sandbox", "--disable-dev-shm-usage"],
15
16
  });
17
+ const launchedAt = Date.now();
18
+ await (await browser.newPage()).close();
19
+ console.info(`[engine-test-chrome] launch ${launchedAt - startedAt} ms, first page ${Date.now() - launchedAt} ms`);
20
+ return browser;
16
21
  }
17
22
  /** Closes a test Chrome, killing it when close hangs (seen on Windows runners) so afterAll never times out. */
18
23
  export async function closeTestChrome(browser, timeoutMs = 5_000) {
@@ -57,7 +57,7 @@ function supportsSettsDuration(binary = getFfmpegBinary()) {
57
57
  let supported = settsDurationSupport.get(binary);
58
58
  if (!supported) {
59
59
  supported = new Promise((resolve) => {
60
- execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => {
60
+ execFile(binary, ["-hide_banner", "-h", "bsf=setts"], { windowsHide: true }, (error, stdout) => {
61
61
  // A binary that cannot even print help fails the encode with its own error, not this one.
62
62
  resolve(error !== null || stdout.includes("duration"));
63
63
  });
@@ -57,8 +57,12 @@ export function shellStarts(binary, timeoutMs = 10_000) {
57
57
  if (answer === undefined) {
58
58
  const probe = ["--no-sandbox", "--dump-dom", "about:blank"];
59
59
  answer =
60
- spawnSync(binary, probe, { stdio: "ignore", timeout: timeoutMs, killSignal: "SIGKILL" })
61
- ?.status === 0;
60
+ spawnSync(binary, probe, {
61
+ stdio: "ignore",
62
+ timeout: timeoutMs,
63
+ killSignal: "SIGKILL",
64
+ windowsHide: true,
65
+ })?.status === 0;
62
66
  if (!answer)
63
67
  debug("skipping %s: it did not start", binary);
64
68
  starts.set(binary, answer);
@@ -54,15 +54,25 @@ export interface MuxVideoWithAudioOptions extends Partial<Pick<EngineConfig, "ff
54
54
  */
55
55
  preserveAudioPrimingEditList?: boolean;
56
56
  }
57
+ /** ProRes profile names in the order users are told about them. */
58
+ export declare const PRORES_PROFILE_NAMES: readonly ["4444", "hq", "422", "lt", "proxy"];
59
+ export type ProresProfile = (typeof PRORES_PROFILE_NAMES)[number];
60
+ /** The one table for `-profile:v` and pixel format; all but 4444 are 4:2:2 without alpha. */
61
+ export declare const PRORES_PROFILES: Record<ProresProfile, {
62
+ profile: string;
63
+ pixelFormat: string;
64
+ alpha: boolean;
65
+ }>;
66
+ export declare function isProresProfile(value: unknown): value is ProresProfile;
57
67
  /**
58
68
  * Get encoder preset for a given quality and output format.
59
69
  * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
60
- * MOV uses ProRes 4444 with alpha for editor-compatible transparency.
70
+ * MOV uses ProRes 4444 with alpha for editor-compatible transparency, or a 422 profile without alpha.
61
71
  */
62
72
  export declare function getEncoderPreset(quality: "draft" | "standard" | "high", format?: "mp4" | "webm" | "mov", hdr?: {
63
73
  transfer: HdrTransfer;
64
- }): EncoderPreset;
65
- export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
74
+ }, proresProfile?: ProresProfile): EncoderPreset;
75
+ export { detectGpuEncoder, getCachedGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
66
76
  /** The `lockGopForChunkConcat` / `gopSize` pair, shared by every encoder entry point. */
67
77
  export interface LockedGopOptions {
68
78
  lockGopForChunkConcat?: boolean;
@@ -59,12 +59,25 @@ async function shouldCopyAacSidecar(audioPath, options) {
59
59
  return false;
60
60
  }
61
61
  }
62
+ /** ProRes profile names in the order users are told about them. */
63
+ export const PRORES_PROFILE_NAMES = ["4444", "hq", "422", "lt", "proxy"];
64
+ /** The one table for `-profile:v` and pixel format; all but 4444 are 4:2:2 without alpha. */
65
+ export const PRORES_PROFILES = {
66
+ "4444": { profile: "4444", pixelFormat: "yuva444p10le", alpha: true },
67
+ hq: { profile: "3", pixelFormat: "yuv422p10le", alpha: false },
68
+ "422": { profile: "2", pixelFormat: "yuv422p10le", alpha: false },
69
+ lt: { profile: "1", pixelFormat: "yuv422p10le", alpha: false },
70
+ proxy: { profile: "0", pixelFormat: "yuv422p10le", alpha: false },
71
+ };
72
+ export function isProresProfile(value) {
73
+ return typeof value === "string" && PRORES_PROFILE_NAMES.includes(value);
74
+ }
62
75
  /**
63
76
  * Get encoder preset for a given quality and output format.
64
77
  * WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
65
- * MOV uses ProRes 4444 with alpha for editor-compatible transparency.
78
+ * MOV uses ProRes 4444 with alpha for editor-compatible transparency, or a 422 profile without alpha.
66
79
  */
67
- export function getEncoderPreset(quality, format = "mp4", hdr) {
80
+ export function getEncoderPreset(quality, format = "mp4", hdr, proresProfile = "4444") {
68
81
  const base = ENCODER_PRESETS[quality];
69
82
  if (format === "webm") {
70
83
  return {
@@ -75,12 +88,8 @@ export function getEncoderPreset(quality, format = "mp4", hdr) {
75
88
  };
76
89
  }
77
90
  if (format === "mov") {
78
- return {
79
- preset: "4444",
80
- quality: base.quality,
81
- codec: "prores",
82
- pixelFormat: "yuva444p10le",
83
- };
91
+ const { profile, pixelFormat } = PRORES_PROFILES[proresProfile];
92
+ return { preset: profile, quality: base.quality, codec: "prores", pixelFormat };
84
93
  }
85
94
  if (hdr) {
86
95
  return {
@@ -94,7 +103,7 @@ export function getEncoderPreset(quality, format = "mp4", hdr) {
94
103
  return { ...base, pixelFormat: "yuv420p" };
95
104
  }
96
105
  // Re-export GPU utilities so existing consumers that import from chunkEncoder still work.
97
- export { detectGpuEncoder } from "../utils/gpuEncoder.js";
106
+ export { detectGpuEncoder, getCachedGpuEncoder } from "../utils/gpuEncoder.js";
98
107
  /**
99
108
  * Integer GOP length, or `null` when no lock was requested. Throws on a lock
100
109
  * with an invalid size — a silent fallback ships open-GOP output that only
@@ -635,8 +644,9 @@ export async function muxVideoWithAudio(videoPath, audioPath, outputPath, signal
635
644
  const outputDir = dirname(outputPath);
636
645
  if (!existsSync(outputDir))
637
646
  mkdirSync(outputDir, { recursive: true });
638
- const isWebm = outputPath.endsWith(".webm");
639
- const isMov = outputPath.endsWith(".mov");
647
+ const outputExtension = extname(outputPath).toLowerCase();
648
+ const isWebm = outputExtension === ".webm";
649
+ const isMov = outputExtension === ".mov";
640
650
  const shouldCopyAudio = isWebm ? false : await shouldCopyAacSidecar(audioPath, config);
641
651
  const args = [...(config?.videoInputArgs ?? ["-i", videoPath]), "-i", audioPath, "-c:v", "copy"];
642
652
  if (isWebm) {
@@ -83,6 +83,8 @@ export interface WorkerSizingConfig extends Partial<Pick<EngineConfig, "concurre
83
83
  * than a plain DOM screenshot. Explicit --workers requests ignore this hint.
84
84
  */
85
85
  captureCostMultiplier?: number;
86
+ /** macOS, hardware GPU, screenshot frames written to disk (not streamed through this process). */
87
+ appleGpuDiskCapture?: boolean;
86
88
  }
87
89
  type WorkerBrowserPoolDecision = {
88
90
  parallel?: boolean;
@@ -32,6 +32,10 @@ const HEAP_RESERVED_MB = 1024;
32
32
  // default heap ⇒ >~500MB/worker + base. Caps auto sizing; an explicit
33
33
  // `--workers N` is still the operator's call.
34
34
  const HEAP_PER_WORKER_MB = 640;
35
+ // Apple-GPU disk capture measured ~15-25 MB parent heap per worker; 14 cores kept gaining to 8.
36
+ const APPLE_DISK_HEAP_PER_WORKER_MB = 128;
37
+ const APPLE_DISK_CORES_PER_WORKER = 1.75;
38
+ const APPLE_DISK_MAX_WORKERS = 8;
35
39
  const MIN_WORKERS = 1;
36
40
  const MAX_WORKER_DIAGNOSTIC_LINES = 8;
37
41
  // Hard ceiling on explicit `--workers N` requests. Above this, the cost of
@@ -133,7 +137,9 @@ export function computeWorkerSizing(totalFrames, requested, config) {
133
137
  // is immediately reclaimable.
134
138
  const totalMemoryMb = getSystemTotalMb();
135
139
  const heapLimitMb = Math.round(getHeapStatistics().heap_size_limit / (1024 * 1024));
136
- const heapBasedWorkers = Math.max(1, Math.floor((heapLimitMb - HEAP_RESERVED_MB) / HEAP_PER_WORKER_MB));
140
+ const appleDisk = config?.appleGpuDiskCapture === true;
141
+ const heapBasedWorkers = Math.max(1, Math.floor((heapLimitMb - HEAP_RESERVED_MB) /
142
+ (appleDisk ? APPLE_DISK_HEAP_PER_WORKER_MB : HEAP_PER_WORKER_MB)));
137
143
  const cpuBasedWorkers = Math.max(1, cpuCount - 2);
138
144
  const memoryBasedWorkers = Math.max(1, Math.floor((totalMemoryMb * 0.5) / MEMORY_PER_WORKER_MB));
139
145
  const frameBasedWorkers = Math.floor(totalFrames / MIN_FRAMES_PER_WORKER);
@@ -164,9 +170,14 @@ export function computeWorkerSizing(totalFrames, requested, config) {
164
170
  if (concurrency !== "auto") {
165
171
  return Math.max(MIN_WORKERS, Math.min(ABSOLUTE_MAX_WORKERS, Math.floor(concurrency)));
166
172
  }
167
- return defaultSafeMaxWorkers();
173
+ return appleDisk
174
+ ? Math.max(defaultSafeMaxWorkers(), APPLE_DISK_MAX_WORKERS)
175
+ : defaultSafeMaxWorkers();
168
176
  })();
169
- const effectiveCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
177
+ const configuredCoresPerWorker = config?.coresPerWorker ?? DEFAULT_CONFIG.coresPerWorker;
178
+ const effectiveCoresPerWorker = appleDisk && configuredCoresPerWorker === DEFAULT_CONFIG.coresPerWorker
179
+ ? APPLE_DISK_CORES_PER_WORKER
180
+ : configuredCoresPerWorker;
170
181
  const effectiveMinParallelFrames = config?.minParallelFrames ?? DEFAULT_CONFIG.minParallelFrames;
171
182
  const effectiveLargeRenderThreshold = config?.largeRenderThreshold ?? DEFAULT_CONFIG.largeRenderThreshold;
172
183
  if (totalFrames < MIN_FRAMES_PER_WORKER * 2) {
@@ -17,7 +17,7 @@ import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
17
17
  import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, mediaElementTime, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
18
18
  import { resolveReferencedStart } from "./referenceResolver.js";
19
19
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
20
- import { extractFinalVideoFrameTimestamp, extractMediaMetadata, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, writeMetadataCache, } from "../utils/ffprobe.js";
20
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, writeMetadataCache, } from "../utils/ffprobe.js";
21
21
  import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
22
22
  import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
23
23
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
@@ -508,6 +508,22 @@ export function sdrCanvasColourFilters(metadata, toneMappedToSdr = false) {
508
508
  * frames are byte-identical to it.
509
509
  */
510
510
  const EXTRACTION_SEGMENT_SECONDS = 120;
511
+ /** Slowest decode rate the stall clock allows while ffmpeg discards frames from a keyframe up to the seek point. */
512
+ const SKIP_DECODE_FPS_FLOOR = 30;
513
+ async function leadInSecondsUnlessAborted(videoPath, seek, signal) {
514
+ try {
515
+ return await keyframeGapSeconds(videoPath, seek, signal);
516
+ }
517
+ catch (error) {
518
+ // Aborted: let runFfmpeg report the abort, so a segment stopped by another's failure reads as stopped.
519
+ if (signal?.aborted)
520
+ return 0;
521
+ throw error;
522
+ }
523
+ }
524
+ function stallTimeoutMs(baseMs, skippedSeconds, sourceFps) {
525
+ return baseMs + Math.ceil((skippedSeconds * sourceFps * 1000) / SKIP_DECODE_FPS_FLOOR);
526
+ }
511
527
  /** Frame times since file start are `PTS*TB + seek`; snapping them to `lead + k/FR` gives every process one grid. */
512
528
  function undoTickRoundingOnTheSourceFrameGrid(seek, lead) {
513
529
  const sinceFileStart = `(PTS*TB+${seek}-${lead})`;
@@ -535,7 +551,7 @@ function splitFrameRanges(ranges, segmentFrames) {
535
551
  return segments;
536
552
  }
537
553
  async function runSegmentedExtraction(job) {
538
- const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, segments, gate } = job;
554
+ const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, sourceFps, segments, gate } = job;
539
555
  const segmentCount = segments.length;
540
556
  // One failed segment fails the range, so stop the others instead of finishing them.
541
557
  const failed = new AbortController();
@@ -549,7 +565,7 @@ async function runSegmentedExtraction(job) {
549
565
  const index = next++;
550
566
  const { firstFrame, frames } = segments[index];
551
567
  const seek = startTime + firstFrame / fps;
552
- const result = await gate(() => runFfmpeg([
568
+ const result = await gate(async () => runFfmpeg([
553
569
  ...decodeArgs,
554
570
  "-noaccurate_seek",
555
571
  "-ss",
@@ -564,14 +580,21 @@ async function runSegmentedExtraction(job) {
564
580
  "-start_number",
565
581
  String(firstFrame + 1),
566
582
  ...filterAndEncodeArgs(seek),
567
- ], { ...job.runOptions, signal }), signal);
583
+ ], {
584
+ ...job.runOptions,
585
+ signal,
586
+ timeout: stallTimeoutMs(job.runOptions.timeout, await leadInSecondsUnlessAborted(videoPath, seek, signal), sourceFps),
587
+ }), signal);
568
588
  results[index] = result;
569
589
  if (!result.success)
570
590
  failed.abort();
571
591
  }
572
592
  };
573
593
  const workers = Math.min(segmentCount, EXTRACTION_SLOTS);
574
- await Promise.all(Array.from({ length: workers }, worker));
594
+ await Promise.all(Array.from({ length: workers }, worker)).catch((error) => {
595
+ failed.abort();
596
+ throw error;
597
+ });
575
598
  const ran = results.filter(Boolean);
576
599
  // Report the segment that failed, not the ones stopped because of it.
577
600
  const failure = ran.find((r) => !r.success && r.terminationReason !== "abort") ?? ran.find((r) => !r.success);
@@ -731,11 +754,17 @@ outputDirOverride) {
731
754
  videoPath,
732
755
  startTime,
733
756
  fps,
757
+ sourceFps: metadata.fps,
734
758
  gate: options.tailProbe ? inTailProbeSlot : inExtractionSlot,
735
759
  segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
736
760
  runOptions,
737
761
  })
738
- : inExtractionSlot(() => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], runOptions), signal);
762
+ : inExtractionSlot(async () => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], {
763
+ ...runOptions,
764
+ timeout: stallTimeoutMs(ffmpegProcessTimeout, options.finalFrameOnly
765
+ ? startTime
766
+ : await leadInSecondsUnlessAborted(videoPath, startTime, signal), metadata.fps),
767
+ }), signal);
739
768
  let processResult = await extract(decodeWithVideoToolbox ? ["-hwaccel", "videotoolbox"] : []);
740
769
  const ffmpegExitedFailing = processResult.terminationReason === "exit" &&
741
770
  !processResult.success &&
@@ -1349,7 +1378,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1349
1378
  // Phase 3 still calls `extractVideoFramesRange` on the same past-EOF
1350
1379
  // mediaStart, surfacing a second raw FFmpeg error for the same clip.
1351
1380
  const hdrSkippedIndices = new Set();
1352
- if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
1381
+ if (!options.toneMapHdrToSdr && hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
1353
1382
  // dominantTransfer is "majority wins" — if a composition mixes PQ and HLG
1354
1383
  // sources (rare but legal), the minority transfer's videos get converted
1355
1384
  // with the wrong curve. We treat this as caller-error: a single composition
@@ -0,0 +1,8 @@
1
+ import type { Server } from "node:net";
2
+ export declare const isBrowserSafePort: (port: number) => boolean;
3
+ type Bindable = Pick<Server, "address" | "close" | "listen" | "once" | "off">;
4
+ /** Rebinds an already-listening server until the OS hands it a port a browser will load from. */
5
+ export declare function settleOnBrowserSafePort(server: Bindable, host: string): Promise<number>;
6
+ /** `server.listen(0, host)` that never lands on a port Chromium blocks; resolves to the bound port. */
7
+ export declare function listenOnBrowserSafePort(server: Bindable, host: string): Promise<number>;
8
+ export {};
@@ -0,0 +1,38 @@
1
+ // Chromium refuses to load from these ports (net::ERR_UNSAFE_PORT); net/base/port_util.cc kRestrictedPorts.
2
+ const RESTRICTED_PORTS = new Set([
3
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
4
+ 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465,
5
+ 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993,
6
+ 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669,
7
+ 6697, 10080,
8
+ ]);
9
+ const MAX_ATTEMPTS = 32;
10
+ export const isBrowserSafePort = (port) => !RESTRICTED_PORTS.has(port);
11
+ function listenOnce(server, host) {
12
+ return new Promise((resolve, reject) => {
13
+ server.once("error", reject);
14
+ server.listen(0, host, () => {
15
+ server.off("error", reject);
16
+ resolve(server.address().port);
17
+ });
18
+ });
19
+ }
20
+ function closeServer(server) {
21
+ return new Promise((resolve) => server.close(() => resolve()));
22
+ }
23
+ /** Rebinds an already-listening server until the OS hands it a port a browser will load from. */
24
+ export async function settleOnBrowserSafePort(server, host) {
25
+ let port = server.address().port;
26
+ for (let attempt = 1; !isBrowserSafePort(port); attempt++) {
27
+ if (attempt >= MAX_ATTEMPTS)
28
+ throw new Error(`The OS kept offering browser-restricted ports (last: ${port}).`);
29
+ await closeServer(server);
30
+ port = await listenOnce(server, host);
31
+ }
32
+ return port;
33
+ }
34
+ /** `server.listen(0, host)` that never lands on a port Chromium blocks; resolves to the bound port. */
35
+ export async function listenOnBrowserSafePort(server, host) {
36
+ await listenOnce(server, host);
37
+ return settleOnBrowserSafePort(server, host);
38
+ }
@@ -219,4 +219,6 @@ export interface KeyframeAnalysis {
219
219
  * Pass the stream's own duration/start (not the container's), which can overstate a single-keyframe GOP.
220
220
  */
221
221
  export declare function analyzeKeyframeIntervals(filePath: string, metadata: Pick<VideoMetadata, "videoStreamDurationSeconds" | "videoStreamStartSeconds">): Promise<KeyframeAnalysis>;
222
+ /** Seconds ffmpeg decodes without output after a seek to `seekSeconds`: back to the keyframe it lands on. */
223
+ export declare function keyframeGapSeconds(filePath: string, seekSeconds: number, signal?: AbortSignal): Promise<number>;
222
224
  export {};
@@ -296,7 +296,10 @@ function ffprobeMajor() {
296
296
  runtimeFfprobeMajor ??= new Promise((resolve) => {
297
297
  let out = "";
298
298
  const binary = getFfprobeBinary();
299
- const proc = spawn(binary, ["-version"], { stdio: ["ignore", "pipe", "ignore"] });
299
+ const proc = spawn(binary, ["-version"], {
300
+ stdio: ["ignore", "pipe", "ignore"],
301
+ windowsHide: true,
302
+ });
300
303
  proc.stdout.on("data", (chunk) => (out += String(chunk)));
301
304
  proc.on("error", () => resolve(undefined));
302
305
  proc.on("close", () => resolve(Number(/version n?(\d+)/.exec(out)?.[1]) || undefined));
@@ -1084,6 +1087,15 @@ export async function analyzeKeyframeIntervals(filePath, metadata) {
1084
1087
  });
1085
1088
  return promise;
1086
1089
  }
1090
+ /** Seconds ffmpeg decodes without output after a seek to `seekSeconds`: back to the keyframe it lands on. */
1091
+ export async function keyframeGapSeconds(filePath, seekSeconds, signal) {
1092
+ const stdout = await runFfprobe(filePath, [
1093
+ ...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
1094
+ ...["-read_intervals", `${seekSeconds}%+#1`],
1095
+ ], signal);
1096
+ const keyframe = parseFloat(stdout.split("\n")[0] ?? "");
1097
+ return Number.isFinite(keyframe) ? Math.max(0, seekSeconds - keyframe) : 0;
1098
+ }
1087
1099
  async function analyzeKeyframeIntervalsUncached(filePath, videoStreamStartSeconds, videoStreamDurationSeconds) {
1088
1100
  const stdout = await runFfprobe(filePath, [
1089
1101
  "-select_streams",
@@ -10,6 +10,7 @@ export declare function getCompiledGpuEncoders(ffmpegEncodersStdout: string): Co
10
10
  export declare function selectUsableGpuEncoder(candidates: readonly ConcreteGpuEncoder[], isUsable: (encoder: ConcreteGpuEncoder) => Promise<boolean>): Promise<GpuEncoder>;
11
11
  export declare function detectGpuEncoder(): Promise<GpuEncoder>;
12
12
  export declare function getCachedGpuEncoder(): Promise<GpuEncoder>;
13
+ export declare function resetCachedGpuEncoder(): void;
13
14
  export declare function getGpuEncoderName(encoder: GpuEncoder, codec: "h264" | "h265"): string;
14
15
  export declare function buildVideoToolboxRateControlArgs(input: {
15
16
  bitrate?: string;
@@ -64,12 +64,13 @@ export async function detectGpuEncoder() {
64
64
  return selectUsableGpuEncoder(candidates, canUseGpuEncoder).catch(() => null);
65
65
  }
66
66
  let cachedGpuEncoder = undefined;
67
- export async function getCachedGpuEncoder() {
68
- if (cachedGpuEncoder === undefined) {
69
- cachedGpuEncoder = await detectGpuEncoder();
70
- }
67
+ export function getCachedGpuEncoder() {
68
+ cachedGpuEncoder ??= detectGpuEncoder();
71
69
  return cachedGpuEncoder;
72
70
  }
71
+ export function resetCachedGpuEncoder() {
72
+ cachedGpuEncoder = undefined;
73
+ }
73
74
  export function getGpuEncoderName(encoder, codec) {
74
75
  if (!encoder)
75
76
  return codec === "h264" ? "libx264" : "libx265";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.209",
3
+ "version": "0.8.211",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -52,10 +52,10 @@
52
52
  "@hono/node-server": "^2.0.5",
53
53
  "hono": "^4.6.0",
54
54
  "linkedom": "^0.18.12",
55
- "puppeteer": "^25.8.0",
56
- "puppeteer-core": "^25.8.0",
57
- "@hyperframes/parsers": "^0.8.209",
58
- "@hyperframes/core": "^0.8.209"
55
+ "puppeteer": "~25.11.0",
56
+ "puppeteer-core": "^25.11.0",
57
+ "@hyperframes/parsers": "^0.8.211",
58
+ "@hyperframes/core": "^0.8.211"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^25.0.10",