aivee 0.0.0-stage → 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 -0
  2. package/README.md +117 -2
  3. package/docs/architecture.md +401 -0
  4. package/docs/aspect-ratio-presets.md +72 -0
  5. package/docs/releasing.md +19 -0
  6. package/docs/usage.md +1193 -0
  7. package/package.json +50 -4
  8. package/requirements-otio.txt +1 -0
  9. package/src/capabilities.mjs +307 -0
  10. package/src/cli.mjs +646 -0
  11. package/src/doctor.mjs +133 -0
  12. package/src/edits/analysis-to-edl.mjs +43 -0
  13. package/src/edits/detect-silence.mjs +51 -0
  14. package/src/edits/find-mistake-candidates.mjs +42 -0
  15. package/src/edits/plan-shot-placements.mjs +105 -0
  16. package/src/edits/plan-silence-cuts.mjs +49 -0
  17. package/src/edits/retime-transcript.mjs +105 -0
  18. package/src/files.mjs +80 -0
  19. package/src/index.mjs +44 -0
  20. package/src/interchange/otio.mjs +204 -0
  21. package/src/interchange/otio_bridge.py +521 -0
  22. package/src/media/analysis-provider.mjs +75 -0
  23. package/src/media/compatibility.mjs +45 -0
  24. package/src/media/ffmpeg-capabilities.mjs +129 -0
  25. package/src/media/frame-rate.mjs +58 -0
  26. package/src/media/frame-time.mjs +22 -0
  27. package/src/media/preview-assets.mjs +350 -0
  28. package/src/media/probe.mjs +149 -0
  29. package/src/media/render-cuts.mjs +90 -0
  30. package/src/media/scene-detection.mjs +107 -0
  31. package/src/media/scene-review.mjs +205 -0
  32. package/src/project/assets.mjs +1194 -0
  33. package/src/project/create.mjs +100 -0
  34. package/src/project/presets.mjs +44 -0
  35. package/src/render/checkpoints.mjs +192 -0
  36. package/src/render/formats.mjs +163 -0
  37. package/src/render/hyperframes.mjs +590 -0
  38. package/src/render/process.mjs +111 -0
  39. package/src/render/progress.mjs +46 -0
  40. package/src/render/timeline-control.mjs +53 -0
  41. package/src/timeline/assemble.mjs +119 -0
  42. package/src/timeline/audio-effects.mjs +75 -0
  43. package/src/timeline/audio-mastering.mjs +236 -0
  44. package/src/timeline/audio-mix.mjs +105 -0
  45. package/src/timeline/audio-track-effects.mjs +139 -0
  46. package/src/timeline/audio-volume.mjs +63 -0
  47. package/src/timeline/background.mjs +8 -0
  48. package/src/timeline/blend-mode.mjs +34 -0
  49. package/src/timeline/caption-layouts.mjs +61 -0
  50. package/src/timeline/captions.mjs +80 -0
  51. package/src/timeline/edit.mjs +965 -0
  52. package/src/timeline/fingerprint.mjs +6 -0
  53. package/src/timeline/from-edl.mjs +31 -0
  54. package/src/timeline/history-store.mjs +281 -0
  55. package/src/timeline/history.mjs +69 -0
  56. package/src/timeline/inspect.mjs +117 -0
  57. package/src/timeline/keyframes.mjs +158 -0
  58. package/src/timeline/layers.mjs +18 -0
  59. package/src/timeline/mask.mjs +53 -0
  60. package/src/timeline/project.mjs +52 -0
  61. package/src/timeline/render.mjs +759 -0
  62. package/src/timeline/sidechain-render.mjs +209 -0
  63. package/src/timeline/source-fit.mjs +15 -0
  64. package/src/timeline/speech-bubble.mjs +26 -0
  65. package/src/timeline/speed-ramp.mjs +188 -0
  66. package/src/timeline/track-state.mjs +22 -0
  67. package/src/timeline/transition.mjs +34 -0
  68. package/src/timeline/validate.mjs +322 -0
  69. package/src/transcript/corrections.mjs +21 -0
  70. package/src/transcript/normalize.mjs +17 -0
  71. package/src/transcript/provider.mjs +42 -0
  72. package/src/transcript/subtitles.mjs +57 -0
  73. package/src/transcript/validate.mjs +39 -0
@@ -0,0 +1,111 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function runProcess(command, args, { cwd, signal, stdio = 'inherit', windowsHide = true, onStdout, onStderr } = {}) {
4
+ if (signal?.aborted) return Promise.reject(abortReason(signal));
5
+ return new Promise((resolve, reject) => {
6
+ const capture = stdio === 'capture';
7
+ const child = spawn(command, args, {
8
+ cwd,
9
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : stdio,
10
+ windowsHide,
11
+ detached: process.platform !== 'win32',
12
+ });
13
+ let settled = false;
14
+ let killPromise;
15
+ let forceTimer;
16
+ const outputLimit = 16 * 1024 * 1024;
17
+ const output = { stdout: [], stderr: [], stdoutBytes: 0, stderrBytes: 0 };
18
+ const captureOutput = (stream, key, byteKey, onChunk) => {
19
+ stream?.on('data', (chunk) => {
20
+ onChunk?.(chunk);
21
+ if (output[byteKey] >= outputLimit) return;
22
+ const retained = chunk.subarray(0, outputLimit - output[byteKey]);
23
+ output[key].push(retained);
24
+ output[byteKey] += retained.length;
25
+ });
26
+ };
27
+ if (capture) {
28
+ captureOutput(child.stdout, 'stdout', 'stdoutBytes', onStdout);
29
+ captureOutput(child.stderr, 'stderr', 'stderrBytes', onStderr);
30
+ }
31
+ const finish = (callback, value) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ if (forceTimer) clearTimeout(forceTimer);
35
+ signal?.removeEventListener('abort', abort);
36
+ callback(value);
37
+ };
38
+ const abort = () => {
39
+ if (killPromise) return;
40
+ killPromise = terminateProcessTree(child, (timer) => { forceTimer = timer; });
41
+ };
42
+ signal?.addEventListener('abort', abort, { once: true });
43
+ child.once('error', (error) => {
44
+ finish(reject, signal?.aborted ? abortReason(signal) : error);
45
+ });
46
+ child.once('close', async (code, signalName) => {
47
+ if (killPromise) await killPromise;
48
+ if (signal?.aborted) {
49
+ finish(reject, abortReason(signal));
50
+ } else if (code === 0) {
51
+ finish(resolve, capture ? { stdout: Buffer.concat(output.stdout).toString('utf8'), stderr: Buffer.concat(output.stderr).toString('utf8') } : true);
52
+ } else {
53
+ const stderr = capture ? Buffer.concat(output.stderr).toString('utf8').trim() : '';
54
+ finish(reject, new Error(`${command} exited with ${signalName ? `signal ${signalName}` : `code ${code}`}.${stderr ? ` ${stderr}` : ''}`));
55
+ }
56
+ });
57
+ if (signal?.aborted) abort();
58
+ });
59
+ }
60
+
61
+ function terminateProcessTree(child, setForceTimer) {
62
+ if (!child.pid) {
63
+ child.kill('SIGTERM');
64
+ return Promise.resolve();
65
+ }
66
+ if (process.platform === 'win32') {
67
+ return new Promise((resolve) => {
68
+ const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
69
+ stdio: 'ignore',
70
+ windowsHide: true,
71
+ });
72
+ let settled = false;
73
+ const fallbackTimer = setTimeout(() => {
74
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
75
+ }, 2000);
76
+ setForceTimer(fallbackTimer);
77
+ const finish = () => {
78
+ if (settled) return;
79
+ settled = true;
80
+ resolve();
81
+ };
82
+ killer.once('error', () => {
83
+ child.kill('SIGTERM');
84
+ finish();
85
+ });
86
+ killer.once('close', (code) => {
87
+ if (code !== 0 && child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
88
+ finish();
89
+ });
90
+ });
91
+ }
92
+
93
+ try {
94
+ process.kill(-child.pid, 'SIGTERM');
95
+ } catch {
96
+ child.kill('SIGTERM');
97
+ }
98
+ const forceTimer = setTimeout(() => {
99
+ try { process.kill(-child.pid, 'SIGKILL'); } catch { /* process tree already exited */ }
100
+ }, 1500);
101
+ forceTimer.unref();
102
+ setForceTimer(forceTimer);
103
+ return Promise.resolve();
104
+ }
105
+
106
+ function abortReason(signal) {
107
+ if (signal.reason instanceof Error) return signal.reason;
108
+ const error = new Error('Render cancelled.');
109
+ error.name = 'AbortError';
110
+ return error;
111
+ }
@@ -0,0 +1,46 @@
1
+ export function emitRenderProgress(onProgress, stage, phase, details = {}) {
2
+ if (!onProgress) return;
3
+ try {
4
+ const percent = phase === 'completed' ? 100 : phase === 'started' ? 0 : undefined;
5
+ const result = onProgress({ stage, phase, ...(percent !== undefined ? { percent } : {}), ...details });
6
+ if (result && typeof result.then === 'function') Promise.resolve(result).catch(warnProgressCallback);
7
+ } catch (error) {
8
+ warnProgressCallback(error);
9
+ }
10
+ }
11
+
12
+ export function createFfmpegProgressParser(onProgress, stage, durationSeconds, details = {}) {
13
+ let pending = '';
14
+ let currentTimeUs;
15
+ const parseLine = (line) => {
16
+ const separator = line.indexOf('=');
17
+ if (separator < 0) return;
18
+ const key = line.slice(0, separator);
19
+ const value = line.slice(separator + 1);
20
+ if (key === 'out_time_us') currentTimeUs = Number(value);
21
+ if (key !== 'progress' || !Number.isFinite(currentTimeUs)) return;
22
+ const completed = Math.max(0, currentTimeUs / 1_000_000);
23
+ const percent = Math.max(0, Math.min(99, completed / durationSeconds * 100));
24
+ emitRenderProgress(onProgress, stage, 'progress', { percent, completed, total: durationSeconds, ...details });
25
+ };
26
+ return {
27
+ push(chunk) {
28
+ pending += chunk.toString('utf8');
29
+ let newline;
30
+ while ((newline = pending.indexOf('\n')) >= 0) {
31
+ parseLine(pending.slice(0, newline).replace(/\r$/, ''));
32
+ pending = pending.slice(newline + 1);
33
+ }
34
+ if (pending.length > 4096) pending = pending.slice(-4096);
35
+ },
36
+ flush() {
37
+ if (pending) parseLine(pending);
38
+ pending = '';
39
+ },
40
+ };
41
+ }
42
+
43
+ function warnProgressCallback(error) {
44
+ const detail = error instanceof Error ? error.message : String(error);
45
+ process.emitWarning(`Progress callback failed: ${detail}`);
46
+ }
@@ -0,0 +1,53 @@
1
+ export function createTimelineRenderControl(signal, timeoutSeconds) {
2
+ if (signal !== undefined && (typeof signal.aborted !== 'boolean' || typeof signal.addEventListener !== 'function')) {
3
+ throw new TypeError('Render signal must be an AbortSignal.');
4
+ }
5
+ if (timeoutSeconds !== undefined
6
+ && (!Number.isSafeInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 86_400)) {
7
+ throw new TypeError('Render timeoutSeconds must be an integer from 1 to 86400.');
8
+ }
9
+
10
+ const controller = new AbortController();
11
+ const deadline = timeoutSeconds === undefined ? undefined : Date.now() + timeoutSeconds * 1000;
12
+ const timeoutError = () => {
13
+ const error = new Error(`Render exceeded the configured timeout of ${timeoutSeconds} seconds.`);
14
+ error.name = 'TimeoutError';
15
+ return error;
16
+ };
17
+ const relayAbort = () => controller.abort(signal.reason ?? new DOMException('Render was aborted.', 'AbortError'));
18
+ if (signal?.aborted) relayAbort();
19
+ else signal?.addEventListener('abort', relayAbort, { once: true });
20
+ const timer = deadline === undefined ? undefined : setTimeout(() => controller.abort(timeoutError()), timeoutSeconds * 1000);
21
+ timer?.unref();
22
+
23
+ return {
24
+ signal: controller.signal,
25
+ assertActive() {
26
+ if (signal?.aborted && !controller.signal.aborted) relayAbort();
27
+ if (deadline !== undefined && Date.now() >= deadline) controller.abort(timeoutError());
28
+ if (controller.signal.aborted) throw controller.signal.reason ?? new DOMException('Render was aborted.', 'AbortError');
29
+ },
30
+ timeoutMs() {
31
+ if (deadline === undefined) return undefined;
32
+ const remaining = deadline - Date.now();
33
+ if (remaining <= 0) {
34
+ controller.abort(timeoutError());
35
+ this.assertActive();
36
+ }
37
+ return Math.max(1, Math.ceil(remaining));
38
+ },
39
+ processError(error) {
40
+ if (signal?.aborted && !controller.signal.aborted) relayAbort();
41
+ if (controller.signal.aborted) return controller.signal.reason;
42
+ if (error?.code === 'ETIMEDOUT' || (deadline !== undefined && Date.now() >= deadline)) {
43
+ controller.abort(timeoutError());
44
+ return controller.signal.reason;
45
+ }
46
+ return error;
47
+ },
48
+ dispose() {
49
+ if (timer) clearTimeout(timer);
50
+ signal?.removeEventListener('abort', relayAbort);
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,119 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { retimeTranscript, validateEdl } from '../edits/retime-transcript.mjs';
4
+ import { createCaptionTrack } from './captions.mjs';
5
+ import { timelineFromEdl } from './from-edl.mjs';
6
+ import { renderTimeline, renderTimelineAsync } from './render.mjs';
7
+ import { validateTimeline } from './validate.mjs';
8
+
9
+ export function assembleEditedTimeline(projectPath, assetId, transcript, edl, options = {}) {
10
+ const root = resolve(projectPath);
11
+ const meta = readProjectMeta(root);
12
+ const manifest = JSON.parse(readFileSync(join(root, 'assets', 'manifest.json'), 'utf8'));
13
+ const asset = manifest.assets.find((entry) => entry.id === assetId);
14
+ if (!asset) throw new TypeError(`Asset is not registered in this project: ${assetId}`);
15
+ if (asset.type !== 'video') throw new TypeError('Transcript-driven editing requires a registered video asset.');
16
+ validateEdl(edl, asset.durationSeconds);
17
+ if (Math.abs(transcript.durationSeconds - asset.durationSeconds) > 1e-6) throw new TypeError('Transcript duration must match the registered source media.');
18
+
19
+ const retimedTranscript = retimeTranscript(transcript, edl);
20
+ const timeline = timelineFromEdl(edl, assetId, {
21
+ id: meta.id,
22
+ width: meta.width,
23
+ height: meta.height,
24
+ fps: meta.fps,
25
+ sourceFit: meta.sourceFit,
26
+ backgroundColor: meta.backgroundColor,
27
+ assetPath: asset.path,
28
+ assetType: asset.type,
29
+ assetHasAudio: asset.hasAudio,
30
+ assetAudioStreams: asset.audioStreams,
31
+ assetDurationSeconds: asset.durationSeconds,
32
+ assetStreamTiming: asset.streamTiming,
33
+ includeAudioTrack: asset.hasAudio,
34
+ });
35
+ if (Math.abs(retimedTranscript.durationSeconds - timeline.project.durationSeconds) > 1e-6) throw new Error('Retimed transcript and edit timeline durations do not match.');
36
+ const captionOptions = { ...options.captionOptions, order: timeline.tracks.length };
37
+ if (captionOptions.fontSize === undefined) captionOptions.fontSize = Math.max(8, Math.round(54 * Math.min(meta.width, meta.height) / 1080));
38
+ const captions = createCaptionTrack(retimedTranscript, captionOptions);
39
+ timeline.tracks.push(captions);
40
+ timeline.lineage = {
41
+ assetId,
42
+ sourceSha256: asset.sha256,
43
+ approvedEdl: 'edits/approved-edl.json',
44
+ retimedTranscript: 'transcripts/retimed-transcript.json',
45
+ };
46
+ validateTimeline(timeline);
47
+ return { timeline, retimedTranscript, asset };
48
+ }
49
+
50
+ export function renderEditedProject(projectPath, assetId, transcript, edl, options = {}) {
51
+ if (options.signal !== undefined || options.timeoutSeconds !== undefined || options.onProgress !== undefined) {
52
+ throw new TypeError('Cancellation, timeouts, and progress callbacks require renderEditedProjectAsync.');
53
+ }
54
+ return renderEditedProjectWith(projectPath, assetId, transcript, edl, options, renderTimeline);
55
+ }
56
+
57
+ export function renderEditedProjectAsync(projectPath, assetId, transcript, edl, options = {}) {
58
+ return renderEditedProjectWith(projectPath, assetId, transcript, edl, options, renderTimelineAsync);
59
+ }
60
+
61
+ function renderEditedProjectWith(projectPath, assetId, transcript, edl, options, renderTimelineFunction) {
62
+ const root = resolve(projectPath);
63
+ const metaPath = join(root, 'meta.json');
64
+ const indexPath = join(root, 'index.html');
65
+ const timelinePath = join(root, 'timeline.json');
66
+ const approvedEdlPath = join(root, 'edits', 'approved-edl.json');
67
+ const retimedTranscriptPath = join(root, 'transcripts', 'retimed-transcript.json');
68
+ const outputPath = resolve(options.outputPath ?? join(root, 'renders', 'final.mp4'));
69
+ for (const path of [timelinePath, approvedEdlPath, retimedTranscriptPath, outputPath]) {
70
+ if (existsSync(path)) throw new Error(`End-to-end edit would overwrite existing project data: ${path}`);
71
+ }
72
+
73
+ const { timeline, retimedTranscript } = assembleEditedTimeline(root, assetId, transcript, edl, options);
74
+ const originalMeta = readFileSync(metaPath, 'utf8');
75
+ const originalHtml = readFileSync(indexPath);
76
+ const nextMeta = { ...JSON.parse(originalMeta), durationSeconds: timeline.project.durationSeconds };
77
+ const renderOptions = Object.fromEntries(
78
+ ['format', 'encoder', 'colorProfile', 'workers', 'lowMemoryMode', 'timeoutSeconds', 'signal', 'onProgress']
79
+ .filter((field) => options[field] !== undefined)
80
+ .map((field) => [field, options[field]]),
81
+ );
82
+ let wroteEdl = false;
83
+ let wroteTranscript = false;
84
+ try {
85
+ writeFileSync(metaPath, `${JSON.stringify(nextMeta, null, 2)}\n`);
86
+ writeFileSync(approvedEdlPath, `${JSON.stringify(edl, null, 2)}\n`, { flag: 'wx' });
87
+ wroteEdl = true;
88
+ writeFileSync(retimedTranscriptPath, `${JSON.stringify(retimedTranscript, null, 2)}\n`, { flag: 'wx' });
89
+ wroteTranscript = true;
90
+ const render = renderTimelineFunction(root, timeline, outputPath, options.quality ?? 'draft', renderOptions);
91
+ if (render && typeof render.then === 'function') {
92
+ return render.then((result) => ({ ...result, assetId, timelinePath, approvedEdlPath, retimedTranscriptPath }))
93
+ .catch((error) => {
94
+ restoreEditedProject({ metaPath, originalMeta, indexPath, originalHtml, approvedEdlPath, retimedTranscriptPath, timelinePath, wroteEdl, wroteTranscript });
95
+ throw error;
96
+ });
97
+ }
98
+ return { ...render, assetId, timelinePath, approvedEdlPath, retimedTranscriptPath };
99
+ } catch (error) {
100
+ restoreEditedProject({ metaPath, originalMeta, indexPath, originalHtml, approvedEdlPath, retimedTranscriptPath, timelinePath, wroteEdl, wroteTranscript });
101
+ throw error;
102
+ }
103
+ }
104
+
105
+ function restoreEditedProject({ metaPath, originalMeta, indexPath, originalHtml, approvedEdlPath, retimedTranscriptPath, timelinePath, wroteEdl, wroteTranscript }) {
106
+ writeFileSync(metaPath, originalMeta);
107
+ writeFileSync(indexPath, originalHtml);
108
+ if (wroteEdl) unlinkSync(approvedEdlPath);
109
+ if (wroteTranscript) unlinkSync(retimedTranscriptPath);
110
+ if (existsSync(timelinePath)) {
111
+ try { unlinkSync(timelinePath); } catch {}
112
+ }
113
+ }
114
+
115
+ function readProjectMeta(root) {
116
+ const path = join(root, 'meta.json');
117
+ if (!existsSync(path)) throw new Error(`Not an Engine project: ${root}`);
118
+ return JSON.parse(readFileSync(path, 'utf8'));
119
+ }
@@ -0,0 +1,75 @@
1
+ const EFFECT_TYPES = new Set(['equalizer', 'highpass', 'lowpass', 'compressor', 'denoise', 'balance']);
2
+
3
+ export function validateAudioClipEffects(effects, clipId) {
4
+ if (effects === undefined) return;
5
+ if (!Array.isArray(effects) || effects.length > 16) {
6
+ throw new TypeError(`Clip ${clipId} audioEffects must be an array with at most 16 effects.`);
7
+ }
8
+ for (const [index, effect] of effects.entries()) {
9
+ if (!effect || typeof effect !== 'object' || Array.isArray(effect) || !EFFECT_TYPES.has(effect.type)) {
10
+ throw new TypeError(`Clip ${clipId} audio effect ${index + 1} has an unsupported type.`);
11
+ }
12
+ const fields = {
13
+ equalizer: ['type', 'frequencyHz', 'gainDB', 'q'],
14
+ highpass: ['type', 'frequencyHz', 'order'],
15
+ lowpass: ['type', 'frequencyHz', 'order'],
16
+ compressor: ['type', 'thresholdDB', 'ratio', 'attackMs', 'releaseMs', 'makeupDB'],
17
+ denoise: ['type', 'reductionDB'],
18
+ balance: ['type', 'value'],
19
+ }[effect.type];
20
+ if (Object.keys(effect).some((field) => !fields.includes(field))) {
21
+ throw new TypeError(`Clip ${clipId} audio effect ${index + 1} has unsupported settings.`);
22
+ }
23
+ if (effect.type === 'equalizer') {
24
+ requireRange(effect.frequencyHz, 20, 20000, `Clip ${clipId} equalizer frequencyHz`);
25
+ requireRange(effect.gainDB, -18, 18, `Clip ${clipId} equalizer gainDB`);
26
+ if (effect.q !== undefined) requireRange(effect.q, 0.1, 10, `Clip ${clipId} equalizer q`);
27
+ } else if (effect.type === 'highpass' || effect.type === 'lowpass') {
28
+ requireRange(effect.frequencyHz, 20, 20000, `Clip ${clipId} ${effect.type} frequencyHz`);
29
+ if (effect.order !== undefined && (!Number.isSafeInteger(effect.order) || effect.order < 1 || effect.order > 2)) {
30
+ throw new TypeError(`Clip ${clipId} ${effect.type} order must be 1 or 2.`);
31
+ }
32
+ } else if (effect.type === 'compressor') {
33
+ requireRange(effect.thresholdDB, -60, 0, `Clip ${clipId} compressor thresholdDB`);
34
+ requireRange(effect.ratio, 1, 20, `Clip ${clipId} compressor ratio`);
35
+ requireRange(effect.attackMs ?? 20, 0.01, 200, `Clip ${clipId} compressor attackMs`);
36
+ requireRange(effect.releaseMs ?? 250, 1, 2000, `Clip ${clipId} compressor releaseMs`);
37
+ requireRange(effect.makeupDB ?? 0, 0, 24, `Clip ${clipId} compressor makeupDB`);
38
+ } else if (effect.type === 'denoise') {
39
+ requireRange(effect.reductionDB, 0.01, 30, `Clip ${clipId} denoise reductionDB`);
40
+ } else {
41
+ requireRange(effect.value, -1, 1, `Clip ${clipId} stereo balance`);
42
+ }
43
+ }
44
+ }
45
+
46
+ export function buildAudioClipEffectFilters(effects = []) {
47
+ return effects.map((effect) => {
48
+ if (effect.type === 'equalizer') return `equalizer=f=${effect.frequencyHz}:t=q:w=${effect.q ?? 1}:g=${effect.gainDB}`;
49
+ if (effect.type === 'highpass' || effect.type === 'lowpass') return `${effect.type}=f=${effect.frequencyHz}:p=${effect.order ?? 2}`;
50
+ if (effect.type === 'compressor') {
51
+ return `acompressor=threshold=${10 ** ((effect.thresholdDB ?? -18) / 20)}:ratio=${effect.ratio}:attack=${effect.attackMs ?? 20}:release=${effect.releaseMs ?? 250}:makeup=${10 ** ((effect.makeupDB ?? 0) / 20)}:detection=rms`;
52
+ }
53
+ if (effect.type === 'denoise') return `afftdn=nr=${effect.reductionDB}`;
54
+ return `stereotools=balance_in=${effect.value}`;
55
+ });
56
+ }
57
+
58
+ export function audioClipEffectRequirements(effects = []) {
59
+ const requirements = new Set();
60
+ for (const effect of effects) {
61
+ if (effect.type === 'equalizer') requirements.add('equalizer');
62
+ if (effect.type === 'highpass') requirements.add('highpass');
63
+ if (effect.type === 'lowpass') requirements.add('lowpass');
64
+ if (effect.type === 'compressor') requirements.add('acompressor');
65
+ if (effect.type === 'denoise') requirements.add('afftdn');
66
+ if (effect.type === 'balance') requirements.add('stereotools');
67
+ }
68
+ return [...requirements];
69
+ }
70
+
71
+ function requireRange(value, minimum, maximum, label) {
72
+ if (!Number.isFinite(value) || value < minimum || value > maximum) {
73
+ throw new TypeError(`${label} must be from ${minimum} to ${maximum}.`);
74
+ }
75
+ }
@@ -0,0 +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
+ }