@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 +2 -1
- package/dist/index.js +2 -1
- package/dist/services/aacEncode.js +27 -0
- package/dist/services/audioMixer.js +20 -4
- package/dist/services/audioMixer.types.d.ts +1 -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/extractionCache.js +4 -3
- 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/videoFrameExtractor.d.ts +15 -2
- package/dist/services/videoFrameExtractor.js +70 -19
- package/dist/utils/ffprobe.d.ts +14 -0
- package/dist/utils/ffprobe.js +131 -1
- package/dist/utils/renameWithRetry.d.ts +12 -0
- package/dist/utils/renameWithRetry.js +46 -0
- package/package.json +3 -3
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,
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
return {
|
|
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
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
|
@@ -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
|
@@ -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
|
*
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -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 =
|
|
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.
|
|
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.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.193",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.193"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|