@hyperframes/engine 0.8.206 → 0.8.208
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 +2 -1
- package/dist/services/aacEncode.js +3 -2
- package/dist/services/audioFxRender.js +3 -2
- package/dist/services/audioMixer.js +10 -9
- package/dist/services/browserManager.d.ts +0 -26
- package/dist/services/browserManager.js +29 -5
- package/dist/services/chromeHostCeiling.d.ts +1 -1
- package/dist/services/chromeHostCeiling.js +4 -2
- package/dist/services/chromeStartup.d.ts +10 -0
- package/dist/services/chromeStartup.js +85 -0
- package/dist/services/chunkEncoder.d.ts +8 -0
- package/dist/services/chunkEncoder.js +33 -0
- package/dist/services/chunkEncoder.types.d.ts +2 -0
- package/dist/services/drawElementCaptureError.d.ts +1 -0
- package/dist/services/drawElementCaptureError.js +13 -0
- package/dist/services/frameCapture.js +7 -3
- package/dist/services/parallelCoordinator.d.ts +4 -0
- package/dist/services/parallelCoordinator.js +29 -5
- package/dist/services/streamingEncoder.d.ts +2 -0
- package/dist/services/streamingEncoder.js +2 -1
- package/dist/services/videoFrameExtractor.d.ts +4 -1
- package/dist/services/videoFrameExtractor.js +42 -22
- package/dist/utils/removeScratch.d.ts +5 -0
- package/dist/utils/removeScratch.js +23 -0
- package/dist/utils/renameWithRetry.d.ts +4 -0
- package/dist/utils/renameWithRetry.js +41 -6
- package/package.json +7 -3
package/dist/index.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type Cap
|
|
|
43
43
|
export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
|
|
44
44
|
export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
|
|
45
45
|
export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
|
|
46
|
-
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
|
|
46
|
+
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
|
|
47
47
|
export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
|
|
48
48
|
export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
49
49
|
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
|
|
@@ -55,7 +55,7 @@ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCo
|
|
|
55
55
|
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
|
|
56
56
|
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
|
57
57
|
export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
|
|
58
|
-
export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, } from "./services/parallelCoordinator.js";
|
|
58
|
+
export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, type ParallelWorkerPhaseDiagnostic, } from "./services/parallelCoordinator.js";
|
|
59
59
|
export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
|
|
60
60
|
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
61
61
|
export { frameFileExtension } from "./services/frameCapture.js";
|
|
@@ -74,6 +74,7 @@ export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChild
|
|
|
74
74
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
75
75
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
76
76
|
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
77
|
+
export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
|
|
77
78
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
78
79
|
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
|
|
79
80
|
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
|
package/dist/index.js
CHANGED
|
@@ -47,7 +47,7 @@ export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
|
|
|
47
47
|
export { installMediaRenderIdBridge } from "./services/mediaRenderIdBridge.js";
|
|
48
48
|
export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, capturesAlpha, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, } from "./services/screenshotService.js";
|
|
49
49
|
// ── Encoding ───────────────────────────────────────────────────────────────────
|
|
50
|
-
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
|
|
50
|
+
export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, appendRateCapArgs, keyframeIntervalToGopSize, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
|
|
51
51
|
export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
|
|
52
52
|
// ── Media processing ───────────────────────────────────────────────────────────
|
|
53
53
|
export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveFrameFormat, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, sdrCanvasColourFilters, } from "./services/videoFrameExtractor.js";
|
|
@@ -78,6 +78,7 @@ export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
|
|
|
78
78
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
79
79
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
80
80
|
export { renameWithRetry, renameWithRetryAsync } from "./utils/renameWithRetry.js";
|
|
81
|
+
export { removeScratch, collectScratchLeftovers } from "./utils/removeScratch.js";
|
|
81
82
|
// drawElement self-verify comparison — shared by the streaming drain
|
|
82
83
|
// (producer) and the parallel disk-path verify (parallelCoordinator).
|
|
83
84
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
4
|
*/
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
|
-
import { mkdtempSync, readFileSync,
|
|
6
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { removeScratch } from "../utils/removeScratch.js";
|
|
7
8
|
import { dirname, join } from "node:path";
|
|
8
9
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
|
9
10
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
@@ -133,6 +134,6 @@ export async function encodeAac(input, outputPath, totalDuration, { signal, time
|
|
|
133
134
|
return { ...muxed, durationMs: slowest + muxed.durationMs };
|
|
134
135
|
}
|
|
135
136
|
finally {
|
|
136
|
-
|
|
137
|
+
removeScratch(dir);
|
|
137
138
|
}
|
|
138
139
|
}
|
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
* dynamics processors and the modulated delays) have no filter that behaves the
|
|
12
12
|
* same way, so preview would quietly stop predicting the render.
|
|
13
13
|
*/
|
|
14
|
-
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync,
|
|
14
|
+
import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, statSync, writeFileSync, writeSync, } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
import { pathToFileURL } from "node:url";
|
|
18
18
|
import { getAudioFxRuntimeScript } from "@hyperframes/core/audio-fx-runtime";
|
|
19
19
|
import { enabledAudioFxNodes } from "@hyperframes/core/audio-fx";
|
|
20
20
|
import { serializeAutomation } from "@hyperframes/core/audio-automation";
|
|
21
|
+
import { removeScratch } from "../utils/removeScratch.js";
|
|
21
22
|
import { acquireBrowser } from "./browserManager.js";
|
|
22
23
|
import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
|
|
23
24
|
import { riffChunks, wavFormatTag } from "./wavChunks.js";
|
|
@@ -460,7 +461,7 @@ async function withFxBrowser(options, body) {
|
|
|
460
461
|
throw new AudioFxRenderError(`Audio FX failed for track ${options.trackId}: ${err.message}`);
|
|
461
462
|
}
|
|
462
463
|
finally {
|
|
463
|
-
|
|
464
|
+
removeScratch(hostDir);
|
|
464
465
|
await lease?.release().catch(() => undefined);
|
|
465
466
|
}
|
|
466
467
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
|
|
3
3
|
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
|
|
4
4
|
import { renameWithRetry } from "../utils/renameWithRetry.js";
|
|
5
|
+
import { removeScratch } from "../utils/removeScratch.js";
|
|
5
6
|
import { join, dirname, isAbsolute, relative } from "path";
|
|
6
7
|
import { parseHTML } from "linkedom";
|
|
7
8
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
@@ -575,7 +576,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
575
576
|
};
|
|
576
577
|
}
|
|
577
578
|
finally {
|
|
578
|
-
|
|
579
|
+
removeScratch(cycleDir);
|
|
579
580
|
}
|
|
580
581
|
}
|
|
581
582
|
const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
|
|
@@ -773,7 +774,7 @@ async function mixAudioTracksFlat(tracks, outputPath, totalDuration, signal, con
|
|
|
773
774
|
return await runFfmpeg(currentArgs, { signal, timeout: ffmpegProcessTimeout });
|
|
774
775
|
}
|
|
775
776
|
finally {
|
|
776
|
-
|
|
777
|
+
removeScratch(scriptDir);
|
|
777
778
|
}
|
|
778
779
|
};
|
|
779
780
|
let useNormalize = true;
|
|
@@ -934,7 +935,7 @@ async function mixGroupMembersFlat(memberTracks, outputPath, totalDuration, sign
|
|
|
934
935
|
return await runFfmpeg(currentArgs, { signal, timeout: ffmpegProcessTimeout });
|
|
935
936
|
}
|
|
936
937
|
finally {
|
|
937
|
-
|
|
938
|
+
removeScratch(scriptDir);
|
|
938
939
|
}
|
|
939
940
|
};
|
|
940
941
|
let useNormalize = true;
|
|
@@ -991,8 +992,8 @@ async function sumInSlices(tracks, scratchDir, totalDuration, signal, config) {
|
|
|
991
992
|
}
|
|
992
993
|
const foldedPath = join(scratchDir, `${id}.wav`);
|
|
993
994
|
const folded = await mixGroupMembersFlat([running, wavTrack(`${id}-slice`, slicePath)], foldedPath, totalDuration, signal, config);
|
|
994
|
-
|
|
995
|
-
|
|
995
|
+
removeScratch(running.srcPath);
|
|
996
|
+
removeScratch(slicePath);
|
|
996
997
|
if (!folded.success)
|
|
997
998
|
return folded;
|
|
998
999
|
running = wavTrack(id, foldedPath);
|
|
@@ -1011,11 +1012,11 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
1011
1012
|
const summed = await sumInSlices(memberTracks, scratchDir, totalDuration, signal, config);
|
|
1012
1013
|
if (!summed.success)
|
|
1013
1014
|
return summed;
|
|
1014
|
-
renameWithRetry(summed.track.srcPath, outputPath);
|
|
1015
|
+
renameWithRetry(summed.track.srcPath, outputPath, { copyFallback: true });
|
|
1015
1016
|
return { success: true, degradedAutomation: summed.degradedAutomation };
|
|
1016
1017
|
}
|
|
1017
1018
|
finally {
|
|
1018
|
-
|
|
1019
|
+
removeScratch(scratchDir);
|
|
1019
1020
|
}
|
|
1020
1021
|
}
|
|
1021
1022
|
async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
|
|
@@ -1048,7 +1049,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
|
|
|
1048
1049
|
: mixed;
|
|
1049
1050
|
}
|
|
1050
1051
|
finally {
|
|
1051
|
-
|
|
1052
|
+
removeScratch(scratchDir);
|
|
1052
1053
|
}
|
|
1053
1054
|
}
|
|
1054
1055
|
/**
|
|
@@ -1085,7 +1086,7 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
|
|
|
1085
1086
|
};
|
|
1086
1087
|
}
|
|
1087
1088
|
if (config?.keepEncoderInputAt)
|
|
1088
|
-
renameWithRetry(encoderInput, config.keepEncoderInputAt);
|
|
1089
|
+
renameWithRetry(encoderInput, config.keepEncoderInputAt, { copyFallback: true });
|
|
1089
1090
|
return {
|
|
1090
1091
|
...sum,
|
|
1091
1092
|
outputPath,
|
|
@@ -61,32 +61,6 @@ export declare const _closeBrowserAfterFailedProbeForTests: typeof closeBrowserA
|
|
|
61
61
|
export declare function _resetAutoBrowserGpuModeCacheForTests(): void;
|
|
62
62
|
/** True when a `resolvedMode` render must launch without WAYLAND_DISPLAY to reach the GPU. */
|
|
63
63
|
export declare function captureWithoutWaylandDisplay(resolvedMode: "software" | "hardware"): Promise<boolean>;
|
|
64
|
-
/**
|
|
65
|
-
* Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
|
|
66
|
-
*
|
|
67
|
-
* For `"software"` this is a pure pass-through. For `"auto"` it launches a
|
|
68
|
-
* tiny Chrome with the platform's hardware GPU args, runs a one-shot WebGL
|
|
69
|
-
* availability probe, and falls back to `"software"` if hardware-mode WebGL
|
|
70
|
-
* is unavailable. The Promise is cached for the process lifetime, so
|
|
71
|
-
* concurrent callers (parallel workers) share the same probe.
|
|
72
|
-
*
|
|
73
|
-
* `"hardware"` (an explicit `--browser-gpu` / `PRODUCER_BROWSER_GPU_MODE=
|
|
74
|
-
* hardware`) is honoured verbatim — the operator asked for it — but runs the
|
|
75
|
-
* SAME probe to VERIFY it, because Chrome's hardware GL args are advisory:
|
|
76
|
-
* with no usable GPU in the sandbox (no `/dev/dri`, no NVIDIA container
|
|
77
|
-
* runtime, missing EGL/driver libraries) Chrome silently falls back to
|
|
78
|
-
* software WebGL and the render just runs at CPU speed. Without this check
|
|
79
|
-
* the only trace is a buried `Automatic fallback to software WebGL` browser
|
|
80
|
-
* warning — heygen-com/hyperframes#2967 rendered 19186 frames on CPU while
|
|
81
|
-
* `--browser-gpu` was set and nothing said so. The probe result never
|
|
82
|
-
* changes the returned mode; it only makes the fallback loud.
|
|
83
|
-
*
|
|
84
|
-
* Any probe failure (Chrome launch error, navigation timeout, missing canvas
|
|
85
|
-
* API, etc.) is treated as a `"software"` result. The render path with
|
|
86
|
-
* SwiftShader always works, so a misclassification toward software is the
|
|
87
|
-
* safe failure mode; misclassifying toward hardware would error on the real
|
|
88
|
-
* render.
|
|
89
|
-
*/
|
|
90
64
|
export declare function resolveBrowserGpuMode(mode: EngineConfig["browserGpuMode"], options?: GpuProbeOptions): Promise<"software" | "hardware">;
|
|
91
65
|
type BrowserLaunchConfig = Partial<Pick<EngineConfig, "browserTimeout" | "protocolTimeout" | "chromePath" | "forceScreenshot">> & {
|
|
92
66
|
withoutWaylandDisplay?: boolean;
|
|
@@ -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 { explainChromeLaunchFailure } from "./chromeStartup.js";
|
|
8
9
|
import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
|
|
9
10
|
import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
|
|
10
11
|
import { execSync } from "child_process";
|
|
@@ -462,7 +463,8 @@ async function probeWebGlMode(ppt, options, env, label = "") {
|
|
|
462
463
|
}
|
|
463
464
|
catch (err) {
|
|
464
465
|
logResolvedBrowserGpuMode("software", label + formatProbeFailure(err));
|
|
465
|
-
|
|
466
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
467
|
+
return { mode: "software", cause: "probe-error", ...(aborted && { aborted }) };
|
|
466
468
|
}
|
|
467
469
|
}
|
|
468
470
|
function envWithoutWaylandDisplay() {
|
|
@@ -524,12 +526,24 @@ function wslAccess(options) {
|
|
|
524
526
|
* safe failure mode; misclassifying toward hardware would error on the real
|
|
525
527
|
* render.
|
|
526
528
|
*/
|
|
529
|
+
async function sharedGpuProbe(options) {
|
|
530
|
+
const shared = (_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options));
|
|
531
|
+
const outcome = await shared;
|
|
532
|
+
if (!outcome.aborted)
|
|
533
|
+
return outcome;
|
|
534
|
+
if (_autoBrowserGpuModeCache === shared)
|
|
535
|
+
_autoBrowserGpuModeCache = undefined;
|
|
536
|
+
const own = await probeAutoBrowserGpuMode(options);
|
|
537
|
+
if (!own.aborted)
|
|
538
|
+
_autoBrowserGpuModeCache ??= Promise.resolve(own);
|
|
539
|
+
return own;
|
|
540
|
+
}
|
|
527
541
|
export function resolveBrowserGpuMode(mode, options = {}) {
|
|
528
542
|
if (mode === "software")
|
|
529
543
|
return Promise.resolve(mode);
|
|
530
|
-
|
|
544
|
+
const probe = sharedGpuProbe(options);
|
|
531
545
|
if (mode === "auto") {
|
|
532
|
-
return
|
|
546
|
+
return probe.then((probed) => {
|
|
533
547
|
if (probed.mode === "software" && probed.wslAccess && !_wslSoftwareNoteLogged) {
|
|
534
548
|
_wslSoftwareNoteLogged = true;
|
|
535
549
|
console.error(buildWslSoftwareNote(probed));
|
|
@@ -537,7 +551,7 @@ export function resolveBrowserGpuMode(mode, options = {}) {
|
|
|
537
551
|
return probed.mode;
|
|
538
552
|
});
|
|
539
553
|
}
|
|
540
|
-
return
|
|
554
|
+
return probe.then((probed) => {
|
|
541
555
|
// Warn once per cache lifetime, not once per caller: `createCaptureSession`
|
|
542
556
|
// resolves the mode for the probe browser AND every parallel worker, so
|
|
543
557
|
// an un-deduplicated warning prints N+1 times and buries itself.
|
|
@@ -658,7 +672,8 @@ async function launchBrowser(fingerprint) {
|
|
|
658
672
|
const env = launchEnv(fingerprint);
|
|
659
673
|
let captureMode = fingerprint.requestedCaptureMode;
|
|
660
674
|
let browser;
|
|
661
|
-
const launch = (args) => ppt
|
|
675
|
+
const launch = (args) => ppt
|
|
676
|
+
.launch({
|
|
662
677
|
...ownedLaunchOptions(),
|
|
663
678
|
headless: true,
|
|
664
679
|
args: withHyperframesBrowserMarker(args),
|
|
@@ -667,6 +682,13 @@ async function launchBrowser(fingerprint) {
|
|
|
667
682
|
env,
|
|
668
683
|
timeout: fingerprint.browserTimeoutMs,
|
|
669
684
|
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
685
|
+
})
|
|
686
|
+
.catch(async (error) => {
|
|
687
|
+
const defaults = await ppt.defaultArgs({
|
|
688
|
+
headless: true,
|
|
689
|
+
args: withHyperframesBrowserMarker(args),
|
|
690
|
+
});
|
|
691
|
+
throw await explainChromeLaunchFailure(error, fingerprint.executablePath, defaults);
|
|
670
692
|
});
|
|
671
693
|
const probe = (target, timeoutMs) => probeBeginFrameSupport(target, timeoutMs).catch((error) => ({
|
|
672
694
|
supported: true,
|
|
@@ -706,6 +728,8 @@ async function launchBrowser(fingerprint) {
|
|
|
706
728
|
}
|
|
707
729
|
function forceCloseBrowserProcess(browser) {
|
|
708
730
|
const proc = browser.process?.();
|
|
731
|
+
for (const stream of proc?.stdio ?? [])
|
|
732
|
+
stream?.on("error", () => { });
|
|
709
733
|
if (proc && !proc.killed) {
|
|
710
734
|
try {
|
|
711
735
|
proc.kill("SIGKILL");
|
|
@@ -7,7 +7,7 @@ export declare const isManagedChromeDir: (versionName: string) => boolean;
|
|
|
7
7
|
/** Newest first, by numeric build (`linux-148.0.1` before `linux-99.0.1`). */
|
|
8
8
|
export declare function compareBrowserVersionsDescending(left: string, right: string): number;
|
|
9
9
|
/** Whether a shell loads a blank page and exits: an old build can die at launch on a newer OS. Asked once per path. */
|
|
10
|
-
export declare function shellStarts(binary: string): boolean;
|
|
10
|
+
export declare function shellStarts(binary: string, timeoutMs?: number): boolean;
|
|
11
11
|
/** The shared Puppeteer cache's shell: the managed build, else the newest under the host's ceiling that starts.
|
|
12
12
|
* `shell` names a version dir's executable, or undefined when it holds no usable file. */
|
|
13
13
|
export declare function pickSharedShell(versions: readonly string[], shell: (version: string) => string | undefined): string | undefined;
|
|
@@ -52,11 +52,13 @@ export function compareBrowserVersionsDescending(left, right) {
|
|
|
52
52
|
const starts = new Map();
|
|
53
53
|
const debug = debuglog("hyperframes:browser");
|
|
54
54
|
/** Whether a shell loads a blank page and exits: an old build can die at launch on a newer OS. Asked once per path. */
|
|
55
|
-
export function shellStarts(binary) {
|
|
55
|
+
export function shellStarts(binary, timeoutMs = 10_000) {
|
|
56
56
|
let answer = starts.get(binary);
|
|
57
57
|
if (answer === undefined) {
|
|
58
58
|
const probe = ["--no-sandbox", "--dump-dom", "about:blank"];
|
|
59
|
-
answer =
|
|
59
|
+
answer =
|
|
60
|
+
spawnSync(binary, probe, { stdio: "ignore", timeout: timeoutMs, killSignal: "SIGKILL" })
|
|
61
|
+
?.status === 0;
|
|
60
62
|
if (!answer)
|
|
61
63
|
debug("skipping %s: it did not start", binary);
|
|
62
64
|
starts.set(binary, answer);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Names an exit code the way a person can act on it; undefined when it has no name. */
|
|
2
|
+
export declare function nameChromeExit(code: number | null, platform?: NodeJS.Platform): string | undefined;
|
|
3
|
+
/** Starts the browser once more with the failed launch's flags and says how it ended. */
|
|
4
|
+
export declare function diagnoseChromeStartup(executablePath: string, args: readonly string[], options?: {
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
platform?: NodeJS.Platform;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
}): Promise<string>;
|
|
9
|
+
/** The launch error, with the startup diagnosis appended; the original stays as `cause`. */
|
|
10
|
+
export declare function explainChromeLaunchFailure(error: unknown, executablePath: string | undefined, args: readonly string[] | undefined, signal?: AbortSignal): Promise<unknown>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { removeTreeBestEffort } from "@hyperframes/core/remove-tree";
|
|
6
|
+
const STDERR_TAIL_LINES = 8;
|
|
7
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
8
|
+
// NTSTATUS codes a Windows process dies with before it can print anything.
|
|
9
|
+
const WINDOWS_EXITS = [
|
|
10
|
+
[0xc0000135, "0xC0000135: a required runtime or DLL is missing (Visual C++ runtime)."],
|
|
11
|
+
[0xc0000017, "0xC0000017: Windows had no memory to start it (out of memory)."],
|
|
12
|
+
[0xc0000142, "0xC0000142: a DLL failed to initialise, often security software blocking it."],
|
|
13
|
+
[0xc0000022, "0xC0000022: access denied, often antivirus or a policy blocking the executable."],
|
|
14
|
+
[0xc000007b, "0xC000007B: the executable and a DLL do not match (32-bit and 64-bit)."],
|
|
15
|
+
];
|
|
16
|
+
/** Names an exit code the way a person can act on it; undefined when it has no name. */
|
|
17
|
+
export function nameChromeExit(code, platform = process.platform) {
|
|
18
|
+
if (platform !== "win32" || code === null)
|
|
19
|
+
return undefined;
|
|
20
|
+
const unsigned = code >>> 0;
|
|
21
|
+
return WINDOWS_EXITS.find(([known]) => known === unsigned)?.[1];
|
|
22
|
+
}
|
|
23
|
+
function startupArgs(args, profile) {
|
|
24
|
+
const kept = args.filter((arg) => arg.startsWith("--") &&
|
|
25
|
+
!arg.startsWith("--remote-debugging") &&
|
|
26
|
+
!arg.startsWith("--user-data-dir"));
|
|
27
|
+
return [...kept, `--user-data-dir=${profile}`, "--remote-debugging-port=0", "about:blank"];
|
|
28
|
+
}
|
|
29
|
+
function tail(stderr) {
|
|
30
|
+
return stderr
|
|
31
|
+
.split(/\r?\n/)
|
|
32
|
+
.map((line) => line.trim())
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.slice(-STDERR_TAIL_LINES)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
/** Starts the browser once more with the failed launch's flags and says how it ended. */
|
|
38
|
+
export async function diagnoseChromeStartup(executablePath, args, options = {}) {
|
|
39
|
+
const profile = mkdtempSync(join(tmpdir(), "hf-chrome-probe-"));
|
|
40
|
+
const timeoutMs = options.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
41
|
+
try {
|
|
42
|
+
return await new Promise((resolve) => {
|
|
43
|
+
let stderr = "";
|
|
44
|
+
const child = spawn(executablePath, startupArgs(args, profile), {
|
|
45
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
});
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
child.kill();
|
|
50
|
+
resolve(`Chrome started and kept running for ${timeoutMs} ms when launched on its own.`);
|
|
51
|
+
}, timeoutMs);
|
|
52
|
+
options.signal?.addEventListener("abort", () => {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
child.kill();
|
|
55
|
+
resolve("The startup check was cancelled.");
|
|
56
|
+
}, { once: true });
|
|
57
|
+
child.stderr.on("data", (chunk) => {
|
|
58
|
+
stderr = (stderr + chunk.toString()).slice(-4000);
|
|
59
|
+
});
|
|
60
|
+
child.once("error", (error) => {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
resolve(`Chrome could not be started: ${error.message}`);
|
|
63
|
+
});
|
|
64
|
+
child.once("exit", (code, signal) => {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
const how = signal ? `was stopped by ${signal}` : `exited with code ${code}`;
|
|
67
|
+
const named = nameChromeExit(code, options.platform);
|
|
68
|
+
const lines = tail(stderr);
|
|
69
|
+
resolve([`Chrome ${how} when launched on its own.`, named, lines && `stderr:\n${lines}`]
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join("\n"));
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
removeTreeBestEffort(profile);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The launch error, with the startup diagnosis appended; the original stays as `cause`. */
|
|
80
|
+
export async function explainChromeLaunchFailure(error, executablePath, args, signal) {
|
|
81
|
+
if (!executablePath || !(error instanceof Error))
|
|
82
|
+
return error;
|
|
83
|
+
const diagnosis = await diagnoseChromeStartup(executablePath, args ?? [], { signal });
|
|
84
|
+
return new Error(`${error.message}\n${diagnosis}`, { cause: error });
|
|
85
|
+
}
|
|
@@ -87,6 +87,14 @@ export declare function appendLockedGopArgs(args: string[], gopSize: number): vo
|
|
|
87
87
|
* open-GOP references. `repeat-headers=1` keeps each boundary self-contained.
|
|
88
88
|
*/
|
|
89
89
|
export declare function lockedGopCodecParams(codec: "h264" | "h265", gopSize: number): string;
|
|
90
|
+
/** Frames per keyframe interval: the nearest whole frame, never below one. */
|
|
91
|
+
export declare function keyframeIntervalToGopSize(fps: Fps, seconds: number): number;
|
|
92
|
+
/** `-maxrate` / `-bufsize` for the VBV cap, written once for both arg builders. Buffer defaults to 2x. */
|
|
93
|
+
export declare function appendRateCapArgs(args: string[], options: {
|
|
94
|
+
codec?: string;
|
|
95
|
+
maxBitrate?: string;
|
|
96
|
+
bufferSize?: string;
|
|
97
|
+
}): void;
|
|
90
98
|
export declare function buildEncoderArgs(options: EncoderOptions, inputArgs: string[], outputPath: string, gpuEncoder?: GpuEncoder): string[];
|
|
91
99
|
export declare function encodeFramesFromDir(framesDir: string, framePattern: string, outputPath: string, options: EncoderOptions, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegEncodeTimeout">>, onFramesEncoded?: (frames: number) => void): Promise<EncodeResult>;
|
|
92
100
|
export declare function buildConcatArgs(concatListPath: string, outputPath: string): string[];
|
|
@@ -128,6 +128,38 @@ export function lockedGopCodecParams(codec, gopSize) {
|
|
|
128
128
|
const shared = "scenecut=0:open-gop=0:repeat-headers=1";
|
|
129
129
|
return codec === "h264" ? shared : `keyint=${gopSize}:min-keyint=${gopSize}:${shared}`;
|
|
130
130
|
}
|
|
131
|
+
/** Frames per keyframe interval: the nearest whole frame, never below one. */
|
|
132
|
+
export function keyframeIntervalToGopSize(fps, seconds) {
|
|
133
|
+
return Math.max(1, Math.round(fpsToNumber(fps) * seconds));
|
|
134
|
+
}
|
|
135
|
+
const BITRATE_PATTERN = /^(\d+(?:\.\d+)?)([kKmMgG]?)$/;
|
|
136
|
+
function bitrateToBitsPerSecond(value) {
|
|
137
|
+
const match = BITRATE_PATTERN.exec(value);
|
|
138
|
+
if (!match)
|
|
139
|
+
throw new Error(`[chunkEncoder] invalid bitrate ${JSON.stringify(value)}`);
|
|
140
|
+
const unit = match[2].toLowerCase();
|
|
141
|
+
const scale = { g: 1_000_000_000, m: 1_000_000, k: 1_000 }[unit] ?? 1;
|
|
142
|
+
return Number(match[1]) * scale;
|
|
143
|
+
}
|
|
144
|
+
/** `-maxrate` / `-bufsize` for the VBV cap, written once for both arg builders. Buffer defaults to 2x. */
|
|
145
|
+
export function appendRateCapArgs(args, options) {
|
|
146
|
+
const { maxBitrate, bufferSize } = options;
|
|
147
|
+
if (maxBitrate === undefined) {
|
|
148
|
+
if (bufferSize !== undefined)
|
|
149
|
+
throw new Error("[chunkEncoder] bufferSize requires maxBitrate");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const codec = options.codec ?? "h264";
|
|
153
|
+
if (codec !== "h264" && codec !== "h265") {
|
|
154
|
+
throw new Error(`[chunkEncoder] maxBitrate is not supported for codec ${codec}`);
|
|
155
|
+
}
|
|
156
|
+
const cap = bitrateToBitsPerSecond(maxBitrate);
|
|
157
|
+
const buffer = bufferSize !== undefined ? bitrateToBitsPerSecond(bufferSize) : cap * 2;
|
|
158
|
+
if (cap <= 0 || buffer <= 0) {
|
|
159
|
+
throw new Error("[chunkEncoder] maxBitrate and bufferSize must be positive");
|
|
160
|
+
}
|
|
161
|
+
args.push("-maxrate", String(Math.round(cap)), "-bufsize", String(Math.round(buffer)));
|
|
162
|
+
}
|
|
131
163
|
export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = null) {
|
|
132
164
|
const { fps, codec = "h264", preset = "medium", quality = 23, bitrate, pixelFormat = "yuv420p", vp9CpuUsed, useGpu = false, } = options;
|
|
133
165
|
// libx264 cannot encode HDR. If a caller passes hdr with codec=h264 we'd
|
|
@@ -142,6 +174,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
|
|
|
142
174
|
// Without -xerror, ffmpeg ends the video at the first frame file it cannot read and still exits 0.
|
|
143
175
|
const args = ["-xerror", ...inputArgs, "-r", fpsToFfmpegArg(fps)];
|
|
144
176
|
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
|
177
|
+
appendRateCapArgs(args, options);
|
|
145
178
|
if (codec === "h264" || codec === "h265") {
|
|
146
179
|
if (shouldUseGpu) {
|
|
147
180
|
const encoderName = getGpuEncoderName(gpuEncoder, codec);
|
|
@@ -9,6 +9,8 @@ export interface EncoderOptions {
|
|
|
9
9
|
preset?: string;
|
|
10
10
|
quality?: number;
|
|
11
11
|
bitrate?: string;
|
|
12
|
+
maxBitrate?: string;
|
|
13
|
+
bufferSize?: string;
|
|
12
14
|
pixelFormat?: string;
|
|
13
15
|
/** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
|
|
14
16
|
vp9CpuUsed?: number;
|
|
@@ -3,5 +3,6 @@ export declare class DrawElementCaptureError extends Error {
|
|
|
3
3
|
readonly frameIndex: number;
|
|
4
4
|
constructor(frameIndex: number, reason: string, cause?: unknown);
|
|
5
5
|
}
|
|
6
|
+
export declare function asDrawElementCaptureFailure(frameIndex: number, encodeResult: Promise<Buffer>): Promise<Buffer>;
|
|
6
7
|
/** Structural matching survives engine/producer copies and worker error wrappers. */
|
|
7
8
|
export declare function isDrawElementCaptureError(error: unknown): boolean;
|
|
@@ -9,6 +9,19 @@ export class DrawElementCaptureError extends Error {
|
|
|
9
9
|
this.frameIndex = frameIndex;
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
function markRejectionHandled(promise) {
|
|
13
|
+
promise.catch(() => { });
|
|
14
|
+
}
|
|
15
|
+
export function asDrawElementCaptureFailure(frameIndex, encodeResult) {
|
|
16
|
+
const wrapped = encodeResult.catch((cause) => {
|
|
17
|
+
if (isDrawElementCaptureError(cause))
|
|
18
|
+
throw cause;
|
|
19
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
20
|
+
throw new DrawElementCaptureError(frameIndex, reason, cause);
|
|
21
|
+
});
|
|
22
|
+
markRejectionHandled(wrapped);
|
|
23
|
+
return wrapped;
|
|
24
|
+
}
|
|
12
25
|
/** Structural matching survives engine/producer copies and worker error wrappers. */
|
|
13
26
|
export function isDrawElementCaptureError(error) {
|
|
14
27
|
let current = error;
|
|
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { COMPOSITION_SOURCE_URL, MEDIA_RENDER_ID_ATTR, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, AUTHORED_SOURCE_BEGIN, AUTHORED_SOURCE_END, RUNTIME_BOOTSTRAP_ATTR, SCENE_CALLBACK_ERROR_PREFIX, } from "@hyperframes/core";
|
|
13
13
|
import { holdRenderFrameVideos } from "./renderFrameVideoHold.js";
|
|
14
|
-
import { DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
14
|
+
import { asDrawElementCaptureFailure, DrawElementCaptureError } from "./drawElementCaptureError.js";
|
|
15
15
|
import { encodePng } from "../utils/alphaBlit.js";
|
|
16
16
|
import { DEFAULT_SAMPLES_PER_FRAME, MotionBlurAccumulator, SPATIAL_TWEEN_PROPERTIES, adaptiveSampleCount, motionBlurProbeTimes, motionBlurSampleTimes, motionBlurWindowIsStatic, probeDiffMagnitude, resolveMotionBlurPlan, } from "./motionBlur.js";
|
|
17
17
|
import { describePageMotionBlur, describePageMotionBlurAtEnd, installPageMotionBlur, } from "./pageMotionBlur.js";
|
|
@@ -3648,7 +3648,8 @@ export async function captureFrameToBufferPipelined(session, frameIndex, time) {
|
|
|
3648
3648
|
// syncToPaintEvent = true); see initDrawElementOrTransparentBackground. The
|
|
3649
3649
|
// BeginFrame branch present in the synchronous captureFrameCore is therefore
|
|
3650
3650
|
// unreachable here and intentionally omitted.
|
|
3651
|
-
const
|
|
3651
|
+
const produced = await produceDrawElementFrame(page, options.width, options.height, options.quality ?? 80, true);
|
|
3652
|
+
const encodeResult = asDrawElementCaptureFailure(frameIndex, produced.encodeResult);
|
|
3652
3653
|
const captureTimeMs = Date.now() - startTime;
|
|
3653
3654
|
session.capturePerf.frames += 1;
|
|
3654
3655
|
session.capturePerf.seekMs += seekMs;
|
|
@@ -3763,7 +3764,10 @@ export async function captureFramesBatchPipelined(session, frameIndices, times)
|
|
|
3763
3764
|
const encodeResult = encodeResults[i];
|
|
3764
3765
|
if (frameIndex === undefined || !encodeResult)
|
|
3765
3766
|
break;
|
|
3766
|
-
results.push({
|
|
3767
|
+
results.push({
|
|
3768
|
+
frameIndex,
|
|
3769
|
+
encodeResult: asDrawElementCaptureFailure(frameIndex, encodeResult),
|
|
3770
|
+
});
|
|
3767
3771
|
}
|
|
3768
3772
|
if (failedAt !== null) {
|
|
3769
3773
|
// `error` is a plain string here (produceDrawElementFrameBatch returns it
|
|
@@ -74,6 +74,7 @@ export interface ParallelWorkerPhaseDiagnostic {
|
|
|
74
74
|
browserVersion: string;
|
|
75
75
|
canvasDrawElement: boolean | "unknown";
|
|
76
76
|
gpuBackend: string;
|
|
77
|
+
readStep?: () => string | undefined;
|
|
77
78
|
}
|
|
78
79
|
export interface WorkerSizingConfig extends Partial<Pick<EngineConfig, "concurrency" | "coresPerWorker" | "minParallelFrames" | "largeRenderThreshold">> {
|
|
79
80
|
/**
|
|
@@ -237,6 +238,9 @@ export interface ParallelCaptureHooks {
|
|
|
237
238
|
* frame (the ordered writer) with the original error, not a stall.
|
|
238
239
|
*/
|
|
239
240
|
onWorkerFailure?: (failure: CaptureFailure) => void;
|
|
241
|
+
onWorkerOperation?: (diagnostic: ParallelWorkerPhaseDiagnostic) => void;
|
|
242
|
+
/** Worker 0 runs this on its initialised session; the other workers wait for it. A rejection fails the pool. */
|
|
243
|
+
verifyFirstSession?: (session: CaptureSession) => Promise<void>;
|
|
240
244
|
}
|
|
241
245
|
/**
|
|
242
246
|
* The pool's single failure gate. Both halves of the contract the streaming
|
|
@@ -320,20 +320,22 @@ function resolveParallelWorkerTimeoutMs(enabled) {
|
|
|
320
320
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30_000;
|
|
321
321
|
}
|
|
322
322
|
// fallow-ignore-next-line complexity
|
|
323
|
-
async function captureFrameRange(session, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase) {
|
|
323
|
+
async function captureFrameRange(session, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase, onWorkerOperation) {
|
|
324
324
|
let framesCaptured = 0;
|
|
325
325
|
const outputOffset = task.outputFrameOffset ?? 0;
|
|
326
326
|
const stride = task.frameStride ?? 1;
|
|
327
327
|
const runCaptureOperation = (phase, frameIndex, operation) => {
|
|
328
|
-
const diagnostic = diagnosticFor(phase, frameIndex);
|
|
328
|
+
const diagnostic = { ...diagnosticFor(phase, frameIndex), readStep: () => session.captureStep };
|
|
329
329
|
if (framesCaptured === 0)
|
|
330
330
|
onWorkerPhase?.(diagnostic);
|
|
331
|
+
onWorkerOperation?.(diagnostic);
|
|
331
332
|
return withParallelWorkerDeadline(operation(), diagnostic, phaseTimeoutMs, signal);
|
|
332
333
|
};
|
|
333
334
|
const awaitEncode = (frameIndex, encodeResult) => {
|
|
334
335
|
const diagnostic = diagnosticFor("frame_encode", frameIndex);
|
|
335
336
|
if (framesCaptured === 0)
|
|
336
337
|
onWorkerPhase?.(diagnostic);
|
|
338
|
+
onWorkerOperation?.(diagnostic);
|
|
337
339
|
return withParallelWorkerDeadline(encodeResult, diagnostic, phaseTimeoutMs, signal);
|
|
338
340
|
};
|
|
339
341
|
// Depth-2 pipelined drawElement produce (HF_DE_PARALLEL_STREAM spike): frame
|
|
@@ -550,7 +552,7 @@ export async function verifyDiskDrawElementSamples(session, task, streaming) {
|
|
|
550
552
|
// flagged only because the disk self-verify call shifted its line range into
|
|
551
553
|
// the changed-code audit. Not restructured by this PR.
|
|
552
554
|
// fallow-ignore-next-line complexity
|
|
553
|
-
async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase) {
|
|
555
|
+
async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, onWorkerOperation, gate) {
|
|
554
556
|
const startTime = Date.now();
|
|
555
557
|
let framesCaptured = 0;
|
|
556
558
|
if (!existsSync(task.outputDir))
|
|
@@ -601,11 +603,13 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
|
|
|
601
603
|
await assertSwiftShader(activeSession.page, readWebGlVendorInfoFromCanvas);
|
|
602
604
|
}
|
|
603
605
|
await initializeSession(activeSession);
|
|
606
|
+
if (gate)
|
|
607
|
+
await (task.workerId === 0 ? gate.verify(activeSession) : gate.wait());
|
|
604
608
|
});
|
|
605
609
|
canvasDrawElement = activeSession.captureMode === "drawelement";
|
|
606
610
|
gpuBackend = activeSession.gpuRenderer ?? "unknown";
|
|
607
611
|
logParDebug(() => `[par:w${task.workerId}] init done (mode=${session?.captureMode} workerEncode=${session?.workerEncodeEnabled === true})`);
|
|
608
|
-
framesCaptured = await captureFrameRange(activeSession, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase);
|
|
612
|
+
framesCaptured = await captureFrameRange(activeSession, task, captureOptions, signal, onFrameCaptured, onFrameBuffer, phaseTimeoutMs, diagnosticFor, onWorkerPhase, onWorkerOperation);
|
|
609
613
|
await verifyDiskDrawElementSamples(activeSession, task, Boolean(onFrameBuffer));
|
|
610
614
|
perf = getCapturePerfSummary(activeSession);
|
|
611
615
|
return {
|
|
@@ -620,6 +624,8 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
|
|
|
620
624
|
};
|
|
621
625
|
}
|
|
622
626
|
catch (error) {
|
|
627
|
+
if (task.workerId === 0)
|
|
628
|
+
gate?.fail(error);
|
|
623
629
|
const diagnostics = session ? selectWorkerDiagnostics(session.browserConsoleBuffer) : [];
|
|
624
630
|
const workerDiagnostic = {
|
|
625
631
|
workerId: task.workerId,
|
|
@@ -685,6 +691,23 @@ export function isPoolFatalWorkerFailure(failure, streaming) {
|
|
|
685
691
|
return failure.kind !== "cancelled";
|
|
686
692
|
return isFatalCaptureFailure(failure);
|
|
687
693
|
}
|
|
694
|
+
function createFirstSessionGate(verify) {
|
|
695
|
+
let fail;
|
|
696
|
+
let pass;
|
|
697
|
+
const settled = new Promise((resolve, reject) => {
|
|
698
|
+
pass = resolve;
|
|
699
|
+
fail = reject;
|
|
700
|
+
});
|
|
701
|
+
settled.catch(() => { });
|
|
702
|
+
return {
|
|
703
|
+
verify: (session) => verify(session).then(pass, (error) => {
|
|
704
|
+
fail(error);
|
|
705
|
+
throw error;
|
|
706
|
+
}),
|
|
707
|
+
wait: () => settled,
|
|
708
|
+
fail,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
688
711
|
function pageErrorLinesOf(session) {
|
|
689
712
|
return session.browserConsoleBuffer.filter((line) => line.includes("PAGEERROR"));
|
|
690
713
|
}
|
|
@@ -775,7 +798,8 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
|
|
|
775
798
|
hooks,
|
|
776
799
|
});
|
|
777
800
|
const onFailure = failureHandler.onFailure;
|
|
778
|
-
const
|
|
801
|
+
const gate = hooks?.verifyFirstSession && createFirstSessionGate(hooks.verifyFirstSession);
|
|
802
|
+
const results = await Promise.all(tasks.map((task) => executeWorkerTask(task, serverUrl, workerCaptureOptions, createBeforeCaptureHook, workerSignal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, hooks?.onWorkerOperation, gate)));
|
|
779
803
|
flagSilentWorkerExits(results);
|
|
780
804
|
const errors = results.filter((r) => r.failure || r.error);
|
|
781
805
|
if (errors.length > 0) {
|
|
@@ -38,6 +38,8 @@ export interface StreamingEncoderOptions {
|
|
|
38
38
|
preset?: string;
|
|
39
39
|
quality?: number;
|
|
40
40
|
bitrate?: string;
|
|
41
|
+
maxBitrate?: string;
|
|
42
|
+
bufferSize?: string;
|
|
41
43
|
pixelFormat?: string;
|
|
42
44
|
/** libvpx-vp9 -cpu-used value. Defaults to the engine VP9 setting. */
|
|
43
45
|
vp9CpuUsed?: number;
|
|
@@ -30,7 +30,7 @@ import { fpsToFfmpegArg, fpsToNumber } from "@hyperframes/core";
|
|
|
30
30
|
import { appendVp9CpuUsedArg } from "./vp9Options.js";
|
|
31
31
|
import { appendSoftwareEncoderThreadsArg } from "./softwareEncoderThreads.js";
|
|
32
32
|
import { appendRenderProvenanceArgs } from "../utils/renderProvenance.js";
|
|
33
|
-
import { appendLockedGopArgs, lockedGopCodecParams, resolveLockedGopSize } from "./chunkEncoder.js";
|
|
33
|
+
import { appendLockedGopArgs, appendRateCapArgs, lockedGopCodecParams, resolveLockedGopSize, } from "./chunkEncoder.js";
|
|
34
34
|
export function createFrameReorderBuffer(startFrame, endFrame) {
|
|
35
35
|
let cursor = startFrame;
|
|
36
36
|
let aborted = null;
|
|
@@ -121,6 +121,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
|
|
|
121
121
|
}
|
|
122
122
|
args.push("-r", fpsToFfmpegArg(fps));
|
|
123
123
|
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
|
124
|
+
appendRateCapArgs(args, options);
|
|
124
125
|
if (codec === "h264" || codec === "h265") {
|
|
125
126
|
if (shouldUseGpu) {
|
|
126
127
|
const encoderName = getGpuEncoderName(gpuEncoder, codec);
|
|
@@ -188,6 +188,8 @@ export interface VideoExtractionFailure {
|
|
|
188
188
|
* can contain a local path or a signed source URL.
|
|
189
189
|
*/
|
|
190
190
|
error: string;
|
|
191
|
+
/** ffmpeg's last stderr lines, without paths or URLs: what a producer-facing error may say. */
|
|
192
|
+
ffmpegReason?: string;
|
|
191
193
|
}
|
|
192
194
|
export type VideoExtractionFailureStatusClass = "http_4xx" | "http_5xx" | "timeout" | "network" | "other";
|
|
193
195
|
export interface VideoExtractionFailureRetry {
|
|
@@ -211,8 +213,9 @@ export declare class VideoSourceExtractionError extends Error {
|
|
|
211
213
|
readonly kind: VideoExtractionFailureKind;
|
|
212
214
|
readonly retryable: boolean;
|
|
213
215
|
readonly diagnostic: string;
|
|
216
|
+
readonly ffmpegReason?: string | undefined;
|
|
214
217
|
readonly hyperframesVideoSourceExtractionError: true;
|
|
215
|
-
constructor(kind: VideoExtractionFailureKind, retryable: boolean, message: string, diagnostic?: string);
|
|
218
|
+
constructor(kind: VideoExtractionFailureKind, retryable: boolean, message: string, diagnostic?: string, ffmpegReason?: string | undefined);
|
|
216
219
|
}
|
|
217
220
|
export declare function isVideoSourceExtractionError(error: unknown): error is VideoSourceExtractionError;
|
|
218
221
|
/**
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { audioGroupsById, isMemberGroupHidden, isSelfOrAncestorHidden } from "./mediaHidden.js";
|
|
9
9
|
import { copyFileSync, existsSync, linkSync, mkdirSync, rmSync } from "fs";
|
|
10
|
-
import { join } from "path";
|
|
10
|
+
import { basename, join } from "path";
|
|
11
11
|
import { cpus } from "os";
|
|
12
12
|
import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
|
|
13
13
|
import { parseHTML } from "linkedom";
|
|
@@ -208,15 +208,34 @@ export class VideoSourceExtractionError extends Error {
|
|
|
208
208
|
kind;
|
|
209
209
|
retryable;
|
|
210
210
|
diagnostic;
|
|
211
|
+
ffmpegReason;
|
|
211
212
|
hyperframesVideoSourceExtractionError = true;
|
|
212
|
-
constructor(kind, retryable, message, diagnostic = message) {
|
|
213
|
+
constructor(kind, retryable, message, diagnostic = message, ffmpegReason) {
|
|
213
214
|
super(message);
|
|
214
215
|
this.kind = kind;
|
|
215
216
|
this.retryable = retryable;
|
|
216
217
|
this.diagnostic = diagnostic;
|
|
218
|
+
this.ffmpegReason = ffmpegReason;
|
|
217
219
|
this.name = "VideoSourceExtractionError";
|
|
218
220
|
}
|
|
219
221
|
}
|
|
222
|
+
const FFMPEG_REASON_LINES = 3;
|
|
223
|
+
const FFMPEG_REASON_CHARS = 300;
|
|
224
|
+
/** ffmpeg's last stderr lines for an error that leaves this host: no local path, file name or URL. */
|
|
225
|
+
function ffmpegReasonFrom(stderr, paths) {
|
|
226
|
+
let text = stderr;
|
|
227
|
+
for (const known of paths.filter(Boolean).sort((a, b) => b.length - a.length))
|
|
228
|
+
text = text.split(known).join("<path>");
|
|
229
|
+
return text
|
|
230
|
+
.replace(/[a-z][a-z\d+.-]*:\/\/[^\s'"]+/gi, "<url>")
|
|
231
|
+
.replace(/(?:[a-z]:)?(?:[\\/][^\s\\/:'"]+){2,}/gi, "<path>")
|
|
232
|
+
.split(/\r?\n/)
|
|
233
|
+
.map((line) => line.trim())
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.slice(-FFMPEG_REASON_LINES)
|
|
236
|
+
.join(" | ")
|
|
237
|
+
.slice(-FFMPEG_REASON_CHARS);
|
|
238
|
+
}
|
|
220
239
|
export function isVideoSourceExtractionError(error) {
|
|
221
240
|
return (typeof error === "object" &&
|
|
222
241
|
error !== null &&
|
|
@@ -623,9 +642,6 @@ outputDirOverride) {
|
|
|
623
642
|
throw new Error("frameRanges extraction needs a constant-frame-rate source");
|
|
624
643
|
}
|
|
625
644
|
const args = urlInputArgs(videoPath);
|
|
626
|
-
if (decodeWithVideoToolbox) {
|
|
627
|
-
args.push("-hwaccel", "videotoolbox");
|
|
628
|
-
}
|
|
629
645
|
// Always force the alpha-aware decoder on codecs that can carry alpha. The
|
|
630
646
|
// alternative — gating on `metadata.hasAlpha` — relies on tag detection that
|
|
631
647
|
// has at least three known failure modes: case-sensitivity across ffmpeg
|
|
@@ -708,9 +724,9 @@ outputDirOverride) {
|
|
|
708
724
|
// emits that last slot: the fps filter ends a `-t` window at the nearest slot. A
|
|
709
725
|
// decimal rate counts zero frames here and keeps the `-t` window.
|
|
710
726
|
const boundVfrFrameCount = metadata.isVFR && totalFrames > 0;
|
|
711
|
-
const
|
|
712
|
-
?
|
|
713
|
-
decodeArgs,
|
|
727
|
+
const extract = (hwaccel) => sampleAtOutputFps && (options.frameRanges || boundVfrFrameCount || totalFrames > segmentFrames)
|
|
728
|
+
? runSegmentedExtraction({
|
|
729
|
+
decodeArgs: [...hwaccel, ...decodeArgs],
|
|
714
730
|
filterAndEncodeArgs: (seek) => [...filterArgsFor(seek), ...encodeArgs],
|
|
715
731
|
videoPath,
|
|
716
732
|
startTime,
|
|
@@ -719,7 +735,14 @@ outputDirOverride) {
|
|
|
719
735
|
segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
|
|
720
736
|
runOptions,
|
|
721
737
|
})
|
|
722
|
-
:
|
|
738
|
+
: inExtractionSlot(() => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], runOptions), signal);
|
|
739
|
+
let processResult = await extract(decodeWithVideoToolbox ? ["-hwaccel", "videotoolbox"] : []);
|
|
740
|
+
const ffmpegExitedFailing = processResult.terminationReason === "exit" &&
|
|
741
|
+
!processResult.success &&
|
|
742
|
+
!processResult.failureReason;
|
|
743
|
+
const videoToolboxWasOptional = decodeWithVideoToolbox && !keepsHdrForChrome;
|
|
744
|
+
if (ffmpegExitedFailing && videoToolboxWasOptional)
|
|
745
|
+
processResult = await extract([]);
|
|
723
746
|
if (processResult.failureReason === "external_interruption") {
|
|
724
747
|
throw new VideoSourceExtractionError("external_interruption", true, "Video frame extraction interrupted by host lifecycle", `FFmpeg exited with code ${processResult.exitCode}: ${processResult.stderr.slice(-500)}`);
|
|
725
748
|
}
|
|
@@ -741,13 +764,18 @@ outputDirOverride) {
|
|
|
741
764
|
const timeoutSuffix = timedOut ? ` (no new frame for ${ffmpegProcessTimeout} ms)` : "";
|
|
742
765
|
const diagnostic = `${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ` +
|
|
743
766
|
processResult.stderr.slice(-500);
|
|
767
|
+
const ffmpegReason = ffmpegReasonFrom(processResult.stderr, [
|
|
768
|
+
videoPath,
|
|
769
|
+
basename(videoPath),
|
|
770
|
+
videoOutputDir,
|
|
771
|
+
]);
|
|
744
772
|
if (timedOut) {
|
|
745
|
-
throw new VideoSourceExtractionError("ffmpeg_timeout", true, "Video frame extraction timed out", diagnostic);
|
|
773
|
+
throw new VideoSourceExtractionError("ffmpeg_timeout", true, "Video frame extraction timed out", diagnostic, ffmpegReason);
|
|
746
774
|
}
|
|
747
775
|
const transientIo = /resource temporarily unavailable|device or resource busy|input\/output error/i.test(processResult.stderr);
|
|
748
776
|
throw new VideoSourceExtractionError(transientIo ? "ffmpeg_transient" : "ffmpeg_failed", transientIo, transientIo
|
|
749
777
|
? "Video frame extraction hit a transient I/O failure"
|
|
750
|
-
: "Video source could not be decoded", diagnostic);
|
|
778
|
+
: "Video source could not be decoded", diagnostic, ffmpegReason);
|
|
751
779
|
}
|
|
752
780
|
const framePaths = framePathsFromDirectory(videoOutputDir, format, !!options.frameRanges);
|
|
753
781
|
if (framePaths.size === 0 && duration > 0) {
|
|
@@ -1219,13 +1247,9 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1219
1247
|
resolvedVideos.push({ video, videoPath });
|
|
1220
1248
|
}
|
|
1221
1249
|
catch (err) {
|
|
1222
|
-
const classified = classifyVideoExtractionError(err);
|
|
1223
1250
|
errors.push({
|
|
1224
|
-
|
|
1225
|
-
kind: classified.kind,
|
|
1226
|
-
retryable: classified.retryable,
|
|
1251
|
+
...extractionError(video.id, err),
|
|
1227
1252
|
...(err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {}),
|
|
1228
|
-
error: classified.diagnostic,
|
|
1229
1253
|
});
|
|
1230
1254
|
}
|
|
1231
1255
|
}
|
|
@@ -1413,6 +1437,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1413
1437
|
kind: classified.kind,
|
|
1414
1438
|
retryable: classified.retryable,
|
|
1415
1439
|
error: classified.diagnostic,
|
|
1440
|
+
...(classified.ffmpegReason ? { ffmpegReason: classified.ffmpegReason } : {}),
|
|
1416
1441
|
};
|
|
1417
1442
|
}
|
|
1418
1443
|
function scopedExtractionOptions(work) {
|
|
@@ -1771,12 +1796,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1771
1796
|
? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
|
|
1772
1797
|
: outcome.error.error;
|
|
1773
1798
|
results.push({
|
|
1774
|
-
error: {
|
|
1775
|
-
videoId: prepared.work.video.id,
|
|
1776
|
-
kind: outcome.error.kind,
|
|
1777
|
-
retryable: outcome.error.retryable,
|
|
1778
|
-
error: message,
|
|
1779
|
-
},
|
|
1799
|
+
error: { ...outcome.error, videoId: prepared.work.video.id, error: message },
|
|
1780
1800
|
});
|
|
1781
1801
|
continue;
|
|
1782
1802
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
|
|
2
|
+
* delete) leaves it for the render's own cleanup and never fails the audio it served. */
|
|
3
|
+
export declare function removeScratch(path: string): void;
|
|
4
|
+
/** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
|
|
5
|
+
export declare function collectScratchLeftovers<T>(run: () => Promise<T>, report: (left: string[]) => void): Promise<T>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { rmSync } from "node:fs";
|
|
3
|
+
const collecting = new AsyncLocalStorage();
|
|
4
|
+
/** Removes a scratch folder or file the mix is already done with. A refusal (Windows temp folders that deny
|
|
5
|
+
* delete) leaves it for the render's own cleanup and never fails the audio it served. */
|
|
6
|
+
export function removeScratch(path) {
|
|
7
|
+
try {
|
|
8
|
+
rmSync(path, { recursive: true, force: true });
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
collecting.getStore()?.push(path);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Runs `run`; whatever removeScratch left behind inside it (this run's only) goes to `report` once it ends. */
|
|
15
|
+
export async function collectScratchLeftovers(run, report) {
|
|
16
|
+
const left = [];
|
|
17
|
+
try {
|
|
18
|
+
return await collecting.run(left, run);
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
report(left);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
interface Options {
|
|
2
2
|
platform?: NodeJS.Platform;
|
|
3
|
+
/** Copy the bytes when Windows keeps refusing (a TEMP that denies delete). Not atomic: scratch files only. */
|
|
4
|
+
copyFallback?: boolean;
|
|
3
5
|
}
|
|
4
6
|
/** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
|
|
5
7
|
export declare function renameWithRetry(from: string, to: string, options?: Options & {
|
|
6
8
|
rename?: (from: string, to: string) => void;
|
|
9
|
+
copy?: (from: string, to: string) => void;
|
|
7
10
|
}): void;
|
|
8
11
|
/** Async twin of {@link renameWithRetry}. */
|
|
9
12
|
export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
|
|
10
13
|
rename?: (from: string, to: string) => Promise<void>;
|
|
14
|
+
copy?: (from: string, to: string) => Promise<void>;
|
|
11
15
|
}): Promise<void>;
|
|
12
16
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { renameSync } from "node:fs";
|
|
2
|
-
import { rename } from "node:fs/promises";
|
|
1
|
+
import { copyFileSync, renameSync } from "node:fs";
|
|
2
|
+
import { copyFile, rename } from "node:fs/promises";
|
|
3
|
+
import { removeScratch } from "./removeScratch.js";
|
|
3
4
|
// Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
|
|
4
5
|
// file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
|
|
5
6
|
const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
@@ -9,12 +10,21 @@ function isRetryable(error, platform) {
|
|
|
9
10
|
return false;
|
|
10
11
|
return RETRYABLE.has(error?.code ?? "");
|
|
11
12
|
}
|
|
13
|
+
/** What a refused rename does next: wait and try again, fall back to a copy, or give up with the error. */
|
|
14
|
+
function nextStep(error, attempt, options, platform) {
|
|
15
|
+
if (!isRetryable(error, platform))
|
|
16
|
+
return "throw";
|
|
17
|
+
if (attempt < BACKOFF_MS.length)
|
|
18
|
+
return "wait";
|
|
19
|
+
return options.copyFallback ? "copy" : "throw";
|
|
20
|
+
}
|
|
12
21
|
function sleepSync(ms) {
|
|
13
22
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
14
23
|
}
|
|
15
24
|
/** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
|
|
16
25
|
export function renameWithRetry(from, to, options = {}) {
|
|
17
26
|
const doRename = options.rename ?? renameSync;
|
|
27
|
+
const doCopy = options.copy ?? copyFileSync;
|
|
18
28
|
const platform = options.platform ?? process.platform;
|
|
19
29
|
for (let attempt = 0;; attempt++) {
|
|
20
30
|
try {
|
|
@@ -22,15 +32,28 @@ export function renameWithRetry(from, to, options = {}) {
|
|
|
22
32
|
return;
|
|
23
33
|
}
|
|
24
34
|
catch (error) {
|
|
25
|
-
|
|
35
|
+
const step = nextStep(error, attempt, options, platform);
|
|
36
|
+
if (step === "throw")
|
|
37
|
+
throw error;
|
|
38
|
+
if (step === "wait") {
|
|
39
|
+
sleepSync(BACKOFF_MS[attempt]);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
doCopy(from, to);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
26
46
|
throw error;
|
|
27
|
-
|
|
47
|
+
}
|
|
48
|
+
removeScratch(from);
|
|
49
|
+
return;
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
52
|
}
|
|
31
53
|
/** Async twin of {@link renameWithRetry}. */
|
|
32
54
|
export async function renameWithRetryAsync(from, to, options = {}) {
|
|
33
55
|
const doRename = options.rename ?? rename;
|
|
56
|
+
const doCopy = options.copy ?? copyFile;
|
|
34
57
|
const platform = options.platform ?? process.platform;
|
|
35
58
|
for (let attempt = 0;; attempt++) {
|
|
36
59
|
try {
|
|
@@ -38,9 +61,21 @@ export async function renameWithRetryAsync(from, to, options = {}) {
|
|
|
38
61
|
return;
|
|
39
62
|
}
|
|
40
63
|
catch (error) {
|
|
41
|
-
|
|
64
|
+
const step = nextStep(error, attempt, options, platform);
|
|
65
|
+
if (step === "throw")
|
|
42
66
|
throw error;
|
|
43
|
-
|
|
67
|
+
if (step === "wait") {
|
|
68
|
+
await new Promise((resolve) => setTimeout(resolve, BACKOFF_MS[attempt]));
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
await doCopy(from, to);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
removeScratch(from);
|
|
78
|
+
return;
|
|
44
79
|
}
|
|
45
80
|
}
|
|
46
81
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.208",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"import": "./dist/services/chromeHostCeiling.js",
|
|
28
28
|
"types": "./dist/services/chromeHostCeiling.d.ts"
|
|
29
29
|
},
|
|
30
|
+
"./chrome-startup": {
|
|
31
|
+
"import": "./dist/services/chromeStartup.js",
|
|
32
|
+
"types": "./dist/services/chromeStartup.d.ts"
|
|
33
|
+
},
|
|
30
34
|
"./system-memory": {
|
|
31
35
|
"import": "./dist/services/systemMemory.js",
|
|
32
36
|
"types": "./dist/services/systemMemory.d.ts"
|
|
@@ -50,8 +54,8 @@
|
|
|
50
54
|
"linkedom": "^0.18.12",
|
|
51
55
|
"puppeteer": "^25.8.0",
|
|
52
56
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/core": "^0.8.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
57
|
+
"@hyperframes/core": "^0.8.208",
|
|
58
|
+
"@hyperframes/parsers": "^0.8.208"
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|
|
57
61
|
"@types/node": "^25.0.10",
|