@hyperframes/engine 0.8.207 → 0.8.209
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 +1 -1
- package/dist/services/audioFxRender.js +6 -3
- package/dist/services/audioMixer.js +9 -9
- package/dist/services/audioVolumeEnvelope.js +1 -1
- package/dist/services/browserManager.d.ts +0 -26
- package/dist/services/browserManager.js +44 -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/extractionCache.js +1 -1
- package/dist/services/parallelCoordinator.d.ts +2 -0
- package/dist/services/parallelCoordinator.js +25 -3
- package/dist/services/sandboxLaunch.d.ts +10 -0
- package/dist/services/sandboxLaunch.js +31 -0
- package/dist/services/videoFrameExtractor.d.ts +4 -1
- package/dist/services/videoFrameExtractor.js +31 -15
- package/dist/utils/ffprobe.d.ts +2 -0
- package/dist/utils/ffprobe.js +12 -8
- package/package.json +7 -3
- package/dist/utils/removeScratch.d.ts +0 -5
- package/dist/utils/removeScratch.js +0 -23
- package/dist/utils/renameWithRetry.d.ts +0 -16
- package/dist/utils/renameWithRetry.js +0 -81
package/dist/index.d.ts
CHANGED
|
@@ -73,8 +73,8 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
73
73
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
|
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
|
-
export { renameWithRetry, renameWithRetryAsync } from "
|
|
77
|
-
export { removeScratch, collectScratchLeftovers } from "
|
|
76
|
+
export { renameWithRetry, renameWithRetryAsync } from "@hyperframes/core/rename-retry";
|
|
77
|
+
export { removeScratch, collectScratchLeftovers } from "@hyperframes/core/remove-scratch";
|
|
78
78
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
79
79
|
export { decodePng, decodePngToRgb48le, blitRgba8OverRgb48le, blitRgb48leRegion, blitRgb48leAffine, parseTransformMatrix, roundedRectAlpha, resampleRgb48leObjectFit, normalizeObjectFit, type ObjectFit, } from "./utils/alphaBlit.js";
|
|
80
80
|
export { groupIntoLayers, type CompositeLayer } from "./utils/layerCompositor.js";
|
|
@@ -87,3 +87,4 @@ export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompo
|
|
|
87
87
|
export type { VideoColorSpace } from "./utils/ffprobe.js";
|
|
88
88
|
export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, type RenderProvenance, } from "./utils/renderProvenance.js";
|
|
89
89
|
export { DrawElementCaptureError, isDrawElementCaptureError, } from "./services/drawElementCaptureError.js";
|
|
90
|
+
export { inAgentSandbox, isOneProcessOverPipe, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./services/sandboxLaunch.js";
|
package/dist/index.js
CHANGED
|
@@ -77,8 +77,8 @@ export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
|
77
77
|
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
|
-
export { renameWithRetry, renameWithRetryAsync } from "
|
|
81
|
-
export { removeScratch, collectScratchLeftovers } from "
|
|
80
|
+
export { renameWithRetry, renameWithRetryAsync } from "@hyperframes/core/rename-retry";
|
|
81
|
+
export { removeScratch, collectScratchLeftovers } from "@hyperframes/core/remove-scratch";
|
|
82
82
|
// drawElement self-verify comparison — shared by the streaming drain
|
|
83
83
|
// (producer) and the parallel disk-path verify (parallelCoordinator).
|
|
84
84
|
export { psnrDb, resolveDeVerifyMinDb } from "./utils/psnr.js";
|
|
@@ -93,3 +93,4 @@ export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryEle
|
|
|
93
93
|
export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompositionHdr, findHdrAutoPromotion, formatHdrAutoPromotionWarning, sanitizeHdrAutoPromotionAsset, DEFAULT_HDR10_MASTERING, HDR_AUTO_PROMOTION_PIPELINE, } from "./utils/hdr.js";
|
|
94
94
|
export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, } from "./utils/renderProvenance.js";
|
|
95
95
|
export { DrawElementCaptureError, isDrawElementCaptureError, } from "./services/drawElementCaptureError.js";
|
|
96
|
+
export { inAgentSandbox, isOneProcessOverPipe, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./services/sandboxLaunch.js";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { execFile } from "node:child_process";
|
|
6
6
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
-
import { removeScratch } from "
|
|
7
|
+
import { removeScratch } from "@hyperframes/core/remove-scratch";
|
|
8
8
|
import { dirname, join } from "node:path";
|
|
9
9
|
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
|
|
10
10
|
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
@@ -18,8 +18,8 @@ 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 "
|
|
22
|
-
import { acquireBrowser } from "./browserManager.js";
|
|
21
|
+
import { removeScratch } from "@hyperframes/core/remove-scratch";
|
|
22
|
+
import { acquireBrowser, buildChromeArgs } from "./browserManager.js";
|
|
23
23
|
import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
|
|
24
24
|
import { riffChunks, wavFormatTag } from "./wavChunks.js";
|
|
25
25
|
import { windowPrerollSeconds } from "./audioFxWarmup.js";
|
|
@@ -439,6 +439,9 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
|
|
|
439
439
|
}
|
|
440
440
|
});
|
|
441
441
|
}
|
|
442
|
+
/** Audio processing needs no GPU, so software mode; every other launch detail is the shared one. */
|
|
443
|
+
let fxBrowserArgs;
|
|
444
|
+
const fxChromeArgs = () => (fxBrowserArgs ??= buildChromeArgs({ width: 1280, height: 720, captureMode: "screenshot" }, { browserGpuMode: "software" }));
|
|
442
445
|
/** Run `body` with a leased browser and a way to open pages on the runtime; both are released in the same try that takes them. */
|
|
443
446
|
async function withFxBrowser(options, body) {
|
|
444
447
|
const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-"));
|
|
@@ -451,7 +454,7 @@ async function withFxBrowser(options, body) {
|
|
|
451
454
|
if (options.signal?.aborted) {
|
|
452
455
|
throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
|
|
453
456
|
}
|
|
454
|
-
lease = await acquireBrowser(
|
|
457
|
+
lease = await acquireBrowser(fxChromeArgs());
|
|
455
458
|
const { browser } = lease;
|
|
456
459
|
return await body(() => openFxPage(browser, hostDir));
|
|
457
460
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// fallow-ignore-file complexity code-duplication
|
|
2
2
|
import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
|
|
3
3
|
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
|
|
4
|
-
import { renameWithRetry } from "
|
|
5
|
-
import { removeScratch } from "
|
|
4
|
+
import { renameWithRetry } from "@hyperframes/core/rename-retry";
|
|
5
|
+
import { removeScratch } from "@hyperframes/core/remove-scratch";
|
|
6
6
|
import { join, dirname, isAbsolute, relative } from "path";
|
|
7
7
|
import { parseHTML } from "linkedom";
|
|
8
8
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
@@ -78,11 +78,11 @@ function buildAtempoFilter(playbackRate) {
|
|
|
78
78
|
const RAMP_SLICE_SECONDS = 0.25;
|
|
79
79
|
const MAX_RAMP_SLICES = 240;
|
|
80
80
|
/** Bake a rate lane as consecutive source slices, each stretched by its mean `sourceTimeAt` rate, keeping pitch. */
|
|
81
|
-
function buildRampFilterComplex(lane, duration, headFilter, tailFilter) {
|
|
81
|
+
function buildRampFilterComplex(lane, duration, headFilter, tailFilter, input) {
|
|
82
82
|
const slices = Math.min(MAX_RAMP_SLICES, Math.max(1, Math.ceil(duration / RAMP_SLICE_SECONDS)));
|
|
83
83
|
const step = duration / slices;
|
|
84
84
|
const split = Array.from({ length: slices }, (_, i) => `[s${i}]`).join("");
|
|
85
|
-
const parts = [`[
|
|
85
|
+
const parts = [`[${input}]${headFilter ? `${headFilter},` : ""}asplit=${slices}${split}`];
|
|
86
86
|
for (let i = 0; i < slices; i += 1) {
|
|
87
87
|
const from = sourceTimeAt(lane, i * step);
|
|
88
88
|
const to = sourceTimeAt(lane, (i + 1) * step);
|
|
@@ -103,17 +103,17 @@ function buildRampFilterComplex(lane, duration, headFilter, tailFilter) {
|
|
|
103
103
|
parts.push(`[cat]${tailFilter}[out]`);
|
|
104
104
|
return parts.join(";");
|
|
105
105
|
}
|
|
106
|
-
function preparedAudioOutputArgs(metadata, playbackRate, duration, headFilter) {
|
|
106
|
+
function preparedAudioOutputArgs(metadata, playbackRate, duration, headFilter, stream) {
|
|
107
107
|
const channelFilter = metadata?.channels === 1 ? STEREO_CHANNEL_FILTER : null;
|
|
108
108
|
const outputArgs = channelFilter ? [] : ["-ac", "2"];
|
|
109
109
|
if (typeof playbackRate === "object") {
|
|
110
|
-
const graph = buildRampFilterComplex(playbackRate, duration, headFilter, channelFilter);
|
|
110
|
+
const graph = buildRampFilterComplex(playbackRate, duration, headFilter, channelFilter, stream ?? "0:a");
|
|
111
111
|
return ["-filter_complex", graph, "-map", "[out]", ...outputArgs];
|
|
112
112
|
}
|
|
113
113
|
const filters = [headFilter, channelFilter, buildAtempoFilter(playbackRate)].filter(Boolean);
|
|
114
114
|
if (filters.length > 0)
|
|
115
115
|
outputArgs.push("-af", filters.join(","));
|
|
116
|
-
return outputArgs;
|
|
116
|
+
return [...(stream ? ["-map", stream] : []), ...outputArgs];
|
|
117
117
|
}
|
|
118
118
|
function escapeExpressionCommas(expression) {
|
|
119
119
|
return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
@@ -545,7 +545,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
545
545
|
const cycle = await extractAudioSegment(srcPath, cyclePath, stage, mediaStart, loopDuration, 1, signal, config, audioSidecarUrl, { padToDuration: true });
|
|
546
546
|
if (!cycle.success)
|
|
547
547
|
return { ...cycle, outputPath };
|
|
548
|
-
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null);
|
|
548
|
+
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null, null);
|
|
549
549
|
const result = await runFfmpeg([
|
|
550
550
|
"-stream_loop",
|
|
551
551
|
"-1",
|
|
@@ -591,7 +591,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
591
591
|
...(options?.padToDuration ? ["apad"] : []),
|
|
592
592
|
]
|
|
593
593
|
.filter(Boolean)
|
|
594
|
-
.join(",") || null);
|
|
594
|
+
.join(",") || null, metadata ? `0:a:${metadata.audioStream}` : null);
|
|
595
595
|
const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
|
|
596
596
|
const args = [
|
|
597
597
|
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* the caller can fall back to the expression path rather than corrupting audio.
|
|
16
16
|
*/
|
|
17
17
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
18
|
-
import { renameWithRetry } from "
|
|
18
|
+
import { renameWithRetry } from "@hyperframes/core/rename-retry";
|
|
19
19
|
import { dirname, join } from "path";
|
|
20
20
|
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
|
|
21
21
|
import { riffChunks, wavFormatTag } from "./wavChunks.js";
|
|
@@ -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,7 +5,9 @@
|
|
|
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";
|
|
10
|
+
import { inAgentSandbox, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./sandboxLaunch.js";
|
|
9
11
|
import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
|
|
10
12
|
import { execSync } from "child_process";
|
|
11
13
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
@@ -462,7 +464,8 @@ async function probeWebGlMode(ppt, options, env, label = "") {
|
|
|
462
464
|
}
|
|
463
465
|
catch (err) {
|
|
464
466
|
logResolvedBrowserGpuMode("software", label + formatProbeFailure(err));
|
|
465
|
-
|
|
467
|
+
const aborted = err instanceof Error && err.name === "AbortError";
|
|
468
|
+
return { mode: "software", cause: "probe-error", ...(aborted && { aborted }) };
|
|
466
469
|
}
|
|
467
470
|
}
|
|
468
471
|
function envWithoutWaylandDisplay() {
|
|
@@ -524,12 +527,24 @@ function wslAccess(options) {
|
|
|
524
527
|
* safe failure mode; misclassifying toward hardware would error on the real
|
|
525
528
|
* render.
|
|
526
529
|
*/
|
|
530
|
+
async function sharedGpuProbe(options) {
|
|
531
|
+
const shared = (_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options));
|
|
532
|
+
const outcome = await shared;
|
|
533
|
+
if (!outcome.aborted)
|
|
534
|
+
return outcome;
|
|
535
|
+
if (_autoBrowserGpuModeCache === shared)
|
|
536
|
+
_autoBrowserGpuModeCache = undefined;
|
|
537
|
+
const own = await probeAutoBrowserGpuMode(options);
|
|
538
|
+
if (!own.aborted)
|
|
539
|
+
_autoBrowserGpuModeCache ??= Promise.resolve(own);
|
|
540
|
+
return own;
|
|
541
|
+
}
|
|
527
542
|
export function resolveBrowserGpuMode(mode, options = {}) {
|
|
528
543
|
if (mode === "software")
|
|
529
544
|
return Promise.resolve(mode);
|
|
530
|
-
|
|
545
|
+
const probe = sharedGpuProbe(options);
|
|
531
546
|
if (mode === "auto") {
|
|
532
|
-
return
|
|
547
|
+
return probe.then((probed) => {
|
|
533
548
|
if (probed.mode === "software" && probed.wslAccess && !_wslSoftwareNoteLogged) {
|
|
534
549
|
_wslSoftwareNoteLogged = true;
|
|
535
550
|
console.error(buildWslSoftwareNote(probed));
|
|
@@ -537,7 +552,7 @@ export function resolveBrowserGpuMode(mode, options = {}) {
|
|
|
537
552
|
return probed.mode;
|
|
538
553
|
});
|
|
539
554
|
}
|
|
540
|
-
return
|
|
555
|
+
return probe.then((probed) => {
|
|
541
556
|
// Warn once per cache lifetime, not once per caller: `createCaptureSession`
|
|
542
557
|
// resolves the mode for the probe browser AND every parallel worker, so
|
|
543
558
|
// an un-deduplicated warning prints N+1 times and buries itself.
|
|
@@ -652,13 +667,27 @@ export async function acquireBrowser(chromeArgs, config) {
|
|
|
652
667
|
const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
|
|
653
668
|
return browserLeasePool.acquire(createBrowserLaunchFingerprint(chromeArgs, config), enablePool);
|
|
654
669
|
}
|
|
670
|
+
/** An agent's macOS seatbelt allows one launch shape only: Chrome in one process over a pipe. */
|
|
671
|
+
async function launchInsideSandbox(ppt, options) {
|
|
672
|
+
const sandboxed = inAgentSandbox();
|
|
673
|
+
try {
|
|
674
|
+
return await ppt.launch(sandboxed ? oneProcessOverPipe(options) : options);
|
|
675
|
+
}
|
|
676
|
+
catch (error) {
|
|
677
|
+
const message = error instanceof Error ? error.message : "";
|
|
678
|
+
const refused = machServiceDenied(message) || pipeLaunchClosedOnMac(error);
|
|
679
|
+
if (process.platform !== "darwin" || sandboxed || !refused)
|
|
680
|
+
throw error;
|
|
681
|
+
return ppt.launch(oneProcessOverPipe(options));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
655
684
|
// fallow-ignore-next-line complexity
|
|
656
685
|
async function launchBrowser(fingerprint) {
|
|
657
686
|
const ppt = await getPuppeteer();
|
|
658
687
|
const env = launchEnv(fingerprint);
|
|
659
688
|
let captureMode = fingerprint.requestedCaptureMode;
|
|
660
689
|
let browser;
|
|
661
|
-
const launch = (args) => ppt
|
|
690
|
+
const launch = (args) => launchInsideSandbox(ppt, {
|
|
662
691
|
...ownedLaunchOptions(),
|
|
663
692
|
headless: true,
|
|
664
693
|
args: withHyperframesBrowserMarker(args),
|
|
@@ -667,6 +696,16 @@ async function launchBrowser(fingerprint) {
|
|
|
667
696
|
env,
|
|
668
697
|
timeout: fingerprint.browserTimeoutMs,
|
|
669
698
|
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
699
|
+
}).catch(async (error) => {
|
|
700
|
+
const remediation = process.platform === "darwin" &&
|
|
701
|
+
sandboxDeniedRemediation(error instanceof Error ? error.message : "");
|
|
702
|
+
if (remediation)
|
|
703
|
+
throw new Error(remediation, { cause: error });
|
|
704
|
+
const defaults = await ppt.defaultArgs({
|
|
705
|
+
headless: true,
|
|
706
|
+
args: withHyperframesBrowserMarker(args),
|
|
707
|
+
});
|
|
708
|
+
throw await explainChromeLaunchFailure(error, fingerprint.executablePath, defaults);
|
|
670
709
|
});
|
|
671
710
|
const probe = (target, timeoutMs) => probeBeginFrameSupport(target, timeoutMs).catch((error) => ({
|
|
672
711
|
supported: true,
|
|
@@ -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
|
+
}
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { createHash, randomUUID } from "node:crypto";
|
|
35
35
|
import { createReadStream, existsSync, lstatSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, } from "node:fs";
|
|
36
|
-
import { renameWithRetry } from "
|
|
36
|
+
import { renameWithRetry } from "@hyperframes/core/rename-retry";
|
|
37
37
|
import { join } from "node:path";
|
|
38
38
|
import { FRAME_FILENAME_PREFIX, framePathsFromDirectory } from "./extractedFrameIndex.js";
|
|
39
39
|
/** Filename prefix for extracted frames. Shared with the extractor. */
|
|
@@ -239,6 +239,8 @@ export interface ParallelCaptureHooks {
|
|
|
239
239
|
*/
|
|
240
240
|
onWorkerFailure?: (failure: CaptureFailure) => void;
|
|
241
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>;
|
|
242
244
|
}
|
|
243
245
|
/**
|
|
244
246
|
* The pool's single failure gate. Both halves of the contract the streaming
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { cpus, freemem } from "os";
|
|
8
8
|
import { existsSync, mkdirSync, readdirSync } from "fs";
|
|
9
9
|
import { copyFile, readFile } from "fs/promises";
|
|
10
|
-
import { renameWithRetryAsync } from "
|
|
10
|
+
import { renameWithRetryAsync } from "@hyperframes/core/rename-retry";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { getHeapStatistics } from "v8";
|
|
13
13
|
import { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBufferPipelined, captureFrameToBuffer, getCapturePerfSummary, DrawElementVerificationError, } from "./frameCapture.js";
|
|
@@ -552,7 +552,7 @@ export async function verifyDiskDrawElementSamples(session, task, streaming) {
|
|
|
552
552
|
// flagged only because the disk self-verify call shifted its line range into
|
|
553
553
|
// the changed-code audit. Not restructured by this PR.
|
|
554
554
|
// fallow-ignore-next-line complexity
|
|
555
|
-
async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, onWorkerOperation) {
|
|
555
|
+
async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCaptureHook, signal, onFrameCaptured, onFrameBuffer, config, parallel, onFailure, onWorkerPhase, onWorkerOperation, gate) {
|
|
556
556
|
const startTime = Date.now();
|
|
557
557
|
let framesCaptured = 0;
|
|
558
558
|
if (!existsSync(task.outputDir))
|
|
@@ -603,6 +603,8 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
|
|
|
603
603
|
await assertSwiftShader(activeSession.page, readWebGlVendorInfoFromCanvas);
|
|
604
604
|
}
|
|
605
605
|
await initializeSession(activeSession);
|
|
606
|
+
if (gate)
|
|
607
|
+
await (task.workerId === 0 ? gate.verify(activeSession) : gate.wait());
|
|
606
608
|
});
|
|
607
609
|
canvasDrawElement = activeSession.captureMode === "drawelement";
|
|
608
610
|
gpuBackend = activeSession.gpuRenderer ?? "unknown";
|
|
@@ -622,6 +624,8 @@ async function executeWorkerTask(task, serverUrl, captureOptions, createBeforeCa
|
|
|
622
624
|
};
|
|
623
625
|
}
|
|
624
626
|
catch (error) {
|
|
627
|
+
if (task.workerId === 0)
|
|
628
|
+
gate?.fail(error);
|
|
625
629
|
const diagnostics = session ? selectWorkerDiagnostics(session.browserConsoleBuffer) : [];
|
|
626
630
|
const workerDiagnostic = {
|
|
627
631
|
workerId: task.workerId,
|
|
@@ -687,6 +691,23 @@ export function isPoolFatalWorkerFailure(failure, streaming) {
|
|
|
687
691
|
return failure.kind !== "cancelled";
|
|
688
692
|
return isFatalCaptureFailure(failure);
|
|
689
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
|
+
}
|
|
690
711
|
function pageErrorLinesOf(session) {
|
|
691
712
|
return session.browserConsoleBuffer.filter((line) => line.includes("PAGEERROR"));
|
|
692
713
|
}
|
|
@@ -777,7 +798,8 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
|
|
|
777
798
|
hooks,
|
|
778
799
|
});
|
|
779
800
|
const onFailure = failureHandler.onFailure;
|
|
780
|
-
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)));
|
|
781
803
|
flagSilentWorkerExits(results);
|
|
782
804
|
const errors = results.filter((r) => r.failure || r.error);
|
|
783
805
|
if (errors.length > 0) {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LaunchOptions } from "puppeteer-core";
|
|
2
|
+
export declare function machServiceDenied(errorMessage: string): boolean;
|
|
3
|
+
export declare function pipeLaunchClosedOnMac(error: unknown, platform?: NodeJS.Platform): boolean;
|
|
4
|
+
/** Codex runs agent commands in a macOS seatbelt, which names itself in the environment. */
|
|
5
|
+
export declare function inAgentSandbox(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
6
|
+
/** The one launch a seatbelt allows: no child process registers a mach port, nothing listens, no GPU composites. */
|
|
7
|
+
export declare function oneProcessOverPipe(options: LaunchOptions): LaunchOptions;
|
|
8
|
+
export declare function isOneProcessOverPipe(options: LaunchOptions): boolean;
|
|
9
|
+
/** A process sandbox (an agent's macOS seatbelt) refused Chrome its mach service, so no browser starts here. */
|
|
10
|
+
export declare function sandboxDeniedRemediation(errorMessage: string): string | undefined;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const MACH_SERVICE_DENIED = /bootstrap_check_in\b[^\n]*Permission denied/;
|
|
2
|
+
const SINGLE_PROCESS = "--single-process";
|
|
3
|
+
const CPU_COMPOSITING = "--disable-gpu-compositing";
|
|
4
|
+
export function machServiceDenied(errorMessage) {
|
|
5
|
+
return MACH_SERVICE_DENIED.test(errorMessage);
|
|
6
|
+
}
|
|
7
|
+
export function pipeLaunchClosedOnMac(error, platform = process.platform) {
|
|
8
|
+
return platform === "darwin" && error instanceof Error && error.name === "TargetCloseError";
|
|
9
|
+
}
|
|
10
|
+
/** Codex runs agent commands in a macOS seatbelt, which names itself in the environment. */
|
|
11
|
+
export function inAgentSandbox(env = process.env, platform = process.platform) {
|
|
12
|
+
return platform === "darwin" && env.CODEX_SANDBOX === "seatbelt";
|
|
13
|
+
}
|
|
14
|
+
/** The one launch a seatbelt allows: no child process registers a mach port, nothing listens, no GPU composites. */
|
|
15
|
+
export function oneProcessOverPipe(options) {
|
|
16
|
+
const args = options.args ?? [];
|
|
17
|
+
const missing = [SINGLE_PROCESS, CPU_COMPOSITING].filter((flag) => !args.includes(flag));
|
|
18
|
+
return { ...options, pipe: true, args: [...args, ...missing] };
|
|
19
|
+
}
|
|
20
|
+
export function isOneProcessOverPipe(options) {
|
|
21
|
+
return options.pipe === true && (options.args ?? []).includes(SINGLE_PROCESS);
|
|
22
|
+
}
|
|
23
|
+
/** A process sandbox (an agent's macOS seatbelt) refused Chrome its mach service, so no browser starts here. */
|
|
24
|
+
export function sandboxDeniedRemediation(errorMessage) {
|
|
25
|
+
if (!machServiceDenied(errorMessage))
|
|
26
|
+
return undefined;
|
|
27
|
+
return [
|
|
28
|
+
"The browser can't start inside this sandbox: macOS refused Chrome's mach service (bootstrap_check_in: Permission denied).",
|
|
29
|
+
"Rerun this command with escalation, outside the sandbox.",
|
|
30
|
+
].join("\n");
|
|
31
|
+
}
|
|
@@ -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 &&
|
|
@@ -745,13 +764,18 @@ outputDirOverride) {
|
|
|
745
764
|
const timeoutSuffix = timedOut ? ` (no new frame for ${ffmpegProcessTimeout} ms)` : "";
|
|
746
765
|
const diagnostic = `${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ` +
|
|
747
766
|
processResult.stderr.slice(-500);
|
|
767
|
+
const ffmpegReason = ffmpegReasonFrom(processResult.stderr, [
|
|
768
|
+
videoPath,
|
|
769
|
+
basename(videoPath),
|
|
770
|
+
videoOutputDir,
|
|
771
|
+
]);
|
|
748
772
|
if (timedOut) {
|
|
749
|
-
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);
|
|
750
774
|
}
|
|
751
775
|
const transientIo = /resource temporarily unavailable|device or resource busy|input\/output error/i.test(processResult.stderr);
|
|
752
776
|
throw new VideoSourceExtractionError(transientIo ? "ffmpeg_transient" : "ffmpeg_failed", transientIo, transientIo
|
|
753
777
|
? "Video frame extraction hit a transient I/O failure"
|
|
754
|
-
: "Video source could not be decoded", diagnostic);
|
|
778
|
+
: "Video source could not be decoded", diagnostic, ffmpegReason);
|
|
755
779
|
}
|
|
756
780
|
const framePaths = framePathsFromDirectory(videoOutputDir, format, !!options.frameRanges);
|
|
757
781
|
if (framePaths.size === 0 && duration > 0) {
|
|
@@ -1223,13 +1247,9 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1223
1247
|
resolvedVideos.push({ video, videoPath });
|
|
1224
1248
|
}
|
|
1225
1249
|
catch (err) {
|
|
1226
|
-
const classified = classifyVideoExtractionError(err);
|
|
1227
1250
|
errors.push({
|
|
1228
|
-
|
|
1229
|
-
kind: classified.kind,
|
|
1230
|
-
retryable: classified.retryable,
|
|
1251
|
+
...extractionError(video.id, err),
|
|
1231
1252
|
...(err instanceof UrlDownloadError ? { group: downloadFailureGroup(video.src, err) } : {}),
|
|
1232
|
-
error: classified.diagnostic,
|
|
1233
1253
|
});
|
|
1234
1254
|
}
|
|
1235
1255
|
}
|
|
@@ -1417,6 +1437,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1417
1437
|
kind: classified.kind,
|
|
1418
1438
|
retryable: classified.retryable,
|
|
1419
1439
|
error: classified.diagnostic,
|
|
1440
|
+
...(classified.ffmpegReason ? { ffmpegReason: classified.ffmpegReason } : {}),
|
|
1420
1441
|
};
|
|
1421
1442
|
}
|
|
1422
1443
|
function scopedExtractionOptions(work) {
|
|
@@ -1775,12 +1796,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1775
1796
|
? `[shared extraction, leader ${outcome.error.videoId}] ${outcome.error.error}`
|
|
1776
1797
|
: outcome.error.error;
|
|
1777
1798
|
results.push({
|
|
1778
|
-
error: {
|
|
1779
|
-
videoId: prepared.work.video.id,
|
|
1780
|
-
kind: outcome.error.kind,
|
|
1781
|
-
retryable: outcome.error.retryable,
|
|
1782
|
-
error: message,
|
|
1783
|
-
},
|
|
1799
|
+
error: { ...outcome.error, videoId: prepared.work.video.id, error: message },
|
|
1784
1800
|
});
|
|
1785
1801
|
continue;
|
|
1786
1802
|
}
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -61,6 +61,8 @@ export interface AudioMetadata {
|
|
|
61
61
|
latestStreamLeadSeconds?: number;
|
|
62
62
|
audioStreamLeadSeconds?: number;
|
|
63
63
|
audioStreamCount?: number;
|
|
64
|
+
/** The track read for these facts and for the mix, by its place among the audio streams (decodableAudioStream). */
|
|
65
|
+
audioStream: number;
|
|
64
66
|
}
|
|
65
67
|
interface FFProbeStream {
|
|
66
68
|
codec_type: string;
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -8,6 +8,7 @@ import { basename, join } from "path";
|
|
|
8
8
|
import { redactTelemetryString } from "@hyperframes/core";
|
|
9
9
|
import { alphaDecoderFor, pixelFormatHasAlpha, videoStreamHasAlpha, } from "@hyperframes/parsers/media-alpha";
|
|
10
10
|
import { urlInputArgs } from "./urlInput.js";
|
|
11
|
+
import { decodableAudioStream } from "@hyperframes/parsers/media-streams";
|
|
11
12
|
import { runFfmpeg } from "./runFfmpeg.js";
|
|
12
13
|
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
|
|
13
14
|
import { ManagedChildProcess } from "./managedChildProcess.js";
|
|
@@ -445,7 +446,7 @@ export async function probeMediaProfile(filePath, options) {
|
|
|
445
446
|
}
|
|
446
447
|
return {
|
|
447
448
|
hasVideoStream: videoStreams.length > 0,
|
|
448
|
-
hasAudioStream: output.streams
|
|
449
|
+
hasAudioStream: decodableAudioStream(output.streams) !== null,
|
|
449
450
|
visualKind: isStillImage ? "still" : hasMovingVideoStream ? "moving" : "none",
|
|
450
451
|
};
|
|
451
452
|
}
|
|
@@ -790,7 +791,7 @@ export async function extractMediaMetadata(filePath) {
|
|
|
790
791
|
height: videoStream.height || stillImage()?.height || 0,
|
|
791
792
|
fps,
|
|
792
793
|
videoCodec: videoStream.codec_name || "unknown",
|
|
793
|
-
hasAudio: output?.streams
|
|
794
|
+
hasAudio: decodableAudioStream(output?.streams ?? []) !== null,
|
|
794
795
|
isVFR,
|
|
795
796
|
hasAlpha,
|
|
796
797
|
...(alphaPlaneDecoded === undefined ? {} : { alphaPlaneDecoded }),
|
|
@@ -956,8 +957,10 @@ export async function extractAudioMetadata(filePath, options) {
|
|
|
956
957
|
}
|
|
957
958
|
async function probeAudioMetadata(filePath, signal) {
|
|
958
959
|
const output = await probeMediaOutput(filePath, signal);
|
|
959
|
-
const
|
|
960
|
-
|
|
960
|
+
const audioStreams = output.streams.filter((s) => s.codec_type === "audio");
|
|
961
|
+
const audioStreamIndex = decodableAudioStream(output.streams);
|
|
962
|
+
const audioStream = audioStreamIndex === null ? undefined : audioStreams[audioStreamIndex];
|
|
963
|
+
if (audioStreamIndex === null || !audioStream)
|
|
961
964
|
throw new Error("[FFmpeg] No audio stream found");
|
|
962
965
|
let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
|
|
963
966
|
const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
|
|
@@ -966,7 +969,7 @@ async function probeAudioMetadata(filePath, signal) {
|
|
|
966
969
|
// Container summaries can undercount intact audio, so the decoded last frame wins.
|
|
967
970
|
// The probe returns null instead of throwing: a throw here drops the audio element.
|
|
968
971
|
if (sampleRate > 0) {
|
|
969
|
-
const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
|
|
972
|
+
const decoded = await probeDecodedAudioDuration(filePath, audioStreamIndex, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
|
|
970
973
|
if (decoded !== null) {
|
|
971
974
|
durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
|
|
972
975
|
}
|
|
@@ -980,7 +983,8 @@ async function probeAudioMetadata(filePath, signal) {
|
|
|
980
983
|
bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
|
|
981
984
|
latestStreamLeadSeconds: latestStreamLeadSeconds(output),
|
|
982
985
|
audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
|
|
983
|
-
audioStreamCount:
|
|
986
|
+
audioStreamCount: audioStreams.length,
|
|
987
|
+
audioStream: audioStreamIndex,
|
|
984
988
|
};
|
|
985
989
|
}
|
|
986
990
|
/** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
|
|
@@ -1018,11 +1022,11 @@ function reconcileAudioDuration(containerSeconds, decodedSeconds, format) {
|
|
|
1018
1022
|
* True audio duration from the last decoded frame (timestamp + nb_samples - stream start): tail
|
|
1019
1023
|
* first, then the whole file if a bad seek finds nothing. Null, never a throw, except on abort.
|
|
1020
1024
|
*/
|
|
1021
|
-
async function probeDecodedAudioDuration(filePath, sampleRate, startSeconds, containerDurationSeconds, signal) {
|
|
1025
|
+
async function probeDecodedAudioDuration(filePath, audioStream, sampleRate, startSeconds, containerDurationSeconds, signal) {
|
|
1022
1026
|
const probe = async (readInterval) => {
|
|
1023
1027
|
const args = [
|
|
1024
1028
|
"-select_streams",
|
|
1025
|
-
|
|
1029
|
+
`a:${audioStream}`,
|
|
1026
1030
|
"-show_entries",
|
|
1027
1031
|
"frame=best_effort_timestamp_time,nb_samples",
|
|
1028
1032
|
"-of",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.209",
|
|
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/
|
|
54
|
-
"@hyperframes/
|
|
57
|
+
"@hyperframes/parsers": "^0.8.209",
|
|
58
|
+
"@hyperframes/core": "^0.8.209"
|
|
55
59
|
},
|
|
56
60
|
"devDependencies": {
|
|
57
61
|
"@types/node": "^25.0.10",
|
|
@@ -1,5 +0,0 @@
|
|
|
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>;
|
|
@@ -1,23 +0,0 @@
|
|
|
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,16 +0,0 @@
|
|
|
1
|
-
interface Options {
|
|
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;
|
|
5
|
-
}
|
|
6
|
-
/** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
|
|
7
|
-
export declare function renameWithRetry(from: string, to: string, options?: Options & {
|
|
8
|
-
rename?: (from: string, to: string) => void;
|
|
9
|
-
copy?: (from: string, to: string) => void;
|
|
10
|
-
}): void;
|
|
11
|
-
/** Async twin of {@link renameWithRetry}. */
|
|
12
|
-
export declare function renameWithRetryAsync(from: string, to: string, options?: Options & {
|
|
13
|
-
rename?: (from: string, to: string) => Promise<void>;
|
|
14
|
-
copy?: (from: string, to: string) => Promise<void>;
|
|
15
|
-
}): Promise<void>;
|
|
16
|
-
export {};
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { copyFileSync, renameSync } from "node:fs";
|
|
2
|
-
import { copyFile, rename } from "node:fs/promises";
|
|
3
|
-
import { removeScratch } from "./removeScratch.js";
|
|
4
|
-
// Windows reports EPERM/EACCES/EBUSY while antivirus or a just-exited process still holds the
|
|
5
|
-
// file; the hold clears within milliseconds. Total wait before giving up: about 1.3 s.
|
|
6
|
-
const RETRYABLE = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
7
|
-
const BACKOFF_MS = [10, 20, 40, 80, 160, 320, 640];
|
|
8
|
-
function isRetryable(error, platform) {
|
|
9
|
-
if (platform !== "win32")
|
|
10
|
-
return false;
|
|
11
|
-
return RETRYABLE.has(error?.code ?? "");
|
|
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
|
-
}
|
|
21
|
-
function sleepSync(ms) {
|
|
22
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
23
|
-
}
|
|
24
|
-
/** `renameSync` that rides out Windows' transient file locks; other platforms and errors throw at once. */
|
|
25
|
-
export function renameWithRetry(from, to, options = {}) {
|
|
26
|
-
const doRename = options.rename ?? renameSync;
|
|
27
|
-
const doCopy = options.copy ?? copyFileSync;
|
|
28
|
-
const platform = options.platform ?? process.platform;
|
|
29
|
-
for (let attempt = 0;; attempt++) {
|
|
30
|
-
try {
|
|
31
|
-
doRename(from, to);
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
catch (error) {
|
|
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 {
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
48
|
-
removeScratch(from);
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
/** Async twin of {@link renameWithRetry}. */
|
|
54
|
-
export async function renameWithRetryAsync(from, to, options = {}) {
|
|
55
|
-
const doRename = options.rename ?? rename;
|
|
56
|
-
const doCopy = options.copy ?? copyFile;
|
|
57
|
-
const platform = options.platform ?? process.platform;
|
|
58
|
-
for (let attempt = 0;; attempt++) {
|
|
59
|
-
try {
|
|
60
|
-
await doRename(from, to);
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
catch (error) {
|
|
64
|
-
const step = nextStep(error, attempt, options, platform);
|
|
65
|
-
if (step === "throw")
|
|
66
|
-
throw error;
|
|
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;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|