@hyperframes/engine 0.8.180 → 0.8.183
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/config.d.ts +1 -1
- package/dist/config.js +0 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/services/aacEncode.d.ts +4 -3
- package/dist/services/aacEncode.js +21 -14
- package/dist/services/audioMixer.d.ts +1 -0
- package/dist/services/audioMixer.js +44 -16
- package/dist/services/browserManager.js +6 -14
- package/dist/services/chromeHostCeiling.d.ts +5 -0
- package/dist/services/chromeHostCeiling.js +9 -0
- package/dist/services/chunkEncoder.js +25 -5
- package/dist/services/frameCapture.d.ts +1 -0
- package/dist/services/frameCapture.js +21 -41
- package/dist/services/referenceResolver.d.ts +1 -38
- package/dist/services/referenceResolver.js +2 -87
- package/dist/services/videoFrameExtractor.d.ts +4 -0
- package/dist/services/videoFrameExtractor.js +36 -16
- package/dist/services/videoFrameInjector.d.ts +9 -0
- package/dist/services/videoFrameInjector.js +48 -1
- package/dist/utils/ffprobe.d.ts +43 -0
- package/dist/utils/ffprobe.js +3 -3
- package/dist/utils/notMediaPayload.d.ts +2 -2
- package/dist/utils/notMediaPayload.js +2 -2
- package/dist/utils/runFfmpeg.d.ts +2 -0
- package/dist/utils/runFfmpeg.js +17 -3
- package/package.json +3 -3
package/dist/config.d.ts
CHANGED
|
@@ -165,7 +165,7 @@ export interface EngineConfig {
|
|
|
165
165
|
streamingEncodeDurationCapEnabled: boolean;
|
|
166
166
|
/** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */
|
|
167
167
|
ffmpegEncodeTimeout: number;
|
|
168
|
-
/**
|
|
168
|
+
/** FFmpeg mux/faststart timeout (ms); extraction waits this long for each new frame. Default: 300_000 */
|
|
169
169
|
ffmpegProcessTimeout: number;
|
|
170
170
|
/**
|
|
171
171
|
* Inactivity timeout for FFmpeg streaming encode (ms). The timer resets on
|
package/dist/config.js
CHANGED
|
@@ -497,7 +497,6 @@ export function resolveConfig(overrides) {
|
|
|
497
497
|
coresPerWorker: envNum("PRODUCER_CORES_PER_WORKER", DEFAULT_CONFIG.coresPerWorker),
|
|
498
498
|
minParallelFrames: envNum("PRODUCER_MIN_PARALLEL_FRAMES", DEFAULT_CONFIG.minParallelFrames),
|
|
499
499
|
largeRenderThreshold: envNum("PRODUCER_LARGE_RENDER_THRESHOLD", DEFAULT_CONFIG.largeRenderThreshold),
|
|
500
|
-
chromePath: env("PRODUCER_HEADLESS_SHELL_PATH"),
|
|
501
500
|
disableGpu: envBool("PRODUCER_DISABLE_GPU", DEFAULT_CONFIG.disableGpu),
|
|
502
501
|
browserGpuMode: envBrowserGpuMode(),
|
|
503
502
|
allowSoftwareWebGpu: envBool("PRODUCER_ALLOW_SOFTWARE_WEBGPU", DEFAULT_CONFIG.allowSoftwareWebGpu),
|
package/dist/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export { chromeMajorCeiling } from "./services/chromeHostCeiling.js";
|
|
|
38
38
|
export { acquireBrowser, setHostHandlesSigint, releaseBrowser, drainBrowserPool, closeBrowserPool, resolveHeadlessShellPath, resolveBrowserGpuMode, buildChromeArgs, compositionRequiresWebGpu, assertWebGpuAdapterAvailable, usesSoftwareWebGpu, WebGpuUnavailableError, ENABLE_BROWSER_POOL, BrowserLeasePool, type GpuProbeLifecycle, type BuildChromeArgsOptions, type BrowserLaunchFingerprint, type BrowserLease, type BrowserPoolState, type CaptureMode, type AcquiredBrowser, } from "./services/browserManager.js";
|
|
39
39
|
export { augmentProtocolTimeoutError, isProtocolTimeoutError, } from "./services/protocolTimeoutErrorHint.js";
|
|
40
40
|
export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, type NavigationTimeoutHintContext, } from "./services/pageNavigationTimeoutErrorHint.js";
|
|
41
|
-
export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, } from "./services/frameCapture.js";
|
|
41
|
+
export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, type DrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, type CaptureSession, isTransientBrowserError, isMemoryExhaustionError, type BeforeCaptureHook, type DiscardWarmupInnerCapture, type StaticVerificationOutcome, VfxFailureError, waitForImagesReady, } from "./services/frameCapture.js";
|
|
42
42
|
export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, type CaptureFailureKind, type CaptureWorkerDiagnostic, } from "./services/captureFailure.js";
|
|
43
43
|
export { createChromeMemorySampler, type ChromeMemoryStats, } from "./services/chromeMemorySampler.js";
|
|
44
44
|
export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFramesBatch, syncVideoFrameVisibility, cdpSessionCache, probeBeginFrameLiveness, initTransparentBackground, captureAlphaPng, applyDomLayerMask, removeDomLayerMask, DOM_LAYER_MASK_STYLE_ID, type BeginFrameResult, } from "./services/screenshotService.js";
|
|
@@ -64,6 +64,7 @@ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimes
|
|
|
64
64
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
65
65
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
|
|
66
66
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
|
|
67
|
+
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
67
68
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
|
68
69
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
69
70
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
@@ -74,7 +75,7 @@ export { diffGpuParityFrames, diffGpuParityPngs, verifyGpuParity, type RgbaFrame
|
|
|
74
75
|
export { type TransitionFn, TRANSITIONS, crossfade, sampleRgb48le, hdrToLinear, linearToHdr, convertTransfer, } from "./utils/shaderTransitions.js";
|
|
75
76
|
export { initHdrReadback, uploadAndReadbackHdrFrame, float16ToPqRgb, buildHdrChromeArgs, launchHdrBrowser, } from "./services/hdrCapture.js";
|
|
76
77
|
export { captureScreenshotWithAlpha } from "./services/screenshotService.js";
|
|
77
|
-
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, type VideoElementBounds, type ElementStackingInfo, } from "./services/videoFrameInjector.js";
|
|
78
|
+
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, measureVideoShownBoxes, type ShownBox, type VideoElementBounds, type ElementStackingInfo, } from "./services/videoFrameInjector.js";
|
|
78
79
|
export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompositionHdr, findHdrAutoPromotion, formatHdrAutoPromotionWarning, sanitizeHdrAutoPromotionAsset, DEFAULT_HDR10_MASTERING, HDR_AUTO_PROMOTION_PIPELINE, type HdrTransfer, type HdrEncoderColorParams, type HdrAutoPromotion, type CompositionHdrInfo, type HdrMasteringMetadata, } from "./utils/hdr.js";
|
|
79
80
|
export type { VideoColorSpace } from "./utils/ffprobe.js";
|
|
80
81
|
export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, type RenderProvenance, } from "./utils/renderProvenance.js";
|
package/dist/index.js
CHANGED
|
@@ -40,7 +40,7 @@ export { acquireBrowser, setHostHandlesSigint, releaseBrowser, drainBrowserPool,
|
|
|
40
40
|
export { augmentProtocolTimeoutError, isProtocolTimeoutError, } from "./services/protocolTimeoutErrorHint.js";
|
|
41
41
|
export { augmentPageNavigationTimeoutError, isPageNavigationTimeoutError, } from "./services/pageNavigationTimeoutErrorHint.js";
|
|
42
42
|
// ── Frame capture pipeline ──────────────────────────────────────────────────────
|
|
43
|
-
export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, isTransientBrowserError, isMemoryExhaustionError, VfxFailureError, } from "./services/frameCapture.js";
|
|
43
|
+
export { createCaptureSession, initializeSession, closeCaptureSession, captureFrame, captureFrameToBuffer, captureFrameToBufferPipelined, captureFramesBatchPipelined, DrawElementVerificationError, isDrawElementVerificationError, getDrawElementVerificationDetails, recaptureDrawElementFrameForVerify, completeDeferredDrawElementInit, writeCapturedFrame, discardWarmupCapture, getCompositionDuration, getCapturePerfSummary, percentileOf, prepareCaptureSessionForReuse, deriveBeginFrameProbeTimeTicks, isTransientBrowserError, isMemoryExhaustionError, VfxFailureError, waitForImagesReady, } from "./services/frameCapture.js";
|
|
44
44
|
export { CaptureFailure, classifyCaptureFailure, isFatalCaptureFailure, } from "./services/captureFailure.js";
|
|
45
45
|
export { createChromeMemorySampler, } from "./services/chromeMemorySampler.js";
|
|
46
46
|
// ── Screenshot (BeginFrame) ─────────────────────────────────────────────────────
|
|
@@ -69,6 +69,7 @@ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimes
|
|
|
69
69
|
export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
|
|
70
70
|
export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
|
|
71
71
|
export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
|
|
72
|
+
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
72
73
|
export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
|
|
73
74
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
74
75
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
@@ -82,7 +83,7 @@ export { diffGpuParityFrames, diffGpuParityPngs, verifyGpuParity, } from "./util
|
|
|
82
83
|
export { TRANSITIONS, crossfade, sampleRgb48le, hdrToLinear, linearToHdr, convertTransfer, } from "./utils/shaderTransitions.js";
|
|
83
84
|
export { initHdrReadback, uploadAndReadbackHdrFrame, float16ToPqRgb, buildHdrChromeArgs, launchHdrBrowser, } from "./services/hdrCapture.js";
|
|
84
85
|
export { captureScreenshotWithAlpha } from "./services/screenshotService.js";
|
|
85
|
-
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, } from "./services/videoFrameInjector.js";
|
|
86
|
+
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, measureVideoShownBoxes, } from "./services/videoFrameInjector.js";
|
|
86
87
|
export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompositionHdr, findHdrAutoPromotion, formatHdrAutoPromotionWarning, sanitizeHdrAutoPromotionAsset, DEFAULT_HDR10_MASTERING, HDR_AUTO_PROMOTION_PIPELINE, } from "./utils/hdr.js";
|
|
87
88
|
export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, } from "./utils/renderProvenance.js";
|
|
88
89
|
export { DrawElementCaptureError, isDrawElementCaptureError, } from "./services/drawElementCaptureError.js";
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* so long audio is encoded as frame-aligned segments in parallel and their frames joined.
|
|
4
4
|
*/
|
|
5
5
|
import { type RunFfmpegResult } from "../utils/runFfmpeg.js";
|
|
6
|
-
export declare const AAC_ARGS: string[];
|
|
7
6
|
export declare function aacSegmentCount(totalDuration: number): number;
|
|
8
7
|
export declare function splitAdtsFrames(bytes: Buffer): Buffer[];
|
|
9
|
-
/** `input` is
|
|
10
|
-
export declare function encodeAac(input: string, outputPath: string, totalDuration: number, { signal, timeout }: {
|
|
8
|
+
/** `input`, after `filter`, is audio of exactly `totalDuration`. */
|
|
9
|
+
export declare function encodeAac(input: string, outputPath: string, totalDuration: number, { signal, timeout, filter, segments: count, }: {
|
|
11
10
|
signal?: AbortSignal;
|
|
12
11
|
timeout: number;
|
|
12
|
+
filter?: string;
|
|
13
|
+
segments?: number;
|
|
13
14
|
}): Promise<RunFfmpegResult>;
|
|
@@ -10,11 +10,11 @@ const FRAME_SAMPLES = 1024;
|
|
|
10
10
|
const MIN_SEGMENT_SECONDS = 60;
|
|
11
11
|
// Fixed rather than the core count, so the bytes (and the planHash) do not depend on the machine.
|
|
12
12
|
const MAX_SEGMENTS = 8;
|
|
13
|
-
// Encoded
|
|
14
|
-
//
|
|
13
|
+
// Encoded around each segment and dropped: rate control settles over ~10 s, and opening cold on
|
|
14
|
+
// quiet sound codes the first loud frame up to 4.5 dB hot, so the first segment opens in silence.
|
|
15
15
|
const PRE_ROLL_FRAMES = 469;
|
|
16
16
|
const POST_ROLL_FRAMES = 8;
|
|
17
|
-
|
|
17
|
+
const AAC_ARGS = ["-acodec", "aac", "-b:a", "192k"];
|
|
18
18
|
export function aacSegmentCount(totalDuration) {
|
|
19
19
|
return Math.max(1, Math.min(MAX_SEGMENTS, Math.floor(totalDuration / MIN_SEGMENT_SECONDS)));
|
|
20
20
|
}
|
|
@@ -40,26 +40,33 @@ function planSegments(totalDuration, count) {
|
|
|
40
40
|
const cuts = Array.from({ length: count + 1 }, (_, k) => Math.round((k * packets) / count));
|
|
41
41
|
return cuts.slice(0, -1).map((cut, k) => {
|
|
42
42
|
const next = cuts[k + 1];
|
|
43
|
-
const firstFrame =
|
|
44
|
-
const end = k === count - 1 ? "" :
|
|
45
|
-
const trim =
|
|
43
|
+
const firstFrame = cut - PRE_ROLL_FRAMES;
|
|
44
|
+
const end = k === count - 1 ? "" : `end_sample=${(next + POST_ROLL_FRAMES) * FRAME_SAMPLES}`;
|
|
45
|
+
const trim = firstFrame < 0
|
|
46
|
+
? `${end && `atrim=${end},`}adelay=delays=${-firstFrame * FRAME_SAMPLES}S:all=1`
|
|
47
|
+
: `atrim=start_sample=${firstFrame * FRAME_SAMPLES}${end && `:${end}`}`;
|
|
46
48
|
return { trim, keepFrom: cut - firstFrame, keepTo: next - firstFrame };
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
|
-
/** `input` is
|
|
50
|
-
export async function encodeAac(input, outputPath, totalDuration, { signal, timeout }) {
|
|
51
|
-
const count = aacSegmentCount(totalDuration);
|
|
52
|
-
if (count === 1) {
|
|
53
|
-
const args = ["-i", input, "-t", String(totalDuration), ...AAC_ARGS, "-y", outputPath];
|
|
54
|
-
return runFfmpeg(args, { signal, timeout });
|
|
55
|
-
}
|
|
51
|
+
/** `input`, after `filter`, is audio of exactly `totalDuration`. */
|
|
52
|
+
export async function encodeAac(input, outputPath, totalDuration, { signal, timeout, filter, segments: count = aacSegmentCount(totalDuration), }) {
|
|
56
53
|
const dir = mkdtempSync(join(dirname(outputPath), ".aac-segments-"));
|
|
57
54
|
try {
|
|
58
55
|
const segments = planSegments(totalDuration, count).map((segment, k) => ({
|
|
59
56
|
...segment,
|
|
60
57
|
path: join(dir, `${k}.aac`),
|
|
61
58
|
}));
|
|
62
|
-
const results = await Promise.all(segments.map(({ trim, path }) => runFfmpeg([
|
|
59
|
+
const results = await Promise.all(segments.map(({ trim, path }) => runFfmpeg([
|
|
60
|
+
"-i",
|
|
61
|
+
input,
|
|
62
|
+
"-af",
|
|
63
|
+
[filter, `aresample=${SAMPLE_RATE}`, trim].filter(Boolean).join(","),
|
|
64
|
+
...AAC_ARGS,
|
|
65
|
+
"-f",
|
|
66
|
+
"adts",
|
|
67
|
+
"-y",
|
|
68
|
+
path,
|
|
69
|
+
], {
|
|
63
70
|
signal,
|
|
64
71
|
timeout,
|
|
65
72
|
})));
|
|
@@ -34,4 +34,5 @@ export declare function processCompositionAudio(elements: AudioElement[], baseDi
|
|
|
34
34
|
/** Serialised master bus chain, from `parseMasterFxChain`. */
|
|
35
35
|
masterFxChain?: string;
|
|
36
36
|
urlByAbsentLocalPath?: ReadonlyMap<string, string>;
|
|
37
|
+
keepEncoderInputAt?: string;
|
|
37
38
|
}, compiledDir?: string): Promise<MixResult>;
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// fallow-ignore-file complexity code-duplication
|
|
2
2
|
import { isSelfOrAncestorHidden, memberGroupKey, isMemberGroupHidden } from "./mediaHidden.js";
|
|
3
|
-
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeFileSync } from "fs";
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, writeFileSync, } from "fs";
|
|
4
4
|
import { join, dirname, isAbsolute, relative } from "path";
|
|
5
5
|
import { parseHTML } from "linkedom";
|
|
6
6
|
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
7
7
|
import { isNotMediaPayload } from "../utils/notMediaPayload.js";
|
|
8
|
+
import { urlInputArgs } from "../utils/urlInput.js";
|
|
8
9
|
import { clampAudioGain } from "@hyperframes/core/audio-gain";
|
|
9
10
|
import { clampFadesToDuration, readElementFades } from "@hyperframes/core/audio-fade";
|
|
10
11
|
import { downloadToTemp, isHttpUrl, UrlDownloadError, writeUrlDownloadTelemetry, } from "../utils/urlDownloader.js";
|
|
11
12
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
12
13
|
import { formatFfmpegError, runFfmpeg } from "../utils/runFfmpeg.js";
|
|
13
|
-
import {
|
|
14
|
+
import { encodeAac } from "./aacEncode.js";
|
|
14
15
|
import { audioSubprocessEvidence } from "./audioFailureDiagnostic.js";
|
|
15
16
|
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
16
17
|
import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
|
|
@@ -492,6 +493,7 @@ export function parseAudioElements(html) {
|
|
|
492
493
|
}
|
|
493
494
|
return elements;
|
|
494
495
|
}
|
|
496
|
+
const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
|
|
495
497
|
/** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
|
|
496
498
|
* started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
|
|
497
499
|
async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config) {
|
|
@@ -501,17 +503,29 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
501
503
|
mkdirSync(outputDir, { recursive: true });
|
|
502
504
|
const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
|
|
503
505
|
const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
|
|
504
|
-
// A failed probe keeps FFmpeg's default channel conversion and the input seek
|
|
505
|
-
|
|
506
|
+
// A failed local probe keeps FFmpeg's default channel conversion and the input seek; a URL probe
|
|
507
|
+
// is retried once, then fails: a blip must not change how the next, successful read is mixed.
|
|
508
|
+
const readsUrl = isHttpUrl(srcPath);
|
|
509
|
+
const metadata = readsUrl
|
|
510
|
+
? await extractAudioMetadata(srcPath, { signal }).catch((error) => {
|
|
511
|
+
if (signal?.aborted)
|
|
512
|
+
throw error;
|
|
513
|
+
return extractAudioMetadata(srcPath, { signal });
|
|
514
|
+
})
|
|
515
|
+
: await extractAudioMetadata(srcPath).catch(() => null);
|
|
506
516
|
const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
|
|
507
517
|
const start = formatFilterNumber(mediaStart);
|
|
508
518
|
const timestampTrim = inputSeek
|
|
509
519
|
? null
|
|
510
520
|
: `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
|
|
511
521
|
const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, timestampTrim);
|
|
512
|
-
const args =
|
|
513
|
-
?
|
|
514
|
-
|
|
522
|
+
const args = [
|
|
523
|
+
...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
|
|
524
|
+
...urlInputArgs(srcPath),
|
|
525
|
+
...(inputSeek ? ["-ss", String(mediaStart), "-t", String(sourceDuration)] : []),
|
|
526
|
+
"-i",
|
|
527
|
+
srcPath,
|
|
528
|
+
];
|
|
515
529
|
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
|
|
516
530
|
if (normalizedPlaybackRate !== 1)
|
|
517
531
|
args.push("-t", String(duration));
|
|
@@ -533,7 +547,11 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
|
|
|
533
547
|
failure,
|
|
534
548
|
};
|
|
535
549
|
}
|
|
536
|
-
|
|
550
|
+
let failure = result.success ? undefined : ffmpegFailure(stage, result);
|
|
551
|
+
const probedLinkFailedToRead = readsUrl && (failure?.reason === "invalid_media" || failure?.reason === "ffmpeg_failed");
|
|
552
|
+
if (failure && probedLinkFailedToRead) {
|
|
553
|
+
failure = { ...failure, reason: "download_failed", owner: "system", retryable: true };
|
|
554
|
+
}
|
|
537
555
|
return {
|
|
538
556
|
success: result.success,
|
|
539
557
|
outputPath,
|
|
@@ -585,7 +603,7 @@ async function generateSilence(outputPath, duration, signal, config) {
|
|
|
585
603
|
failure,
|
|
586
604
|
};
|
|
587
605
|
}
|
|
588
|
-
async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config
|
|
606
|
+
async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
|
|
589
607
|
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
590
608
|
const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
|
|
591
609
|
if (tracks.length === 0) {
|
|
@@ -647,7 +665,10 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config,
|
|
|
647
665
|
scriptPath,
|
|
648
666
|
"-map",
|
|
649
667
|
"[out]",
|
|
650
|
-
|
|
668
|
+
"-acodec",
|
|
669
|
+
"pcm_f32le",
|
|
670
|
+
"-ar",
|
|
671
|
+
"48000",
|
|
651
672
|
"-t",
|
|
652
673
|
String(totalDuration),
|
|
653
674
|
"-y",
|
|
@@ -858,7 +879,7 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
|
|
|
858
879
|
*/
|
|
859
880
|
async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
|
|
860
881
|
const sumPath = join(workDir, "master-sum.wav");
|
|
861
|
-
const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config
|
|
882
|
+
const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config);
|
|
862
883
|
if (!sum.success)
|
|
863
884
|
return { ...sum, outputPath };
|
|
864
885
|
const encoderInput = masterChain
|
|
@@ -882,6 +903,8 @@ async function mixThroughFloatSum(tracks, masterChain, outputPath, workDir, tota
|
|
|
882
903
|
failures: [failure],
|
|
883
904
|
};
|
|
884
905
|
}
|
|
906
|
+
if (config?.keepEncoderInputAt)
|
|
907
|
+
renameSync(encoderInput, config.keepEncoderInputAt);
|
|
885
908
|
return { ...sum, outputPath, durationMs: sum.durationMs + encoded.durationMs };
|
|
886
909
|
}
|
|
887
910
|
export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
|
|
@@ -932,12 +955,17 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
932
955
|
}
|
|
933
956
|
try {
|
|
934
957
|
let srcPath = element.src;
|
|
958
|
+
let readInPlace = false;
|
|
935
959
|
if (!isHttpUrl(srcPath)) {
|
|
936
960
|
// Same browser-URL path semantics as videos.
|
|
937
961
|
srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
|
|
938
|
-
|
|
962
|
+
const remoteUrl = config?.urlByAbsentLocalPath?.get(srcPath);
|
|
963
|
+
if (remoteUrl) {
|
|
964
|
+
srcPath = remoteUrl;
|
|
965
|
+
readInPlace = true;
|
|
966
|
+
}
|
|
939
967
|
}
|
|
940
|
-
if (isHttpUrl(srcPath)) {
|
|
968
|
+
if (isHttpUrl(srcPath) && !readInPlace) {
|
|
941
969
|
try {
|
|
942
970
|
srcPath = await downloadToTemp(srcPath, workDir, undefined, effectiveSignal, undefined, {
|
|
943
971
|
onTelemetry: writeUrlDownloadTelemetry,
|
|
@@ -948,7 +976,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
948
976
|
return;
|
|
949
977
|
}
|
|
950
978
|
}
|
|
951
|
-
if (!existsSync(srcPath)) {
|
|
979
|
+
if (!readInPlace && !existsSync(srcPath)) {
|
|
952
980
|
failures.push({
|
|
953
981
|
stage: "source",
|
|
954
982
|
reason: "source_not_found",
|
|
@@ -980,7 +1008,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
980
1008
|
if (element.end - element.start <= 0) {
|
|
981
1009
|
let metadata;
|
|
982
1010
|
try {
|
|
983
|
-
metadata = await extractAudioMetadata(srcPath);
|
|
1011
|
+
metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
|
|
984
1012
|
}
|
|
985
1013
|
catch (err) {
|
|
986
1014
|
failures.push(probeFailure(err instanceof Error ? err.message : String(err), element.id));
|
|
@@ -1291,7 +1319,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
|
|
|
1291
1319
|
let mixResult;
|
|
1292
1320
|
try {
|
|
1293
1321
|
mixResult =
|
|
1294
|
-
|
|
1322
|
+
tracks.length > 0
|
|
1295
1323
|
? await mixThroughFloatSum(tracks, masterChain, outputPath, workDir, totalDuration, signal, config)
|
|
1296
1324
|
: await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
|
|
1297
1325
|
}
|
|
@@ -10,7 +10,7 @@ import { execSync } from "child_process";
|
|
|
10
10
|
import { existsSync, readdirSync, statSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { homedir } from "os";
|
|
13
|
-
import { chromeMajorCeiling, compareBrowserVersionsDescending, exceedsChromeCeiling, isManagedChromeDir, pickSharedShell, } from "./chromeHostCeiling.js";
|
|
13
|
+
import { browserPathFromEnv, chromeMajorCeiling, compareBrowserVersionsDescending, exceedsChromeCeiling, isManagedChromeDir, pickSharedShell, } from "./chromeHostCeiling.js";
|
|
14
14
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
15
15
|
import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
|
|
16
16
|
import { describeWslGpuAccess, detectWsl } from "./wslHost.js";
|
|
@@ -186,21 +186,13 @@ export function resolveHeadlessShellPath(config) {
|
|
|
186
186
|
if (config?.chromePath) {
|
|
187
187
|
return config.chromePath;
|
|
188
188
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (!existsSync(
|
|
192
|
-
throw new Error(`[BrowserManager] Chrome binary not found at
|
|
189
|
+
const fromEnv = browserPathFromEnv();
|
|
190
|
+
if (fromEnv) {
|
|
191
|
+
if (!existsSync(fromEnv.path)) {
|
|
192
|
+
throw new Error(`[BrowserManager] Chrome binary not found at ${fromEnv.name}="${fromEnv.path}". ` +
|
|
193
193
|
"Run `hyperframes browser ensure` to re-download.");
|
|
194
194
|
}
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
if (process.env.HYPERFRAMES_BROWSER_PATH) {
|
|
198
|
-
const envPath = process.env.HYPERFRAMES_BROWSER_PATH;
|
|
199
|
-
if (!existsSync(envPath)) {
|
|
200
|
-
throw new Error(`[BrowserManager] Chrome binary not found at HYPERFRAMES_BROWSER_PATH="${envPath}". ` +
|
|
201
|
-
"Run `hyperframes browser ensure` to re-download.");
|
|
202
|
-
}
|
|
203
|
-
return envPath;
|
|
195
|
+
return fromEnv.path;
|
|
204
196
|
}
|
|
205
197
|
const ours = join(homedir(), ".cache", "hyperframes", "chrome", "chrome-headless-shell");
|
|
206
198
|
const shared = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
|
|
@@ -11,3 +11,8 @@ export declare function shellStarts(binary: string): 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;
|
|
14
|
+
/** The browser binary an env var names. The CLI and the engine both resolve it here, HYPERFRAMES_BROWSER_PATH first. */
|
|
15
|
+
export declare function browserPathFromEnv(env?: NodeJS.ProcessEnv): {
|
|
16
|
+
name: "HYPERFRAMES_BROWSER_PATH" | "PRODUCER_HEADLESS_SHELL_PATH";
|
|
17
|
+
path: string;
|
|
18
|
+
} | undefined;
|
|
@@ -80,3 +80,12 @@ export function pickSharedShell(versions, shell) {
|
|
|
80
80
|
}
|
|
81
81
|
return undefined;
|
|
82
82
|
}
|
|
83
|
+
/** The browser binary an env var names. The CLI and the engine both resolve it here, HYPERFRAMES_BROWSER_PATH first. */
|
|
84
|
+
export function browserPathFromEnv(env = process.env) {
|
|
85
|
+
for (const name of ["HYPERFRAMES_BROWSER_PATH", "PRODUCER_HEADLESS_SHELL_PATH"]) {
|
|
86
|
+
const path = env[name];
|
|
87
|
+
if (path)
|
|
88
|
+
return { name, path };
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
@@ -143,7 +143,8 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
|
|
|
143
143
|
"Stripping HDR metadata and tagging output as SDR/BT.709. Use codec=h265 for HDR output.");
|
|
144
144
|
options = { ...options, hdr: undefined };
|
|
145
145
|
}
|
|
146
|
-
|
|
146
|
+
// Without -xerror, ffmpeg ends the video at the first frame file it cannot read and still exits 0.
|
|
147
|
+
const args = ["-xerror", ...inputArgs, "-r", fpsToFfmpegArg(fps)];
|
|
147
148
|
const shouldUseGpu = useGpu && gpuEncoder !== null;
|
|
148
149
|
if (codec === "h264" || codec === "h265") {
|
|
149
150
|
if (shouldUseGpu) {
|
|
@@ -363,6 +364,16 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
|
|
|
363
364
|
return args;
|
|
364
365
|
}
|
|
365
366
|
const framesReader = (onFrames) => onFrames && ffmpegStatsReader(({ frames }) => frames !== undefined && onFrames(frames));
|
|
367
|
+
/** ffmpeg's frame read error, which the encoder summary pushes out of the stderr tail, and where it stopped. */
|
|
368
|
+
function frameReadFailure(stderr, framePattern, startNumber, framesRead, frameCount) {
|
|
369
|
+
const lines = stderr.match(/^.*(?:Error during demuxing|Could not open file).*$/gm) ?? [];
|
|
370
|
+
const readError = [...new Set(lines.map((line) => line.trim()))].join("; ");
|
|
371
|
+
if (!readError)
|
|
372
|
+
return "";
|
|
373
|
+
const name = framePattern.replace(/%0(\d+)d/, (_, width) => String(startNumber + framesRead).padStart(Number(width), "0"));
|
|
374
|
+
const at = framesRead < frameCount ? ` at ${name} (${framesRead} of ${frameCount} read)` : "";
|
|
375
|
+
return `[FFmpeg] could not read a frame${at}: ${readError}\n`;
|
|
376
|
+
}
|
|
366
377
|
const secondsReader = (onSeconds) => onSeconds && ffmpegStatsReader(({ seconds }) => seconds !== undefined && onSeconds(seconds));
|
|
367
378
|
export async function encodeFramesFromDir(framesDir, framePattern, outputPath, options, signal, config, onFramesEncoded) {
|
|
368
379
|
const startTime = Date.now();
|
|
@@ -389,10 +400,14 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
|
|
|
389
400
|
const inputArgs = ["-framerate", fpsToFfmpegArg(options.fps), "-i", inputPath];
|
|
390
401
|
const args = buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder);
|
|
391
402
|
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
403
|
+
let framesRead = 0;
|
|
392
404
|
const result = await runFfmpeg(args, {
|
|
393
405
|
signal,
|
|
394
406
|
timeout: encodeTimeout,
|
|
395
|
-
onStderr: framesReader(
|
|
407
|
+
onStderr: framesReader((frames) => {
|
|
408
|
+
framesRead = frames;
|
|
409
|
+
onFramesEncoded?.(frames);
|
|
410
|
+
}),
|
|
396
411
|
});
|
|
397
412
|
if (result.terminationReason === "abort") {
|
|
398
413
|
return {
|
|
@@ -411,7 +426,8 @@ export async function encodeFramesFromDir(framesDir, framePattern, outputPath, o
|
|
|
411
426
|
durationMs: result.durationMs,
|
|
412
427
|
framesEncoded: 0,
|
|
413
428
|
fileSize: 0,
|
|
414
|
-
error: appendEncodeTimeoutMessage(
|
|
429
|
+
error: appendEncodeTimeoutMessage(frameReadFailure(result.stderr, framePattern, 0, framesRead, frameCount) +
|
|
430
|
+
formatFfmpegError(result.exitCode, result.stderr), result.terminationReason === "deadline", encodeTimeout),
|
|
415
431
|
failureReason: isExternalFfmpegInterruption(result) ? "external_interruption" : undefined,
|
|
416
432
|
};
|
|
417
433
|
}
|
|
@@ -534,16 +550,20 @@ export async function encodeFramesChunkedConcat(framesDir, framePattern, outputP
|
|
|
534
550
|
gpuEncoder = await getCachedGpuEncoder();
|
|
535
551
|
const args = buildEncoderArgs(options, inputArgs, chunkPath, gpuEncoder);
|
|
536
552
|
const encodeTimeout = config?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout;
|
|
553
|
+
let framesRead = 0;
|
|
537
554
|
const processResult = await runFfmpeg(args, {
|
|
538
555
|
signal,
|
|
539
556
|
timeout: encodeTimeout,
|
|
540
|
-
onStderr: framesReader(
|
|
557
|
+
onStderr: framesReader((frames) => {
|
|
558
|
+
framesRead = frames;
|
|
559
|
+
onFramesEncoded?.(startNumber + frames);
|
|
560
|
+
}),
|
|
541
561
|
});
|
|
542
562
|
const chunkResult = {
|
|
543
563
|
success: processResult.success,
|
|
544
564
|
error: processResult.success
|
|
545
565
|
? undefined
|
|
546
|
-
: appendEncodeTimeoutMessage(`Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`, processResult.terminationReason === "deadline", encodeTimeout),
|
|
566
|
+
: appendEncodeTimeoutMessage(frameReadFailure(processResult.stderr, framePattern, startNumber, framesRead, framesInChunk) + `Chunk ${i} encode failed: ${processResult.stderr.slice(-400)}`, processResult.terminationReason === "deadline", encodeTimeout),
|
|
547
567
|
};
|
|
548
568
|
if (!chunkResult.success) {
|
|
549
569
|
return {
|
|
@@ -416,6 +416,7 @@ export interface SubTimelinePollOptions {
|
|
|
416
416
|
export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
|
|
417
417
|
/** @internal exported for unit testing only */
|
|
418
418
|
export declare function pollImagesReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<boolean>;
|
|
419
|
+
export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
|
|
419
420
|
/** @internal exported for contract testing. */
|
|
420
421
|
export declare function collectMediaReadinessWarnings(page: Page, skipIds: readonly string[], timeoutMs: number): Promise<CaptureWarning[]>;
|
|
421
422
|
export declare function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void;
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* the page to expose window.__hf, then captures frames deterministically
|
|
8
8
|
* via Chrome's BeginFrame API or Page.captureScreenshot fallback.
|
|
9
9
|
*/
|
|
10
|
-
import { CDPSession, } from "puppeteer-core";
|
|
11
10
|
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
12
11
|
import { join } from "path";
|
|
13
12
|
import { COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
|
|
@@ -860,6 +859,7 @@ async function constructCaptureSession(input) {
|
|
|
860
859
|
// Cap the existing session's inspection buffer; new sessions have separate buffers.
|
|
861
860
|
const frame = page.mainFrame();
|
|
862
861
|
// Frame.client is runtime-only, so keep capture working when it is absent.
|
|
862
|
+
const { CDPSession } = await import("puppeteer-core");
|
|
863
863
|
if ("client" in frame && frame.client instanceof CDPSession) {
|
|
864
864
|
await frame.client.send("Network.enable", {
|
|
865
865
|
maxTotalBufferSize: 32 * 1024 * 1024,
|
|
@@ -1356,6 +1356,22 @@ export async function pollImagesReady(page, timeoutMs, intervalMs = 100) {
|
|
|
1356
1356
|
}
|
|
1357
1357
|
return check();
|
|
1358
1358
|
}
|
|
1359
|
+
export async function waitForImagesReady(page, timeoutMs, onUnloaded) {
|
|
1360
|
+
const ready = await pollImagesReady(page, timeoutMs);
|
|
1361
|
+
if (!ready) {
|
|
1362
|
+
onUnloaded(await page.evaluate(() => Array.from(document.querySelectorAll("img"))
|
|
1363
|
+
.filter((img) => {
|
|
1364
|
+
const src = img.getAttribute("src") || "";
|
|
1365
|
+
if (!src || src.startsWith("data:"))
|
|
1366
|
+
return false;
|
|
1367
|
+
return !(img.complete && img.naturalWidth > 0);
|
|
1368
|
+
})
|
|
1369
|
+
.map((img) => img.src || img.getAttribute("src") || "(no src)")
|
|
1370
|
+
.join(", ")));
|
|
1371
|
+
}
|
|
1372
|
+
await decodeAllImages(page);
|
|
1373
|
+
return ready;
|
|
1374
|
+
}
|
|
1359
1375
|
/** @internal exported for contract testing. */
|
|
1360
1376
|
export async function collectMediaReadinessWarnings(page, skipIds, timeoutMs) {
|
|
1361
1377
|
const snapshot = await page.evaluate((skipIdList) => {
|
|
@@ -1769,26 +1785,8 @@ export async function initializeSession(session) {
|
|
|
1769
1785
|
const skipVideoIds = session.options.skipReadinessVideoIds ?? [];
|
|
1770
1786
|
const [videosReady] = await Promise.all([
|
|
1771
1787
|
pollVideosReady(page, skipVideoIds, pageReadyTimeout),
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
const failedImages = await page.evaluate(() => {
|
|
1775
|
-
return Array.from(document.querySelectorAll("img"))
|
|
1776
|
-
.filter((img) => {
|
|
1777
|
-
const ie = img;
|
|
1778
|
-
const src = ie.getAttribute("src") || "";
|
|
1779
|
-
if (!src || src.startsWith("data:"))
|
|
1780
|
-
return false;
|
|
1781
|
-
return !(ie.complete && ie.naturalWidth > 0);
|
|
1782
|
-
})
|
|
1783
|
-
.map((img) => img.src || img.getAttribute("src") || "(no src)")
|
|
1784
|
-
.join(", ");
|
|
1785
|
-
});
|
|
1786
|
-
console.warn(`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
|
|
1787
|
-
`Continuing render. Affected images may appear blank/missing in early frames.`);
|
|
1788
|
-
}
|
|
1789
|
-
await decodeAllImages(page);
|
|
1790
|
-
return ready;
|
|
1791
|
-
}),
|
|
1788
|
+
waitForImagesReady(page, pageReadyTimeout, (failedImages) => console.warn(`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
|
|
1789
|
+
`Continuing render. Affected images may appear blank/missing in early frames.`)),
|
|
1792
1790
|
page.evaluate(`document.fonts?.ready`),
|
|
1793
1791
|
waitForOptionalTailwindReady(page, pageReadyTimeout),
|
|
1794
1792
|
]);
|
|
@@ -1899,26 +1897,8 @@ export async function initializeSession(session) {
|
|
|
1899
1897
|
const bfSkipVideoIds = session.options.skipReadinessVideoIds ?? [];
|
|
1900
1898
|
const [bfVideosReady] = await Promise.all([
|
|
1901
1899
|
pollVideosReady(page, bfSkipVideoIds, pageReadyTimeout),
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
const failedImages = await page.evaluate(() => {
|
|
1905
|
-
return Array.from(document.querySelectorAll("img"))
|
|
1906
|
-
.filter((img) => {
|
|
1907
|
-
const ie = img;
|
|
1908
|
-
const src = ie.getAttribute("src") || "";
|
|
1909
|
-
if (!src || src.startsWith("data:"))
|
|
1910
|
-
return false;
|
|
1911
|
-
return !(ie.complete && ie.naturalWidth > 0);
|
|
1912
|
-
})
|
|
1913
|
-
.map((img) => img.src || img.getAttribute("src") || "(no src)")
|
|
1914
|
-
.join(", ");
|
|
1915
|
-
});
|
|
1916
|
-
console.warn(`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
|
|
1917
|
-
`Continuing render. Affected images may appear blank/missing in early frames.`);
|
|
1918
|
-
}
|
|
1919
|
-
await decodeAllImages(page);
|
|
1920
|
-
return ready;
|
|
1921
|
-
}),
|
|
1900
|
+
waitForImagesReady(page, pageReadyTimeout, (failedImages) => console.warn(`[FrameCapture] Some image elements did not load within ${pageReadyTimeout}ms: ${failedImages}. ` +
|
|
1901
|
+
`Continuing render. Affected images may appear blank/missing in early frames.`)),
|
|
1922
1902
|
page.evaluate(`document.fonts?.ready`),
|
|
1923
1903
|
waitForOptionalTailwindReady(page, pageReadyTimeout),
|
|
1924
1904
|
]);
|
|
@@ -1,38 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Node-side resolver for relative `data-start` timing references, shared by
|
|
3
|
-
* every parser that reads media timing out of compiled HTML — video frames
|
|
4
|
-
* (`parseVideoElements`), images (`parseImageElements`), and audio
|
|
5
|
-
* (`parseAudioElements`). Keeping the resolution in ONE place is load-bearing:
|
|
6
|
-
* if audio and video disagree on what `data-start="intro"` means, a relative
|
|
7
|
-
* reference that renders a video at the right time silently drops the audio
|
|
8
|
-
* track (they used to — audio parsed `parseFloat("intro") = NaN`).
|
|
9
|
-
*
|
|
10
|
-
* Mirrors the browser runtime's startResolver so `snapshot`/`render` agree.
|
|
11
|
-
* DOM access is via a minimal structural shape so it works against linkedom
|
|
12
|
-
* (Node) without pulling in lib.dom types.
|
|
13
|
-
*/
|
|
14
|
-
/** Minimal structural DOM shape the reference resolver needs. */
|
|
15
|
-
export interface RefResolverEl {
|
|
16
|
-
getAttribute(name: string): string | null;
|
|
17
|
-
}
|
|
18
|
-
export interface RefResolverDoc {
|
|
19
|
-
getElementById(id: string): RefResolverEl | null;
|
|
20
|
-
querySelector(selector: string): RefResolverEl | null;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Resolve an element's absolute start time (seconds) the same way the browser
|
|
24
|
-
* runtime's startResolver does, so `<video data-start="intro">` (a relative
|
|
25
|
-
* reference to another clip's end) renders at the right time instead of
|
|
26
|
-
* producing NaN and compositing blank / dropping audio. Durations come from
|
|
27
|
-
* `data-duration` or `data-end` here; the natural-media-duration fallback isn't
|
|
28
|
-
* known at parse time, so — exactly like the runtime — an unknown-duration
|
|
29
|
-
* reference falls back to the target's start and an unknown target falls back
|
|
30
|
-
* to 0 (never NaN).
|
|
31
|
-
*/
|
|
32
|
-
export declare function resolveReferencedStart(doc: RefResolverDoc, el: RefResolverEl, startCache: Map<RefResolverEl, number>, visiting: Set<RefResolverEl>): number;
|
|
33
|
-
/**
|
|
34
|
-
* Duration of a referenced clip, from `data-duration` or `data-end - start`.
|
|
35
|
-
* Returns null when only the natural media duration would settle it (unknown
|
|
36
|
-
* at parse time) — the caller then treats the reference as duration-0.
|
|
37
|
-
*/
|
|
38
|
-
export declare function resolveReferencedDuration(doc: RefResolverDoc, el: RefResolverEl, startCache: Map<RefResolverEl, number>, visiting: Set<RefResolverEl>): number | null;
|
|
1
|
+
export * from "@hyperframes/parsers/reference-resolver";
|
|
@@ -1,87 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
* every parser that reads media timing out of compiled HTML — video frames
|
|
4
|
-
* (`parseVideoElements`), images (`parseImageElements`), and audio
|
|
5
|
-
* (`parseAudioElements`). Keeping the resolution in ONE place is load-bearing:
|
|
6
|
-
* if audio and video disagree on what `data-start="intro"` means, a relative
|
|
7
|
-
* reference that renders a video at the right time silently drops the audio
|
|
8
|
-
* track (they used to — audio parsed `parseFloat("intro") = NaN`).
|
|
9
|
-
*
|
|
10
|
-
* Mirrors the browser runtime's startResolver so `snapshot`/`render` agree.
|
|
11
|
-
* DOM access is via a minimal structural shape so it works against linkedom
|
|
12
|
-
* (Node) without pulling in lib.dom types.
|
|
13
|
-
*/
|
|
14
|
-
import { parseNumeric, parseStartExpression } from "@hyperframes/core";
|
|
15
|
-
/**
|
|
16
|
-
* Find the element a relative `data-start` reference points at — by `id`
|
|
17
|
-
* first, then by `data-composition-id` (a sub-composition can be referenced).
|
|
18
|
-
* The reference-id grammar (see parseStartExpression) is restricted to
|
|
19
|
-
* `[A-Za-z0-9_.:-]`, none of which need escaping inside a quoted attribute
|
|
20
|
-
* selector, so no CSS.escape (absent in linkedom) is required.
|
|
21
|
-
*/
|
|
22
|
-
function findReferenceTargetEl(doc, refId) {
|
|
23
|
-
return doc.getElementById(refId) ?? doc.querySelector(`[data-composition-id="${refId}"]`);
|
|
24
|
-
}
|
|
25
|
-
/**
|
|
26
|
-
* Resolve an element's absolute start time (seconds) the same way the browser
|
|
27
|
-
* runtime's startResolver does, so `<video data-start="intro">` (a relative
|
|
28
|
-
* reference to another clip's end) renders at the right time instead of
|
|
29
|
-
* producing NaN and compositing blank / dropping audio. Durations come from
|
|
30
|
-
* `data-duration` or `data-end` here; the natural-media-duration fallback isn't
|
|
31
|
-
* known at parse time, so — exactly like the runtime — an unknown-duration
|
|
32
|
-
* reference falls back to the target's start and an unknown target falls back
|
|
33
|
-
* to 0 (never NaN).
|
|
34
|
-
*/
|
|
35
|
-
export function resolveReferencedStart(doc, el, startCache, visiting) {
|
|
36
|
-
const cached = startCache.get(el);
|
|
37
|
-
if (cached !== undefined)
|
|
38
|
-
return cached;
|
|
39
|
-
if (visiting.has(el))
|
|
40
|
-
return 0; // cycle guard (A -> B -> A)
|
|
41
|
-
visiting.add(el);
|
|
42
|
-
try {
|
|
43
|
-
const expression = parseStartExpression(el.getAttribute("data-start"));
|
|
44
|
-
if (!expression) {
|
|
45
|
-
startCache.set(el, 0);
|
|
46
|
-
return 0;
|
|
47
|
-
}
|
|
48
|
-
if (expression.kind === "absolute") {
|
|
49
|
-
const value = Math.max(0, expression.value);
|
|
50
|
-
startCache.set(el, value);
|
|
51
|
-
return value;
|
|
52
|
-
}
|
|
53
|
-
const target = findReferenceTargetEl(doc, expression.refId);
|
|
54
|
-
if (!target) {
|
|
55
|
-
startCache.set(el, 0);
|
|
56
|
-
return 0;
|
|
57
|
-
}
|
|
58
|
-
const targetStart = resolveReferencedStart(doc, target, startCache, visiting);
|
|
59
|
-
const targetDuration = resolveReferencedDuration(doc, target, startCache, visiting);
|
|
60
|
-
const resolved = targetDuration != null && targetDuration > 0
|
|
61
|
-
? Math.max(0, targetStart + targetDuration + expression.offset)
|
|
62
|
-
: Math.max(0, targetStart + expression.offset);
|
|
63
|
-
startCache.set(el, resolved);
|
|
64
|
-
return resolved;
|
|
65
|
-
}
|
|
66
|
-
finally {
|
|
67
|
-
visiting.delete(el);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
/**
|
|
71
|
-
* Duration of a referenced clip, from `data-duration` or `data-end - start`.
|
|
72
|
-
* Returns null when only the natural media duration would settle it (unknown
|
|
73
|
-
* at parse time) — the caller then treats the reference as duration-0.
|
|
74
|
-
*/
|
|
75
|
-
export function resolveReferencedDuration(doc, el, startCache, visiting) {
|
|
76
|
-
const durationAttr = parseNumeric(el.getAttribute("data-duration"));
|
|
77
|
-
if (durationAttr != null && durationAttr > 0)
|
|
78
|
-
return durationAttr;
|
|
79
|
-
const endAttr = parseNumeric(el.getAttribute("data-end"));
|
|
80
|
-
if (endAttr != null) {
|
|
81
|
-
const start = resolveReferencedStart(doc, el, startCache, visiting);
|
|
82
|
-
const delta = endAttr - start;
|
|
83
|
-
if (Number.isFinite(delta) && delta > 0)
|
|
84
|
-
return delta;
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
1
|
+
// Kept for existing importers; the resolver lives in @hyperframes/parsers.
|
|
2
|
+
export * from "@hyperframes/parsers/reference-resolver";
|
|
@@ -108,6 +108,10 @@ export interface ExtractionOptions {
|
|
|
108
108
|
/** Directories the caller rewrites on every render (downloaded media); their sources are cached by content. */
|
|
109
109
|
contentKeyedDirs?: readonly string[];
|
|
110
110
|
urlByAbsentLocalPath?: ReadonlyMap<string, string>;
|
|
111
|
+
shownBox?: {
|
|
112
|
+
width: number;
|
|
113
|
+
height: number;
|
|
114
|
+
};
|
|
111
115
|
}
|
|
112
116
|
export declare const EXTRACT_CACHE_MIN_AGE_MS: number;
|
|
113
117
|
/**
|
|
@@ -16,7 +16,7 @@ import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution
|
|
|
16
16
|
import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
|
|
17
17
|
import { resolveReferencedStart } from "./referenceResolver.js";
|
|
18
18
|
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
|
|
19
|
-
import { extractFinalVideoFrameTimestamp, extractMediaMetadata, } from "../utils/ffprobe.js";
|
|
19
|
+
import { extractFinalVideoFrameTimestamp, extractMediaMetadata, mediaFileIdentity, readMetadataCache, writeMetadataCache, } from "../utils/ffprobe.js";
|
|
20
20
|
import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
|
|
21
21
|
import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
|
|
22
22
|
import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
|
|
@@ -115,6 +115,12 @@ const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv"
|
|
|
115
115
|
const HDR_TO_SDR_ZSCALE_TRANSFORM_KEY = "hdr2sdr-hable-srgb";
|
|
116
116
|
const HDR_TO_SDR_VIDEOTOOLBOX_TRANSFORM_KEY = "hdr2sdr-videotoolbox-hdr";
|
|
117
117
|
const SDR_CANVAS_PASSTHROUGH_FILTER = "setparams=color_primaries=bt709:color_trc=iec61966-2-1";
|
|
118
|
+
function halveToCoverScaleFilter(box) {
|
|
119
|
+
const fit = `max(${Math.ceil(box.width)}/iw\\,${Math.ceil(box.height)}/ih)`;
|
|
120
|
+
const halvings = `floor(log(1/${fit})/log(2))`;
|
|
121
|
+
const side = (dim) => `if(lt(${fit}\\,0.5)\\,2*ceil(${dim}/pow(2\\,${halvings})/2)\\,${dim})`;
|
|
122
|
+
return `scale=w=${side("iw")}:h=${side("ih")}:flags=area+accurate_rnd`;
|
|
123
|
+
}
|
|
118
124
|
const SDR_JPEG_AS_BT601_FULL_RANGE_FILTER = "scale=flags=neighbor+accurate_rnd,format=gbrp,scale=out_color_matrix=bt601:out_range=pc:flags=neighbor,format=yuv420p";
|
|
119
125
|
let warnedMissingZscale = false;
|
|
120
126
|
async function toneMapsWithZscale() {
|
|
@@ -417,12 +423,23 @@ export function parseImageElements(html) {
|
|
|
417
423
|
}
|
|
418
424
|
return images;
|
|
419
425
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
426
|
+
const firstFrameColourCache = new Map();
|
|
427
|
+
/** The first shown frame's colour tags (see {@link firstFrameColourArgs}), probed once per source file. */
|
|
428
|
+
function readFirstFrameColour(videoPath) {
|
|
429
|
+
const identity = mediaFileIdentity(videoPath);
|
|
430
|
+
const cached = readMetadataCache(firstFrameColourCache, videoPath, identity);
|
|
431
|
+
if (cached)
|
|
432
|
+
return cached;
|
|
433
|
+
// In an extraction slot, so it never adds a decoder; unsignalled, so one cancel cannot cut it short for others.
|
|
434
|
+
const probe = inExtractionSlot(() => runFfmpeg([...urlInputArgs(videoPath), ...firstFrameColourArgs(videoPath)])).then((result) => {
|
|
435
|
+
// A failed probe answers this cut only; the next one probes again.
|
|
436
|
+
if (!result.success && firstFrameColourCache.get(videoPath)?.promise === probe) {
|
|
437
|
+
firstFrameColourCache.delete(videoPath);
|
|
438
|
+
}
|
|
439
|
+
return parseFirstFrameColour(result.stderr);
|
|
424
440
|
});
|
|
425
|
-
|
|
441
|
+
writeMetadataCache(firstFrameColourCache, videoPath, identity, probe);
|
|
442
|
+
return probe;
|
|
426
443
|
}
|
|
427
444
|
/** Chrome plays untagged VP9 and AV1 as BT.601, H.264 and VP8 as BT.709 from 720 coded lines (assumed for the rest). */
|
|
428
445
|
const CHROME_BT601_UNTAGGED_CODECS = new Set(["vp9", "av1"]);
|
|
@@ -434,10 +451,9 @@ function chromeGuessForUntaggedMatrix(metadata) {
|
|
|
434
451
|
return [`setparams=colorspace=${hd ? "bt709" : "smpte170m"}`];
|
|
435
452
|
}
|
|
436
453
|
/**
|
|
437
|
-
* Longest stretch of output one ffmpeg process extracts
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* same absolute times as a single pass, so their frames are byte-identical to it.
|
|
454
|
+
* Longest stretch of output one ffmpeg process extracts; longer ranges split into segments
|
|
455
|
+
* that run side by side. Segments sample the same absolute times as a single pass, so their
|
|
456
|
+
* frames are byte-identical to it.
|
|
441
457
|
*/
|
|
442
458
|
const EXTRACTION_SEGMENT_SECONDS = 120;
|
|
443
459
|
/** Frame times since file start are `PTS*TB + seek`; snapping them to `lead + k/FR` gives every process one grid. */
|
|
@@ -624,20 +640,24 @@ outputDirOverride) {
|
|
|
624
640
|
vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
|
|
625
641
|
}
|
|
626
642
|
if (toneMappedToSdr) {
|
|
627
|
-
const frameColour = await readFirstFrameColour(videoPath
|
|
643
|
+
const frameColour = await readFirstFrameColour(videoPath);
|
|
628
644
|
vfFilters.push(hdrToSdrToneMapFilter(metadata.colorSpace ?? {}, frameColour));
|
|
629
645
|
}
|
|
630
646
|
if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
|
|
631
647
|
vfFilters.push(...(isHdr ? [] : chromeGuessForUntaggedMatrix(metadata)), SDR_CANVAS_PASSTHROUGH_FILTER);
|
|
632
|
-
if (format === "jpg")
|
|
648
|
+
if (format === "jpg") {
|
|
649
|
+
if (options.shownBox)
|
|
650
|
+
vfFilters.push(halveToCoverScaleFilter(options.shownBox));
|
|
633
651
|
vfFilters.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
|
|
652
|
+
}
|
|
634
653
|
}
|
|
635
654
|
const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
|
|
636
655
|
// Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
|
|
637
656
|
if (format === "png")
|
|
638
657
|
encodeArgs.push("-compression_level", "1");
|
|
639
658
|
encodeArgs.push("-y", outputPattern);
|
|
640
|
-
|
|
659
|
+
// A long 4K run can take far longer than the timeout and still be healthy; only a stall is a hang.
|
|
660
|
+
const runOptions = { signal, stallTimeout: ffmpegProcessTimeout };
|
|
641
661
|
const filterArgsFor = (seek) => {
|
|
642
662
|
const filters = [...vfFilters];
|
|
643
663
|
filters.splice(samplingAt, 0, ...samplingFilters(seek));
|
|
@@ -677,8 +697,8 @@ outputDirOverride) {
|
|
|
677
697
|
const hdrPrefix = options.sdrToHdrTransfer
|
|
678
698
|
? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
|
|
679
699
|
: "";
|
|
680
|
-
const timedOut = processResult.terminationReason === "
|
|
681
|
-
const timeoutSuffix = timedOut ? ` (
|
|
700
|
+
const timedOut = processResult.terminationReason === "inactivity";
|
|
701
|
+
const timeoutSuffix = timedOut ? ` (no new frame for ${ffmpegProcessTimeout} ms)` : "";
|
|
682
702
|
const diagnostic = `${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ` +
|
|
683
703
|
processResult.stderr.slice(-500);
|
|
684
704
|
if (timedOut) {
|
|
@@ -1562,7 +1582,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
|
|
|
1562
1582
|
: join(options.outputDir, group.groupId);
|
|
1563
1583
|
try {
|
|
1564
1584
|
rmSync(tempDir, { recursive: true, force: true });
|
|
1565
|
-
// A long union can
|
|
1585
|
+
// A long union can fail even when each shorter
|
|
1566
1586
|
// member range succeeds. Do not retry the optimization itself; preserve
|
|
1567
1587
|
// the established grouped→direct fallback and apply bounded retries only
|
|
1568
1588
|
// to the individual source ranges below.
|
|
@@ -83,6 +83,15 @@ export declare function hideVideoElements(page: Page, videoIds: string[]): Promi
|
|
|
83
83
|
* Restore visibility of video elements after a DOM screenshot.
|
|
84
84
|
*/
|
|
85
85
|
export declare function showVideoElements(page: Page, videoIds: string[]): Promise<void>;
|
|
86
|
+
export interface ShownBox {
|
|
87
|
+
width: number;
|
|
88
|
+
height: number;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Seeks to each of `times`; returns each video's largest drawn box in device pixels. Videos that are
|
|
92
|
+
* graded, sampled by a canvas, `object-fit: none | scale-down` or never laid out get none (full size).
|
|
93
|
+
*/
|
|
94
|
+
export declare function measureVideoShownBoxes(page: Page, videoIds: readonly string[], times: readonly number[]): Promise<Map<string, ShownBox>>;
|
|
86
95
|
/**
|
|
87
96
|
* Query the current bounds, transform, and visibility of video elements.
|
|
88
97
|
* Called after seeking (so GSAP has moved things) but before the screenshot.
|
|
@@ -10,7 +10,7 @@ import { promises as fs } from "fs";
|
|
|
10
10
|
import { touchCacheDir } from "./extractionCache.js";
|
|
11
11
|
import { injectVideoFramesBatch, syncVideoFrameVisibility } from "./screenshotService.js";
|
|
12
12
|
import { DEFAULT_CONFIG } from "../config.js";
|
|
13
|
-
import { HF_COLOR_GRADING_CANVAS_ID_PREFIX, RENDER_FRAME_ID_PREFIX, RENDER_FRAME_ID_SUFFIX, renderFrameIdForRenderId, } from "@hyperframes/core";
|
|
13
|
+
import { HF_COLOR_GRADING_ATTR, HF_COLOR_GRADING_CANVAS_ID_PREFIX, RENDER_FRAME_ID_PREFIX, RENDER_FRAME_ID_SUFFIX, renderFrameIdForRenderId, } from "@hyperframes/core";
|
|
14
14
|
/**
|
|
15
15
|
* Two-bound LRU keyed by frame path. Either bound triggers eviction of the
|
|
16
16
|
* oldest entry — entry count protects against pathological many-tiny-frames
|
|
@@ -247,6 +247,53 @@ async function setVideoElementsVisibility(page, videoIds, visible) {
|
|
|
247
247
|
// format has one definition across the engine and the runtime readers.
|
|
248
248
|
videoIds.map((id) => ({ id, frameId: renderFrameIdForRenderId(id) })), HF_COLOR_GRADING_CANVAS_ID_PREFIX, visible);
|
|
249
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Seeks to each of `times`; returns each video's largest drawn box in device pixels. Videos that are
|
|
252
|
+
* graded, sampled by a canvas, `object-fit: none | scale-down` or never laid out get none (full size).
|
|
253
|
+
*/
|
|
254
|
+
export async function measureVideoShownBoxes(page, videoIds, times) {
|
|
255
|
+
if (videoIds.length === 0 || times.length === 0)
|
|
256
|
+
return new Map();
|
|
257
|
+
const boxes = await page.evaluate(async (ids, seekTimes, gradingAttr, gradingCanvasPrefix) => {
|
|
258
|
+
const hf = window.__hf;
|
|
259
|
+
if (typeof hf?.seek !== "function")
|
|
260
|
+
return [];
|
|
261
|
+
const waitForSeek = window
|
|
262
|
+
.__hfWaitForSeekCompletion;
|
|
263
|
+
const largest = new Map();
|
|
264
|
+
const fullSize = new Set();
|
|
265
|
+
const keepsFullSize = (video) => {
|
|
266
|
+
if (video.hasAttribute(gradingAttr))
|
|
267
|
+
return true;
|
|
268
|
+
const fit = window.getComputedStyle(video).objectFit;
|
|
269
|
+
return fit === "none" || fit === "scale-down";
|
|
270
|
+
};
|
|
271
|
+
const measure = (id) => {
|
|
272
|
+
const video = window.__hfMediaEl?.(id) ?? document.getElementById(id);
|
|
273
|
+
if (!video || keepsFullSize(video)) {
|
|
274
|
+
fullSize.add(id);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const rect = video.getBoundingClientRect();
|
|
278
|
+
const box = largest.get(id) ?? { width: 0, height: 0 };
|
|
279
|
+
box.width = Math.max(box.width, rect.width * window.devicePixelRatio);
|
|
280
|
+
box.height = Math.max(box.height, rect.height * window.devicePixelRatio);
|
|
281
|
+
largest.set(id, box);
|
|
282
|
+
};
|
|
283
|
+
const pageHasCanvas = () => Array.from(document.querySelectorAll("canvas")).some((canvas) => !canvas.id.startsWith(gradingCanvasPrefix));
|
|
284
|
+
for (const t of seekTimes) {
|
|
285
|
+
hf.seek(t);
|
|
286
|
+
await waitForSeek?.();
|
|
287
|
+
// A canvas could sample any video, so every video keeps full size.
|
|
288
|
+
if (pageHasCanvas())
|
|
289
|
+
return [];
|
|
290
|
+
for (const id of ids)
|
|
291
|
+
measure(id);
|
|
292
|
+
}
|
|
293
|
+
return Array.from(largest).filter(([id, box]) => !fullSize.has(id) && box.width > 0 && box.height > 0);
|
|
294
|
+
}, [...videoIds], [...times], HF_COLOR_GRADING_ATTR, HF_COLOR_GRADING_CANVAS_ID_PREFIX);
|
|
295
|
+
return new Map(boxes);
|
|
296
|
+
}
|
|
250
297
|
/**
|
|
251
298
|
* Query the current bounds, transform, and visibility of video elements.
|
|
252
299
|
* Called after seeking (so GSAP has moved things) but before the screenshot.
|
package/dist/utils/ffprobe.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
export interface MediaProbeCacheEntry<T = FFProbeOutput> {
|
|
2
|
+
identity: string | null;
|
|
3
|
+
promise: Promise<T>;
|
|
4
|
+
}
|
|
1
5
|
export interface VideoColorSpace {
|
|
2
6
|
/** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
|
|
3
7
|
colorTransfer: string;
|
|
@@ -48,6 +52,42 @@ export interface AudioMetadata {
|
|
|
48
52
|
/** Seconds after the file's media time 0 at which its last audio or video stream starts. */
|
|
49
53
|
latestStreamLeadSeconds?: number;
|
|
50
54
|
}
|
|
55
|
+
interface FFProbeStream {
|
|
56
|
+
codec_type: string;
|
|
57
|
+
codec_name?: string;
|
|
58
|
+
/** e.g. "LC", "HE-AAC", "HE-AACv2" — where the SBR marker lives, since
|
|
59
|
+
* codec_name is plain "aac" for all of them. */
|
|
60
|
+
profile?: string;
|
|
61
|
+
width?: number;
|
|
62
|
+
height?: number;
|
|
63
|
+
duration?: string;
|
|
64
|
+
start_time?: string;
|
|
65
|
+
nb_frames?: string;
|
|
66
|
+
nb_read_packets?: string;
|
|
67
|
+
pix_fmt?: string;
|
|
68
|
+
r_frame_rate?: string;
|
|
69
|
+
avg_frame_rate?: string;
|
|
70
|
+
sample_rate?: string;
|
|
71
|
+
channels?: number;
|
|
72
|
+
color_range?: string;
|
|
73
|
+
color_transfer?: string;
|
|
74
|
+
color_primaries?: string;
|
|
75
|
+
color_space?: string;
|
|
76
|
+
tags?: Record<string, string>;
|
|
77
|
+
disposition?: {
|
|
78
|
+
attached_pic?: number;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
interface FFProbeFormat {
|
|
82
|
+
duration?: string;
|
|
83
|
+
start_time?: string;
|
|
84
|
+
bit_rate?: string;
|
|
85
|
+
format_name?: string;
|
|
86
|
+
}
|
|
87
|
+
interface FFProbeOutput {
|
|
88
|
+
streams: FFProbeStream[];
|
|
89
|
+
format: FFProbeFormat;
|
|
90
|
+
}
|
|
51
91
|
interface StillImageMetadata {
|
|
52
92
|
width: number;
|
|
53
93
|
height: number;
|
|
@@ -58,6 +98,9 @@ export interface MediaProbeProfile {
|
|
|
58
98
|
hasAudioStream: boolean;
|
|
59
99
|
visualKind: "none" | "still" | "moving";
|
|
60
100
|
}
|
|
101
|
+
export declare function mediaFileIdentity(filePath: string): string | null;
|
|
102
|
+
export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
|
|
103
|
+
export declare function writeMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null, promise: Promise<T>): void;
|
|
61
104
|
/**
|
|
62
105
|
* Probe stream capabilities without assuming the caller's element type.
|
|
63
106
|
* File extensions and HTTP MIME are deliberately ignored: extensionless
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -209,7 +209,7 @@ function isPngImageProbe(output) {
|
|
|
209
209
|
return (formatNames.includes("png_pipe") ||
|
|
210
210
|
output.streams.some((stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png"));
|
|
211
211
|
}
|
|
212
|
-
function mediaFileIdentity(filePath) {
|
|
212
|
+
export function mediaFileIdentity(filePath) {
|
|
213
213
|
try {
|
|
214
214
|
const stat = statSync(filePath, { bigint: true });
|
|
215
215
|
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
|
|
@@ -218,11 +218,11 @@ function mediaFileIdentity(filePath) {
|
|
|
218
218
|
return null;
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
|
-
function readMetadataCache(cache, key, identity) {
|
|
221
|
+
export function readMetadataCache(cache, key, identity) {
|
|
222
222
|
const cached = cache.get(key);
|
|
223
223
|
return cached?.identity === identity ? cached.promise : undefined;
|
|
224
224
|
}
|
|
225
|
-
function writeMetadataCache(cache, key, identity, promise) {
|
|
225
|
+
export function writeMetadataCache(cache, key, identity, promise) {
|
|
226
226
|
const entry = { identity, promise };
|
|
227
227
|
cache.set(key, entry);
|
|
228
228
|
void promise.then(() => {
|
|
@@ -12,8 +12,8 @@ export declare const NOT_MEDIA_PAYLOAD: "NOT_MEDIA_PAYLOAD";
|
|
|
12
12
|
*
|
|
13
13
|
* Only a 2xx response reaches this classifier. `downloadToTemp` rejects a
|
|
14
14
|
* 404/410 as `http_not_found` before writing a byte (urlDownloader.ts
|
|
15
|
-
* `classifyHttpFailure`), and
|
|
16
|
-
*
|
|
15
|
+
* `classifyHttpFailure`), and an authored http src is downloaded first (a cloud-held
|
|
16
|
+
* project file is read by URL, which the sniff cannot open). So the shapes that land here are a soft-404 or
|
|
17
17
|
* interstitial HTML body, an S3/CloudFront error document, or a JSON API error
|
|
18
18
|
* body, each served with a success status.
|
|
19
19
|
*
|
|
@@ -16,8 +16,8 @@ const MAX_LISTED_FINGERPRINTS = 8;
|
|
|
16
16
|
*
|
|
17
17
|
* Only a 2xx response reaches this classifier. `downloadToTemp` rejects a
|
|
18
18
|
* 404/410 as `http_not_found` before writing a byte (urlDownloader.ts
|
|
19
|
-
* `classifyHttpFailure`), and
|
|
20
|
-
*
|
|
19
|
+
* `classifyHttpFailure`), and an authored http src is downloaded first (a cloud-held
|
|
20
|
+
* project file is read by URL, which the sniff cannot open). So the shapes that land here are a soft-404 or
|
|
21
21
|
* interstitial HTML body, an S3/CloudFront error document, or a JSON API error
|
|
22
22
|
* body, each served with a success status.
|
|
23
23
|
*
|
|
@@ -8,6 +8,8 @@ import { type ManagedProcessTerminationReason } from "./managedChildProcess.js";
|
|
|
8
8
|
export interface RunFfmpegOptions {
|
|
9
9
|
signal?: AbortSignal;
|
|
10
10
|
timeout?: number;
|
|
11
|
+
/** Replaces `timeout`: stop only once this long passes without a new output frame. */
|
|
12
|
+
stallTimeout?: number;
|
|
11
13
|
onStderr?: (line: string) => void;
|
|
12
14
|
}
|
|
13
15
|
export interface RunFfmpegResult {
|
package/dist/utils/runFfmpeg.js
CHANGED
|
@@ -147,11 +147,25 @@ const ABORTED_BEFORE_START = {
|
|
|
147
147
|
export async function runFfmpeg(args, opts) {
|
|
148
148
|
if (opts?.signal?.aborted)
|
|
149
149
|
return { ...ABORTED_BEFORE_START };
|
|
150
|
-
const
|
|
150
|
+
const stallTimeout = opts?.stallTimeout;
|
|
151
|
+
let framesWritten = 0;
|
|
152
|
+
const readStats = ffmpegStatsReader(({ frames }) => {
|
|
153
|
+
if (frames === undefined || frames <= framesWritten)
|
|
154
|
+
return;
|
|
155
|
+
framesWritten = frames;
|
|
156
|
+
managed.markActivity();
|
|
157
|
+
});
|
|
151
158
|
const managed = new ManagedChildProcess(spawnFfmpeg(args), {
|
|
152
159
|
signal: opts?.signal,
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
...(stallTimeout === undefined
|
|
161
|
+
? { deadlineAtMs: Date.now() + (opts?.timeout ?? DEFAULT_TIMEOUT) }
|
|
162
|
+
: { inactivityTimeoutMs: stallTimeout }),
|
|
163
|
+
onStderr: stallTimeout === undefined
|
|
164
|
+
? opts?.onStderr
|
|
165
|
+
: (chunk) => {
|
|
166
|
+
readStats(chunk);
|
|
167
|
+
opts?.onStderr?.(chunk);
|
|
168
|
+
},
|
|
155
169
|
});
|
|
156
170
|
return toResult(await managed.wait());
|
|
157
171
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.183",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/
|
|
54
|
-
"@hyperframes/
|
|
53
|
+
"@hyperframes/parsers": "^0.8.183",
|
|
54
|
+
"@hyperframes/core": "^0.8.183"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|