aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1

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 (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -21
  3. package/README.md +120 -118
  4. package/docs/architecture.md +401 -401
  5. package/docs/aspect-ratio-presets.md +72 -72
  6. package/docs/releasing.md +19 -19
  7. package/docs/usage.md +1193 -1193
  8. package/package.json +52 -52
  9. package/requirements-otio.txt +1 -1
  10. package/src/capabilities.mjs +307 -307
  11. package/src/cli.mjs +646 -646
  12. package/src/doctor.mjs +133 -133
  13. package/src/edits/analysis-to-edl.mjs +43 -43
  14. package/src/edits/detect-silence.mjs +51 -51
  15. package/src/edits/find-mistake-candidates.mjs +42 -42
  16. package/src/edits/plan-shot-placements.mjs +105 -105
  17. package/src/edits/plan-silence-cuts.mjs +49 -49
  18. package/src/edits/retime-transcript.mjs +105 -105
  19. package/src/files.mjs +80 -80
  20. package/src/index.mjs +44 -44
  21. package/src/interchange/otio.mjs +204 -204
  22. package/src/interchange/otio_bridge.py +521 -521
  23. package/src/media/analysis-provider.mjs +75 -75
  24. package/src/media/compatibility.mjs +45 -45
  25. package/src/media/ffmpeg-capabilities.mjs +129 -129
  26. package/src/media/frame-rate.mjs +58 -58
  27. package/src/media/frame-time.mjs +22 -22
  28. package/src/media/preview-assets.mjs +350 -350
  29. package/src/media/probe.mjs +149 -149
  30. package/src/media/render-cuts.mjs +90 -90
  31. package/src/media/scene-detection.mjs +107 -107
  32. package/src/media/scene-review.mjs +205 -205
  33. package/src/project/assets.mjs +1194 -1194
  34. package/src/project/create.mjs +100 -100
  35. package/src/project/presets.mjs +44 -44
  36. package/src/render/checkpoints.mjs +192 -192
  37. package/src/render/formats.mjs +163 -163
  38. package/src/render/hyperframes.mjs +590 -590
  39. package/src/render/process.mjs +111 -111
  40. package/src/render/progress.mjs +46 -46
  41. package/src/render/timeline-control.mjs +53 -53
  42. package/src/timeline/assemble.mjs +119 -119
  43. package/src/timeline/audio-effects.mjs +75 -75
  44. package/src/timeline/audio-mastering.mjs +236 -236
  45. package/src/timeline/audio-mix.mjs +105 -105
  46. package/src/timeline/audio-track-effects.mjs +139 -139
  47. package/src/timeline/audio-volume.mjs +63 -63
  48. package/src/timeline/background.mjs +8 -8
  49. package/src/timeline/blend-mode.mjs +34 -34
  50. package/src/timeline/caption-layouts.mjs +62 -61
  51. package/src/timeline/captions.mjs +80 -80
  52. package/src/timeline/edit.mjs +965 -965
  53. package/src/timeline/fingerprint.mjs +6 -6
  54. package/src/timeline/from-edl.mjs +31 -31
  55. package/src/timeline/history-store.mjs +281 -281
  56. package/src/timeline/history.mjs +69 -69
  57. package/src/timeline/inspect.mjs +117 -117
  58. package/src/timeline/keyframes.mjs +158 -158
  59. package/src/timeline/layers.mjs +18 -18
  60. package/src/timeline/mask.mjs +53 -53
  61. package/src/timeline/project.mjs +52 -52
  62. package/src/timeline/render.mjs +759 -759
  63. package/src/timeline/sidechain-render.mjs +209 -209
  64. package/src/timeline/source-fit.mjs +15 -15
  65. package/src/timeline/speech-bubble.mjs +26 -26
  66. package/src/timeline/speed-ramp.mjs +188 -188
  67. package/src/timeline/track-state.mjs +22 -22
  68. package/src/timeline/transition.mjs +34 -34
  69. package/src/timeline/validate.mjs +322 -322
  70. package/src/transcript/corrections.mjs +21 -21
  71. package/src/transcript/normalize.mjs +17 -17
  72. package/src/transcript/provider.mjs +42 -42
  73. package/src/transcript/subtitles.mjs +57 -57
  74. 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
+ }