@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 +1 -1
- package/dist/index.js +1 -1
- package/dist/services/audioFxRender.d.ts +43 -5
- package/dist/services/audioFxRender.js +240 -62
- package/dist/services/audioFxWarmup.d.ts +13 -0
- package/dist/services/audioFxWarmup.js +34 -0
- package/dist/services/audioMixer.d.ts +10 -1
- package/dist/services/audioMixer.js +84 -13
- package/dist/services/frameCapture.js +10 -0
- package/dist/services/screenshotService.js +9 -1
- package/dist/services/videoFrameTransport.d.ts +13 -0
- package/dist/services/videoFrameTransport.js +98 -0
- package/package.json +3 -3
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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),
|
|
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
|
-
|
|
236
|
-
const
|
|
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
|
-
|
|
247
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
1280
|
+
let mixResult;
|
|
1213
1281
|
try {
|
|
1214
|
-
|
|
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
|
-
|
|
1217
|
-
|
|
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.
|
|
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.
|
|
50
|
-
"@hyperframes/parsers": "^0.8.
|
|
49
|
+
"@hyperframes/core": "^0.8.150",
|
|
50
|
+
"@hyperframes/parsers": "^0.8.150"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^25.0.10",
|