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.
- package/CHANGELOG.md +34 -0
- 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,209 +1,209 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { rmSync } from 'node:fs';
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { validateTimeline } from './validate.mjs';
|
|
6
|
-
import { resolveAudioSidechainSettings } from './audio-mix.mjs';
|
|
7
|
-
import { runProcess } from '../render/process.mjs';
|
|
8
|
-
|
|
9
|
-
export function applySidechainDuckingProxies(root, timeline, temporaryAssets, renderControl) {
|
|
10
|
-
const rules = timeline.audioMix?.sidechain ?? [];
|
|
11
|
-
if (rules.length === 0) return timeline;
|
|
12
|
-
|
|
13
|
-
const next = structuredClone(timeline);
|
|
14
|
-
const startIndex = temporaryAssets.length;
|
|
15
|
-
try {
|
|
16
|
-
const stems = new Map();
|
|
17
|
-
for (const rule of rules) {
|
|
18
|
-
if (!stems.has(rule.sourceTrackId)) {
|
|
19
|
-
const sourceTrack = next.tracks.find((track) => track.id === rule.sourceTrackId);
|
|
20
|
-
stems.set(rule.sourceTrackId, buildTrackStem(root, next, sourceTrack, temporaryAssets, renderControl));
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
for (const rule of rules) {
|
|
25
|
-
const settings = resolveAudioSidechainSettings(rule);
|
|
26
|
-
const detector = stems.get(rule.sourceTrackId);
|
|
27
|
-
const targetTrack = next.tracks.find((track) => track.id === rule.targetTrackId);
|
|
28
|
-
for (const clip of targetTrack.clips) {
|
|
29
|
-
const sourceAsset = next.assets.find((asset) => asset.id === clip.assetId);
|
|
30
|
-
const sourcePath = join(root, ...sourceAsset.path.split('/'));
|
|
31
|
-
const id = `sidechain-${targetTrack.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
32
|
-
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
33
|
-
const absolutePath = join(root, ...relativePath.split('/'));
|
|
34
|
-
const detectorStart = clip.timelineStart;
|
|
35
|
-
const detectorEnd = detectorStart + clip.durationSeconds;
|
|
36
|
-
const threshold = 10 ** (settings.thresholdDb / 20);
|
|
37
|
-
const filter = [
|
|
38
|
-
`[0:a:0]atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[target]`,
|
|
39
|
-
`[1:a:0]atrim=start=${detectorStart}:end=${detectorEnd},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[detector]`,
|
|
40
|
-
`[target][detector]sidechaincompress=threshold=${threshold}:ratio=${settings.ratio}:attack=${settings.attackSeconds * 1000}:release=${settings.releaseSeconds * 1000}:makeup=1:detection=rms:link=average,apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}[ducked]`,
|
|
41
|
-
].join(';');
|
|
42
|
-
temporaryAssets.push(absolutePath);
|
|
43
|
-
runFfmpeg(root, [
|
|
44
|
-
'-i', sourcePath, '-i', detector.path,
|
|
45
|
-
'-filter_complex', filter, '-map', '[ducked]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le',
|
|
46
|
-
absolutePath,
|
|
47
|
-
], `Could not apply sidechain compression to clip ${clip.id}`, renderControl);
|
|
48
|
-
next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: clip.durationSeconds });
|
|
49
|
-
clip.assetId = id;
|
|
50
|
-
clip.sourceIn = 0;
|
|
51
|
-
clip.sourceOut = clip.durationSeconds;
|
|
52
|
-
delete clip.playbackRate;
|
|
53
|
-
delete clip.speedRamp;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
validateTimeline(next);
|
|
57
|
-
return next;
|
|
58
|
-
} catch (error) {
|
|
59
|
-
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function applySidechainDuckingProxiesAsync(root, timeline, temporaryAssets, renderControl) {
|
|
65
|
-
const rules = timeline.audioMix?.sidechain ?? [];
|
|
66
|
-
if (rules.length === 0) return timeline;
|
|
67
|
-
|
|
68
|
-
const next = structuredClone(timeline);
|
|
69
|
-
const startIndex = temporaryAssets.length;
|
|
70
|
-
try {
|
|
71
|
-
const stems = new Map();
|
|
72
|
-
for (const rule of rules) {
|
|
73
|
-
if (!stems.has(rule.sourceTrackId)) {
|
|
74
|
-
const sourceTrack = next.tracks.find((track) => track.id === rule.sourceTrackId);
|
|
75
|
-
stems.set(rule.sourceTrackId, await buildTrackStemAsync(root, next, sourceTrack, temporaryAssets, renderControl));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
for (const rule of rules) {
|
|
80
|
-
renderControl?.assertActive();
|
|
81
|
-
const settings = resolveAudioSidechainSettings(rule);
|
|
82
|
-
const detector = stems.get(rule.sourceTrackId);
|
|
83
|
-
const targetTrack = next.tracks.find((track) => track.id === rule.targetTrackId);
|
|
84
|
-
for (const clip of targetTrack.clips) {
|
|
85
|
-
const sourceAsset = next.assets.find((asset) => asset.id === clip.assetId);
|
|
86
|
-
const sourcePath = join(root, ...sourceAsset.path.split('/'));
|
|
87
|
-
const id = `sidechain-${targetTrack.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
88
|
-
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
89
|
-
const absolutePath = join(root, ...relativePath.split('/'));
|
|
90
|
-
const detectorStart = clip.timelineStart;
|
|
91
|
-
const detectorEnd = detectorStart + clip.durationSeconds;
|
|
92
|
-
const threshold = 10 ** (settings.thresholdDb / 20);
|
|
93
|
-
const filter = [
|
|
94
|
-
`[0:a:0]atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[target]`,
|
|
95
|
-
`[1:a:0]atrim=start=${detectorStart}:end=${detectorEnd},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[detector]`,
|
|
96
|
-
`[target][detector]sidechaincompress=threshold=${threshold}:ratio=${settings.ratio}:attack=${settings.attackSeconds * 1000}:release=${settings.releaseSeconds * 1000}:makeup=1:detection=rms:link=average,apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}[ducked]`,
|
|
97
|
-
].join(';');
|
|
98
|
-
temporaryAssets.push(absolutePath);
|
|
99
|
-
await runSidechainFfmpegAsync(root, [
|
|
100
|
-
'-i', sourcePath, '-i', detector.path,
|
|
101
|
-
'-filter_complex', filter, '-map', '[ducked]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le',
|
|
102
|
-
absolutePath,
|
|
103
|
-
], `Could not apply sidechain compression to clip ${clip.id}`, renderControl);
|
|
104
|
-
next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: clip.durationSeconds });
|
|
105
|
-
clip.assetId = id;
|
|
106
|
-
clip.sourceIn = 0;
|
|
107
|
-
clip.sourceOut = clip.durationSeconds;
|
|
108
|
-
delete clip.playbackRate;
|
|
109
|
-
delete clip.speedRamp;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
validateTimeline(next);
|
|
113
|
-
return next;
|
|
114
|
-
} catch (error) {
|
|
115
|
-
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
116
|
-
throw error;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function buildTrackStem(root, timeline, track, temporaryAssets, renderControl) {
|
|
121
|
-
const id = `sidechain-source-${track.id}-${randomUUID().slice(0, 8)}`;
|
|
122
|
-
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
123
|
-
const absolutePath = join(root, ...relativePath.split('/'));
|
|
124
|
-
const args = [];
|
|
125
|
-
const filters = [];
|
|
126
|
-
for (const [index, clip] of track.clips.entries()) {
|
|
127
|
-
const asset = timeline.assets.find((item) => item.id === clip.assetId);
|
|
128
|
-
args.push('-i', join(root, ...asset.path.split('/')));
|
|
129
|
-
const gain = (track.volume ?? 1) * (clip.volume ?? 1);
|
|
130
|
-
const steps = [
|
|
131
|
-
`atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
|
|
132
|
-
'asetpts=PTS-STARTPTS',
|
|
133
|
-
`volume=${gain}`,
|
|
134
|
-
];
|
|
135
|
-
if (clip.fadeInSeconds > 0) steps.push(`afade=t=in:st=0:d=${clip.fadeInSeconds}`);
|
|
136
|
-
if (clip.fadeOutSeconds > 0) steps.push(`afade=t=out:st=${clip.durationSeconds - clip.fadeOutSeconds}:d=${clip.fadeOutSeconds}`);
|
|
137
|
-
steps.push('aresample=48000', 'aformat=sample_fmts=fltp:channel_layouts=stereo');
|
|
138
|
-
steps.push(`adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`);
|
|
139
|
-
filters.push(`[${index}:a:0]${steps.join(',')}[source${index}]`);
|
|
140
|
-
}
|
|
141
|
-
const labels = track.clips.map((_, index) => `[source${index}]`).join('');
|
|
142
|
-
filters.push(`${labels}amix=inputs=${track.clips.length}:duration=longest:normalize=0,apad=whole_dur=${timeline.project.durationSeconds},atrim=duration=${timeline.project.durationSeconds},asetpts=PTS-STARTPTS[stem]`);
|
|
143
|
-
args.push('-filter_complex', filters.join(';'));
|
|
144
|
-
args.push('-map', '[stem]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath);
|
|
145
|
-
temporaryAssets.push(absolutePath);
|
|
146
|
-
runFfmpeg(root, args, `Could not build sidechain detector stem for track ${track.id}`, renderControl);
|
|
147
|
-
return { id, path: absolutePath };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
async function buildTrackStemAsync(root, timeline, track, temporaryAssets, renderControl) {
|
|
151
|
-
const id = `sidechain-source-${track.id}-${randomUUID().slice(0, 8)}`;
|
|
152
|
-
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
153
|
-
const absolutePath = join(root, ...relativePath.split('/'));
|
|
154
|
-
const args = [];
|
|
155
|
-
const filters = [];
|
|
156
|
-
for (const [index, clip] of track.clips.entries()) {
|
|
157
|
-
const asset = timeline.assets.find((item) => item.id === clip.assetId);
|
|
158
|
-
args.push('-i', join(root, ...asset.path.split('/')));
|
|
159
|
-
const gain = (track.volume ?? 1) * (clip.volume ?? 1);
|
|
160
|
-
const steps = [
|
|
161
|
-
`atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
|
|
162
|
-
'asetpts=PTS-STARTPTS',
|
|
163
|
-
`volume=${gain}`,
|
|
164
|
-
];
|
|
165
|
-
if (clip.fadeInSeconds > 0) steps.push(`afade=t=in:st=0:d=${clip.fadeInSeconds}`);
|
|
166
|
-
if (clip.fadeOutSeconds > 0) steps.push(`afade=t=out:st=${clip.durationSeconds - clip.fadeOutSeconds}:d=${clip.fadeOutSeconds}`);
|
|
167
|
-
steps.push('aresample=48000', 'aformat=sample_fmts=fltp:channel_layouts=stereo');
|
|
168
|
-
steps.push(`adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`);
|
|
169
|
-
filters.push(`[${index}:a:0]${steps.join(',')}[source${index}]`);
|
|
170
|
-
}
|
|
171
|
-
const labels = track.clips.map((_, index) => `[source${index}]`).join('');
|
|
172
|
-
filters.push(`${labels}amix=inputs=${track.clips.length}:duration=longest:normalize=0,apad=whole_dur=${timeline.project.durationSeconds},atrim=duration=${timeline.project.durationSeconds},asetpts=PTS-STARTPTS[stem]`);
|
|
173
|
-
args.push('-filter_complex', filters.join(';'));
|
|
174
|
-
args.push('-map', '[stem]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath);
|
|
175
|
-
temporaryAssets.push(absolutePath);
|
|
176
|
-
await runSidechainFfmpegAsync(root, args, `Could not build sidechain detector stem for track ${track.id}`, renderControl);
|
|
177
|
-
return { id, path: absolutePath };
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function runFfmpeg(cwd, args, message, renderControl) {
|
|
181
|
-
renderControl?.assertActive();
|
|
182
|
-
const result = spawnSync('ffmpeg', [
|
|
183
|
-
'-nostdin', '-hide_banner', '-v', 'error', '-y',
|
|
184
|
-
'-filter_threads', '1', '-filter_complex_threads', '1', ...args,
|
|
185
|
-
], {
|
|
186
|
-
encoding: 'utf8',
|
|
187
|
-
windowsHide: true,
|
|
188
|
-
cwd,
|
|
189
|
-
...(renderControl ? { timeout: renderControl.timeoutMs() } : {}),
|
|
190
|
-
});
|
|
191
|
-
renderControl?.assertActive();
|
|
192
|
-
if (result.error || result.status !== 0) {
|
|
193
|
-
const error = renderControl?.processError(result.error);
|
|
194
|
-
throw error ?? new Error(`${message}: ${result.error?.message ?? result.stderr.trim()}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function runSidechainFfmpegAsync(cwd, args, message, renderControl) {
|
|
199
|
-
try {
|
|
200
|
-
renderControl?.assertActive();
|
|
201
|
-
await runProcess('ffmpeg', [
|
|
202
|
-
'-nostdin', '-hide_banner', '-v', 'error', '-y',
|
|
203
|
-
'-filter_threads', '1', '-filter_complex_threads', '1', ...args,
|
|
204
|
-
], { cwd, signal: renderControl?.signal, stdio: 'capture' });
|
|
205
|
-
} catch (error) {
|
|
206
|
-
if (renderControl?.signal.aborted) throw renderControl.signal.reason ?? error;
|
|
207
|
-
throw new Error(`${message}: ${error.message}`, { cause: error });
|
|
208
|
-
}
|
|
209
|
-
}
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { rmSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { validateTimeline } from './validate.mjs';
|
|
6
|
+
import { resolveAudioSidechainSettings } from './audio-mix.mjs';
|
|
7
|
+
import { runProcess } from '../render/process.mjs';
|
|
8
|
+
|
|
9
|
+
export function applySidechainDuckingProxies(root, timeline, temporaryAssets, renderControl) {
|
|
10
|
+
const rules = timeline.audioMix?.sidechain ?? [];
|
|
11
|
+
if (rules.length === 0) return timeline;
|
|
12
|
+
|
|
13
|
+
const next = structuredClone(timeline);
|
|
14
|
+
const startIndex = temporaryAssets.length;
|
|
15
|
+
try {
|
|
16
|
+
const stems = new Map();
|
|
17
|
+
for (const rule of rules) {
|
|
18
|
+
if (!stems.has(rule.sourceTrackId)) {
|
|
19
|
+
const sourceTrack = next.tracks.find((track) => track.id === rule.sourceTrackId);
|
|
20
|
+
stems.set(rule.sourceTrackId, buildTrackStem(root, next, sourceTrack, temporaryAssets, renderControl));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const rule of rules) {
|
|
25
|
+
const settings = resolveAudioSidechainSettings(rule);
|
|
26
|
+
const detector = stems.get(rule.sourceTrackId);
|
|
27
|
+
const targetTrack = next.tracks.find((track) => track.id === rule.targetTrackId);
|
|
28
|
+
for (const clip of targetTrack.clips) {
|
|
29
|
+
const sourceAsset = next.assets.find((asset) => asset.id === clip.assetId);
|
|
30
|
+
const sourcePath = join(root, ...sourceAsset.path.split('/'));
|
|
31
|
+
const id = `sidechain-${targetTrack.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
32
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
33
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
34
|
+
const detectorStart = clip.timelineStart;
|
|
35
|
+
const detectorEnd = detectorStart + clip.durationSeconds;
|
|
36
|
+
const threshold = 10 ** (settings.thresholdDb / 20);
|
|
37
|
+
const filter = [
|
|
38
|
+
`[0:a:0]atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[target]`,
|
|
39
|
+
`[1:a:0]atrim=start=${detectorStart}:end=${detectorEnd},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[detector]`,
|
|
40
|
+
`[target][detector]sidechaincompress=threshold=${threshold}:ratio=${settings.ratio}:attack=${settings.attackSeconds * 1000}:release=${settings.releaseSeconds * 1000}:makeup=1:detection=rms:link=average,apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}[ducked]`,
|
|
41
|
+
].join(';');
|
|
42
|
+
temporaryAssets.push(absolutePath);
|
|
43
|
+
runFfmpeg(root, [
|
|
44
|
+
'-i', sourcePath, '-i', detector.path,
|
|
45
|
+
'-filter_complex', filter, '-map', '[ducked]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le',
|
|
46
|
+
absolutePath,
|
|
47
|
+
], `Could not apply sidechain compression to clip ${clip.id}`, renderControl);
|
|
48
|
+
next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: clip.durationSeconds });
|
|
49
|
+
clip.assetId = id;
|
|
50
|
+
clip.sourceIn = 0;
|
|
51
|
+
clip.sourceOut = clip.durationSeconds;
|
|
52
|
+
delete clip.playbackRate;
|
|
53
|
+
delete clip.speedRamp;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
validateTimeline(next);
|
|
57
|
+
return next;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function applySidechainDuckingProxiesAsync(root, timeline, temporaryAssets, renderControl) {
|
|
65
|
+
const rules = timeline.audioMix?.sidechain ?? [];
|
|
66
|
+
if (rules.length === 0) return timeline;
|
|
67
|
+
|
|
68
|
+
const next = structuredClone(timeline);
|
|
69
|
+
const startIndex = temporaryAssets.length;
|
|
70
|
+
try {
|
|
71
|
+
const stems = new Map();
|
|
72
|
+
for (const rule of rules) {
|
|
73
|
+
if (!stems.has(rule.sourceTrackId)) {
|
|
74
|
+
const sourceTrack = next.tracks.find((track) => track.id === rule.sourceTrackId);
|
|
75
|
+
stems.set(rule.sourceTrackId, await buildTrackStemAsync(root, next, sourceTrack, temporaryAssets, renderControl));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const rule of rules) {
|
|
80
|
+
renderControl?.assertActive();
|
|
81
|
+
const settings = resolveAudioSidechainSettings(rule);
|
|
82
|
+
const detector = stems.get(rule.sourceTrackId);
|
|
83
|
+
const targetTrack = next.tracks.find((track) => track.id === rule.targetTrackId);
|
|
84
|
+
for (const clip of targetTrack.clips) {
|
|
85
|
+
const sourceAsset = next.assets.find((asset) => asset.id === clip.assetId);
|
|
86
|
+
const sourcePath = join(root, ...sourceAsset.path.split('/'));
|
|
87
|
+
const id = `sidechain-${targetTrack.id}-${clip.id}-${randomUUID().slice(0, 8)}`;
|
|
88
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
89
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
90
|
+
const detectorStart = clip.timelineStart;
|
|
91
|
+
const detectorEnd = detectorStart + clip.durationSeconds;
|
|
92
|
+
const threshold = 10 ** (settings.thresholdDb / 20);
|
|
93
|
+
const filter = [
|
|
94
|
+
`[0:a:0]atrim=start=${clip.sourceIn}:end=${clip.sourceOut},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[target]`,
|
|
95
|
+
`[1:a:0]atrim=start=${detectorStart}:end=${detectorEnd},asetpts=PTS-STARTPTS,aresample=48000,aformat=sample_fmts=fltp:channel_layouts=stereo[detector]`,
|
|
96
|
+
`[target][detector]sidechaincompress=threshold=${threshold}:ratio=${settings.ratio}:attack=${settings.attackSeconds * 1000}:release=${settings.releaseSeconds * 1000}:makeup=1:detection=rms:link=average,apad=whole_dur=${clip.durationSeconds},atrim=duration=${clip.durationSeconds}[ducked]`,
|
|
97
|
+
].join(';');
|
|
98
|
+
temporaryAssets.push(absolutePath);
|
|
99
|
+
await runSidechainFfmpegAsync(root, [
|
|
100
|
+
'-i', sourcePath, '-i', detector.path,
|
|
101
|
+
'-filter_complex', filter, '-map', '[ducked]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le',
|
|
102
|
+
absolutePath,
|
|
103
|
+
], `Could not apply sidechain compression to clip ${clip.id}`, renderControl);
|
|
104
|
+
next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: clip.durationSeconds });
|
|
105
|
+
clip.assetId = id;
|
|
106
|
+
clip.sourceIn = 0;
|
|
107
|
+
clip.sourceOut = clip.durationSeconds;
|
|
108
|
+
delete clip.playbackRate;
|
|
109
|
+
delete clip.speedRamp;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
validateTimeline(next);
|
|
113
|
+
return next;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildTrackStem(root, timeline, track, temporaryAssets, renderControl) {
|
|
121
|
+
const id = `sidechain-source-${track.id}-${randomUUID().slice(0, 8)}`;
|
|
122
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
123
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
124
|
+
const args = [];
|
|
125
|
+
const filters = [];
|
|
126
|
+
for (const [index, clip] of track.clips.entries()) {
|
|
127
|
+
const asset = timeline.assets.find((item) => item.id === clip.assetId);
|
|
128
|
+
args.push('-i', join(root, ...asset.path.split('/')));
|
|
129
|
+
const gain = (track.volume ?? 1) * (clip.volume ?? 1);
|
|
130
|
+
const steps = [
|
|
131
|
+
`atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
|
|
132
|
+
'asetpts=PTS-STARTPTS',
|
|
133
|
+
`volume=${gain}`,
|
|
134
|
+
];
|
|
135
|
+
if (clip.fadeInSeconds > 0) steps.push(`afade=t=in:st=0:d=${clip.fadeInSeconds}`);
|
|
136
|
+
if (clip.fadeOutSeconds > 0) steps.push(`afade=t=out:st=${clip.durationSeconds - clip.fadeOutSeconds}:d=${clip.fadeOutSeconds}`);
|
|
137
|
+
steps.push('aresample=48000', 'aformat=sample_fmts=fltp:channel_layouts=stereo');
|
|
138
|
+
steps.push(`adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`);
|
|
139
|
+
filters.push(`[${index}:a:0]${steps.join(',')}[source${index}]`);
|
|
140
|
+
}
|
|
141
|
+
const labels = track.clips.map((_, index) => `[source${index}]`).join('');
|
|
142
|
+
filters.push(`${labels}amix=inputs=${track.clips.length}:duration=longest:normalize=0,apad=whole_dur=${timeline.project.durationSeconds},atrim=duration=${timeline.project.durationSeconds},asetpts=PTS-STARTPTS[stem]`);
|
|
143
|
+
args.push('-filter_complex', filters.join(';'));
|
|
144
|
+
args.push('-map', '[stem]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath);
|
|
145
|
+
temporaryAssets.push(absolutePath);
|
|
146
|
+
runFfmpeg(root, args, `Could not build sidechain detector stem for track ${track.id}`, renderControl);
|
|
147
|
+
return { id, path: absolutePath };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function buildTrackStemAsync(root, timeline, track, temporaryAssets, renderControl) {
|
|
151
|
+
const id = `sidechain-source-${track.id}-${randomUUID().slice(0, 8)}`;
|
|
152
|
+
const relativePath = `assets/.${id}-${randomUUID()}.wav`;
|
|
153
|
+
const absolutePath = join(root, ...relativePath.split('/'));
|
|
154
|
+
const args = [];
|
|
155
|
+
const filters = [];
|
|
156
|
+
for (const [index, clip] of track.clips.entries()) {
|
|
157
|
+
const asset = timeline.assets.find((item) => item.id === clip.assetId);
|
|
158
|
+
args.push('-i', join(root, ...asset.path.split('/')));
|
|
159
|
+
const gain = (track.volume ?? 1) * (clip.volume ?? 1);
|
|
160
|
+
const steps = [
|
|
161
|
+
`atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
|
|
162
|
+
'asetpts=PTS-STARTPTS',
|
|
163
|
+
`volume=${gain}`,
|
|
164
|
+
];
|
|
165
|
+
if (clip.fadeInSeconds > 0) steps.push(`afade=t=in:st=0:d=${clip.fadeInSeconds}`);
|
|
166
|
+
if (clip.fadeOutSeconds > 0) steps.push(`afade=t=out:st=${clip.durationSeconds - clip.fadeOutSeconds}:d=${clip.fadeOutSeconds}`);
|
|
167
|
+
steps.push('aresample=48000', 'aformat=sample_fmts=fltp:channel_layouts=stereo');
|
|
168
|
+
steps.push(`adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`);
|
|
169
|
+
filters.push(`[${index}:a:0]${steps.join(',')}[source${index}]`);
|
|
170
|
+
}
|
|
171
|
+
const labels = track.clips.map((_, index) => `[source${index}]`).join('');
|
|
172
|
+
filters.push(`${labels}amix=inputs=${track.clips.length}:duration=longest:normalize=0,apad=whole_dur=${timeline.project.durationSeconds},atrim=duration=${timeline.project.durationSeconds},asetpts=PTS-STARTPTS[stem]`);
|
|
173
|
+
args.push('-filter_complex', filters.join(';'));
|
|
174
|
+
args.push('-map', '[stem]', '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath);
|
|
175
|
+
temporaryAssets.push(absolutePath);
|
|
176
|
+
await runSidechainFfmpegAsync(root, args, `Could not build sidechain detector stem for track ${track.id}`, renderControl);
|
|
177
|
+
return { id, path: absolutePath };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function runFfmpeg(cwd, args, message, renderControl) {
|
|
181
|
+
renderControl?.assertActive();
|
|
182
|
+
const result = spawnSync('ffmpeg', [
|
|
183
|
+
'-nostdin', '-hide_banner', '-v', 'error', '-y',
|
|
184
|
+
'-filter_threads', '1', '-filter_complex_threads', '1', ...args,
|
|
185
|
+
], {
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
cwd,
|
|
189
|
+
...(renderControl ? { timeout: renderControl.timeoutMs() } : {}),
|
|
190
|
+
});
|
|
191
|
+
renderControl?.assertActive();
|
|
192
|
+
if (result.error || result.status !== 0) {
|
|
193
|
+
const error = renderControl?.processError(result.error);
|
|
194
|
+
throw error ?? new Error(`${message}: ${result.error?.message ?? result.stderr.trim()}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function runSidechainFfmpegAsync(cwd, args, message, renderControl) {
|
|
199
|
+
try {
|
|
200
|
+
renderControl?.assertActive();
|
|
201
|
+
await runProcess('ffmpeg', [
|
|
202
|
+
'-nostdin', '-hide_banner', '-v', 'error', '-y',
|
|
203
|
+
'-filter_threads', '1', '-filter_complex_threads', '1', ...args,
|
|
204
|
+
], { cwd, signal: renderControl?.signal, stdio: 'capture' });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (renderControl?.signal.aborted) throw renderControl.signal.reason ?? error;
|
|
207
|
+
throw new Error(`${message}: ${error.message}`, { cause: error });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
export const SOURCE_FIT_MODES = Object.freeze(['contain', 'cover', 'fill']);
|
|
2
|
-
export const DEFAULT_SOURCE_FIT = 'contain';
|
|
3
|
-
|
|
4
|
-
export function validateSourceFit(value, label = 'sourceFit') {
|
|
5
|
-
if (value !== undefined && !SOURCE_FIT_MODES.includes(value)) {
|
|
6
|
-
throw new TypeError(`${label} must be one of: ${SOURCE_FIT_MODES.join(', ')}.`);
|
|
7
|
-
}
|
|
8
|
-
return value;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function resolveSourceFit(project, clip, projectDefault) {
|
|
12
|
-
const fit = clip?.fit ?? project?.sourceFit ?? projectDefault ?? DEFAULT_SOURCE_FIT;
|
|
13
|
-
validateSourceFit(fit, 'Resolved source fit');
|
|
14
|
-
return fit;
|
|
15
|
-
}
|
|
1
|
+
export const SOURCE_FIT_MODES = Object.freeze(['contain', 'cover', 'fill']);
|
|
2
|
+
export const DEFAULT_SOURCE_FIT = 'contain';
|
|
3
|
+
|
|
4
|
+
export function validateSourceFit(value, label = 'sourceFit') {
|
|
5
|
+
if (value !== undefined && !SOURCE_FIT_MODES.includes(value)) {
|
|
6
|
+
throw new TypeError(`${label} must be one of: ${SOURCE_FIT_MODES.join(', ')}.`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveSourceFit(project, clip, projectDefault) {
|
|
12
|
+
const fit = clip?.fit ?? project?.sourceFit ?? projectDefault ?? DEFAULT_SOURCE_FIT;
|
|
13
|
+
validateSourceFit(fit, 'Resolved source fit');
|
|
14
|
+
return fit;
|
|
15
|
+
}
|
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
const SIDES = new Set(['top', 'right', 'bottom', 'left']);
|
|
2
|
-
const DEFAULTS = { tailSide: 'bottom', tailPosition: 0.16, tailSizePixels: 26, cornerRadiusPixels: 28 };
|
|
3
|
-
|
|
4
|
-
export function resolveSpeechBubble(value, base = DEFAULTS) {
|
|
5
|
-
if (value === undefined) return undefined;
|
|
6
|
-
const result = { ...base, ...value };
|
|
7
|
-
validateSpeechBubble(result);
|
|
8
|
-
return result;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function validateSpeechBubble(value, label = 'Speech bubble') {
|
|
12
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
13
|
-
|| Object.keys(value).some((key) => !Object.hasOwn(DEFAULTS, key))) {
|
|
14
|
-
throw new TypeError(`${label} must be an object with only tailSide, tailPosition, tailSizePixels, and cornerRadiusPixels.`);
|
|
15
|
-
}
|
|
16
|
-
if (!SIDES.has(value.tailSide)) throw new TypeError(`${label} tailSide must be top, right, bottom, or left.`);
|
|
17
|
-
if (!Number.isFinite(value.tailPosition) || value.tailPosition < 0.08 || value.tailPosition > 0.92) {
|
|
18
|
-
throw new TypeError(`${label} tailPosition must be from 0.08 to 0.92.`);
|
|
19
|
-
}
|
|
20
|
-
if (!Number.isSafeInteger(value.tailSizePixels) || value.tailSizePixels < 8 || value.tailSizePixels > 64) {
|
|
21
|
-
throw new TypeError(`${label} tailSizePixels must be an integer from 8 to 64.`);
|
|
22
|
-
}
|
|
23
|
-
if (!Number.isSafeInteger(value.cornerRadiusPixels) || value.cornerRadiusPixels < 0 || value.cornerRadiusPixels > 128) {
|
|
24
|
-
throw new TypeError(`${label} cornerRadiusPixels must be an integer from 0 to 128.`);
|
|
25
|
-
}
|
|
26
|
-
}
|
|
1
|
+
const SIDES = new Set(['top', 'right', 'bottom', 'left']);
|
|
2
|
+
const DEFAULTS = { tailSide: 'bottom', tailPosition: 0.16, tailSizePixels: 26, cornerRadiusPixels: 28 };
|
|
3
|
+
|
|
4
|
+
export function resolveSpeechBubble(value, base = DEFAULTS) {
|
|
5
|
+
if (value === undefined) return undefined;
|
|
6
|
+
const result = { ...base, ...value };
|
|
7
|
+
validateSpeechBubble(result);
|
|
8
|
+
return result;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function validateSpeechBubble(value, label = 'Speech bubble') {
|
|
12
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
13
|
+
|| Object.keys(value).some((key) => !Object.hasOwn(DEFAULTS, key))) {
|
|
14
|
+
throw new TypeError(`${label} must be an object with only tailSide, tailPosition, tailSizePixels, and cornerRadiusPixels.`);
|
|
15
|
+
}
|
|
16
|
+
if (!SIDES.has(value.tailSide)) throw new TypeError(`${label} tailSide must be top, right, bottom, or left.`);
|
|
17
|
+
if (!Number.isFinite(value.tailPosition) || value.tailPosition < 0.08 || value.tailPosition > 0.92) {
|
|
18
|
+
throw new TypeError(`${label} tailPosition must be from 0.08 to 0.92.`);
|
|
19
|
+
}
|
|
20
|
+
if (!Number.isSafeInteger(value.tailSizePixels) || value.tailSizePixels < 8 || value.tailSizePixels > 64) {
|
|
21
|
+
throw new TypeError(`${label} tailSizePixels must be an integer from 8 to 64.`);
|
|
22
|
+
}
|
|
23
|
+
if (!Number.isSafeInteger(value.cornerRadiusPixels) || value.cornerRadiusPixels < 0 || value.cornerRadiusPixels > 128) {
|
|
24
|
+
throw new TypeError(`${label} cornerRadiusPixels must be an integer from 0 to 128.`);
|
|
25
|
+
}
|
|
26
|
+
}
|