aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +120 -118
- package/docs/architecture.md +401 -401
- package/docs/aspect-ratio-presets.md +72 -72
- package/docs/releasing.md +19 -19
- package/docs/usage.md +1193 -1193
- package/package.json +52 -52
- package/requirements-otio.txt +1 -1
- package/src/capabilities.mjs +307 -307
- package/src/cli.mjs +646 -646
- package/src/doctor.mjs +133 -133
- package/src/edits/analysis-to-edl.mjs +43 -43
- package/src/edits/detect-silence.mjs +51 -51
- package/src/edits/find-mistake-candidates.mjs +42 -42
- package/src/edits/plan-shot-placements.mjs +105 -105
- package/src/edits/plan-silence-cuts.mjs +49 -49
- package/src/edits/retime-transcript.mjs +105 -105
- package/src/files.mjs +80 -80
- package/src/index.mjs +44 -44
- package/src/interchange/otio.mjs +204 -204
- package/src/interchange/otio_bridge.py +521 -521
- package/src/media/analysis-provider.mjs +75 -75
- package/src/media/compatibility.mjs +45 -45
- package/src/media/ffmpeg-capabilities.mjs +129 -129
- package/src/media/frame-rate.mjs +58 -58
- package/src/media/frame-time.mjs +22 -22
- package/src/media/preview-assets.mjs +350 -350
- package/src/media/probe.mjs +149 -149
- package/src/media/render-cuts.mjs +90 -90
- package/src/media/scene-detection.mjs +107 -107
- package/src/media/scene-review.mjs +205 -205
- package/src/project/assets.mjs +1194 -1194
- package/src/project/create.mjs +100 -100
- package/src/project/presets.mjs +44 -44
- package/src/render/checkpoints.mjs +192 -192
- package/src/render/formats.mjs +163 -163
- package/src/render/hyperframes.mjs +590 -590
- package/src/render/process.mjs +111 -111
- package/src/render/progress.mjs +46 -46
- package/src/render/timeline-control.mjs +53 -53
- package/src/timeline/assemble.mjs +119 -119
- package/src/timeline/audio-effects.mjs +75 -75
- package/src/timeline/audio-mastering.mjs +236 -236
- package/src/timeline/audio-mix.mjs +105 -105
- package/src/timeline/audio-track-effects.mjs +139 -139
- package/src/timeline/audio-volume.mjs +63 -63
- package/src/timeline/background.mjs +8 -8
- package/src/timeline/blend-mode.mjs +34 -34
- package/src/timeline/caption-layouts.mjs +62 -61
- package/src/timeline/captions.mjs +80 -80
- package/src/timeline/edit.mjs +965 -965
- package/src/timeline/fingerprint.mjs +6 -6
- package/src/timeline/from-edl.mjs +31 -31
- package/src/timeline/history-store.mjs +281 -281
- package/src/timeline/history.mjs +69 -69
- package/src/timeline/inspect.mjs +117 -117
- package/src/timeline/keyframes.mjs +158 -158
- package/src/timeline/layers.mjs +18 -18
- package/src/timeline/mask.mjs +53 -53
- package/src/timeline/project.mjs +52 -52
- package/src/timeline/render.mjs +759 -759
- package/src/timeline/sidechain-render.mjs +209 -209
- package/src/timeline/source-fit.mjs +15 -15
- package/src/timeline/speech-bubble.mjs +26 -26
- package/src/timeline/speed-ramp.mjs +188 -188
- package/src/timeline/track-state.mjs +22 -22
- package/src/timeline/transition.mjs +34 -34
- package/src/timeline/validate.mjs +322 -322
- package/src/transcript/corrections.mjs +21 -21
- package/src/transcript/normalize.mjs +17 -17
- package/src/transcript/provider.mjs +42 -42
- package/src/transcript/subtitles.mjs +57 -57
- 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
|
+
}
|