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,965 +1,965 @@
1
- import { isDeepStrictEqual } from 'node:util';
2
- import { validateTimeline } from './validate.mjs';
3
- import { sliceVisualKeyframes } from './keyframes.mjs';
4
- import { sliceSpeedRamp, speedRampDuration, speedRampSourceAtTime } from './speed-ramp.mjs';
5
- import { validateClipTransition } from './transition.mjs';
6
- import { frameRateNumber } from '../media/frame-rate.mjs';
7
- import { sampleAudioVolumeKeyframes, sliceAudioVolumeKeyframes } from './audio-volume.mjs';
8
-
9
- export function addTimelineTrack(timeline, track) {
10
- if (!track || typeof track !== 'object' || Array.isArray(track)) throw new TypeError('Timeline track must be an object.');
11
- const next = structuredClone(timeline);
12
- if (next.tracks.some((candidate) => candidate.id === track.id)) throw new TypeError(`Timeline track already exists: ${track.id}`);
13
- next.tracks.push(structuredClone(track));
14
- return validateTimeline(next);
15
- }
16
-
17
- export function removeTimelineTrack(timeline, trackId) {
18
- const next = structuredClone(timeline);
19
- const index = next.tracks.findIndex((candidate) => candidate.id === trackId);
20
- if (index < 0) throw new TypeError(`Unknown timeline track: ${trackId}`);
21
- assertTrackEditable(next.tracks[index]);
22
- next.tracks.splice(index, 1);
23
- return validateTimeline(next);
24
- }
25
-
26
- /** Add or replace a timeline review marker by its stable id. */
27
- export function setTimelineMarker(timeline, marker) {
28
- validateTimeline(timeline);
29
- if (!marker || typeof marker !== 'object' || Array.isArray(marker)) throw new TypeError('Timeline marker must be an object.');
30
- const next = structuredClone(timeline);
31
- next.markers ??= [];
32
- const index = next.markers.findIndex((entry) => entry.id === marker.id);
33
- if (index < 0) next.markers.push(structuredClone(marker));
34
- else next.markers[index] = structuredClone(marker);
35
- return validateTimeline(next);
36
- }
37
-
38
- /** Remove a timeline review marker by its stable id. */
39
- export function removeTimelineMarker(timeline, markerId) {
40
- validateTimeline(timeline);
41
- if (typeof markerId !== 'string' || !/^[a-z][a-z0-9-]*$/.test(markerId)) throw new TypeError('Timeline marker id must be a kebab-case id.');
42
- const next = structuredClone(timeline);
43
- const index = (next.markers ?? []).findIndex((marker) => marker.id === markerId);
44
- if (index < 0) throw new TypeError(`Unknown timeline marker: ${markerId}`);
45
- next.markers.splice(index, 1);
46
- return validateTimeline(next);
47
- }
48
-
49
- /** Create a renderable, non-destructive timeline window from project time. */
50
- export function extractTimelineRange(timeline, startSeconds, endSeconds) {
51
- validateTimeline(timeline);
52
- if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds)
53
- || startSeconds < 0 || endSeconds <= startSeconds
54
- || endSeconds > timeline.project.durationSeconds + 1e-6) {
55
- throw new TypeError('Timeline range must be a positive time range within the project duration.');
56
- }
57
- const end = Math.min(endSeconds, timeline.project.durationSeconds);
58
- const durationSeconds = end - startSeconds;
59
- if (durationSeconds < 1 / frameRateNumber(timeline.project.fps) - 1e-9) {
60
- throw new TypeError('Timeline range must include at least one project frame.');
61
- }
62
- const next = structuredClone(timeline);
63
- next.project.durationSeconds = durationSeconds;
64
- if (next.markers) {
65
- next.markers = next.markers
66
- .filter((marker) => marker.timeSeconds >= startSeconds && marker.timeSeconds <= end)
67
- .map((marker) => ({ ...marker, timeSeconds: marker.timeSeconds - startSeconds }));
68
- }
69
- for (const track of next.tracks) {
70
- if (track.volumeKeyframes) track.volumeKeyframes = sliceAudioVolumeKeyframes(track.volumeKeyframes, startSeconds, end);
71
- track.clips = track.clips.flatMap((clip) => {
72
- const clipStart = clip.timelineStart;
73
- const clipEnd = clipStart + clip.durationSeconds;
74
- const overlapStart = Math.max(startSeconds, clipStart);
75
- const overlapEnd = Math.min(end, clipEnd);
76
- if (overlapEnd <= overlapStart) return [];
77
- const localStart = overlapStart - clipStart;
78
- const localEnd = overlapEnd - clipStart;
79
- assertRangeDoesNotCutEdgeEffects(clip, localStart, localEnd);
80
- const segment = sliceTimelineClip(clip, track.kind, localStart, localEnd, overlapStart - startSeconds);
81
- if (!segment) return [];
82
- return [segment];
83
- });
84
- }
85
- return validateTimeline(next);
86
- }
87
-
88
- function assertRangeDoesNotCutEdgeEffects(clip, localStart, localEnd) {
89
- if (clip.fadeInSeconds !== undefined && localStart > 0 && localStart < clip.fadeInSeconds) {
90
- throw new TypeError(`Render range cuts through clip ${clip.id}'s fade-in; start the range before or after the fade.`);
91
- }
92
- if (clip.fadeOutSeconds !== undefined && localEnd < clip.durationSeconds
93
- && localEnd > clip.durationSeconds - clip.fadeOutSeconds) {
94
- throw new TypeError(`Render range cuts through clip ${clip.id}'s fade-out; end the range before or after the fade.`);
95
- }
96
- if (clip.transitionIn && localStart > 0 && localStart < clip.transitionIn.durationSeconds) {
97
- throw new TypeError(`Render range cuts through clip ${clip.id}'s incoming transition; start the range before or after it.`);
98
- }
99
- if (clip.transitionOut && localEnd < clip.durationSeconds
100
- && localEnd > clip.durationSeconds - clip.transitionOut.durationSeconds) {
101
- throw new TypeError(`Render range cuts through clip ${clip.id}'s outgoing transition; end the range before or after it.`);
102
- }
103
- }
104
-
105
- export function reorderTimelineTrack(timeline, trackId, order) {
106
- validateTimeline(timeline);
107
- if (!Number.isSafeInteger(order) || order < 0 || order >= timeline.tracks.length) {
108
- throw new TypeError(`Track order must be an integer from 0 to ${timeline.tracks.length - 1}.`);
109
- }
110
- const next = structuredClone(timeline);
111
- const orderedTracks = next.tracks.sort((left, right) => left.order - right.order);
112
- const currentIndex = orderedTracks.findIndex((track) => track.id === trackId);
113
- if (currentIndex < 0) throw new TypeError(`Unknown timeline track: ${trackId}`);
114
- const [track] = orderedTracks.splice(currentIndex, 1);
115
- orderedTracks.splice(order, 0, track);
116
- orderedTracks.forEach((candidate, index) => { candidate.order = index; });
117
- const previousOrders = new Map(timeline.tracks.map((candidate) => [candidate.id, candidate.order]));
118
- for (const candidate of orderedTracks) {
119
- if (candidate.locked === true && candidate.order !== previousOrders.get(candidate.id)) {
120
- throw new TypeError(`Track ${candidate.id} is locked and cannot be reordered.`);
121
- }
122
- }
123
- next.tracks = orderedTracks;
124
- return validateTimeline(next);
125
- }
126
-
127
- export function addTimelineClip(timeline, trackId, clip) {
128
- if (!clip || typeof clip !== 'object' || Array.isArray(clip)) throw new TypeError('Timeline clip must be an object.');
129
- const next = structuredClone(timeline);
130
- const track = next.tracks.find((candidate) => candidate.id === trackId);
131
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
132
- assertTrackEditable(track);
133
- if (track.clips.some((candidate) => candidate.id === clip.id)) throw new TypeError(`Timeline clip already exists on track ${trackId}: ${clip.id}`);
134
- track.clips.push(structuredClone(clip));
135
- next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
136
- return validateTimeline(next);
137
- }
138
-
139
- export function duplicateTimelineClip(timeline, trackId, clipId, newClipId, timelineStart) {
140
- if (!/^[a-z][a-z0-9-]*$/.test(newClipId ?? '')) throw new TypeError('newClipId must be a lowercase kebab-case id.');
141
- if (!Number.isFinite(timelineStart) || timelineStart < 0) throw new TypeError('timelineStart must be a non-negative number.');
142
- const { clip } = findClipEntry(timeline, trackId, clipId);
143
- return addTimelineClip(timeline, trackId, {
144
- ...structuredClone(clip),
145
- id: newClipId,
146
- timelineStart,
147
- });
148
- }
149
-
150
- export function detachAudioFromTimelineClip(timeline, videoTrackId, videoClipId, audioTrackId, audioClipId, audioStreamIndex) {
151
- if (!/^[a-z][a-z0-9-]*$/.test(audioTrackId ?? '') || !/^[a-z][a-z0-9-]*$/.test(audioClipId ?? '')) {
152
- throw new TypeError('Detached audio track and clip ids must be kebab-case ids.');
153
- }
154
- const next = structuredClone(timeline);
155
- const videoTrack = next.tracks.find((track) => track.id === videoTrackId);
156
- if (!videoTrack || videoTrack.kind !== 'video') throw new TypeError(`Unknown video track: ${videoTrackId}`);
157
- assertTrackEditable(videoTrack);
158
- const videoClip = videoTrack.clips.find((clip) => clip.id === videoClipId);
159
- if (!videoClip) throw new TypeError(`Unknown clip ${videoClipId} on track ${videoTrackId}.`);
160
- const asset = next.assets.find((item) => item.id === videoClip.assetId);
161
- if (asset?.type === 'image' || asset?.hasAudio === false) {
162
- throw new TypeError(`Video clip ${videoClipId} references media without an audio stream.`);
163
- }
164
-
165
- let audioTrack = next.tracks.find((track) => track.id === audioTrackId);
166
- if (audioTrack && audioTrack.kind !== 'audio') throw new TypeError(`Track ${audioTrackId} is not an audio track.`);
167
- if (audioTrack) assertTrackEditable(audioTrack);
168
- if (!audioTrack) {
169
- if (next.tracks.some((track) => track.id === audioTrackId)) throw new TypeError(`Timeline track already exists: ${audioTrackId}`);
170
- const order = next.tracks.reduce((maximum, track) => Math.max(maximum, track.order), -1) + 1;
171
- audioTrack = { id: audioTrackId, kind: 'audio', order, clips: [] };
172
- next.tracks.push(audioTrack);
173
- }
174
- if (audioTrack.clips.some((clip) => clip.id === audioClipId)) {
175
- throw new TypeError(`Timeline clip already exists on track ${audioTrackId}: ${audioClipId}`);
176
- }
177
-
178
- const audioClip = {
179
- id: audioClipId,
180
- assetId: videoClip.assetId,
181
- sourceIn: videoClip.sourceIn,
182
- sourceOut: videoClip.sourceOut,
183
- timelineStart: videoClip.timelineStart,
184
- durationSeconds: videoClip.durationSeconds,
185
- ...(audioStreamIndex === undefined ? {} : { audioStreamIndex }),
186
- ...(videoClip.reverse === true ? { reverse: true } : {}),
187
- ...(videoClip.playbackRate === undefined ? {} : { playbackRate: videoClip.playbackRate }),
188
- ...(videoClip.speedRamp === undefined ? {} : { speedRamp: structuredClone(videoClip.speedRamp) }),
189
- };
190
- audioTrack.clips.push(audioClip);
191
- return validateTimeline(next);
192
- }
193
-
194
- export function removeTimelineClip(timeline, trackId, clipId) {
195
- const next = structuredClone(timeline);
196
- const track = next.tracks.find((candidate) => candidate.id === trackId);
197
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
198
- assertTrackEditable(track);
199
- const index = track.clips.findIndex((candidate) => candidate.id === clipId);
200
- if (index < 0) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
201
- track.clips.splice(index, 1);
202
- return validateTimeline(next);
203
- }
204
-
205
- export function rippleDeleteTimelineClip(timeline, trackId, clipId) {
206
- validateTimeline(timeline);
207
- const targetTrack = timeline.tracks.find((candidate) => candidate.id === trackId);
208
- if (!targetTrack) throw new TypeError(`Unknown timeline track: ${trackId}`);
209
- const target = targetTrack.clips.find((clip) => clip.id === clipId);
210
- if (!target) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
211
- return rippleDeleteTimelineRange(timeline, target.timelineStart, target.timelineStart + target.durationSeconds);
212
- }
213
-
214
- export function rippleDeleteTimelineRange(timeline, cutStart, cutEnd) {
215
- validateTimeline(timeline);
216
- if (!Number.isFinite(cutStart) || !Number.isFinite(cutEnd)
217
- || cutStart < 0 || cutEnd <= cutStart || cutEnd > timeline.project.durationSeconds + 1e-6) {
218
- throw new TypeError('Ripple delete range must be a positive time range within the project duration.');
219
- }
220
- cutEnd = Math.min(cutEnd, timeline.project.durationSeconds);
221
- const cutDuration = cutEnd - cutStart;
222
- for (const track of timeline.tracks) {
223
- if (track.locked === true && (track.clips.some((clip) => clip.timelineStart + clip.durationSeconds > cutStart)
224
- || track.volumeKeyframes !== undefined)) {
225
- throw new TypeError(`Ripple delete would change locked track ${track.id}. Unlock it before making a synchronized ripple edit.`);
226
- }
227
- }
228
- const next = structuredClone(timeline);
229
- const usedIds = new Set(next.tracks.flatMap((track) => track.clips.map((clip) => clip.id)));
230
-
231
- for (const track of next.tracks) {
232
- if (track.volumeKeyframes) track.volumeKeyframes = rippleTrackVolumeKeyframes(track.volumeKeyframes, cutStart, cutEnd);
233
- const clips = [];
234
- for (const clip of track.clips) {
235
- const clipStart = clip.timelineStart;
236
- const clipEnd = clipStart + clip.durationSeconds;
237
- if (clipEnd <= cutStart) {
238
- clips.push(clip);
239
- continue;
240
- }
241
- if (clipStart >= cutEnd) {
242
- clip.timelineStart = clipStart - cutDuration;
243
- clips.push(clip);
244
- continue;
245
- }
246
-
247
- const localCutStart = Math.max(0, cutStart - clipStart);
248
- const localCutEnd = Math.min(clip.durationSeconds, cutEnd - clipStart);
249
- const keepLeft = localCutStart > 1e-9;
250
- const keepRight = clip.durationSeconds - localCutEnd > 1e-9;
251
- if (keepLeft) clips.push(sliceTimelineClip(clip, track.kind, 0, localCutStart, clipStart));
252
- if (keepRight) {
253
- const rightStart = Math.max(clipStart, cutEnd) - cutDuration;
254
- const right = sliceTimelineClip(clip, track.kind, localCutEnd, clip.durationSeconds, rightStart);
255
- if (keepLeft) right.id = uniqueRippleClipId(clip.id, usedIds);
256
- clips.push(right);
257
- }
258
- }
259
- track.clips = clips.filter(Boolean);
260
- }
261
-
262
- next.project.durationSeconds = Math.max(1 / frameRateNumber(next.project.fps), timeline.project.durationSeconds - cutDuration);
263
- if (next.markers) {
264
- next.markers = next.markers
265
- .filter((marker) => marker.timeSeconds < cutStart || marker.timeSeconds >= cutEnd)
266
- .map((marker) => marker.timeSeconds < cutStart ? marker : { ...marker, timeSeconds: marker.timeSeconds - cutDuration });
267
- }
268
- return validateTimeline(next);
269
- }
270
-
271
- export function insertTimelineGap(timeline, timelineStart, durationSeconds) {
272
- validateTimeline(timeline);
273
- if (!Number.isFinite(timelineStart) || timelineStart < 0 || timelineStart > timeline.project.durationSeconds) {
274
- throw new TypeError('timelineStart must be within the project duration.');
275
- }
276
- if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
277
- throw new TypeError('durationSeconds must be a positive number.');
278
- }
279
- for (const track of timeline.tracks) {
280
- if (track.locked === true && (track.clips.some((clip) => clip.timelineStart + clip.durationSeconds > timelineStart)
281
- || track.volumeKeyframes !== undefined)) {
282
- throw new TypeError(`Gap insertion would change locked track ${track.id}. Unlock it before making a synchronized timeline edit.`);
283
- }
284
- }
285
-
286
- const next = structuredClone(timeline);
287
- if (next.markers) {
288
- next.markers = next.markers.map((marker) => marker.timeSeconds >= timelineStart
289
- ? { ...marker, timeSeconds: marker.timeSeconds + durationSeconds }
290
- : marker);
291
- }
292
- const usedIds = new Set(next.tracks.flatMap((track) => track.clips.map((clip) => clip.id)));
293
- for (const track of next.tracks) {
294
- if (track.volumeKeyframes) track.volumeKeyframes = insertTrackVolumeGap(track.volumeKeyframes, timelineStart, durationSeconds);
295
- const clips = [];
296
- for (const clip of track.clips) {
297
- const clipStart = clip.timelineStart;
298
- const clipEnd = clipStart + clip.durationSeconds;
299
- if (clipStart >= timelineStart) {
300
- clip.timelineStart += durationSeconds;
301
- clips.push(clip);
302
- continue;
303
- }
304
- if (clipEnd <= timelineStart) {
305
- clips.push(clip);
306
- continue;
307
- }
308
-
309
- const splitOffset = timelineStart - clipStart;
310
- const left = sliceTimelineClip(clip, track.kind, 0, splitOffset, clipStart);
311
- const right = sliceTimelineClip(clip, track.kind, splitOffset, clip.durationSeconds, timelineStart + durationSeconds);
312
- if (left) clips.push(left);
313
- if (right) {
314
- right.id = uniqueRippleClipId(`${clip.id}-gap-right`, usedIds);
315
- clips.push(right);
316
- }
317
- }
318
- track.clips = clips;
319
- }
320
-
321
- next.project.durationSeconds += durationSeconds;
322
- return validateTimeline(next);
323
- }
324
-
325
- function rippleTrackVolumeKeyframes(keyframes, cutStart, cutEnd) {
326
- const cutDuration = cutEnd - cutStart;
327
- const retained = keyframes.filter((keyframe) => keyframe.time < cutStart)
328
- .concat(keyframes.filter((keyframe) => keyframe.time >= cutEnd)
329
- .map((keyframe) => ({ ...keyframe, time: keyframe.time - cutDuration })));
330
- if (!retained.some((keyframe) => Math.abs(keyframe.time - cutStart) < 1e-9)) {
331
- retained.push({ time: cutStart, volume: sampleAudioVolumeKeyframes(keyframes, cutEnd) });
332
- }
333
- return retained.sort((left, right) => left.time - right.time);
334
- }
335
-
336
- function insertTrackVolumeGap(keyframes, timelineStart, durationSeconds) {
337
- const retained = keyframes.filter((keyframe) => keyframe.time <= timelineStart)
338
- .concat(keyframes.filter((keyframe) => keyframe.time > timelineStart)
339
- .map((keyframe) => ({ ...keyframe, time: keyframe.time + durationSeconds })));
340
- const heldVolume = sampleAudioVolumeKeyframes(keyframes, timelineStart);
341
- if (!retained.some((keyframe) => Math.abs(keyframe.time - timelineStart) < 1e-9)) {
342
- retained.push({ time: timelineStart, volume: heldVolume });
343
- }
344
- if (!retained.some((keyframe) => Math.abs(keyframe.time - (timelineStart + durationSeconds)) < 1e-9)) {
345
- retained.push({ time: timelineStart + durationSeconds, volume: heldVolume });
346
- }
347
- return retained.sort((left, right) => left.time - right.time);
348
- }
349
-
350
- export function moveTimelineClip(timeline, trackId, clipId, timelineStart) {
351
- if (!Number.isFinite(timelineStart) || timelineStart < 0) throw new TypeError('timelineStart must be a non-negative number.');
352
- const next = structuredClone(timeline);
353
- const clip = findClip(next, trackId, clipId);
354
- clip.timelineStart = timelineStart;
355
- next.project.durationSeconds = Math.max(next.project.durationSeconds, timelineStart + clip.durationSeconds);
356
- return validateTimeline(next);
357
- }
358
-
359
- export function moveTimelineClipToTrack(timeline, trackId, clipId, targetTrackId, timelineStart) {
360
- if (timelineStart !== undefined && (!Number.isFinite(timelineStart) || timelineStart < 0)) {
361
- throw new TypeError('timelineStart must be a non-negative number.');
362
- }
363
- if (trackId === targetTrackId) {
364
- findClipEntry(timeline, trackId, clipId);
365
- return timelineStart === undefined ? validateTimeline(structuredClone(timeline)) : moveTimelineClip(timeline, trackId, clipId, timelineStart);
366
- }
367
-
368
- const next = structuredClone(timeline);
369
- const { track: sourceTrack, clip, index } = findClipEntry(next, trackId, clipId);
370
- const targetTrack = next.tracks.find((candidate) => candidate.id === targetTrackId);
371
- if (!targetTrack) throw new TypeError(`Unknown timeline track: ${targetTrackId}`);
372
- assertTrackEditable(targetTrack);
373
- if (sourceTrack.kind !== targetTrack.kind) {
374
- throw new TypeError(`Cannot move a ${sourceTrack.kind} clip to a track of kind ${targetTrack.kind}.`);
375
- }
376
- if (targetTrack.clips.some((candidate) => candidate.id === clipId)) {
377
- throw new TypeError(`Timeline clip already exists on track ${targetTrackId}: ${clipId}`);
378
- }
379
-
380
- sourceTrack.clips.splice(index, 1);
381
- if (timelineStart !== undefined) clip.timelineStart = timelineStart;
382
- targetTrack.clips.push(clip);
383
- next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
384
- return validateTimeline(next);
385
- }
386
-
387
- export function replaceTimelineClipAsset(timeline, trackId, clipId, assetId, options = {}) {
388
- validateTimeline(timeline);
389
- if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Replacement media options must be an object.');
390
- const allowed = new Set(['sourceIn', 'sourceOut', 'audioStreamIndex', 'videoStreamIndex', 'playbackRate', 'reverse', 'speedRamp']);
391
- for (const field of Object.keys(options)) {
392
- if (!allowed.has(field)) throw new TypeError(`Unsupported replacement media option: ${field}.`);
393
- if (options[field] === undefined) throw new TypeError('Replacement media options must not be undefined.');
394
- }
395
- if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]*$/.test(assetId)) throw new TypeError('Replacement asset id must be a lowercase kebab-case id.');
396
- const next = structuredClone(timeline);
397
- const { track, clip } = findClipEntry(next, trackId, clipId);
398
- if (track.kind === 'overlay') throw new TypeError('Overlay clips do not reference media assets.');
399
- if (!next.assets.some((asset) => asset.id === assetId)) throw new TypeError(`Unknown timeline asset: ${assetId}`);
400
- clip.assetId = assetId;
401
- if (options.sourceIn !== undefined) clip.sourceIn = options.sourceIn;
402
- if (options.sourceOut !== undefined) clip.sourceOut = options.sourceOut;
403
- if (options.audioStreamIndex === null) delete clip.audioStreamIndex;
404
- else if (options.audioStreamIndex !== undefined) clip.audioStreamIndex = options.audioStreamIndex;
405
- if (options.videoStreamIndex === null) delete clip.videoStreamIndex;
406
- else if (options.videoStreamIndex !== undefined) clip.videoStreamIndex = options.videoStreamIndex;
407
- if (options.playbackRate === null) delete clip.playbackRate;
408
- else if (options.playbackRate !== undefined) clip.playbackRate = options.playbackRate;
409
- if (options.reverse !== undefined) clip.reverse = options.reverse;
410
- if (options.speedRamp === null) delete clip.speedRamp;
411
- else if (options.speedRamp !== undefined) clip.speedRamp = structuredClone(options.speedRamp);
412
- return validateTimeline(next);
413
- }
414
-
415
- export function setTimelineClipSpeed(timeline, trackId, clipId, speed) {
416
- validateTimeline(timeline);
417
- if (!speed || typeof speed !== 'object' || Array.isArray(speed)) throw new TypeError('Clip speed must be an object.');
418
- const fields = Object.keys(speed);
419
- if (fields.length !== 1 || !['playbackRate', 'speedRamp'].includes(fields[0])) {
420
- throw new TypeError('Set exactly one of playbackRate or speedRamp.');
421
- }
422
- const next = structuredClone(timeline);
423
- const { track, clip } = findClipEntry(next, trackId, clipId);
424
- if (track.kind !== 'video' && track.kind !== 'audio') throw new TypeError('Playback speed is supported on video and audio clips.');
425
- const asset = next.assets.find((entry) => entry.id === clip.assetId);
426
- if (track.kind === 'video' && asset?.type === 'image') throw new TypeError('Still-image clips do not support playback speed.');
427
- const sourceSpan = clip.sourceOut - clip.sourceIn;
428
- const previousDuration = clip.durationSeconds;
429
- if (Object.hasOwn(speed, 'playbackRate')) {
430
- const playbackRate = speed.playbackRate === null ? 1 : speed.playbackRate;
431
- if (!Number.isFinite(playbackRate) || playbackRate < 0.1 || playbackRate > 5) {
432
- throw new TypeError('Playback rate must be from 0.1 to 5, or null to reset to normal speed.');
433
- }
434
- delete clip.speedRamp;
435
- if (playbackRate === 1) delete clip.playbackRate;
436
- else clip.playbackRate = playbackRate;
437
- clip.durationSeconds = sourceSpan / playbackRate;
438
- } else if (speed.speedRamp === null) {
439
- delete clip.speedRamp;
440
- delete clip.playbackRate;
441
- clip.durationSeconds = sourceSpan;
442
- } else {
443
- if (clip.reverse) throw new TypeError('Reverse playback cannot be combined with a speed ramp.');
444
- if (!Array.isArray(speed.speedRamp) || speed.speedRamp.length < 2) throw new TypeError('Speed ramp must contain at least two points.');
445
- clip.speedRamp = structuredClone(speed.speedRamp);
446
- delete clip.playbackRate;
447
- clip.durationSeconds = speedRampDuration(clip.speedRamp);
448
- }
449
- scaleClipLocalTiming(clip, previousDuration, clip.durationSeconds);
450
- next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
451
- return validateTimeline(next);
452
- }
453
-
454
- function scaleClipLocalTiming(clip, previousDuration, nextDuration) {
455
- const scale = nextDuration / previousDuration;
456
- for (const frame of clip.keyframes ?? []) frame.time *= scale;
457
- for (const keyframe of clip.volumeKeyframes ?? []) keyframe.time *= scale;
458
- for (const word of clip.wordTimings ?? []) {
459
- word.start *= scale;
460
- word.end *= scale;
461
- }
462
- for (const field of ['fadeInSeconds', 'fadeOutSeconds']) {
463
- if (clip[field] !== undefined) clip[field] *= scale;
464
- }
465
- for (const field of ['transitionIn', 'transitionOut']) {
466
- if (clip[field] !== undefined) clip[field].durationSeconds *= scale;
467
- }
468
- }
469
-
470
- export function trimTimelineClip(timeline, trackId, clipId, sourceIn, sourceOut) {
471
- if (!Number.isFinite(sourceIn) || !Number.isFinite(sourceOut)) throw new TypeError('Trim source bounds must be finite numbers.');
472
- const next = structuredClone(timeline);
473
- const { track, clip } = findClipEntry(next, trackId, clipId);
474
- if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-trimmed.');
475
- if (sourceIn < clip.sourceIn || sourceOut > clip.sourceOut || sourceOut <= sourceIn) throw new TypeError('Trim bounds must be a positive subrange of the existing source range.');
476
- const sourceOffset = sourceIn - clip.sourceIn;
477
- const sourceEndOffset = sourceOut - clip.sourceIn;
478
- const trimBefore = clip.reverse
479
- ? (clip.sourceOut - sourceOut) / (clip.playbackRate ?? 1)
480
- : clip.speedRamp
481
- ? speedRampDuration(clip.speedRamp, 0, sourceOffset)
482
- : sourceOffset / (clip.playbackRate ?? 1);
483
- const durationSeconds = clip.speedRamp
484
- ? speedRampDuration(clip.speedRamp, sourceOffset, sourceEndOffset)
485
- : (sourceOut - sourceIn) / (clip.playbackRate ?? 1);
486
- clip.keyframes = sliceVisualKeyframes(clip.keyframes, trimBefore, trimBefore + durationSeconds);
487
- clip.volumeKeyframes = sliceAudioVolumeKeyframes(clip.volumeKeyframes, trimBefore, trimBefore + durationSeconds);
488
- if (clip.speedRamp) clip.speedRamp = sliceSpeedRamp(clip.speedRamp, sourceOffset, sourceEndOffset);
489
- clip.timelineStart += trimBefore;
490
- clip.sourceIn = sourceIn;
491
- clip.sourceOut = sourceOut;
492
- clip.durationSeconds = durationSeconds;
493
- clip.fadeInSeconds = Math.min(clip.fadeInSeconds ?? 0, clip.durationSeconds);
494
- clip.fadeOutSeconds = Math.min(clip.fadeOutSeconds ?? 0, clip.durationSeconds - clip.fadeInSeconds);
495
- return validateTimeline(next);
496
- }
497
-
498
- export function slipTimelineClip(timeline, trackId, clipId, sourceDelta) {
499
- validateTimeline(timeline);
500
- if (!Number.isFinite(sourceDelta)) throw new TypeError('sourceDelta must be a finite number.');
501
- const next = structuredClone(timeline);
502
- const { track, clip } = findClipEntry(next, trackId, clipId);
503
- if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-slipped.');
504
- clip.sourceIn += sourceDelta;
505
- clip.sourceOut += sourceDelta;
506
- return validateTimeline(next);
507
- }
508
-
509
- export function splitTimelineClip(timeline, trackId, clipId, splitAt, rightClipId = `${clipId}-right`) {
510
- if (!Number.isFinite(splitAt)) throw new TypeError('splitAt must be a finite timeline time.');
511
- if (!/^[a-z][a-z0-9-]*$/.test(rightClipId)) throw new TypeError('rightClipId must be a lowercase kebab-case id.');
512
- const next = structuredClone(timeline);
513
- const { track, clip, index } = findClipEntry(next, trackId, clipId);
514
- if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-split.');
515
- const splitOffset = splitAt - clip.timelineStart;
516
- if (splitOffset <= 0 || splitOffset >= clip.durationSeconds) throw new TypeError('splitAt must fall strictly inside the clip.');
517
- if (track.clips.some((candidate) => candidate.id === rightClipId)) throw new TypeError(`Clip id already exists on track ${trackId}: ${rightClipId}`);
518
-
519
- const originalDuration = clip.durationSeconds;
520
- const originalKeyframes = clip.keyframes;
521
- const originalVolumeKeyframes = clip.volumeKeyframes;
522
- const originalSpeedRamp = clip.speedRamp;
523
- const originalStart = clip.sourceIn;
524
- const originalEnd = clip.sourceOut;
525
- const sourceSplit = clip.reverse
526
- ? originalEnd - splitOffset * (clip.playbackRate ?? 1)
527
- : originalStart + (originalSpeedRamp
528
- ? speedRampSourceAtTime(originalSpeedRamp, splitOffset)
529
- : splitOffset * (clip.playbackRate ?? 1));
530
- const originalFadeOut = clip.fadeOutSeconds ?? 0;
531
- const originalFadeIn = clip.fadeInSeconds ?? 0;
532
- const originalTransitionOut = clip.transitionOut;
533
- if (clip.reverse) clip.sourceIn = sourceSplit;
534
- else clip.sourceOut = sourceSplit;
535
- clip.durationSeconds = splitOffset;
536
- clip.keyframes = sliceVisualKeyframes(originalKeyframes, 0, splitOffset);
537
- clip.volumeKeyframes = sliceAudioVolumeKeyframes(originalVolumeKeyframes, 0, splitOffset);
538
- if (originalSpeedRamp) clip.speedRamp = sliceSpeedRamp(originalSpeedRamp, 0, sourceSplit - clip.sourceIn);
539
- clip.fadeInSeconds = Math.min(originalFadeIn, splitOffset);
540
- clip.fadeOutSeconds = 0;
541
- delete clip.transitionOut;
542
- const right = {
543
- ...clip,
544
- id: rightClipId,
545
- sourceIn: clip.reverse ? originalStart : sourceSplit,
546
- sourceOut: clip.reverse ? sourceSplit : originalEnd,
547
- timelineStart: splitAt,
548
- durationSeconds: originalDuration - splitOffset,
549
- keyframes: sliceVisualKeyframes(originalKeyframes, splitOffset, originalDuration),
550
- volumeKeyframes: sliceAudioVolumeKeyframes(originalVolumeKeyframes, splitOffset, originalDuration),
551
- ...(originalSpeedRamp ? { speedRamp: sliceSpeedRamp(originalSpeedRamp, sourceSplit - clip.sourceIn, originalSpeedRamp.at(-1).sourceOffset) } : {}),
552
- fadeInSeconds: 0,
553
- fadeOutSeconds: Math.min(originalFadeOut, originalDuration - splitOffset),
554
- };
555
- delete right.transitionIn;
556
- if (originalTransitionOut) right.transitionOut = originalTransitionOut;
557
- track.clips.splice(index + 1, 0, right);
558
- return validateTimeline(next);
559
- }
560
-
561
- export function crossfadeTimelineClips(timeline, trackId, outgoingClipId, incomingClipId, durationSeconds) {
562
- if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new TypeError('Crossfade duration must be a positive number.');
563
- const next = structuredClone(timeline);
564
- const track = next.tracks.find((candidate) => candidate.id === trackId);
565
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
566
- assertTrackEditable(track);
567
- const outgoing = track.clips.find((clip) => clip.id === outgoingClipId);
568
- const incoming = track.clips.find((clip) => clip.id === incomingClipId);
569
- if (!outgoing || !incoming) throw new TypeError(`Crossfade clips must both belong to track ${trackId}.`);
570
- if (outgoing === incoming) throw new TypeError('A crossfade requires two different clips.');
571
- const outgoingEnd = outgoing.timelineStart + outgoing.durationSeconds;
572
- const overlapSeconds = outgoingEnd - incoming.timelineStart;
573
- if (Math.abs(overlapSeconds - durationSeconds) > 1e-6) throw new TypeError('Crossfade clips must already overlap by the requested duration.');
574
- if (durationSeconds > outgoing.durationSeconds || durationSeconds > incoming.durationSeconds) throw new TypeError('Crossfade duration cannot exceed either clip duration.');
575
- if (outgoing.timelineStart >= incoming.timelineStart) throw new TypeError('Crossfade clips must be ordered outgoing then incoming.');
576
- if (track.kind === 'audio') outgoing.fadeOutSeconds = durationSeconds;
577
- else delete outgoing.fadeOutSeconds;
578
- incoming.fadeInSeconds = durationSeconds;
579
- return validateTimeline(next);
580
- }
581
-
582
- export function setTimelineTransition(timeline, trackId, clipId, edge, transition) {
583
- if (!['in', 'out'].includes(edge)) throw new TypeError('Transition edge must be in or out.');
584
- const next = structuredClone(timeline);
585
- const { track, clip } = findClipEntry(next, trackId, clipId);
586
- if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError('Transitions are supported on video and overlay tracks.');
587
- const field = edge === 'in' ? 'transitionIn' : 'transitionOut';
588
- if (transition === null) delete clip[field];
589
- else clip[field] = validateClipTransition(transition, clip, `Clip ${clip.id} ${field}`);
590
- return validateTimeline(next);
591
- }
592
-
593
- export function setTimelineClipReverse(timeline, trackId, clipId, reverse) {
594
- if (typeof reverse !== 'boolean') throw new TypeError('reverse must be a boolean.');
595
- const next = structuredClone(timeline);
596
- const { track, clip } = findClipEntry(next, trackId, clipId);
597
- if (track.kind !== 'video' && track.kind !== 'audio') throw new TypeError(`Reverse playback is supported on video and audio tracks, not ${track.kind}.`);
598
- if (reverse) clip.reverse = true;
599
- else delete clip.reverse;
600
- return validateTimeline(next);
601
- }
602
-
603
- export function setTimelineClipAudioEffects(timeline, trackId, clipId, effects) {
604
- if (effects !== null && !Array.isArray(effects)) throw new TypeError('audioEffects must be an array or null.');
605
- const next = structuredClone(timeline);
606
- const { track, clip } = findClipEntry(next, trackId, clipId);
607
- if (track.kind !== 'audio') throw new TypeError(`Audio effects are supported on audio tracks, not ${track.kind}.`);
608
- if (effects === null || effects.length === 0) delete clip.audioEffects;
609
- else clip.audioEffects = structuredClone(effects);
610
- return validateTimeline(next);
611
- }
612
-
613
- export function setTimelineClipVolumeKeyframes(timeline, trackId, clipId, keyframes) {
614
- if (keyframes !== null && !Array.isArray(keyframes)) throw new TypeError('volumeKeyframes must be an array or null.');
615
- const next = structuredClone(timeline);
616
- const { track, clip } = findClipEntry(next, trackId, clipId);
617
- if (track.kind !== 'audio') throw new TypeError(`Volume keyframes are supported on audio tracks, not ${track.kind}.`);
618
- if (keyframes === null || keyframes.length === 0) delete clip.volumeKeyframes;
619
- else clip.volumeKeyframes = structuredClone(keyframes);
620
- return validateTimeline(next);
621
- }
622
-
623
- export function setTimelineClipAudioState(timeline, trackId, clipId, state) {
624
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Audio state must be an object.');
625
- const fields = Object.keys(state);
626
- const allowed = ['volume', 'fadeInSeconds', 'fadeOutSeconds', 'audioStreamIndex'];
627
- if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
628
- throw new TypeError(`Audio state must set one or more of: ${allowed.join(', ')}.`);
629
- }
630
- if (fields.some((field) => state[field] === undefined)) throw new TypeError('Audio state values must not be undefined; use null to clear an override.');
631
- const next = structuredClone(timeline);
632
- const { track, clip } = findClipEntry(next, trackId, clipId);
633
- if (track.kind !== 'audio') throw new TypeError(`Audio state is supported on audio tracks, not ${track.kind}.`);
634
- for (const field of fields) {
635
- if (state[field] === null) delete clip[field];
636
- else clip[field] = state[field];
637
- }
638
- return validateTimeline(next);
639
- }
640
-
641
- export function setTimelineClipVideoState(timeline, trackId, clipId, state) {
642
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Video state must be an object.');
643
- const fields = Object.keys(state);
644
- if (fields.length !== 1 || fields[0] !== 'videoStreamIndex') {
645
- throw new TypeError('Video state must set videoStreamIndex.');
646
- }
647
- if (state.videoStreamIndex === undefined) throw new TypeError('Video state values must not be undefined; use null to clear an override.');
648
- const next = structuredClone(timeline);
649
- const { track, clip } = findClipEntry(next, trackId, clipId);
650
- if (track.kind !== 'video') throw new TypeError(`Video state is supported on video tracks, not ${track.kind}.`);
651
- if (state.videoStreamIndex === null) delete clip.videoStreamIndex;
652
- else clip.videoStreamIndex = state.videoStreamIndex;
653
- return validateTimeline(next);
654
- }
655
-
656
- export function setTimelineClipKeyframes(timeline, trackId, clipId, keyframes) {
657
- if (keyframes !== null && !Array.isArray(keyframes)) throw new TypeError('keyframes must be an array or null.');
658
- const next = structuredClone(timeline);
659
- const { track, clip } = findClipEntry(next, trackId, clipId);
660
- if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError(`Visual keyframes are supported on video and overlay tracks, not ${track.kind}.`);
661
- if (keyframes === null || keyframes.length === 0) delete clip.keyframes;
662
- else clip.keyframes = structuredClone(keyframes);
663
- return validateTimeline(next);
664
- }
665
-
666
- export function setTimelineClipFraming(timeline, trackId, clipId, framing) {
667
- if (!framing || typeof framing !== 'object' || Array.isArray(framing)) throw new TypeError('framing must be an object.');
668
- const fields = Object.keys(framing);
669
- if (fields.length === 0 || fields.some((field) => !['fit', 'x', 'y', 'width', 'height'].includes(field))) {
670
- throw new TypeError('framing must set one or more of fit, x, y, width, or height.');
671
- }
672
- if (fields.some((field) => framing[field] === undefined)) throw new TypeError('framing values must not be undefined; use null to clear an override.');
673
- const next = structuredClone(timeline);
674
- const { track, clip } = findClipEntry(next, trackId, clipId);
675
- if (track.kind !== 'video') throw new TypeError(`Static framing is supported on video tracks, not ${track.kind}.`);
676
- for (const field of fields) {
677
- if (framing[field] === null) delete clip[field];
678
- else clip[field] = framing[field];
679
- }
680
- return validateTimeline(next);
681
- }
682
-
683
- export function setTimelineTrackState(timeline, trackId, state) {
684
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Track state must be an object.');
685
- const next = structuredClone(timeline);
686
- validateTimeline(next);
687
- const track = next.tracks.find((candidate) => candidate.id === trackId);
688
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
689
- const allowed = track.kind === 'audio' ? ['muted', 'solo', 'volume', 'volumeKeyframes', 'audioEffects', 'locked'] : ['visible', 'locked'];
690
- const fields = Object.keys(state);
691
- if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
692
- throw new TypeError(`Track ${trackId} state must set one or more of: ${allowed.join(', ')}.`);
693
- }
694
- if (fields.some((field) => state[field] === undefined)) throw new TypeError('Track state values must not be undefined; use null to clear an override.');
695
- if (fields.includes('locked') && fields.length !== 1) throw new TypeError('Set or clear track lock state in a separate operation.');
696
- if (track.locked === true && fields.some((field) => field !== 'locked')) {
697
- throw new TypeError(`Track ${trackId} is locked. Unlock it before editing track state.`);
698
- }
699
- for (const field of fields) {
700
- if (state[field] === null) delete track[field];
701
- else track[field] = state[field];
702
- }
703
- return validateTimeline(next);
704
- }
705
-
706
- export function setTimelineClipState(timeline, trackId, clipId, state) {
707
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Clip state must be an object.');
708
- const fields = Object.keys(state);
709
- if (fields.length !== 1 || fields[0] !== 'enabled') throw new TypeError('Clip state must set enabled.');
710
- if (state.enabled !== null && typeof state.enabled !== 'boolean') throw new TypeError('Clip enabled must be a boolean or null.');
711
- const next = structuredClone(timeline);
712
- const { clip } = findClipEntry(next, trackId, clipId);
713
- if (state.enabled === null) delete clip.enabled;
714
- else clip.enabled = state.enabled;
715
- return validateTimeline(next);
716
- }
717
-
718
- export function setTimelineTrackAudioEffects(timeline, trackId, effects) {
719
- if (effects !== null && !Array.isArray(effects)) throw new TypeError('Track audioEffects must be an array or null.');
720
- const next = structuredClone(timeline);
721
- validateTimeline(next);
722
- const track = next.tracks.find((candidate) => candidate.id === trackId);
723
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
724
- if (track.kind !== 'audio') throw new TypeError(`Track audio effects are supported on audio tracks, not ${track.kind}.`);
725
- assertTrackEditable(track);
726
- if (effects === null || effects.length === 0) delete track.audioEffects;
727
- else track.audioEffects = structuredClone(effects);
728
- return validateTimeline(next);
729
- }
730
-
731
- export function setTimelineAudioMix(timeline, audioMix) {
732
- if (audioMix === undefined) throw new TypeError('audioMix must be an object or null.');
733
- const next = structuredClone(timeline);
734
- if (audioMix === null) delete next.audioMix;
735
- else next.audioMix = structuredClone(audioMix);
736
- return validateTimeline(next);
737
- }
738
-
739
- export function setTimelineAudioMastering(timeline, audioMastering) {
740
- if (audioMastering === undefined) throw new TypeError('audioMastering must be an object or null.');
741
- const next = structuredClone(timeline);
742
- if (audioMastering === null) delete next.audioMastering;
743
- else next.audioMastering = structuredClone(audioMastering);
744
- return validateTimeline(next);
745
- }
746
-
747
- export function setTimelineProjectState(timeline, state) {
748
- if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Project state must be an object.');
749
- const fields = Object.keys(state);
750
- if (fields.length === 0 || fields.some((field) => !['durationSeconds', 'sourceFit', 'backgroundColor'].includes(field))) {
751
- throw new TypeError('Project state must set one or more of: durationSeconds, sourceFit, backgroundColor.');
752
- }
753
- if (fields.some((field) => state[field] === undefined)) throw new TypeError('Project state values must not be undefined; use null to clear sourceFit or make the background transparent.');
754
- const next = structuredClone(timeline);
755
- for (const field of fields) {
756
- if (field === 'sourceFit' && state[field] === null) delete next.project.sourceFit;
757
- else next.project[field] = structuredClone(state[field]);
758
- }
759
- return validateTimeline(next);
760
- }
761
-
762
- export function setTimelineOverlay(timeline, trackId, clipId, properties) {
763
- if (!properties || typeof properties !== 'object' || Array.isArray(properties)) throw new TypeError('Overlay properties must be an object.');
764
- const allowed = [
765
- 'text', 'composition', 'x', 'y', 'width', 'height', 'fontSize', 'fontFamily', 'fontWeight',
766
- 'lineHeight', 'verticalAlign', 'paddingPixels', 'color', 'backgroundColor', 'textAlign',
767
- 'wordWrap', 'wordsPerLine', 'wordTimings', 'wordHighlightColor', 'wordHighlightAnimation',
768
- 'textOutline', 'textShadow', 'fadeInSeconds', 'fadeOutSeconds', 'keyframes', 'transitionIn', 'transitionOut',
769
- 'bubble',
770
- ];
771
- const fields = Object.keys(properties);
772
- if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
773
- throw new TypeError(`Overlay properties must set one or more of: ${allowed.join(', ')}.`);
774
- }
775
- if (fields.some((field) => properties[field] === undefined)) throw new TypeError('Overlay property values must not be undefined; use null to clear an override.');
776
- const next = structuredClone(timeline);
777
- const { track, clip } = findClipEntry(next, trackId, clipId);
778
- if (track.kind !== 'overlay') throw new TypeError(`Overlay properties are supported on overlay tracks, not ${track.kind}.`);
779
- for (const field of fields) {
780
- if (properties[field] === null) delete clip[field];
781
- else clip[field] = structuredClone(properties[field]);
782
- }
783
- return validateTimeline(next);
784
- }
785
-
786
- export function editTimeline(timeline, operation) {
787
- if (!operation || typeof operation !== 'object') throw new TypeError('Timeline edit operation must be an object.');
788
- const { type, trackId, clipId } = operation;
789
- if (type === 'add-track') return addTimelineTrack(timeline, operation.track);
790
- if (type === 'set-marker') return setTimelineMarker(timeline, operation.marker);
791
- if (type === 'remove-marker') return removeTimelineMarker(timeline, operation.markerId);
792
- if (type === 'remove-track') return removeTimelineTrack(timeline, operation.trackId);
793
- if (type === 'reorder-track') return reorderTimelineTrack(timeline, operation.trackId, operation.order);
794
- if (type === 'add-clip') return addTimelineClip(timeline, trackId, operation.clip);
795
- if (type === 'duplicate-clip') return duplicateTimelineClip(timeline, trackId, clipId, operation.newClipId, operation.timelineStart);
796
- if (type === 'detach-audio') return detachAudioFromTimelineClip(timeline, trackId, clipId, operation.audioTrackId, operation.audioClipId, operation.audioStreamIndex);
797
- if (type === 'remove-clip') return removeTimelineClip(timeline, trackId, clipId);
798
- if (type === 'ripple-delete') return rippleDeleteTimelineClip(timeline, trackId, clipId);
799
- if (type === 'ripple-delete-range') return rippleDeleteTimelineRange(timeline, operation.startSeconds, operation.endSeconds);
800
- if (type === 'insert-gap') return insertTimelineGap(timeline, operation.timelineStart, operation.durationSeconds);
801
- if (type === 'move') return moveTimelineClip(timeline, trackId, clipId, operation.timelineStart);
802
- if (type === 'move-track') return moveTimelineClipToTrack(timeline, trackId, clipId, operation.targetTrackId, operation.timelineStart);
803
- if (type === 'set-speed') return setTimelineClipSpeed(timeline, trackId, clipId, operation.speed);
804
- if (type === 'replace-asset') return replaceTimelineClipAsset(timeline, trackId, clipId, operation.assetId, {
805
- ...(operation.sourceIn === undefined ? {} : { sourceIn: operation.sourceIn }),
806
- ...(operation.sourceOut === undefined ? {} : { sourceOut: operation.sourceOut }),
807
- ...(operation.audioStreamIndex === undefined ? {} : { audioStreamIndex: operation.audioStreamIndex }),
808
- ...(operation.videoStreamIndex === undefined ? {} : { videoStreamIndex: operation.videoStreamIndex }),
809
- ...(operation.playbackRate === undefined ? {} : { playbackRate: operation.playbackRate }),
810
- ...(operation.reverse === undefined ? {} : { reverse: operation.reverse }),
811
- ...(operation.speedRamp === undefined ? {} : { speedRamp: operation.speedRamp }),
812
- });
813
- if (type === 'trim') return trimTimelineClip(timeline, trackId, clipId, operation.sourceIn, operation.sourceOut);
814
- if (type === 'slip') return slipTimelineClip(timeline, trackId, clipId, operation.sourceDelta);
815
- if (type === 'split') return splitTimelineClip(timeline, trackId, clipId, operation.splitAt, operation.rightClipId);
816
- if (type === 'crossfade') return crossfadeTimelineClips(timeline, trackId, clipId, operation.incomingClipId, operation.durationSeconds);
817
- if (type === 'transition') return setTimelineTransition(timeline, trackId, clipId, operation.edge, operation.transition);
818
- if (type === 'set-reverse') return setTimelineClipReverse(timeline, trackId, clipId, operation.reverse);
819
- if (type === 'set-audio-effects') return setTimelineClipAudioEffects(timeline, trackId, clipId, operation.audioEffects);
820
- if (type === 'set-volume-keyframes') return setTimelineClipVolumeKeyframes(timeline, trackId, clipId, operation.volumeKeyframes);
821
- if (type === 'set-audio-state') return setTimelineClipAudioState(timeline, trackId, clipId, operation.state);
822
- if (type === 'set-video-state') return setTimelineClipVideoState(timeline, trackId, clipId, operation.state);
823
- if (type === 'set-clip-state') return setTimelineClipState(timeline, trackId, clipId, operation.state);
824
- if (type === 'set-keyframes') return setTimelineClipKeyframes(timeline, trackId, clipId, operation.keyframes);
825
- if (type === 'set-framing') return setTimelineClipFraming(timeline, trackId, clipId, operation.framing);
826
- if (type === 'set-track-state') return setTimelineTrackState(timeline, trackId, operation.state);
827
- if (type === 'set-track-audio-effects') return setTimelineTrackAudioEffects(timeline, trackId, operation.audioEffects);
828
- if (type === 'set-audio-mix') return setTimelineAudioMix(timeline, operation.audioMix);
829
- if (type === 'set-audio-mastering') return setTimelineAudioMastering(timeline, operation.audioMastering);
830
- if (type === 'set-project-state') return setTimelineProjectState(timeline, operation.state);
831
- if (type === 'set-overlay') return setTimelineOverlay(timeline, trackId, clipId, operation.properties);
832
- throw new TypeError('Timeline edit type must be add-track, set-marker, remove-marker, remove-track, reorder-track, add-clip, duplicate-clip, detach-audio, remove-clip, ripple-delete, ripple-delete-range, insert-gap, move, move-track, replace-asset, set-speed, trim, slip, split, crossfade, transition, set-reverse, set-audio-effects, set-track-audio-effects, set-audio-mix, set-audio-mastering, set-project-state, set-volume-keyframes, set-audio-state, set-video-state, set-clip-state, set-keyframes, set-framing, set-track-state, or set-overlay.');
833
- }
834
-
835
- /** Apply a non-empty edit plan in order, returning one validated result or no changes. */
836
- export function editTimelineBatch(timeline, operations) {
837
- if (!Array.isArray(operations) || operations.length === 0) {
838
- throw new TypeError('Timeline edit batch must be a non-empty array of operations.');
839
- }
840
- let next = timeline;
841
- for (const operation of operations) next = editTimeline(next, operation);
842
- return next;
843
- }
844
-
845
- /** Describe the exact result of an edit plan without changing the source timeline. */
846
- export function previewTimelineEdit(timeline, operationOrOperations) {
847
- const operations = Array.isArray(operationOrOperations) ? operationOrOperations : [operationOrOperations];
848
- const edited = editTimelineBatch(timeline, operations);
849
- const changes = [];
850
- const beforeTracks = new Map(timeline.tracks.map((track) => [track.id, track]));
851
- const afterTracks = new Map(edited.tracks.map((track) => [track.id, track]));
852
-
853
- for (const trackId of new Set([...beforeTracks.keys(), ...afterTracks.keys()])) {
854
- const beforeTrack = beforeTracks.get(trackId);
855
- const afterTrack = afterTracks.get(trackId);
856
- const beforeMetadata = beforeTrack && withoutClips(beforeTrack);
857
- const afterMetadata = afterTrack && withoutClips(afterTrack);
858
- if (!isDeepStrictEqual(beforeMetadata, afterMetadata)) {
859
- changes.push({ entity: 'track', change: beforeTrack ? (afterTrack ? 'updated' : 'removed') : 'added', trackId, before: beforeMetadata ?? null, after: afterMetadata ?? null });
860
- }
861
-
862
- const beforeClips = new Map((beforeTrack?.clips ?? []).map((clip) => [clip.id, clip]));
863
- const afterClips = new Map((afterTrack?.clips ?? []).map((clip) => [clip.id, clip]));
864
- for (const clipId of new Set([...beforeClips.keys(), ...afterClips.keys()])) {
865
- const before = beforeClips.get(clipId);
866
- const after = afterClips.get(clipId);
867
- if (!isDeepStrictEqual(before, after)) {
868
- changes.push({ entity: 'clip', change: before ? (after ? 'updated' : 'removed') : 'added', trackId, clipId, before: before ?? null, after: after ?? null });
869
- }
870
- }
871
- }
872
-
873
- return {
874
- schemaVersion: 1,
875
- operationCount: operations.length,
876
- durationBeforeSeconds: timeline.project.durationSeconds,
877
- durationAfterSeconds: edited.project.durationSeconds,
878
- durationDeltaSeconds: edited.project.durationSeconds - timeline.project.durationSeconds,
879
- changes,
880
- };
881
- }
882
-
883
- function withoutClips(track) {
884
- const { clips, ...metadata } = track;
885
- return metadata;
886
- }
887
-
888
- function sliceTimelineClip(clip, kind, localStart, localEnd, timelineStart) {
889
- const durationSeconds = localEnd - localStart;
890
- if (durationSeconds <= 1e-9) return undefined;
891
- const segment = structuredClone(clip);
892
- const originalDuration = clip.durationSeconds;
893
- segment.timelineStart = timelineStart;
894
- segment.durationSeconds = durationSeconds;
895
- if (kind === 'overlay' && clip.composition) {
896
- segment.compositionTimeOffsetSeconds = (clip.compositionTimeOffsetSeconds ?? 0) + localStart;
897
- }
898
- const keyframes = sliceVisualKeyframes(clip.keyframes, localStart, localEnd);
899
- if (keyframes) segment.keyframes = keyframes;
900
- else delete segment.keyframes;
901
- const volumeKeyframes = sliceAudioVolumeKeyframes(clip.volumeKeyframes, localStart, localEnd);
902
- if (volumeKeyframes) segment.volumeKeyframes = volumeKeyframes;
903
- else delete segment.volumeKeyframes;
904
-
905
- if (kind !== 'overlay') {
906
- const sourceStartOffset = clip.speedRamp
907
- ? speedRampSourceAtTime(clip.speedRamp, localStart)
908
- : localStart * (clip.playbackRate ?? 1);
909
- const sourceEndOffset = clip.speedRamp
910
- ? speedRampSourceAtTime(clip.speedRamp, localEnd)
911
- : localEnd * (clip.playbackRate ?? 1);
912
- segment.sourceIn = clip.reverse ? clip.sourceOut - sourceEndOffset : clip.sourceIn + sourceStartOffset;
913
- segment.sourceOut = clip.reverse ? clip.sourceOut - sourceStartOffset : clip.sourceIn + sourceEndOffset;
914
- if (clip.speedRamp) segment.speedRamp = sliceSpeedRamp(clip.speedRamp, sourceStartOffset, sourceEndOffset);
915
- }
916
-
917
- if (clip.wordTimings) {
918
- const words = clip.wordTimings.flatMap((word) => {
919
- const start = Math.max(word.start, localStart);
920
- const end = Math.min(word.end, localEnd);
921
- return end > start ? [{ text: word.text, start: start - localStart, end: end - localStart }] : [];
922
- });
923
- if (!words.length) return undefined;
924
- segment.wordTimings = words;
925
- segment.text = words.map((word) => word.text).join(' ');
926
- }
927
-
928
- if (localStart > 0) {
929
- delete segment.fadeInSeconds;
930
- delete segment.transitionIn;
931
- }
932
- if (localEnd < originalDuration) {
933
- delete segment.fadeOutSeconds;
934
- delete segment.transitionOut;
935
- }
936
- if (segment.fadeInSeconds !== undefined) segment.fadeInSeconds = Math.min(segment.fadeInSeconds, durationSeconds);
937
- if (segment.fadeOutSeconds !== undefined) segment.fadeOutSeconds = Math.min(segment.fadeOutSeconds, durationSeconds - (segment.fadeInSeconds ?? 0));
938
- return segment;
939
- }
940
-
941
- function uniqueRippleClipId(baseId, usedIds) {
942
- let index = 1;
943
- let id = `${baseId}-ripple-right`;
944
- while (usedIds.has(id)) id = `${baseId}-ripple-right-${++index}`;
945
- usedIds.add(id);
946
- return id;
947
- }
948
-
949
- function findClip(timeline, trackId, clipId) {
950
- return findClipEntry(timeline, trackId, clipId).clip;
951
- }
952
-
953
- function findClipEntry(timeline, trackId, clipId) {
954
- validateTimeline(timeline);
955
- const track = timeline.tracks.find((candidate) => candidate.id === trackId);
956
- if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
957
- assertTrackEditable(track);
958
- const index = track.clips.findIndex((candidate) => candidate.id === clipId);
959
- if (index < 0) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
960
- return { track, clip: track.clips[index], index };
961
- }
962
-
963
- function assertTrackEditable(track) {
964
- if (track.locked === true) throw new TypeError(`Track ${track.id} is locked. Unlock it before editing its clips or track settings.`);
965
- }
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { validateTimeline } from './validate.mjs';
3
+ import { sliceVisualKeyframes } from './keyframes.mjs';
4
+ import { sliceSpeedRamp, speedRampDuration, speedRampSourceAtTime } from './speed-ramp.mjs';
5
+ import { validateClipTransition } from './transition.mjs';
6
+ import { frameRateNumber } from '../media/frame-rate.mjs';
7
+ import { sampleAudioVolumeKeyframes, sliceAudioVolumeKeyframes } from './audio-volume.mjs';
8
+
9
+ export function addTimelineTrack(timeline, track) {
10
+ if (!track || typeof track !== 'object' || Array.isArray(track)) throw new TypeError('Timeline track must be an object.');
11
+ const next = structuredClone(timeline);
12
+ if (next.tracks.some((candidate) => candidate.id === track.id)) throw new TypeError(`Timeline track already exists: ${track.id}`);
13
+ next.tracks.push(structuredClone(track));
14
+ return validateTimeline(next);
15
+ }
16
+
17
+ export function removeTimelineTrack(timeline, trackId) {
18
+ const next = structuredClone(timeline);
19
+ const index = next.tracks.findIndex((candidate) => candidate.id === trackId);
20
+ if (index < 0) throw new TypeError(`Unknown timeline track: ${trackId}`);
21
+ assertTrackEditable(next.tracks[index]);
22
+ next.tracks.splice(index, 1);
23
+ return validateTimeline(next);
24
+ }
25
+
26
+ /** Add or replace a timeline review marker by its stable id. */
27
+ export function setTimelineMarker(timeline, marker) {
28
+ validateTimeline(timeline);
29
+ if (!marker || typeof marker !== 'object' || Array.isArray(marker)) throw new TypeError('Timeline marker must be an object.');
30
+ const next = structuredClone(timeline);
31
+ next.markers ??= [];
32
+ const index = next.markers.findIndex((entry) => entry.id === marker.id);
33
+ if (index < 0) next.markers.push(structuredClone(marker));
34
+ else next.markers[index] = structuredClone(marker);
35
+ return validateTimeline(next);
36
+ }
37
+
38
+ /** Remove a timeline review marker by its stable id. */
39
+ export function removeTimelineMarker(timeline, markerId) {
40
+ validateTimeline(timeline);
41
+ if (typeof markerId !== 'string' || !/^[a-z][a-z0-9-]*$/.test(markerId)) throw new TypeError('Timeline marker id must be a kebab-case id.');
42
+ const next = structuredClone(timeline);
43
+ const index = (next.markers ?? []).findIndex((marker) => marker.id === markerId);
44
+ if (index < 0) throw new TypeError(`Unknown timeline marker: ${markerId}`);
45
+ next.markers.splice(index, 1);
46
+ return validateTimeline(next);
47
+ }
48
+
49
+ /** Create a renderable, non-destructive timeline window from project time. */
50
+ export function extractTimelineRange(timeline, startSeconds, endSeconds) {
51
+ validateTimeline(timeline);
52
+ if (!Number.isFinite(startSeconds) || !Number.isFinite(endSeconds)
53
+ || startSeconds < 0 || endSeconds <= startSeconds
54
+ || endSeconds > timeline.project.durationSeconds + 1e-6) {
55
+ throw new TypeError('Timeline range must be a positive time range within the project duration.');
56
+ }
57
+ const end = Math.min(endSeconds, timeline.project.durationSeconds);
58
+ const durationSeconds = end - startSeconds;
59
+ if (durationSeconds < 1 / frameRateNumber(timeline.project.fps) - 1e-9) {
60
+ throw new TypeError('Timeline range must include at least one project frame.');
61
+ }
62
+ const next = structuredClone(timeline);
63
+ next.project.durationSeconds = durationSeconds;
64
+ if (next.markers) {
65
+ next.markers = next.markers
66
+ .filter((marker) => marker.timeSeconds >= startSeconds && marker.timeSeconds <= end)
67
+ .map((marker) => ({ ...marker, timeSeconds: marker.timeSeconds - startSeconds }));
68
+ }
69
+ for (const track of next.tracks) {
70
+ if (track.volumeKeyframes) track.volumeKeyframes = sliceAudioVolumeKeyframes(track.volumeKeyframes, startSeconds, end);
71
+ track.clips = track.clips.flatMap((clip) => {
72
+ const clipStart = clip.timelineStart;
73
+ const clipEnd = clipStart + clip.durationSeconds;
74
+ const overlapStart = Math.max(startSeconds, clipStart);
75
+ const overlapEnd = Math.min(end, clipEnd);
76
+ if (overlapEnd <= overlapStart) return [];
77
+ const localStart = overlapStart - clipStart;
78
+ const localEnd = overlapEnd - clipStart;
79
+ assertRangeDoesNotCutEdgeEffects(clip, localStart, localEnd);
80
+ const segment = sliceTimelineClip(clip, track.kind, localStart, localEnd, overlapStart - startSeconds);
81
+ if (!segment) return [];
82
+ return [segment];
83
+ });
84
+ }
85
+ return validateTimeline(next);
86
+ }
87
+
88
+ function assertRangeDoesNotCutEdgeEffects(clip, localStart, localEnd) {
89
+ if (clip.fadeInSeconds !== undefined && localStart > 0 && localStart < clip.fadeInSeconds) {
90
+ throw new TypeError(`Render range cuts through clip ${clip.id}'s fade-in; start the range before or after the fade.`);
91
+ }
92
+ if (clip.fadeOutSeconds !== undefined && localEnd < clip.durationSeconds
93
+ && localEnd > clip.durationSeconds - clip.fadeOutSeconds) {
94
+ throw new TypeError(`Render range cuts through clip ${clip.id}'s fade-out; end the range before or after the fade.`);
95
+ }
96
+ if (clip.transitionIn && localStart > 0 && localStart < clip.transitionIn.durationSeconds) {
97
+ throw new TypeError(`Render range cuts through clip ${clip.id}'s incoming transition; start the range before or after it.`);
98
+ }
99
+ if (clip.transitionOut && localEnd < clip.durationSeconds
100
+ && localEnd > clip.durationSeconds - clip.transitionOut.durationSeconds) {
101
+ throw new TypeError(`Render range cuts through clip ${clip.id}'s outgoing transition; end the range before or after it.`);
102
+ }
103
+ }
104
+
105
+ export function reorderTimelineTrack(timeline, trackId, order) {
106
+ validateTimeline(timeline);
107
+ if (!Number.isSafeInteger(order) || order < 0 || order >= timeline.tracks.length) {
108
+ throw new TypeError(`Track order must be an integer from 0 to ${timeline.tracks.length - 1}.`);
109
+ }
110
+ const next = structuredClone(timeline);
111
+ const orderedTracks = next.tracks.sort((left, right) => left.order - right.order);
112
+ const currentIndex = orderedTracks.findIndex((track) => track.id === trackId);
113
+ if (currentIndex < 0) throw new TypeError(`Unknown timeline track: ${trackId}`);
114
+ const [track] = orderedTracks.splice(currentIndex, 1);
115
+ orderedTracks.splice(order, 0, track);
116
+ orderedTracks.forEach((candidate, index) => { candidate.order = index; });
117
+ const previousOrders = new Map(timeline.tracks.map((candidate) => [candidate.id, candidate.order]));
118
+ for (const candidate of orderedTracks) {
119
+ if (candidate.locked === true && candidate.order !== previousOrders.get(candidate.id)) {
120
+ throw new TypeError(`Track ${candidate.id} is locked and cannot be reordered.`);
121
+ }
122
+ }
123
+ next.tracks = orderedTracks;
124
+ return validateTimeline(next);
125
+ }
126
+
127
+ export function addTimelineClip(timeline, trackId, clip) {
128
+ if (!clip || typeof clip !== 'object' || Array.isArray(clip)) throw new TypeError('Timeline clip must be an object.');
129
+ const next = structuredClone(timeline);
130
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
131
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
132
+ assertTrackEditable(track);
133
+ if (track.clips.some((candidate) => candidate.id === clip.id)) throw new TypeError(`Timeline clip already exists on track ${trackId}: ${clip.id}`);
134
+ track.clips.push(structuredClone(clip));
135
+ next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
136
+ return validateTimeline(next);
137
+ }
138
+
139
+ export function duplicateTimelineClip(timeline, trackId, clipId, newClipId, timelineStart) {
140
+ if (!/^[a-z][a-z0-9-]*$/.test(newClipId ?? '')) throw new TypeError('newClipId must be a lowercase kebab-case id.');
141
+ if (!Number.isFinite(timelineStart) || timelineStart < 0) throw new TypeError('timelineStart must be a non-negative number.');
142
+ const { clip } = findClipEntry(timeline, trackId, clipId);
143
+ return addTimelineClip(timeline, trackId, {
144
+ ...structuredClone(clip),
145
+ id: newClipId,
146
+ timelineStart,
147
+ });
148
+ }
149
+
150
+ export function detachAudioFromTimelineClip(timeline, videoTrackId, videoClipId, audioTrackId, audioClipId, audioStreamIndex) {
151
+ if (!/^[a-z][a-z0-9-]*$/.test(audioTrackId ?? '') || !/^[a-z][a-z0-9-]*$/.test(audioClipId ?? '')) {
152
+ throw new TypeError('Detached audio track and clip ids must be kebab-case ids.');
153
+ }
154
+ const next = structuredClone(timeline);
155
+ const videoTrack = next.tracks.find((track) => track.id === videoTrackId);
156
+ if (!videoTrack || videoTrack.kind !== 'video') throw new TypeError(`Unknown video track: ${videoTrackId}`);
157
+ assertTrackEditable(videoTrack);
158
+ const videoClip = videoTrack.clips.find((clip) => clip.id === videoClipId);
159
+ if (!videoClip) throw new TypeError(`Unknown clip ${videoClipId} on track ${videoTrackId}.`);
160
+ const asset = next.assets.find((item) => item.id === videoClip.assetId);
161
+ if (asset?.type === 'image' || asset?.hasAudio === false) {
162
+ throw new TypeError(`Video clip ${videoClipId} references media without an audio stream.`);
163
+ }
164
+
165
+ let audioTrack = next.tracks.find((track) => track.id === audioTrackId);
166
+ if (audioTrack && audioTrack.kind !== 'audio') throw new TypeError(`Track ${audioTrackId} is not an audio track.`);
167
+ if (audioTrack) assertTrackEditable(audioTrack);
168
+ if (!audioTrack) {
169
+ if (next.tracks.some((track) => track.id === audioTrackId)) throw new TypeError(`Timeline track already exists: ${audioTrackId}`);
170
+ const order = next.tracks.reduce((maximum, track) => Math.max(maximum, track.order), -1) + 1;
171
+ audioTrack = { id: audioTrackId, kind: 'audio', order, clips: [] };
172
+ next.tracks.push(audioTrack);
173
+ }
174
+ if (audioTrack.clips.some((clip) => clip.id === audioClipId)) {
175
+ throw new TypeError(`Timeline clip already exists on track ${audioTrackId}: ${audioClipId}`);
176
+ }
177
+
178
+ const audioClip = {
179
+ id: audioClipId,
180
+ assetId: videoClip.assetId,
181
+ sourceIn: videoClip.sourceIn,
182
+ sourceOut: videoClip.sourceOut,
183
+ timelineStart: videoClip.timelineStart,
184
+ durationSeconds: videoClip.durationSeconds,
185
+ ...(audioStreamIndex === undefined ? {} : { audioStreamIndex }),
186
+ ...(videoClip.reverse === true ? { reverse: true } : {}),
187
+ ...(videoClip.playbackRate === undefined ? {} : { playbackRate: videoClip.playbackRate }),
188
+ ...(videoClip.speedRamp === undefined ? {} : { speedRamp: structuredClone(videoClip.speedRamp) }),
189
+ };
190
+ audioTrack.clips.push(audioClip);
191
+ return validateTimeline(next);
192
+ }
193
+
194
+ export function removeTimelineClip(timeline, trackId, clipId) {
195
+ const next = structuredClone(timeline);
196
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
197
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
198
+ assertTrackEditable(track);
199
+ const index = track.clips.findIndex((candidate) => candidate.id === clipId);
200
+ if (index < 0) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
201
+ track.clips.splice(index, 1);
202
+ return validateTimeline(next);
203
+ }
204
+
205
+ export function rippleDeleteTimelineClip(timeline, trackId, clipId) {
206
+ validateTimeline(timeline);
207
+ const targetTrack = timeline.tracks.find((candidate) => candidate.id === trackId);
208
+ if (!targetTrack) throw new TypeError(`Unknown timeline track: ${trackId}`);
209
+ const target = targetTrack.clips.find((clip) => clip.id === clipId);
210
+ if (!target) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
211
+ return rippleDeleteTimelineRange(timeline, target.timelineStart, target.timelineStart + target.durationSeconds);
212
+ }
213
+
214
+ export function rippleDeleteTimelineRange(timeline, cutStart, cutEnd) {
215
+ validateTimeline(timeline);
216
+ if (!Number.isFinite(cutStart) || !Number.isFinite(cutEnd)
217
+ || cutStart < 0 || cutEnd <= cutStart || cutEnd > timeline.project.durationSeconds + 1e-6) {
218
+ throw new TypeError('Ripple delete range must be a positive time range within the project duration.');
219
+ }
220
+ cutEnd = Math.min(cutEnd, timeline.project.durationSeconds);
221
+ const cutDuration = cutEnd - cutStart;
222
+ for (const track of timeline.tracks) {
223
+ if (track.locked === true && (track.clips.some((clip) => clip.timelineStart + clip.durationSeconds > cutStart)
224
+ || track.volumeKeyframes !== undefined)) {
225
+ throw new TypeError(`Ripple delete would change locked track ${track.id}. Unlock it before making a synchronized ripple edit.`);
226
+ }
227
+ }
228
+ const next = structuredClone(timeline);
229
+ const usedIds = new Set(next.tracks.flatMap((track) => track.clips.map((clip) => clip.id)));
230
+
231
+ for (const track of next.tracks) {
232
+ if (track.volumeKeyframes) track.volumeKeyframes = rippleTrackVolumeKeyframes(track.volumeKeyframes, cutStart, cutEnd);
233
+ const clips = [];
234
+ for (const clip of track.clips) {
235
+ const clipStart = clip.timelineStart;
236
+ const clipEnd = clipStart + clip.durationSeconds;
237
+ if (clipEnd <= cutStart) {
238
+ clips.push(clip);
239
+ continue;
240
+ }
241
+ if (clipStart >= cutEnd) {
242
+ clip.timelineStart = clipStart - cutDuration;
243
+ clips.push(clip);
244
+ continue;
245
+ }
246
+
247
+ const localCutStart = Math.max(0, cutStart - clipStart);
248
+ const localCutEnd = Math.min(clip.durationSeconds, cutEnd - clipStart);
249
+ const keepLeft = localCutStart > 1e-9;
250
+ const keepRight = clip.durationSeconds - localCutEnd > 1e-9;
251
+ if (keepLeft) clips.push(sliceTimelineClip(clip, track.kind, 0, localCutStart, clipStart));
252
+ if (keepRight) {
253
+ const rightStart = Math.max(clipStart, cutEnd) - cutDuration;
254
+ const right = sliceTimelineClip(clip, track.kind, localCutEnd, clip.durationSeconds, rightStart);
255
+ if (keepLeft) right.id = uniqueRippleClipId(clip.id, usedIds);
256
+ clips.push(right);
257
+ }
258
+ }
259
+ track.clips = clips.filter(Boolean);
260
+ }
261
+
262
+ next.project.durationSeconds = Math.max(1 / frameRateNumber(next.project.fps), timeline.project.durationSeconds - cutDuration);
263
+ if (next.markers) {
264
+ next.markers = next.markers
265
+ .filter((marker) => marker.timeSeconds < cutStart || marker.timeSeconds >= cutEnd)
266
+ .map((marker) => marker.timeSeconds < cutStart ? marker : { ...marker, timeSeconds: marker.timeSeconds - cutDuration });
267
+ }
268
+ return validateTimeline(next);
269
+ }
270
+
271
+ export function insertTimelineGap(timeline, timelineStart, durationSeconds) {
272
+ validateTimeline(timeline);
273
+ if (!Number.isFinite(timelineStart) || timelineStart < 0 || timelineStart > timeline.project.durationSeconds) {
274
+ throw new TypeError('timelineStart must be within the project duration.');
275
+ }
276
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
277
+ throw new TypeError('durationSeconds must be a positive number.');
278
+ }
279
+ for (const track of timeline.tracks) {
280
+ if (track.locked === true && (track.clips.some((clip) => clip.timelineStart + clip.durationSeconds > timelineStart)
281
+ || track.volumeKeyframes !== undefined)) {
282
+ throw new TypeError(`Gap insertion would change locked track ${track.id}. Unlock it before making a synchronized timeline edit.`);
283
+ }
284
+ }
285
+
286
+ const next = structuredClone(timeline);
287
+ if (next.markers) {
288
+ next.markers = next.markers.map((marker) => marker.timeSeconds >= timelineStart
289
+ ? { ...marker, timeSeconds: marker.timeSeconds + durationSeconds }
290
+ : marker);
291
+ }
292
+ const usedIds = new Set(next.tracks.flatMap((track) => track.clips.map((clip) => clip.id)));
293
+ for (const track of next.tracks) {
294
+ if (track.volumeKeyframes) track.volumeKeyframes = insertTrackVolumeGap(track.volumeKeyframes, timelineStart, durationSeconds);
295
+ const clips = [];
296
+ for (const clip of track.clips) {
297
+ const clipStart = clip.timelineStart;
298
+ const clipEnd = clipStart + clip.durationSeconds;
299
+ if (clipStart >= timelineStart) {
300
+ clip.timelineStart += durationSeconds;
301
+ clips.push(clip);
302
+ continue;
303
+ }
304
+ if (clipEnd <= timelineStart) {
305
+ clips.push(clip);
306
+ continue;
307
+ }
308
+
309
+ const splitOffset = timelineStart - clipStart;
310
+ const left = sliceTimelineClip(clip, track.kind, 0, splitOffset, clipStart);
311
+ const right = sliceTimelineClip(clip, track.kind, splitOffset, clip.durationSeconds, timelineStart + durationSeconds);
312
+ if (left) clips.push(left);
313
+ if (right) {
314
+ right.id = uniqueRippleClipId(`${clip.id}-gap-right`, usedIds);
315
+ clips.push(right);
316
+ }
317
+ }
318
+ track.clips = clips;
319
+ }
320
+
321
+ next.project.durationSeconds += durationSeconds;
322
+ return validateTimeline(next);
323
+ }
324
+
325
+ function rippleTrackVolumeKeyframes(keyframes, cutStart, cutEnd) {
326
+ const cutDuration = cutEnd - cutStart;
327
+ const retained = keyframes.filter((keyframe) => keyframe.time < cutStart)
328
+ .concat(keyframes.filter((keyframe) => keyframe.time >= cutEnd)
329
+ .map((keyframe) => ({ ...keyframe, time: keyframe.time - cutDuration })));
330
+ if (!retained.some((keyframe) => Math.abs(keyframe.time - cutStart) < 1e-9)) {
331
+ retained.push({ time: cutStart, volume: sampleAudioVolumeKeyframes(keyframes, cutEnd) });
332
+ }
333
+ return retained.sort((left, right) => left.time - right.time);
334
+ }
335
+
336
+ function insertTrackVolumeGap(keyframes, timelineStart, durationSeconds) {
337
+ const retained = keyframes.filter((keyframe) => keyframe.time <= timelineStart)
338
+ .concat(keyframes.filter((keyframe) => keyframe.time > timelineStart)
339
+ .map((keyframe) => ({ ...keyframe, time: keyframe.time + durationSeconds })));
340
+ const heldVolume = sampleAudioVolumeKeyframes(keyframes, timelineStart);
341
+ if (!retained.some((keyframe) => Math.abs(keyframe.time - timelineStart) < 1e-9)) {
342
+ retained.push({ time: timelineStart, volume: heldVolume });
343
+ }
344
+ if (!retained.some((keyframe) => Math.abs(keyframe.time - (timelineStart + durationSeconds)) < 1e-9)) {
345
+ retained.push({ time: timelineStart + durationSeconds, volume: heldVolume });
346
+ }
347
+ return retained.sort((left, right) => left.time - right.time);
348
+ }
349
+
350
+ export function moveTimelineClip(timeline, trackId, clipId, timelineStart) {
351
+ if (!Number.isFinite(timelineStart) || timelineStart < 0) throw new TypeError('timelineStart must be a non-negative number.');
352
+ const next = structuredClone(timeline);
353
+ const clip = findClip(next, trackId, clipId);
354
+ clip.timelineStart = timelineStart;
355
+ next.project.durationSeconds = Math.max(next.project.durationSeconds, timelineStart + clip.durationSeconds);
356
+ return validateTimeline(next);
357
+ }
358
+
359
+ export function moveTimelineClipToTrack(timeline, trackId, clipId, targetTrackId, timelineStart) {
360
+ if (timelineStart !== undefined && (!Number.isFinite(timelineStart) || timelineStart < 0)) {
361
+ throw new TypeError('timelineStart must be a non-negative number.');
362
+ }
363
+ if (trackId === targetTrackId) {
364
+ findClipEntry(timeline, trackId, clipId);
365
+ return timelineStart === undefined ? validateTimeline(structuredClone(timeline)) : moveTimelineClip(timeline, trackId, clipId, timelineStart);
366
+ }
367
+
368
+ const next = structuredClone(timeline);
369
+ const { track: sourceTrack, clip, index } = findClipEntry(next, trackId, clipId);
370
+ const targetTrack = next.tracks.find((candidate) => candidate.id === targetTrackId);
371
+ if (!targetTrack) throw new TypeError(`Unknown timeline track: ${targetTrackId}`);
372
+ assertTrackEditable(targetTrack);
373
+ if (sourceTrack.kind !== targetTrack.kind) {
374
+ throw new TypeError(`Cannot move a ${sourceTrack.kind} clip to a track of kind ${targetTrack.kind}.`);
375
+ }
376
+ if (targetTrack.clips.some((candidate) => candidate.id === clipId)) {
377
+ throw new TypeError(`Timeline clip already exists on track ${targetTrackId}: ${clipId}`);
378
+ }
379
+
380
+ sourceTrack.clips.splice(index, 1);
381
+ if (timelineStart !== undefined) clip.timelineStart = timelineStart;
382
+ targetTrack.clips.push(clip);
383
+ next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
384
+ return validateTimeline(next);
385
+ }
386
+
387
+ export function replaceTimelineClipAsset(timeline, trackId, clipId, assetId, options = {}) {
388
+ validateTimeline(timeline);
389
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Replacement media options must be an object.');
390
+ const allowed = new Set(['sourceIn', 'sourceOut', 'audioStreamIndex', 'videoStreamIndex', 'playbackRate', 'reverse', 'speedRamp']);
391
+ for (const field of Object.keys(options)) {
392
+ if (!allowed.has(field)) throw new TypeError(`Unsupported replacement media option: ${field}.`);
393
+ if (options[field] === undefined) throw new TypeError('Replacement media options must not be undefined.');
394
+ }
395
+ if (typeof assetId !== 'string' || !/^[a-z][a-z0-9-]*$/.test(assetId)) throw new TypeError('Replacement asset id must be a lowercase kebab-case id.');
396
+ const next = structuredClone(timeline);
397
+ const { track, clip } = findClipEntry(next, trackId, clipId);
398
+ if (track.kind === 'overlay') throw new TypeError('Overlay clips do not reference media assets.');
399
+ if (!next.assets.some((asset) => asset.id === assetId)) throw new TypeError(`Unknown timeline asset: ${assetId}`);
400
+ clip.assetId = assetId;
401
+ if (options.sourceIn !== undefined) clip.sourceIn = options.sourceIn;
402
+ if (options.sourceOut !== undefined) clip.sourceOut = options.sourceOut;
403
+ if (options.audioStreamIndex === null) delete clip.audioStreamIndex;
404
+ else if (options.audioStreamIndex !== undefined) clip.audioStreamIndex = options.audioStreamIndex;
405
+ if (options.videoStreamIndex === null) delete clip.videoStreamIndex;
406
+ else if (options.videoStreamIndex !== undefined) clip.videoStreamIndex = options.videoStreamIndex;
407
+ if (options.playbackRate === null) delete clip.playbackRate;
408
+ else if (options.playbackRate !== undefined) clip.playbackRate = options.playbackRate;
409
+ if (options.reverse !== undefined) clip.reverse = options.reverse;
410
+ if (options.speedRamp === null) delete clip.speedRamp;
411
+ else if (options.speedRamp !== undefined) clip.speedRamp = structuredClone(options.speedRamp);
412
+ return validateTimeline(next);
413
+ }
414
+
415
+ export function setTimelineClipSpeed(timeline, trackId, clipId, speed) {
416
+ validateTimeline(timeline);
417
+ if (!speed || typeof speed !== 'object' || Array.isArray(speed)) throw new TypeError('Clip speed must be an object.');
418
+ const fields = Object.keys(speed);
419
+ if (fields.length !== 1 || !['playbackRate', 'speedRamp'].includes(fields[0])) {
420
+ throw new TypeError('Set exactly one of playbackRate or speedRamp.');
421
+ }
422
+ const next = structuredClone(timeline);
423
+ const { track, clip } = findClipEntry(next, trackId, clipId);
424
+ if (track.kind !== 'video' && track.kind !== 'audio') throw new TypeError('Playback speed is supported on video and audio clips.');
425
+ const asset = next.assets.find((entry) => entry.id === clip.assetId);
426
+ if (track.kind === 'video' && asset?.type === 'image') throw new TypeError('Still-image clips do not support playback speed.');
427
+ const sourceSpan = clip.sourceOut - clip.sourceIn;
428
+ const previousDuration = clip.durationSeconds;
429
+ if (Object.hasOwn(speed, 'playbackRate')) {
430
+ const playbackRate = speed.playbackRate === null ? 1 : speed.playbackRate;
431
+ if (!Number.isFinite(playbackRate) || playbackRate < 0.1 || playbackRate > 5) {
432
+ throw new TypeError('Playback rate must be from 0.1 to 5, or null to reset to normal speed.');
433
+ }
434
+ delete clip.speedRamp;
435
+ if (playbackRate === 1) delete clip.playbackRate;
436
+ else clip.playbackRate = playbackRate;
437
+ clip.durationSeconds = sourceSpan / playbackRate;
438
+ } else if (speed.speedRamp === null) {
439
+ delete clip.speedRamp;
440
+ delete clip.playbackRate;
441
+ clip.durationSeconds = sourceSpan;
442
+ } else {
443
+ if (clip.reverse) throw new TypeError('Reverse playback cannot be combined with a speed ramp.');
444
+ if (!Array.isArray(speed.speedRamp) || speed.speedRamp.length < 2) throw new TypeError('Speed ramp must contain at least two points.');
445
+ clip.speedRamp = structuredClone(speed.speedRamp);
446
+ delete clip.playbackRate;
447
+ clip.durationSeconds = speedRampDuration(clip.speedRamp);
448
+ }
449
+ scaleClipLocalTiming(clip, previousDuration, clip.durationSeconds);
450
+ next.project.durationSeconds = Math.max(next.project.durationSeconds, clip.timelineStart + clip.durationSeconds);
451
+ return validateTimeline(next);
452
+ }
453
+
454
+ function scaleClipLocalTiming(clip, previousDuration, nextDuration) {
455
+ const scale = nextDuration / previousDuration;
456
+ for (const frame of clip.keyframes ?? []) frame.time *= scale;
457
+ for (const keyframe of clip.volumeKeyframes ?? []) keyframe.time *= scale;
458
+ for (const word of clip.wordTimings ?? []) {
459
+ word.start *= scale;
460
+ word.end *= scale;
461
+ }
462
+ for (const field of ['fadeInSeconds', 'fadeOutSeconds']) {
463
+ if (clip[field] !== undefined) clip[field] *= scale;
464
+ }
465
+ for (const field of ['transitionIn', 'transitionOut']) {
466
+ if (clip[field] !== undefined) clip[field].durationSeconds *= scale;
467
+ }
468
+ }
469
+
470
+ export function trimTimelineClip(timeline, trackId, clipId, sourceIn, sourceOut) {
471
+ if (!Number.isFinite(sourceIn) || !Number.isFinite(sourceOut)) throw new TypeError('Trim source bounds must be finite numbers.');
472
+ const next = structuredClone(timeline);
473
+ const { track, clip } = findClipEntry(next, trackId, clipId);
474
+ if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-trimmed.');
475
+ if (sourceIn < clip.sourceIn || sourceOut > clip.sourceOut || sourceOut <= sourceIn) throw new TypeError('Trim bounds must be a positive subrange of the existing source range.');
476
+ const sourceOffset = sourceIn - clip.sourceIn;
477
+ const sourceEndOffset = sourceOut - clip.sourceIn;
478
+ const trimBefore = clip.reverse
479
+ ? (clip.sourceOut - sourceOut) / (clip.playbackRate ?? 1)
480
+ : clip.speedRamp
481
+ ? speedRampDuration(clip.speedRamp, 0, sourceOffset)
482
+ : sourceOffset / (clip.playbackRate ?? 1);
483
+ const durationSeconds = clip.speedRamp
484
+ ? speedRampDuration(clip.speedRamp, sourceOffset, sourceEndOffset)
485
+ : (sourceOut - sourceIn) / (clip.playbackRate ?? 1);
486
+ clip.keyframes = sliceVisualKeyframes(clip.keyframes, trimBefore, trimBefore + durationSeconds);
487
+ clip.volumeKeyframes = sliceAudioVolumeKeyframes(clip.volumeKeyframes, trimBefore, trimBefore + durationSeconds);
488
+ if (clip.speedRamp) clip.speedRamp = sliceSpeedRamp(clip.speedRamp, sourceOffset, sourceEndOffset);
489
+ clip.timelineStart += trimBefore;
490
+ clip.sourceIn = sourceIn;
491
+ clip.sourceOut = sourceOut;
492
+ clip.durationSeconds = durationSeconds;
493
+ clip.fadeInSeconds = Math.min(clip.fadeInSeconds ?? 0, clip.durationSeconds);
494
+ clip.fadeOutSeconds = Math.min(clip.fadeOutSeconds ?? 0, clip.durationSeconds - clip.fadeInSeconds);
495
+ return validateTimeline(next);
496
+ }
497
+
498
+ export function slipTimelineClip(timeline, trackId, clipId, sourceDelta) {
499
+ validateTimeline(timeline);
500
+ if (!Number.isFinite(sourceDelta)) throw new TypeError('sourceDelta must be a finite number.');
501
+ const next = structuredClone(timeline);
502
+ const { track, clip } = findClipEntry(next, trackId, clipId);
503
+ if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-slipped.');
504
+ clip.sourceIn += sourceDelta;
505
+ clip.sourceOut += sourceDelta;
506
+ return validateTimeline(next);
507
+ }
508
+
509
+ export function splitTimelineClip(timeline, trackId, clipId, splitAt, rightClipId = `${clipId}-right`) {
510
+ if (!Number.isFinite(splitAt)) throw new TypeError('splitAt must be a finite timeline time.');
511
+ if (!/^[a-z][a-z0-9-]*$/.test(rightClipId)) throw new TypeError('rightClipId must be a lowercase kebab-case id.');
512
+ const next = structuredClone(timeline);
513
+ const { track, clip, index } = findClipEntry(next, trackId, clipId);
514
+ if (track.kind === 'overlay') throw new TypeError('Overlay clips cannot be source-split.');
515
+ const splitOffset = splitAt - clip.timelineStart;
516
+ if (splitOffset <= 0 || splitOffset >= clip.durationSeconds) throw new TypeError('splitAt must fall strictly inside the clip.');
517
+ if (track.clips.some((candidate) => candidate.id === rightClipId)) throw new TypeError(`Clip id already exists on track ${trackId}: ${rightClipId}`);
518
+
519
+ const originalDuration = clip.durationSeconds;
520
+ const originalKeyframes = clip.keyframes;
521
+ const originalVolumeKeyframes = clip.volumeKeyframes;
522
+ const originalSpeedRamp = clip.speedRamp;
523
+ const originalStart = clip.sourceIn;
524
+ const originalEnd = clip.sourceOut;
525
+ const sourceSplit = clip.reverse
526
+ ? originalEnd - splitOffset * (clip.playbackRate ?? 1)
527
+ : originalStart + (originalSpeedRamp
528
+ ? speedRampSourceAtTime(originalSpeedRamp, splitOffset)
529
+ : splitOffset * (clip.playbackRate ?? 1));
530
+ const originalFadeOut = clip.fadeOutSeconds ?? 0;
531
+ const originalFadeIn = clip.fadeInSeconds ?? 0;
532
+ const originalTransitionOut = clip.transitionOut;
533
+ if (clip.reverse) clip.sourceIn = sourceSplit;
534
+ else clip.sourceOut = sourceSplit;
535
+ clip.durationSeconds = splitOffset;
536
+ clip.keyframes = sliceVisualKeyframes(originalKeyframes, 0, splitOffset);
537
+ clip.volumeKeyframes = sliceAudioVolumeKeyframes(originalVolumeKeyframes, 0, splitOffset);
538
+ if (originalSpeedRamp) clip.speedRamp = sliceSpeedRamp(originalSpeedRamp, 0, sourceSplit - clip.sourceIn);
539
+ clip.fadeInSeconds = Math.min(originalFadeIn, splitOffset);
540
+ clip.fadeOutSeconds = 0;
541
+ delete clip.transitionOut;
542
+ const right = {
543
+ ...clip,
544
+ id: rightClipId,
545
+ sourceIn: clip.reverse ? originalStart : sourceSplit,
546
+ sourceOut: clip.reverse ? sourceSplit : originalEnd,
547
+ timelineStart: splitAt,
548
+ durationSeconds: originalDuration - splitOffset,
549
+ keyframes: sliceVisualKeyframes(originalKeyframes, splitOffset, originalDuration),
550
+ volumeKeyframes: sliceAudioVolumeKeyframes(originalVolumeKeyframes, splitOffset, originalDuration),
551
+ ...(originalSpeedRamp ? { speedRamp: sliceSpeedRamp(originalSpeedRamp, sourceSplit - clip.sourceIn, originalSpeedRamp.at(-1).sourceOffset) } : {}),
552
+ fadeInSeconds: 0,
553
+ fadeOutSeconds: Math.min(originalFadeOut, originalDuration - splitOffset),
554
+ };
555
+ delete right.transitionIn;
556
+ if (originalTransitionOut) right.transitionOut = originalTransitionOut;
557
+ track.clips.splice(index + 1, 0, right);
558
+ return validateTimeline(next);
559
+ }
560
+
561
+ export function crossfadeTimelineClips(timeline, trackId, outgoingClipId, incomingClipId, durationSeconds) {
562
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new TypeError('Crossfade duration must be a positive number.');
563
+ const next = structuredClone(timeline);
564
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
565
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
566
+ assertTrackEditable(track);
567
+ const outgoing = track.clips.find((clip) => clip.id === outgoingClipId);
568
+ const incoming = track.clips.find((clip) => clip.id === incomingClipId);
569
+ if (!outgoing || !incoming) throw new TypeError(`Crossfade clips must both belong to track ${trackId}.`);
570
+ if (outgoing === incoming) throw new TypeError('A crossfade requires two different clips.');
571
+ const outgoingEnd = outgoing.timelineStart + outgoing.durationSeconds;
572
+ const overlapSeconds = outgoingEnd - incoming.timelineStart;
573
+ if (Math.abs(overlapSeconds - durationSeconds) > 1e-6) throw new TypeError('Crossfade clips must already overlap by the requested duration.');
574
+ if (durationSeconds > outgoing.durationSeconds || durationSeconds > incoming.durationSeconds) throw new TypeError('Crossfade duration cannot exceed either clip duration.');
575
+ if (outgoing.timelineStart >= incoming.timelineStart) throw new TypeError('Crossfade clips must be ordered outgoing then incoming.');
576
+ if (track.kind === 'audio') outgoing.fadeOutSeconds = durationSeconds;
577
+ else delete outgoing.fadeOutSeconds;
578
+ incoming.fadeInSeconds = durationSeconds;
579
+ return validateTimeline(next);
580
+ }
581
+
582
+ export function setTimelineTransition(timeline, trackId, clipId, edge, transition) {
583
+ if (!['in', 'out'].includes(edge)) throw new TypeError('Transition edge must be in or out.');
584
+ const next = structuredClone(timeline);
585
+ const { track, clip } = findClipEntry(next, trackId, clipId);
586
+ if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError('Transitions are supported on video and overlay tracks.');
587
+ const field = edge === 'in' ? 'transitionIn' : 'transitionOut';
588
+ if (transition === null) delete clip[field];
589
+ else clip[field] = validateClipTransition(transition, clip, `Clip ${clip.id} ${field}`);
590
+ return validateTimeline(next);
591
+ }
592
+
593
+ export function setTimelineClipReverse(timeline, trackId, clipId, reverse) {
594
+ if (typeof reverse !== 'boolean') throw new TypeError('reverse must be a boolean.');
595
+ const next = structuredClone(timeline);
596
+ const { track, clip } = findClipEntry(next, trackId, clipId);
597
+ if (track.kind !== 'video' && track.kind !== 'audio') throw new TypeError(`Reverse playback is supported on video and audio tracks, not ${track.kind}.`);
598
+ if (reverse) clip.reverse = true;
599
+ else delete clip.reverse;
600
+ return validateTimeline(next);
601
+ }
602
+
603
+ export function setTimelineClipAudioEffects(timeline, trackId, clipId, effects) {
604
+ if (effects !== null && !Array.isArray(effects)) throw new TypeError('audioEffects must be an array or null.');
605
+ const next = structuredClone(timeline);
606
+ const { track, clip } = findClipEntry(next, trackId, clipId);
607
+ if (track.kind !== 'audio') throw new TypeError(`Audio effects are supported on audio tracks, not ${track.kind}.`);
608
+ if (effects === null || effects.length === 0) delete clip.audioEffects;
609
+ else clip.audioEffects = structuredClone(effects);
610
+ return validateTimeline(next);
611
+ }
612
+
613
+ export function setTimelineClipVolumeKeyframes(timeline, trackId, clipId, keyframes) {
614
+ if (keyframes !== null && !Array.isArray(keyframes)) throw new TypeError('volumeKeyframes must be an array or null.');
615
+ const next = structuredClone(timeline);
616
+ const { track, clip } = findClipEntry(next, trackId, clipId);
617
+ if (track.kind !== 'audio') throw new TypeError(`Volume keyframes are supported on audio tracks, not ${track.kind}.`);
618
+ if (keyframes === null || keyframes.length === 0) delete clip.volumeKeyframes;
619
+ else clip.volumeKeyframes = structuredClone(keyframes);
620
+ return validateTimeline(next);
621
+ }
622
+
623
+ export function setTimelineClipAudioState(timeline, trackId, clipId, state) {
624
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Audio state must be an object.');
625
+ const fields = Object.keys(state);
626
+ const allowed = ['volume', 'fadeInSeconds', 'fadeOutSeconds', 'audioStreamIndex'];
627
+ if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
628
+ throw new TypeError(`Audio state must set one or more of: ${allowed.join(', ')}.`);
629
+ }
630
+ if (fields.some((field) => state[field] === undefined)) throw new TypeError('Audio state values must not be undefined; use null to clear an override.');
631
+ const next = structuredClone(timeline);
632
+ const { track, clip } = findClipEntry(next, trackId, clipId);
633
+ if (track.kind !== 'audio') throw new TypeError(`Audio state is supported on audio tracks, not ${track.kind}.`);
634
+ for (const field of fields) {
635
+ if (state[field] === null) delete clip[field];
636
+ else clip[field] = state[field];
637
+ }
638
+ return validateTimeline(next);
639
+ }
640
+
641
+ export function setTimelineClipVideoState(timeline, trackId, clipId, state) {
642
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Video state must be an object.');
643
+ const fields = Object.keys(state);
644
+ if (fields.length !== 1 || fields[0] !== 'videoStreamIndex') {
645
+ throw new TypeError('Video state must set videoStreamIndex.');
646
+ }
647
+ if (state.videoStreamIndex === undefined) throw new TypeError('Video state values must not be undefined; use null to clear an override.');
648
+ const next = structuredClone(timeline);
649
+ const { track, clip } = findClipEntry(next, trackId, clipId);
650
+ if (track.kind !== 'video') throw new TypeError(`Video state is supported on video tracks, not ${track.kind}.`);
651
+ if (state.videoStreamIndex === null) delete clip.videoStreamIndex;
652
+ else clip.videoStreamIndex = state.videoStreamIndex;
653
+ return validateTimeline(next);
654
+ }
655
+
656
+ export function setTimelineClipKeyframes(timeline, trackId, clipId, keyframes) {
657
+ if (keyframes !== null && !Array.isArray(keyframes)) throw new TypeError('keyframes must be an array or null.');
658
+ const next = structuredClone(timeline);
659
+ const { track, clip } = findClipEntry(next, trackId, clipId);
660
+ if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError(`Visual keyframes are supported on video and overlay tracks, not ${track.kind}.`);
661
+ if (keyframes === null || keyframes.length === 0) delete clip.keyframes;
662
+ else clip.keyframes = structuredClone(keyframes);
663
+ return validateTimeline(next);
664
+ }
665
+
666
+ export function setTimelineClipFraming(timeline, trackId, clipId, framing) {
667
+ if (!framing || typeof framing !== 'object' || Array.isArray(framing)) throw new TypeError('framing must be an object.');
668
+ const fields = Object.keys(framing);
669
+ if (fields.length === 0 || fields.some((field) => !['fit', 'x', 'y', 'width', 'height'].includes(field))) {
670
+ throw new TypeError('framing must set one or more of fit, x, y, width, or height.');
671
+ }
672
+ if (fields.some((field) => framing[field] === undefined)) throw new TypeError('framing values must not be undefined; use null to clear an override.');
673
+ const next = structuredClone(timeline);
674
+ const { track, clip } = findClipEntry(next, trackId, clipId);
675
+ if (track.kind !== 'video') throw new TypeError(`Static framing is supported on video tracks, not ${track.kind}.`);
676
+ for (const field of fields) {
677
+ if (framing[field] === null) delete clip[field];
678
+ else clip[field] = framing[field];
679
+ }
680
+ return validateTimeline(next);
681
+ }
682
+
683
+ export function setTimelineTrackState(timeline, trackId, state) {
684
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Track state must be an object.');
685
+ const next = structuredClone(timeline);
686
+ validateTimeline(next);
687
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
688
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
689
+ const allowed = track.kind === 'audio' ? ['muted', 'solo', 'volume', 'volumeKeyframes', 'audioEffects', 'locked'] : ['visible', 'locked'];
690
+ const fields = Object.keys(state);
691
+ if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
692
+ throw new TypeError(`Track ${trackId} state must set one or more of: ${allowed.join(', ')}.`);
693
+ }
694
+ if (fields.some((field) => state[field] === undefined)) throw new TypeError('Track state values must not be undefined; use null to clear an override.');
695
+ if (fields.includes('locked') && fields.length !== 1) throw new TypeError('Set or clear track lock state in a separate operation.');
696
+ if (track.locked === true && fields.some((field) => field !== 'locked')) {
697
+ throw new TypeError(`Track ${trackId} is locked. Unlock it before editing track state.`);
698
+ }
699
+ for (const field of fields) {
700
+ if (state[field] === null) delete track[field];
701
+ else track[field] = state[field];
702
+ }
703
+ return validateTimeline(next);
704
+ }
705
+
706
+ export function setTimelineClipState(timeline, trackId, clipId, state) {
707
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Clip state must be an object.');
708
+ const fields = Object.keys(state);
709
+ if (fields.length !== 1 || fields[0] !== 'enabled') throw new TypeError('Clip state must set enabled.');
710
+ if (state.enabled !== null && typeof state.enabled !== 'boolean') throw new TypeError('Clip enabled must be a boolean or null.');
711
+ const next = structuredClone(timeline);
712
+ const { clip } = findClipEntry(next, trackId, clipId);
713
+ if (state.enabled === null) delete clip.enabled;
714
+ else clip.enabled = state.enabled;
715
+ return validateTimeline(next);
716
+ }
717
+
718
+ export function setTimelineTrackAudioEffects(timeline, trackId, effects) {
719
+ if (effects !== null && !Array.isArray(effects)) throw new TypeError('Track audioEffects must be an array or null.');
720
+ const next = structuredClone(timeline);
721
+ validateTimeline(next);
722
+ const track = next.tracks.find((candidate) => candidate.id === trackId);
723
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
724
+ if (track.kind !== 'audio') throw new TypeError(`Track audio effects are supported on audio tracks, not ${track.kind}.`);
725
+ assertTrackEditable(track);
726
+ if (effects === null || effects.length === 0) delete track.audioEffects;
727
+ else track.audioEffects = structuredClone(effects);
728
+ return validateTimeline(next);
729
+ }
730
+
731
+ export function setTimelineAudioMix(timeline, audioMix) {
732
+ if (audioMix === undefined) throw new TypeError('audioMix must be an object or null.');
733
+ const next = structuredClone(timeline);
734
+ if (audioMix === null) delete next.audioMix;
735
+ else next.audioMix = structuredClone(audioMix);
736
+ return validateTimeline(next);
737
+ }
738
+
739
+ export function setTimelineAudioMastering(timeline, audioMastering) {
740
+ if (audioMastering === undefined) throw new TypeError('audioMastering must be an object or null.');
741
+ const next = structuredClone(timeline);
742
+ if (audioMastering === null) delete next.audioMastering;
743
+ else next.audioMastering = structuredClone(audioMastering);
744
+ return validateTimeline(next);
745
+ }
746
+
747
+ export function setTimelineProjectState(timeline, state) {
748
+ if (!state || typeof state !== 'object' || Array.isArray(state)) throw new TypeError('Project state must be an object.');
749
+ const fields = Object.keys(state);
750
+ if (fields.length === 0 || fields.some((field) => !['durationSeconds', 'sourceFit', 'backgroundColor'].includes(field))) {
751
+ throw new TypeError('Project state must set one or more of: durationSeconds, sourceFit, backgroundColor.');
752
+ }
753
+ if (fields.some((field) => state[field] === undefined)) throw new TypeError('Project state values must not be undefined; use null to clear sourceFit or make the background transparent.');
754
+ const next = structuredClone(timeline);
755
+ for (const field of fields) {
756
+ if (field === 'sourceFit' && state[field] === null) delete next.project.sourceFit;
757
+ else next.project[field] = structuredClone(state[field]);
758
+ }
759
+ return validateTimeline(next);
760
+ }
761
+
762
+ export function setTimelineOverlay(timeline, trackId, clipId, properties) {
763
+ if (!properties || typeof properties !== 'object' || Array.isArray(properties)) throw new TypeError('Overlay properties must be an object.');
764
+ const allowed = [
765
+ 'text', 'composition', 'x', 'y', 'width', 'height', 'fontSize', 'fontFamily', 'fontWeight',
766
+ 'lineHeight', 'verticalAlign', 'paddingPixels', 'color', 'backgroundColor', 'textAlign',
767
+ 'wordWrap', 'wordsPerLine', 'wordTimings', 'wordHighlightColor', 'wordHighlightAnimation',
768
+ 'textOutline', 'textShadow', 'fadeInSeconds', 'fadeOutSeconds', 'keyframes', 'transitionIn', 'transitionOut',
769
+ 'bubble',
770
+ ];
771
+ const fields = Object.keys(properties);
772
+ if (fields.length === 0 || fields.some((field) => !allowed.includes(field))) {
773
+ throw new TypeError(`Overlay properties must set one or more of: ${allowed.join(', ')}.`);
774
+ }
775
+ if (fields.some((field) => properties[field] === undefined)) throw new TypeError('Overlay property values must not be undefined; use null to clear an override.');
776
+ const next = structuredClone(timeline);
777
+ const { track, clip } = findClipEntry(next, trackId, clipId);
778
+ if (track.kind !== 'overlay') throw new TypeError(`Overlay properties are supported on overlay tracks, not ${track.kind}.`);
779
+ for (const field of fields) {
780
+ if (properties[field] === null) delete clip[field];
781
+ else clip[field] = structuredClone(properties[field]);
782
+ }
783
+ return validateTimeline(next);
784
+ }
785
+
786
+ export function editTimeline(timeline, operation) {
787
+ if (!operation || typeof operation !== 'object') throw new TypeError('Timeline edit operation must be an object.');
788
+ const { type, trackId, clipId } = operation;
789
+ if (type === 'add-track') return addTimelineTrack(timeline, operation.track);
790
+ if (type === 'set-marker') return setTimelineMarker(timeline, operation.marker);
791
+ if (type === 'remove-marker') return removeTimelineMarker(timeline, operation.markerId);
792
+ if (type === 'remove-track') return removeTimelineTrack(timeline, operation.trackId);
793
+ if (type === 'reorder-track') return reorderTimelineTrack(timeline, operation.trackId, operation.order);
794
+ if (type === 'add-clip') return addTimelineClip(timeline, trackId, operation.clip);
795
+ if (type === 'duplicate-clip') return duplicateTimelineClip(timeline, trackId, clipId, operation.newClipId, operation.timelineStart);
796
+ if (type === 'detach-audio') return detachAudioFromTimelineClip(timeline, trackId, clipId, operation.audioTrackId, operation.audioClipId, operation.audioStreamIndex);
797
+ if (type === 'remove-clip') return removeTimelineClip(timeline, trackId, clipId);
798
+ if (type === 'ripple-delete') return rippleDeleteTimelineClip(timeline, trackId, clipId);
799
+ if (type === 'ripple-delete-range') return rippleDeleteTimelineRange(timeline, operation.startSeconds, operation.endSeconds);
800
+ if (type === 'insert-gap') return insertTimelineGap(timeline, operation.timelineStart, operation.durationSeconds);
801
+ if (type === 'move') return moveTimelineClip(timeline, trackId, clipId, operation.timelineStart);
802
+ if (type === 'move-track') return moveTimelineClipToTrack(timeline, trackId, clipId, operation.targetTrackId, operation.timelineStart);
803
+ if (type === 'set-speed') return setTimelineClipSpeed(timeline, trackId, clipId, operation.speed);
804
+ if (type === 'replace-asset') return replaceTimelineClipAsset(timeline, trackId, clipId, operation.assetId, {
805
+ ...(operation.sourceIn === undefined ? {} : { sourceIn: operation.sourceIn }),
806
+ ...(operation.sourceOut === undefined ? {} : { sourceOut: operation.sourceOut }),
807
+ ...(operation.audioStreamIndex === undefined ? {} : { audioStreamIndex: operation.audioStreamIndex }),
808
+ ...(operation.videoStreamIndex === undefined ? {} : { videoStreamIndex: operation.videoStreamIndex }),
809
+ ...(operation.playbackRate === undefined ? {} : { playbackRate: operation.playbackRate }),
810
+ ...(operation.reverse === undefined ? {} : { reverse: operation.reverse }),
811
+ ...(operation.speedRamp === undefined ? {} : { speedRamp: operation.speedRamp }),
812
+ });
813
+ if (type === 'trim') return trimTimelineClip(timeline, trackId, clipId, operation.sourceIn, operation.sourceOut);
814
+ if (type === 'slip') return slipTimelineClip(timeline, trackId, clipId, operation.sourceDelta);
815
+ if (type === 'split') return splitTimelineClip(timeline, trackId, clipId, operation.splitAt, operation.rightClipId);
816
+ if (type === 'crossfade') return crossfadeTimelineClips(timeline, trackId, clipId, operation.incomingClipId, operation.durationSeconds);
817
+ if (type === 'transition') return setTimelineTransition(timeline, trackId, clipId, operation.edge, operation.transition);
818
+ if (type === 'set-reverse') return setTimelineClipReverse(timeline, trackId, clipId, operation.reverse);
819
+ if (type === 'set-audio-effects') return setTimelineClipAudioEffects(timeline, trackId, clipId, operation.audioEffects);
820
+ if (type === 'set-volume-keyframes') return setTimelineClipVolumeKeyframes(timeline, trackId, clipId, operation.volumeKeyframes);
821
+ if (type === 'set-audio-state') return setTimelineClipAudioState(timeline, trackId, clipId, operation.state);
822
+ if (type === 'set-video-state') return setTimelineClipVideoState(timeline, trackId, clipId, operation.state);
823
+ if (type === 'set-clip-state') return setTimelineClipState(timeline, trackId, clipId, operation.state);
824
+ if (type === 'set-keyframes') return setTimelineClipKeyframes(timeline, trackId, clipId, operation.keyframes);
825
+ if (type === 'set-framing') return setTimelineClipFraming(timeline, trackId, clipId, operation.framing);
826
+ if (type === 'set-track-state') return setTimelineTrackState(timeline, trackId, operation.state);
827
+ if (type === 'set-track-audio-effects') return setTimelineTrackAudioEffects(timeline, trackId, operation.audioEffects);
828
+ if (type === 'set-audio-mix') return setTimelineAudioMix(timeline, operation.audioMix);
829
+ if (type === 'set-audio-mastering') return setTimelineAudioMastering(timeline, operation.audioMastering);
830
+ if (type === 'set-project-state') return setTimelineProjectState(timeline, operation.state);
831
+ if (type === 'set-overlay') return setTimelineOverlay(timeline, trackId, clipId, operation.properties);
832
+ throw new TypeError('Timeline edit type must be add-track, set-marker, remove-marker, remove-track, reorder-track, add-clip, duplicate-clip, detach-audio, remove-clip, ripple-delete, ripple-delete-range, insert-gap, move, move-track, replace-asset, set-speed, trim, slip, split, crossfade, transition, set-reverse, set-audio-effects, set-track-audio-effects, set-audio-mix, set-audio-mastering, set-project-state, set-volume-keyframes, set-audio-state, set-video-state, set-clip-state, set-keyframes, set-framing, set-track-state, or set-overlay.');
833
+ }
834
+
835
+ /** Apply a non-empty edit plan in order, returning one validated result or no changes. */
836
+ export function editTimelineBatch(timeline, operations) {
837
+ if (!Array.isArray(operations) || operations.length === 0) {
838
+ throw new TypeError('Timeline edit batch must be a non-empty array of operations.');
839
+ }
840
+ let next = timeline;
841
+ for (const operation of operations) next = editTimeline(next, operation);
842
+ return next;
843
+ }
844
+
845
+ /** Describe the exact result of an edit plan without changing the source timeline. */
846
+ export function previewTimelineEdit(timeline, operationOrOperations) {
847
+ const operations = Array.isArray(operationOrOperations) ? operationOrOperations : [operationOrOperations];
848
+ const edited = editTimelineBatch(timeline, operations);
849
+ const changes = [];
850
+ const beforeTracks = new Map(timeline.tracks.map((track) => [track.id, track]));
851
+ const afterTracks = new Map(edited.tracks.map((track) => [track.id, track]));
852
+
853
+ for (const trackId of new Set([...beforeTracks.keys(), ...afterTracks.keys()])) {
854
+ const beforeTrack = beforeTracks.get(trackId);
855
+ const afterTrack = afterTracks.get(trackId);
856
+ const beforeMetadata = beforeTrack && withoutClips(beforeTrack);
857
+ const afterMetadata = afterTrack && withoutClips(afterTrack);
858
+ if (!isDeepStrictEqual(beforeMetadata, afterMetadata)) {
859
+ changes.push({ entity: 'track', change: beforeTrack ? (afterTrack ? 'updated' : 'removed') : 'added', trackId, before: beforeMetadata ?? null, after: afterMetadata ?? null });
860
+ }
861
+
862
+ const beforeClips = new Map((beforeTrack?.clips ?? []).map((clip) => [clip.id, clip]));
863
+ const afterClips = new Map((afterTrack?.clips ?? []).map((clip) => [clip.id, clip]));
864
+ for (const clipId of new Set([...beforeClips.keys(), ...afterClips.keys()])) {
865
+ const before = beforeClips.get(clipId);
866
+ const after = afterClips.get(clipId);
867
+ if (!isDeepStrictEqual(before, after)) {
868
+ changes.push({ entity: 'clip', change: before ? (after ? 'updated' : 'removed') : 'added', trackId, clipId, before: before ?? null, after: after ?? null });
869
+ }
870
+ }
871
+ }
872
+
873
+ return {
874
+ schemaVersion: 1,
875
+ operationCount: operations.length,
876
+ durationBeforeSeconds: timeline.project.durationSeconds,
877
+ durationAfterSeconds: edited.project.durationSeconds,
878
+ durationDeltaSeconds: edited.project.durationSeconds - timeline.project.durationSeconds,
879
+ changes,
880
+ };
881
+ }
882
+
883
+ function withoutClips(track) {
884
+ const { clips, ...metadata } = track;
885
+ return metadata;
886
+ }
887
+
888
+ function sliceTimelineClip(clip, kind, localStart, localEnd, timelineStart) {
889
+ const durationSeconds = localEnd - localStart;
890
+ if (durationSeconds <= 1e-9) return undefined;
891
+ const segment = structuredClone(clip);
892
+ const originalDuration = clip.durationSeconds;
893
+ segment.timelineStart = timelineStart;
894
+ segment.durationSeconds = durationSeconds;
895
+ if (kind === 'overlay' && clip.composition) {
896
+ segment.compositionTimeOffsetSeconds = (clip.compositionTimeOffsetSeconds ?? 0) + localStart;
897
+ }
898
+ const keyframes = sliceVisualKeyframes(clip.keyframes, localStart, localEnd);
899
+ if (keyframes) segment.keyframes = keyframes;
900
+ else delete segment.keyframes;
901
+ const volumeKeyframes = sliceAudioVolumeKeyframes(clip.volumeKeyframes, localStart, localEnd);
902
+ if (volumeKeyframes) segment.volumeKeyframes = volumeKeyframes;
903
+ else delete segment.volumeKeyframes;
904
+
905
+ if (kind !== 'overlay') {
906
+ const sourceStartOffset = clip.speedRamp
907
+ ? speedRampSourceAtTime(clip.speedRamp, localStart)
908
+ : localStart * (clip.playbackRate ?? 1);
909
+ const sourceEndOffset = clip.speedRamp
910
+ ? speedRampSourceAtTime(clip.speedRamp, localEnd)
911
+ : localEnd * (clip.playbackRate ?? 1);
912
+ segment.sourceIn = clip.reverse ? clip.sourceOut - sourceEndOffset : clip.sourceIn + sourceStartOffset;
913
+ segment.sourceOut = clip.reverse ? clip.sourceOut - sourceStartOffset : clip.sourceIn + sourceEndOffset;
914
+ if (clip.speedRamp) segment.speedRamp = sliceSpeedRamp(clip.speedRamp, sourceStartOffset, sourceEndOffset);
915
+ }
916
+
917
+ if (clip.wordTimings) {
918
+ const words = clip.wordTimings.flatMap((word) => {
919
+ const start = Math.max(word.start, localStart);
920
+ const end = Math.min(word.end, localEnd);
921
+ return end > start ? [{ text: word.text, start: start - localStart, end: end - localStart }] : [];
922
+ });
923
+ if (!words.length) return undefined;
924
+ segment.wordTimings = words;
925
+ segment.text = words.map((word) => word.text).join(' ');
926
+ }
927
+
928
+ if (localStart > 0) {
929
+ delete segment.fadeInSeconds;
930
+ delete segment.transitionIn;
931
+ }
932
+ if (localEnd < originalDuration) {
933
+ delete segment.fadeOutSeconds;
934
+ delete segment.transitionOut;
935
+ }
936
+ if (segment.fadeInSeconds !== undefined) segment.fadeInSeconds = Math.min(segment.fadeInSeconds, durationSeconds);
937
+ if (segment.fadeOutSeconds !== undefined) segment.fadeOutSeconds = Math.min(segment.fadeOutSeconds, durationSeconds - (segment.fadeInSeconds ?? 0));
938
+ return segment;
939
+ }
940
+
941
+ function uniqueRippleClipId(baseId, usedIds) {
942
+ let index = 1;
943
+ let id = `${baseId}-ripple-right`;
944
+ while (usedIds.has(id)) id = `${baseId}-ripple-right-${++index}`;
945
+ usedIds.add(id);
946
+ return id;
947
+ }
948
+
949
+ function findClip(timeline, trackId, clipId) {
950
+ return findClipEntry(timeline, trackId, clipId).clip;
951
+ }
952
+
953
+ function findClipEntry(timeline, trackId, clipId) {
954
+ validateTimeline(timeline);
955
+ const track = timeline.tracks.find((candidate) => candidate.id === trackId);
956
+ if (!track) throw new TypeError(`Unknown timeline track: ${trackId}`);
957
+ assertTrackEditable(track);
958
+ const index = track.clips.findIndex((candidate) => candidate.id === clipId);
959
+ if (index < 0) throw new TypeError(`Unknown clip ${clipId} on track ${trackId}.`);
960
+ return { track, clip: track.clips[index], index };
961
+ }
962
+
963
+ function assertTrackEditable(track) {
964
+ if (track.locked === true) throw new TypeError(`Track ${track.id} is locked. Unlock it before editing its clips or track settings.`);
965
+ }