@hyperframes/engine 0.8.191 → 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.
@@ -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);
@@ -1,2 +1,3 @@
1
+ export declare const X265_MAX_THREADS = 16;
1
2
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
2
- export declare function appendSoftwareEncoderThreadsArg(args: string[], parallelism?: number): void;
3
+ export declare function appendSoftwareEncoderThreadsArg(args: string[], encoder: "libx264" | "libx265", parallelism?: number): void;
@@ -1,5 +1,7 @@
1
1
  import { availableParallelism } from "node:os";
2
+ export const X265_MAX_THREADS = 16;
2
3
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
3
- export function appendSoftwareEncoderThreadsArg(args, parallelism = availableParallelism()) {
4
- args.push("-threads", String(Math.max(1, Math.floor(parallelism))));
4
+ export function appendSoftwareEncoderThreadsArg(args, encoder, parallelism = availableParallelism()) {
5
+ const cap = encoder === "libx265" ? X265_MAX_THREADS : Infinity;
6
+ args.push("-threads", String(Math.min(cap, Math.max(1, Math.floor(parallelism)))));
5
7
  }
@@ -181,7 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
181
181
  else {
182
182
  const encoderName = codec === "h264" ? "libx264" : "libx265";
183
183
  args.push("-c:v", encoderName, "-preset", preset);
184
- appendSoftwareEncoderThreadsArg(args);
184
+ appendSoftwareEncoderThreadsArg(args, encoderName);
185
185
  if (bitrate)
186
186
  args.push("-b:v", bitrate);
187
187
  else
@@ -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;
@@ -100,9 +100,20 @@ export interface MediaProbeProfile {
100
100
  hasAudioStream: boolean;
101
101
  visualKind: "none" | "still" | "moving";
102
102
  }
103
+ /** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
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;
103
107
  export declare function mediaFileIdentity(filePath: string): string | null;
104
108
  export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
105
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;
106
117
  /**
107
118
  * Probe stream capabilities without assuming the caller's element type.
108
119
  * File extensions and HTTP MIME are deliberately ignored: extensionless
@@ -152,6 +163,11 @@ export declare function parseFrameRate(frameRateStr: string | undefined): number
152
163
  * can be used uniformly for any visual asset the HDR pipeline encounters.
153
164
  */
154
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;
155
171
  /**
156
172
  * Return the FFmpeg input-seek position of the final decoded video frame.
157
173
  *
@@ -140,6 +140,7 @@ const videoMetadataCache = new Map();
140
140
  const finalVideoFrameTimestampCache = new Map();
141
141
  const finalVideoFrameTimestampSignalCaches = new WeakMap();
142
142
  const audioMetadataCache = new Map();
143
+ const audioMetadataSignalCaches = new WeakMap();
143
144
  const mediaProbeOutputCache = new Map();
144
145
  const mediaProbeOutputSignalCaches = new WeakMap();
145
146
  const MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES = 128;
@@ -210,7 +211,22 @@ function isPngImageProbe(output) {
210
211
  return (formatNames.includes("png_pipe") ||
211
212
  output.streams.some((stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png"));
212
213
  }
214
+ const contentAddressedUrls = new Map();
215
+ const withoutQuery = (url) => url.split(/[?#]/, 1)[0] ?? url;
216
+ /** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
217
+ export function declareContentAddressedUrl(url, sha256) {
218
+ contentAddressedUrls.set(withoutQuery(url), sha256);
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
+ }
213
225
  export function mediaFileIdentity(filePath) {
226
+ if (/^https?:\/\//i.test(filePath)) {
227
+ const sha256 = contentAddressedUrls.get(withoutQuery(filePath));
228
+ return sha256 ? `sha256:${sha256}` : null;
229
+ }
214
230
  try {
215
231
  const stat = statSync(filePath, { bigint: true });
216
232
  return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
@@ -235,6 +251,88 @@ export function writeMetadataCache(cache, key, identity, promise) {
235
251
  cache.delete(key);
236
252
  });
237
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
+ }
238
336
  async function probeMediaOutput(filePath, signal) {
239
337
  let cache = mediaProbeOutputCache;
240
338
  if (signal) {
@@ -253,7 +351,7 @@ async function probeMediaOutput(filePath, signal) {
253
351
  }
254
352
  return cached.promise;
255
353
  }
256
- const promise = runFfprobe(filePath, ["-print_format", "json", "-show_format", "-show_streams"], signal).then(parseProbeJson);
354
+ const promise = readProbeJson(filePath, signal).then(parseProbeJson);
257
355
  if (identity !== null) {
258
356
  cache.set(filePath, { identity, promise });
259
357
  if (!signal && cache.size > MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES) {
@@ -661,6 +759,45 @@ function resolveStreamWindow(filePath, metadata) {
661
759
  const cacheKey = `${filePath}\0${String(videoStreamStartSeconds)}\0${String(videoStreamDurationSeconds)}`;
662
760
  return { videoStreamStartSeconds, videoStreamDurationSeconds, cacheKey };
663
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
+ }
664
801
  /**
665
802
  * Return the FFmpeg input-seek position of the final decoded video frame.
666
803
  *
@@ -694,6 +831,10 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
694
831
  if (cached)
695
832
  return cached;
696
833
  const probePromise = (async () => {
834
+ const pendingStored = readStoredFinalFrame(filePath, metadata);
835
+ const stored = pendingStored && (await pendingStored);
836
+ if (stored)
837
+ return stored.seconds;
697
838
  if (!(videoDurationSeconds > 0) || !Number.isFinite(videoDurationSeconds)) {
698
839
  throw new Error(`[FFmpeg] Cannot locate final video frame for invalid duration ${String(videoDurationSeconds)}`);
699
840
  }
@@ -740,49 +881,53 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
740
881
  */
741
882
  export const extractVideoMetadata = extractMediaMetadata;
742
883
  export async function extractAudioMetadata(filePath, options) {
743
- // A caller-owned abort signal cannot safely share a cached in-flight probe:
744
- // cancelling one consumer would also cancel unrelated consumers. Signal-bound
745
- // probes therefore bypass the process-promise cache.
884
+ // A probe bound to a caller's abort signal is shared only among callers of that same signal,
885
+ // and only when the file has an identity to key it by.
746
886
  const identity = mediaFileIdentity(filePath);
747
- const cached = options?.signal
748
- ? undefined
749
- : readMetadataCache(audioMetadataCache, filePath, identity);
887
+ const signal = options?.signal;
888
+ if (signal && identity === null)
889
+ return probeAudioMetadata(filePath, signal);
890
+ let cache = audioMetadataCache;
891
+ if (signal) {
892
+ cache = audioMetadataSignalCaches.get(signal) ?? new Map();
893
+ audioMetadataSignalCaches.set(signal, cache);
894
+ }
895
+ const cached = readMetadataCache(cache, filePath, identity);
750
896
  if (cached)
751
897
  return cached;
752
- const probePromise = (async () => {
753
- const output = await probeMediaOutput(filePath, options?.signal);
754
- const audioStream = output.streams.find((s) => s.codec_type === "audio");
755
- if (!audioStream)
756
- throw new Error("[FFmpeg] No audio stream found");
757
- let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
758
- const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
759
- const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
760
- const audioCodec = audioStream.codec_name || "unknown";
761
- // Container summaries can undercount intact audio, so the decoded last frame wins.
762
- // The probe returns null instead of throwing: a throw here drops the audio element.
763
- if (sampleRate > 0) {
764
- const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, options?.signal);
765
- if (decoded !== null) {
766
- durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
767
- }
768
- }
769
- return {
770
- durationSeconds,
771
- streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
772
- sampleRate,
773
- channels: audioStream.channels || 2,
774
- audioCodec,
775
- bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
776
- latestStreamLeadSeconds: latestStreamLeadSeconds(output),
777
- audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
778
- audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
779
- };
780
- })();
781
- if (options?.signal)
782
- return probePromise;
783
- writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
898
+ const probePromise = probeAudioMetadata(filePath, signal);
899
+ writeMetadataCache(cache, filePath, identity, probePromise);
784
900
  return probePromise;
785
901
  }
902
+ async function probeAudioMetadata(filePath, signal) {
903
+ const output = await probeMediaOutput(filePath, signal);
904
+ const audioStream = output.streams.find((s) => s.codec_type === "audio");
905
+ if (!audioStream)
906
+ throw new Error("[FFmpeg] No audio stream found");
907
+ let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
908
+ const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
909
+ const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
910
+ const audioCodec = audioStream.codec_name || "unknown";
911
+ // Container summaries can undercount intact audio, so the decoded last frame wins.
912
+ // The probe returns null instead of throwing: a throw here drops the audio element.
913
+ if (sampleRate > 0) {
914
+ const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
915
+ if (decoded !== null) {
916
+ durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
917
+ }
918
+ }
919
+ return {
920
+ durationSeconds,
921
+ streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
922
+ sampleRate,
923
+ channels: audioStream.channels || 2,
924
+ audioCodec,
925
+ bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
926
+ latestStreamLeadSeconds: latestStreamLeadSeconds(output),
927
+ audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
928
+ audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
929
+ };
930
+ }
786
931
  /** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
787
932
  function audioVideoStreamStarts(output) {
788
933
  return (output?.streams ?? [])
@@ -22,7 +22,7 @@ function getSingleMeaningfulChild(container) {
22
22
  */
23
23
  export function unwrapTemplate(html) {
24
24
  const lowered = html.toLowerCase();
25
- if (!lowered.includes("<template") || !lowered.includes("</template>")) {
25
+ if (!lowered.includes("<template") || !/<\/template[ \t\n\r\f]*>/.test(lowered)) {
26
26
  return html;
27
27
  }
28
28
  const { body } = parseHTMLContent(html);
@@ -78,8 +78,11 @@ export async function sampleProcessRss(pids, exec = defaultExec(), platform = pr
78
78
  if (platform === "win32") {
79
79
  const results = [];
80
80
  for (const pid of unique) {
81
- const { stdout } = await exec("tasklist", ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`]);
82
- results.push(...parseTasklistCsv(pid, stdout));
81
+ try {
82
+ const { stdout } = await exec("tasklist", ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`]);
83
+ results.push(...parseTasklistCsv(pid, stdout));
84
+ }
85
+ catch { }
83
86
  }
84
87
  return results;
85
88
  }
@@ -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 {};