@hyperframes/engine 0.8.192 → 0.8.194

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
@@ -50,7 +50,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
50
50
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
51
51
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
52
52
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
53
- export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
53
+ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, videoAudioId, } from "./services/audioMixer.js";
54
54
  export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
55
55
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
56
56
  export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
@@ -64,7 +64,7 @@ export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionB
64
64
  export type { PageMotionBlurOff } from "./types.js";
65
65
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
66
66
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
67
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
67
+ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
68
68
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
69
69
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
70
70
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
@@ -72,6 +72,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
72
72
  export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
73
73
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
74
74
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
75
+ export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
75
76
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
76
77
  export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
77
78
  export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
package/dist/index.js CHANGED
@@ -54,7 +54,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
54
54
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
55
55
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
56
56
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
57
- export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
57
+ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, videoAudioId, } from "./services/audioMixer.js";
58
58
  export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
59
59
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
60
60
  // ── Parallel rendering ─────────────────────────────────────────────────────────
@@ -68,7 +68,7 @@ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThrea
68
68
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
69
69
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
70
70
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
71
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
71
+ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
72
72
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
73
73
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
74
74
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
@@ -76,6 +76,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
76
76
  export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
77
77
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
78
78
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
79
+ export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
79
80
  // drawElement self-verify comparison — shared by the streaming drain
80
81
  // (producer) and the parallel disk-path verify (parallelCoordinator).
81
82
  export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
@@ -2,8 +2,10 @@
2
2
  * AAC for the mixed 48 kHz audio. One encoder is single-threaded (about 60x real time),
3
3
  * so long audio is encoded as frame-aligned segments in parallel and their frames joined.
4
4
  */
5
+ import { execFile } from "node:child_process";
5
6
  import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { dirname, join } from "node:path";
8
+ import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
7
9
  import { runFfmpeg } from "../utils/runFfmpeg.js";
8
10
  const SAMPLE_RATE = 48_000;
9
11
  const FRAME_SAMPLES = 1024;
@@ -48,8 +50,33 @@ function planSegments(totalDuration, count) {
48
50
  return { trim, keepFrom: cut - firstFrame, keepTo: next - firstFrame };
49
51
  });
50
52
  }
53
+ const settsDurationSupport = new Map();
54
+ /** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */
55
+ function supportsSettsDuration(binary = getFfmpegBinary()) {
56
+ let supported = settsDurationSupport.get(binary);
57
+ if (!supported) {
58
+ supported = new Promise((resolve) => {
59
+ execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => {
60
+ // A binary that cannot even print help fails the encode with its own error, not this one.
61
+ resolve(error !== null || stdout.includes("duration"));
62
+ });
63
+ });
64
+ settsDurationSupport.set(binary, supported);
65
+ }
66
+ return supported;
67
+ }
68
+ function encodeInOnePass(input, outputPath, { signal, timeout, filter }) {
69
+ const filters = [filter, `aresample=${SAMPLE_RATE}`].filter(Boolean).join(",");
70
+ return runFfmpeg(["-i", input, "-af", filters, ...AAC_ARGS, "-y", outputPath], {
71
+ signal,
72
+ timeout,
73
+ });
74
+ }
51
75
  /** `input`, after `filter`, is audio of exactly `totalDuration`. */
52
76
  export async function encodeAac(input, outputPath, totalDuration, { signal, timeout, filter, segments: count = aacSegmentCount(totalDuration), }) {
77
+ if (!(await supportsSettsDuration())) {
78
+ return encodeInOnePass(input, outputPath, { signal, timeout, filter });
79
+ }
53
80
  const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-"));
54
81
  try {
55
82
  const segments = planSegments(totalDuration, count).map((segment, k) => ({
@@ -30,6 +30,7 @@ export declare function volumeLaneKeyframes(automation: {
30
30
  * itself has no parsed element to carry it.
31
31
  */
32
32
  export declare function parseMasterFxChain(html: string): string | undefined;
33
+ export declare function videoAudioId(videoId: string): string;
33
34
  export declare function parseAudioElements(html: string): AudioElement[];
34
35
  /** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
35
36
  export declare const MAX_MIX_INPUTS = 96;
@@ -1,6 +1,7 @@
1
1
  // fallow-ignore-file complexity code-duplication
2
2
  import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
3
- import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync, } from "fs";
3
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
4
+ import { renameWithRetry } from "../utils/renameWithRetry.js";
4
5
  import { join, dirname, isAbsolute, relative } from "path";
5
6
  import { parseHTML } from "linkedom";
6
7
  import { extractAudioMetadata } from "../utils/ffprobe.js";
@@ -419,6 +420,9 @@ export function volumeLaneKeyframes(automation, trackStart, duration, gain, gain
419
420
  export function parseMasterFxChain(html) {
420
421
  return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
421
422
  }
423
+ export function videoAudioId(videoId) {
424
+ return `${videoId}-audio`;
425
+ }
422
426
  export function parseAudioElements(html) {
423
427
  const elements = [];
424
428
  const { document } = parseHTML(unwrapTemplate(html));
@@ -503,7 +507,7 @@ export function parseAudioElements(html) {
503
507
  continue;
504
508
  if (isKnownInactiveTimelineWindow(el, resolveStart(el)))
505
509
  continue;
506
- elements.push(build(el, `${id}-audio`, src, "video", true));
510
+ elements.push(build(el, videoAudioId(id), src, "video", true));
507
511
  }
508
512
  return elements;
509
513
  }
@@ -1013,7 +1017,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
1013
1017
  const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
1014
1018
  if (!summed.success)
1015
1019
  return summed;
1016
- renameSync(summed.track.srcPath, outputPath);
1020
+ renameWithRetry(summed.track.srcPath, outputPath);
1017
1021
  return { success: true, degradedAutomation: summed.degradedAutomation };
1018
1022
  }
1019
1023
  finally {
@@ -1059,15 +1063,18 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
1059
1063
  */
1060
1064
  async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
1061
1065
  const sumPath = join(workDir, "master-sum.wav");
1066
+ const mixStart = Date.now();
1062
1067
  const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config);
1063
1068
  if (!sum.success)
1064
1069
  return { ...sum, outputPath };
1070
+ const masterStart = Date.now();
1065
1071
  const encoderInput = masterChain
1066
1072
  ? (await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
1067
1073
  trackId: "master",
1068
1074
  signal,
1069
1075
  })).path
1070
1076
  : sumPath;
1077
+ const encodeStart = Date.now();
1071
1078
  const encoded = await encodeAac(encoderInput, outputPath, totalDuration, {
1072
1079
  signal,
1073
1080
  timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
@@ -1084,8 +1091,17 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
1084
1091
  };
1085
1092
  }
1086
1093
  if (config?.keepEncoderInputAt)
1087
- renameSync(encoderInput, config.keepEncoderInputAt);
1088
- return { ...sum, outputPath, durationMs: sum.durationMs + encoded.durationMs };
1094
+ renameWithRetry(encoderInput, config.keepEncoderInputAt);
1095
+ return {
1096
+ ...sum,
1097
+ outputPath,
1098
+ durationMs: sum.durationMs + encoded.durationMs,
1099
+ phaseMs: {
1100
+ mix: masterStart - mixStart,
1101
+ master: encodeStart - masterStart,
1102
+ encode: Date.now() - encodeStart,
1103
+ },
1104
+ };
1089
1105
  }
1090
1106
  export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
1091
1107
  const startMs = Date.now();
@@ -1367,6 +1383,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1367
1383
  failures,
1368
1384
  };
1369
1385
  };
1386
+ const prepareMs = Date.now() - startMs;
1370
1387
  if (failures.length > 0)
1371
1388
  return bail();
1372
1389
  // Parsed before any group is mixed, so a malformed master chain fails fast and
@@ -1486,6 +1503,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1486
1503
  }
1487
1504
  if (failures.length > 0)
1488
1505
  return bail();
1506
+ const groupsMs = Date.now() - startMs - prepareMs;
1489
1507
  let mixResult;
1490
1508
  try {
1491
1509
  mixResult =
@@ -1505,6 +1523,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1505
1523
  return {
1506
1524
  ...mixResult,
1507
1525
  durationMs: Date.now() - startMs,
1526
+ phaseMs: { prepare: prepareMs, groups: groupsMs, ...mixResult.phaseMs },
1508
1527
  // Both, when both degraded. `mixResult.error ?? degradedNote` reported only
1509
1528
  // the outer mix and dropped the one that names which GROUPS lost their
1510
1529
  // members' automation — two different losses, and the operator needs to
@@ -81,4 +81,5 @@ export interface MixResult {
81
81
  tracksProcessed: number;
82
82
  error?: string;
83
83
  failures?: AudioProcessingFailure[];
84
+ phaseMs?: Partial<Record<"prepare" | "groups" | "mix" | "master" | "encode", number>>;
84
85
  }
@@ -14,7 +14,8 @@
14
14
  * `extractAudioSegment`). Anything else is rejected so
15
15
  * the caller can fall back to the expression path rather than corrupting audio.
16
16
  */
17
- import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
17
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
18
+ import { renameWithRetry } from "../utils/renameWithRetry.js";
18
19
  import { dirname, join } from "path";
19
20
  import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
20
21
  import { riffChunks, wavFormatTag } from "./wavChunks.js";
@@ -131,7 +132,7 @@ export function applyVolumeEnvelopeToWav(wavPath, keyframes, trackStart, baseVol
131
132
  stagingDir = mkdtempSync(join(dirname(wavPath), ".hf-volume-"));
132
133
  const tempPath = join(stagingDir, "audio.wav");
133
134
  writeFileSync(tempPath, buffer, { flag: "wx" });
134
- renameSync(tempPath, wavPath);
135
+ renameWithRetry(tempPath, wavPath);
135
136
  return true;
136
137
  }
137
138
  catch {
@@ -5,6 +5,7 @@
5
5
  * Manages Puppeteer browser lifecycle: Chrome executable resolution,
6
6
  * launch args, pooled browser acquisition/release.
7
7
  */
8
+ import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
8
9
  import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
9
10
  import { execSync } from "child_process";
10
11
  import { existsSync, readdirSync, statSync } from "fs";
@@ -55,7 +56,7 @@ async function probeHardwareWebGlInfo(ppt, options) {
55
56
  const launchOptions = {
56
57
  ...sigintLaunchOptions(),
57
58
  headless: true,
58
- args: options.args,
59
+ args: withHyperframesBrowserMarker(options.args),
59
60
  defaultViewport: { width: 64, height: 64 },
60
61
  executablePath: options.executablePath,
61
62
  env: options.env,
@@ -637,7 +638,7 @@ async function launchBrowser(fingerprint) {
637
638
  browser = await ppt.launch({
638
639
  ...sigintLaunchOptions(),
639
640
  headless: true,
640
- args: [...fingerprint.args],
641
+ args: withHyperframesBrowserMarker(fingerprint.args),
641
642
  defaultViewport: null,
642
643
  executablePath: fingerprint.executablePath,
643
644
  env,
@@ -662,7 +663,7 @@ async function launchBrowser(fingerprint) {
662
663
  browser = await ppt.launch({
663
664
  ...sigintLaunchOptions(),
664
665
  headless: true,
665
- args: stripBeginFrameFlags([...fingerprint.args]),
666
+ args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])),
666
667
  defaultViewport: null,
667
668
  executablePath: fingerprint.executablePath,
668
669
  env,
@@ -769,6 +770,7 @@ function getLowMemoryFlags() {
769
770
  }
770
771
  const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
771
772
  const WEBGPU_FLAG = "--enable-unsafe-webgpu";
773
+ const DISABLE_DXC_FLAG = "--disable-dawn-features=use_dxc";
772
774
  /**
773
775
  * Whether this launch renders WebGPU on SwiftShader: the composition needs it, the render opted in, no GPU is in use.
774
776
  * SwiftShader draws canvas WebGPU only via Vulkan with GPU compositing on, and not at all under --disable-gpu.
@@ -839,6 +841,9 @@ export function buildChromeArgs(options, config) {
839
841
  ];
840
842
  if (browserGpuMode !== "software" || options.requiresWebGpu) {
841
843
  chromeArgs.push(WEBGPU_FLAG);
844
+ if (platform === "win32") {
845
+ chromeArgs.push(DISABLE_DXC_FLAG);
846
+ }
842
847
  }
843
848
  if (softwareWebGpu)
844
849
  chromeArgs.push("--use-vulkan=swiftshader");
@@ -10,16 +10,19 @@ export declare class CaptureFailure extends Error {
10
10
  readonly kind: CaptureFailureKind;
11
11
  readonly cause: unknown;
12
12
  readonly workerDiagnostics: readonly CaptureWorkerDiagnostic[];
13
+ readonly pageErrorLines: readonly string[];
13
14
  constructor(input: {
14
15
  kind: CaptureFailureKind;
15
16
  message: string;
16
17
  cause?: unknown;
17
18
  workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
19
+ pageErrorLines?: readonly string[];
18
20
  });
19
21
  }
20
22
  export declare function classifyCaptureFailure(error: unknown, options?: {
21
23
  signal?: AbortSignal;
22
24
  workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
25
+ pageErrorLines?: readonly string[];
23
26
  }): CaptureFailure;
24
27
  export declare function isTransientBrowserError(error: unknown): boolean;
25
28
  export declare function isMemoryExhaustionError(error: unknown): boolean;
@@ -2,12 +2,14 @@ export class CaptureFailure extends Error {
2
2
  kind;
3
3
  cause;
4
4
  workerDiagnostics;
5
+ pageErrorLines;
5
6
  constructor(input) {
6
7
  super(input.message);
7
8
  this.name = "CaptureFailure";
8
9
  this.kind = input.kind;
9
10
  this.cause = input.cause;
10
11
  this.workerDiagnostics = Object.freeze((input.workerDiagnostics ?? []).map((diagnostic) => Object.freeze({ ...diagnostic, lines: Object.freeze([...diagnostic.lines]) })));
12
+ this.pageErrorLines = Object.freeze([...(input.pageErrorLines ?? [])]);
11
13
  if (input.cause instanceof Error && input.cause.stack)
12
14
  this.stack = input.cause.stack;
13
15
  }
@@ -101,41 +103,40 @@ function ioError(error, message) {
101
103
  BROKEN_PIPE_MESSAGE.test(message) ||
102
104
  hasIoOperationFailure(message));
103
105
  }
106
+ const ORDERED_KIND_RULES = [
107
+ [
108
+ "memory_exhaustion",
109
+ (message) => BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) ||
110
+ BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) ||
111
+ matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS),
112
+ ],
113
+ ["verification", (message) => matchesAny(message, VERIFICATION_ERROR_PATTERNS)],
114
+ ["protocol_timeout", (message) => matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)],
115
+ ["transient_browser", (message) => matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)],
116
+ ["authoring", (message) => matchesAny(message, AUTHORING_ERROR_PATTERNS)],
117
+ ];
118
+ function classifyKind(error, message, signal) {
119
+ if (signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) {
120
+ return "cancelled";
121
+ }
122
+ const rule = ORDERED_KIND_RULES.find(([, matches]) => matches(message));
123
+ if (rule)
124
+ return rule[0];
125
+ return ioError(error, message) ? "io" : "authoring";
126
+ }
104
127
  export function classifyCaptureFailure(error, options = {}) {
105
- if (error instanceof CaptureFailure && !options.workerDiagnostics && !options.signal?.aborted) {
106
- return error;
128
+ const prior = error instanceof CaptureFailure ? error : undefined;
129
+ if (prior && !(options.workerDiagnostics || options.pageErrorLines || options.signal?.aborted)) {
130
+ return prior;
107
131
  }
108
132
  const message = messageOf(error);
109
- let kind;
110
- if (options.signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) {
111
- kind = "cancelled";
112
- }
113
- else if (BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) ||
114
- BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) ||
115
- matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS)) {
116
- kind = "memory_exhaustion";
117
- }
118
- else if (matchesAny(message, VERIFICATION_ERROR_PATTERNS)) {
119
- kind = "verification";
120
- }
121
- else if (matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)) {
122
- kind = "protocol_timeout";
123
- }
124
- else if (matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)) {
125
- kind = "transient_browser";
126
- }
127
- else if (matchesAny(message, AUTHORING_ERROR_PATTERNS)) {
128
- kind = "authoring";
129
- }
130
- else {
131
- kind = ioError(error, message) ? "io" : "authoring";
132
- }
133
+ const kind = classifyKind(error, message, options.signal);
133
134
  return new CaptureFailure({
134
135
  kind,
135
136
  message,
136
137
  cause: error,
137
- workerDiagnostics: options.workerDiagnostics ??
138
- (error instanceof CaptureFailure ? error.workerDiagnostics : undefined),
138
+ workerDiagnostics: options.workerDiagnostics ?? prior?.workerDiagnostics,
139
+ pageErrorLines: options.pageErrorLines ?? prior?.pageErrorLines,
139
140
  });
140
141
  }
141
142
  export function isTransientBrowserError(error) {
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { frameSequencePath } from "./extractedFrameIndex.js";
9
9
  import { closeSync, copyFileSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync, } from "fs";
10
- import { join, dirname, extname } from "path";
10
+ import { join, dirname, extname, resolve } from "path";
11
11
  import { DEFAULT_CONFIG } from "../config.js";
12
12
  import { getCachedGpuEncoder, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
13
13
  import { getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
@@ -460,7 +460,7 @@ let concatListSeq = 0;
460
460
  function writeConcatList(dir, inputPaths) {
461
461
  concatListSeq += 1;
462
462
  const listPath = join(dir, `concat-list-${process.pid}-${concatListSeq}.txt`);
463
- const body = inputPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n");
463
+ const body = inputPaths.map((p) => `file '${resolve(p).replace(/'/g, "'\\''")}'`).join("\n");
464
464
  writeFileSync(listPath, body, "utf-8");
465
465
  return listPath;
466
466
  }
@@ -32,7 +32,8 @@
32
32
  * become inert and can be gc'd by the caller.
33
33
  */
34
34
  import { createHash, randomUUID } from "node:crypto";
35
- import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
35
+ import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
36
+ import { renameWithRetry } from "../utils/renameWithRetry.js";
36
37
  import { join } from "node:path";
37
38
  import { FRAME_FILENAME_PREFIX, framePathsFromDirectory } from "./extractedFrameIndex.js";
38
39
  /** Filename prefix for extracted frames. Shared with the extractor. */
@@ -185,7 +186,7 @@ export function publishCacheEntry(entry, partialDir) {
185
186
  return { dir: partialDir, published: false };
186
187
  }
187
188
  try {
188
- renameSync(partialDir, entry.dir);
189
+ renameWithRetry(partialDir, entry.dir);
189
190
  return { dir: entry.dir, published: true };
190
191
  }
191
192
  catch (err) {
@@ -202,7 +203,7 @@ export function publishCacheEntry(entry, partialDir) {
202
203
  return { dir: partialDir, published: false };
203
204
  }
204
205
  try {
205
- renameSync(partialDir, entry.dir);
206
+ renameWithRetry(partialDir, entry.dir);
206
207
  return { dir: entry.dir, published: true };
207
208
  }
208
209
  catch {
@@ -393,14 +393,17 @@ export async function driveWarmupTicks(options, state) {
393
393
  if (!state.running)
394
394
  return;
395
395
  }
396
+ let ticked = false;
396
397
  try {
397
398
  await options.tick(state.ticks * options.intervalMs, options.intervalMs);
398
399
  state.ticks += 1;
400
+ ticked = true;
399
401
  }
400
402
  catch {
401
403
  // Page not ready yet; keep spinning.
402
404
  }
403
- await sleep(options.intervalMs);
405
+ if (!(options.lockWarmupTicks && !state.running && ticked))
406
+ await sleep(options.intervalMs);
404
407
  }
405
408
  }
406
409
  export function resolveCaptureSessionOptions(options, browserVersion, platform = process.platform) {
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Performance: ~6 fps at 1080x1920 via base64 transfer.
20
20
  */
21
+ import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
21
22
  import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js";
22
23
  // ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
23
24
  const PQ_M1 = 0.1593017578125;
@@ -183,7 +184,7 @@ export async function launchHdrBrowser(width, height) {
183
184
  ...sigintLaunchOptions(),
184
185
  headless: false,
185
186
  executablePath: chromePath,
186
- args: buildHdrChromeArgs(width, height),
187
+ args: withHyperframesBrowserMarker(buildHdrChromeArgs(width, height)),
187
188
  });
188
189
  const page = await browser.newPage();
189
190
  await page.setViewport({ width, height });
@@ -46,6 +46,8 @@ export interface WorkerResult {
46
46
  perf?: CapturePerfSummary;
47
47
  error?: string;
48
48
  diagnostics?: string[];
49
+ /** The worker session's `[Browser:PAGEERROR]` console lines, kept so the render's error count sees them. */
50
+ pageErrorLines?: string[];
49
51
  failure?: CaptureFailure;
50
52
  }
51
53
  export interface ParallelProgress {
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { cpus, freemem } from "os";
8
8
  import { existsSync, mkdirSync, readdirSync } from "fs";
9
- import { copyFile, readFile, rename } from "fs/promises";
9
+ import { copyFile, readFile } from "fs/promises";
10
+ import { renameWithRetryAsync } from "../utils/renameWithRetry.js";
10
11
  import { join } from "path";
11
12
  import { getHeapStatistics } from "v8";
12
13
  import { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBufferPipelined, captureFrameToBuffer, getCapturePerfSummary, DrawElementVerificationError, } from "./frameCapture.js";
@@ -615,6 +616,7 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
615
616
  frameStride: task.frameStride,
616
617
  durationMs: Date.now() - startTime,
617
618
  perf,
619
+ pageErrorLines: pageErrorLinesOf(activeSession),
618
620
  };
619
621
  }
620
622
  catch (error) {
@@ -629,6 +631,7 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
629
631
  const failure = classifyCaptureFailure(error, {
630
632
  signal,
631
633
  workerDiagnostics: [workerDiagnostic],
634
+ pageErrorLines: session ? pageErrorLinesOf(session) : undefined,
632
635
  });
633
636
  onFailure?.(failure);
634
637
  return {
@@ -641,6 +644,7 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
641
644
  perf,
642
645
  error: failure.message,
643
646
  diagnostics: diagnostics.length > 0 ? diagnostics : undefined,
647
+ pageErrorLines: session ? pageErrorLinesOf(session) : undefined,
644
648
  failure,
645
649
  };
646
650
  }
@@ -681,6 +685,9 @@ export function isPoolFatalWorkerFailure(failure, streaming) {
681
685
  return failure.kind !== "cancelled";
682
686
  return isFatalCaptureFailure(failure);
683
687
  }
688
+ function pageErrorLinesOf(session) {
689
+ return session.browserConsoleBuffer.filter((line) => line.includes("PAGEERROR"));
690
+ }
684
691
  /**
685
692
  * The pool's single failure gate. Both halves of the contract the streaming
686
693
  * stage relies on live here so they can be pinned without a browser: the
@@ -778,6 +785,7 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
778
785
  message: `[Parallel] Capture failed: ${errorMessages}`,
779
786
  cause: representative,
780
787
  workerDiagnostics,
788
+ pageErrorLines: [...new Set(results.flatMap((result) => result.pageErrorLines ?? []))],
781
789
  });
782
790
  }
783
791
  return results;
@@ -798,7 +806,7 @@ export async function mergeWorkerFrames(workDir, tasks, outputDir) {
798
806
  const sourcePath = join(task.outputDir, file);
799
807
  const targetPath = join(outputDir, file);
800
808
  try {
801
- await rename(sourcePath, targetPath);
809
+ await renameWithRetryAsync(sourcePath, targetPath);
802
810
  }
803
811
  catch {
804
812
  await copyFile(sourcePath, targetPath);
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution";
8
8
  import { type RateSpec, type FpsInput } from "@hyperframes/core";
9
- import { type VideoMetadata } from "../utils/ffprobe.js";
9
+ import { resolvePlayableVideoDuration, type VideoMetadata } from "../utils/ffprobe.js";
10
10
  import { type HdrTransfer } from "../utils/hdr.js";
11
11
  import { type EngineConfig } from "../config.js";
12
12
  import { type CacheFrameFormat } from "./extractionCache.js";
@@ -60,6 +60,17 @@ export declare function isVideoFrameFormat(value: unknown): value is VideoFrameF
60
60
  * rejected because the two paths disagree by one frame.
61
61
  */
62
62
  export declare function extractionFrameCountForDuration(durationSeconds: number, fps: FpsInput, isVFR: boolean): number;
63
+ /** Slots of a window reaching the source's end that hold a frame, from the stored final frame; null if unsure. */
64
+ export declare function tailFramesFromFinalFrame(input: {
65
+ startTime: number;
66
+ firstFrame: number;
67
+ tailFrames: number;
68
+ outputFps: number;
69
+ sourceFps: number;
70
+ finalFrameSeconds: number;
71
+ streamLeadSeconds: number;
72
+ lastPacketSeconds?: number;
73
+ }): number | null;
63
74
  export interface FrameRange {
64
75
  firstFrame: number;
65
76
  frames: number;
@@ -100,6 +111,8 @@ export interface ExtractionOptions {
100
111
  collectProbeFailures?: boolean;
101
112
  /** Extract only these runs of output frames, named as a full extraction names them. CFR only. */
102
113
  frameRanges?: readonly FrameRange[];
114
+ /** A short decode of a remote source, only to count its frames: it waits on the network, so it runs wider. */
115
+ tailProbe?: boolean;
103
116
  /**
104
117
  * Leave CFR windows longer than a second unextracted, returning `deferredRange` and an exact
105
118
  * `totalFrames` so another process can extract just the frames it needs with `frameRanges`.
@@ -259,7 +272,7 @@ export declare function classifyFfmpegSpawnError(error: unknown, stderr?: string
259
272
  * the container duration when ffprobe omits the stream duration; keep the
260
273
  * explicit fallback here for callers supplying older/manual metadata.
261
274
  */
262
- export declare function resolvePlayableVideoDuration(metadata: VideoMetadata): number;
275
+ export { resolvePlayableVideoDuration };
263
276
  export interface TimelineExtractionWindow {
264
277
  compositionStart: number;
265
278
  mediaStart: number;
@@ -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, 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, writeMetadataCache, } from "../utils/ffprobe.js";
20
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, 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";
@@ -109,6 +109,32 @@ export function extractionFrameCountForDuration(durationSeconds, fps, isVFR) {
109
109
  const frames = Number(frameCount);
110
110
  return Math.max(1, Number.isSafeInteger(frames) ? frames : Number.MAX_SAFE_INTEGER);
111
111
  }
112
+ /** The exact rate behind a probed one (rounded to 0.01): a whole number or its 1000/1001 NTSC form. */
113
+ function exactSourceRate(probed) {
114
+ const nominal = Math.round(probed * 1.001);
115
+ return ([nominal, (nominal * 1000) / 1001].find((rate) => Math.abs(rate - probed) <= 0.005 + 1e-9) ??
116
+ null);
117
+ }
118
+ /** Slots of a window reaching the source's end that hold a frame, from the stored final frame; null if unsure. */
119
+ export function tailFramesFromFinalFrame(input) {
120
+ const sourceRate = exactSourceRate(input.sourceFps);
121
+ if (!sourceRate || !(input.outputFps > 0))
122
+ return null;
123
+ // A late-starting stream ends where its last packet does, and ffmpeg shortens the last frame
124
+ // to a short packet in ways the stored facts cannot predict: trust only a full-frame packet.
125
+ const lead = input.streamLeadSeconds;
126
+ if (lead !== 0 && !(Math.abs((input.lastPacketSeconds ?? NaN) - 1 / sourceRate) <= 1e-4)) {
127
+ return null;
128
+ }
129
+ const seek = input.startTime + input.firstFrame / input.outputFps;
130
+ const slots = (lead + input.finalFrameSeconds + 1 / sourceRate - seek) * input.outputFps;
131
+ const distanceToSlot = Math.abs(slots - Math.round(slots));
132
+ // ffprobe prints the final time to 1 us; a slot closer than 50 us/fps to the end is a tie, up to 500 us/fps unknown.
133
+ if (distanceToSlot >= 5e-5 && distanceToSlot < 5e-4)
134
+ return null;
135
+ const count = Math.min(input.tailFrames, Math.ceil(slots - 5e-5));
136
+ return count > 0 ? count : null;
137
+ }
112
138
  const DOWNLOAD_SUBDIR = "_downloads";
113
139
  export const EXTRACT_CACHE_MIN_AGE_MS = 60 * 60 * 1000;
114
140
  const GC_STALENESS_MS = 24 * 60 * 60 * 1000;
@@ -468,6 +494,8 @@ function undoTickRoundingOnTheSourceFrameGrid(seek, lead) {
468
494
  // Caps extraction ffmpeg runs across all clips; uncapped, dozens of clips starved each other into timeouts.
469
495
  const EXTRACTION_SLOTS = Math.max(1, Math.floor(cpus().length / 2));
470
496
  const inExtractionSlot = createConcurrencyLimit(EXTRACTION_SLOTS);
497
+ const TAIL_PROBE_SLOTS = Math.max(1, Number(process.env.HF_TAIL_PROBE_SLOTS) || 16);
498
+ const inTailProbeSlot = createConcurrencyLimit(TAIL_PROBE_SLOTS);
471
499
  /** Split runs so no single ffmpeg process extracts more than `segmentFrames` frames. */
472
500
  function splitFrameRanges(ranges, segmentFrames) {
473
501
  const segments = [];
@@ -482,7 +510,7 @@ function splitFrameRanges(ranges, segmentFrames) {
482
510
  return segments;
483
511
  }
484
512
  async function runSegmentedExtraction(job) {
485
- const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, segments } = job;
513
+ const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, segments, gate } = job;
486
514
  const segmentCount = segments.length;
487
515
  // One failed segment fails the range, so stop the others instead of finishing them.
488
516
  const failed = new AbortController();
@@ -496,7 +524,7 @@ async function runSegmentedExtraction(job) {
496
524
  const index = next++;
497
525
  const { firstFrame, frames } = segments[index];
498
526
  const seek = startTime + firstFrame / fps;
499
- const result = await inExtractionSlot(() => runFfmpeg([
527
+ const result = await gate(() => runFfmpeg([
500
528
  ...decodeArgs,
501
529
  "-noaccurate_seek",
502
530
  "-ss",
@@ -677,6 +705,7 @@ outputDirOverride) {
677
705
  videoPath,
678
706
  startTime,
679
707
  fps,
708
+ gate: options.tailProbe ? inTailProbeSlot : inExtractionSlot,
680
709
  segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
681
710
  runOptions,
682
711
  })
@@ -772,12 +801,7 @@ function resolveSegmentDuration(requested, mediaStart, sourceDuration) {
772
801
  * the container duration when ffprobe omits the stream duration; keep the
773
802
  * explicit fallback here for callers supplying older/manual metadata.
774
803
  */
775
- export function resolvePlayableVideoDuration(metadata) {
776
- return Number.isFinite(metadata.videoStreamDurationSeconds) &&
777
- metadata.videoStreamDurationSeconds > 0
778
- ? (metadata.videoStreamLeadSeconds ?? 0) + metadata.videoStreamDurationSeconds
779
- : metadata.durationSeconds;
780
- }
804
+ export { resolvePlayableVideoDuration };
781
805
  function canHoldFinalFramePastEof(video) {
782
806
  const timelineDuration = video.end - video.start;
783
807
  return !video.loop && Number.isFinite(timelineDuration) && timelineDuration > 0;
@@ -1499,16 +1523,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1499
1523
  const tailFrames = Math.min(expectedFrames, Math.ceil(fps));
1500
1524
  if (expectedFrames <= tailFrames)
1501
1525
  return null;
1502
- const probeDir = join(options.outputDir, `${work.video.id}.tail-probe`);
1503
- try {
1504
- const tail = await extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, {
1505
- ...scopedExtractionOptions(work),
1506
- frameRanges: [{ firstFrame: expectedFrames - tailFrames, frames: tailFrames }],
1507
- }, signal, config, probeDir);
1508
- const totalFrames = expectedFrames - tailFrames + tail.totalFrames;
1509
- // A full extraction this short rewrites the probed duration; let it.
1510
- if (totalFrames <= expectedFrames * 0.6)
1511
- return null;
1526
+ const deferred = (totalFrames) => {
1512
1527
  const outputDir = join(options.outputDir, work.video.id);
1513
1528
  mkdirSync(outputDir, { recursive: true });
1514
1529
  return {
@@ -1526,6 +1541,42 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1526
1541
  format: work.format,
1527
1542
  },
1528
1543
  };
1544
+ };
1545
+ // A window that ends a second or more inside the source cannot run out of frames: its count is the expected one.
1546
+ if (work.extractionMediaStart + work.videoDuration + 1 <=
1547
+ resolvePlayableVideoDuration(work.metadata)) {
1548
+ return deferred(expectedFrames);
1549
+ }
1550
+ const firstTailFrame = expectedFrames - tailFrames;
1551
+ const storedFinalFrame = await readStoredFinalFrame(work.videoPath, work.metadata);
1552
+ const storedTailFrames = storedFinalFrame === undefined
1553
+ ? null
1554
+ : tailFramesFromFinalFrame({
1555
+ startTime: work.extractionMediaStart,
1556
+ firstFrame: firstTailFrame,
1557
+ tailFrames,
1558
+ outputFps: fps,
1559
+ sourceFps: work.metadata.fps,
1560
+ finalFrameSeconds: storedFinalFrame.seconds,
1561
+ lastPacketSeconds: storedFinalFrame.lastPacketSeconds,
1562
+ streamLeadSeconds: work.metadata.videoStreamLeadSeconds ?? 0,
1563
+ });
1564
+ if (storedTailFrames !== null) {
1565
+ const totalFrames = firstTailFrame + storedTailFrames;
1566
+ return totalFrames <= expectedFrames * 0.6 ? null : deferred(totalFrames);
1567
+ }
1568
+ const probeDir = join(options.outputDir, `${work.video.id}.tail-probe`);
1569
+ try {
1570
+ const tail = await extractVideoFramesRange(work.videoPath, work.video.id, work.extractionMediaStart, work.videoDuration, {
1571
+ ...scopedExtractionOptions(work),
1572
+ frameRanges: [{ firstFrame: firstTailFrame, frames: tailFrames }],
1573
+ tailProbe: isHttpUrl(work.videoPath),
1574
+ }, signal, config, probeDir);
1575
+ const totalFrames = firstTailFrame + tail.totalFrames;
1576
+ // A full extraction this short rewrites the probed duration; let it.
1577
+ if (totalFrames <= expectedFrames * 0.6)
1578
+ return null;
1579
+ return deferred(totalFrames);
1529
1580
  }
1530
1581
  catch {
1531
1582
  return null;
@@ -102,9 +102,18 @@ export interface MediaProbeProfile {
102
102
  }
103
103
  /** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
104
104
  export declare function declareContentAddressedUrl(url: string, sha256: string): void;
105
+ /** Where the ffprobe JSON of a content-addressed URL was stored beforehand: reading it replaces probing the file. */
106
+ export declare function declareProbeFactsUrl(url: string, factsUrl: string): void;
105
107
  export declare function mediaFileIdentity(filePath: string): string | null;
106
108
  export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
107
109
  export declare function writeMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null, promise: Promise<T>): void;
110
+ /** Part of the name a stored probe is kept under: bump it with the args below. */
111
+ export declare const PROBE_FACTS_VERSION = "v2";
112
+ /** The ffprobe JSON `probeMediaOutput` works from; a declared URL's stored copy is read in place of probing it. */
113
+ export declare function readProbeJson(filePath: string, signal?: AbortSignal): Promise<string>;
114
+ /** What a site stores beside a video: its ffprobe JSON and the final-frame facts a plan would otherwise probe for. */
115
+ export declare function buildProbeFactsText(filePath: string, signal?: AbortSignal): Promise<string>;
116
+ export declare function resolvePlayableVideoDuration(metadata: VideoMetadata): number;
108
117
  /**
109
118
  * Probe stream capabilities without assuming the caller's element type.
110
119
  * File extensions and HTTP MIME are deliberately ignored: extensionless
@@ -154,6 +163,11 @@ export declare function parseFrameRate(frameRateStr: string | undefined): number
154
163
  * can be used uniformly for any visual asset the HDR pipeline encounters.
155
164
  */
156
165
  export declare function extractMediaMetadata(filePath: string): Promise<VideoMetadata>;
166
+ /** Stored final-frame facts for this stream window; none if undeclared (no await then, so a probe spawns at once). */
167
+ export declare function readStoredFinalFrame(filePath: string, metadata: Pick<VideoMetadata, "videoStreamDurationSeconds" | "videoStreamStartSeconds">): Promise<{
168
+ seconds: number;
169
+ lastPacketSeconds?: number;
170
+ } | undefined> | undefined;
157
171
  /**
158
172
  * Return the FFmpeg input-seek position of the final decoded video frame.
159
173
  *
@@ -217,6 +217,11 @@ const withoutQuery = (url) => url.split(/[?#]/, 1)[0] ?? url;
217
217
  export function declareContentAddressedUrl(url, sha256) {
218
218
  contentAddressedUrls.set(withoutQuery(url), sha256);
219
219
  }
220
+ const probeFactsUrls = new Map();
221
+ /** Where the ffprobe JSON of a content-addressed URL was stored beforehand: reading it replaces probing the file. */
222
+ export function declareProbeFactsUrl(url, factsUrl) {
223
+ probeFactsUrls.set(withoutQuery(url), factsUrl);
224
+ }
220
225
  export function mediaFileIdentity(filePath) {
221
226
  if (/^https?:\/\//i.test(filePath)) {
222
227
  const sha256 = contentAddressedUrls.get(withoutQuery(filePath));
@@ -246,6 +251,88 @@ export function writeMetadataCache(cache, key, identity, promise) {
246
251
  cache.delete(key);
247
252
  });
248
253
  }
254
+ /** Part of the name a stored probe is kept under: bump it with the args below. */
255
+ export const PROBE_FACTS_VERSION = "v2";
256
+ const PROBE_JSON_ARGS = ["-print_format", "json", "-show_format", "-show_streams"];
257
+ const storedFacts = new Map();
258
+ /** The facts stored for a declared URL, fetched once; null when there are none or they cannot be read. */
259
+ function readStoredFacts(filePath) {
260
+ if (!/^https?:\/\//i.test(filePath))
261
+ return undefined;
262
+ const factsUrl = probeFactsUrls.get(withoutQuery(filePath));
263
+ if (!factsUrl)
264
+ return undefined;
265
+ let facts = storedFacts.get(factsUrl);
266
+ if (!facts) {
267
+ facts = (async () => {
268
+ try {
269
+ const response = await fetch(factsUrl, { signal: AbortSignal.timeout(10_000) });
270
+ if (!response.ok)
271
+ return null;
272
+ const parsed = JSON.parse(await response.text());
273
+ return Array.isArray(parsed.ffprobe?.streams) ? parsed : null;
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ })();
279
+ storedFacts.set(factsUrl, facts);
280
+ }
281
+ return facts;
282
+ }
283
+ /** The ffprobe JSON `probeMediaOutput` works from; a declared URL's stored copy is read in place of probing it. */
284
+ export function readProbeJson(filePath, signal) {
285
+ const pending = readStoredFacts(filePath);
286
+ if (!pending)
287
+ return runFfprobe(filePath, PROBE_JSON_ARGS, signal);
288
+ return pending.then((stored) => stored ? JSON.stringify(stored.ffprobe) : runFfprobe(filePath, PROBE_JSON_ARGS, signal));
289
+ }
290
+ let runtimeFfprobeMajor;
291
+ /** The major version of the ffprobe this process runs; undefined when it cannot be read. */
292
+ function ffprobeMajor() {
293
+ runtimeFfprobeMajor ??= new Promise((resolve) => {
294
+ let out = "";
295
+ const binary = getFfprobeBinary();
296
+ const proc = spawn(binary, ["-version"], { stdio: ["ignore", "pipe", "ignore"] });
297
+ proc.stdout.on("data", (chunk) => (out += String(chunk)));
298
+ proc.on("error", () => resolve(undefined));
299
+ proc.on("close", () => resolve(Number(/version n?(\d+)/.exec(out)?.[1]) || undefined));
300
+ });
301
+ return runtimeFfprobeMajor;
302
+ }
303
+ /** What a site stores beside a video: its ffprobe JSON and the final-frame facts a plan would otherwise probe for. */
304
+ export async function buildProbeFactsText(filePath, signal) {
305
+ const facts = {
306
+ ffprobe: parseProbeJson(await runFfprobe(filePath, PROBE_JSON_ARGS, signal)),
307
+ ffprobeMajor: await ffprobeMajor(),
308
+ };
309
+ try {
310
+ const metadata = await extractMediaMetadata(filePath);
311
+ const window = resolveStreamWindow(filePath, {
312
+ videoStreamStartSeconds: metadata.videoStreamStartSeconds,
313
+ videoStreamDurationSeconds: resolvePlayableVideoDuration(metadata) - (metadata.videoStreamLeadSeconds ?? 0),
314
+ });
315
+ if (window.videoStreamDurationSeconds > 0) {
316
+ facts.finalFrame = {
317
+ streamStart: window.videoStreamStartSeconds,
318
+ streamDuration: window.videoStreamDurationSeconds,
319
+ seconds: await extractFinalVideoFrameTimestamp(filePath, window, signal),
320
+ lastPacketSeconds: await extractFinalPacketDuration(filePath, window, signal),
321
+ };
322
+ }
323
+ }
324
+ catch {
325
+ if (signal?.aborted)
326
+ throw signal.reason;
327
+ }
328
+ return JSON.stringify(facts);
329
+ }
330
+ export function resolvePlayableVideoDuration(metadata) {
331
+ return Number.isFinite(metadata.videoStreamDurationSeconds) &&
332
+ metadata.videoStreamDurationSeconds > 0
333
+ ? (metadata.videoStreamLeadSeconds ?? 0) + metadata.videoStreamDurationSeconds
334
+ : metadata.durationSeconds;
335
+ }
249
336
  async function probeMediaOutput(filePath, signal) {
250
337
  let cache = mediaProbeOutputCache;
251
338
  if (signal) {
@@ -264,7 +351,7 @@ async function probeMediaOutput(filePath, signal) {
264
351
  }
265
352
  return cached.promise;
266
353
  }
267
- const promise = runFfprobe(filePath, ["-print_format", "json", "-show_format", "-show_streams"], signal).then(parseProbeJson);
354
+ const promise = readProbeJson(filePath, signal).then(parseProbeJson);
268
355
  if (identity !== null) {
269
356
  cache.set(filePath, { identity, promise });
270
357
  if (!signal && cache.size > MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES) {
@@ -672,6 +759,45 @@ function resolveStreamWindow(filePath, metadata) {
672
759
  const cacheKey = `${filePath}\0${String(videoStreamStartSeconds)}\0${String(videoStreamDurationSeconds)}`;
673
760
  return { videoStreamStartSeconds, videoStreamDurationSeconds, cacheKey };
674
761
  }
762
+ /** Stored final-frame facts for this stream window; none if undeclared (no await then, so a probe spawns at once). */
763
+ export function readStoredFinalFrame(filePath, metadata) {
764
+ const { videoStreamStartSeconds, videoStreamDurationSeconds } = resolveStreamWindow(filePath, metadata);
765
+ return readStoredFacts(filePath)?.then(async (facts) => {
766
+ const stored = facts?.finalFrame;
767
+ // Another major version may place the final frame or stream start differently: probe instead.
768
+ if (facts?.ffprobeMajor !== undefined && facts.ffprobeMajor !== (await ffprobeMajor()))
769
+ return undefined;
770
+ return stored &&
771
+ stored.streamStart === videoStreamStartSeconds &&
772
+ stored.streamDuration === videoStreamDurationSeconds
773
+ ? stored
774
+ : undefined;
775
+ });
776
+ }
777
+ /**
778
+ * `duration_time` of the stream's last packet (by presentation time), read from the same final
779
+ * second the final-frame probe reads; undefined when ffprobe reports none.
780
+ */
781
+ async function extractFinalPacketDuration(filePath, window, signal) {
782
+ const streamEnd = window.videoStreamStartSeconds + window.videoStreamDurationSeconds;
783
+ const intervalStart = Math.max(window.videoStreamStartSeconds, streamEnd - 1);
784
+ const stdout = await runFfprobe(filePath, [
785
+ "-select_streams",
786
+ "v:0",
787
+ "-read_intervals",
788
+ `${intervalStart}%${streamEnd}`,
789
+ "-show_entries",
790
+ "packet=pts_time,duration_time",
791
+ "-of",
792
+ "csv=p=0",
793
+ ], signal, { retainTail: true, maxChars: 64 * 1024 });
794
+ const packets = stdout
795
+ .split("\n")
796
+ .map((line) => line.trim().split(",").map(Number))
797
+ .filter(([pts, duration]) => Number.isFinite(pts) && Number.isFinite(duration));
798
+ const last = packets.reduce((best, packet) => (!best || packet[0] > best[0] ? packet : best), undefined);
799
+ return last?.[1];
800
+ }
675
801
  /**
676
802
  * Return the FFmpeg input-seek position of the final decoded video frame.
677
803
  *
@@ -705,6 +831,10 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
705
831
  if (cached)
706
832
  return cached;
707
833
  const probePromise = (async () => {
834
+ const pendingStored = readStoredFinalFrame(filePath, metadata);
835
+ const stored = pendingStored && (await pendingStored);
836
+ if (stored)
837
+ return stored.seconds;
708
838
  if (!(videoDurationSeconds > 0) || !Number.isFinite(videoDurationSeconds)) {
709
839
  throw new Error(`[FFmpeg] Cannot locate final video frame for invalid duration ${String(videoDurationSeconds)}`);
710
840
  }
@@ -0,0 +1,12 @@
1
+ interface Options {
2
+ platform?: NodeJS.Platform;
3
+ }
4
+ /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
5
+ export declare function renameWithRetry(from: string, to: string, options?: Options & {
6
+ rename?: (from: string, to: string) => void;
7
+ }): void;
8
+ /** Async twin of {@link renameWithRetry}. */
9
+ export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
10
+ rename?: (from: string, to: string) => Promise<void>;
11
+ }): Promise<void>;
12
+ export {};
@@ -0,0 +1,46 @@
1
+ import { renameSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ // Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
4
+ // file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
5
+ const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
6
+ const BACKOFF_MS = [10, 20, 40, 80, 160, 320, 640];
7
+ function isRetryable(error, platform) {
8
+ if (platform !== "win32")
9
+ return false;
10
+ return RETRYABLE.has(error?.code ?? "");
11
+ }
12
+ function sleepSync(ms) {
13
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
14
+ }
15
+ /** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
16
+ export function renameWithRetry(from, to, options = {}) {
17
+ const doRename = options.rename ?? renameSync;
18
+ const platform = options.platform ?? process.platform;
19
+ for (let attempt = 0;; attempt++) {
20
+ try {
21
+ doRename(from, to);
22
+ return;
23
+ }
24
+ catch (error) {
25
+ if (attempt >= BACKOFF_MS.length || !isRetryable(error, platform))
26
+ throw error;
27
+ sleepSync(BACKOFF_MS[attempt]);
28
+ }
29
+ }
30
+ }
31
+ /** Async twin of {@link renameWithRetry}. */
32
+ export async function renameWithRetryAsync(from, to, options = {}) {
33
+ const doRename = options.rename ?? rename;
34
+ const platform = options.platform ?? process.platform;
35
+ for (let attempt = 0;; attempt++) {
36
+ try {
37
+ await doRename(from, to);
38
+ return;
39
+ }
40
+ catch (error) {
41
+ if (attempt >= BACKOFF_MS.length || !isRetryable(error, platform))
42
+ throw error;
43
+ await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
44
+ }
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.192",
3
+ "version": "0.8.194",
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.192",
54
- "@hyperframes/parsers": "^0.8.192"
53
+ "@hyperframes/core": "^0.8.194",
54
+ "@hyperframes/parsers": "^0.8.194"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",