@hyperframes/engine 0.8.208 → 0.8.210
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 +4 -3
- package/dist/index.js +4 -3
- package/dist/services/__fixtures__/loopback.js +7 -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.js +21 -4
- package/dist/services/chunkEncoder.d.ts +12 -2
- package/dist/services/chunkEncoder.js +20 -10
- package/dist/services/extractionCache.js +1 -1
- package/dist/services/parallelCoordinator.js +1 -1
- package/dist/services/sandboxLaunch.d.ts +10 -0
- package/dist/services/sandboxLaunch.js +31 -0
- package/dist/services/videoFrameExtractor.js +36 -7
- package/dist/utils/ffprobe.d.ts +4 -0
- package/dist/utils/ffprobe.js +21 -8
- package/package.json +3 -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
|
@@ -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, appendRateCapArgs, keyframeIntervalToGopSize, 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, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, type ProresProfile, 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";
|
|
@@ -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
|
@@ -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, appendRateCapArgs, keyframeIntervalToGopSize, 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, PRORES_PROFILES, PRORES_PROFILE_NAMES, isProresProfile, 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";
|
|
@@ -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";
|
|
@@ -7,12 +7,17 @@ export async function listenOnLoopback(server) {
|
|
|
7
7
|
throw new Error("No test server address");
|
|
8
8
|
return `http://127.0.0.1:${address.port}`;
|
|
9
9
|
}
|
|
10
|
-
export function launchTestChrome(executablePath) {
|
|
11
|
-
|
|
10
|
+
export async function launchTestChrome(executablePath) {
|
|
11
|
+
const startedAt = Date.now();
|
|
12
|
+
const browser = await puppeteer.launch({
|
|
12
13
|
executablePath,
|
|
13
14
|
headless: true,
|
|
14
15
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
15
16
|
});
|
|
17
|
+
const launchedAt = Date.now();
|
|
18
|
+
await (await browser.newPage()).close();
|
|
19
|
+
console.info(`[engine-test-chrome] launch ${launchedAt - startedAt} ms, first page ${Date.now() - launchedAt} ms`);
|
|
20
|
+
return browser;
|
|
16
21
|
}
|
|
17
22
|
/** Closes a test Chrome, killing it when close hangs (seen on Windows runners) so afterAll never times out. */
|
|
18
23
|
export async function closeTestChrome(browser, timeoutMs = 5_000) {
|
|
@@ -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";
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { explainChromeLaunchFailure } from "./chromeStartup.js";
|
|
9
9
|
import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker";
|
|
10
|
+
import { inAgentSandbox, machServiceDenied, oneProcessOverPipe, pipeLaunchClosedOnMac, sandboxDeniedRemediation, } from "./sandboxLaunch.js";
|
|
10
11
|
import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans";
|
|
11
12
|
import { execSync } from "child_process";
|
|
12
13
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
@@ -666,14 +667,27 @@ export async function acquireBrowser(chromeArgs, config) {
|
|
|
666
667
|
const enablePool = config?.enableBrowserPool ?? DEFAULT_CONFIG.enableBrowserPool;
|
|
667
668
|
return browserLeasePool.acquire(createBrowserLaunchFingerprint(chromeArgs, config), enablePool);
|
|
668
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
|
+
}
|
|
669
684
|
// fallow-ignore-next-line complexity
|
|
670
685
|
async function launchBrowser(fingerprint) {
|
|
671
686
|
const ppt = await getPuppeteer();
|
|
672
687
|
const env = launchEnv(fingerprint);
|
|
673
688
|
let captureMode = fingerprint.requestedCaptureMode;
|
|
674
689
|
let browser;
|
|
675
|
-
const launch = (args) => ppt
|
|
676
|
-
.launch({
|
|
690
|
+
const launch = (args) => launchInsideSandbox(ppt, {
|
|
677
691
|
...ownedLaunchOptions(),
|
|
678
692
|
headless: true,
|
|
679
693
|
args: withHyperframesBrowserMarker(args),
|
|
@@ -682,8 +696,11 @@ async function launchBrowser(fingerprint) {
|
|
|
682
696
|
env,
|
|
683
697
|
timeout: fingerprint.browserTimeoutMs,
|
|
684
698
|
protocolTimeout: fingerprint.protocolTimeoutMs,
|
|
685
|
-
})
|
|
686
|
-
.
|
|
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 });
|
|
687
704
|
const defaults = await ppt.defaultArgs({
|
|
688
705
|
headless: true,
|
|
689
706
|
args: withHyperframesBrowserMarker(args),
|
|
@@ -54,14 +54,24 @@ export interface MuxVideoWithAudioOptions extends Partial<Pick<EngineConfig, "ff
|
|
|
54
54
|
*/
|
|
55
55
|
preserveAudioPrimingEditList?: boolean;
|
|
56
56
|
}
|
|
57
|
+
/** ProRes profile names in the order users are told about them. */
|
|
58
|
+
export declare const PRORES_PROFILE_NAMES: readonly ["4444", "hq", "422", "lt", "proxy"];
|
|
59
|
+
export type ProresProfile = (typeof PRORES_PROFILE_NAMES)[number];
|
|
60
|
+
/** The one table for `-profile:v` and pixel format; all but 4444 are 4:2:2 without alpha. */
|
|
61
|
+
export declare const PRORES_PROFILES: Record<ProresProfile, {
|
|
62
|
+
profile: string;
|
|
63
|
+
pixelFormat: string;
|
|
64
|
+
alpha: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
export declare function isProresProfile(value: unknown): value is ProresProfile;
|
|
57
67
|
/**
|
|
58
68
|
* Get encoder preset for a given quality and output format.
|
|
59
69
|
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
|
|
60
|
-
* MOV uses ProRes 4444 with alpha for editor-compatible transparency.
|
|
70
|
+
* MOV uses ProRes 4444 with alpha for editor-compatible transparency, or a 422 profile without alpha.
|
|
61
71
|
*/
|
|
62
72
|
export declare function getEncoderPreset(quality: "draft" | "standard" | "high", format?: "mp4" | "webm" | "mov", hdr?: {
|
|
63
73
|
transfer: HdrTransfer;
|
|
64
|
-
}): EncoderPreset;
|
|
74
|
+
}, proresProfile?: ProresProfile): EncoderPreset;
|
|
65
75
|
export { detectGpuEncoder, type GpuEncoder } from "../utils/gpuEncoder.js";
|
|
66
76
|
/** The `lockGopForChunkConcat` / `gopSize` pair, shared by every encoder entry point. */
|
|
67
77
|
export interface LockedGopOptions {
|
|
@@ -59,12 +59,25 @@ async function shouldCopyAacSidecar(audioPath, options) {
|
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
/** ProRes profile names in the order users are told about them. */
|
|
63
|
+
export const PRORES_PROFILE_NAMES = ["4444", "hq", "422", "lt", "proxy"];
|
|
64
|
+
/** The one table for `-profile:v` and pixel format; all but 4444 are 4:2:2 without alpha. */
|
|
65
|
+
export const PRORES_PROFILES = {
|
|
66
|
+
"4444": { profile: "4444", pixelFormat: "yuva444p10le", alpha: true },
|
|
67
|
+
hq: { profile: "3", pixelFormat: "yuv422p10le", alpha: false },
|
|
68
|
+
"422": { profile: "2", pixelFormat: "yuv422p10le", alpha: false },
|
|
69
|
+
lt: { profile: "1", pixelFormat: "yuv422p10le", alpha: false },
|
|
70
|
+
proxy: { profile: "0", pixelFormat: "yuv422p10le", alpha: false },
|
|
71
|
+
};
|
|
72
|
+
export function isProresProfile(value) {
|
|
73
|
+
return typeof value === "string" && PRORES_PROFILE_NAMES.includes(value);
|
|
74
|
+
}
|
|
62
75
|
/**
|
|
63
76
|
* Get encoder preset for a given quality and output format.
|
|
64
77
|
* WebM uses VP9 with alpha-capable pixel format; MP4 uses h264 (or h265 for HDR);
|
|
65
|
-
* MOV uses ProRes 4444 with alpha for editor-compatible transparency.
|
|
78
|
+
* MOV uses ProRes 4444 with alpha for editor-compatible transparency, or a 422 profile without alpha.
|
|
66
79
|
*/
|
|
67
|
-
export function getEncoderPreset(quality, format = "mp4", hdr) {
|
|
80
|
+
export function getEncoderPreset(quality, format = "mp4", hdr, proresProfile = "4444") {
|
|
68
81
|
const base = ENCODER_PRESETS[quality];
|
|
69
82
|
if (format === "webm") {
|
|
70
83
|
return {
|
|
@@ -75,12 +88,8 @@ export function getEncoderPreset(quality, format = "mp4", hdr) {
|
|
|
75
88
|
};
|
|
76
89
|
}
|
|
77
90
|
if (format === "mov") {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
quality: base.quality,
|
|
81
|
-
codec: "prores",
|
|
82
|
-
pixelFormat: "yuva444p10le",
|
|
83
|
-
};
|
|
91
|
+
const { profile, pixelFormat } = PRORES_PROFILES[proresProfile];
|
|
92
|
+
return { preset: profile, quality: base.quality, codec: "prores", pixelFormat };
|
|
84
93
|
}
|
|
85
94
|
if (hdr) {
|
|
86
95
|
return {
|
|
@@ -635,8 +644,9 @@ export async function muxVideoWithAudio(videoPath, audioPath, outputPath, signal
|
|
|
635
644
|
const outputDir = dirname(outputPath);
|
|
636
645
|
if (!existsSync(outputDir))
|
|
637
646
|
mkdirSync(outputDir, { recursive: true });
|
|
638
|
-
const
|
|
639
|
-
const
|
|
647
|
+
const outputExtension = extname(outputPath).toLowerCase();
|
|
648
|
+
const isWebm = outputExtension === ".webm";
|
|
649
|
+
const isMov = outputExtension === ".mov";
|
|
640
650
|
const shouldCopyAudio = isWebm ? false : await shouldCopyAacSidecar(audioPath, config);
|
|
641
651
|
const args = [...(config?.videoInputArgs ?? ["-i", videoPath]), "-i", audioPath, "-c:v", "copy"];
|
|
642
652
|
if (isWebm) {
|
|
@@ -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. */
|
|
@@ -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";
|
|
@@ -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
|
+
}
|
|
@@ -17,7 +17,7 @@ import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
|
|
|
17
17
|
import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, mediaElementTime, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
|
|
18
18
|
import { resolveReferencedStart } from "./referenceResolver.js";
|
|
19
19
|
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
|
|
20
|
-
import { extractFinalVideoFrameTimestamp, extractMediaMetadata, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, writeMetadataCache, } from "../utils/ffprobe.js";
|
|
20
|
+
import { extractFinalVideoFrameTimestamp, extractMediaMetadata, keyframeGapSeconds, mediaFileIdentity, readMetadataCache, readStoredFinalFrame, resolvePlayableVideoDuration, writeMetadataCache, } from "../utils/ffprobe.js";
|
|
21
21
|
import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
|
|
22
22
|
import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
|
|
23
23
|
import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
|
|
@@ -508,6 +508,22 @@ export function sdrCanvasColourFilters(metadata, toneMappedToSdr = false) {
|
|
|
508
508
|
* frames are byte-identical to it.
|
|
509
509
|
*/
|
|
510
510
|
const EXTRACTION_SEGMENT_SECONDS = 120;
|
|
511
|
+
/** Slowest decode rate the stall clock allows while ffmpeg discards frames from a keyframe up to the seek point. */
|
|
512
|
+
const SKIP_DECODE_FPS_FLOOR = 30;
|
|
513
|
+
async function leadInSecondsUnlessAborted(videoPath, seek, signal) {
|
|
514
|
+
try {
|
|
515
|
+
return await keyframeGapSeconds(videoPath, seek, signal);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
// Aborted: let runFfmpeg report the abort, so a segment stopped by another's failure reads as stopped.
|
|
519
|
+
if (signal?.aborted)
|
|
520
|
+
return 0;
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function stallTimeoutMs(baseMs, skippedSeconds, sourceFps) {
|
|
525
|
+
return baseMs + Math.ceil((skippedSeconds * sourceFps * 1000) / SKIP_DECODE_FPS_FLOOR);
|
|
526
|
+
}
|
|
511
527
|
/** Frame times since file start are `PTS*TB + seek`; snapping them to `lead + k/FR` gives every process one grid. */
|
|
512
528
|
function undoTickRoundingOnTheSourceFrameGrid(seek, lead) {
|
|
513
529
|
const sinceFileStart = `(PTS*TB+${seek}-${lead})`;
|
|
@@ -535,7 +551,7 @@ function splitFrameRanges(ranges, segmentFrames) {
|
|
|
535
551
|
return segments;
|
|
536
552
|
}
|
|
537
553
|
async function runSegmentedExtraction(job) {
|
|
538
|
-
const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, segments, gate } = job;
|
|
554
|
+
const { decodeArgs, filterAndEncodeArgs, videoPath, startTime, fps, sourceFps, segments, gate } = job;
|
|
539
555
|
const segmentCount = segments.length;
|
|
540
556
|
// One failed segment fails the range, so stop the others instead of finishing them.
|
|
541
557
|
const failed = new AbortController();
|
|
@@ -549,7 +565,7 @@ async function runSegmentedExtraction(job) {
|
|
|
549
565
|
const index = next++;
|
|
550
566
|
const { firstFrame, frames } = segments[index];
|
|
551
567
|
const seek = startTime + firstFrame / fps;
|
|
552
|
-
const result = await gate(() => runFfmpeg([
|
|
568
|
+
const result = await gate(async () => runFfmpeg([
|
|
553
569
|
...decodeArgs,
|
|
554
570
|
"-noaccurate_seek",
|
|
555
571
|
"-ss",
|
|
@@ -564,14 +580,21 @@ async function runSegmentedExtraction(job) {
|
|
|
564
580
|
"-start_number",
|
|
565
581
|
String(firstFrame + 1),
|
|
566
582
|
...filterAndEncodeArgs(seek),
|
|
567
|
-
], {
|
|
583
|
+
], {
|
|
584
|
+
...job.runOptions,
|
|
585
|
+
signal,
|
|
586
|
+
timeout: stallTimeoutMs(job.runOptions.timeout, await leadInSecondsUnlessAborted(videoPath, seek, signal), sourceFps),
|
|
587
|
+
}), signal);
|
|
568
588
|
results[index] = result;
|
|
569
589
|
if (!result.success)
|
|
570
590
|
failed.abort();
|
|
571
591
|
}
|
|
572
592
|
};
|
|
573
593
|
const workers = Math.min(segmentCount, EXTRACTION_SLOTS);
|
|
574
|
-
await Promise.all(Array.from({ length: workers }, worker))
|
|
594
|
+
await Promise.all(Array.from({ length: workers }, worker)).catch((error) => {
|
|
595
|
+
failed.abort();
|
|
596
|
+
throw error;
|
|
597
|
+
});
|
|
575
598
|
const ran = results.filter(Boolean);
|
|
576
599
|
// Report the segment that failed, not the ones stopped because of it.
|
|
577
600
|
const failure = ran.find((r) => !r.success && r.terminationReason !== "abort") ?? ran.find((r) => !r.success);
|
|
@@ -731,11 +754,17 @@ outputDirOverride) {
|
|
|
731
754
|
videoPath,
|
|
732
755
|
startTime,
|
|
733
756
|
fps,
|
|
757
|
+
sourceFps: metadata.fps,
|
|
734
758
|
gate: options.tailProbe ? inTailProbeSlot : inExtractionSlot,
|
|
735
759
|
segments: splitFrameRanges(options.frameRanges ?? [{ firstFrame: 0, frames: totalFrames }], segmentFrames),
|
|
736
760
|
runOptions,
|
|
737
761
|
})
|
|
738
|
-
: inExtractionSlot(() => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs],
|
|
762
|
+
: inExtractionSlot(async () => runFfmpeg([...hwaccel, ...args, ...filterArgsFor(startTime), ...encodeArgs], {
|
|
763
|
+
...runOptions,
|
|
764
|
+
timeout: stallTimeoutMs(ffmpegProcessTimeout, options.finalFrameOnly
|
|
765
|
+
? startTime
|
|
766
|
+
: await leadInSecondsUnlessAborted(videoPath, startTime, signal), metadata.fps),
|
|
767
|
+
}), signal);
|
|
739
768
|
let processResult = await extract(decodeWithVideoToolbox ? ["-hwaccel", "videotoolbox"] : []);
|
|
740
769
|
const ffmpegExitedFailing = processResult.terminationReason === "exit" &&
|
|
741
770
|
!processResult.success &&
|
|
@@ -1349,7 +1378,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1349
1378
|
// Phase 3 still calls `extractVideoFramesRange` on the same past-EOF
|
|
1350
1379
|
// mediaStart, surfacing a second raw FFmpeg error for the same clip.
|
|
1351
1380
|
const hdrSkippedIndices = new Set();
|
|
1352
|
-
if (hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
|
|
1381
|
+
if (!options.toneMapHdrToSdr && hdrInfo.hasHdr && hdrInfo.dominantTransfer) {
|
|
1353
1382
|
// dominantTransfer is "majority wins" — if a composition mixes PQ and HLG
|
|
1354
1383
|
// sources (rare but legal), the minority transfer's videos get converted
|
|
1355
1384
|
// with the wrong curve. We treat this as caller-error: a single composition
|
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;
|
|
@@ -217,4 +219,6 @@ export interface KeyframeAnalysis {
|
|
|
217
219
|
* Pass the stream's own duration/start (not the container's), which can overstate a single-keyframe GOP.
|
|
218
220
|
*/
|
|
219
221
|
export declare function analyzeKeyframeIntervals(filePath: string, metadata: Pick<VideoMetadata, "videoStreamDurationSeconds" | "videoStreamStartSeconds">): Promise<KeyframeAnalysis>;
|
|
222
|
+
/** Seconds ffmpeg decodes without output after a seek to `seekSeconds`: back to the keyframe it lands on. */
|
|
223
|
+
export declare function keyframeGapSeconds(filePath: string, seekSeconds: number, signal?: AbortSignal): Promise<number>;
|
|
220
224
|
export {};
|
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",
|
|
@@ -1080,6 +1084,15 @@ export async function analyzeKeyframeIntervals(filePath, metadata) {
|
|
|
1080
1084
|
});
|
|
1081
1085
|
return promise;
|
|
1082
1086
|
}
|
|
1087
|
+
/** Seconds ffmpeg decodes without output after a seek to `seekSeconds`: back to the keyframe it lands on. */
|
|
1088
|
+
export async function keyframeGapSeconds(filePath, seekSeconds, signal) {
|
|
1089
|
+
const stdout = await runFfprobe(filePath, [
|
|
1090
|
+
...["-select_streams", "v:0", "-show_entries", "packet=pts_time", "-of", "csv=p=0"],
|
|
1091
|
+
...["-read_intervals", `${seekSeconds}%+#1`],
|
|
1092
|
+
], signal);
|
|
1093
|
+
const keyframe = parseFloat(stdout.split("\n")[0] ?? "");
|
|
1094
|
+
return Number.isFinite(keyframe) ? Math.max(0, seekSeconds - keyframe) : 0;
|
|
1095
|
+
}
|
|
1083
1096
|
async function analyzeKeyframeIntervalsUncached(filePath, videoStreamStartSeconds, videoStreamDurationSeconds) {
|
|
1084
1097
|
const stdout = await runFfprobe(filePath, [
|
|
1085
1098
|
"-select_streams",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.210",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"linkedom": "^0.18.12",
|
|
55
55
|
"puppeteer": "^25.8.0",
|
|
56
56
|
"puppeteer-core": "^25.8.0",
|
|
57
|
-
"@hyperframes/core": "^0.8.
|
|
58
|
-
"@hyperframes/parsers": "^0.8.
|
|
57
|
+
"@hyperframes/core": "^0.8.210",
|
|
58
|
+
"@hyperframes/parsers": "^0.8.210"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
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
|
-
}
|