@hyperframes/engine 0.8.180 → 0.8.181

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/config.d.ts CHANGED
@@ -165,7 +165,7 @@ export interface EngineConfig {
165
165
  streamingEncodeDurationCapEnabled: boolean;
166
166
  /** Timeout for FFmpeg frame encoding (ms). Default: 600_000 */
167
167
  ffmpegEncodeTimeout: number;
168
- /** Timeout for FFmpeg mux/faststart processes (ms). Default: 300_000 */
168
+ /** FFmpeg mux/faststart timeout (ms); extraction waits this long for each new frame. Default: 300_000 */
169
169
  ffmpegProcessTimeout: number;
170
170
  /**
171
171
  * Inactivity timeout for FFmpeg streaming encode (ms). The timer resets on
package/dist/index.d.ts CHANGED
@@ -64,6 +64,7 @@ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimes
64
64
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
65
65
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, type SafeDownloadUrlIdentity, type UrlDownloadOptions, type UrlDownloadTelemetry, type PublicHttpsTextOptions, } from "./utils/urlDownloader.js";
66
66
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, type RunFfmpegOptions, type RunFfmpegResult, } from "./utils/runFfmpeg.js";
67
+ export { aacSegmentCount } from "./services/aacEncode.js";
67
68
  export { ManagedChildProcess, type ManagedChildProcessOptions, type ManagedChildProcessOutcome, type ManagedProcessTerminationReason, } from "./utils/managedChildProcess.js";
68
69
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
69
70
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ export { extractMediaMetadata, extractVideoMetadata, extractFinalVideoFrameTimes
69
69
  export { NOT_MEDIA_PAYLOAD, NotMediaPayloadError, assertMediaPayload, fingerprintElementId, isNotMediaPayload, } from "./utils/notMediaPayload.js";
70
70
  export { assertPublicHttpsUrl, isBlockedNetworkHost, downloadToTemp, fetchPublicHttpsText, isHttpUrl, safeDownloadUrlIdentity, writeUrlDownloadTelemetry, } from "./utils/urlDownloader.js";
71
71
  export { runFfmpeg, formatFfmpegError, describeFfmpegFailure, isExternalFfmpegInterruption, } from "./utils/runFfmpeg.js";
72
+ export { aacSegmentCount } from "./services/aacEncode.js";
72
73
  export { ManagedChildProcess, } from "./utils/managedChildProcess.js";
73
74
  export { assertConfiguredFfmpegBinariesExist, getFfmpegBinary, getFfprobeBinary, FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, } from "./utils/ffmpegBinaries.js";
74
75
  export { trackChildProcess, killTrackedProcesses } from "./utils/processTracker.js";
@@ -16,7 +16,7 @@ import { resolveProjectRelativeSrc } from "@hyperframes/parsers/asset-resolution
16
16
  import { MEDIA_RENDER_ID_ATTR, isAudibleVideoElement, fpsToFfmpegArg, fpsToNumber, MEDIA_DURATION_CLAMP_EPSILON_SECONDS, normalizeRateSpec, readElementRateSpec, shiftRateLane, sourceTimeAt, timeAtSourceTime, exportClipWindow, hasClipStarted, isInClipWindow, parseStrictFiniteTimingNumber, readMediaStart, toFps, firstFrameColourArgs, hdrToSdrToneMapFilter, parseFirstFrameColour, } from "@hyperframes/core";
17
17
  import { resolveReferencedStart } from "./referenceResolver.js";
18
18
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
19
- import { extractFinalVideoFrameTimestamp, extractMediaMetadata, } from "../utils/ffprobe.js";
19
+ import { extractFinalVideoFrameTimestamp, extractMediaMetadata, mediaFileIdentity, readMetadataCache, writeMetadataCache, } from "../utils/ffprobe.js";
20
20
  import { inputAlphaOpaqueWarning, probeInputAlphaPlane } from "../utils/alphaPlaneProbe.js";
21
21
  import { redactUrlQueriesInError, urlInputArgs } from "../utils/urlInput.js";
22
22
  import { analyzeCompositionHdr, isHdrColorSpace as isHdrColorSpaceUtil, } from "../utils/hdr.js";
@@ -417,12 +417,23 @@ export function parseImageElements(html) {
417
417
  }
418
418
  return images;
419
419
  }
420
- /** The first shown frame's colour tags; see {@link firstFrameColourArgs}. */
421
- async function readFirstFrameColour(videoPath, signal) {
422
- const result = await runFfmpeg([...urlInputArgs(videoPath), ...firstFrameColourArgs(videoPath)], {
423
- signal,
420
+ const firstFrameColourCache = new Map();
421
+ /** The first shown frame's colour tags (see {@link firstFrameColourArgs}), probed once per source file. */
422
+ function readFirstFrameColour(videoPath) {
423
+ const identity = mediaFileIdentity(videoPath);
424
+ const cached = readMetadataCache(firstFrameColourCache, videoPath, identity);
425
+ if (cached)
426
+ return cached;
427
+ // In an extraction slot, so it never adds a decoder; unsignalled, so one cancel cannot cut it short for others.
428
+ const probe = inExtractionSlot(() => runFfmpeg([...urlInputArgs(videoPath), ...firstFrameColourArgs(videoPath)])).then((result) => {
429
+ // A failed probe answers this cut only; the next one probes again.
430
+ if (!result.success && firstFrameColourCache.get(videoPath)?.promise === probe) {
431
+ firstFrameColourCache.delete(videoPath);
432
+ }
433
+ return parseFirstFrameColour(result.stderr);
424
434
  });
425
- return parseFirstFrameColour(result.stderr);
435
+ writeMetadataCache(firstFrameColourCache, videoPath, identity, probe);
436
+ return probe;
426
437
  }
427
438
  /** Chrome plays untagged VP9 and AV1 as BT.601, H.264 and VP8 as BT.709 from 720 coded lines (assumed for the rest). */
428
439
  const CHROME_BT601_UNTAGGED_CODECS = new Set(["vp9", "av1"]);
@@ -434,10 +445,9 @@ function chromeGuessForUntaggedMatrix(metadata) {
434
445
  return [`setparams=colorspace=${hd ? "bt709" : "smpte170m"}`];
435
446
  }
436
447
  /**
437
- * Longest stretch of output one ffmpeg process extracts. `ffmpegProcessTimeout` bounds a
438
- * single process, so one process over a long clip timed out however much time the render
439
- * had left. Longer ranges split into segments that run side by side. Segments sample the
440
- * same absolute times as a single pass, so their frames are byte-identical to it.
448
+ * Longest stretch of output one ffmpeg process extracts; longer ranges split into segments
449
+ * that run side by side. Segments sample the same absolute times as a single pass, so their
450
+ * frames are byte-identical to it.
441
451
  */
442
452
  const EXTRACTION_SEGMENT_SECONDS = 120;
443
453
  /** Frame times since file start are `PTS*TB + seek`; snapping them to `lead + k/FR` gives every process one grid. */
@@ -624,7 +634,7 @@ outputDirOverride) {
624
634
  vfFilters.push(SDR_TO_HDR_COLORSPACE_FILTER);
625
635
  }
626
636
  if (toneMappedToSdr) {
627
- const frameColour = await readFirstFrameColour(videoPath, signal);
637
+ const frameColour = await readFirstFrameColour(videoPath);
628
638
  vfFilters.push(hdrToSdrToneMapFilter(metadata.colorSpace ?? {}, frameColour));
629
639
  }
630
640
  if (toneMappedToSdr || (!isHdr && !options.sdrToHdrTransfer)) {
@@ -637,7 +647,8 @@ outputDirOverride) {
637
647
  if (format === "png")
638
648
  encodeArgs.push("-compression_level", "1");
639
649
  encodeArgs.push("-y", outputPattern);
640
- const runOptions = { signal, timeout: ffmpegProcessTimeout };
650
+ // A long 4K run can take far longer than the timeout and still be healthy; only a stall is a hang.
651
+ const runOptions = { signal, stallTimeout: ffmpegProcessTimeout };
641
652
  const filterArgsFor = (seek) => {
642
653
  const filters = [...vfFilters];
643
654
  filters.splice(samplingAt, 0, ...samplingFilters(seek));
@@ -677,8 +688,8 @@ outputDirOverride) {
677
688
  const hdrPrefix = options.sdrToHdrTransfer
678
689
  ? `SDR→HDR conversion failed (colorspace filter in extract pass, target ${options.sdrToHdrTransfer}): `
679
690
  : "";
680
- const timedOut = processResult.terminationReason === "deadline";
681
- const timeoutSuffix = timedOut ? ` (timed out after ${ffmpegProcessTimeout} ms)` : "";
691
+ const timedOut = processResult.terminationReason === "inactivity";
692
+ const timeoutSuffix = timedOut ? ` (no new frame for ${ffmpegProcessTimeout} ms)` : "";
682
693
  const diagnostic = `${hdrPrefix}FFmpeg exited with code ${processResult.exitCode}${timeoutSuffix}: ` +
683
694
  processResult.stderr.slice(-500);
684
695
  if (timedOut) {
@@ -1562,7 +1573,7 @@ export async function extractAllVideoFrames(videos, baseDir, options, signal, co
1562
1573
  : join(options.outputDir, group.groupId);
1563
1574
  try {
1564
1575
  rmSync(tempDir, { recursive: true, force: true });
1565
- // A long union can hit the fixed FFmpeg deadline even when each shorter
1576
+ // A long union can fail even when each shorter
1566
1577
  // member range succeeds. Do not retry the optimization itself; preserve
1567
1578
  // the established grouped→direct fallback and apply bounded retries only
1568
1579
  // to the individual source ranges below.
@@ -1,3 +1,7 @@
1
+ export interface MediaProbeCacheEntry<T = FFProbeOutput> {
2
+ identity: string | null;
3
+ promise: Promise<T>;
4
+ }
1
5
  export interface VideoColorSpace {
2
6
  /** Color transfer characteristics, e.g. "bt709", "smpte2084", "arib-std-b67" */
3
7
  colorTransfer: string;
@@ -48,6 +52,42 @@ export interface AudioMetadata {
48
52
  /** Seconds after the file's media time 0 at which its last audio or video stream starts. */
49
53
  latestStreamLeadSeconds?: number;
50
54
  }
55
+ interface FFProbeStream {
56
+ codec_type: string;
57
+ codec_name?: string;
58
+ /** e.g. "LC", "HE-AAC", "HE-AACv2" — where the SBR marker lives, since
59
+ * codec_name is plain "aac" for all of them. */
60
+ profile?: string;
61
+ width?: number;
62
+ height?: number;
63
+ duration?: string;
64
+ start_time?: string;
65
+ nb_frames?: string;
66
+ nb_read_packets?: string;
67
+ pix_fmt?: string;
68
+ r_frame_rate?: string;
69
+ avg_frame_rate?: string;
70
+ sample_rate?: string;
71
+ channels?: number;
72
+ color_range?: string;
73
+ color_transfer?: string;
74
+ color_primaries?: string;
75
+ color_space?: string;
76
+ tags?: Record<string, string>;
77
+ disposition?: {
78
+ attached_pic?: number;
79
+ };
80
+ }
81
+ interface FFProbeFormat {
82
+ duration?: string;
83
+ start_time?: string;
84
+ bit_rate?: string;
85
+ format_name?: string;
86
+ }
87
+ interface FFProbeOutput {
88
+ streams: FFProbeStream[];
89
+ format: FFProbeFormat;
90
+ }
51
91
  interface StillImageMetadata {
52
92
  width: number;
53
93
  height: number;
@@ -58,6 +98,9 @@ export interface MediaProbeProfile {
58
98
  hasAudioStream: boolean;
59
99
  visualKind: "none" | "still" | "moving";
60
100
  }
101
+ export declare function mediaFileIdentity(filePath: string): string | null;
102
+ export declare function readMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null): Promise<T> | undefined;
103
+ export declare function writeMetadataCache<T>(cache: Map<string, MediaProbeCacheEntry<T>>, key: string, identity: string | null, promise: Promise<T>): void;
61
104
  /**
62
105
  * Probe stream capabilities without assuming the caller's element type.
63
106
  * File extensions and HTTP MIME are deliberately ignored: extensionless
@@ -209,7 +209,7 @@ function isPngImageProbe(output) {
209
209
  return (formatNames.includes("png_pipe") ||
210
210
  output.streams.some((stream) => stream.codec_type === "video" && stream.codec_name?.toLowerCase() === "png"));
211
211
  }
212
- function mediaFileIdentity(filePath) {
212
+ export function mediaFileIdentity(filePath) {
213
213
  try {
214
214
  const stat = statSync(filePath, { bigint: true });
215
215
  return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(":");
@@ -218,11 +218,11 @@ function mediaFileIdentity(filePath) {
218
218
  return null;
219
219
  }
220
220
  }
221
- function readMetadataCache(cache, key, identity) {
221
+ export function readMetadataCache(cache, key, identity) {
222
222
  const cached = cache.get(key);
223
223
  return cached?.identity === identity ? cached.promise : undefined;
224
224
  }
225
- function writeMetadataCache(cache, key, identity, promise) {
225
+ export function writeMetadataCache(cache, key, identity, promise) {
226
226
  const entry = { identity, promise };
227
227
  cache.set(key, entry);
228
228
  void promise.then(() => {
@@ -8,6 +8,8 @@ import { type ManagedProcessTerminationReason } from "./managedChildProcess.js";
8
8
  export interface RunFfmpegOptions {
9
9
  signal?: AbortSignal;
10
10
  timeout?: number;
11
+ /** Replaces `timeout`: stop only once this long passes without a new output frame. */
12
+ stallTimeout?: number;
11
13
  onStderr?: (line: string) => void;
12
14
  }
13
15
  export interface RunFfmpegResult {
@@ -147,11 +147,25 @@ const ABORTED_BEFORE_START = {
147
147
  export async function runFfmpeg(args, opts) {
148
148
  if (opts?.signal?.aborted)
149
149
  return { ...ABORTED_BEFORE_START };
150
- const timeout = opts?.timeout ?? DEFAULT_TIMEOUT;
150
+ const stallTimeout = opts?.stallTimeout;
151
+ let framesWritten = 0;
152
+ const readStats = ffmpegStatsReader(({ frames }) => {
153
+ if (frames === undefined || frames <= framesWritten)
154
+ return;
155
+ framesWritten = frames;
156
+ managed.markActivity();
157
+ });
151
158
  const managed = new ManagedChildProcess(spawnFfmpeg(args), {
152
159
  signal: opts?.signal,
153
- deadlineAtMs: Date.now() + timeout,
154
- onStderr: opts?.onStderr,
160
+ ...(stallTimeout === undefined
161
+ ? { deadlineAtMs: Date.now() + (opts?.timeout ?? DEFAULT_TIMEOUT) }
162
+ : { inactivityTimeoutMs: stallTimeout }),
163
+ onStderr: stallTimeout === undefined
164
+ ? opts?.onStderr
165
+ : (chunk) => {
166
+ readStats(chunk);
167
+ opts?.onStderr?.(chunk);
168
+ },
155
169
  });
156
170
  return toResult(await managed.wait());
157
171
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.180",
3
+ "version": "0.8.181",
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.180",
54
- "@hyperframes/parsers": "^0.8.180"
53
+ "@hyperframes/core": "^0.8.181",
54
+ "@hyperframes/parsers": "^0.8.181"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",