@hyperframes/engine 0.8.192 → 0.8.194
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/services/aacEncode.js +27 -0
- package/dist/services/audioMixer.d.ts +1 -0
- package/dist/services/audioMixer.js +24 -5
- package/dist/services/audioMixer.types.d.ts +1 -0
- package/dist/services/audioVolumeEnvelope.js +3 -2
- package/dist/services/browserManager.js +8 -3
- package/dist/services/captureFailure.d.ts +3 -0
- package/dist/services/captureFailure.js +29 -28
- package/dist/services/chunkEncoder.js +2 -2
- package/dist/services/extractionCache.js +4 -3
- package/dist/services/frameCapture.js +4 -1
- package/dist/services/hdrCapture.js +2 -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
|
@@ -50,7 +50,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
|
|
|
50
50
|
export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
|
|
51
51
|
export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
|
|
52
52
|
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
|
53
|
-
export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
|
|
53
|
+
export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, videoAudioId, } from "./services/audioMixer.js";
|
|
54
54
|
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
|
|
55
55
|
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
|
56
56
|
export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
|
|
@@ -64,7 +64,7 @@ export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionB
|
|
|
64
64
|
export type { PageMotionBlurOff } from "./types.js";
|
|
65
65
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
66
66
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
67
|
-
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
|
|
67
|
+
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
|
|
68
68
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
69
69
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
|
|
70
70
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
|
|
@@ -72,6 +72,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
72
72
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
|
73
73
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
74
74
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
75
|
+
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
75
76
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
76
77
|
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
|
|
77
78
|
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
|
package/dist/index.js
CHANGED
|
@@ -54,7 +54,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
|
|
|
54
54
|
export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
|
|
55
55
|
export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
|
|
56
56
|
export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
|
|
57
|
-
export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
|
|
57
|
+
export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, videoAudioId, } from "./services/audioMixer.js";
|
|
58
58
|
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
|
|
59
59
|
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
|
60
60
|
// ── Parallel rendering ─────────────────────────────────────────────────────────
|
|
@@ -68,7 +68,7 @@ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThrea
|
|
|
68
68
|
export { frameSequencePath } from "./services/extractedFrameIndex.js";
|
|
69
69
|
export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
|
|
70
70
|
export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
|
|
71
|
-
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
71
|
+
export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, declareProbeFactsUrl, PROBE_FACTS_VERSION, buildProbeFactsText, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
|
|
72
72
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
73
73
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
|
|
74
74
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
|
|
@@ -76,6 +76,7 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
76
76
|
export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
|
|
77
77
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
78
78
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
79
|
+
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
79
80
|
// drawElement self-verify comparison — shared by the streaming drain
|
|
80
81
|
// (producer) and the parallel disk-path verify (parallelCoordinator).
|
|
81
82
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* AAC for the mixed 48 kHz audio. One encoder is single-threaded (about 60x real time),
|
|
3
3
|
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
4
|
*/
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
5
6
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { dirname, join } from "node:path";
|
|
8
|
+
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
|
7
9
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
8
10
|
const SAMPLE_RATE = 48_000;
|
|
9
11
|
const FRAME_SAMPLES = 1024;
|
|
@@ -48,8 +50,33 @@ function planSegments(totalDuration, count) {
|
|
|
48
50
|
return { trim, keepFrom: cut - firstFrame, keepTo: next - firstFrame };
|
|
49
51
|
});
|
|
50
52
|
}
|
|
53
|
+
const settsDurationSupport = new Map();
|
|
54
|
+
/** FFmpeg before 5.0 (Ubuntu 22.04 ships 4.4) has no `duration` option on the setts filter. */
|
|
55
|
+
function supportsSettsDuration(binary = getFfmpegBinary()) {
|
|
56
|
+
let supported = settsDurationSupport.get(binary);
|
|
57
|
+
if (!supported) {
|
|
58
|
+
supported = new Promise((resolve) => {
|
|
59
|
+
execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => {
|
|
60
|
+
// A binary that cannot even print help fails the encode with its own error, not this one.
|
|
61
|
+
resolve(error !== null || stdout.includes("duration"));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
settsDurationSupport.set(binary, supported);
|
|
65
|
+
}
|
|
66
|
+
return supported;
|
|
67
|
+
}
|
|
68
|
+
function encodeInOnePass(input, outputPath, { signal, timeout, filter }) {
|
|
69
|
+
const filters = [filter, `aresample=${SAMPLE_RATE}`].filter(Boolean).join(",");
|
|
70
|
+
return runFfmpeg(["-i", input, "-af", filters, ...AAC_ARGS, "-y", outputPath], {
|
|
71
|
+
signal,
|
|
72
|
+
timeout,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
51
75
|
/** `input`, after `filter`, is audio of exactly `totalDuration`. */
|
|
52
76
|
export async function encodeAac(input, outputPath, totalDuration, { signal, timeout, filter, segments: count = aacSegmentCount(totalDuration), }) {
|
|
77
|
+
if (!(await supportsSettsDuration())) {
|
|
78
|
+
return encodeInOnePass(input, outputPath, { signal, timeout, filter });
|
|
79
|
+
}
|
|
53
80
|
const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-"));
|
|
54
81
|
try {
|
|
55
82
|
const segments = planSegments(totalDuration, count).map((segment, k) => ({
|
|
@@ -30,6 +30,7 @@ export declare function volumeLaneKeyframes(automation: {
|
|
|
30
30
|
* itself has no parsed element to carry it.
|
|
31
31
|
*/
|
|
32
32
|
export declare function parseMasterFxChain(html: string): string | undefined;
|
|
33
|
+
export declare function videoAudioId(videoId: string): string;
|
|
33
34
|
export declare function parseAudioElements(html: string): AudioElement[];
|
|
34
35
|
/** Tracks per ffmpeg graph; a larger mix is summed in stages, so there is no cap on the total. */
|
|
35
36
|
export declare const MAX_MIX_INPUTS = 96;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// fallow-ignore-file complexity code-duplication
|
|
2
2
|
import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync,
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
|
|
4
|
+
import { renameWithRetry } from "../utils/renameWithRetry.js";
|
|
4
5
|
import { join, dirname, isAbsolute, relative } from "path";
|
|
5
6
|
import { parseHTML } from "linkedom";
|
|
6
7
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
@@ -419,6 +420,9 @@ export function volumeLaneKeyframes(automation, trackStart, duration, gain, gain
|
|
|
419
420
|
export function parseMasterFxChain(html) {
|
|
420
421
|
return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
|
|
421
422
|
}
|
|
423
|
+
export function videoAudioId(videoId) {
|
|
424
|
+
return `${videoId}-audio`;
|
|
425
|
+
}
|
|
422
426
|
export function parseAudioElements(html) {
|
|
423
427
|
const elements = [];
|
|
424
428
|
const { document } = parseHTML(unwrapTemplate(html));
|
|
@@ -503,7 +507,7 @@ export function parseAudioElements(html) {
|
|
|
503
507
|
continue;
|
|
504
508
|
if (isKnownInactiveTimelineWindow(el, resolveStart(el)))
|
|
505
509
|
continue;
|
|
506
|
-
elements.push(build(el,
|
|
510
|
+
elements.push(build(el, videoAudioId(id), src, "video", true));
|
|
507
511
|
}
|
|
508
512
|
return elements;
|
|
509
513
|
}
|
|
@@ -1013,7 +1017,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
1013
1017
|
const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
|
|
1014
1018
|
if (!summed.success)
|
|
1015
1019
|
return summed;
|
|
1016
|
-
|
|
1020
|
+
renameWithRetry(summed.track.srcPath, outputPath);
|
|
1017
1021
|
return { success: true, degradedAutomation: summed.degradedAutomation };
|
|
1018
1022
|
}
|
|
1019
1023
|
finally {
|
|
@@ -1059,15 +1063,18 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
|
|
|
1059
1063
|
*/
|
|
1060
1064
|
async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
|
|
1061
1065
|
const sumPath = join(workDir, "master-sum.wav");
|
|
1066
|
+
const mixStart = Date.now();
|
|
1062
1067
|
const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config);
|
|
1063
1068
|
if (!sum.success)
|
|
1064
1069
|
return { ...sum, outputPath };
|
|
1070
|
+
const masterStart = Date.now();
|
|
1065
1071
|
const encoderInput = masterChain
|
|
1066
1072
|
? (await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
|
|
1067
1073
|
trackId: "master",
|
|
1068
1074
|
signal,
|
|
1069
1075
|
})).path
|
|
1070
1076
|
: sumPath;
|
|
1077
|
+
const encodeStart = Date.now();
|
|
1071
1078
|
const encoded = await encodeAac(encoderInput, outputPath, totalDuration, {
|
|
1072
1079
|
signal,
|
|
1073
1080
|
timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout,
|
|
@@ -1084,8 +1091,17 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
|
|
|
1084
1091
|
};
|
|
1085
1092
|
}
|
|
1086
1093
|
if (config?.keepEncoderInputAt)
|
|
1087
|
-
|
|
1088
|
-
return {
|
|
1094
|
+
renameWithRetry(encoderInput, config.keepEncoderInputAt);
|
|
1095
|
+
return {
|
|
1096
|
+
...sum,
|
|
1097
|
+
outputPath,
|
|
1098
|
+
durationMs: sum.durationMs + encoded.durationMs,
|
|
1099
|
+
phaseMs: {
|
|
1100
|
+
mix: masterStart - mixStart,
|
|
1101
|
+
master: encodeStart - masterStart,
|
|
1102
|
+
encode: Date.now() - encodeStart,
|
|
1103
|
+
},
|
|
1104
|
+
};
|
|
1089
1105
|
}
|
|
1090
1106
|
export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
|
|
1091
1107
|
const startMs = Date.now();
|
|
@@ -1367,6 +1383,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1367
1383
|
failures,
|
|
1368
1384
|
};
|
|
1369
1385
|
};
|
|
1386
|
+
const prepareMs = Date.now() - startMs;
|
|
1370
1387
|
if (failures.length > 0)
|
|
1371
1388
|
return bail();
|
|
1372
1389
|
// Parsed before any group is mixed, so a malformed master chain fails fast and
|
|
@@ -1486,6 +1503,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1486
1503
|
}
|
|
1487
1504
|
if (failures.length > 0)
|
|
1488
1505
|
return bail();
|
|
1506
|
+
const groupsMs = Date.now() - startMs - prepareMs;
|
|
1489
1507
|
let mixResult;
|
|
1490
1508
|
try {
|
|
1491
1509
|
mixResult =
|
|
@@ -1505,6 +1523,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1505
1523
|
return {
|
|
1506
1524
|
...mixResult,
|
|
1507
1525
|
durationMs: Date.now() - startMs,
|
|
1526
|
+
phaseMs: { prepare: prepareMs, groups: groupsMs, ...mixResult.phaseMs },
|
|
1508
1527
|
// Both, when both degraded. `mixResult.error ?? degradedNote` reported only
|
|
1509
1528
|
// the outer mix and dropped the one that names which GROUPS lost their
|
|
1510
1529
|
// members' automation — two different losses, and the operator needs to
|
|
@@ -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 {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* Manages Puppeteer browser lifecycle: Chrome executable resolution,
|
|
6
6
|
* launch args, pooled browser acquisition/release.
|
|
7
7
|
*/
|
|
8
|
+
import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
|
|
8
9
|
import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
|
|
9
10
|
import { execSync } from "child_process";
|
|
10
11
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
@@ -55,7 +56,7 @@ async function probeHardwareWebGlInfo(ppt, options) {
|
|
|
55
56
|
const launchOptions = {
|
|
56
57
|
...sigintLaunchOptions(),
|
|
57
58
|
headless: true,
|
|
58
|
-
args: options.args,
|
|
59
|
+
args: withHyperframesBrowserMarker(options.args),
|
|
59
60
|
defaultViewport: { width: 64, height: 64 },
|
|
60
61
|
executablePath: options.executablePath,
|
|
61
62
|
env: options.env,
|
|
@@ -637,7 +638,7 @@ async function launchBrowser(fingerprint) {
|
|
|
637
638
|
browser = await ppt.launch({
|
|
638
639
|
...sigintLaunchOptions(),
|
|
639
640
|
headless: true,
|
|
640
|
-
args:
|
|
641
|
+
args: withHyperframesBrowserMarker(fingerprint.args),
|
|
641
642
|
defaultViewport: null,
|
|
642
643
|
executablePath: fingerprint.executablePath,
|
|
643
644
|
env,
|
|
@@ -662,7 +663,7 @@ async function launchBrowser(fingerprint) {
|
|
|
662
663
|
browser = await ppt.launch({
|
|
663
664
|
...sigintLaunchOptions(),
|
|
664
665
|
headless: true,
|
|
665
|
-
args: stripBeginFrameFlags([...fingerprint.args]),
|
|
666
|
+
args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])),
|
|
666
667
|
defaultViewport: null,
|
|
667
668
|
executablePath: fingerprint.executablePath,
|
|
668
669
|
env,
|
|
@@ -769,6 +770,7 @@ function getLowMemoryFlags() {
|
|
|
769
770
|
}
|
|
770
771
|
const CANVAS_DRAW_ELEMENT_FEATURE_FLAG = "--enable-features=CanvasDrawElement";
|
|
771
772
|
const WEBGPU_FLAG = "--enable-unsafe-webgpu";
|
|
773
|
+
const DISABLE_DXC_FLAG = "--disable-dawn-features=use_dxc";
|
|
772
774
|
/**
|
|
773
775
|
* Whether this launch renders WebGPU on SwiftShader: the composition needs it, the render opted in, no GPU is in use.
|
|
774
776
|
* SwiftShader draws canvas WebGPU only via Vulkan with GPU compositing on, and not at all under --disable-gpu.
|
|
@@ -839,6 +841,9 @@ export function buildChromeArgs(options, config) {
|
|
|
839
841
|
];
|
|
840
842
|
if (browserGpuMode !== "software" || options.requiresWebGpu) {
|
|
841
843
|
chromeArgs.push(WEBGPU_FLAG);
|
|
844
|
+
if (platform === "win32") {
|
|
845
|
+
chromeArgs.push(DISABLE_DXC_FLAG);
|
|
846
|
+
}
|
|
842
847
|
}
|
|
843
848
|
if (softwareWebGpu)
|
|
844
849
|
chromeArgs.push("--use-vulkan=swiftshader");
|
|
@@ -10,16 +10,19 @@ export declare class CaptureFailure extends Error {
|
|
|
10
10
|
readonly kind: CaptureFailureKind;
|
|
11
11
|
readonly cause: unknown;
|
|
12
12
|
readonly workerDiagnostics: readonly CaptureWorkerDiagnostic[];
|
|
13
|
+
readonly pageErrorLines: readonly string[];
|
|
13
14
|
constructor(input: {
|
|
14
15
|
kind: CaptureFailureKind;
|
|
15
16
|
message: string;
|
|
16
17
|
cause?: unknown;
|
|
17
18
|
workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
|
|
19
|
+
pageErrorLines?: readonly string[];
|
|
18
20
|
});
|
|
19
21
|
}
|
|
20
22
|
export declare function classifyCaptureFailure(error: unknown, options?: {
|
|
21
23
|
signal?: AbortSignal;
|
|
22
24
|
workerDiagnostics?: readonly CaptureWorkerDiagnostic[];
|
|
25
|
+
pageErrorLines?: readonly string[];
|
|
23
26
|
}): CaptureFailure;
|
|
24
27
|
export declare function isTransientBrowserError(error: unknown): boolean;
|
|
25
28
|
export declare function isMemoryExhaustionError(error: unknown): boolean;
|
|
@@ -2,12 +2,14 @@ export class CaptureFailure extends Error {
|
|
|
2
2
|
kind;
|
|
3
3
|
cause;
|
|
4
4
|
workerDiagnostics;
|
|
5
|
+
pageErrorLines;
|
|
5
6
|
constructor(input) {
|
|
6
7
|
super(input.message);
|
|
7
8
|
this.name = "CaptureFailure";
|
|
8
9
|
this.kind = input.kind;
|
|
9
10
|
this.cause = input.cause;
|
|
10
11
|
this.workerDiagnostics = Object.freeze((input.workerDiagnostics ?? []).map((diagnostic) => Object.freeze({ ...diagnostic, lines: Object.freeze([...diagnostic.lines]) })));
|
|
12
|
+
this.pageErrorLines = Object.freeze([...(input.pageErrorLines ?? [])]);
|
|
11
13
|
if (input.cause instanceof Error && input.cause.stack)
|
|
12
14
|
this.stack = input.cause.stack;
|
|
13
15
|
}
|
|
@@ -101,41 +103,40 @@ function ioError(error, message) {
|
|
|
101
103
|
BROKEN_PIPE_MESSAGE.test(message) ||
|
|
102
104
|
hasIoOperationFailure(message));
|
|
103
105
|
}
|
|
106
|
+
const ORDERED_KIND_RULES = [
|
|
107
|
+
[
|
|
108
|
+
"memory_exhaustion",
|
|
109
|
+
(message) => BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim()) ||
|
|
110
|
+
BUN_MEMORY_EXHAUSTION_WRAPPED_WORKER_MESSAGE.test(message) ||
|
|
111
|
+
matchesAny(message, MEMORY_EXHAUSTION_ERROR_PATTERNS),
|
|
112
|
+
],
|
|
113
|
+
["verification", (message) => matchesAny(message, VERIFICATION_ERROR_PATTERNS)],
|
|
114
|
+
["protocol_timeout", (message) => matchesAny(message, PROTOCOL_TIMEOUT_PATTERNS)],
|
|
115
|
+
["transient_browser", (message) => matchesAny(message, TRANSIENT_BROWSER_ERROR_PATTERNS)],
|
|
116
|
+
["authoring", (message) => matchesAny(message, AUTHORING_ERROR_PATTERNS)],
|
|
117
|
+
];
|
|
118
|
+
function classifyKind(error, message, signal) {
|
|
119
|
+
if (signal?.aborted || /(?:render|capture)?_?cancelled|AbortError/i.test(message)) {
|
|
120
|
+
return "cancelled";
|
|
121
|
+
}
|
|
122
|
+
const rule = ORDERED_KIND_RULES.find(([, matches]) => matches(message));
|
|
123
|
+
if (rule)
|
|
124
|
+
return rule[0];
|
|
125
|
+
return ioError(error, message) ? "io" : "authoring";
|
|
126
|
+
}
|
|
104
127
|
export function classifyCaptureFailure(error, options = {}) {
|
|
105
|
-
|
|
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) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { frameSequencePath } from "./extractedFrameIndex.js";
|
|
9
9
|
import { closeSync, copyFileSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, statSync, writeFileSync, writeSync, } from "fs";
|
|
10
|
-
import { join, dirname, extname } from "path";
|
|
10
|
+
import { join, dirname, extname, resolve } from "path";
|
|
11
11
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
12
12
|
import { getCachedGpuEncoder, getGpuEncoderName, buildVideoToolboxRateControlArgs, mapPresetForGpuEncoder, } from "../utils/gpuEncoder.js";
|
|
13
13
|
import { getHdrEncoderColorParams, hdrFrameTagsFilter } from "../utils/hdr.js";
|
|
@@ -460,7 +460,7 @@ let concatListSeq = 0;
|
|
|
460
460
|
function writeConcatList(dir, inputPaths) {
|
|
461
461
|
concatListSeq += 1;
|
|
462
462
|
const listPath = join(dir, `concat-list-${process.pid}-${concatListSeq}.txt`);
|
|
463
|
-
const body = inputPaths.map((p) => `file '${p.replace(/'/g, "'\\''")}'`).join("\n");
|
|
463
|
+
const body = inputPaths.map((p) => `file '${resolve(p).replace(/'/g, "'\\''")}'`).join("\n");
|
|
464
464
|
writeFileSync(listPath, body, "utf-8");
|
|
465
465
|
return listPath;
|
|
466
466
|
}
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
* become inert and can be gc'd by the caller.
|
|
33
33
|
*/
|
|
34
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
35
|
-
import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync,
|
|
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) {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* Performance: ~6 fps at 1080x1920 via base64 transfer.
|
|
20
20
|
*/
|
|
21
|
+
import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
|
|
21
22
|
import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js";
|
|
22
23
|
// ── PQ (SMPTE 2084) OETF ─────────────────────────────────────────────────────
|
|
23
24
|
const PQ_M1 = 0.1593017578125;
|
|
@@ -183,7 +184,7 @@ export async function launchHdrBrowser(width, height) {
|
|
|
183
184
|
...sigintLaunchOptions(),
|
|
184
185
|
headless: false,
|
|
185
186
|
executablePath: chromePath,
|
|
186
|
-
args: buildHdrChromeArgs(width, height),
|
|
187
|
+
args: withHyperframesBrowserMarker(buildHdrChromeArgs(width, height)),
|
|
187
188
|
});
|
|
188
189
|
const page = await browser.newPage();
|
|
189
190
|
await page.setViewport({ width, height });
|
|
@@ -46,6 +46,8 @@ export interface WorkerResult {
|
|
|
46
46
|
perf?: CapturePerfSummary;
|
|
47
47
|
error?: string;
|
|
48
48
|
diagnostics?: string[];
|
|
49
|
+
/** The worker session's `[Browser:PAGEERROR]` console lines, kept so the render's error count sees them. */
|
|
50
|
+
pageErrorLines?: string[];
|
|
49
51
|
failure?: CaptureFailure;
|
|
50
52
|
}
|
|
51
53
|
export interface ParallelProgress {
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { cpus, freemem } from "os";
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
9
|
-
import { copyFile, readFile
|
|
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.194",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/core": "^0.8.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.194",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.194"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|