@hyperframes/engine 0.6.119 → 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.
Files changed (73) hide show
  1. package/package.json +24 -7
  2. package/scripts/generate-lut-reference.py +0 -168
  3. package/scripts/test-fitTextFontSize-browser.ts +0 -135
  4. package/src/cdp-headless-experimental.d.ts +0 -54
  5. package/src/config.test.ts +0 -213
  6. package/src/config.ts +0 -417
  7. package/src/index.ts +0 -273
  8. package/src/services/audioMixer.test.ts +0 -326
  9. package/src/services/audioMixer.ts +0 -604
  10. package/src/services/audioMixer.types.ts +0 -35
  11. package/src/services/audioVolumeEnvelope.test.ts +0 -176
  12. package/src/services/audioVolumeEnvelope.ts +0 -138
  13. package/src/services/browserManager.test.ts +0 -330
  14. package/src/services/browserManager.ts +0 -670
  15. package/src/services/chunkEncoder.test.ts +0 -1415
  16. package/src/services/chunkEncoder.ts +0 -831
  17. package/src/services/chunkEncoder.types.ts +0 -60
  18. package/src/services/extractionCache.test.ts +0 -199
  19. package/src/services/extractionCache.ts +0 -216
  20. package/src/services/fileServer.ts +0 -110
  21. package/src/services/frameCapture-discardWarmup.test.ts +0 -183
  22. package/src/services/frameCapture-namePolyfill.test.ts +0 -78
  23. package/src/services/frameCapture-pollImagesReady.test.ts +0 -153
  24. package/src/services/frameCapture-staticDedupIndex.test.ts +0 -76
  25. package/src/services/frameCapture-warmupTicks.test.ts +0 -174
  26. package/src/services/frameCapture.test.ts +0 -192
  27. package/src/services/frameCapture.ts +0 -1934
  28. package/src/services/hdrCapture.test.ts +0 -159
  29. package/src/services/hdrCapture.ts +0 -315
  30. package/src/services/parallelCoordinator.test.ts +0 -139
  31. package/src/services/parallelCoordinator.ts +0 -437
  32. package/src/services/screenshotService.test.ts +0 -510
  33. package/src/services/screenshotService.ts +0 -615
  34. package/src/services/streamingEncoder.test.ts +0 -832
  35. package/src/services/streamingEncoder.ts +0 -594
  36. package/src/services/systemMemory.test.ts +0 -324
  37. package/src/services/systemMemory.ts +0 -180
  38. package/src/services/videoFrameExtractor.test.ts +0 -1062
  39. package/src/services/videoFrameExtractor.ts +0 -1139
  40. package/src/services/videoFrameInjector.test.ts +0 -300
  41. package/src/services/videoFrameInjector.ts +0 -687
  42. package/src/services/vp9Options.ts +0 -13
  43. package/src/types.ts +0 -191
  44. package/src/utils/alphaBlit.test.ts +0 -1349
  45. package/src/utils/alphaBlit.ts +0 -1015
  46. package/src/utils/assertSwiftShader.test.ts +0 -130
  47. package/src/utils/assertSwiftShader.ts +0 -126
  48. package/src/utils/ffmpegBinaries.test.ts +0 -43
  49. package/src/utils/ffmpegBinaries.ts +0 -63
  50. package/src/utils/ffprobe.test.ts +0 -342
  51. package/src/utils/ffprobe.ts +0 -457
  52. package/src/utils/gpuEncoder.test.ts +0 -140
  53. package/src/utils/gpuEncoder.ts +0 -268
  54. package/src/utils/hdr.test.ts +0 -191
  55. package/src/utils/hdr.ts +0 -137
  56. package/src/utils/hdrCompositing.test.ts +0 -130
  57. package/src/utils/htmlTemplate.test.ts +0 -42
  58. package/src/utils/htmlTemplate.ts +0 -42
  59. package/src/utils/layerCompositor.test.ts +0 -150
  60. package/src/utils/layerCompositor.ts +0 -58
  61. package/src/utils/parityContract.ts +0 -1
  62. package/src/utils/processTracker.test.ts +0 -74
  63. package/src/utils/processTracker.ts +0 -41
  64. package/src/utils/readWebGlVendorInfoFromCanvas.ts +0 -52
  65. package/src/utils/runFfmpeg.test.ts +0 -102
  66. package/src/utils/runFfmpeg.ts +0 -136
  67. package/src/utils/shaderTransitions.test.ts +0 -738
  68. package/src/utils/shaderTransitions.ts +0 -1130
  69. package/src/utils/uint16-alignment-audit.test.ts +0 -125
  70. package/src/utils/urlDownloader.test.ts +0 -65
  71. package/src/utils/urlDownloader.ts +0 -143
  72. package/tsconfig.json +0 -19
  73. 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
- }