@hyperframes/engine 0.8.181 → 0.8.184
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.js +0 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- 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/parallelCoordinator.d.ts +7 -0
- package/dist/services/parallelCoordinator.js +6 -3
- 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 +12 -7
- package/dist/services/videoFrameInjector.d.ts +9 -0
- package/dist/services/videoFrameInjector.js +48 -1
- package/dist/utils/ffprobe.d.ts +0 -14
- package/dist/utils/ffprobe.js +2 -26
- package/dist/utils/notMediaPayload.d.ts +2 -2
- package/dist/utils/notMediaPayload.js +2 -2
- package/package.json +3 -3
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";
|
|
@@ -53,7 +53,7 @@ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCo
|
|
|
53
53
|
export { audioFailureDiagnostics } from "./services/audioFailureDiagnostic.js";
|
|
54
54
|
export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
|
|
55
55
|
export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioFailureDiagnostic, AudioSubprocessEvidence, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
|
|
56
|
-
export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type ParallelProgress, } from "./services/parallelCoordinator.js";
|
|
56
|
+
export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type LandedFrame, type ParallelProgress, } from "./services/parallelCoordinator.js";
|
|
57
57
|
export { createFileServer, type FileServerOptions, type FileServerHandle, } from "./services/fileServer.js";
|
|
58
58
|
export { quantizeTimeToFrame, MEDIA_VISUAL_STYLE_PROPERTIES } from "@hyperframes/core";
|
|
59
59
|
export { frameFileExtension } from "./services/frameCapture.js";
|
|
@@ -64,7 +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 } from "./services/aacEncode.js";
|
|
67
|
+
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
68
68
|
export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
|
|
69
69
|
export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
|
|
70
70
|
export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
|
|
@@ -75,7 +75,7 @@ export { diffGpuParityFrames, diffGpuParityPngs, verifyGpuParity, type RgbaFrame
|
|
|
75
75
|
export { type TransitionFn, TRANSITIONS, crossfade, sampleRgb48le, hdrToLinear, linearToHdr, convertTransfer, } from "./utils/shaderTransitions.js";
|
|
76
76
|
export { initHdrReadback, uploadAndReadbackHdrFrame, float16ToPqRgb, buildHdrChromeArgs, launchHdrBrowser, } from "./services/hdrCapture.js";
|
|
77
77
|
export { captureScreenshotWithAlpha } from "./services/screenshotService.js";
|
|
78
|
-
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";
|
|
79
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";
|
|
80
80
|
export type { VideoColorSpace } from "./utils/ffprobe.js";
|
|
81
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,7 +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 } from "./services/aacEncode.js";
|
|
72
|
+
export { aacSegmentCount, encodeAac } from "./services/aacEncode.js";
|
|
73
73
|
export { ManagedChildProcess, } 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";
|
|
@@ -83,7 +83,7 @@ export { diffGpuParityFrames, diffGpuParityPngs, verifyGpuParity, } from "./util
|
|
|
83
83
|
export { TRANSITIONS, crossfade, sampleRgb48le, hdrToLinear, linearToHdr, convertTransfer, } from "./utils/shaderTransitions.js";
|
|
84
84
|
export { initHdrReadback, uploadAndReadbackHdrFrame, float16ToPqRgb, buildHdrChromeArgs, launchHdrBrowser, } from "./services/hdrCapture.js";
|
|
85
85
|
export { captureScreenshotWithAlpha } from "./services/screenshotService.js";
|
|
86
|
-
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, } from "./services/videoFrameInjector.js";
|
|
86
|
+
export { hideVideoElements, showVideoElements, queryVideoElementBounds, queryElementStacking, measureVideoShownBoxes, } from "./services/videoFrameInjector.js";
|
|
87
87
|
export { isHdrColorSpace, detectTransfer, getHdrEncoderColorParams, analyzeCompositionHdr, findHdrAutoPromotion, formatHdrAutoPromotionWarning, sanitizeHdrAutoPromotionAsset, DEFAULT_HDR10_MASTERING, HDR_AUTO_PROMOTION_PIPELINE, } from "./utils/hdr.js";
|
|
88
88
|
export { renderProvenanceArgs, appendRenderProvenanceArgs, readRenderProvenance, PROVENANCE_RENDERER_TAG, PROVENANCE_VERSION_TAG, PROVENANCE_RENDERER_NAME, PROVENANCE_VERSION, } from "./utils/renderProvenance.js";
|
|
89
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
|
]);
|
|
@@ -55,6 +55,13 @@ export interface ParallelProgress {
|
|
|
55
55
|
workerProgress: Map<number, number>;
|
|
56
56
|
/** Latest lifecycle transition; absent on ordinary completed-frame updates. */
|
|
57
57
|
latestWorkerPhase?: ParallelWorkerPhaseDiagnostic;
|
|
58
|
+
/** Disk capture only: the frame file this update finished writing, readable from now on. */
|
|
59
|
+
landedFrame?: LandedFrame;
|
|
60
|
+
}
|
|
61
|
+
/** A captured frame file: `index` is its file index (`frame_<index>`), `path` where it was written. */
|
|
62
|
+
export interface LandedFrame {
|
|
63
|
+
index: number;
|
|
64
|
+
path: string;
|
|
58
65
|
}
|
|
59
66
|
export type ParallelWorkerPhase = "browser_launch" | "browser_probe" | "session_init" | "frame_capture" | "frame_encode";
|
|
60
67
|
export interface ParallelWorkerPhaseDiagnostic {
|
|
@@ -400,16 +400,18 @@ async function captureFrameRange(session, task, captureOptions, signal, onFrameC
|
|
|
400
400
|
throw new Error("Parallel worker cancelled");
|
|
401
401
|
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
|
|
402
402
|
const fileFrameIdx = i - outputOffset;
|
|
403
|
+
let landedFrame;
|
|
403
404
|
if (onFrameBuffer) {
|
|
404
405
|
const { buffer } = await runCaptureOperation("frame_capture", i, () => captureFrameToBuffer(session, fileFrameIdx, time));
|
|
405
406
|
await onFrameBuffer(i, buffer, session);
|
|
406
407
|
}
|
|
407
408
|
else {
|
|
408
|
-
await runCaptureOperation("frame_capture", i, () => captureFrame(session, fileFrameIdx, time));
|
|
409
|
+
const { path } = await runCaptureOperation("frame_capture", i, () => captureFrame(session, fileFrameIdx, time));
|
|
410
|
+
landedFrame = { index: fileFrameIdx, path };
|
|
409
411
|
}
|
|
410
412
|
framesCaptured++;
|
|
411
413
|
if (onFrameCaptured)
|
|
412
|
-
onFrameCaptured(task.workerId, i);
|
|
414
|
+
onFrameCaptured(task.workerId, i, landedFrame);
|
|
413
415
|
}
|
|
414
416
|
return framesCaptured;
|
|
415
417
|
}
|
|
@@ -722,7 +724,7 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
|
|
|
722
724
|
const workerPhases = new Map();
|
|
723
725
|
for (const task of tasks)
|
|
724
726
|
workerProgress.set(task.workerId, 0);
|
|
725
|
-
const onFrameCaptured = (workerId, _frameIndex) => {
|
|
727
|
+
const onFrameCaptured = (workerId, _frameIndex, landedFrame) => {
|
|
726
728
|
const current = workerProgress.get(workerId) || 0;
|
|
727
729
|
workerProgress.set(workerId, current + 1);
|
|
728
730
|
if (onProgress) {
|
|
@@ -732,6 +734,7 @@ export async function executeParallelCapture(serverUrl, workDir, tasks, captureO
|
|
|
732
734
|
capturedFrames,
|
|
733
735
|
activeWorkers: tasks.length,
|
|
734
736
|
workerProgress: new Map(workerProgress),
|
|
737
|
+
...(landedFrame && { landedFrame }),
|
|
735
738
|
});
|
|
736
739
|
}
|
|
737
740
|
};
|
|
@@ -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
|
/**
|
|
@@ -13,6 +13,7 @@ import { createConcurrencyLimit } from "../utils/concurrencyLimit.js";
|
|
|
13
13
|
import { parseHTML } from "linkedom";
|
|
14
14
|
import { isPathInside } from "@hyperframes/parsers/asset-paths";
|
|
15
15
|
import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution";
|
|
16
|
+
import { alphaDecoderFor } from "@hyperframes/parsers/media-alpha";
|
|
16
17
|
import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
|
|
17
18
|
import { resolveReferencedStart } from "./referenceResolver.js";
|
|
18
19
|
import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
|
|
@@ -115,6 +116,12 @@ const SDR_TO_HDR_COLORSPACE_FILTER = "colorspace=all=bt2020:iall=bt709:range=tv"
|
|
|
115
116
|
const HDR_TO_SDR_ZSCALE_TRANSFORM_KEY = "hdr2sdr-hable-srgb";
|
|
116
117
|
const HDR_TO_SDR_VIDEOTOOLBOX_TRANSFORM_KEY = "hdr2sdr-videotoolbox-hdr";
|
|
117
118
|
const SDR_CANVAS_PASSTHROUGH_FILTER = "setparams=color_primaries=bt709:color_trc=iec61966-2-1";
|
|
119
|
+
function halveToCoverScaleFilter(box) {
|
|
120
|
+
const fit = `max(${Math.ceil(box.width)}/iw\\,${Math.ceil(box.height)}/ih)`;
|
|
121
|
+
const halvings = `floor(log(1/${fit})/log(2))`;
|
|
122
|
+
const side = (dim) => `if(lt(${fit}\\,0.5)\\,2*ceil(${dim}/pow(2\\,${halvings})/2)\\,${dim})`;
|
|
123
|
+
return `scale=w=${side("iw")}:h=${side("ih")}:flags=area+accurate_rnd`;
|
|
124
|
+
}
|
|
118
125
|
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
126
|
let warnedMissingZscale = false;
|
|
120
127
|
async function toneMapsWithZscale() {
|
|
@@ -639,8 +646,11 @@ outputDirOverride) {
|
|
|
639
646
|
}
|
|
640
647
|
if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
|
|
641
648
|
vfFilters.push(...(isHdr ? [] : chromeGuessForUntaggedMatrix(metadata)), SDR_CANVAS_PASSTHROUGH_FILTER);
|
|
642
|
-
if (format === "jpg")
|
|
649
|
+
if (format === "jpg") {
|
|
650
|
+
if (options.shownBox)
|
|
651
|
+
vfFilters.push(halveToCoverScaleFilter(options.shownBox));
|
|
643
652
|
vfFilters.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
|
|
653
|
+
}
|
|
644
654
|
}
|
|
645
655
|
const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
|
|
646
656
|
// Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
|
|
@@ -949,12 +959,7 @@ export function codecMayHaveAlpha(codec) {
|
|
|
949
959
|
return ALPHA_CAPABLE_CODECS.has((codec ?? "").toLowerCase());
|
|
950
960
|
}
|
|
951
961
|
export function decoderForCodec(codec) {
|
|
952
|
-
|
|
953
|
-
if (c === "vp9")
|
|
954
|
-
return "libvpx-vp9";
|
|
955
|
-
if (c === "vp8")
|
|
956
|
-
return "libvpx";
|
|
957
|
-
return c;
|
|
962
|
+
return alphaDecoderFor(codec) ?? (codec ?? "").toLowerCase();
|
|
958
963
|
}
|
|
959
964
|
export function resolveFrameFormat(metadata, requested) {
|
|
960
965
|
if (metadata.hasAlpha || codecMayHaveAlpha(metadata.videoCodec))
|
|
@@ -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
|
@@ -110,20 +110,6 @@ export declare function probeMediaProfile(filePath: string, options?: {
|
|
|
110
110
|
signal?: AbortSignal;
|
|
111
111
|
}): Promise<MediaProbeProfile>;
|
|
112
112
|
export declare function extractPngMetadataFromBuffer(buf: Buffer): StillImageMetadata | null;
|
|
113
|
-
/**
|
|
114
|
-
* Does this pix_fmt carry an alpha channel?
|
|
115
|
-
*
|
|
116
|
-
* Exported so the test asserts the shipped predicate rather than a copy of
|
|
117
|
-
* the pattern. Anchored at the start, matching studio-server's
|
|
118
|
-
* mediaMetadata.ts: the previous inline pattern bound its `(^|[^a-z])` anchor
|
|
119
|
-
* to the first alternative only — `|` is looser than concatenation — so the
|
|
120
|
-
* guard was decorative for every other name. It also missed abgr, ya8/ya16
|
|
121
|
-
* and ayuv64, and its `gray[a-z0-9]*a` branch matched only gray8a/gray16a,
|
|
122
|
-
* names FFmpeg renamed to ya8/ya16 in 2013. A `ya8` grayscale-plus-alpha PNG
|
|
123
|
-
* reported hasAlpha:false, resolveFrameFormat picked jpg, and the overlay
|
|
124
|
-
* flattened to an opaque rectangle.
|
|
125
|
-
*/
|
|
126
|
-
export declare function pixelFormatHasAlpha(pixelFormat: string): boolean;
|
|
127
113
|
/**
|
|
128
114
|
* Read an ffprobe tag case-insensitively. ffmpeg/libavformat versions disagree
|
|
129
115
|
* on tag casing — VP9 alpha is `alpha_mode` in older builds and `ALPHA_MODE`
|
package/dist/utils/ffprobe.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chunkCrc32 } from "./crc32.js";
|
|
|
5
5
|
import { StringDecoder } from "node:string_decoder";
|
|
6
6
|
import { basename } from "path";
|
|
7
7
|
import { redactTelemetryString } from "@hyperframes/core";
|
|
8
|
+
import { videoStreamHasAlpha } from "@hyperframes/parsers/media-alpha";
|
|
8
9
|
import { urlInputArgs } from "./urlInput.js";
|
|
9
10
|
import { FFPROBE_PATH_ENV, getFfprobeBinary } from "./ffmpegBinaries.js";
|
|
10
11
|
import { ManagedChildProcess } from "./managedChildProcess.js";
|
|
@@ -437,22 +438,6 @@ export function extractPngMetadataFromBuffer(buf) {
|
|
|
437
438
|
}
|
|
438
439
|
return width > 0 && height > 0 ? { width, height, colorSpace: colorSpaceFromCicp } : null;
|
|
439
440
|
}
|
|
440
|
-
/**
|
|
441
|
-
* Does this pix_fmt carry an alpha channel?
|
|
442
|
-
*
|
|
443
|
-
* Exported so the test asserts the shipped predicate rather than a copy of
|
|
444
|
-
* the pattern. Anchored at the start, matching studio-server's
|
|
445
|
-
* mediaMetadata.ts: the previous inline pattern bound its `(^|[^a-z])` anchor
|
|
446
|
-
* to the first alternative only — `|` is looser than concatenation — so the
|
|
447
|
-
* guard was decorative for every other name. It also missed abgr, ya8/ya16
|
|
448
|
-
* and ayuv64, and its `gray[a-z0-9]*a` branch matched only gray8a/gray16a,
|
|
449
|
-
* names FFmpeg renamed to ya8/ya16 in 2013. A `ya8` grayscale-plus-alpha PNG
|
|
450
|
-
* reported hasAlpha:false, resolveFrameFormat picked jpg, and the overlay
|
|
451
|
-
* flattened to an opaque rectangle.
|
|
452
|
-
*/
|
|
453
|
-
export function pixelFormatHasAlpha(pixelFormat) {
|
|
454
|
-
return /^(?:yuva|rgba|argb|bgra|abgr|gbrap|ya|ayuv)/i.test(pixelFormat);
|
|
455
|
-
}
|
|
456
441
|
function extractStillImageMetadata(filePath) {
|
|
457
442
|
try {
|
|
458
443
|
return extractPngMetadataFromBuffer(readFileSync(filePath));
|
|
@@ -613,16 +598,7 @@ export async function extractMediaMetadata(filePath) {
|
|
|
613
598
|
};
|
|
614
599
|
const colorSpace = merged.colorTransfer || merged.colorPrimaries || merged.colorSpace ? merged : null;
|
|
615
600
|
const pixelFormat = videoStream.pix_fmt || "";
|
|
616
|
-
const
|
|
617
|
-
const hasAlpha = pixelFormatHasAlpha(pixelFormat) || alphaMode === "1";
|
|
618
|
-
// Anchored at the start, matching studio-server's mediaMetadata.ts. The
|
|
619
|
-
// previous pattern bound its `(^|[^a-z])` anchor to the FIRST alternative
|
|
620
|
-
// only — `|` is looser than concatenation — so the guard was decorative
|
|
621
|
-
// for every other name. It also missed abgr, ya8/ya16 and ayuv64, and its
|
|
622
|
-
// `gray[a-z0-9]*a` branch matched only gray8a/gray16a, names FFmpeg
|
|
623
|
-
// renamed to ya8/ya16 in 2013. A `ya8` grayscale-plus-alpha PNG reported
|
|
624
|
-
// hasAlpha:false, resolveFrameFormat picked jpg, and the overlay
|
|
625
|
-
// flattened to an opaque rectangle.
|
|
601
|
+
const hasAlpha = videoStreamHasAlpha(videoStream);
|
|
626
602
|
const containerDuration = output?.format.duration ? parseFloat(output.format.duration) : 0;
|
|
627
603
|
const streamDuration = videoStream.duration ? parseFloat(videoStream.duration) : 0;
|
|
628
604
|
const parsedStreamStart = videoStream.start_time ? parseFloat(videoStream.start_time) : 0;
|
|
@@ -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
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/engine",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.184",
|
|
4
4
|
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"linkedom": "^0.18.12",
|
|
51
51
|
"puppeteer": "^25.8.0",
|
|
52
52
|
"puppeteer-core": "^25.8.0",
|
|
53
|
-
"@hyperframes/core": "^0.8.
|
|
54
|
-
"@hyperframes/parsers": "^0.8.
|
|
53
|
+
"@hyperframes/core": "^0.8.184",
|
|
54
|
+
"@hyperframes/parsers": "^0.8.184"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@types/node": "^25.0.10",
|