@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.
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/services/aacEncode.js +27 -0
- package/dist/services/audioMixer.js +105 -18
- package/dist/services/audioMixer.types.d.ts +2 -0
- package/dist/services/audioVolumeEnvelope.js +3 -2
- package/dist/services/browserManager.js +4 -0
- package/dist/services/captureFailure.d.ts +3 -0
- package/dist/services/captureFailure.js +29 -28
- package/dist/services/chunkEncoder.js +1 -1
- package/dist/services/extractionCache.js +5 -4
- package/dist/services/frameCapture.js +4 -1
- package/dist/services/parallelCoordinator.d.ts +2 -0
- package/dist/services/parallelCoordinator.js +10 -2
- package/dist/services/softwareEncoderThreads.d.ts +2 -1
- package/dist/services/softwareEncoderThreads.js +4 -2
- package/dist/services/streamingEncoder.js +1 -1
- package/dist/services/videoFrameExtractor.d.ts +15 -2
- package/dist/services/videoFrameExtractor.js +70 -19
- package/dist/utils/ffprobe.d.ts +16 -0
- package/dist/utils/ffprobe.js +184 -39
- package/dist/utils/htmlTemplate.js +1 -1
- package/dist/utils/processRss.js +5 -2
- package/dist/utils/renameWithRetry.d.ts +12 -0
- package/dist/utils/renameWithRetry.js +46 -0
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -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 =
|
|
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
|
|
744
|
-
//
|
|
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
|
|
748
|
-
|
|
749
|
-
|
|
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 = (
|
|
753
|
-
|
|
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") ||
|
|
25
|
+
if (!lowered.includes("<template") || !/<\/template[ \t\n\r\f]*>/.test(lowered)) {
|
|
26
26
|
return html;
|
|
27
27
|
}
|
|
28
28
|
const { body } = parseHTMLContent(html);
|
package/dist/utils/processRss.js
CHANGED
|
@@ -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
|
-
|
|
82
|
-
|
|
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 {};
|