@hyperframes/engine 0.8.191 → 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";
@@ -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 } : {}),
@@ -506,14 +511,14 @@ const FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE = ["-xerror"];
506
511
  const SIDECAR_WARM_UP_SECONDS = 1;
507
512
  /** Decode `duration` s of audio from `mediaStart` in Chrome's media time. Until every stream has
508
513
  * started, an input seek snaps audio to the video's first keyframe, so read by timestamp instead. */
509
- async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl) {
514
+ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, duration, playbackRate = 1, signal, config, audioSidecarUrl, options) {
510
515
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
511
516
  const outputDir = dirname(outputPath);
512
517
  if (!existsSync(outputDir))
513
518
  mkdirSync(outputDir, { recursive: true });
514
519
  const normalizedPlaybackRate = normalizeRateSpec(playbackRate);
515
520
  const sourceDuration = sourceTimeAt(normalizedPlaybackRate, duration);
516
- // 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
517
522
  // is retried once, then fails: a blip must not change how the next, successful read is mixed.
518
523
  const readsUrl = isHttpUrl(srcPath);
519
524
  const metadata = readsUrl
@@ -522,7 +527,53 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
522
527
  throw error;
523
528
  return extractAudioMetadata(srcPath, { signal });
524
529
  })
525
- : 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
+ }
526
577
  const inputSeek = mediaStart >= (metadata?.latestStreamLeadSeconds ?? 0);
527
578
  const start = formatFilterNumber(mediaStart);
528
579
  const timestampTrim = inputSeek
@@ -530,7 +581,12 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
530
581
  : `atrim=start=${start}:end=${formatFilterNumber(mediaStart + sourceDuration)},asetpts=PTS-${start}/TB,aresample=async=1:first_pts=0`;
531
582
  const fromSidecar = audioSidecarUrl !== undefined && inputSeek && normalizedPlaybackRate === 1;
532
583
  const warmUp = fromSidecar ? Math.min(SIDECAR_WARM_UP_SECONDS, mediaStart) : 0;
533
- const outputArgs = preparedAudioOutputArgs(metadata, normalizedPlaybackRate, duration, warmUp > 0 ? `atrim=start=${warmUp},asetpts=PTS-STARTPTS` : timestampTrim);
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);
534
590
  const readFrom = fromSidecar ? audioSidecarUrl : srcPath;
535
591
  const args = [
536
592
  ...(readsUrl ? FAIL_ON_READ_ERROR_INSTEAD_OF_ENDING_THERE : []),
@@ -542,7 +598,7 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
542
598
  readFrom,
543
599
  ];
544
600
  args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", ...outputArgs);
545
- if (normalizedPlaybackRate !== 1)
601
+ if (normalizedPlaybackRate !== 1 || options?.padToDuration)
546
602
  args.push("-t", String(duration));
547
603
  args.push("-y", outputPath);
548
604
  const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
@@ -575,6 +631,18 @@ async function extractAudioSegment(srcPath, outputPath, stage, mediaStart, durat
575
631
  failure,
576
632
  };
577
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
+ }
578
646
  async function generateSilence(outputPath, duration, signal, config) {
579
647
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
580
648
  const outputDir = dirname(outputPath);
@@ -1053,7 +1121,9 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1053
1121
  else
1054
1122
  signal.addEventListener("abort", () => internalController.abort(), { once: true });
1055
1123
  }
1056
- 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 () => {
1057
1127
  if (effectiveSignal.aborted) {
1058
1128
  failures.push({
1059
1129
  stage: "cancelled",
@@ -1115,6 +1185,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1115
1185
  }
1116
1186
  // Fallback: if no duration was specified, probe the actual file
1117
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`);
1118
1189
  let metadata;
1119
1190
  try {
1120
1191
  metadata = await extractAudioMetadata(srcPath, readInPlace ? { signal: effectiveSignal } : undefined);
@@ -1131,7 +1202,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1131
1202
  let audioSrcPath = srcPath;
1132
1203
  if (element.type === "video") {
1133
1204
  const extractedPath = join(workDir, `${element.id}-extracted.wav`);
1134
- const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl);
1205
+ const extractResult = await extractAudioSegment(srcPath, extractedPath, "extract", element.mediaStart, element.end - element.start, element.playbackRate, effectiveSignal, config, audioSidecarUrl, { loop: element.loop });
1135
1206
  if (!extractResult.success) {
1136
1207
  failures.push(extractResult.failure
1137
1208
  ? { ...extractResult.failure, elementId: element.id }
@@ -1149,7 +1220,7 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1149
1220
  }
1150
1221
  else {
1151
1222
  const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
1152
- 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 });
1153
1224
  if (!prepResult.success) {
1154
1225
  failures.push(prepResult.failure
1155
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
@@ -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 };
@@ -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
@@ -100,6 +100,8 @@ export interface MediaProbeProfile {
100
100
  hasAudioStream: boolean;
101
101
  visualKind: "none" | "still" | "moving";
102
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;
103
105
  export declare function mediaFileIdentity(filePath: string): string | null;
104
106
  export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
105
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,49 +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
- audioStreamLeadSeconds: streamLeadSeconds(output, audioStream),
778
- audioStreamCount: output.streams.filter((s) => s.codec_type === "audio").length,
779
- };
780
- })();
781
- if (options?.signal)
782
- return probePromise;
783
- writeMetadataCache(audioMetadataCache, filePath, identity, probePromise);
768
+ const probePromise = probeAudioMetadata(filePath, signal);
769
+ writeMetadataCache(cache, filePath, identity, probePromise);
784
770
  return probePromise;
785
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
+ }
786
801
  /** Chrome's media time 0 is the earliest of these; a stream starting later plays that much in. */
787
802
  function audioVideoStreamStarts(output) {
788
803
  return (output?.streams ?? [])
@@ -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.191",
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/parsers": "^0.8.191",
54
- "@hyperframes/core": "^0.8.191"
53
+ "@hyperframes/core": "^0.8.192",
54
+ "@hyperframes/parsers": "^0.8.192"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",