@hyperframes/engine 0.8.190 → 0.8.192

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
46
46
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, type GpuEncoder, type PackageHlsOptions, } from "./services/chunkEncoder.js";
47
47
  export type { EncoderOptions, EncodeResult, MuxResult } from "./services/chunkEncoder.types.js";
48
48
  export { spawnStreamingEncoder, createFrameReorderBuffer, type StreamingEncoder, type StreamingEncoderOptions, type StreamingEncoderResult, type FrameReorderBuffer, } from "./services/streamingEncoder.js";
49
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
49
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, type VideoElement, type ImageElement, type ExtractedFrames, type DeferredFrameRange, type FrameRange, type ExtractionOptions, type ExtractionResult, type ExtractionPhaseBreakdown, type TimelineExtractionWindow, type VideoExtractionFailure, type VideoExtractionFailureKind, type VideoExtractionFailureGroupDetails, type VideoExtractionFailureRetry, type VideoExtractionFailureStatusClass, type SafeVideoExtractionSourceIdentity, type VideoFrameFormat, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
50
50
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
51
51
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
52
52
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
@@ -64,7 +64,7 @@ export type { MotionBlurOptions, MotionBlurBlendSpace } from "./services/motionB
64
64
  export type { PageMotionBlurOff } from "./types.js";
65
65
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
66
66
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
67
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
67
+ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, type VideoMetadata, type AudioMetadata, type MediaProbeProfile, type KeyframeAnalysis, } from "./utils/ffprobe.js";
68
68
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
69
69
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
70
70
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ export { beginFrameCapture, pageScreenshotCapture, getCdpSession, injectVideoFra
50
50
  export { buildEncoderArgs, encodeFramesFromDir, buildConcatArgs, concatVideoFiles, encodeFramesChunkedConcat, muxVideoWithAudio, applyFaststart, packageHls, detectGpuEncoder, ENCODER_PRESETS, getEncoderPreset, HLS_MASTER_PLAYLIST, HLS_VIDEO_PLAYLIST, HLS_AUDIO_PLAYLIST, } from "./services/chunkEncoder.js";
51
51
  export { spawnStreamingEncoder, createFrameReorderBuffer, } from "./services/streamingEncoder.js";
52
52
  // ── Media processing ───────────────────────────────────────────────────────────
53
- export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
53
+ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extractAllVideoFrames, isVideoPastTimelineEnd, resolveTimelineExtractionWindow, resolveVideoExtractionWindow, rebaseVideoToWindow, resolveFinalFrameExtractionWindow, resolveVideoExtractionDuration, resolvePlayableVideoDuration, extractionFrameCountForDuration, resolveProjectRelativeSrc, getFrameAtTime, isVideoHiddenBeforeStreamStart, createFrameLookupTable, FrameLookupTable, analyzeClipMediaFit, classifyVideoExtractionError, isVideoSourceExtractionError, runVideoExtractionWithRetry, safeVideoExtractionSourceIdentity, VideoSourceExtractionError, VIDEO_FRAME_FORMATS, isVideoFrameFormat, EXTRACT_CACHE_MIN_AGE_MS, } from "./services/videoFrameExtractor.js";
54
54
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
55
55
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
56
56
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
@@ -68,7 +68,7 @@ export { appendSoftwareEncoderThreadsArg } from "./services/softwareEncoderThrea
68
68
  export { frameSequencePath } from "./services/extractedFrameIndex.js";
69
69
  export { assertSwiftShader, readWebGlVendorInfo, SwiftShaderAssertionError, BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js";
70
70
  export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js";
71
- export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
71
+ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimestamp, extractAudioMetadata, declareContentAddressedUrl, probeMediaProfile, analyzeKeyframeIntervals, } from "./utils/ffprobe.js";
72
72
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
73
73
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
74
74
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
@@ -37,5 +37,6 @@ export declare function processCompositionAudio(elements: AudioElement[], baseDi
37
37
  /** Serialised master bus chain, from `parseMasterFxChain`. */
38
38
  masterFxChain?: string;
39
39
  urlByAbsentLocalPath?: ReadonlyMap<string, string>;
40
+ audioSidecarUrlByLocalPath?: ReadonlyMap<string, string>;
40
41
  keepEncoderInputAt?: string;
41
42
  }, compiledDir?: string): Promise<MixResult>;
@@ -133,6 +133,8 @@ function legacyFilterScriptOptionIsUnsupported(stderr) {
133
133
  */
134
134
  // Caps the ffmpeg runs that prepare clips; all of a long mix's clips at once starved the host (200 clips, 200 ffmpeg).
135
135
  const inPrepareSlot = createConcurrencyLimit(Math.max(2, Math.floor(cpus().length / 2)));
136
+ // A clip read from a URL spends its time waiting on the network, so it needs no CPU-sized limit.
137
+ const inRemotePrepareSlot = createConcurrencyLimit(16);
136
138
  const MAX_VOLUME_SEGMENTS = 32;
137
139
  /**
138
140
  * Volume delta below which a keyframe is collinear enough to drop. Kept tight
@@ -428,11 +430,13 @@ export function parseAudioElements(html) {
428
430
  const startCache = new Map();
429
431
  const visiting = new Set();
430
432
  const resolveStart = (el) => el.getAttribute("data-start") ? resolveReferencedStart(document, el, startCache, visiting) : 0;
431
- // `end` stays a plain numeric read (the mixer derives the real segment length
432
- // from data-duration / natural media downstream); guard NaN so a malformed
433
- // value never poisons the mix instead of falling back to 0.
434
- const parseEnd = (raw) => {
435
- return parseStrictFiniteTimingNumber(raw) ?? 0;
433
+ // Same rule as core's compileTimingAttrs: data-end, else start + data-duration, else 0 (unknown).
434
+ const resolveEnd = (el) => {
435
+ const end = parseStrictFiniteTimingNumber(el.getAttribute("data-end"));
436
+ if (end != null)
437
+ return end;
438
+ const duration = parseStrictFiniteTimingNumber(el.getAttribute("data-duration"));
439
+ return duration != null && duration > 0 ? resolveStart(el) + duration : 0;
436
440
  };
437
441
  const isHidden = isSelfOrAncestorHidden;
438
442
  // Resolved once per parse. A group element carrying `data-hidden` drops
@@ -454,9 +458,10 @@ export function parseAudioElements(html) {
454
458
  id,
455
459
  src,
456
460
  start: resolveStart(el),
457
- end: parseEnd(el.getAttribute("data-end")),
461
+ end: resolveEnd(el),
458
462
  mediaStart: readMediaStart(el),
459
463
  playbackRate: readElementRateSpec(el),
464
+ loop: el.getAttribute("loop") !== null,
460
465
  layer: layerAttr ? parseInt(layerAttr) : 0,
461
466
  volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
462
467
  ...(fades.fadeIn > 0 ? { fadeIn: fades.fadeIn } : {}),
@@ -503,16 +508,17 @@ export function parseAudioElements(html) {
503
508
  return elements;
504
509
  }
505
510
  const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
511
+ const SIDECAR_WARM_UP_SECONDS = 1;
506
512
  /** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
507
513
  * started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
508
- async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config) {
514
+ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl, options) {
509
515
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
510
516
  const outputDir = dirname(outputPath);
511
517
  if (!existsSync(outputDir))
512
518
  mkdirSync(outputDir, { recursive: true });
513
519
  const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
514
520
  const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
515
- // A failed local probe keeps FFmpeg's default channel conversion and the input seek; a URL probe
521
+ // A failed non-looping local probe keeps FFmpeg's channel conversion and input seek; a URL probe
516
522
  // is retried once, then fails: a blip must not change how the next, successful read is mixed.
517
523
  const readsUrl = isHttpUrl(srcPath);
518
524
  const metadata = readsUrl
@@ -521,22 +527,78 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
521
527
  throw error;
522
528
  return extractAudioMetadata(srcPath, { signal });
523
529
  })
524
- : await extractAudioMetadata(srcPath).catch(() => null);
530
+ : await extractAudioMetadata(srcPath).catch((error) => {
531
+ if (options?.loop)
532
+ throw error;
533
+ return null;
534
+ });
535
+ const loopDuration = (metadata?.durationSeconds ?? 0) - mediaStart;
536
+ if (options?.loop && loopDuration > 0 && sourceDuration > loopDuration) {
537
+ const cycleDir = mkdtempSync(join(outputDir, ".audio-loop-"));
538
+ const cyclePath = join(cycleDir, "cycle.wav");
539
+ try {
540
+ const cycle = await extractAudioSegment(srcPath, cyclePath, stage, mediaStart, loopDuration, 1, signal, config, audioSidecarUrl, { padToDuration: true });
541
+ if (!cycle.success)
542
+ return { ...cycle, outputPath };
543
+ const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, null);
544
+ const result = await runFfmpeg([
545
+ "-stream_loop",
546
+ "-1",
547
+ "-t",
548
+ String(sourceDuration),
549
+ "-i",
550
+ cyclePath,
551
+ "-vn",
552
+ "-acodec",
553
+ "pcm_s16le",
554
+ "-ar",
555
+ "48000",
556
+ ...outputArgs,
557
+ "-t",
558
+ String(duration),
559
+ "-y",
560
+ outputPath,
561
+ ], { signal, timeout: ffmpegProcessTimeout });
562
+ const failure = result.success
563
+ ? undefined
564
+ : ffmpegFailure(stage, result, ffmpegProcessTimeout);
565
+ return {
566
+ success: result.success,
567
+ outputPath,
568
+ durationMs: cycle.durationMs + result.durationMs,
569
+ error: failure?.detail,
570
+ failure,
571
+ };
572
+ }
573
+ finally {
574
+ rmSync(cycleDir, { recursive: true, force: true });
575
+ }
576
+ }
525
577
  const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
526
578
  const start = formatFilterNumber(mediaStart);
527
579
  const timestampTrim = inputSeek
528
580
  ? null
529
581
  : `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
530
- const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, timestampTrim);
582
+ const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
583
+ const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
584
+ const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, [
585
+ warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim,
586
+ ...(options?.padToDuration ? ["apad"] : []),
587
+ ]
588
+ .filter(Boolean)
589
+ .join(",") || null);
590
+ const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
531
591
  const args = [
532
592
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
533
- ...urlInputArgs(srcPath),
534
- ...(inputSeek ? ["-ss", String(mediaStart), "-t", String(sourceDuration)] : []),
593
+ ...urlInputArgs(readFrom),
594
+ ...(inputSeek
595
+ ? ["-ss", String(mediaStart - warmUp), "-t", String(sourceDuration + warmUp)]
596
+ : []),
535
597
  "-i",
536
- srcPath,
598
+ readFrom,
537
599
  ];
538
600
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
539
- if (normalizedPlaybackRate !== 1)
601
+ if (normalizedPlaybackRate !== 1 || options?.padToDuration)
540
602
  args.push("-t", String(duration));
541
603
  args.push("-y", outputPath);
542
604
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
@@ -569,6 +631,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
569
631
  failure,
570
632
  };
571
633
  }
634
+ function isRemoteElement(element, baseDir, compiledDir, config) {
635
+ if (isHttpUrl(element.src))
636
+ return true;
637
+ if (!config?.urlByAbsentLocalPath?.size)
638
+ return false;
639
+ try {
640
+ return config.urlByAbsentLocalPath.has(resolveProjectRelativeSrc(element.src, baseDir, compiledDir));
641
+ }
642
+ catch {
643
+ return false;
644
+ }
645
+ }
572
646
  async function generateSilence(outputPath, duration, signal, config) {
573
647
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
574
648
  const outputDir = dirname(outputPath);
@@ -1047,7 +1121,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1047
1121
  else
1048
1122
  signal.addEventListener("abort", () => internalController.abort(), { once: true });
1049
1123
  }
1050
- await Promise.all(elements.map((element, index) => inPrepareSlot(async () => {
1124
+ await Promise.all(elements.map((element, index) => (isRemoteElement(element, baseDir, compiledDir, config)
1125
+ ? inRemotePrepareSlot
1126
+ : inPrepareSlot)(async () => {
1051
1127
  if (effectiveSignal.aborted) {
1052
1128
  failures.push({
1053
1129
  stage: "cancelled",
@@ -1062,11 +1138,13 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1062
1138
  try {
1063
1139
  let srcPath = element.src;
1064
1140
  let readInPlace = false;
1141
+ let audioSidecarUrl;
1065
1142
  if (!isHttpUrl(srcPath)) {
1066
1143
  // Same browser-URL path semantics as videos.
1067
1144
  srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
1068
1145
  const remoteUrl = config?.urlByAbsentLocalPath?.get(srcPath);
1069
1146
  if (remoteUrl) {
1147
+ audioSidecarUrl = config?.audioSidecarUrlByLocalPath?.get(srcPath);
1070
1148
  srcPath = remoteUrl;
1071
1149
  readInPlace = true;
1072
1150
  }
@@ -1107,6 +1185,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1107
1185
  }
1108
1186
  // Fallback: if no duration was specified, probe the actual file
1109
1187
  if (element.end - element.start <= 0) {
1188
+ console.warn(`[audioMixer] ${element.id} has no data-duration or data-end; its length is unknown, so it plays to the end of its source`);
1110
1189
  let metadata;
1111
1190
  try {
1112
1191
  metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
@@ -1123,7 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1123
1202
  let audioSrcPath = srcPath;
1124
1203
  if (element.type === "video") {
1125
1204
  const extractedPath = join(workDir, `${element.id}-extracted.wav`);
1126
- const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
1205
+ const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl, { loop: element.loop });
1127
1206
  if (!extractResult.success) {
1128
1207
  failures.push(extractResult.failure
1129
1208
  ? { ...extractResult.failure, elementId: element.id }
@@ -1141,7 +1220,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1141
1220
  }
1142
1221
  else {
1143
1222
  const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
1144
- const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config);
1223
+ const prepResult = await extractAudioSegment(srcPath, trimmedPath, "prepare", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, undefined, { loop: element.loop });
1145
1224
  if (!prepResult.success) {
1146
1225
  failures.push(prepResult.failure
1147
1226
  ? { ...prepResult.failure, elementId: element.id }
@@ -11,6 +11,7 @@ export interface AudioElement {
11
11
  mediaStart: number;
12
12
  /** Normalized source-time multiplier: a constant, or the clip's `rate` lane. */
13
13
  playbackRate?: RateSpec;
14
+ loop?: boolean;
14
15
  layer: number;
15
16
  volume?: number;
16
17
  volumeKeyframes?: AudioVolumeKeyframe[];
@@ -214,7 +214,7 @@ export function buildEncoderArgs(options, inputArgs, outputPath, gpuEncoder = nu
214
214
  else {
215
215
  const encoderName = codec === "h264" ? "libx264" : "libx265";
216
216
  args.push("-c:v", encoderName, "-preset", preset);
217
- appendSoftwareEncoderThreadsArg(args);
217
+ appendSoftwareEncoderThreadsArg(args, encoderName);
218
218
  if (bitrate)
219
219
  args.push("-b:v", bitrate);
220
220
  else
@@ -934,6 +934,7 @@ export async function produceDrawElementFrameBatch(page, times, width, height, q
934
934
  try {
935
935
  if (aw.__hf && typeof aw.__hf.seek === "function")
936
936
  aw.__hf.seek(t);
937
+ await aw.__hfWaitForSeekCompletion?.();
937
938
  await aw.__hfDecodeDynamicCssBackgroundImages?.();
938
939
  aw.__hf3d?.update();
939
940
  const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
@@ -172,7 +172,7 @@ function isTargetExistsRenameError(err) {
172
172
  * winner is always correct. Returns null when no winner is present.
173
173
  */
174
174
  function adoptPublishedWinner(entry, partialDir) {
175
- if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)))
175
+ if (!existsSync(join(entry.dir, COMPLETE_SENTINEL)) || !hasFrameFiles(entry.dir))
176
176
  return null;
177
177
  removeDir(partialDir);
178
178
  return { dir: entry.dir, published: true };
@@ -98,6 +98,7 @@ export interface CaptureSession {
98
98
  * our console output.
99
99
  */
100
100
  pendingTimelineIds?: string[];
101
+ isolatedCompositions?: IsolatedComposition[];
101
102
  /** The render's shared memo when the caller passed one, else this session's own. */
102
103
  subTimelineWaitMemo: SubTimelineWaitMemo;
103
104
  /** Structured readiness warnings surfaced to the producer's render policy. */
@@ -164,6 +165,12 @@ export interface CaptureSession {
164
165
  * drawElement intermittently returns an anomalously small (blank) frame with no
165
166
  * throw; a frame far below the running median is re-captured via screenshot. */
166
167
  deFrameSizes?: number[];
168
+ /** Absolute floor of the serial drawElement size guard. Starts at 20000 bytes and drops to
169
+ * half of a small frame that redraws byte-identically. */
170
+ deAbsFloor?: number;
171
+ /** The last small drawElement frame that redrew byte-identically; an equal frame is kept
172
+ * without a redraw. */
173
+ deAcceptedSmall?: Buffer;
167
174
  /** Last non-deduped encode result, reused for a static frame on the drawElement
168
175
  * worker-encode path (mirrors `lastFrameBuffer` on the screenshot path). Only set
169
176
  * when static-frame dedup is armed on the drawElement path. */
@@ -404,16 +411,25 @@ export interface HfDiagnostic {
404
411
  }
405
412
  export declare function buildZeroDurationDiagnostic(diag: HfDiagnostic): string;
406
413
  export declare function pollHfReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<void>;
407
- export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number): Promise<void>;
414
+ export declare function waitForSubCompositionTimelines(session: CaptureSession, page: Page, timeoutMs: number, options?: {
415
+ scriptErrorGraceMs?: number;
416
+ }): Promise<void>;
417
+ export interface IsolatedComposition {
418
+ compositionId: string;
419
+ elementId: string | null;
420
+ }
408
421
  export interface SubTimelinePollOptions {
409
422
  intervalMs?: number;
410
423
  getScriptLoadFailures?: () => readonly string[];
411
424
  scriptFailureGraceMs?: number;
425
+ getPageErrors?: () => readonly string[];
426
+ scriptErrorGraceMs?: number;
412
427
  onPending?: (ids: readonly string[]) => void;
413
428
  shouldStop?: () => boolean;
414
429
  knownUnregisteredIds?: readonly string[];
415
430
  }
416
- export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
431
+ export declare function pollSubCompositionTimelines(page: Page, timeoutMs: number, { intervalMs, getScriptLoadFailures, scriptFailureGraceMs, getPageErrors, scriptErrorGraceMs, onPending, shouldStop, knownUnregisteredIds, }?: SubTimelinePollOptions): Promise<SubTimelineWaitOutcome>;
432
+ export declare function pollVideosReady(page: Page, skipIds: readonly string[], timeoutMs: number, intervalMs?: number): Promise<boolean>;
417
433
  /** @internal exported for unit testing only */
418
434
  export declare function pollImagesReady(page: Page, timeoutMs: number, intervalMs?: number): Promise<boolean>;
419
435
  export declare function waitForImagesReady(page: Page, timeoutMs: number, onUnloaded: (sources: string) => void): Promise<boolean>;
@@ -1202,16 +1202,61 @@ async function listUnregisteredTimelineIds(page) {
1202
1202
  // Builds a warning, so it must never throw: normalise a loosely typed result.
1203
1203
  return Array.isArray(evaluated) ? evaluated.map((id) => String(id)) : [];
1204
1204
  }
1205
- export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
1205
+ function isScriptFailure(session) {
1206
+ const outcome = session.subTimelineWaitOutcome;
1207
+ return outcome === "script_failure" || (outcome === "timeout" && session.pageErrors.length > 0);
1208
+ }
1209
+ /** A nested composition whose script threw gets an empty timeline, but only while another nested timeline is live. */
1210
+ async function isolateUnregisteredCompositions(page) {
1211
+ const isolated = await page.evaluate(`(function() {
1212
+ var timelines = (window.__timelines = window.__timelines || {});
1213
+ var out = [];
1214
+ if (!window.gsap) return out;
1215
+ var broken = [];
1216
+ var live = 0;
1217
+ var hosts = document.querySelectorAll("[data-composition-id]");
1218
+ for (var i = 0; i < hosts.length; i++) {
1219
+ var host = hosts[i];
1220
+ var id = host.getAttribute("data-composition-id");
1221
+ if (!id || host.hasAttribute("data-no-timeline")) continue;
1222
+ var isRoot = !host.parentElement || !host.parentElement.closest("[data-composition-id]");
1223
+ if (timelines[id]) {
1224
+ if (!isRoot) live++;
1225
+ } else if (isRoot) {
1226
+ return out;
1227
+ } else {
1228
+ broken.push(host);
1229
+ }
1230
+ }
1231
+ if (live === 0) return out;
1232
+ broken.forEach(function(host) {
1233
+ var tl = window.gsap.timeline({ paused: true });
1234
+ var duration = parseFloat(host.getAttribute("data-duration"));
1235
+ if (duration > 0) tl.set({}, {}, duration);
1236
+ var id = host.getAttribute("data-composition-id");
1237
+ timelines[id] = tl;
1238
+ out.push({ compositionId: id, elementId: host.id || host.getAttribute("data-hf-id") || null });
1239
+ });
1240
+ if (out.length && typeof window.__hfForceTimelineRebind === "function") window.__hfForceTimelineRebind();
1241
+ return out;
1242
+ })()`);
1243
+ return Array.isArray(isolated) ? isolated : [];
1244
+ }
1245
+ export async function waitForSubCompositionTimelines(session, page, timeoutMs, options = {}) {
1206
1246
  const memo = session.subTimelineWaitMemo;
1207
1247
  session.subTimelineWaitOutcome = await pollSubCompositionTimelines(page, timeoutMs, {
1208
1248
  getScriptLoadFailures: () => session.scriptLoadFailures,
1249
+ getPageErrors: () => session.pageErrors,
1250
+ scriptErrorGraceMs: options.scriptErrorGraceMs,
1209
1251
  onPending: (ids) => {
1210
1252
  session.pendingTimelineIds = [...ids];
1211
1253
  },
1212
1254
  shouldStop: () => session.vfxFailure !== undefined,
1213
1255
  knownUnregisteredIds: memo.unregisteredIds,
1214
1256
  });
1257
+ if (isScriptFailure(session) && session.scriptLoadFailures.length === 0) {
1258
+ session.isolatedCompositions = await isolateUnregisteredCompositions(page);
1259
+ }
1215
1260
  // Only a clean full-length timeout is replayed: a VFX stop ends early, and page or script
1216
1261
  // errors must reach each session so it can still classify the wait as a script failure.
1217
1262
  const cleanTimeout = session.subTimelineWaitOutcome === "timeout" &&
@@ -1222,7 +1267,7 @@ export async function waitForSubCompositionTimelines(session, page, timeoutMs) {
1222
1267
  memo.unregisteredIds = session.pendingTimelineIds ?? [];
1223
1268
  }
1224
1269
  }
1225
- export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
1270
+ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs = 150, getScriptLoadFailures, scriptFailureGraceMs = 2_000, getPageErrors, scriptErrorGraceMs = 5_000, onPending, shouldStop, knownUnregisteredIds = [], } = {}) {
1226
1271
  // Hosts may opt out of the timeline wait with `data-no-timeline` —
1227
1272
  // compositions driven purely by CSS animations / rAF (the render-compat
1228
1273
  // contract) never register window.__timelines[id], and without the opt-out
@@ -1244,6 +1289,8 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1244
1289
  const deadline = start + timeoutMs;
1245
1290
  let ready = false;
1246
1291
  let scriptFailureBail = false;
1292
+ // A page error is given its grace from when it first appeared, not from when the poll began.
1293
+ let errorSeenAt;
1247
1294
  for (;;) {
1248
1295
  ready = Boolean(await page.evaluate(expression));
1249
1296
  if (ready || shouldStop?.())
@@ -1252,12 +1299,18 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1252
1299
  if (now >= deadline)
1253
1300
  break;
1254
1301
  const failures = getScriptLoadFailures?.() ?? [];
1255
- if (failures.length > 0 && now - start >= scriptFailureGraceMs) {
1302
+ const errors = getPageErrors?.() ?? [];
1303
+ const failed = failures.length > 0 ? failures : errors;
1304
+ const graceMs = failures.length > 0 ? scriptFailureGraceMs : scriptErrorGraceMs;
1305
+ if (failed.length > 0)
1306
+ errorSeenAt ??= now;
1307
+ const sinceFailure = failures.length > 0 ? now - start : now - (errorSeenAt ?? now);
1308
+ if (failed.length > 0 && sinceFailure >= graceMs) {
1256
1309
  scriptFailureBail = true;
1257
1310
  console.warn(`[FrameCapture] Sub-composition timeline wait cut short after ${now - start}ms: ` +
1258
- `script resource(s) failed to load (${failures.join(", ")}), so ` +
1311
+ `script(s) failed (${failed.join(", ")}), so ` +
1259
1312
  `the timeline registration they carry can never arrive. ` +
1260
- `Fix the script reference; the render proceeds without those animations.`);
1313
+ `Fix the script.`);
1261
1314
  break;
1262
1315
  }
1263
1316
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
@@ -1294,11 +1347,13 @@ export async function pollSubCompositionTimelines(page, timeoutMs, { intervalMs
1294
1347
  }
1295
1348
  return scriptFailureBail ? "script_failure" : "timeout";
1296
1349
  }
1297
- async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1350
+ export async function pollVideosReady(page, skipIds, timeoutMs, intervalMs = 100) {
1298
1351
  const check = async () => {
1299
1352
  return Boolean(await page.evaluate((skipIdList) => {
1300
1353
  const skip = new Set(skipIdList);
1301
- const vids = Array.from(document.querySelectorAll("video")).filter((v) => !skip.has(v.id));
1354
+ const vids = Array.from(document.querySelectorAll("video"))
1355
+ .filter((v) => !skip.has(v.id))
1356
+ .filter((v) => !v.closest("[data-hidden]"));
1302
1357
  return (vids.length === 0 ||
1303
1358
  vids.every((v) => {
1304
1359
  const ve = v;
@@ -1449,16 +1504,33 @@ export function recordSubTimelineWarning(session, timeoutMs) {
1449
1504
  const outcome = session.subTimelineWaitOutcome;
1450
1505
  if (outcome === "ready" || !outcome)
1451
1506
  return;
1452
- const threwThenTimedOut = outcome === "timeout" && session.pageErrors.length > 0;
1453
- const scriptFailure = outcome === "script_failure" || threwThenTimedOut;
1507
+ const threwThenTimedOut = session.pageErrors.length > 0 && session.scriptLoadFailures.length === 0;
1508
+ const scriptFailure = isScriptFailure(session);
1454
1509
  const sources = [...session.scriptLoadFailures, ...session.pageErrors];
1455
1510
  const pending = session.pendingTimelineIds ?? [];
1456
1511
  const pendingSuffix = pending.length > 0 ? ` (still unregistered: ${pending.join(", ")})` : "";
1512
+ const isolated = session.isolatedCompositions ?? [];
1513
+ if (isolated.length > 0) {
1514
+ recordCaptureWarnings(session, [
1515
+ {
1516
+ code: "script_error_isolated",
1517
+ message: `A composition script failed, so its animation was skipped and the render went on: ` +
1518
+ `${isolated.map((c) => c.elementId ?? c.compositionId).join(", ")} (${sources.join(", ")}).`,
1519
+ details: {
1520
+ sources,
1521
+ pendingCompositionIds: [...pending],
1522
+ isolatedElements: isolated,
1523
+ failureOwner: "user",
1524
+ },
1525
+ },
1526
+ ]);
1527
+ return;
1528
+ }
1457
1529
  recordCaptureWarnings(session, [
1458
1530
  {
1459
1531
  code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
1460
1532
  message: threwThenTimedOut
1461
- ? `A composition script threw and a timeline did not register within ${timeoutMs}ms` +
1533
+ ? `A composition script threw before its timeline registered` +
1462
1534
  `${pendingSuffix} (${sources.join(", ")}). Fix the error; a composition animated by ` +
1463
1535
  `CSS or rAF rather than a GSAP timeline must mark its host with data-no-timeline.`
1464
1536
  : scriptFailure
@@ -1802,6 +1874,7 @@ export async function initializeSession(session) {
1802
1874
  const skip = new Set(skipIdList);
1803
1875
  return Array.from(document.querySelectorAll("video"))
1804
1876
  .filter((v) => !skip.has(v.id))
1877
+ .filter((v) => !v.closest("[data-hidden]"))
1805
1878
  .filter((v) => v.readyState < 2 && !v.error)
1806
1879
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1807
1880
  .join(", ");
@@ -1914,6 +1987,7 @@ export async function initializeSession(session) {
1914
1987
  const skip = new Set(skipIdList);
1915
1988
  return Array.from(document.querySelectorAll("video"))
1916
1989
  .filter((v) => !skip.has(v.id))
1990
+ .filter((v) => !v.closest("[data-hidden]"))
1917
1991
  .filter((v) => v.readyState < 2 && !v.error)
1918
1992
  .map((v) => v.src || v.getAttribute("src") || "(no src)")
1919
1993
  .join(", ");
@@ -2925,6 +2999,50 @@ export function resolveSessionMotionBlur(session) {
2925
2999
  }
2926
3000
  return plan;
2927
3001
  }
3002
+ /**
3003
+ * Size guard for a serial drawElement frame. A frame far below the rolling median may be a
3004
+ * dropped paint record, which is intermittent, so the frame is drawn once more: a byte-identical
3005
+ * redraw is real content (a flat or dark frame) and is kept, as the streaming drain guard in the
3006
+ * producer keeps it. Segmented capture reaches only this guard, so it must judge a small frame
3007
+ * the same way, or a render that passes in one pass fails when segmented (GH #5685). A redraw
3008
+ * that differs and is still small throws, and the caller restarts on a fresh screenshot page.
3009
+ */
3010
+ async function judgeSmallDrawElementFrame(session, frameIndex, buffer, redraw) {
3011
+ const sizes = (session.deFrameSizes ??= []);
3012
+ const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
3013
+ const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
3014
+ const floor = Math.max(session.deAbsFloor ?? 20000, median * 0.12);
3015
+ let kept = buffer;
3016
+ if (buffer.length < floor && !session.deAcceptedSmall?.equals(buffer)) {
3017
+ const suspect = () => new DrawElementCaptureError(frameIndex, `suspect small frame (${buffer.length}B < ${Math.round(floor)}B)`);
3018
+ // Under BeginFrame control the page paints only on the frame already sent for this index,
3019
+ // so a second draw reads the same snapshot and cannot tell a drop from real content.
3020
+ if (session.beginFrameTimeTicks > 0)
3021
+ throw suspect();
3022
+ const retry = await redraw();
3023
+ if (retry.equals(buffer)) {
3024
+ // Halving the floor keeps a run of similar flat frames from redrawing every frame.
3025
+ session.deAbsFloor = Math.min(session.deAbsFloor ?? 20000, Math.floor(buffer.length / 2));
3026
+ session.deAcceptedSmall = buffer;
3027
+ }
3028
+ else if (retry.length < floor) {
3029
+ throw suspect();
3030
+ }
3031
+ else {
3032
+ kept = retry;
3033
+ }
3034
+ }
3035
+ if (sizes.length >= 60)
3036
+ sizes.shift();
3037
+ sizes.push(kept.length);
3038
+ return kept;
3039
+ }
3040
+ /** Seek to `time` again and draw the frame once more, for {@link judgeSmallDrawElementFrame}. */
3041
+ async function redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime) {
3042
+ const { page, options } = session;
3043
+ await prepareFrameForCapture(session, frameIndex, time, seekOptions, heldVideoTime);
3044
+ return captureDrawElementFrame(page, options.width, options.height, options.format ?? "jpeg", options.quality ?? 80, true);
3045
+ }
2928
3046
  /** Seek to `time` and capture one surface with the session's capture mode. */
2929
3047
  async function captureFrameSurface(session, frameIndex, time, seekOptions, heldVideoTime) {
2930
3048
  const { page, options } = session;
@@ -2976,20 +3094,8 @@ async function captureFrameSurface(session, frameIndex, time, seekOptions, heldV
2976
3094
  session.beginFrameTimeTicks === 0);
2977
3095
  // A tiny JPEG may be a dropped paint record. Never screenshot this page:
2978
3096
  // its injected canvas can still hold the preceding frame's bitmap.
2979
- // Restart on a fresh screenshot page even if this was a simple valid frame.
2980
3097
  if ((options.format ?? "jpeg") !== "png" && process.env.HF_FORCE_DRAWELEMENT !== "1") {
2981
- const sizes = (session.deFrameSizes ??= []);
2982
- const sorted = sizes.length >= 12 ? [...sizes].sort((a, b) => a - b) : null;
2983
- const median = sorted ? (sorted[sorted.length >> 1] ?? 0) : 0;
2984
- const floor = Math.max(20000, median * 0.12);
2985
- if (screenshotBuffer.length < floor) {
2986
- throw new DrawElementCaptureError(frameIndex, `suspect small frame (${screenshotBuffer.length}B < ${Math.round(floor)}B)`);
2987
- }
2988
- else {
2989
- if (sizes.length >= 60)
2990
- sizes.shift();
2991
- sizes.push(screenshotBuffer.length);
2992
- }
3098
+ screenshotBuffer = await judgeSmallDrawElementFrame(session, frameIndex, screenshotBuffer, () => redrawDrawElementFrame(session, frameIndex, time, seekOptions, heldVideoTime));
2993
3099
  }
2994
3100
  }
2995
3101
  catch (err) {
@@ -1,2 +1,3 @@
1
+ export declare const X265_MAX_THREADS = 16;
1
2
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
2
- export declare function appendSoftwareEncoderThreadsArg(args: string[], parallelism?: number): void;
3
+ export declare function appendSoftwareEncoderThreadsArg(args: string[], encoder: "libx264" | "libx265", parallelism?: number): void;
@@ -1,5 +1,7 @@
1
1
  import { availableParallelism } from "node:os";
2
+ export const X265_MAX_THREADS = 16;
2
3
  /** x264/x265 bitstreams depend on the thread count, so it comes from the CPU quota, never ffmpeg's host-core guess. */
3
- export function appendSoftwareEncoderThreadsArg(args, parallelism = availableParallelism()) {
4
- args.push("-threads", String(Math.max(1, Math.floor(parallelism))));
4
+ export function appendSoftwareEncoderThreadsArg(args, encoder, parallelism = availableParallelism()) {
5
+ const cap = encoder === "libx265" ? X265_MAX_THREADS : Infinity;
6
+ args.push("-threads", String(Math.min(cap, Math.max(1, Math.floor(parallelism)))));
5
7
  }
@@ -181,7 +181,7 @@ export function buildStreamingArgs(options, outputPath, gpuEncoder = null) {
181
181
  else {
182
182
  const encoderName = codec === "h264" ? "libx264" : "libx265";
183
183
  args.push("-c:v", encoderName, "-preset", preset);
184
- appendSoftwareEncoderThreadsArg(args);
184
+ appendSoftwareEncoderThreadsArg(args, encoderName);
185
185
  if (bitrate)
186
186
  args.push("-b:v", bitrate);
187
187
  else
package/dist/types.d.ts CHANGED
@@ -22,7 +22,7 @@ export type SubTimelineWaitOutcome = "ready" | "timeout" | "script_failure";
22
22
  export interface SubTimelineWaitMemo {
23
23
  unregisteredIds?: readonly string[];
24
24
  }
25
- export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "vfx_failure" | "live_map_detected";
25
+ export type CaptureWarningCode = "media_readiness_timeout" | "media_load_failed" | "audio_processing_failed" | "sub_timeline_readiness_timeout" | "sub_timeline_script_failure" | "script_error_isolated" | "vfx_failure" | "live_map_detected";
26
26
  /** Structured correctness warning produced while preparing a capture session. */
27
27
  export interface CaptureWarning {
28
28
  code: CaptureWarningCode;
@@ -31,6 +31,10 @@ export interface CaptureWarning {
31
31
  mediaType?: "image" | "video" | "audio";
32
32
  sources?: string[];
33
33
  pendingCompositionIds?: string[];
34
+ isolatedElements?: Array<{
35
+ compositionId: string;
36
+ elementId: string | null;
37
+ }>;
34
38
  timeoutMs?: number;
35
39
  audioFailures?: AudioFailureDiagnostic[];
36
40
  failureReasons?: string[];
@@ -51,6 +51,8 @@ export interface AudioMetadata {
51
51
  bitrate?: number;
52
52
  /** Seconds after the file's media time 0 at which its last audio or video stream starts. */
53
53
  latestStreamLeadSeconds?: number;
54
+ audioStreamLeadSeconds?: number;
55
+ audioStreamCount?: number;
54
56
  }
55
57
  interface FFProbeStream {
56
58
  codec_type: string;
@@ -98,6 +100,8 @@ export interface MediaProbeProfile {
98
100
  hasAudioStream: boolean;
99
101
  visualKind: "none" | "still" | "moving";
100
102
  }
103
+ /** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
104
+ export declare function declareContentAddressedUrl(url: string, sha256: string): void;
101
105
  export declare function mediaFileIdentity(filePath: string): string | null;
102
106
  export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
103
107
  export declare function writeMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null, promise: Promise<T>): void;
@@ -140,6 +140,7 @@ const videoMetadataCache = new Map();
140
140
  const finalVideoFrameTimestampCache = new Map();
141
141
  const finalVideoFrameTimestampSignalCaches = new WeakMap();
142
142
  const audioMetadataCache = new Map();
143
+ const audioMetadataSignalCaches = new WeakMap();
143
144
  const mediaProbeOutputCache = new Map();
144
145
  const mediaProbeOutputSignalCaches = new WeakMap();
145
146
  const MEDIA_PROBE_OUTPUT_CACHE_MAX_ENTRIES = 128;
@@ -210,7 +211,17 @@ function isPngImageProbe(output) {
210
211
  return (formatNames.includes("png_pipe") ||
211
212
  output.streams.some((stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png"));
212
213
  }
214
+ const contentAddressedUrls = new Map();
215
+ const withoutQuery = (url) => url.split(/[?#]/, 1)[0] ?? url;
216
+ /** A URL whose bytes can never change (its path holds their hash), so probes of it are cacheable. */
217
+ export function declareContentAddressedUrl(url, sha256) {
218
+ contentAddressedUrls.set(withoutQuery(url), sha256);
219
+ }
213
220
  export function mediaFileIdentity(filePath) {
221
+ if (/^https?:\/\//i.test(filePath)) {
222
+ const sha256 = contentAddressedUrls.get(withoutQuery(filePath));
223
+ return sha256 ? `sha256:${sha256}` : null;
224
+ }
214
225
  try {
215
226
  const stat = statSync(filePath, { bigint: true });
216
227
  return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
@@ -740,47 +751,53 @@ export async function extractFinalVideoFrameTimestamp(filePath, metadata, signal
740
751
  */
741
752
  export const extractVideoMetadata = extractMediaMetadata;
742
753
  export async function extractAudioMetadata(filePath, options) {
743
- // A caller-owned abort signal cannot safely share a cached in-flight probe:
744
- // cancelling one consumer would also cancel unrelated consumers. Signal-bound
745
- // probes therefore bypass the process-promise cache.
754
+ // A probe bound to a caller's abort signal is shared only among callers of that same signal,
755
+ // and only when the file has an identity to key it by.
746
756
  const identity = mediaFileIdentity(filePath);
747
- const cached = options?.signal
748
- ? undefined
749
- : readMetadataCache(audioMetadataCache, filePath, identity);
757
+ const signal = options?.signal;
758
+ if (signal && identity === null)
759
+ return probeAudioMetadata(filePath, signal);
760
+ let cache = audioMetadataCache;
761
+ if (signal) {
762
+ cache = audioMetadataSignalCaches.get(signal) ?? new Map();
763
+ audioMetadataSignalCaches.set(signal, cache);
764
+ }
765
+ const cached = readMetadataCache(cache, filePath, identity);
750
766
  if (cached)
751
767
  return cached;
752
- const probePromise = (async () => {
753
- const output = await probeMediaOutput(filePath, options?.signal);
754
- const audioStream = output.streams.find((s) => s.codec_type === "audio");
755
- if (!audioStream)
756
- throw new Error("[FFmpeg] No audio stream found");
757
- let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
758
- const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
759
- const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
760
- const audioCodec = audioStream.codec_name || "unknown";
761
- // Container summaries can undercount intact audio, so the decoded last frame wins.
762
- // The probe returns null instead of throwing: a throw here drops the audio element.
763
- if (sampleRate > 0) {
764
- const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, options?.signal);
765
- if (decoded !== null) {
766
- durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
767
- }
768
- }
769
- return {
770
- durationSeconds,
771
- streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
772
- sampleRate,
773
- channels: audioStream.channels || 2,
774
- audioCodec,
775
- bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
776
- latestStreamLeadSeconds: latestStreamLeadSeconds(output),
777
- };
778
- })();
779
- if (options?.signal)
780
- return probePromise;
781
- writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
768
+ const probePromise = probeAudioMetadata(filePath, signal);
769
+ writeMetadataCache(cache, filePath, identity, probePromise);
782
770
  return probePromise;
783
771
  }
772
+ async function probeAudioMetadata(filePath, signal) {
773
+ const output = await probeMediaOutput(filePath, signal);
774
+ const audioStream = output.streams.find((s) => s.codec_type === "audio");
775
+ if (!audioStream)
776
+ throw new Error("[FFmpeg] No audio stream found");
777
+ let durationSeconds = output.format.duration ? parseFloat(output.format.duration) : 0;
778
+ const streamDuration = audioStream.duration ? parseFloat(audioStream.duration) : undefined;
779
+ const sampleRate = audioStream.sample_rate ? parseInt(audioStream.sample_rate) : 44100;
780
+ const audioCodec = audioStream.codec_name || "unknown";
781
+ // Container summaries can undercount intact audio, so the decoded last frame wins.
782
+ // The probe returns null instead of throwing: a throw here drops the audio element.
783
+ if (sampleRate > 0) {
784
+ const decoded = await probeDecodedAudioDuration(filePath, sampleRate, audioStartSeconds(audioStream, output.format), durationSeconds, signal);
785
+ if (decoded !== null) {
786
+ durationSeconds = reconcileAudioDuration(durationSeconds, decoded, output.format);
787
+ }
788
+ }
789
+ return {
790
+ durationSeconds,
791
+ streamDurationSeconds: streamDuration && streamDuration > 0 ? streamDuration : undefined,
792
+ sampleRate,
793
+ channels: audioStream.channels || 2,
794
+ audioCodec,
795
+ bitrate: output.format.bit_rate ? parseInt(output.format.bit_rate) : undefined,
796
+ latestStreamLeadSeconds: latestStreamLeadSeconds(output),
797
+ audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
798
+ audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
799
+ };
800
+ }
784
801
  /** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
785
802
  function audioVideoStreamStarts(output) {
786
803
  return (output?.streams ?? [])
@@ -858,8 +875,7 @@ async function probeDecodedAudioDuration(filePath, sampleRate, startSeconds, con
858
875
  return lastFrame.timestamp + lastFrame.nbSamples / sampleRate - startSeconds;
859
876
  }
860
877
  const keyframeCache = new Map();
861
- /** Intervals beyond this cause seeking issues in the headless renderer and
862
- * audio/video desync — the sole threshold for `isProblematic` below. */
878
+ /** Advisory threshold for seeking cost; GOP length alone does not establish incorrect output. */
863
879
  const PROBLEMATIC_KEYFRAME_INTERVAL_SECONDS = 2;
864
880
  /**
865
881
  * Checks keyframe intervals in a video stream; intervals over the threshold below flag `isProblematic`.
@@ -22,7 +22,7 @@ function getSingleMeaningfulChild(container) {
22
22
  */
23
23
  export function unwrapTemplate(html) {
24
24
  const lowered = html.toLowerCase();
25
- if (!lowered.includes("<template") || !lowered.includes("</template>")) {
25
+ if (!lowered.includes("<template") || !/<\/template[ \t\n\r\f]*>/.test(lowered)) {
26
26
  return html;
27
27
  }
28
28
  const { body } = parseHTMLContent(html);
@@ -78,8 +78,11 @@ export async function sampleProcessRss(pids, exec = defaultExec(), platform = pr
78
78
  if (platform === "win32") {
79
79
  const results = [];
80
80
  for (const pid of unique) {
81
- const { stdout } = await exec("tasklist", ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`]);
82
- results.push(...parseTasklistCsv(pid, stdout));
81
+ try {
82
+ const { stdout } = await exec("tasklist", ["/FO", "CSV", "/NH", "/FI", `PID eq ${pid}`]);
83
+ results.push(...parseTasklistCsv(pid, stdout));
84
+ }
85
+ catch { }
83
86
  }
84
87
  return results;
85
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.190",
3
+ "version": "0.8.192",
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.190",
54
- "@hyperframes/parsers": "^0.8.190"
53
+ "@hyperframes/core": "^0.8.192",
54
+ "@hyperframes/parsers": "^0.8.192"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",