aivee 0.1.0-alpha.0 → 0.1.0-dev.7025438
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +118 -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 +61 -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,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
|
+
}
|