@hyperframes/engine 0.6.118 → 0.6.120
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/package.json +24 -7
- package/scripts/generate-lut-reference.py +0 -168
- package/scripts/test-fitTextFontSize-browser.ts +0 -135
- package/src/cdp-headless-experimental.d.ts +0 -54
- package/src/config.test.ts +0 -213
- package/src/config.ts +0 -417
- package/src/index.ts +0 -273
- package/src/services/audioMixer.test.ts +0 -326
- package/src/services/audioMixer.ts +0 -604
- package/src/services/audioMixer.types.ts +0 -35
- package/src/services/audioVolumeEnvelope.test.ts +0 -176
- package/src/services/audioVolumeEnvelope.ts +0 -138
- package/src/services/browserManager.test.ts +0 -330
- package/src/services/browserManager.ts +0 -670
- package/src/services/chunkEncoder.test.ts +0 -1415
- package/src/services/chunkEncoder.ts +0 -831
- package/src/services/chunkEncoder.types.ts +0 -60
- package/src/services/extractionCache.test.ts +0 -199
- package/src/services/extractionCache.ts +0 -216
- package/src/services/fileServer.ts +0 -110
- package/src/services/frameCapture-discardWarmup.test.ts +0 -183
- package/src/services/frameCapture-namePolyfill.test.ts +0 -78
- package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
- package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
- package/src/services/frameCapture-warmupTicks.test.ts +0 -174
- package/src/services/frameCapture.test.ts +0 -192
- package/src/services/frameCapture.ts +0 -1934
- package/src/services/hdrCapture.test.ts +0 -159
- package/src/services/hdrCapture.ts +0 -315
- package/src/services/parallelCoordinator.test.ts +0 -139
- package/src/services/parallelCoordinator.ts +0 -437
- package/src/services/screenshotService.test.ts +0 -510
- package/src/services/screenshotService.ts +0 -615
- package/src/services/streamingEncoder.test.ts +0 -832
- package/src/services/streamingEncoder.ts +0 -594
- package/src/services/systemMemory.test.ts +0 -324
- package/src/services/systemMemory.ts +0 -180
- package/src/services/videoFrameExtractor.test.ts +0 -1062
- package/src/services/videoFrameExtractor.ts +0 -1139
- package/src/services/videoFrameInjector.test.ts +0 -300
- package/src/services/videoFrameInjector.ts +0 -687
- package/src/services/vp9Options.ts +0 -13
- package/src/types.ts +0 -191
- package/src/utils/alphaBlit.test.ts +0 -1349
- package/src/utils/alphaBlit.ts +0 -1015
- package/src/utils/assertSwiftShader.test.ts +0 -130
- package/src/utils/assertSwiftShader.ts +0 -126
- package/src/utils/ffmpegBinaries.test.ts +0 -43
- package/src/utils/ffmpegBinaries.ts +0 -63
- package/src/utils/ffprobe.test.ts +0 -342
- package/src/utils/ffprobe.ts +0 -457
- package/src/utils/gpuEncoder.test.ts +0 -140
- package/src/utils/gpuEncoder.ts +0 -268
- package/src/utils/hdr.test.ts +0 -191
- package/src/utils/hdr.ts +0 -137
- package/src/utils/hdrCompositing.test.ts +0 -130
- package/src/utils/htmlTemplate.test.ts +0 -42
- package/src/utils/htmlTemplate.ts +0 -42
- package/src/utils/layerCompositor.test.ts +0 -150
- package/src/utils/layerCompositor.ts +0 -58
- package/src/utils/parityContract.ts +0 -1
- package/src/utils/processTracker.test.ts +0 -74
- package/src/utils/processTracker.ts +0 -41
- package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
- package/src/utils/runFfmpeg.test.ts +0 -102
- package/src/utils/runFfmpeg.ts +0 -136
- package/src/utils/shaderTransitions.test.ts +0 -738
- package/src/utils/shaderTransitions.ts +0 -1130
- package/src/utils/uint16-alignment-audit.test.ts +0 -125
- package/src/utils/urlDownloader.test.ts +0 -65
- package/src/utils/urlDownloader.ts +0 -143
- package/tsconfig.json +0 -19
- package/vitest.config.ts +0 -7
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Audio Mixer Service
|
|
3
|
-
*
|
|
4
|
-
* Processes and mixes audio tracks using FFmpeg.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { existsSync, mkdirSync, rmSync } from "fs";
|
|
8
|
-
import { isAbsolute, join, dirname } from "path";
|
|
9
|
-
import { parseHTML } from "linkedom";
|
|
10
|
-
import { extractAudioMetadata } from "../utils/ffprobe.js";
|
|
11
|
-
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
|
12
|
-
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
|
|
13
|
-
import { runFfmpeg } from "../utils/runFfmpeg.js";
|
|
14
|
-
import { unwrapTemplate } from "../utils/htmlTemplate.js";
|
|
15
|
-
import { resolveProjectRelativeSrc } from "./videoFrameExtractor.js";
|
|
16
|
-
import type { AudioElement, AudioTrack, MixResult } from "./audioMixer.types.js";
|
|
17
|
-
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";
|
|
18
|
-
|
|
19
|
-
export type { AudioElement, MixResult } from "./audioMixer.types.js";
|
|
20
|
-
|
|
21
|
-
function clampVolume(volume: number): number {
|
|
22
|
-
if (!Number.isFinite(volume)) return 1;
|
|
23
|
-
return Math.max(0, Math.min(1, volume));
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function formatFilterNumber(value: number): string {
|
|
27
|
-
return Number(value.toFixed(6)).toString();
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function escapeExpressionCommas(expression: string): string {
|
|
31
|
-
return expression.replace(/\\/g, "\\\\").replace(/,/g, "\\,");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Upper bound on volume-automation keyframes folded into the FFmpeg `volume`
|
|
36
|
-
* expression. The expression nests one `if(lt(...))` per keyframe, and
|
|
37
|
-
* FFmpeg's expression evaluator has a finite nesting depth: past ~95 levels
|
|
38
|
-
* (build-dependent — lower on some Linux ffmpeg builds) `volume=...:eval=frame`
|
|
39
|
-
* fails filter-graph init, which fails the whole mix and drops the audio track
|
|
40
|
-
* entirely. The 60 Hz timeline probe routinely emits 100–300 keyframes for a
|
|
41
|
-
* multi-second fade (GH #1066 follow-up: a 171-keyframe GSAP fade rendered with
|
|
42
|
-
* no audio). 32 segments keeps a wide safety margin and is far more resolution
|
|
43
|
-
* than a piecewise-linear volume envelope needs.
|
|
44
|
-
*/
|
|
45
|
-
const MAX_VOLUME_SEGMENTS = 32;
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Volume delta below which a keyframe is collinear enough to drop. Kept tight
|
|
49
|
-
* (0.5% linear) so the rendered piecewise-linear envelope tracks the GSAP curve
|
|
50
|
-
* the browser plays in preview to within ~0.2 dB across the audible range — well
|
|
51
|
-
* under the ~1 dB loudness JND, so render stays WYSIWYG with preview. A full
|
|
52
|
-
* ease-in/ease-out fade still reduces to ~25 segments, inside MAX_VOLUME_SEGMENTS.
|
|
53
|
-
*/
|
|
54
|
-
const VOLUME_SIMPLIFY_EPSILON = 0.005;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Reduce a sorted keyframe list to a perceptually-equivalent piecewise-linear
|
|
58
|
-
* envelope with a bounded segment count.
|
|
59
|
-
*
|
|
60
|
-
* Ramer–Douglas–Peucker drops control points lying within
|
|
61
|
-
* `VOLUME_SIMPLIFY_EPSILON` of the line through their neighbours (a linear fade
|
|
62
|
-
* collapses to its two endpoints; an eased fade to a handful). A uniform
|
|
63
|
-
* downsample backstop then bounds pathological inputs (e.g. audio-rate volume
|
|
64
|
-
* oscillation) to `MAX_VOLUME_SEGMENTS`. Endpoints are always preserved so the
|
|
65
|
-
* envelope still spans the full clip.
|
|
66
|
-
*/
|
|
67
|
-
function simplifyVolumeKeyframes(
|
|
68
|
-
keyframes: { time: number; volume: number }[],
|
|
69
|
-
): { time: number; volume: number }[] {
|
|
70
|
-
if (keyframes.length < 3) return keyframes;
|
|
71
|
-
|
|
72
|
-
const keep = new Array<boolean>(keyframes.length).fill(false);
|
|
73
|
-
keep[0] = true;
|
|
74
|
-
keep[keyframes.length - 1] = true;
|
|
75
|
-
const stack: [number, number][] = [[0, keyframes.length - 1]];
|
|
76
|
-
while (stack.length > 0) {
|
|
77
|
-
const [startIndex, endIndex] = stack.pop()!;
|
|
78
|
-
const start = keyframes[startIndex]!;
|
|
79
|
-
const end = keyframes[endIndex]!;
|
|
80
|
-
const span = end.time - start.time;
|
|
81
|
-
let maxDistance = VOLUME_SIMPLIFY_EPSILON;
|
|
82
|
-
let splitIndex = -1;
|
|
83
|
-
for (let i = startIndex + 1; i < endIndex; i += 1) {
|
|
84
|
-
const point = keyframes[i]!;
|
|
85
|
-
const interpolated =
|
|
86
|
-
span === 0
|
|
87
|
-
? start.volume
|
|
88
|
-
: start.volume + ((end.volume - start.volume) * (point.time - start.time)) / span;
|
|
89
|
-
const distance = Math.abs(point.volume - interpolated);
|
|
90
|
-
if (distance > maxDistance) {
|
|
91
|
-
maxDistance = distance;
|
|
92
|
-
splitIndex = i;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (splitIndex !== -1) {
|
|
96
|
-
keep[splitIndex] = true;
|
|
97
|
-
stack.push([startIndex, splitIndex], [splitIndex, endIndex]);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const simplified = keyframes.filter((_, i) => keep[i]);
|
|
102
|
-
if (simplified.length <= MAX_VOLUME_SEGMENTS) return simplified;
|
|
103
|
-
|
|
104
|
-
const step = (simplified.length - 1) / (MAX_VOLUME_SEGMENTS - 1);
|
|
105
|
-
const sampled: { time: number; volume: number }[] = [];
|
|
106
|
-
for (let i = 0; i < MAX_VOLUME_SEGMENTS; i += 1) {
|
|
107
|
-
const point = simplified[Math.round(i * step)]!;
|
|
108
|
-
if (sampled.length === 0 || point.time > sampled.at(-1)!.time) sampled.push(point);
|
|
109
|
-
}
|
|
110
|
-
return sampled;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function buildVolumeExpression(track: AudioTrack, ignoreKeyframes = false): string {
|
|
114
|
-
const trimDuration = track.end - track.start;
|
|
115
|
-
const staticVolume = clampVolume(track.volume);
|
|
116
|
-
const keyframes = (ignoreKeyframes ? [] : (track.volumeKeyframes ?? []))
|
|
117
|
-
.filter((keyframe) => Number.isFinite(keyframe.time) && Number.isFinite(keyframe.volume))
|
|
118
|
-
.map((keyframe) => ({
|
|
119
|
-
time: Math.max(0, Math.min(trimDuration, keyframe.time - track.start)),
|
|
120
|
-
volume: clampVolume(keyframe.volume),
|
|
121
|
-
}))
|
|
122
|
-
.sort((a, b) => a.time - b.time);
|
|
123
|
-
|
|
124
|
-
if (keyframes.length === 0) return `volume=${formatFilterNumber(staticVolume)}`;
|
|
125
|
-
|
|
126
|
-
if (keyframes[0]!.time > 0) {
|
|
127
|
-
keyframes.unshift({ time: 0, volume: staticVolume });
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const deduped: typeof keyframes = [];
|
|
131
|
-
for (const keyframe of keyframes) {
|
|
132
|
-
const previous = deduped.at(-1);
|
|
133
|
-
if (previous && Math.abs(previous.time - keyframe.time) < 0.000001) {
|
|
134
|
-
previous.volume = keyframe.volume;
|
|
135
|
-
} else {
|
|
136
|
-
deduped.push(keyframe);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Collapse the densely-sampled probe output to a bounded piecewise-linear
|
|
141
|
-
// envelope. Without this, the nested-if expression below grows one level per
|
|
142
|
-
// keyframe and overflows FFmpeg's expression evaluator (see MAX_VOLUME_SEGMENTS).
|
|
143
|
-
const simplified = simplifyVolumeKeyframes(deduped);
|
|
144
|
-
|
|
145
|
-
if (simplified.length === 1) {
|
|
146
|
-
return `volume=${formatFilterNumber(simplified[0]!.volume)}`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
let expression = formatFilterNumber(simplified.at(-1)!.volume);
|
|
150
|
-
for (let i = simplified.length - 2; i >= 0; i -= 1) {
|
|
151
|
-
const current = simplified[i]!;
|
|
152
|
-
const next = simplified[i + 1]!;
|
|
153
|
-
const currentTime = formatFilterNumber(current.time);
|
|
154
|
-
const nextTime = formatFilterNumber(next.time);
|
|
155
|
-
const currentVolume = formatFilterNumber(current.volume);
|
|
156
|
-
const span = Math.max(0.000001, next.time - current.time);
|
|
157
|
-
const slope = formatFilterNumber((next.volume - current.volume) / span);
|
|
158
|
-
const segment = `${currentVolume}+(${slope})*(t-${currentTime})`;
|
|
159
|
-
expression = `if(lt(t,${nextTime}),${segment},${expression})`;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return `volume=${escapeExpressionCommas(expression)}:eval=frame`;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
interface ExtractResult {
|
|
166
|
-
success: boolean;
|
|
167
|
-
outputPath: string;
|
|
168
|
-
durationMs: number;
|
|
169
|
-
error?: string;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function parseAudioElements(html: string): AudioElement[] {
|
|
173
|
-
const elements: AudioElement[] = [];
|
|
174
|
-
const { document } = parseHTML(unwrapTemplate(html));
|
|
175
|
-
|
|
176
|
-
// Parse <audio> elements
|
|
177
|
-
const audioEls = document.querySelectorAll("audio[id][src]");
|
|
178
|
-
for (const el of audioEls) {
|
|
179
|
-
const id = el.getAttribute("id");
|
|
180
|
-
const src = el.getAttribute("src");
|
|
181
|
-
if (!id || !src) continue;
|
|
182
|
-
|
|
183
|
-
const startAttr = el.getAttribute("data-start");
|
|
184
|
-
const endAttr = el.getAttribute("data-end");
|
|
185
|
-
const mediaStartAttr = el.getAttribute("data-media-start");
|
|
186
|
-
const layerAttr = el.getAttribute("data-layer");
|
|
187
|
-
const volumeAttr = el.getAttribute("data-volume");
|
|
188
|
-
|
|
189
|
-
elements.push({
|
|
190
|
-
id,
|
|
191
|
-
src,
|
|
192
|
-
start: startAttr ? parseFloat(startAttr) : 0,
|
|
193
|
-
end: endAttr ? parseFloat(endAttr) : 0,
|
|
194
|
-
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
|
195
|
-
layer: layerAttr ? parseInt(layerAttr) : 0,
|
|
196
|
-
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
|
|
197
|
-
type: "audio",
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Parse <video> elements with data-has-audio="true"
|
|
202
|
-
const videoEls = document.querySelectorAll('video[id][src][data-has-audio="true"]');
|
|
203
|
-
for (const el of videoEls) {
|
|
204
|
-
const id = el.getAttribute("id");
|
|
205
|
-
const src = el.getAttribute("src");
|
|
206
|
-
if (!id || !src) continue;
|
|
207
|
-
|
|
208
|
-
const startAttr = el.getAttribute("data-start");
|
|
209
|
-
const endAttr = el.getAttribute("data-end");
|
|
210
|
-
const mediaStartAttr = el.getAttribute("data-media-start");
|
|
211
|
-
const layerAttr = el.getAttribute("data-layer");
|
|
212
|
-
const volumeAttr = el.getAttribute("data-volume");
|
|
213
|
-
|
|
214
|
-
elements.push({
|
|
215
|
-
id: `${id}-audio`,
|
|
216
|
-
src,
|
|
217
|
-
start: startAttr ? parseFloat(startAttr) : 0,
|
|
218
|
-
end: endAttr ? parseFloat(endAttr) : 0,
|
|
219
|
-
mediaStart: mediaStartAttr ? parseFloat(mediaStartAttr) : 0,
|
|
220
|
-
layer: layerAttr ? parseInt(layerAttr) : 0,
|
|
221
|
-
volume: volumeAttr ? parseFloat(volumeAttr) : 1.0,
|
|
222
|
-
type: "video",
|
|
223
|
-
});
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return elements;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
async function extractAudioFromVideo(
|
|
230
|
-
videoPath: string,
|
|
231
|
-
outputPath: string,
|
|
232
|
-
options?: { startTime?: number; duration?: number },
|
|
233
|
-
signal?: AbortSignal,
|
|
234
|
-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
|
235
|
-
): Promise<ExtractResult> {
|
|
236
|
-
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
237
|
-
const outputDir = dirname(outputPath);
|
|
238
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
239
|
-
|
|
240
|
-
const args: string[] = ["-i", videoPath];
|
|
241
|
-
if (options?.startTime !== undefined) args.push("-ss", String(options.startTime));
|
|
242
|
-
if (options?.duration !== undefined) args.push("-t", String(options.duration));
|
|
243
|
-
args.push("-vn", "-acodec", "pcm_s16le", "-ar", "48000", "-ac", "2", "-y", outputPath);
|
|
244
|
-
|
|
245
|
-
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
246
|
-
|
|
247
|
-
if (signal?.aborted) {
|
|
248
|
-
return {
|
|
249
|
-
success: false,
|
|
250
|
-
outputPath,
|
|
251
|
-
durationMs: result.durationMs,
|
|
252
|
-
error: "Audio extract cancelled",
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
if (!result.success) {
|
|
256
|
-
return {
|
|
257
|
-
success: false,
|
|
258
|
-
outputPath,
|
|
259
|
-
durationMs: result.durationMs,
|
|
260
|
-
error:
|
|
261
|
-
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
return { success: true, outputPath, durationMs: result.durationMs };
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
async function prepareAudioTrack(
|
|
268
|
-
srcPath: string,
|
|
269
|
-
outputPath: string,
|
|
270
|
-
mediaStart: number,
|
|
271
|
-
duration: number,
|
|
272
|
-
signal?: AbortSignal,
|
|
273
|
-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
|
274
|
-
): Promise<ExtractResult> {
|
|
275
|
-
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
276
|
-
const outputDir = dirname(outputPath);
|
|
277
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
278
|
-
|
|
279
|
-
const args = [
|
|
280
|
-
"-ss",
|
|
281
|
-
String(mediaStart),
|
|
282
|
-
"-t",
|
|
283
|
-
String(duration),
|
|
284
|
-
"-i",
|
|
285
|
-
srcPath,
|
|
286
|
-
"-acodec",
|
|
287
|
-
"pcm_s16le",
|
|
288
|
-
"-ar",
|
|
289
|
-
"48000",
|
|
290
|
-
"-ac",
|
|
291
|
-
"2",
|
|
292
|
-
"-y",
|
|
293
|
-
outputPath,
|
|
294
|
-
];
|
|
295
|
-
|
|
296
|
-
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
297
|
-
|
|
298
|
-
if (signal?.aborted) {
|
|
299
|
-
return {
|
|
300
|
-
success: false,
|
|
301
|
-
outputPath,
|
|
302
|
-
durationMs: result.durationMs,
|
|
303
|
-
error: "Audio prepare cancelled",
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
return {
|
|
307
|
-
success: result.success,
|
|
308
|
-
outputPath,
|
|
309
|
-
durationMs: result.durationMs,
|
|
310
|
-
error: !result.success
|
|
311
|
-
? result.exitCode !== null
|
|
312
|
-
? `FFmpeg exited with code ${result.exitCode}: ${result.stderr.slice(-200)}`
|
|
313
|
-
: result.stderr
|
|
314
|
-
: undefined,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function generateSilence(
|
|
319
|
-
outputPath: string,
|
|
320
|
-
duration: number,
|
|
321
|
-
signal?: AbortSignal,
|
|
322
|
-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout">>,
|
|
323
|
-
): Promise<ExtractResult> {
|
|
324
|
-
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
325
|
-
const outputDir = dirname(outputPath);
|
|
326
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
327
|
-
|
|
328
|
-
const args = [
|
|
329
|
-
"-f",
|
|
330
|
-
"lavfi",
|
|
331
|
-
"-i",
|
|
332
|
-
"anullsrc=r=48000:cl=stereo",
|
|
333
|
-
"-t",
|
|
334
|
-
String(duration),
|
|
335
|
-
"-acodec",
|
|
336
|
-
"pcm_s16le",
|
|
337
|
-
"-y",
|
|
338
|
-
outputPath,
|
|
339
|
-
];
|
|
340
|
-
|
|
341
|
-
const result = await runFfmpeg(args, { signal, timeout: ffmpegProcessTimeout });
|
|
342
|
-
|
|
343
|
-
if (signal?.aborted) {
|
|
344
|
-
return {
|
|
345
|
-
success: false,
|
|
346
|
-
outputPath,
|
|
347
|
-
durationMs: result.durationMs,
|
|
348
|
-
error: "Silence generation cancelled",
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
return {
|
|
352
|
-
success: result.success,
|
|
353
|
-
outputPath,
|
|
354
|
-
durationMs: result.durationMs,
|
|
355
|
-
error: !result.success
|
|
356
|
-
? result.exitCode !== null
|
|
357
|
-
? `FFmpeg exited with code ${result.exitCode}`
|
|
358
|
-
: result.stderr
|
|
359
|
-
: undefined,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
async function mixAudioTracks(
|
|
364
|
-
tracks: AudioTrack[],
|
|
365
|
-
outputPath: string,
|
|
366
|
-
totalDuration: number,
|
|
367
|
-
signal?: AbortSignal,
|
|
368
|
-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
|
|
369
|
-
): Promise<MixResult> {
|
|
370
|
-
const ffmpegProcessTimeout = config?.ffmpegProcessTimeout ?? DEFAULT_CONFIG.ffmpegProcessTimeout;
|
|
371
|
-
const masterOutputGain = config?.audioGain ?? DEFAULT_CONFIG.audioGain;
|
|
372
|
-
|
|
373
|
-
if (tracks.length === 0) {
|
|
374
|
-
const result = await generateSilence(outputPath, totalDuration, signal, config);
|
|
375
|
-
return {
|
|
376
|
-
success: result.success,
|
|
377
|
-
outputPath,
|
|
378
|
-
durationMs: result.durationMs,
|
|
379
|
-
tracksProcessed: 0,
|
|
380
|
-
error: result.error,
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
const outputDir = dirname(outputPath);
|
|
385
|
-
if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
|
|
386
|
-
|
|
387
|
-
const buildArgs = (ignoreAutomation: boolean): string[] => {
|
|
388
|
-
const inputs: string[] = [];
|
|
389
|
-
const filterParts: string[] = [];
|
|
390
|
-
tracks.forEach((track, i) => {
|
|
391
|
-
inputs.push("-i", track.srcPath);
|
|
392
|
-
const delayMs = Math.round(track.start * 1000);
|
|
393
|
-
const trimDuration = track.end - track.start;
|
|
394
|
-
const volumeFilter = buildVolumeExpression(track, ignoreAutomation);
|
|
395
|
-
filterParts.push(
|
|
396
|
-
`[${i}:a]atrim=0:${trimDuration},${volumeFilter},adelay=${delayMs}|${delayMs},apad=whole_dur=${totalDuration}[a${i}]`,
|
|
397
|
-
);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
const mixInputs = tracks.map((_, i) => `[a${i}]`).join("");
|
|
401
|
-
const mixFilter = `${mixInputs}amix=inputs=${tracks.length}:duration=longest:dropout_transition=0[mixed]`;
|
|
402
|
-
// amix divides output by inputs count (default normalize=true). Multiply master
|
|
403
|
-
// gain by track count so per-track volumes authored in data-volume are preserved.
|
|
404
|
-
const compensatedGain = masterOutputGain * tracks.length;
|
|
405
|
-
const postMixGainFilter = `[mixed]volume=${formatFilterNumber(compensatedGain)}[out]`;
|
|
406
|
-
const fullFilter = [...filterParts, mixFilter, postMixGainFilter].join(";");
|
|
407
|
-
|
|
408
|
-
return [
|
|
409
|
-
...inputs,
|
|
410
|
-
"-filter_complex",
|
|
411
|
-
fullFilter,
|
|
412
|
-
"-map",
|
|
413
|
-
"[out]",
|
|
414
|
-
"-acodec",
|
|
415
|
-
"aac",
|
|
416
|
-
"-b:a",
|
|
417
|
-
"192k",
|
|
418
|
-
"-t",
|
|
419
|
-
String(totalDuration),
|
|
420
|
-
"-y",
|
|
421
|
-
outputPath,
|
|
422
|
-
];
|
|
423
|
-
};
|
|
424
|
-
|
|
425
|
-
let result = await runFfmpeg(buildArgs(false), { signal, timeout: ffmpegProcessTimeout });
|
|
426
|
-
|
|
427
|
-
// Defense in depth: volume automation is folded into an FFmpeg `volume`
|
|
428
|
-
// expression whose evaluator limits are build-dependent (see
|
|
429
|
-
// MAX_VOLUME_SEGMENTS). If that ever fails the mix, retry once without the
|
|
430
|
-
// automation so the track renders at its base volume rather than being
|
|
431
|
-
// dropped from the output entirely — a missing fade beats missing audio.
|
|
432
|
-
let degradedAutomation = false;
|
|
433
|
-
const hasAutomation = tracks.some((track) => (track.volumeKeyframes?.length ?? 0) > 0);
|
|
434
|
-
if (!result.success && !signal?.aborted && hasAutomation) {
|
|
435
|
-
const retry = await runFfmpeg(buildArgs(true), { signal, timeout: ffmpegProcessTimeout });
|
|
436
|
-
if (retry.success) {
|
|
437
|
-
result = retry;
|
|
438
|
-
degradedAutomation = true;
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (signal?.aborted) {
|
|
443
|
-
return {
|
|
444
|
-
success: false,
|
|
445
|
-
outputPath,
|
|
446
|
-
durationMs: result.durationMs,
|
|
447
|
-
tracksProcessed: 0,
|
|
448
|
-
error: "Audio mix cancelled",
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
if (!result.success) {
|
|
452
|
-
return {
|
|
453
|
-
success: false,
|
|
454
|
-
outputPath,
|
|
455
|
-
durationMs: result.durationMs,
|
|
456
|
-
tracksProcessed: 0,
|
|
457
|
-
error:
|
|
458
|
-
result.exitCode !== null ? `FFmpeg exited with code ${result.exitCode}` : result.stderr,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
return {
|
|
462
|
-
success: true,
|
|
463
|
-
outputPath,
|
|
464
|
-
durationMs: result.durationMs,
|
|
465
|
-
tracksProcessed: tracks.length,
|
|
466
|
-
error: degradedAutomation
|
|
467
|
-
? "Volume automation exceeded this ffmpeg build's expression limits; rendered at base volume"
|
|
468
|
-
: undefined,
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
export async function processCompositionAudio(
|
|
473
|
-
elements: AudioElement[],
|
|
474
|
-
baseDir: string,
|
|
475
|
-
workDir: string,
|
|
476
|
-
outputPath: string,
|
|
477
|
-
totalDuration: number,
|
|
478
|
-
signal?: AbortSignal,
|
|
479
|
-
config?: Partial<Pick<EngineConfig, "ffmpegProcessTimeout" | "audioGain">>,
|
|
480
|
-
compiledDir?: string,
|
|
481
|
-
): Promise<MixResult> {
|
|
482
|
-
const startMs = Date.now();
|
|
483
|
-
const tracks: AudioTrack[] = [];
|
|
484
|
-
const errors: string[] = [];
|
|
485
|
-
|
|
486
|
-
if (!existsSync(workDir)) mkdirSync(workDir, { recursive: true });
|
|
487
|
-
|
|
488
|
-
await Promise.all(
|
|
489
|
-
elements.map(async (element) => {
|
|
490
|
-
if (signal?.aborted) {
|
|
491
|
-
errors.push(`Cancelled: ${element.id}`);
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
try {
|
|
495
|
-
let srcPath = element.src;
|
|
496
|
-
if (!isAbsolute(srcPath) && !isHttpUrl(srcPath)) {
|
|
497
|
-
// Same browser-vs-filesystem path semantics as videos — see
|
|
498
|
-
// resolveProjectRelativeSrc in videoFrameExtractor for the full why.
|
|
499
|
-
srcPath = resolveProjectRelativeSrc(element.src, baseDir, compiledDir);
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
if (isHttpUrl(srcPath)) {
|
|
503
|
-
try {
|
|
504
|
-
srcPath = await downloadToTemp(srcPath, workDir);
|
|
505
|
-
} catch (err: unknown) {
|
|
506
|
-
errors.push(
|
|
507
|
-
`Download failed: ${element.id} — ${err instanceof Error ? err.message : String(err)}`,
|
|
508
|
-
);
|
|
509
|
-
return;
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (!existsSync(srcPath)) {
|
|
514
|
-
errors.push(`Source not found: ${element.id} (${element.src})`);
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Fallback: if no duration was specified, probe the actual file
|
|
519
|
-
if (element.end - element.start <= 0) {
|
|
520
|
-
const metadata = await extractAudioMetadata(srcPath);
|
|
521
|
-
const effectiveDuration = metadata.durationSeconds - element.mediaStart;
|
|
522
|
-
element.end =
|
|
523
|
-
element.start + (effectiveDuration > 0 ? effectiveDuration : metadata.durationSeconds);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
let audioSrcPath = srcPath;
|
|
527
|
-
if (element.type === "video") {
|
|
528
|
-
const extractedPath = join(workDir, `${element.id}-extracted.wav`);
|
|
529
|
-
const extractResult = await extractAudioFromVideo(
|
|
530
|
-
srcPath,
|
|
531
|
-
extractedPath,
|
|
532
|
-
{
|
|
533
|
-
startTime: element.mediaStart,
|
|
534
|
-
duration: element.end - element.start,
|
|
535
|
-
},
|
|
536
|
-
signal,
|
|
537
|
-
config,
|
|
538
|
-
);
|
|
539
|
-
if (!extractResult.success) {
|
|
540
|
-
errors.push(`Extract failed: ${element.id}`);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
audioSrcPath = extractedPath;
|
|
544
|
-
} else {
|
|
545
|
-
const trimmedPath = join(workDir, `${element.id}-trimmed.wav`);
|
|
546
|
-
const prepResult = await prepareAudioTrack(
|
|
547
|
-
srcPath,
|
|
548
|
-
trimmedPath,
|
|
549
|
-
element.mediaStart,
|
|
550
|
-
element.end - element.start,
|
|
551
|
-
signal,
|
|
552
|
-
config,
|
|
553
|
-
);
|
|
554
|
-
if (!prepResult.success) {
|
|
555
|
-
errors.push(`Prepare failed: ${element.id}`);
|
|
556
|
-
return;
|
|
557
|
-
}
|
|
558
|
-
audioSrcPath = trimmedPath;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Primary volume-automation path: bake the envelope into the PCM samples
|
|
562
|
-
// (sample-accurate, no keyframe ceiling). If the WAV isn't the expected
|
|
563
|
-
// 16-bit PCM, fall back to the ffmpeg expression path by leaving the
|
|
564
|
-
// keyframes on the track for buildVolumeExpression to handle.
|
|
565
|
-
let bakedEnvelope = false;
|
|
566
|
-
if (element.volumeKeyframes && element.volumeKeyframes.length > 0) {
|
|
567
|
-
bakedEnvelope = applyVolumeEnvelopeToWav(
|
|
568
|
-
audioSrcPath,
|
|
569
|
-
element.volumeKeyframes,
|
|
570
|
-
element.start,
|
|
571
|
-
element.volume ?? 1.0,
|
|
572
|
-
);
|
|
573
|
-
}
|
|
574
|
-
tracks.push({
|
|
575
|
-
id: element.id,
|
|
576
|
-
srcPath: audioSrcPath,
|
|
577
|
-
start: element.start,
|
|
578
|
-
end: element.end,
|
|
579
|
-
mediaStart: element.mediaStart,
|
|
580
|
-
duration: element.end - element.start,
|
|
581
|
-
// Gain is already in the samples when baked, so mix at unity.
|
|
582
|
-
volume: bakedEnvelope ? 1.0 : (element.volume ?? 1.0),
|
|
583
|
-
volumeKeyframes: bakedEnvelope ? undefined : element.volumeKeyframes,
|
|
584
|
-
});
|
|
585
|
-
} catch (err: unknown) {
|
|
586
|
-
errors.push(`Error: ${element.id} — ${err instanceof Error ? err.message : String(err)}`);
|
|
587
|
-
}
|
|
588
|
-
}),
|
|
589
|
-
);
|
|
590
|
-
|
|
591
|
-
const mixResult = await mixAudioTracks(tracks, outputPath, totalDuration, signal, config);
|
|
592
|
-
|
|
593
|
-
try {
|
|
594
|
-
rmSync(workDir, { recursive: true, force: true });
|
|
595
|
-
} catch {
|
|
596
|
-
/* ignore */
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
...mixResult,
|
|
601
|
-
durationMs: Date.now() - startMs,
|
|
602
|
-
error: errors.length > 0 ? `Warnings: ${errors.join(", ")}` : mixResult.error,
|
|
603
|
-
};
|
|
604
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
export interface AudioVolumeKeyframe {
|
|
2
|
-
time: number;
|
|
3
|
-
volume: number;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface AudioElement {
|
|
7
|
-
id: string;
|
|
8
|
-
src: string;
|
|
9
|
-
start: number;
|
|
10
|
-
end: number;
|
|
11
|
-
mediaStart: number;
|
|
12
|
-
layer: number;
|
|
13
|
-
volume?: number;
|
|
14
|
-
volumeKeyframes?: AudioVolumeKeyframe[];
|
|
15
|
-
type: "audio" | "video";
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface AudioTrack {
|
|
19
|
-
id: string;
|
|
20
|
-
srcPath: string;
|
|
21
|
-
start: number;
|
|
22
|
-
end: number;
|
|
23
|
-
mediaStart: number;
|
|
24
|
-
duration: number;
|
|
25
|
-
volume: number;
|
|
26
|
-
volumeKeyframes?: AudioVolumeKeyframe[];
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface MixResult {
|
|
30
|
-
success: boolean;
|
|
31
|
-
outputPath: string;
|
|
32
|
-
durationMs: number;
|
|
33
|
-
tracksProcessed: number;
|
|
34
|
-
error?: string;
|
|
35
|
-
}
|