@hyperframes/engine 0.8.148 → 0.8.150

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
@@ -49,7 +49,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
49
49
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
50
50
  export { resolveReferencedStart, resolveReferencedDuration, type RefResolverEl, type RefResolverDoc, } from "./services/referenceResolver.js";
51
51
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
52
- export { MIXED_AUDIO_FILENAME, parseAudioElements, processCompositionAudio, } from "./services/audioMixer.js";
52
+ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
53
53
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
54
54
  export type { AudioElement, AudioFailureReason, AudioFailureStage, AudioProcessingFailure, AudioTrack, AudioVolumeKeyframe, MixResult, } from "./services/audioMixer.types.js";
55
55
  export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, type WorkerTask, type WorkerResult, type WorkerSizing, type WorkerSizingBound, type ParallelProgress, } from "./services/parallelCoordinator.js";
package/dist/index.js CHANGED
@@ -53,7 +53,7 @@ export { parseVideoElements, parseImageElements, extractVideoFramesRange, extrac
53
53
  export { directorySizeBytes, gcExtractionCache } from "./services/extractionCache.js";
54
54
  export { resolveReferencedStart, resolveReferencedDuration, } from "./services/referenceResolver.js";
55
55
  export { createVideoFrameInjector } from "./services/videoFrameInjector.js";
56
- export { MIXED_AUDIO_FILENAME, parseAudioElements, processCompositionAudio, } from "./services/audioMixer.js";
56
+ export { MIXED_AUDIO_FILENAME, parseAudioElements, parseMasterFxChain, processCompositionAudio, } from "./services/audioMixer.js";
57
57
  export { cloneCaptureWarning, cloneCaptureWarnings } from "./services/captureWarning.js";
58
58
  // ── Parallel rendering ─────────────────────────────────────────────────────────
59
59
  export { calculateOptimalWorkers, computeWorkerSizing, selectVerifySampleIndicesForTask, verifyDiskDrawElementSamples, distributeFrames, distributeFramesInterleaved, executeParallelCapture, mergeWorkerFrames, getSystemResources, shouldDisableBrowserPoolForParallelWorker, } from "./services/parallelCoordinator.js";
@@ -25,6 +25,20 @@ interface WavData {
25
25
  * the output can be written back in the same format — see `writeWav`. */
26
26
  float: boolean;
27
27
  }
28
+ /** Where a WAV's samples sit, read from its head alone. */
29
+ export interface WavHeader {
30
+ format: number;
31
+ channels: number;
32
+ sampleRate: number;
33
+ bits: number;
34
+ float: boolean;
35
+ dataOffset: number;
36
+ dataBytes: number;
37
+ }
38
+ /** A declared size of 0 or 0xFFFFFFFF (live-written, or past 4 GiB), or one past the end, means "to the end"; both WAV readers use it. */
39
+ export declare function resolveDataBytes(declared: number, availableBytes: number): number;
40
+ /** The header of `path`, or null when the head holds no readable `fmt `/`data` pair. */
41
+ export declare function readWavHeader(path: string): WavHeader | null;
28
42
  export declare function readWav(path: string): WavData;
29
43
  /**
30
44
  * Write interleaved samples, preserving the channel count.
@@ -44,6 +58,33 @@ export declare function readWav(path: string): WavData;
44
58
  * baker reads float too; before that it was not.
45
59
  */
46
60
  export declare function writeWav(path: string, samples: Float32Array, sampleRate: number, channels?: number, float?: boolean): void;
61
+ /** Inputs over `aboveBytes` render in windows: Node reads no file past 2 GiB whole, and the page would hold it twice. */
62
+ export interface AudioFxStreamingLimits {
63
+ aboveBytes: number;
64
+ segmentSeconds: number;
65
+ /** Audio rendered ahead of each window and discarded; the least, when the chain needs more. */
66
+ prerollSeconds: number;
67
+ /** The most pre-roll a chain's own memory can ask for. */
68
+ maxPrerollSeconds: number;
69
+ /** Audio rendered after each window and discarded, for look-ahead. */
70
+ postrollSeconds: number;
71
+ }
72
+ export interface AudioFxSegment {
73
+ /** The frames this window contributes to the output. */
74
+ keepFrom: number;
75
+ keepTo: number;
76
+ /** The frames rendered to produce them. */
77
+ readFrom: number;
78
+ readTo: number;
79
+ last: boolean;
80
+ }
81
+ /** Tile `totalFrames` into windows that each carry their pre/post-roll. */
82
+ export declare function planAudioFxSegments(totalFrames: number, sampleRate: number, limits: AudioFxStreamingLimits): AudioFxSegment[];
83
+ type AudioFxEnvelope = {
84
+ keyframes: AudioVolumeKeyframe[];
85
+ trackStart: number;
86
+ baseVolume: number;
87
+ };
47
88
  /**
48
89
  * Run a chain over `inputWav`, writing `outputWav`. Resolves to the path to use
49
90
  * downstream — `outputWav` when the chain did something, `inputWav` untouched
@@ -65,11 +106,8 @@ export declare function applyAudioFxChain(inputWav: string, chain: HfAudioFxChai
65
106
  trackId: string;
66
107
  signal?: AbortSignal;
67
108
  automation?: HfAutomation;
68
- envelope?: {
69
- keyframes: AudioVolumeKeyframe[];
70
- trackStart: number;
71
- baseVolume: number;
72
- };
109
+ envelope?: AudioFxEnvelope;
110
+ streaming?: Partial<AudioFxStreamingLimits>;
73
111
  }): Promise<{
74
112
  path: string;
75
113
  envelopeBaked: boolean;
@@ -11,7 +11,7 @@
11
11
  * dynamics processors and the modulated delays) have no filter that behaves the
12
12
  * same way, so preview would quietly stop predicting the render.
13
13
  */
14
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { pathToFileURL } from "node:url";
@@ -21,6 +21,7 @@ import { serializeAutomation } from "@hyperframes/core/audio-automation";
21
21
  import { acquireBrowser } from "./browserManager.js";
22
22
  import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
23
23
  import { riffChunks, wavFormatTag } from "./wavChunks.js";
24
+ import { windowPrerollSeconds } from "./audioFxWarmup.js";
24
25
  export class AudioFxRenderError extends Error {
25
26
  constructor(message) {
26
27
  super(message);
@@ -36,6 +37,8 @@ export class AudioFxRenderError extends Error {
36
37
  function readWavChunks(buf) {
37
38
  const head = { format: 1, channels: 1, sampleRate: 48000, bits: 16 };
38
39
  let data;
40
+ let dataBody;
41
+ let dataSize;
39
42
  for (const { id, body, size } of riffChunks(buf)) {
40
43
  if (id === "fmt ") {
41
44
  const format = wavFormatTag(buf, body, size);
@@ -47,7 +50,9 @@ function readWavChunks(buf) {
47
50
  head.bits = buf.readUInt16LE(body + 14);
48
51
  }
49
52
  else if (id === "data") {
50
- data = buf.subarray(body, Math.min(buf.length, body + size));
53
+ data = buf.subarray(body, body + resolveDataBytes(size, buf.length - body));
54
+ dataBody = body;
55
+ dataSize = size;
51
56
  // The payload is the rest of the file for anything the mixer writes, and
52
57
  // reading past it buys nothing: `fmt ` precedes `data` in every WAV these
53
58
  // steps produce, and the alternative is walking a several-hundred-megabyte
@@ -55,7 +60,43 @@ function readWavChunks(buf) {
55
60
  break;
56
61
  }
57
62
  }
58
- return { ...head, data };
63
+ return { ...head, data, dataBody, dataSize };
64
+ }
65
+ /** The most `readFileSync` returns in one call. */
66
+ const MAX_WHOLE_FILE_BYTES = 2 ** 31 - 1;
67
+ const HEADER_PROBE_BYTES = 64 * 1024;
68
+ /** A declared size of 0 or 0xFFFFFFFF (live-written, or past 4 GiB), or one past the end, means "to the end"; both WAV readers use it. */
69
+ export function resolveDataBytes(declared, availableBytes) {
70
+ return declared === 0 || declared === 0xffffffff
71
+ ? availableBytes
72
+ : Math.min(declared, availableBytes);
73
+ }
74
+ /** The header of `path`, or null when the head holds no readable `fmt `/`data` pair. */
75
+ export function readWavHeader(path) {
76
+ const fd = openSync(path, "r");
77
+ try {
78
+ const head = Buffer.alloc(HEADER_PROBE_BYTES);
79
+ const read = readSync(fd, head, 0, head.length, 0);
80
+ const buf = head.subarray(0, read);
81
+ if (buf.length < 44 || buf.toString("ascii", 0, 4) !== "RIFF")
82
+ return null;
83
+ const { format, channels, sampleRate, bits, dataBody, dataSize } = readWavChunks(buf);
84
+ if (dataBody === undefined || dataSize === undefined)
85
+ return null;
86
+ const dataBytes = resolveDataBytes(dataSize, statSync(path).size - dataBody);
87
+ return {
88
+ format,
89
+ channels,
90
+ sampleRate,
91
+ bits,
92
+ float: format === 3 && bits === 32,
93
+ dataOffset: dataBody,
94
+ dataBytes,
95
+ };
96
+ }
97
+ finally {
98
+ closeSync(fd);
99
+ }
59
100
  }
60
101
  export function readWav(path) {
61
102
  const buf = readFileSync(path);
@@ -95,30 +136,11 @@ function decodeSamples(data, format, bits, path) {
95
136
  }
96
137
  throw new AudioFxRenderError(`Unsupported WAV format ${format}/${bits}-bit: ${path}`);
97
138
  }
98
- /**
99
- * Write interleaved samples, preserving the channel count.
100
- *
101
- * 16-bit by default rather than the float32 this used to emit: the very next
102
- * step in the mixer bakes the volume envelope into the samples, and that baker
103
- * accepted only 16-bit PCM. Emitting float meant enabling any effect silently
104
- * downgraded a track's volume automation to the ffmpeg expression path, which is
105
- * capped at 32 straight segments — so a curved envelope was quantised and a
106
- * dense one could fall back to rendering at base volume.
107
- *
108
- * `float` opts back out, for the ONE input that needs it: a group sub-mix. Its
109
- * members sum at unity, so the sum can legitimately exceed full scale, and the
110
- * group's fader is applied downstream — clamping here handed that headroom back
111
- * one step before the thing that was going to reduce it, which is the same bug
112
- * the float intermediate exists to avoid. Safe now only because the envelope
113
- * baker reads float too; before that it was not.
114
- */
115
- export function writeWav(path, samples, sampleRate, channels = 1, float = false) {
116
- const n = samples.length;
139
+ function wavHeader(bytes, sampleRate, channels, float) {
117
140
  const bytesPerSample = float ? 4 : 2;
118
- const bytes = n * bytesPerSample;
119
- const buf = Buffer.alloc(44 + bytes);
141
+ const buf = Buffer.alloc(44);
120
142
  buf.write("RIFF", 0, "ascii");
121
- buf.writeUInt32LE(36 + bytes, 4);
143
+ buf.writeUInt32LE(Math.min(0xffffffff, 36 + bytes), 4);
122
144
  buf.write("WAVE", 8, "ascii");
123
145
  buf.write("fmt ", 12, "ascii");
124
146
  buf.writeUInt32LE(16, 16);
@@ -129,17 +151,44 @@ export function writeWav(path, samples, sampleRate, channels = 1, float = false)
129
151
  buf.writeUInt16LE(channels * bytesPerSample, 32);
130
152
  buf.writeUInt16LE(float ? 32 : 16, 34);
131
153
  buf.write("data", 36, "ascii");
132
- buf.writeUInt32LE(bytes, 40);
154
+ buf.writeUInt32LE(Math.min(0xffffffff, bytes), 40);
155
+ return buf;
156
+ }
157
+ function encodeSamples(samples, float) {
158
+ const n = samples.length;
159
+ const buf = Buffer.alloc(n * (float ? 4 : 2));
133
160
  for (let i = 0; i < n; i++) {
134
161
  if (float) {
135
- buf.writeFloatLE(samples[i] ?? 0, 44 + i * 4);
162
+ buf.writeFloatLE(samples[i] ?? 0, i * 4);
136
163
  continue;
137
164
  }
138
165
  // Clamp before scaling: a limiter set to 0 dB or a resonant filter can push
139
166
  // past full scale, and wrapping would turn that into a click.
140
167
  const v = Math.max(-1, Math.min(1, samples[i] ?? 0));
141
- buf.writeInt16LE(Math.round(v * 32767), 44 + i * 2);
168
+ buf.writeInt16LE(Math.round(v * 32767), i * 2);
142
169
  }
170
+ return buf;
171
+ }
172
+ /**
173
+ * Write interleaved samples, preserving the channel count.
174
+ *
175
+ * 16-bit by default rather than the float32 this used to emit: the very next
176
+ * step in the mixer bakes the volume envelope into the samples, and that baker
177
+ * accepted only 16-bit PCM. Emitting float meant enabling any effect silently
178
+ * downgraded a track's volume automation to the ffmpeg expression path, which is
179
+ * capped at 32 straight segments — so a curved envelope was quantised and a
180
+ * dense one could fall back to rendering at base volume.
181
+ *
182
+ * `float` opts back out, for the ONE input that needs it: a group sub-mix. Its
183
+ * members sum at unity, so the sum can legitimately exceed full scale, and the
184
+ * group's fader is applied downstream — clamping here handed that headroom back
185
+ * one step before the thing that was going to reduce it, which is the same bug
186
+ * the float intermediate exists to avoid. Safe now only because the envelope
187
+ * baker reads float too; before that it was not.
188
+ */
189
+ export function writeWav(path, samples, sampleRate, channels = 1, float = false) {
190
+ const body = encodeSamples(samples, float);
191
+ const buf = Buffer.concat([wavHeader(body.length, sampleRate, channels, float), body]);
143
192
  // lgtm[js/insecure-temporary-file] — `path` is always inside a directory the
144
193
  // caller made with `mkdtempSync`, never a name assembled directly under
145
194
  // `tmpdir()`. Both routes here are covered: the browser host page writes into
@@ -201,14 +250,112 @@ const TRANSFER_BYTES = 8 * 1024 * 1024;
201
250
  * moves forward, so restarting each channel at t=0 would hand the second one
202
251
  * the tail gain for its whole length.
203
252
  */
204
- function applyEnvelopeToPlanes(planes, sampleRate, gainAt) {
253
+ function applyEnvelopeToPlanes(planes, sampleRate, gainAt, startFrame = 0) {
205
254
  const frames = planes[0]?.length ?? 0;
206
255
  for (let frame = 0; frame < frames; frame += 1) {
207
- const gain = gainAt(frame / sampleRate);
256
+ const gain = gainAt((startFrame + frame) / sampleRate);
208
257
  for (const plane of planes)
209
258
  plane[frame] = (plane[frame] ?? 0) * gain;
210
259
  }
211
260
  }
261
+ const DEFAULT_STREAMING_LIMITS = {
262
+ aboveBytes: 512 * 1024 * 1024,
263
+ segmentSeconds: 300,
264
+ prerollSeconds: 10,
265
+ maxPrerollSeconds: 300,
266
+ postrollSeconds: 1,
267
+ };
268
+ /** Frames the audio graph processes at a time; a window starting on one sees the same blocks a whole render does. */
269
+ const RENDER_QUANTUM = 128;
270
+ /** Tile `totalFrames` into windows that each carry their pre/post-roll. */
271
+ export function planAudioFxSegments(totalFrames, sampleRate, limits) {
272
+ const step = Math.max(1, Math.round(limits.segmentSeconds * sampleRate));
273
+ const pre = Math.round(limits.prerollSeconds * sampleRate);
274
+ const post = Math.round(limits.postrollSeconds * sampleRate);
275
+ const segments = [];
276
+ for (let keepFrom = 0; keepFrom < totalFrames; keepFrom += step) {
277
+ const keepTo = Math.min(totalFrames, keepFrom + step);
278
+ segments.push({
279
+ keepFrom,
280
+ keepTo,
281
+ readFrom: Math.max(0, Math.floor((keepFrom - pre) / RENDER_QUANTUM) * RENDER_QUANTUM),
282
+ readTo: Math.min(totalFrames, keepTo + post),
283
+ last: keepTo === totalFrames,
284
+ });
285
+ }
286
+ return segments;
287
+ }
288
+ function readFrames(fd, header, from, to) {
289
+ const frameBytes = (header.channels * header.bits) / 8;
290
+ const data = Buffer.alloc((to - from) * frameBytes);
291
+ for (let filled = 0; filled < data.length;) {
292
+ const n = readSync(fd, data, filled, data.length - filled, header.dataOffset + from * frameBytes + filled);
293
+ if (n === 0)
294
+ break;
295
+ filled += n;
296
+ }
297
+ return decodeSamples(data, header.format, header.bits, "streamed input");
298
+ }
299
+ /** A chain remembers longer than the floor: size the pre-roll to its slowest state, up to the cap. */
300
+ function chainPrerollSeconds(chain, limits, trackId) {
301
+ const { seconds, capped } = windowPrerollSeconds(chain, limits.prerollSeconds, limits.maxPrerollSeconds);
302
+ if (capped) {
303
+ console.warn(`Audio FX for track ${trackId}: the chain remembers longer than the ${limits.maxPrerollSeconds}s pre-roll cap, so its oldest repeats are cut at window seams`);
304
+ }
305
+ return seconds;
306
+ }
307
+ async function renderInSegments(openPage, inputWav, header, chain, outputWav, options, limits) {
308
+ const { sampleRate, channels, float } = header;
309
+ const totalFrames = Math.floor(header.dataBytes / ((channels * header.bits) / 8));
310
+ const gainAt = envelopeWalkerFor(options.envelope);
311
+ const prerollSeconds = chainPrerollSeconds(chain, limits, options.trackId);
312
+ const inFd = openSync(inputWav, "r");
313
+ let outFd;
314
+ try {
315
+ // Same directory rule as writeWav: the caller's mkdtemp work dir.
316
+ outFd = openSync(outputWav, "w");
317
+ writeSync(outFd, wavHeader(0, sampleRate, channels, float));
318
+ let written = 0;
319
+ for (const seg of planAudioFxSegments(totalFrames, sampleRate, {
320
+ ...limits,
321
+ prerollSeconds,
322
+ })) {
323
+ if (options.signal?.aborted) {
324
+ throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
325
+ }
326
+ const planes = deinterleave(readFrames(inFd, header, seg.readFrom, seg.readTo), channels);
327
+ // A fresh page per window, so the browser never holds more than one window of audio.
328
+ const page = await openPage();
329
+ let outPlanes;
330
+ try {
331
+ await sendPlanesToPage(page, planes);
332
+ outPlanes = await readPlanesFromPage(page, await renderPlanesInPage(page, sampleRate, chain, "", seg.readFrom / sampleRate));
333
+ }
334
+ finally {
335
+ await page.close().catch(() => undefined);
336
+ }
337
+ const rendered = outPlanes[0]?.length ?? 0;
338
+ if (rendered === 0) {
339
+ throw new AudioFxRenderError(`Audio FX produced no samples for track ${options.trackId}`);
340
+ }
341
+ // The last window keeps the chain's tail (reverb, delay), as a whole-file render does.
342
+ const skip = seg.keepFrom - seg.readFrom;
343
+ const kept = outPlanes.map((plane) => plane.subarray(skip, seg.last ? rendered : seg.keepTo - seg.readFrom));
344
+ if (gainAt)
345
+ applyEnvelopeToPlanes(kept, sampleRate, gainAt, seg.keepFrom);
346
+ const bytes = encodeSamples(interleave(kept), float);
347
+ writeFileSync(outFd, bytes);
348
+ written += bytes.length;
349
+ }
350
+ writeSync(outFd, wavHeader(written, sampleRate, channels, float), 0, 44, 0);
351
+ }
352
+ finally {
353
+ closeSync(inFd);
354
+ if (outFd !== undefined)
355
+ closeSync(outFd);
356
+ }
357
+ return gainAt !== null;
358
+ }
212
359
  /**
213
360
  * Run a chain over `inputWav`, writing `outputWav`. Resolves to the path to use
214
361
  * downstream — `outputWav` when the chain did something, `inputWav` untouched
@@ -232,8 +379,24 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
232
379
  if (!existsSync(inputWav)) {
233
380
  throw new AudioFxRenderError(`Audio FX input is missing: ${inputWav}`);
234
381
  }
235
- const { samples, sampleRate, channels, float } = readWav(inputWav);
236
- const planes = deinterleave(samples, channels);
382
+ // Automation is clocked from the file's own start, so a windowed render leaves it to the whole path.
383
+ const limits = { ...DEFAULT_STREAMING_LIMITS, ...options.streaming };
384
+ const header = options.automation ? null : readWavHeader(inputWav);
385
+ const streamed = header !== null && header.dataBytes > limits.aboveBytes;
386
+ if (streamed) {
387
+ return withFxBrowser(options, async (openPage) => ({
388
+ path: outputWav,
389
+ envelopeBaked: await renderInSegments(openPage, inputWav, header, chain, outputWav, options, limits),
390
+ }));
391
+ }
392
+ if (statSync(inputWav).size > MAX_WHOLE_FILE_BYTES) {
393
+ const why = options.automation
394
+ ? "parameter automation is not supported on inputs over 2 GiB"
395
+ : "the WAV header of an input over 2 GiB could not be read";
396
+ throw new AudioFxRenderError(`Audio FX for track ${options.trackId}: ${why} (${inputWav})`);
397
+ }
398
+ const input = readWav(inputWav);
399
+ const planes = deinterleave(input.samples, input.channels);
237
400
  // An empty track has nothing to process — and an OfflineAudioContext of zero
238
401
  // length throws, which is fatal for the WHOLE render rather than this track:
239
402
  // the error travels past the mixer's per-track failure collector. ffmpeg
@@ -243,32 +406,11 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
243
406
  // never costs a browser.
244
407
  if ((planes[0]?.length ?? 0) === 0)
245
408
  return { path: inputWav, envelopeBaked: false };
246
- // Both resources are taken INSIDE the try that releases them. The lease used
247
- // to be acquired above it, with the mkdtemp between — so a failure there
248
- // (a full disk, a read-only tmpdir) leaked a pooled browser, and a pool with
249
- // no leases left hangs every later render rather than failing one.
250
- const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-"));
251
- let lease = null;
252
- try {
253
- // Audio processing needs no GPU or special capture mode; a plain sandboxed
254
- // browser is enough, and the lease pool reuses one across tracks.
255
- // Checked before the lease, not after: an already-cancelled track has no
256
- // reason to take a browser out of the pool just to hand it straight back.
257
- if (options.signal?.aborted) {
258
- throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
259
- }
260
- lease = await acquireBrowser(["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]);
261
- const page = await lease.browser.newPage();
409
+ return withFxBrowser(options, async (openPage) => {
410
+ const page = await openPage();
262
411
  try {
263
- // AudioWorklet is only exposed in a secure context, and about:blank is
264
- // not one — the module would fail with an opaque error. A file:// page
265
- // qualifies and needs no listening socket.
266
- const hostPage = join(hostDir, "audio-fx.html");
267
- writeFileSync(hostPage, "<!doctype html><meta charset=utf-8><title>audio fx</title>");
268
- await page.goto(pathToFileURL(hostPage).href, { waitUntil: "domcontentloaded" });
269
- await page.addScriptTag({ content: getAudioFxRuntimeScript() });
270
412
  await sendPlanesToPage(page, planes);
271
- const outLengths = await renderPlanesInPage(page, sampleRate, chain, options.automation ? serializeAutomation(options.automation) : "");
413
+ const outLengths = await renderPlanesInPage(page, input.sampleRate, chain, options.automation ? serializeAutomation(options.automation) : "");
272
414
  const outPlanes = await readPlanesFromPage(page, outLengths);
273
415
  if (outPlanes.length === 0 || (outPlanes[0]?.length ?? 0) === 0) {
274
416
  throw new AudioFxRenderError(`Audio FX produced no samples for track ${options.trackId}`);
@@ -278,15 +420,32 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
278
420
  // a bake that never happened.
279
421
  const gainAt = envelopeWalkerFor(options.envelope);
280
422
  if (gainAt)
281
- applyEnvelopeToPlanes(outPlanes, sampleRate, gainAt);
423
+ applyEnvelopeToPlanes(outPlanes, input.sampleRate, gainAt);
282
424
  // Same format in as out: a float input is a group sub-mix whose headroom
283
425
  // must survive to its fader (see writeWav).
284
- writeWav(outputWav, interleave(outPlanes), sampleRate, outPlanes.length, float);
426
+ writeWav(outputWav, interleave(outPlanes), input.sampleRate, outPlanes.length, input.float);
285
427
  return { path: outputWav, envelopeBaked: gainAt !== null };
286
428
  }
287
429
  finally {
288
430
  await page.close().catch(() => undefined);
289
431
  }
432
+ });
433
+ }
434
+ /** Run `body` with a leased browser and a way to open pages on the runtime; both are released in the same try that takes them. */
435
+ async function withFxBrowser(options, body) {
436
+ const hostDir = mkdtempSync(join(tmpdir(), "hf-fx-host-"));
437
+ let lease = null;
438
+ try {
439
+ // Audio processing needs no GPU or special capture mode; a plain sandboxed
440
+ // browser is enough, and the lease pool reuses one across tracks.
441
+ // Checked before the lease, not after: an already-cancelled track has no
442
+ // reason to take a browser out of the pool just to hand it straight back.
443
+ if (options.signal?.aborted) {
444
+ throw new AudioFxRenderError(`Audio FX cancelled for track ${options.trackId}`);
445
+ }
446
+ lease = await acquireBrowser(["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]);
447
+ const { browser } = lease;
448
+ return await body(() => openFxPage(browser, hostDir));
290
449
  }
291
450
  catch (err) {
292
451
  if (err instanceof AudioFxRenderError)
@@ -298,6 +457,25 @@ export async function applyAudioFxChain(inputWav, chain, outputWav, options) {
298
457
  await lease?.release().catch(() => undefined);
299
458
  }
300
459
  }
460
+ async function openFxPage(browser, hostDir) {
461
+ const page = await browser.newPage();
462
+ try {
463
+ // AudioWorklet is only exposed in a secure context, and about:blank is
464
+ // not one — the module would fail with an opaque error. A file:// page
465
+ // qualifies and needs no listening socket.
466
+ const hostPage = join(hostDir, "audio-fx.html");
467
+ writeFileSync(hostPage, "<!doctype html><meta charset=utf-8><title>audio fx</title>");
468
+ await page.goto(pathToFileURL(hostPage).href, {
469
+ waitUntil: "domcontentloaded",
470
+ });
471
+ await page.addScriptTag({ content: getAudioFxRuntimeScript() });
472
+ return page;
473
+ }
474
+ catch (err) {
475
+ await page.close().catch(() => undefined);
476
+ throw err;
477
+ }
478
+ }
301
479
  /**
302
480
  * Hand the input planes to the page a chunk at a time.
303
481
  *
@@ -330,8 +508,8 @@ async function sendPlanesToPage(page, planes) {
330
508
  }
331
509
  /** Run the chain in the page and return each output plane's sample count. The
332
510
  * samples themselves stay page-side until `readPlanesFromPage` pulls them. */
333
- async function renderPlanesInPage(page, sampleRate, chain, automationJson) {
334
- return (await page.evaluate(async ([rate, chainJson, automation]) => {
511
+ async function renderPlanesInPage(page, sampleRate, chain, automationJson, elapsed = 0) {
512
+ return (await page.evaluate(async ([rate, chainJson, automation, at]) => {
335
513
  const w = window;
336
514
  const io = w.__HF_FX_IO;
337
515
  if (!w.__HF_AUDIO_FX || !io)
@@ -348,9 +526,9 @@ async function renderPlanesInPage(page, sampleRate, chain, automationJson) {
348
526
  // Dropped before the render allocates its own buffers, so the page does
349
527
  // not hold two copies of the track at once.
350
528
  io.in = [];
351
- io.out = await w.__HF_AUDIO_FX.render(inPlanes, rate, chainJson, automation || undefined);
529
+ io.out = await w.__HF_AUDIO_FX.render(inPlanes, rate, chainJson, automation || undefined, at);
352
530
  return io.out.map((plane) => plane.length);
353
- }, [sampleRate, JSON.stringify(chain), automationJson]));
531
+ }, [sampleRate, JSON.stringify(chain), automationJson, elapsed]));
354
532
  }
355
533
  /** Pull one output plane back over CDP, `TRANSFER_BYTES` at a time. */
356
534
  async function readPlaneFromPage(page, index, byteLength) {
@@ -0,0 +1,13 @@
1
+ import { type HfAudioFxChain } from "@hyperframes/core/audio-fx";
2
+ /**
3
+ * How far ahead of a window a chain must be fed for its state to match a whole render.
4
+ *
5
+ * Summed, since the chain is serial: a delay ahead of a reverb hands the reverb every repeat.
6
+ * Biquads, chorus and pitch shift settle well inside the caller's floor.
7
+ */
8
+ export declare function chainWarmupSeconds(chain: HfAudioFxChain): number;
9
+ /** The pre-roll a chain gets: its own memory, between `floorSeconds` and `capSeconds`. */
10
+ export declare function windowPrerollSeconds(chain: HfAudioFxChain, floorSeconds: number, capSeconds: number): {
11
+ seconds: number;
12
+ capped: boolean;
13
+ };
@@ -0,0 +1,34 @@
1
+ import { delayTailSeconds, reverbRoomSeconds } from "@hyperframes/core/audio-fx-tail";
2
+ import { enabledAudioFxNodes, normalizeAudioFxParams, } from "@hyperframes/core/audio-fx";
3
+ /** Where state stops mattering: -60 dB, i.e. e^-6.9 of what it started as. */
4
+ const DECAY_TO_FLOOR = Math.log(1000);
5
+ const param = (node, key) => Number(normalizeAudioFxParams(node.type, node.params)[key] ?? 0);
6
+ const reverbMemory = (node) => param(node, "wet") > 0 ? reverbRoomSeconds(param(node, "size")) : 0;
7
+ const delayMemory = (node) => param(node, "mix") > 0 ? delayTailSeconds(param(node, "time"), param(node, "feedback")) : 0;
8
+ const releaseMemory = (node) => (DECAY_TO_FLOOR * param(node, "release")) / 1000;
9
+ /** Seconds of input a node still remembers, to -60 dB. */
10
+ const MEMORY = {
11
+ reverb: reverbMemory,
12
+ delay: delayMemory,
13
+ compressor: releaseMemory,
14
+ limiter: releaseMemory,
15
+ truepeak: releaseMemory,
16
+ gate: releaseMemory,
17
+ };
18
+ /**
19
+ * How far ahead of a window a chain must be fed for its state to match a whole render.
20
+ *
21
+ * Summed, since the chain is serial: a delay ahead of a reverb hands the reverb every repeat.
22
+ * Biquads, chorus and pitch shift settle well inside the caller's floor.
23
+ */
24
+ export function chainWarmupSeconds(chain) {
25
+ return enabledAudioFxNodes(chain).reduce((sum, node) => sum + (MEMORY[node.type]?.(node) ?? 0), 0);
26
+ }
27
+ /** The pre-roll a chain gets: its own memory, between `floorSeconds` and `capSeconds`. */
28
+ export function windowPrerollSeconds(chain, floorSeconds, capSeconds) {
29
+ const needed = chainWarmupSeconds(chain);
30
+ return {
31
+ seconds: Math.min(capSeconds, Math.max(floorSeconds, needed)),
32
+ capped: needed > capSeconds,
33
+ };
34
+ }
@@ -23,5 +23,14 @@ export declare function buildTrackInputFilter(track: Pick<AudioTrack, "start" |
23
23
  export declare function volumeLaneKeyframes(automation: {
24
24
  lanes: HfAutomationLane[];
25
25
  }, trackStart: number, duration: number): AudioVolumeKeyframe[] | null;
26
+ /**
27
+ * The top-level root's `data-fx-chain`. It belongs to the composition, not to a clip,
28
+ * so `processCompositionAudio` takes it beside the tracks: a track the render adds
29
+ * itself has no parsed element to carry it.
30
+ */
31
+ export declare function parseMasterFxChain(html: string): string | undefined;
26
32
  export declare function parseAudioElements(html: string): AudioElement[];
27
- export declare function processCompositionAudio(elements: AudioElement[], baseDir: string, workDir: string, outputPath: string, totalDuration: number, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>, compiledDir?: string): Promise<MixResult>;
33
+ export declare function processCompositionAudio(elements: AudioElement[], baseDir: string, workDir: string, outputPath: string, totalDuration: number, signal?: AbortSignal, config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">> & {
34
+ /** Serialised master bus chain, from `parseMasterFxChain`. */
35
+ masterFxChain?: string;
36
+ }, compiledDir?: string): Promise<MixResult>;
@@ -15,11 +15,11 @@ import { resolveMediaElementSrc, resolveProjectRelativeSrc } from "./videoFrameE
15
15
  import { resolveReferencedStart } from "./referenceResolver.js";
16
16
  import { isKnownInactiveTimelineWindow } from "./mediaTimelineWindow.js";
17
17
  import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
18
- import { HF_AUDIO_FX_ATTR, parseAudioFxChain } from "@hyperframes/core/audio-fx";
18
+ import { HF_AUDIO_FX_ATTR, parseAudioFxChain, } from "@hyperframes/core/audio-fx";
19
19
  import { HF_AUDIO_AUTOMATION_ATTR, parseAutomation, resolveAutomation, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation";
20
20
  import { chainTailSeconds } from "@hyperframes/core/audio-fx-tail";
21
21
  import { isAudibleVideoElement, MEDIA_RENDER_ID_ATTR, normalizePlaybackRate, normalizeRateSpec, parseStrictFiniteTimingNumber, readElementRateSpec, readMediaStart, sourceTimeAt, timeAtSourceTime, } from "@hyperframes/core";
22
- import { resolveAudioGroups } from "@hyperframes/core/audio-groups";
22
+ import { readMasterFxChain, resolveAudioGroups } from "@hyperframes/core/audio-groups";
23
23
  import { applyAudioFxChain, AudioFxRenderError } from "./audioFxRender.js";
24
24
  /** An MP4-family container records AAC priming as an edit list; a raw .aac plays ~21 ms late.
25
25
  * The video mux, the distributed plan and the PNG-sequence sidecar all read this one name. */
@@ -395,6 +395,14 @@ export function volumeLaneKeyframes(automation, trackStart, duration) {
395
395
  push(duration, last.v);
396
396
  return out;
397
397
  }
398
+ /**
399
+ * The top-level root's `data-fx-chain`. It belongs to the composition, not to a clip,
400
+ * so `processCompositionAudio` takes it beside the tracks: a track the render adds
401
+ * itself has no parsed element to carry it.
402
+ */
403
+ export function parseMasterFxChain(html) {
404
+ return readMasterFxChain(parseHTML(unwrapTemplate(html)).document);
405
+ }
398
406
  export function parseAudioElements(html) {
399
407
  const elements = [];
400
408
  const { document } = parseHTML(unwrapTemplate(html));
@@ -573,7 +581,7 @@ async function generateSilence(outputPath, duration, signal, config) {
573
581
  failure,
574
582
  };
575
583
  }
576
- async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config) {
584
+ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config, floatWav = false) {
577
585
  const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
578
586
  const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
579
587
  if (tracks.length === 0) {
@@ -633,10 +641,7 @@ async function mixAudioTracks(tracks, outputPath, totalDuration, signal, config)
633
641
  scriptPath,
634
642
  "-map",
635
643
  "[out]",
636
- "-acodec",
637
- "aac",
638
- "-b:a",
639
- "192k",
644
+ ...(floatWav ? ["-acodec", "pcm_f32le", "-ar", "48000"] : ["-acodec", "aac", "-b:a", "192k"]),
640
645
  "-t",
641
646
  String(totalDuration),
642
647
  "-y",
@@ -833,6 +838,44 @@ async function mixGroupMembers(memberTracks, outputPath, totalDuration, signal,
833
838
  return { success: false, error: formatFfmpegError(result.exitCode, result.stderr) };
834
839
  return { success: true, degradedAutomation };
835
840
  }
841
+ /**
842
+ * Sum the tracks to a float WAV, run the master chain over the sum, then AAC-encode.
843
+ * Groups are already folded into `tracks`, and the sum stays float until the encoder.
844
+ */
845
+ async function mixThroughMasterBus(tracks, masterChain, outputPath, workDir, totalDuration, signal, config) {
846
+ const sumPath = join(workDir, "master-sum.wav");
847
+ const sum = await mixAudioTracks(tracks, sumPath, totalDuration, signal, config, true);
848
+ if (!sum.success)
849
+ return { ...sum, outputPath };
850
+ const fx = await applyAudioFxChain(sumPath, masterChain, join(workDir, "master-fx.wav"), {
851
+ trackId: "master",
852
+ signal,
853
+ });
854
+ const encoded = await runFfmpeg([
855
+ "-i",
856
+ fx.path,
857
+ "-t",
858
+ String(totalDuration),
859
+ "-acodec",
860
+ "aac",
861
+ "-b:a",
862
+ "192k",
863
+ "-y",
864
+ outputPath,
865
+ ], { signal, timeout: config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout });
866
+ if (!encoded.success) {
867
+ const failure = ffmpegFailure("mix", encoded);
868
+ return {
869
+ success: false,
870
+ outputPath,
871
+ durationMs: sum.durationMs + encoded.durationMs,
872
+ tracksProcessed: 0,
873
+ error: failure.detail,
874
+ failures: [failure],
875
+ };
876
+ }
877
+ return { ...sum, outputPath, durationMs: sum.durationMs + encoded.durationMs };
878
+ }
836
879
  export async function processCompositionAudio(elements, baseDir, workDir, outputPath, totalDuration, signal, config, compiledDir) {
837
880
  const startMs = Date.now();
838
881
  const tracks = [];
@@ -1095,13 +1138,16 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1095
1138
  // The producer only surfaces audio failures when `success` is false; mixing
1096
1139
  // the remaining tracks made the omitted cue indistinguishable from a valid
1097
1140
  // render unless someone manually audited that exact audio window.
1098
- const bail = () => {
1141
+ const removeWorkDir = () => {
1099
1142
  try {
1100
1143
  rmSync(workDir, { recursive: true, force: true });
1101
1144
  }
1102
1145
  catch {
1103
1146
  /* ignore */
1104
1147
  }
1148
+ };
1149
+ const bail = () => {
1150
+ removeWorkDir();
1105
1151
  return {
1106
1152
  success: false,
1107
1153
  outputPath,
@@ -1113,6 +1159,26 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1113
1159
  };
1114
1160
  if (failures.length > 0)
1115
1161
  return bail();
1162
+ // Parsed before any group is mixed, so a malformed master chain fails fast and
1163
+ // as a failure, the way an unreadable group chain does.
1164
+ let masterChain;
1165
+ const masterFxJson = config?.masterFxChain;
1166
+ if (masterFxJson) {
1167
+ try {
1168
+ masterChain = parseAudioFxChain(masterFxJson);
1169
+ }
1170
+ catch (err) {
1171
+ failures.push({
1172
+ stage: "mix",
1173
+ reason: "internal",
1174
+ owner: "user",
1175
+ retryable: false,
1176
+ elementId: "master",
1177
+ detail: boundedDetail(`Master bus chain is unreadable: ${err instanceof Error ? err.message : String(err)}`),
1178
+ });
1179
+ return bail();
1180
+ }
1181
+ }
1116
1182
  // Sub-mix each group into one processed bus (design doc §1.3), then fold it
1117
1183
  // into the flat track list as a single AudioTrack — everything downstream
1118
1184
  // (the final mixAudioTracks call) never has to know groups exist. Group
@@ -1195,8 +1261,10 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1195
1261
  });
1196
1262
  }
1197
1263
  catch (err) {
1198
- if (err instanceof AudioFxRenderError)
1264
+ if (err instanceof AudioFxRenderError) {
1265
+ removeWorkDir();
1199
1266
  throw err;
1267
+ }
1200
1268
  failures.push({
1201
1269
  stage: "mix",
1202
1270
  reason: "internal",
@@ -1209,12 +1277,15 @@ export async function processCompositionAudio(elements, baseDir, workDir, output
1209
1277
  }
1210
1278
  if (failures.length > 0)
1211
1279
  return bail();
1212
- const mixResult = await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
1280
+ let mixResult;
1213
1281
  try {
1214
- rmSync(workDir, { recursive: true, force: true });
1282
+ mixResult =
1283
+ masterChain && tracks.length > 0
1284
+ ? await mixThroughMasterBus(tracks, masterChain, outputPath, workDir, totalDuration, signal, config)
1285
+ : await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
1215
1286
  }
1216
- catch {
1217
- /* ignore */
1287
+ finally {
1288
+ removeWorkDir();
1218
1289
  }
1219
1290
  // A group whose sub-mix had to drop member automation reports it the same
1220
1291
  // way mixAudioTracks reports its own degradation: on a SUCCESSFUL result, so
@@ -7,6 +7,7 @@
7
7
  * the page to expose window.__hf, then captures frames deterministically
8
8
  * via Chrome's BeginFrame API or Page.captureScreenshot fallback.
9
9
  */
10
+ import { CDPSession, } from "puppeteer-core";
10
11
  import { existsSync, mkdirSync, writeFileSync } from "fs";
11
12
  import { join } from "path";
12
13
  import { COMPOSITION_SOURCE_URL, quantizeSeekTime, quantizeTimeToFrame, fpsToNumber, resolveAuthoredTimingWindow, } from "@hyperframes/core";
@@ -855,6 +856,15 @@ async function constructCaptureSession(input) {
855
856
  const { browser, captureMode } = browserLease;
856
857
  const page = await browser.newPage();
857
858
  onPageCreated(page);
859
+ // Cap the existing session's inspection buffer; new sessions have separate buffers.
860
+ const frame = page.mainFrame();
861
+ // Frame.client is runtime-only, so keep capture working when it is absent.
862
+ if ("client" in frame && frame.client instanceof CDPSession) {
863
+ await frame.client.send("Network.enable", {
864
+ maxTotalBufferSize: 32 * 1024 * 1024,
865
+ maxResourceBufferSize: 16 * 1024 * 1024,
866
+ });
867
+ }
858
868
  // Polyfill esbuild's keepNames helper inside the page.
859
869
  //
860
870
  // The engine is published as raw TypeScript (`packages/engine/package.json`
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * BeginFrame-based deterministic screenshot capture and video frame injection.
6
6
  */
7
+ import { withVideoFrameSources } from "./videoFrameTransport.js";
7
8
  import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
8
9
  import { HF_COLOR_GRADING_CANVAS_ID_PREFIX, MEDIA_RENDER_ID_ATTR, MEDIA_VISUAL_STYLE_PROPERTIES, RENDER_FRAME_ID_PREFIX, RENDER_FRAME_ID_SUFFIX, renderFrameIdForRenderId, } from "@hyperframes/core";
9
10
  export const cdpSessionCache = new WeakMap();
@@ -547,6 +548,9 @@ export async function ensureRenderFrameSiblings(page) {
547
548
  export async function injectVideoFramesBatch(page, updates) {
548
549
  if (updates.length === 0)
549
550
  return [];
551
+ return withVideoFrameSources(page, updates, (resolved) => injectResolvedVideoFramesBatch(page, resolved));
552
+ }
553
+ async function injectResolvedVideoFramesBatch(page, updates) {
550
554
  return await page.evaluate(
551
555
  // fallow-ignore-next-line complexity
552
556
  async (items, visualProperties, colorGradingSourceHiddenAttr) => {
@@ -690,8 +694,12 @@ export async function injectVideoFramesBatch(page, updates) {
690
694
  img.style.zIndex = computedStyle.zIndex;
691
695
  img.decoding = "sync";
692
696
  if (img.getAttribute("src") !== item.dataUri) {
697
+ if (item.crossOrigin)
698
+ img.crossOrigin = item.crossOrigin;
699
+ else
700
+ img.removeAttribute("crossorigin");
693
701
  img.src = item.dataUri;
694
- const source = item.dataUri.startsWith("data:") ? "inline frame" : item.dataUri;
702
+ const source = item.sourceLabel ?? (item.dataUri.startsWith("data:") ? "inline frame" : item.dataUri);
695
703
  pendingDecodes.push(img.decode().catch((error) => {
696
704
  throw new Error(`Video frame for "${item.videoId}" failed to load (${source}): ${error instanceof Error ? error.message : String(error)}`);
697
705
  }));
@@ -0,0 +1,13 @@
1
+ import { type Page } from "puppeteer-core";
2
+ export interface VideoFrameUpdate {
3
+ videoId: string;
4
+ dataUri: string;
5
+ crossOrigin?: "anonymous";
6
+ sourceLabel?: string;
7
+ }
8
+ /**
9
+ * Inline image URLs outlive the Node LRU in Chrome's resource cache. Deliver
10
+ * their bytes as no-store responses over CDP instead: the page sees short URLs,
11
+ * and frame requests never wait for the source videos' HTTP connections.
12
+ */
13
+ export declare function withVideoFrameSources<T>(page: Page, updates: VideoFrameUpdate[], inject: (resolved: VideoFrameUpdate[]) => Promise<T>): Promise<T>;
@@ -0,0 +1,98 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const base64ImagePrefix = /^data:(image\/[\w.+-]+);base64,/;
3
+ const transports = new WeakMap();
4
+ async function createTransport(page) {
5
+ const pageUrl = new URL(page.url());
6
+ const origin = /^https?:$/.test(pageUrl.protocol)
7
+ ? pageUrl.origin
8
+ : "https://hyperframes-render.invalid";
9
+ const prefix = `${origin}/__hyperframes_video_frames__/${randomUUID()}/`;
10
+ const responses = new Map();
11
+ const client = await page.createCDPSession();
12
+ client.on("Fetch.requestPaused", (event) => {
13
+ const frame = responses.get(event.request.url);
14
+ void fulfillFrame(client, event.requestId, frame);
15
+ });
16
+ try {
17
+ await client.send("Fetch.enable", {
18
+ patterns: [{ urlPattern: `${prefix}*`, requestStage: "Request" }],
19
+ });
20
+ }
21
+ catch (error) {
22
+ await client.detach().catch(() => { });
23
+ throw error;
24
+ }
25
+ page.once("close", () => responses.clear());
26
+ return { prefix, nextId: 0, responses };
27
+ }
28
+ async function fulfillFrame(client, requestId, frame) {
29
+ try {
30
+ await client.send("Fetch.fulfillRequest", {
31
+ requestId,
32
+ responseCode: frame ? 200 : 404,
33
+ responseHeaders: [
34
+ { name: "Content-Type", value: frame?.contentType ?? "text/plain" },
35
+ { name: "Cache-Control", value: "no-store" },
36
+ { name: "Access-Control-Allow-Origin", value: "*" },
37
+ ],
38
+ body: frame?.body ?? "",
39
+ });
40
+ }
41
+ catch (error) {
42
+ if (frame)
43
+ frame.error = error;
44
+ // A failed protocol response must unblock img.decode, including on page closure.
45
+ await client.send("Fetch.failRequest", { requestId, errorReason: "Failed" }).catch(() => { });
46
+ }
47
+ }
48
+ /**
49
+ * Inline image URLs outlive the Node LRU in Chrome's resource cache. Deliver
50
+ * their bytes as no-store responses over CDP instead: the page sees short URLs,
51
+ * and frame requests never wait for the source videos' HTTP connections.
52
+ */
53
+ export async function withVideoFrameSources(page, updates, inject) {
54
+ if (!updates.some((update) => base64ImagePrefix.test(update.dataUri)))
55
+ return inject(updates);
56
+ let pending = transports.get(page);
57
+ if (!pending) {
58
+ pending = createTransport(page);
59
+ transports.set(page, pending);
60
+ }
61
+ let transport;
62
+ try {
63
+ transport = await pending;
64
+ }
65
+ catch (error) {
66
+ transports.delete(page);
67
+ throw error;
68
+ }
69
+ const registered = [];
70
+ try {
71
+ const resolved = updates.map((update) => {
72
+ const match = base64ImagePrefix.exec(update.dataUri);
73
+ if (!match)
74
+ return update;
75
+ const frame = {
76
+ contentType: match[1] ?? "image/png",
77
+ body: update.dataUri.slice(match[0].length),
78
+ };
79
+ const url = `${transport.prefix}${transport.nextId++}`;
80
+ transport.responses.set(url, frame);
81
+ registered.push({ url, frame });
82
+ return {
83
+ ...update,
84
+ dataUri: url,
85
+ crossOrigin: "anonymous",
86
+ sourceLabel: "inline frame",
87
+ };
88
+ });
89
+ return await inject(resolved);
90
+ }
91
+ catch (error) {
92
+ throw registered.find(({ frame }) => frame.error !== undefined)?.frame.error ?? error;
93
+ }
94
+ finally {
95
+ for (const { url } of registered)
96
+ transport.responses.delete(url);
97
+ }
98
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/engine",
3
- "version": "0.8.148",
3
+ "version": "0.8.150",
4
4
  "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,8 +46,8 @@
46
46
  "linkedom": "^0.18.12",
47
47
  "puppeteer": "^25.8.0",
48
48
  "puppeteer-core": "^25.8.0",
49
- "@hyperframes/core": "^0.8.148",
50
- "@hyperframes/parsers": "^0.8.148"
49
+ "@hyperframes/core": "^0.8.150",
50
+ "@hyperframes/parsers": "^0.8.150"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.10",