@hyperframes/engine 0.8.179 → 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";
@@ -428,10 +428,6 @@ export declare function recordSubTimelineWarning(session: CaptureSession, timeou
428
428
  * PRINFRA-300 (blank hook scene, zero diagnostics, "fixed" by re-render).
429
429
  */
430
430
  export declare function buildLiveMapWarning(libraries: readonly string[]): CaptureWarning;
431
- /** Out-of-band videos a poster now sizes, so dropping their media cannot move layout. */
432
- export declare function releasableVideoIds(options: CaptureOptions): string[];
433
- /** FFmpeg supplies these videos' frames, so their own players would only hold decoders and buffers in every worker. */
434
- export declare function releaseInjectedVideoMedia(page: Page, videoIds: readonly string[]): Promise<void>;
435
431
  export declare function applyVideoMetadataHints(page: Page, hints: readonly CaptureVideoMetadataHint[] | undefined): Promise<void>;
436
432
  /** Thrown once a VFX chain fails: the frames it paints would render without the effect. */
437
433
  export declare class VfxFailureError extends Error {
@@ -1529,28 +1529,6 @@ async function decodeAllImages(page) {
1529
1529
  }));
1530
1530
  });
1531
1531
  }
1532
- /** Out-of-band videos a poster now sizes, so dropping their media cannot move layout. */
1533
- export function releasableVideoIds(options) {
1534
- const hinted = new Set((options.videoMetadataHints ?? []).map((hint) => hint.id));
1535
- return (options.skipReadinessVideoIds ?? []).filter((id) => hinted.has(id));
1536
- }
1537
- /** FFmpeg supplies these videos' frames, so their own players would only hold decoders and buffers in every worker. */
1538
- export async function releaseInjectedVideoMedia(page, videoIds) {
1539
- if (videoIds.length === 0)
1540
- return;
1541
- await page.evaluate((ids) => {
1542
- for (const id of ids) {
1543
- const video = window.__hfMediaEl?.(id) ?? document.getElementById(id);
1544
- if (!(video instanceof HTMLVideoElement))
1545
- continue;
1546
- video.pause();
1547
- for (const source of video.querySelectorAll("source"))
1548
- source.remove();
1549
- video.removeAttribute("src");
1550
- video.load();
1551
- }
1552
- }, [...videoIds]);
1553
- }
1554
1532
  export async function applyVideoMetadataHints(page, hints) {
1555
1533
  if (!hints || hints.length === 0)
1556
1534
  return;
@@ -1785,7 +1763,6 @@ export async function initializeSession(session) {
1785
1763
  assertVfxIntact(session);
1786
1764
  recordSubTimelineWarning(session, pageReadyTimeout);
1787
1765
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
1788
- await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
1789
1766
  logInitPhase("applyVideoMetadataHints complete");
1790
1767
  // Run independent readiness checks in parallel — videos, images, fonts,
1791
1768
  // and Tailwind don't depend on each other's completion.
@@ -1916,7 +1893,6 @@ export async function initializeSession(session) {
1916
1893
  assertVfxIntact(session);
1917
1894
  recordSubTimelineWarning(session, pageReadyTimeout);
1918
1895
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
1919
- await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
1920
1896
  logInitPhase("applyVideoMetadataHints complete");
1921
1897
  // Run independent readiness checks in parallel — videos, images, fonts,
1922
1898
  // and Tailwind don't depend on each other's completion.
@@ -2522,7 +2498,6 @@ export async function createStaticVerificationPage(session) {
2522
2498
  knownUnregisteredIds: session.subTimelineWaitMemo.unregisteredIds,
2523
2499
  });
2524
2500
  await applyVideoMetadataHints(page, session.options.videoMetadataHints);
2525
- await releaseInjectedVideoMedia(page, releasableVideoIds(session.options));
2526
2501
  const skipVideoIds = session.options.skipReadinessVideoIds ?? [];
2527
2502
  await Promise.all([
2528
2503
  pollVideosReady(page, skipVideoIds, pageReadyTimeout),
@@ -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.179",
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.179",
54
- "@hyperframes/parsers": "^0.8.179"
53
+ "@hyperframes/core": "^0.8.181",
54
+ "@hyperframes/parsers": "^0.8.181"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^25.0.10",