@hyperframes/engine 0.8.192 → 0.8.193

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
@@ -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
@@ -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) => ({
@@ -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";
@@ -1013,7 +1014,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
1013
1014
  const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
1014
1015
  if (!summed.success)
1015
1016
  return summed;
1016
- renameSync(summed.track.srcPath, outputPath);
1017
+ renameWithRetry(summed.track.srcPath, outputPath);
1017
1018
  return { success: true, degradedAutomation: summed.degradedAutomation };
1018
1019
  }
1019
1020
  finally {
@@ -1059,15 +1060,18 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
1059
1060
  */
1060
1061
  async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
1061
1062
  const sumPath = join(workDir, "master-sum.wav");
1063
+ const mixStart = Date.now();
1062
1064
  const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config);
1063
1065
  if (!sum.success)
1064
1066
  return { ...sum, outputPath };
1067
+ const masterStart = Date.now();
1065
1068
  const encoderInput = masterChain
1066
1069
  ? (await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
1067
1070
  trackId: "master",
1068
1071
  signal,
1069
1072
  })).path
1070
1073
  : sumPath;
1074
+ const encodeStart = Date.now();
1071
1075
  const encoded = await encodeAac(encoderInput, outputPath, totalDuration, {
1072
1076
  signal,
1073
1077
  timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
@@ -1084,8 +1088,17 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
1084
1088
  };
1085
1089
  }
1086
1090
  if (config?.keepEncoderInputAt)
1087
- renameSync(encoderInput, config.keepEncoderInputAt);
1088
- return { ...sum, outputPath, durationMs: sum.durationMs + encoded.durationMs };
1091
+ renameWithRetry(encoderInput, config.keepEncoderInputAt);
1092
+ return {
1093
+ ...sum,
1094
+ outputPath,
1095
+ durationMs: sum.durationMs + encoded.durationMs,
1096
+ phaseMs: {
1097
+ mix: masterStart - mixStart,
1098
+ master: encodeStart - masterStart,
1099
+ encode: Date.now() - encodeStart,
1100
+ },
1101
+ };
1089
1102
  }
1090
1103
  export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
1091
1104
  const startMs = Date.now();
@@ -1367,6 +1380,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1367
1380
  failures,
1368
1381
  };
1369
1382
  };
1383
+ const prepareMs = Date.now() - startMs;
1370
1384
  if (failures.length > 0)
1371
1385
  return bail();
1372
1386
  // Parsed before any group is mixed, so a malformed master chain fails fast and
@@ -1486,6 +1500,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1486
1500
  }
1487
1501
  if (failures.length > 0)
1488
1502
  return bail();
1503
+ const groupsMs = Date.now() - startMs - prepareMs;
1489
1504
  let mixResult;
1490
1505
  try {
1491
1506
  mixResult =
@@ -1505,6 +1520,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1505
1520
  return {
1506
1521
  ...mixResult,
1507
1522
  durationMs: Date.now() - startMs,
1523
+ phaseMs: { prepare: prepareMs, groups: groupsMs, ...mixResult.phaseMs },
1508
1524
  // Both, when both degraded. `mixResult.error ?? degradedNote` reported only
1509
1525
  // the outer mix and dropped the one that names which GROUPS lost their
1510
1526
  // 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 {
@@ -769,6 +769,7 @@ function getLowMemoryFlags() {
769
769
  }
770
770
  const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
771
771
  const WEBGPU_FLAG = "--enable-unsafe-webgpu";
772
+ const DISABLE_DXC_FLAG = "--disable-dawn-features=use_dxc";
772
773
  /**
773
774
  * Whether this launch renders WebGPU on SwiftShader: the composition needs it, the render opted in, no GPU is in use.
774
775
  * SwiftShader draws canvas WebGPU only via Vulkan with GPU compositing on, and not at all under --disable-gpu.
@@ -839,6 +840,9 @@ export function buildChromeArgs(options, config) {
839
840
  ];
840
841
  if (browserGpuMode !== "software" || options.requiresWebGpu) {
841
842
  chromeArgs.push(WEBGPU_FLAG);
843
+ if (platform === "win32") {
844
+ chromeArgs.push(DISABLE_DXC_FLAG);
845
+ }
842
846
  }
843
847
  if (softwareWebGpu)
844
848
  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) {
@@ -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) {
@@ -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.193",
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.193",
54
+ "@hyperframes/parsers": "^0.8.193"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",