@hyperframes/engine 0.8.181 → 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.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,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 a 48 kHz WAV of exactly `totalDuration`. */
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 on each side of a segment and dropped. The encoder's rate control takes about
14
- // 10 s to settle (its error is higher until then), so a segment starts 10 s before its cut.
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
- export const AAC_ARGS = ["-acodec", "aac", "-b:a", "192k"];
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 = Math.max(0, cut - PRE_ROLL_FRAMES);
44
- const end = k === count - 1 ? "" : `:end_sample=${(next + POST_ROLL_FRAMES) * FRAME_SAMPLES}`;
45
- const trim = `atrim=start_sample=${firstFrame * FRAME_SAMPLES}${end}`;
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 a 48 kHz WAV of exactly `totalDuration`. */
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(["-i", input, "-af", trim, ...AAC_ARGS, "-f", "adts", "-y", path], {
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 { AAC_ARGS, aacSegmentCount, encodeAac } from "./aacEncode.js";
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
- const metadata = await extractAudioMetadata(srcPath).catch(() => null);
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 = inputSeek
513
- ? ["-ss", String(mediaStart), "-t", String(sourceDuration), "-i", srcPath]
514
- : ["-i", srcPath];
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
- const failure = !result.success ? ffmpegFailure(stage, result) : undefined;
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, floatWav = false) {
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
- ...(floatWav ? ["-acodec", "pcm_f32le", "-ar", "48000"] : AAC_ARGS),
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, true);
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
- srcPath = config?.urlByAbsentLocalPath?.get(srcPath) ?? srcPath;
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
- (masterChain || aacSegmentCount(totalDuration) > 1) && tracks.length > 0
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
- if (process.env.PRODUCER_HEADLESS_SHELL_PATH) {
190
- const envPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
191
- if (!existsSync(envPath)) {
192
- throw new Error(`[BrowserManager] Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH="${envPath}". ` +
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 envPath;
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
- const args = [...inputArgs, "-r", fpsToFfmpegArg(fps)];
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(onFramesEncoded),
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(formatFfmpegError(result.exitCode, result.stderr), result.terminationReason === "deadline", encodeTimeout),
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(onFramesEncoded && ((frames) => onFramesEncoded(startNumber + frames))),
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
- pollImagesReady(page, pageReadyTimeout).then(async (ready) => {
1773
- if (!ready) {
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
- pollImagesReady(page, pageReadyTimeout).then(async (ready) => {
1903
- if (!ready) {
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
- * 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
- 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
  /**
@@ -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() {
@@ -639,8 +645,11 @@ outputDirOverride) {
639
645
  }
640
646
  if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
641
647
  vfFilters.push(...(isHdr ? [] : chromeGuessForUntaggedMatrix(metadata)), SDR_CANVAS_PASSTHROUGH_FILTER);
642
- if (format === "jpg")
648
+ if (format === "jpg") {
649
+ if (options.shownBox)
650
+ vfFilters.push(halveToCoverScaleFilter(options.shownBox));
643
651
  vfFilters.push(SDR_JPEG_AS_BT601_FULL_RANGE_FILTER);
652
+ }
644
653
  }
645
654
  const encodeArgs = ["-q:v", format === "jpg" ? String(Math.ceil((100 - quality) / 3)) : "0"];
646
655
  // Render-scoped temp frames are read once; level 1 measured 3-5x faster for ~14% larger files.
@@ -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.
@@ -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 every ffprobe input is local — an http src is
16
- * downloaded first. So the shapes that land here are a soft-404 or
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 every ffprobe input is local — an http src is
20
- * downloaded first. So the shapes that land here are a soft-404 or
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.181",
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/core": "^0.8.181",
54
- "@hyperframes/parsers": "^0.8.181"
53
+ "@hyperframes/parsers": "^0.8.183",
54
+ "@hyperframes/core": "^0.8.183"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",