aivee 0.1.0-alpha.0 → 0.1.0-dev.7025438

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/LICENSE +21 -21
  2. package/README.md +118 -118
  3. package/docs/architecture.md +401 -401
  4. package/docs/aspect-ratio-presets.md +72 -72
  5. package/docs/releasing.md +19 -19
  6. package/docs/usage.md +1193 -1193
  7. package/package.json +52 -52
  8. package/requirements-otio.txt +1 -1
  9. package/src/capabilities.mjs +307 -307
  10. package/src/cli.mjs +646 -646
  11. package/src/doctor.mjs +133 -133
  12. package/src/edits/analysis-to-edl.mjs +43 -43
  13. package/src/edits/detect-silence.mjs +51 -51
  14. package/src/edits/find-mistake-candidates.mjs +42 -42
  15. package/src/edits/plan-shot-placements.mjs +105 -105
  16. package/src/edits/plan-silence-cuts.mjs +49 -49
  17. package/src/edits/retime-transcript.mjs +105 -105
  18. package/src/files.mjs +80 -80
  19. package/src/index.mjs +44 -44
  20. package/src/interchange/otio.mjs +204 -204
  21. package/src/interchange/otio_bridge.py +521 -521
  22. package/src/media/analysis-provider.mjs +75 -75
  23. package/src/media/compatibility.mjs +45 -45
  24. package/src/media/ffmpeg-capabilities.mjs +129 -129
  25. package/src/media/frame-rate.mjs +58 -58
  26. package/src/media/frame-time.mjs +22 -22
  27. package/src/media/preview-assets.mjs +350 -350
  28. package/src/media/probe.mjs +149 -149
  29. package/src/media/render-cuts.mjs +90 -90
  30. package/src/media/scene-detection.mjs +107 -107
  31. package/src/media/scene-review.mjs +205 -205
  32. package/src/project/assets.mjs +1194 -1194
  33. package/src/project/create.mjs +100 -100
  34. package/src/project/presets.mjs +44 -44
  35. package/src/render/checkpoints.mjs +192 -192
  36. package/src/render/formats.mjs +163 -163
  37. package/src/render/hyperframes.mjs +590 -590
  38. package/src/render/process.mjs +111 -111
  39. package/src/render/progress.mjs +46 -46
  40. package/src/render/timeline-control.mjs +53 -53
  41. package/src/timeline/assemble.mjs +119 -119
  42. package/src/timeline/audio-effects.mjs +75 -75
  43. package/src/timeline/audio-mastering.mjs +236 -236
  44. package/src/timeline/audio-mix.mjs +105 -105
  45. package/src/timeline/audio-track-effects.mjs +139 -139
  46. package/src/timeline/audio-volume.mjs +63 -63
  47. package/src/timeline/background.mjs +8 -8
  48. package/src/timeline/blend-mode.mjs +34 -34
  49. package/src/timeline/caption-layouts.mjs +61 -61
  50. package/src/timeline/captions.mjs +80 -80
  51. package/src/timeline/edit.mjs +965 -965
  52. package/src/timeline/fingerprint.mjs +6 -6
  53. package/src/timeline/from-edl.mjs +31 -31
  54. package/src/timeline/history-store.mjs +281 -281
  55. package/src/timeline/history.mjs +69 -69
  56. package/src/timeline/inspect.mjs +117 -117
  57. package/src/timeline/keyframes.mjs +158 -158
  58. package/src/timeline/layers.mjs +18 -18
  59. package/src/timeline/mask.mjs +53 -53
  60. package/src/timeline/project.mjs +52 -52
  61. package/src/timeline/render.mjs +759 -759
  62. package/src/timeline/sidechain-render.mjs +209 -209
  63. package/src/timeline/source-fit.mjs +15 -15
  64. package/src/timeline/speech-bubble.mjs +26 -26
  65. package/src/timeline/speed-ramp.mjs +188 -188
  66. package/src/timeline/track-state.mjs +22 -22
  67. package/src/timeline/transition.mjs +34 -34
  68. package/src/timeline/validate.mjs +322 -322
  69. package/src/transcript/corrections.mjs +21 -21
  70. package/src/transcript/normalize.mjs +17 -17
  71. package/src/transcript/provider.mjs +42 -42
  72. package/src/transcript/subtitles.mjs +57 -57
  73. package/src/transcript/validate.mjs +39 -39
@@ -1,236 +1,236 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { existsSync, renameSync, rmSync, unlinkSync } from 'node:fs';
3
- import { basename, dirname, extname, join } from 'node:path';
4
- import { spawnSync } from 'node:child_process';
5
- import { probeMedia, probeMediaAsync } from '../media/probe.mjs';
6
- import { frameRateNumber } from '../media/frame-rate.mjs';
7
- import { runProcess } from '../render/process.mjs';
8
- import { createFfmpegProgressParser, emitRenderProgress } from '../render/progress.mjs';
9
-
10
- const DEFAULTS = Object.freeze({
11
- integratedLoudnessLUFS: -16,
12
- truePeakDBTP: -1.5,
13
- loudnessRangeLU: 11,
14
- });
15
- const EQ_FIELDS = new Set(['frequencyHz', 'gainDB', 'q']);
16
-
17
- export function validateAudioMastering(timeline) {
18
- const settings = timeline.audioMastering;
19
- if (settings === undefined) return undefined;
20
- if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new TypeError('Timeline audioMastering must be an object.');
21
- const allowed = new Set([...Object.keys(DEFAULTS), 'equalizer', 'limiter', 'channelLayout']);
22
- for (const field of Object.keys(settings)) if (!allowed.has(field)) throw new TypeError(`Timeline audioMastering has an unsupported setting: ${field}.`);
23
- validateEqualizer(settings.equalizer);
24
- validateLimiter(settings.limiter);
25
- if (settings.channelLayout !== undefined && !['mono', 'stereo'].includes(settings.channelLayout)) {
26
- throw new TypeError('Timeline audioMastering.channelLayout must be mono or stereo.');
27
- }
28
- const values = { ...DEFAULTS, ...settings };
29
- if (!Number.isFinite(values.integratedLoudnessLUFS) || values.integratedLoudnessLUFS < -70 || values.integratedLoudnessLUFS > -5) {
30
- throw new TypeError('Timeline audioMastering.integratedLoudnessLUFS must be between -70 and -5 LUFS.');
31
- }
32
- if (!Number.isFinite(values.truePeakDBTP) || values.truePeakDBTP < -9 || values.truePeakDBTP > 0) {
33
- throw new TypeError('Timeline audioMastering.truePeakDBTP must be between -9 and 0 dBTP.');
34
- }
35
- if (!Number.isFinite(values.loudnessRangeLU) || values.loudnessRangeLU < 1 || values.loudnessRangeLU > 50) {
36
- throw new TypeError('Timeline audioMastering.loudnessRangeLU must be between 1 and 50 LU.');
37
- }
38
- if (!timeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0)) {
39
- throw new TypeError('Timeline audioMastering requires at least one audio clip.');
40
- }
41
- return values;
42
- }
43
-
44
- export function masterAudioFile(outputPath, format, settings, fps) {
45
- const input = probeMedia(outputPath);
46
- if (!input.hasAudio) throw new Error('Cannot master loudness because the rendered output has no audio stream.');
47
- const targetDuration = input.streamTiming.videoDurationSeconds ?? input.durationSeconds;
48
- const baseFilter = `loudnorm=I=${settings.integratedLoudnessLUFS}:TP=${settings.truePeakDBTP}:LRA=${settings.loudnessRangeLU}`;
49
- const channelFilter = settings.channelLayout ? [`aresample=ochl=${settings.channelLayout}`] : [];
50
- const equalizerFilters = buildEqualizerFilters(settings.equalizer);
51
- const preLoudnessFilters = [...channelFilter, ...equalizerFilters];
52
- const preLoudnessPrefix = preLoudnessFilters.length ? `${preLoudnessFilters.join(',')},` : '';
53
- const analysis = spawnSync('ffmpeg', [
54
- '-nostdin', '-hide_banner', '-v', 'info', '-i', outputPath,
55
- '-map', '0:a:0', '-af', `${preLoudnessPrefix}${baseFilter}:print_format=json`, '-f', 'null', nullDevice(),
56
- ], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
57
- if (analysis.error || analysis.status !== 0) throw new Error(`Could not analyze rendered audio loudness: ${analysis.error?.message ?? analysis.stderr.trim()}`);
58
- const loudness = parseLoudnessAnalysis(analysis.stderr);
59
- const loudnessFilter = loudness.measured
60
- ? `${baseFilter}:measured_I=${loudness.measured.input_i}:measured_TP=${loudness.measured.input_tp}:measured_LRA=${loudness.measured.input_lra}:measured_thresh=${loudness.measured.input_thresh}:offset=${loudness.measured.target_offset}:linear=true:print_format=summary`
61
- : loudness.silent ? undefined : `${baseFilter}:print_format=summary`;
62
- const limiterFilter = settings.limiter ? buildLimiterFilter(settings.limiter) : undefined;
63
- const filter = buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration);
64
- const extension = extname(outputPath);
65
- const temporaryPath = join(dirname(outputPath), `.${basename(outputPath, extension)}.${randomUUID()}.master${extension}`);
66
- try {
67
- const audioCodec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : ['-c:a', 'aac', '-b:a', '256k'];
68
- const encode = spawnSync('ffmpeg', [
69
- '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', outputPath,
70
- '-map', '0:v:0', '-map', '0:a:0', '-c:v', 'copy', ...audioCodec,
71
- ...(filter ? ['-af', filter] : []), '-ar', '48000',
72
- ...(settings.channelLayout ? ['-ac', settings.channelLayout === 'mono' ? '1' : '2', '-channel_layout', settings.channelLayout] : []),
73
- ...(format === 'mp4' || format === 'mov' ? ['-movflags', '+faststart'] : []),
74
- temporaryPath,
75
- ], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
76
- if (encode.error || encode.status !== 0) throw new Error(`Could not apply rendered audio mastering: ${encode.error?.message ?? encode.stderr.trim()}`);
77
- const mastered = probeMedia(temporaryPath);
78
- validateMasteredOutput(mastered, input, settings, targetDuration, fps);
79
- unlinkSync(outputPath);
80
- renameSync(temporaryPath, outputPath);
81
- return {
82
- ...mastered,
83
- loudnessMode: loudness.measured ? 'two-pass-linear' : loudness.silent ? 'silent-bypass' : 'single-pass-dynamic',
84
- };
85
- } catch (error) {
86
- if (existsSync(temporaryPath)) rmSync(temporaryPath, { force: true });
87
- throw error;
88
- }
89
- }
90
-
91
- export async function masterAudioFileAsync(outputPath, format, settings, fps, signal, onProgress) {
92
- const input = await probeMediaAsync(outputPath, { signal });
93
- if (!input.hasAudio) throw new Error('Cannot master loudness because the rendered output has no audio stream.');
94
- const targetDuration = input.streamTiming.videoDurationSeconds ?? input.durationSeconds;
95
- const baseFilter = `loudnorm=I=${settings.integratedLoudnessLUFS}:TP=${settings.truePeakDBTP}:LRA=${settings.loudnessRangeLU}`;
96
- const channelFilter = settings.channelLayout ? [`aresample=ochl=${settings.channelLayout}`] : [];
97
- const equalizerFilters = buildEqualizerFilters(settings.equalizer);
98
- const preLoudnessFilters = [...channelFilter, ...equalizerFilters];
99
- const preLoudnessPrefix = preLoudnessFilters.length ? `${preLoudnessFilters.join(',')},` : '';
100
- emitRenderProgress(onProgress, 'mastering-analysis', 'started');
101
- const analysisProgress = onProgress ? createFfmpegProgressParser(onProgress, 'mastering-analysis', input.durationSeconds) : undefined;
102
- const analysis = await runProcess('ffmpeg', [
103
- '-nostdin', '-hide_banner', '-v', 'info', '-i', outputPath,
104
- '-map', '0:a:0', '-af', `${preLoudnessPrefix}${baseFilter}:print_format=json`,
105
- ...(onProgress ? ['-progress', 'pipe:1', '-nostats'] : []), '-f', 'null', nullDevice(),
106
- ], { signal, stdio: 'capture', ...(analysisProgress ? { onStdout: analysisProgress.push } : {}) });
107
- analysisProgress?.flush();
108
- emitRenderProgress(onProgress, 'mastering-analysis', 'completed');
109
- const loudness = parseLoudnessAnalysis(analysis.stderr);
110
- const loudnessFilter = loudness.measured
111
- ? `${baseFilter}:measured_I=${loudness.measured.input_i}:measured_TP=${loudness.measured.input_tp}:measured_LRA=${loudness.measured.input_lra}:measured_thresh=${loudness.measured.input_thresh}:offset=${loudness.measured.target_offset}:linear=true:print_format=summary`
112
- : loudness.silent ? undefined : `${baseFilter}:print_format=summary`;
113
- const limiterFilter = settings.limiter ? buildLimiterFilter(settings.limiter) : undefined;
114
- const filter = buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration);
115
- const extension = extname(outputPath);
116
- const temporaryPath = join(dirname(outputPath), `.${basename(outputPath, extension)}.${randomUUID()}.master${extension}`);
117
- try {
118
- const audioCodec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : ['-c:a', 'aac', '-b:a', '256k'];
119
- emitRenderProgress(onProgress, 'mastering-encode', 'started', { codec: audioCodec[1] });
120
- const encodeProgress = onProgress ? createFfmpegProgressParser(onProgress, 'mastering-encode', input.durationSeconds, { codec: audioCodec[1] }) : undefined;
121
- await runProcess('ffmpeg', [
122
- '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', outputPath,
123
- '-map', '0:v:0', '-map', '0:a:0', '-c:v', 'copy', ...audioCodec,
124
- ...(filter ? ['-af', filter] : []), '-ar', '48000',
125
- ...(settings.channelLayout ? ['-ac', settings.channelLayout === 'mono' ? '1' : '2', '-channel_layout', settings.channelLayout] : []),
126
- ...(onProgress ? ['-progress', 'pipe:1', '-nostats'] : []),
127
- ...(format === 'mp4' || format === 'mov' ? ['-movflags', '+faststart'] : []),
128
- temporaryPath,
129
- ], { signal, ...(encodeProgress ? {
130
- stdio: 'capture',
131
- onStdout: encodeProgress.push,
132
- onStderr: (chunk) => process.stderr.write(chunk),
133
- } : {}) });
134
- encodeProgress?.flush();
135
- emitRenderProgress(onProgress, 'mastering-encode', 'completed', { codec: audioCodec[1] });
136
- emitRenderProgress(onProgress, 'mastering-validation', 'started');
137
- const mastered = await probeMediaAsync(temporaryPath, { signal });
138
- validateMasteredOutput(mastered, input, settings, targetDuration, fps);
139
- emitRenderProgress(onProgress, 'mastering-validation', 'completed');
140
- emitRenderProgress(onProgress, 'mastering-publish', 'started');
141
- unlinkSync(outputPath);
142
- renameSync(temporaryPath, outputPath);
143
- emitRenderProgress(onProgress, 'mastering-publish', 'completed');
144
- return {
145
- ...mastered,
146
- loudnessMode: loudness.measured ? 'two-pass-linear' : loudness.silent ? 'silent-bypass' : 'single-pass-dynamic',
147
- };
148
- } catch (error) {
149
- if (existsSync(temporaryPath)) rmSync(temporaryPath, { force: true });
150
- throw error;
151
- }
152
- }
153
-
154
- function validateEqualizer(equalizer) {
155
- if (equalizer === undefined) return;
156
- if (!Array.isArray(equalizer) || equalizer.length > 10) throw new TypeError('Timeline audioMastering.equalizer must be an array with at most 10 bands.');
157
- for (const [index, band] of equalizer.entries()) {
158
- if (!band || typeof band !== 'object' || Array.isArray(band) || Object.keys(band).some((field) => !EQ_FIELDS.has(field))) {
159
- throw new TypeError(`Timeline audioMastering.equalizer band ${index} has an invalid shape.`);
160
- }
161
- if (!Number.isFinite(band.frequencyHz) || band.frequencyHz < 20 || band.frequencyHz > 20000) {
162
- throw new TypeError(`Timeline audioMastering.equalizer band ${index} frequencyHz must be from 20 to 20000.`);
163
- }
164
- if (!Number.isFinite(band.gainDB) || band.gainDB < -18 || band.gainDB > 18) {
165
- throw new TypeError(`Timeline audioMastering.equalizer band ${index} gainDB must be from -18 to 18.`);
166
- }
167
- if (band.q !== undefined && (!Number.isFinite(band.q) || band.q < 0.1 || band.q > 10)) {
168
- throw new TypeError(`Timeline audioMastering.equalizer band ${index} q must be from 0.1 to 10.`);
169
- }
170
- }
171
- }
172
-
173
- function validateLimiter(limiter) {
174
- if (limiter === undefined) return;
175
- const fields = ['limitDBFS', 'attackMs', 'releaseMs'];
176
- if (!limiter || typeof limiter !== 'object' || Array.isArray(limiter) || Object.keys(limiter).some((field) => !fields.includes(field))) {
177
- throw new TypeError('Timeline audioMastering.limiter has an invalid shape.');
178
- }
179
- if (!Number.isFinite(limiter.limitDBFS) || limiter.limitDBFS < -20 || limiter.limitDBFS > 0) {
180
- throw new TypeError('Timeline audioMastering.limiter.limitDBFS must be from -20 to 0.');
181
- }
182
- if (!Number.isFinite(limiter.attackMs) || limiter.attackMs < 0.1 || limiter.attackMs > 80) {
183
- throw new TypeError('Timeline audioMastering.limiter.attackMs must be from 0.1 to 80.');
184
- }
185
- if (!Number.isFinite(limiter.releaseMs) || limiter.releaseMs < 10 || limiter.releaseMs > 1000) {
186
- throw new TypeError('Timeline audioMastering.limiter.releaseMs must be from 10 to 1000.');
187
- }
188
- }
189
-
190
- function buildEqualizerFilters(equalizer = []) {
191
- return equalizer.map(({ frequencyHz, gainDB, q = 1 }) => `equalizer=f=${frequencyHz}:t=q:w=${q}:g=${gainDB}`);
192
- }
193
-
194
- function buildLimiterFilter({ limitDBFS, attackMs, releaseMs }) {
195
- const linearLimit = 10 ** (limitDBFS / 20);
196
- return `alimiter=limit=${linearLimit}:attack=${attackMs}:release=${releaseMs}:level=0`;
197
- }
198
-
199
- function buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration) {
200
- const duration = Number(targetDuration.toFixed(9));
201
- return [...preLoudnessFilters, loudnessFilter, limiterFilter, `apad=whole_dur=${duration}`, `atrim=duration=${duration}`]
202
- .filter(Boolean)
203
- .join(',');
204
- }
205
-
206
- function validateMasteredOutput(mastered, input, settings, targetDuration, fps) {
207
- const expectedChannels = settings.channelLayout === 'mono' ? 1 : settings.channelLayout === 'stereo' ? 2 : undefined;
208
- const frameTolerance = 1 / frameRateNumber(fps);
209
- const videoDuration = mastered.streamTiming.videoDurationSeconds ?? mastered.durationSeconds;
210
- const audioDuration = mastered.streamTiming.audioDurationSeconds ?? mastered.durationSeconds;
211
- if (!mastered.hasAudio || mastered.width !== input.width || mastered.height !== input.height
212
- || (expectedChannels !== undefined && mastered.audioChannels !== expectedChannels)
213
- || Math.abs(videoDuration - targetDuration) > frameTolerance
214
- || Math.abs(audioDuration - videoDuration) > frameTolerance) {
215
- throw new Error('Mastered output failed audio/video stream or channel-layout validation.');
216
- }
217
- }
218
-
219
- function parseLoudnessAnalysis(stderr) {
220
- const blocks = [...stderr.matchAll(/\{\s*"input_i"[\s\S]*?\}/g)];
221
- if (blocks.length === 0) return { silent: false };
222
- let values;
223
- try {
224
- values = JSON.parse(blocks.at(-1)[0]);
225
- } catch {
226
- return { silent: false };
227
- }
228
- if (values.input_i === '-inf' && values.input_tp === '-inf') return { silent: true };
229
- const required = ['input_i', 'input_tp', 'input_lra', 'input_thresh', 'target_offset'];
230
- if (!required.every((field) => Number.isFinite(Number(values[field])))) return { silent: false };
231
- return { measured: Object.fromEntries(required.map((field) => [field, Number(values[field])])) };
232
- }
233
-
234
- function nullDevice() {
235
- return process.platform === 'win32' ? 'NUL' : '/dev/null';
236
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, renameSync, rmSync, unlinkSync } from 'node:fs';
3
+ import { basename, dirname, extname, join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { probeMedia, probeMediaAsync } from '../media/probe.mjs';
6
+ import { frameRateNumber } from '../media/frame-rate.mjs';
7
+ import { runProcess } from '../render/process.mjs';
8
+ import { createFfmpegProgressParser, emitRenderProgress } from '../render/progress.mjs';
9
+
10
+ const DEFAULTS = Object.freeze({
11
+ integratedLoudnessLUFS: -16,
12
+ truePeakDBTP: -1.5,
13
+ loudnessRangeLU: 11,
14
+ });
15
+ const EQ_FIELDS = new Set(['frequencyHz', 'gainDB', 'q']);
16
+
17
+ export function validateAudioMastering(timeline) {
18
+ const settings = timeline.audioMastering;
19
+ if (settings === undefined) return undefined;
20
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) throw new TypeError('Timeline audioMastering must be an object.');
21
+ const allowed = new Set([...Object.keys(DEFAULTS), 'equalizer', 'limiter', 'channelLayout']);
22
+ for (const field of Object.keys(settings)) if (!allowed.has(field)) throw new TypeError(`Timeline audioMastering has an unsupported setting: ${field}.`);
23
+ validateEqualizer(settings.equalizer);
24
+ validateLimiter(settings.limiter);
25
+ if (settings.channelLayout !== undefined && !['mono', 'stereo'].includes(settings.channelLayout)) {
26
+ throw new TypeError('Timeline audioMastering.channelLayout must be mono or stereo.');
27
+ }
28
+ const values = { ...DEFAULTS, ...settings };
29
+ if (!Number.isFinite(values.integratedLoudnessLUFS) || values.integratedLoudnessLUFS < -70 || values.integratedLoudnessLUFS > -5) {
30
+ throw new TypeError('Timeline audioMastering.integratedLoudnessLUFS must be between -70 and -5 LUFS.');
31
+ }
32
+ if (!Number.isFinite(values.truePeakDBTP) || values.truePeakDBTP < -9 || values.truePeakDBTP > 0) {
33
+ throw new TypeError('Timeline audioMastering.truePeakDBTP must be between -9 and 0 dBTP.');
34
+ }
35
+ if (!Number.isFinite(values.loudnessRangeLU) || values.loudnessRangeLU < 1 || values.loudnessRangeLU > 50) {
36
+ throw new TypeError('Timeline audioMastering.loudnessRangeLU must be between 1 and 50 LU.');
37
+ }
38
+ if (!timeline.tracks.some((track) => track.kind === 'audio' && track.clips.length > 0)) {
39
+ throw new TypeError('Timeline audioMastering requires at least one audio clip.');
40
+ }
41
+ return values;
42
+ }
43
+
44
+ export function masterAudioFile(outputPath, format, settings, fps) {
45
+ const input = probeMedia(outputPath);
46
+ if (!input.hasAudio) throw new Error('Cannot master loudness because the rendered output has no audio stream.');
47
+ const targetDuration = input.streamTiming.videoDurationSeconds ?? input.durationSeconds;
48
+ const baseFilter = `loudnorm=I=${settings.integratedLoudnessLUFS}:TP=${settings.truePeakDBTP}:LRA=${settings.loudnessRangeLU}`;
49
+ const channelFilter = settings.channelLayout ? [`aresample=ochl=${settings.channelLayout}`] : [];
50
+ const equalizerFilters = buildEqualizerFilters(settings.equalizer);
51
+ const preLoudnessFilters = [...channelFilter, ...equalizerFilters];
52
+ const preLoudnessPrefix = preLoudnessFilters.length ? `${preLoudnessFilters.join(',')},` : '';
53
+ const analysis = spawnSync('ffmpeg', [
54
+ '-nostdin', '-hide_banner', '-v', 'info', '-i', outputPath,
55
+ '-map', '0:a:0', '-af', `${preLoudnessPrefix}${baseFilter}:print_format=json`, '-f', 'null', nullDevice(),
56
+ ], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
57
+ if (analysis.error || analysis.status !== 0) throw new Error(`Could not analyze rendered audio loudness: ${analysis.error?.message ?? analysis.stderr.trim()}`);
58
+ const loudness = parseLoudnessAnalysis(analysis.stderr);
59
+ const loudnessFilter = loudness.measured
60
+ ? `${baseFilter}:measured_I=${loudness.measured.input_i}:measured_TP=${loudness.measured.input_tp}:measured_LRA=${loudness.measured.input_lra}:measured_thresh=${loudness.measured.input_thresh}:offset=${loudness.measured.target_offset}:linear=true:print_format=summary`
61
+ : loudness.silent ? undefined : `${baseFilter}:print_format=summary`;
62
+ const limiterFilter = settings.limiter ? buildLimiterFilter(settings.limiter) : undefined;
63
+ const filter = buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration);
64
+ const extension = extname(outputPath);
65
+ const temporaryPath = join(dirname(outputPath), `.${basename(outputPath, extension)}.${randomUUID()}.master${extension}`);
66
+ try {
67
+ const audioCodec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : ['-c:a', 'aac', '-b:a', '256k'];
68
+ const encode = spawnSync('ffmpeg', [
69
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', outputPath,
70
+ '-map', '0:v:0', '-map', '0:a:0', '-c:v', 'copy', ...audioCodec,
71
+ ...(filter ? ['-af', filter] : []), '-ar', '48000',
72
+ ...(settings.channelLayout ? ['-ac', settings.channelLayout === 'mono' ? '1' : '2', '-channel_layout', settings.channelLayout] : []),
73
+ ...(format === 'mp4' || format === 'mov' ? ['-movflags', '+faststart'] : []),
74
+ temporaryPath,
75
+ ], { encoding: 'utf8', windowsHide: true, maxBuffer: 4 * 1024 * 1024 });
76
+ if (encode.error || encode.status !== 0) throw new Error(`Could not apply rendered audio mastering: ${encode.error?.message ?? encode.stderr.trim()}`);
77
+ const mastered = probeMedia(temporaryPath);
78
+ validateMasteredOutput(mastered, input, settings, targetDuration, fps);
79
+ unlinkSync(outputPath);
80
+ renameSync(temporaryPath, outputPath);
81
+ return {
82
+ ...mastered,
83
+ loudnessMode: loudness.measured ? 'two-pass-linear' : loudness.silent ? 'silent-bypass' : 'single-pass-dynamic',
84
+ };
85
+ } catch (error) {
86
+ if (existsSync(temporaryPath)) rmSync(temporaryPath, { force: true });
87
+ throw error;
88
+ }
89
+ }
90
+
91
+ export async function masterAudioFileAsync(outputPath, format, settings, fps, signal, onProgress) {
92
+ const input = await probeMediaAsync(outputPath, { signal });
93
+ if (!input.hasAudio) throw new Error('Cannot master loudness because the rendered output has no audio stream.');
94
+ const targetDuration = input.streamTiming.videoDurationSeconds ?? input.durationSeconds;
95
+ const baseFilter = `loudnorm=I=${settings.integratedLoudnessLUFS}:TP=${settings.truePeakDBTP}:LRA=${settings.loudnessRangeLU}`;
96
+ const channelFilter = settings.channelLayout ? [`aresample=ochl=${settings.channelLayout}`] : [];
97
+ const equalizerFilters = buildEqualizerFilters(settings.equalizer);
98
+ const preLoudnessFilters = [...channelFilter, ...equalizerFilters];
99
+ const preLoudnessPrefix = preLoudnessFilters.length ? `${preLoudnessFilters.join(',')},` : '';
100
+ emitRenderProgress(onProgress, 'mastering-analysis', 'started');
101
+ const analysisProgress = onProgress ? createFfmpegProgressParser(onProgress, 'mastering-analysis', input.durationSeconds) : undefined;
102
+ const analysis = await runProcess('ffmpeg', [
103
+ '-nostdin', '-hide_banner', '-v', 'info', '-i', outputPath,
104
+ '-map', '0:a:0', '-af', `${preLoudnessPrefix}${baseFilter}:print_format=json`,
105
+ ...(onProgress ? ['-progress', 'pipe:1', '-nostats'] : []), '-f', 'null', nullDevice(),
106
+ ], { signal, stdio: 'capture', ...(analysisProgress ? { onStdout: analysisProgress.push } : {}) });
107
+ analysisProgress?.flush();
108
+ emitRenderProgress(onProgress, 'mastering-analysis', 'completed');
109
+ const loudness = parseLoudnessAnalysis(analysis.stderr);
110
+ const loudnessFilter = loudness.measured
111
+ ? `${baseFilter}:measured_I=${loudness.measured.input_i}:measured_TP=${loudness.measured.input_tp}:measured_LRA=${loudness.measured.input_lra}:measured_thresh=${loudness.measured.input_thresh}:offset=${loudness.measured.target_offset}:linear=true:print_format=summary`
112
+ : loudness.silent ? undefined : `${baseFilter}:print_format=summary`;
113
+ const limiterFilter = settings.limiter ? buildLimiterFilter(settings.limiter) : undefined;
114
+ const filter = buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration);
115
+ const extension = extname(outputPath);
116
+ const temporaryPath = join(dirname(outputPath), `.${basename(outputPath, extension)}.${randomUUID()}.master${extension}`);
117
+ try {
118
+ const audioCodec = format === 'webm' ? ['-c:a', 'libopus', '-b:a', '192k'] : ['-c:a', 'aac', '-b:a', '256k'];
119
+ emitRenderProgress(onProgress, 'mastering-encode', 'started', { codec: audioCodec[1] });
120
+ const encodeProgress = onProgress ? createFfmpegProgressParser(onProgress, 'mastering-encode', input.durationSeconds, { codec: audioCodec[1] }) : undefined;
121
+ await runProcess('ffmpeg', [
122
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-i', outputPath,
123
+ '-map', '0:v:0', '-map', '0:a:0', '-c:v', 'copy', ...audioCodec,
124
+ ...(filter ? ['-af', filter] : []), '-ar', '48000',
125
+ ...(settings.channelLayout ? ['-ac', settings.channelLayout === 'mono' ? '1' : '2', '-channel_layout', settings.channelLayout] : []),
126
+ ...(onProgress ? ['-progress', 'pipe:1', '-nostats'] : []),
127
+ ...(format === 'mp4' || format === 'mov' ? ['-movflags', '+faststart'] : []),
128
+ temporaryPath,
129
+ ], { signal, ...(encodeProgress ? {
130
+ stdio: 'capture',
131
+ onStdout: encodeProgress.push,
132
+ onStderr: (chunk) => process.stderr.write(chunk),
133
+ } : {}) });
134
+ encodeProgress?.flush();
135
+ emitRenderProgress(onProgress, 'mastering-encode', 'completed', { codec: audioCodec[1] });
136
+ emitRenderProgress(onProgress, 'mastering-validation', 'started');
137
+ const mastered = await probeMediaAsync(temporaryPath, { signal });
138
+ validateMasteredOutput(mastered, input, settings, targetDuration, fps);
139
+ emitRenderProgress(onProgress, 'mastering-validation', 'completed');
140
+ emitRenderProgress(onProgress, 'mastering-publish', 'started');
141
+ unlinkSync(outputPath);
142
+ renameSync(temporaryPath, outputPath);
143
+ emitRenderProgress(onProgress, 'mastering-publish', 'completed');
144
+ return {
145
+ ...mastered,
146
+ loudnessMode: loudness.measured ? 'two-pass-linear' : loudness.silent ? 'silent-bypass' : 'single-pass-dynamic',
147
+ };
148
+ } catch (error) {
149
+ if (existsSync(temporaryPath)) rmSync(temporaryPath, { force: true });
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ function validateEqualizer(equalizer) {
155
+ if (equalizer === undefined) return;
156
+ if (!Array.isArray(equalizer) || equalizer.length > 10) throw new TypeError('Timeline audioMastering.equalizer must be an array with at most 10 bands.');
157
+ for (const [index, band] of equalizer.entries()) {
158
+ if (!band || typeof band !== 'object' || Array.isArray(band) || Object.keys(band).some((field) => !EQ_FIELDS.has(field))) {
159
+ throw new TypeError(`Timeline audioMastering.equalizer band ${index} has an invalid shape.`);
160
+ }
161
+ if (!Number.isFinite(band.frequencyHz) || band.frequencyHz < 20 || band.frequencyHz > 20000) {
162
+ throw new TypeError(`Timeline audioMastering.equalizer band ${index} frequencyHz must be from 20 to 20000.`);
163
+ }
164
+ if (!Number.isFinite(band.gainDB) || band.gainDB < -18 || band.gainDB > 18) {
165
+ throw new TypeError(`Timeline audioMastering.equalizer band ${index} gainDB must be from -18 to 18.`);
166
+ }
167
+ if (band.q !== undefined && (!Number.isFinite(band.q) || band.q < 0.1 || band.q > 10)) {
168
+ throw new TypeError(`Timeline audioMastering.equalizer band ${index} q must be from 0.1 to 10.`);
169
+ }
170
+ }
171
+ }
172
+
173
+ function validateLimiter(limiter) {
174
+ if (limiter === undefined) return;
175
+ const fields = ['limitDBFS', 'attackMs', 'releaseMs'];
176
+ if (!limiter || typeof limiter !== 'object' || Array.isArray(limiter) || Object.keys(limiter).some((field) => !fields.includes(field))) {
177
+ throw new TypeError('Timeline audioMastering.limiter has an invalid shape.');
178
+ }
179
+ if (!Number.isFinite(limiter.limitDBFS) || limiter.limitDBFS < -20 || limiter.limitDBFS > 0) {
180
+ throw new TypeError('Timeline audioMastering.limiter.limitDBFS must be from -20 to 0.');
181
+ }
182
+ if (!Number.isFinite(limiter.attackMs) || limiter.attackMs < 0.1 || limiter.attackMs > 80) {
183
+ throw new TypeError('Timeline audioMastering.limiter.attackMs must be from 0.1 to 80.');
184
+ }
185
+ if (!Number.isFinite(limiter.releaseMs) || limiter.releaseMs < 10 || limiter.releaseMs > 1000) {
186
+ throw new TypeError('Timeline audioMastering.limiter.releaseMs must be from 10 to 1000.');
187
+ }
188
+ }
189
+
190
+ function buildEqualizerFilters(equalizer = []) {
191
+ return equalizer.map(({ frequencyHz, gainDB, q = 1 }) => `equalizer=f=${frequencyHz}:t=q:w=${q}:g=${gainDB}`);
192
+ }
193
+
194
+ function buildLimiterFilter({ limitDBFS, attackMs, releaseMs }) {
195
+ const linearLimit = 10 ** (limitDBFS / 20);
196
+ return `alimiter=limit=${linearLimit}:attack=${attackMs}:release=${releaseMs}:level=0`;
197
+ }
198
+
199
+ function buildMasteringFilter(preLoudnessFilters, loudnessFilter, limiterFilter, targetDuration) {
200
+ const duration = Number(targetDuration.toFixed(9));
201
+ return [...preLoudnessFilters, loudnessFilter, limiterFilter, `apad=whole_dur=${duration}`, `atrim=duration=${duration}`]
202
+ .filter(Boolean)
203
+ .join(',');
204
+ }
205
+
206
+ function validateMasteredOutput(mastered, input, settings, targetDuration, fps) {
207
+ const expectedChannels = settings.channelLayout === 'mono' ? 1 : settings.channelLayout === 'stereo' ? 2 : undefined;
208
+ const frameTolerance = 1 / frameRateNumber(fps);
209
+ const videoDuration = mastered.streamTiming.videoDurationSeconds ?? mastered.durationSeconds;
210
+ const audioDuration = mastered.streamTiming.audioDurationSeconds ?? mastered.durationSeconds;
211
+ if (!mastered.hasAudio || mastered.width !== input.width || mastered.height !== input.height
212
+ || (expectedChannels !== undefined && mastered.audioChannels !== expectedChannels)
213
+ || Math.abs(videoDuration - targetDuration) > frameTolerance
214
+ || Math.abs(audioDuration - videoDuration) > frameTolerance) {
215
+ throw new Error('Mastered output failed audio/video stream or channel-layout validation.');
216
+ }
217
+ }
218
+
219
+ function parseLoudnessAnalysis(stderr) {
220
+ const blocks = [...stderr.matchAll(/\{\s*"input_i"[\s\S]*?\}/g)];
221
+ if (blocks.length === 0) return { silent: false };
222
+ let values;
223
+ try {
224
+ values = JSON.parse(blocks.at(-1)[0]);
225
+ } catch {
226
+ return { silent: false };
227
+ }
228
+ if (values.input_i === '-inf' && values.input_tp === '-inf') return { silent: true };
229
+ const required = ['input_i', 'input_tp', 'input_lra', 'input_thresh', 'target_offset'];
230
+ if (!required.every((field) => Number.isFinite(Number(values[field])))) return { silent: false };
231
+ return { measured: Object.fromEntries(required.map((field) => [field, Number(values[field])])) };
232
+ }
233
+
234
+ function nullDevice() {
235
+ return process.platform === 'win32' ? 'NUL' : '/dev/null';
236
+ }