aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +120 -118
  3. package/docs/architecture.md +401 -401
  4. package/docs/aspect-ratio-presets.md +72 -72
  5. package/docs/releasing.md +19 -19
  6. package/docs/usage.md +1193 -1193
  7. package/package.json +52 -52
  8. package/requirements-otio.txt +1 -1
  9. package/src/capabilities.mjs +307 -307
  10. package/src/cli.mjs +646 -646
  11. package/src/doctor.mjs +133 -133
  12. package/src/edits/analysis-to-edl.mjs +43 -43
  13. package/src/edits/detect-silence.mjs +51 -51
  14. package/src/edits/find-mistake-candidates.mjs +42 -42
  15. package/src/edits/plan-shot-placements.mjs +105 -105
  16. package/src/edits/plan-silence-cuts.mjs +49 -49
  17. package/src/edits/retime-transcript.mjs +105 -105
  18. package/src/files.mjs +80 -80
  19. package/src/index.mjs +44 -44
  20. package/src/interchange/otio.mjs +204 -204
  21. package/src/interchange/otio_bridge.py +521 -521
  22. package/src/media/analysis-provider.mjs +75 -75
  23. package/src/media/compatibility.mjs +45 -45
  24. package/src/media/ffmpeg-capabilities.mjs +129 -129
  25. package/src/media/frame-rate.mjs +58 -58
  26. package/src/media/frame-time.mjs +22 -22
  27. package/src/media/preview-assets.mjs +350 -350
  28. package/src/media/probe.mjs +149 -149
  29. package/src/media/render-cuts.mjs +90 -90
  30. package/src/media/scene-detection.mjs +107 -107
  31. package/src/media/scene-review.mjs +205 -205
  32. package/src/project/assets.mjs +1194 -1194
  33. package/src/project/create.mjs +100 -100
  34. package/src/project/presets.mjs +44 -44
  35. package/src/render/checkpoints.mjs +192 -192
  36. package/src/render/formats.mjs +163 -163
  37. package/src/render/hyperframes.mjs +590 -590
  38. package/src/render/process.mjs +111 -111
  39. package/src/render/progress.mjs +46 -46
  40. package/src/render/timeline-control.mjs +53 -53
  41. package/src/timeline/assemble.mjs +119 -119
  42. package/src/timeline/audio-effects.mjs +75 -75
  43. package/src/timeline/audio-mastering.mjs +236 -236
  44. package/src/timeline/audio-mix.mjs +105 -105
  45. package/src/timeline/audio-track-effects.mjs +139 -139
  46. package/src/timeline/audio-volume.mjs +63 -63
  47. package/src/timeline/background.mjs +8 -8
  48. package/src/timeline/blend-mode.mjs +34 -34
  49. package/src/timeline/caption-layouts.mjs +62 -61
  50. package/src/timeline/captions.mjs +80 -80
  51. package/src/timeline/edit.mjs +965 -965
  52. package/src/timeline/fingerprint.mjs +6 -6
  53. package/src/timeline/from-edl.mjs +31 -31
  54. package/src/timeline/history-store.mjs +281 -281
  55. package/src/timeline/history.mjs +69 -69
  56. package/src/timeline/inspect.mjs +117 -117
  57. package/src/timeline/keyframes.mjs +158 -158
  58. package/src/timeline/layers.mjs +18 -18
  59. package/src/timeline/mask.mjs +53 -53
  60. package/src/timeline/project.mjs +52 -52
  61. package/src/timeline/render.mjs +759 -759
  62. package/src/timeline/sidechain-render.mjs +209 -209
  63. package/src/timeline/source-fit.mjs +15 -15
  64. package/src/timeline/speech-bubble.mjs +26 -26
  65. package/src/timeline/speed-ramp.mjs +188 -188
  66. package/src/timeline/track-state.mjs +22 -22
  67. package/src/timeline/transition.mjs +34 -34
  68. package/src/timeline/validate.mjs +322 -322
  69. package/src/transcript/corrections.mjs +21 -21
  70. package/src/transcript/normalize.mjs +17 -17
  71. package/src/transcript/provider.mjs +42 -42
  72. package/src/transcript/subtitles.mjs +57 -57
  73. package/src/transcript/validate.mjs +39 -39
@@ -1,105 +1,105 @@
1
- import { sampleAudioVolumeKeyframes } from './audio-volume.mjs';
2
-
3
- export function validateAudioMix(timeline) {
4
- const mix = timeline.audioMix;
5
- if (mix === undefined) return timeline;
6
- if (!mix || typeof mix !== 'object' || Array.isArray(mix) || (mix.ducking !== undefined && !Array.isArray(mix.ducking))
7
- || (mix.sidechain !== undefined && !Array.isArray(mix.sidechain))) {
8
- throw new TypeError('Timeline audioMix ducking and sidechain settings must be arrays when provided.');
9
- }
10
- const tracks = new Map(timeline.tracks.map((track) => [track.id, track]));
11
- for (const [index, rule] of (mix.ducking ?? []).entries()) {
12
- const source = tracks.get(rule?.sourceTrackId);
13
- const target = tracks.get(rule?.targetTrackId);
14
- if (!source || !target || source.kind !== 'audio' || target.kind !== 'audio' || source === target) throw new TypeError(`Audio ducking rule ${index + 1} must reference two different audio tracks.`);
15
- if (!Number.isFinite(rule.attenuation) || rule.attenuation < 0 || rule.attenuation > 1) throw new TypeError(`Audio ducking rule ${index + 1} attenuation must be between 0 and 1.`);
16
- for (const field of ['attackSeconds', 'releaseSeconds']) if (!Number.isFinite(rule[field] ?? 0) || (rule[field] ?? 0) < 0) throw new TypeError(`Audio ducking rule ${index + 1} ${field} must be non-negative.`);
17
- }
18
- for (const [index, rule] of (mix.sidechain ?? []).entries()) {
19
- const source = tracks.get(rule?.sourceTrackId);
20
- const target = tracks.get(rule?.targetTrackId);
21
- if (!source || !target || source.kind !== 'audio' || target.kind !== 'audio' || source === target
22
- || source.clips.length === 0 || target.clips.length === 0) {
23
- throw new TypeError(`Audio sidechain rule ${index + 1} must reference two different non-empty audio tracks.`);
24
- }
25
- const settings = resolveAudioSidechainSettings(rule);
26
- if (!Number.isFinite(settings.thresholdDb) || settings.thresholdDb < -60 || settings.thresholdDb > 0) throw new TypeError(`Audio sidechain rule ${index + 1} thresholdDb must be between -60 and 0.`);
27
- if (!Number.isFinite(settings.ratio) || settings.ratio < 1 || settings.ratio > 20) throw new TypeError(`Audio sidechain rule ${index + 1} ratio must be between 1 and 20.`);
28
- if (!Number.isFinite(settings.attackSeconds) || settings.attackSeconds < 0.00001 || settings.attackSeconds > 2) throw new TypeError(`Audio sidechain rule ${index + 1} attackSeconds must be between 0.00001 and 2.`);
29
- if (!Number.isFinite(settings.releaseSeconds) || settings.releaseSeconds < 0.00001 || settings.releaseSeconds > 9) throw new TypeError(`Audio sidechain rule ${index + 1} releaseSeconds must be between 0.00001 and 9.`);
30
- for (const field of Object.keys(rule)) {
31
- if (!['sourceTrackId', 'targetTrackId', 'thresholdDb', 'ratio', 'attackSeconds', 'releaseSeconds'].includes(field)) {
32
- throw new TypeError(`Audio sidechain rule ${index + 1} has an unsupported field: ${field}.`);
33
- }
34
- }
35
- }
36
- return timeline;
37
- }
38
-
39
- export function resolveAudioSidechainSettings(rule) {
40
- return {
41
- thresholdDb: rule.thresholdDb ?? -24,
42
- ratio: rule.ratio ?? 4,
43
- attackSeconds: rule.attackSeconds ?? 0.01,
44
- releaseSeconds: rule.releaseSeconds ?? 0.25,
45
- };
46
- }
47
-
48
- export function buildAudioVolumeAutomation(timeline, track, clip) {
49
- const start = clip.timelineStart;
50
- const end = start + clip.durationSeconds;
51
- const baseVolume = (clip.volume ?? 1) * (track.volume ?? 1);
52
- const duckRamps = [];
53
- for (const rule of timeline.audioMix?.ducking ?? []) {
54
- if (rule.targetTrackId !== track.id) continue;
55
- const source = timeline.tracks.find((item) => item.id === rule.sourceTrackId);
56
- for (const sourceClip of source._duckingIntervals ?? source.clips) {
57
- const duckStart = Math.max(start, sourceClip.timelineStart);
58
- const duckEnd = Math.min(end, sourceClip.timelineStart + sourceClip.durationSeconds);
59
- if (duckEnd <= duckStart) continue;
60
- duckRamps.push({
61
- start: duckStart,
62
- end: duckEnd,
63
- attenuation: rule.attenuation,
64
- attackSeconds: rule.attackSeconds ?? 0,
65
- releaseSeconds: rule.releaseSeconds ?? 0,
66
- });
67
- }
68
- }
69
-
70
- const points = new Set([start, end]);
71
- const fadeIn = clip.fadeInSeconds ?? 0;
72
- const fadeOut = clip.fadeOutSeconds ?? 0;
73
- if (fadeIn > 0) points.add(start + fadeIn);
74
- if (fadeOut > 0) points.add(end - fadeOut);
75
- for (const keyframe of clip.volumeKeyframes ?? []) points.add(start + keyframe.time);
76
- for (const keyframe of track.volumeKeyframes ?? []) {
77
- if (keyframe.time > start && keyframe.time < end) points.add(keyframe.time);
78
- }
79
- for (const ramp of duckRamps) {
80
- points.add(Math.max(start, ramp.start - ramp.attackSeconds));
81
- points.add(ramp.start);
82
- points.add(ramp.end);
83
- points.add(Math.min(end, ramp.end + ramp.releaseSeconds));
84
- }
85
-
86
- return [...points].sort((a, b) => a - b).map((time) => {
87
- let volume = baseVolume;
88
- volume *= sampleAudioVolumeKeyframes(track.volumeKeyframes, time);
89
- volume *= sampleAudioVolumeKeyframes(clip.volumeKeyframes, time - start);
90
- if (fadeIn > 0 && time < start + fadeIn) volume *= Math.max(0, (time - start) / fadeIn);
91
- if (fadeOut > 0 && time > end - fadeOut) volume *= Math.max(0, (end - time) / fadeOut);
92
- for (const ramp of duckRamps) volume *= duckFactorAt(time, ramp);
93
- return { time, volume: Number(volume.toFixed(6)) };
94
- });
95
- }
96
-
97
- function duckFactorAt(time, ramp) {
98
- if (time < ramp.start) {
99
- const attackStart = ramp.start - ramp.attackSeconds;
100
- if (ramp.attackSeconds === 0 || time <= attackStart) return 1;
101
- return 1 - ((time - attackStart) / ramp.attackSeconds) * (1 - ramp.attenuation);
102
- }
103
- if (time <= ramp.end || ramp.releaseSeconds === 0 || time >= ramp.end + ramp.releaseSeconds) return time <= ramp.end ? ramp.attenuation : 1;
104
- return ramp.attenuation + ((time - ramp.end) / ramp.releaseSeconds) * (1 - ramp.attenuation);
105
- }
1
+ import { sampleAudioVolumeKeyframes } from './audio-volume.mjs';
2
+
3
+ export function validateAudioMix(timeline) {
4
+ const mix = timeline.audioMix;
5
+ if (mix === undefined) return timeline;
6
+ if (!mix || typeof mix !== 'object' || Array.isArray(mix) || (mix.ducking !== undefined && !Array.isArray(mix.ducking))
7
+ || (mix.sidechain !== undefined && !Array.isArray(mix.sidechain))) {
8
+ throw new TypeError('Timeline audioMix ducking and sidechain settings must be arrays when provided.');
9
+ }
10
+ const tracks = new Map(timeline.tracks.map((track) => [track.id, track]));
11
+ for (const [index, rule] of (mix.ducking ?? []).entries()) {
12
+ const source = tracks.get(rule?.sourceTrackId);
13
+ const target = tracks.get(rule?.targetTrackId);
14
+ if (!source || !target || source.kind !== 'audio' || target.kind !== 'audio' || source === target) throw new TypeError(`Audio ducking rule ${index + 1} must reference two different audio tracks.`);
15
+ if (!Number.isFinite(rule.attenuation) || rule.attenuation < 0 || rule.attenuation > 1) throw new TypeError(`Audio ducking rule ${index + 1} attenuation must be between 0 and 1.`);
16
+ for (const field of ['attackSeconds', 'releaseSeconds']) if (!Number.isFinite(rule[field] ?? 0) || (rule[field] ?? 0) < 0) throw new TypeError(`Audio ducking rule ${index + 1} ${field} must be non-negative.`);
17
+ }
18
+ for (const [index, rule] of (mix.sidechain ?? []).entries()) {
19
+ const source = tracks.get(rule?.sourceTrackId);
20
+ const target = tracks.get(rule?.targetTrackId);
21
+ if (!source || !target || source.kind !== 'audio' || target.kind !== 'audio' || source === target
22
+ || source.clips.length === 0 || target.clips.length === 0) {
23
+ throw new TypeError(`Audio sidechain rule ${index + 1} must reference two different non-empty audio tracks.`);
24
+ }
25
+ const settings = resolveAudioSidechainSettings(rule);
26
+ if (!Number.isFinite(settings.thresholdDb) || settings.thresholdDb < -60 || settings.thresholdDb > 0) throw new TypeError(`Audio sidechain rule ${index + 1} thresholdDb must be between -60 and 0.`);
27
+ if (!Number.isFinite(settings.ratio) || settings.ratio < 1 || settings.ratio > 20) throw new TypeError(`Audio sidechain rule ${index + 1} ratio must be between 1 and 20.`);
28
+ if (!Number.isFinite(settings.attackSeconds) || settings.attackSeconds < 0.00001 || settings.attackSeconds > 2) throw new TypeError(`Audio sidechain rule ${index + 1} attackSeconds must be between 0.00001 and 2.`);
29
+ if (!Number.isFinite(settings.releaseSeconds) || settings.releaseSeconds < 0.00001 || settings.releaseSeconds > 9) throw new TypeError(`Audio sidechain rule ${index + 1} releaseSeconds must be between 0.00001 and 9.`);
30
+ for (const field of Object.keys(rule)) {
31
+ if (!['sourceTrackId', 'targetTrackId', 'thresholdDb', 'ratio', 'attackSeconds', 'releaseSeconds'].includes(field)) {
32
+ throw new TypeError(`Audio sidechain rule ${index + 1} has an unsupported field: ${field}.`);
33
+ }
34
+ }
35
+ }
36
+ return timeline;
37
+ }
38
+
39
+ export function resolveAudioSidechainSettings(rule) {
40
+ return {
41
+ thresholdDb: rule.thresholdDb ?? -24,
42
+ ratio: rule.ratio ?? 4,
43
+ attackSeconds: rule.attackSeconds ?? 0.01,
44
+ releaseSeconds: rule.releaseSeconds ?? 0.25,
45
+ };
46
+ }
47
+
48
+ export function buildAudioVolumeAutomation(timeline, track, clip) {
49
+ const start = clip.timelineStart;
50
+ const end = start + clip.durationSeconds;
51
+ const baseVolume = (clip.volume ?? 1) * (track.volume ?? 1);
52
+ const duckRamps = [];
53
+ for (const rule of timeline.audioMix?.ducking ?? []) {
54
+ if (rule.targetTrackId !== track.id) continue;
55
+ const source = timeline.tracks.find((item) => item.id === rule.sourceTrackId);
56
+ for (const sourceClip of source._duckingIntervals ?? source.clips) {
57
+ const duckStart = Math.max(start, sourceClip.timelineStart);
58
+ const duckEnd = Math.min(end, sourceClip.timelineStart + sourceClip.durationSeconds);
59
+ if (duckEnd <= duckStart) continue;
60
+ duckRamps.push({
61
+ start: duckStart,
62
+ end: duckEnd,
63
+ attenuation: rule.attenuation,
64
+ attackSeconds: rule.attackSeconds ?? 0,
65
+ releaseSeconds: rule.releaseSeconds ?? 0,
66
+ });
67
+ }
68
+ }
69
+
70
+ const points = new Set([start, end]);
71
+ const fadeIn = clip.fadeInSeconds ?? 0;
72
+ const fadeOut = clip.fadeOutSeconds ?? 0;
73
+ if (fadeIn > 0) points.add(start + fadeIn);
74
+ if (fadeOut > 0) points.add(end - fadeOut);
75
+ for (const keyframe of clip.volumeKeyframes ?? []) points.add(start + keyframe.time);
76
+ for (const keyframe of track.volumeKeyframes ?? []) {
77
+ if (keyframe.time > start && keyframe.time < end) points.add(keyframe.time);
78
+ }
79
+ for (const ramp of duckRamps) {
80
+ points.add(Math.max(start, ramp.start - ramp.attackSeconds));
81
+ points.add(ramp.start);
82
+ points.add(ramp.end);
83
+ points.add(Math.min(end, ramp.end + ramp.releaseSeconds));
84
+ }
85
+
86
+ return [...points].sort((a, b) => a - b).map((time) => {
87
+ let volume = baseVolume;
88
+ volume *= sampleAudioVolumeKeyframes(track.volumeKeyframes, time);
89
+ volume *= sampleAudioVolumeKeyframes(clip.volumeKeyframes, time - start);
90
+ if (fadeIn > 0 && time < start + fadeIn) volume *= Math.max(0, (time - start) / fadeIn);
91
+ if (fadeOut > 0 && time > end - fadeOut) volume *= Math.max(0, (end - time) / fadeOut);
92
+ for (const ramp of duckRamps) volume *= duckFactorAt(time, ramp);
93
+ return { time, volume: Number(volume.toFixed(6)) };
94
+ });
95
+ }
96
+
97
+ function duckFactorAt(time, ramp) {
98
+ if (time < ramp.start) {
99
+ const attackStart = ramp.start - ramp.attackSeconds;
100
+ if (ramp.attackSeconds === 0 || time <= attackStart) return 1;
101
+ return 1 - ((time - attackStart) / ramp.attackSeconds) * (1 - ramp.attenuation);
102
+ }
103
+ if (time <= ramp.end || ramp.releaseSeconds === 0 || time >= ramp.end + ramp.releaseSeconds) return time <= ramp.end ? ramp.attenuation : 1;
104
+ return ramp.attenuation + ((time - ramp.end) / ramp.releaseSeconds) * (1 - ramp.attenuation);
105
+ }
@@ -1,139 +1,139 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { spawnSync } from 'node:child_process';
3
- import { rmSync } from 'node:fs';
4
- import { join } from 'node:path';
5
- import { runProcess } from '../render/process.mjs';
6
- import { buildAudioClipEffectFilters } from './audio-effects.mjs';
7
- import { buildAudioVolumeAutomation } from './audio-mix.mjs';
8
-
9
- export function applyAudioTrackEffectProxies(root, timeline, temporaryAssets) {
10
- const context = prepareAudioTrackEffectProxies(root, timeline, temporaryAssets);
11
- try {
12
- for (const job of context.jobs) {
13
- const result = spawnSync('ffmpeg', job.args, {
14
- encoding: 'utf8',
15
- windowsHide: true,
16
- cwd: root,
17
- maxBuffer: 4 * 1024 * 1024,
18
- });
19
- if (result.error || result.status !== 0) {
20
- throw new Error(`Could not apply audio effects to track ${job.trackId}: ${result.error?.message ?? result.stderr.trim()}`);
21
- }
22
- }
23
- return context.timeline;
24
- } catch (error) {
25
- removePreparedAssets(temporaryAssets, context.startIndex);
26
- throw error;
27
- }
28
- }
29
-
30
- export async function applyAudioTrackEffectProxiesAsync(root, timeline, temporaryAssets, renderControl) {
31
- const context = prepareAudioTrackEffectProxies(root, timeline, temporaryAssets);
32
- try {
33
- for (const job of context.jobs) {
34
- renderControl.assertActive();
35
- try {
36
- await runProcess('ffmpeg', job.args, { cwd: root, signal: renderControl.signal, stdio: 'capture' });
37
- } catch (error) {
38
- if (renderControl.signal.aborted) throw renderControl.signal.reason ?? error;
39
- throw new Error(`Could not apply audio effects to track ${job.trackId}: ${error.message}`, { cause: error });
40
- }
41
- renderControl.assertActive();
42
- }
43
- return context.timeline;
44
- } catch (error) {
45
- removePreparedAssets(temporaryAssets, context.startIndex);
46
- throw error;
47
- }
48
- }
49
-
50
- function prepareAudioTrackEffectProxies(root, timeline, temporaryAssets) {
51
- const next = structuredClone(timeline);
52
- const startIndex = temporaryAssets.length;
53
- const jobs = [];
54
- try {
55
- for (const track of next.tracks) {
56
- if (track.kind !== 'audio' || !track.audioEffects?.length || track.clips.length === 0) continue;
57
- const sourceClips = track.clips.map((clip) => ({ timelineStart: clip.timelineStart, durationSeconds: clip.durationSeconds }));
58
- const labels = [];
59
- const filters = [];
60
- const args = [];
61
- for (const [index, clip] of track.clips.entries()) {
62
- const asset = next.assets.find((entry) => entry.id === clip.assetId);
63
- if (!asset) throw new TypeError(`Track ${track.id} clip ${clip.id} is missing its audio asset.`);
64
- args.push('-i', join(root, ...asset.path.split('/')));
65
- const automation = buildAudioVolumeAutomation(next, track, clip);
66
- const label = `trackclip${index}`;
67
- labels.push(`[${label}]`);
68
- const steps = [
69
- `atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
70
- 'asetpts=PTS-STARTPTS',
71
- 'aresample=48000',
72
- 'aformat=sample_fmts=fltp:channel_layouts=stereo',
73
- `volume='${toVolumeExpression(automation, clip.timelineStart)}':eval=frame`,
74
- `adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`,
75
- ];
76
- filters.push(`[${index}:a:${clip.audioStreamIndex ?? 0}]${steps.join(',')}[${label}]`);
77
- }
78
- const effectFilters = buildAudioClipEffectFilters(track.audioEffects);
79
- const outputLabel = 'trackfxout';
80
- filters.push(`${labels.join('')}amix=inputs=${labels.length}:duration=longest:normalize=0,apad=whole_dur=${next.project.durationSeconds},atrim=duration=${next.project.durationSeconds},asetpts=PTS-STARTPTS${effectFilters.length ? `,${effectFilters.join(',')}` : ''}[${outputLabel}]`);
81
- const id = `track-effects-${track.id}-${randomUUID().slice(0, 8)}`;
82
- const relativePath = `assets/.${id}-${randomUUID()}.wav`;
83
- const absolutePath = join(root, ...relativePath.split('/'));
84
- temporaryAssets.push(absolutePath);
85
- jobs.push({
86
- trackId: track.id,
87
- args: [
88
- '-nostdin', '-hide_banner', '-v', 'error', '-y', '-filter_threads', '1', '-filter_complex_threads', '1',
89
- ...args, '-filter_complex', filters.join(';'), '-map', `[${outputLabel}]`, '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath,
90
- ],
91
- });
92
- next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: next.project.durationSeconds });
93
- track.clips = [{
94
- id: `processed-${track.id}`,
95
- assetId: id,
96
- sourceIn: 0,
97
- sourceOut: next.project.durationSeconds,
98
- timelineStart: 0,
99
- durationSeconds: next.project.durationSeconds,
100
- }];
101
- track.volume = 1;
102
- delete track.volumeKeyframes;
103
- delete track.audioEffects;
104
- track._duckingIntervals = sourceClips;
105
- }
106
-
107
- const effectedTrackIds = new Set(next.tracks.filter((track) => track.kind === 'audio' && track._duckingIntervals).map((track) => track.id));
108
- if (next.audioMix?.ducking) {
109
- const ducking = next.audioMix.ducking.filter((rule) => !effectedTrackIds.has(rule.targetTrackId));
110
- if (ducking.length === 0) delete next.audioMix.ducking;
111
- else next.audioMix.ducking = ducking;
112
- if (Object.keys(next.audioMix).length === 0) delete next.audioMix;
113
- }
114
- return { timeline: next, jobs, startIndex };
115
- } catch (error) {
116
- removePreparedAssets(temporaryAssets, startIndex);
117
- throw error;
118
- }
119
- }
120
-
121
- function removePreparedAssets(temporaryAssets, startIndex) {
122
- for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
123
- }
124
-
125
- function toVolumeExpression(points, clipStart) {
126
- if (points.length === 0) return '1';
127
- let expression = String(points.at(-1).volume);
128
- for (let index = points.length - 2; index >= 0; index--) {
129
- const from = points[index];
130
- const to = points[index + 1];
131
- const fromTime = from.time - clipStart;
132
- const toTime = to.time - clipStart;
133
- const span = toTime - fromTime;
134
- if (span <= 0) continue;
135
- const linear = `(${from.volume}+(${to.volume - from.volume})*(t-${fromTime})/${span})`;
136
- expression = `if(lt(t\\,${toTime})\\,${linear}\\,${expression})`;
137
- }
138
- return expression;
139
- }
1
+ import { randomUUID } from 'node:crypto';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { runProcess } from '../render/process.mjs';
6
+ import { buildAudioClipEffectFilters } from './audio-effects.mjs';
7
+ import { buildAudioVolumeAutomation } from './audio-mix.mjs';
8
+
9
+ export function applyAudioTrackEffectProxies(root, timeline, temporaryAssets) {
10
+ const context = prepareAudioTrackEffectProxies(root, timeline, temporaryAssets);
11
+ try {
12
+ for (const job of context.jobs) {
13
+ const result = spawnSync('ffmpeg', job.args, {
14
+ encoding: 'utf8',
15
+ windowsHide: true,
16
+ cwd: root,
17
+ maxBuffer: 4 * 1024 * 1024,
18
+ });
19
+ if (result.error || result.status !== 0) {
20
+ throw new Error(`Could not apply audio effects to track ${job.trackId}: ${result.error?.message ?? result.stderr.trim()}`);
21
+ }
22
+ }
23
+ return context.timeline;
24
+ } catch (error) {
25
+ removePreparedAssets(temporaryAssets, context.startIndex);
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ export async function applyAudioTrackEffectProxiesAsync(root, timeline, temporaryAssets, renderControl) {
31
+ const context = prepareAudioTrackEffectProxies(root, timeline, temporaryAssets);
32
+ try {
33
+ for (const job of context.jobs) {
34
+ renderControl.assertActive();
35
+ try {
36
+ await runProcess('ffmpeg', job.args, { cwd: root, signal: renderControl.signal, stdio: 'capture' });
37
+ } catch (error) {
38
+ if (renderControl.signal.aborted) throw renderControl.signal.reason ?? error;
39
+ throw new Error(`Could not apply audio effects to track ${job.trackId}: ${error.message}`, { cause: error });
40
+ }
41
+ renderControl.assertActive();
42
+ }
43
+ return context.timeline;
44
+ } catch (error) {
45
+ removePreparedAssets(temporaryAssets, context.startIndex);
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ function prepareAudioTrackEffectProxies(root, timeline, temporaryAssets) {
51
+ const next = structuredClone(timeline);
52
+ const startIndex = temporaryAssets.length;
53
+ const jobs = [];
54
+ try {
55
+ for (const track of next.tracks) {
56
+ if (track.kind !== 'audio' || !track.audioEffects?.length || track.clips.length === 0) continue;
57
+ const sourceClips = track.clips.map((clip) => ({ timelineStart: clip.timelineStart, durationSeconds: clip.durationSeconds }));
58
+ const labels = [];
59
+ const filters = [];
60
+ const args = [];
61
+ for (const [index, clip] of track.clips.entries()) {
62
+ const asset = next.assets.find((entry) => entry.id === clip.assetId);
63
+ if (!asset) throw new TypeError(`Track ${track.id} clip ${clip.id} is missing its audio asset.`);
64
+ args.push('-i', join(root, ...asset.path.split('/')));
65
+ const automation = buildAudioVolumeAutomation(next, track, clip);
66
+ const label = `trackclip${index}`;
67
+ labels.push(`[${label}]`);
68
+ const steps = [
69
+ `atrim=start=${clip.sourceIn}:end=${clip.sourceOut}`,
70
+ 'asetpts=PTS-STARTPTS',
71
+ 'aresample=48000',
72
+ 'aformat=sample_fmts=fltp:channel_layouts=stereo',
73
+ `volume='${toVolumeExpression(automation, clip.timelineStart)}':eval=frame`,
74
+ `adelay=delays=${Math.round(clip.timelineStart * 1000)}:all=1`,
75
+ ];
76
+ filters.push(`[${index}:a:${clip.audioStreamIndex ?? 0}]${steps.join(',')}[${label}]`);
77
+ }
78
+ const effectFilters = buildAudioClipEffectFilters(track.audioEffects);
79
+ const outputLabel = 'trackfxout';
80
+ filters.push(`${labels.join('')}amix=inputs=${labels.length}:duration=longest:normalize=0,apad=whole_dur=${next.project.durationSeconds},atrim=duration=${next.project.durationSeconds},asetpts=PTS-STARTPTS${effectFilters.length ? `,${effectFilters.join(',')}` : ''}[${outputLabel}]`);
81
+ const id = `track-effects-${track.id}-${randomUUID().slice(0, 8)}`;
82
+ const relativePath = `assets/.${id}-${randomUUID()}.wav`;
83
+ const absolutePath = join(root, ...relativePath.split('/'));
84
+ temporaryAssets.push(absolutePath);
85
+ jobs.push({
86
+ trackId: track.id,
87
+ args: [
88
+ '-nostdin', '-hide_banner', '-v', 'error', '-y', '-filter_threads', '1', '-filter_complex_threads', '1',
89
+ ...args, '-filter_complex', filters.join(';'), '-map', `[${outputLabel}]`, '-vn', '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le', absolutePath,
90
+ ],
91
+ });
92
+ next.assets.push({ id, path: relativePath, type: 'audio', hasAudio: true, durationSeconds: next.project.durationSeconds });
93
+ track.clips = [{
94
+ id: `processed-${track.id}`,
95
+ assetId: id,
96
+ sourceIn: 0,
97
+ sourceOut: next.project.durationSeconds,
98
+ timelineStart: 0,
99
+ durationSeconds: next.project.durationSeconds,
100
+ }];
101
+ track.volume = 1;
102
+ delete track.volumeKeyframes;
103
+ delete track.audioEffects;
104
+ track._duckingIntervals = sourceClips;
105
+ }
106
+
107
+ const effectedTrackIds = new Set(next.tracks.filter((track) => track.kind === 'audio' && track._duckingIntervals).map((track) => track.id));
108
+ if (next.audioMix?.ducking) {
109
+ const ducking = next.audioMix.ducking.filter((rule) => !effectedTrackIds.has(rule.targetTrackId));
110
+ if (ducking.length === 0) delete next.audioMix.ducking;
111
+ else next.audioMix.ducking = ducking;
112
+ if (Object.keys(next.audioMix).length === 0) delete next.audioMix;
113
+ }
114
+ return { timeline: next, jobs, startIndex };
115
+ } catch (error) {
116
+ removePreparedAssets(temporaryAssets, startIndex);
117
+ throw error;
118
+ }
119
+ }
120
+
121
+ function removePreparedAssets(temporaryAssets, startIndex) {
122
+ for (const asset of temporaryAssets.splice(startIndex)) rmSync(asset, { force: true });
123
+ }
124
+
125
+ function toVolumeExpression(points, clipStart) {
126
+ if (points.length === 0) return '1';
127
+ let expression = String(points.at(-1).volume);
128
+ for (let index = points.length - 2; index >= 0; index--) {
129
+ const from = points[index];
130
+ const to = points[index + 1];
131
+ const fromTime = from.time - clipStart;
132
+ const toTime = to.time - clipStart;
133
+ const span = toTime - fromTime;
134
+ if (span <= 0) continue;
135
+ const linear = `(${from.volume}+(${to.volume - from.volume})*(t-${fromTime})/${span})`;
136
+ expression = `if(lt(t\\,${toTime})\\,${linear}\\,${expression})`;
137
+ }
138
+ return expression;
139
+ }
@@ -1,63 +1,63 @@
1
- export function validateAudioVolumeKeyframes(keyframes, clip) {
2
- if (keyframes === undefined) return;
3
- if (!Array.isArray(keyframes) || keyframes.length === 0 || keyframes.length > 512) {
4
- throw new TypeError(`Clip ${clip.id} volumeKeyframes must be an array with 1 to 512 entries.`);
5
- }
6
- if (keyframes[0]?.time !== 0) throw new TypeError(`Clip ${clip.id} volumeKeyframes must start at time 0.`);
7
- let previousTime = -1;
8
- for (const [index, keyframe] of keyframes.entries()) {
9
- if (!keyframe || typeof keyframe !== 'object' || Array.isArray(keyframe)
10
- || Object.keys(keyframe).some((field) => !['time', 'volume'].includes(field))
11
- || !Number.isFinite(keyframe.time) || keyframe.time < 0 || keyframe.time > clip.durationSeconds
12
- || keyframe.time <= previousTime
13
- || !Number.isFinite(keyframe.volume) || keyframe.volume < 0 || keyframe.volume > 1) {
14
- throw new TypeError(`Clip ${clip.id} volumeKeyframes entry ${index + 1} must have an increasing time and volume from 0 to 1 within the clip.`);
15
- }
16
- previousTime = keyframe.time;
17
- }
18
- }
19
-
20
- export function sampleAudioVolumeKeyframes(keyframes = [], time) {
21
- if (keyframes.length === 0) return 1;
22
- if (time <= keyframes[0].time) return keyframes[0].volume;
23
- for (let index = 1; index < keyframes.length; index++) {
24
- const next = keyframes[index];
25
- if (time > next.time) continue;
26
- const previous = keyframes[index - 1];
27
- const amount = (time - previous.time) / (next.time - previous.time);
28
- return previous.volume + (next.volume - previous.volume) * amount;
29
- }
30
- return keyframes.at(-1).volume;
31
- }
32
-
33
- export function validateAudioTrackVolumeKeyframes(keyframes, track, projectDurationSeconds) {
34
- if (keyframes === undefined) return;
35
- if (track.kind !== 'audio') throw new TypeError(`Track ${track.id} volumeKeyframes are supported only on audio tracks.`);
36
- if (!Array.isArray(keyframes) || keyframes.length === 0 || keyframes.length > 512) {
37
- throw new TypeError(`Track ${track.id} volumeKeyframes must be an array with 1 to 512 entries.`);
38
- }
39
- if (keyframes[0]?.time !== 0) throw new TypeError(`Track ${track.id} volumeKeyframes must start at time 0.`);
40
- let previousTime = -1;
41
- for (const [index, keyframe] of keyframes.entries()) {
42
- if (!keyframe || typeof keyframe !== 'object' || Array.isArray(keyframe)
43
- || Object.keys(keyframe).some((field) => !['time', 'volume'].includes(field))
44
- || !Number.isFinite(keyframe.time) || keyframe.time < 0 || keyframe.time > projectDurationSeconds
45
- || keyframe.time <= previousTime
46
- || !Number.isFinite(keyframe.volume) || keyframe.volume < 0 || keyframe.volume > 1) {
47
- throw new TypeError(`Track ${track.id} volumeKeyframes entry ${index + 1} must have an increasing project time and volume from 0 to 1.`);
48
- }
49
- previousTime = keyframe.time;
50
- }
51
- }
52
-
53
- export function sliceAudioVolumeKeyframes(keyframes, start, end) {
54
- if (!keyframes) return undefined;
55
- const sliced = [{ time: 0, volume: sampleAudioVolumeKeyframes(keyframes, start) }];
56
- for (const keyframe of keyframes) {
57
- if (keyframe.time > start && keyframe.time < end) {
58
- sliced.push({ time: keyframe.time - start, volume: keyframe.volume });
59
- }
60
- }
61
- if (end > start) sliced.push({ time: end - start, volume: sampleAudioVolumeKeyframes(keyframes, end) });
62
- return sliced;
63
- }
1
+ export function validateAudioVolumeKeyframes(keyframes, clip) {
2
+ if (keyframes === undefined) return;
3
+ if (!Array.isArray(keyframes) || keyframes.length === 0 || keyframes.length > 512) {
4
+ throw new TypeError(`Clip ${clip.id} volumeKeyframes must be an array with 1 to 512 entries.`);
5
+ }
6
+ if (keyframes[0]?.time !== 0) throw new TypeError(`Clip ${clip.id} volumeKeyframes must start at time 0.`);
7
+ let previousTime = -1;
8
+ for (const [index, keyframe] of keyframes.entries()) {
9
+ if (!keyframe || typeof keyframe !== 'object' || Array.isArray(keyframe)
10
+ || Object.keys(keyframe).some((field) => !['time', 'volume'].includes(field))
11
+ || !Number.isFinite(keyframe.time) || keyframe.time < 0 || keyframe.time > clip.durationSeconds
12
+ || keyframe.time <= previousTime
13
+ || !Number.isFinite(keyframe.volume) || keyframe.volume < 0 || keyframe.volume > 1) {
14
+ throw new TypeError(`Clip ${clip.id} volumeKeyframes entry ${index + 1} must have an increasing time and volume from 0 to 1 within the clip.`);
15
+ }
16
+ previousTime = keyframe.time;
17
+ }
18
+ }
19
+
20
+ export function sampleAudioVolumeKeyframes(keyframes = [], time) {
21
+ if (keyframes.length === 0) return 1;
22
+ if (time <= keyframes[0].time) return keyframes[0].volume;
23
+ for (let index = 1; index < keyframes.length; index++) {
24
+ const next = keyframes[index];
25
+ if (time > next.time) continue;
26
+ const previous = keyframes[index - 1];
27
+ const amount = (time - previous.time) / (next.time - previous.time);
28
+ return previous.volume + (next.volume - previous.volume) * amount;
29
+ }
30
+ return keyframes.at(-1).volume;
31
+ }
32
+
33
+ export function validateAudioTrackVolumeKeyframes(keyframes, track, projectDurationSeconds) {
34
+ if (keyframes === undefined) return;
35
+ if (track.kind !== 'audio') throw new TypeError(`Track ${track.id} volumeKeyframes are supported only on audio tracks.`);
36
+ if (!Array.isArray(keyframes) || keyframes.length === 0 || keyframes.length > 512) {
37
+ throw new TypeError(`Track ${track.id} volumeKeyframes must be an array with 1 to 512 entries.`);
38
+ }
39
+ if (keyframes[0]?.time !== 0) throw new TypeError(`Track ${track.id} volumeKeyframes must start at time 0.`);
40
+ let previousTime = -1;
41
+ for (const [index, keyframe] of keyframes.entries()) {
42
+ if (!keyframe || typeof keyframe !== 'object' || Array.isArray(keyframe)
43
+ || Object.keys(keyframe).some((field) => !['time', 'volume'].includes(field))
44
+ || !Number.isFinite(keyframe.time) || keyframe.time < 0 || keyframe.time > projectDurationSeconds
45
+ || keyframe.time <= previousTime
46
+ || !Number.isFinite(keyframe.volume) || keyframe.volume < 0 || keyframe.volume > 1) {
47
+ throw new TypeError(`Track ${track.id} volumeKeyframes entry ${index + 1} must have an increasing project time and volume from 0 to 1.`);
48
+ }
49
+ previousTime = keyframe.time;
50
+ }
51
+ }
52
+
53
+ export function sliceAudioVolumeKeyframes(keyframes, start, end) {
54
+ if (!keyframes) return undefined;
55
+ const sliced = [{ time: 0, volume: sampleAudioVolumeKeyframes(keyframes, start) }];
56
+ for (const keyframe of keyframes) {
57
+ if (keyframe.time > start && keyframe.time < end) {
58
+ sliced.push({ time: keyframe.time - start, volume: keyframe.volume });
59
+ }
60
+ }
61
+ if (end > start) sliced.push({ time: end - start, volume: sampleAudioVolumeKeyframes(keyframes, end) });
62
+ return sliced;
63
+ }
@@ -1,8 +1,8 @@
1
- export const DEFAULT_PROJECT_BACKGROUND_COLOR = '#10141c';
2
-
3
- export function validateProjectBackgroundColor(value, label = 'Project backgroundColor') {
4
- if (value !== null && (typeof value !== 'string' || !/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))) {
5
- throw new TypeError(`${label} must be a 3, 4, 6, or 8 digit hex color, or null for transparent.`);
6
- }
7
- return value;
8
- }
1
+ export const DEFAULT_PROJECT_BACKGROUND_COLOR = '#10141c';
2
+
3
+ export function validateProjectBackgroundColor(value, label = 'Project backgroundColor') {
4
+ if (value !== null && (typeof value !== 'string' || !/^#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.test(value))) {
5
+ throw new TypeError(`${label} must be a 3, 4, 6, or 8 digit hex color, or null for transparent.`);
6
+ }
7
+ return value;
8
+ }