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,119 +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
- }
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
+ }
@@ -1,75 +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
- }
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
+ }