@webpacked-timeline/core 1.0.0-beta.1

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 (77) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/dist/chunk-27XCNVPR.js +5969 -0
  5. package/dist/chunk-6PDBJDHM.js +2263 -0
  6. package/dist/chunk-BWPS6NQT.js +7465 -0
  7. package/dist/chunk-FBOYSUYV.js +1280 -0
  8. package/dist/chunk-FR632TZX.js +1870 -0
  9. package/dist/chunk-HW4Z7YLJ.js +1242 -0
  10. package/dist/chunk-HWW62IFH.js +5424 -0
  11. package/dist/chunk-I2GZXRH4.js +4790 -0
  12. package/dist/chunk-JQZE3OK4.js +1255 -0
  13. package/dist/chunk-KF7JNK2F.js +1864 -0
  14. package/dist/chunk-KR3P2DYK.js +5655 -0
  15. package/dist/chunk-MO5DSFSW.js +2214 -0
  16. package/dist/chunk-MQAW33RJ.js +5530 -0
  17. package/dist/chunk-N4WUWZZX.js +2833 -0
  18. package/dist/chunk-NRJV7I4C.js +1331 -0
  19. package/dist/chunk-NXG52532.js +2230 -0
  20. package/dist/chunk-PVXF67CN.js +1278 -0
  21. package/dist/chunk-QSB6DHIF.js +5429 -0
  22. package/dist/chunk-QYWJT7HR.js +5837 -0
  23. package/dist/chunk-SWBRCMW7.js +7466 -0
  24. package/dist/chunk-TAT3ULSV.js +2214 -0
  25. package/dist/chunk-TTDP5JUM.js +2228 -0
  26. package/dist/chunk-UAGP4VPG.js +1739 -0
  27. package/dist/chunk-WIG6SY7A.js +1183 -0
  28. package/dist/chunk-YJ2K5N2R.js +6187 -0
  29. package/dist/index-3Lr_vKBd.d.cts +2810 -0
  30. package/dist/index-3Lr_vKBd.d.ts +2810 -0
  31. package/dist/index-7IPJn1yM.d.cts +1146 -0
  32. package/dist/index-7IPJn1yM.d.ts +1146 -0
  33. package/dist/index-B0xOv0V0.d.cts +3259 -0
  34. package/dist/index-B0xOv0V0.d.ts +3259 -0
  35. package/dist/index-B2m3zwg7.d.cts +1381 -0
  36. package/dist/index-B2m3zwg7.d.ts +1381 -0
  37. package/dist/index-B3sUrU_X.d.cts +1249 -0
  38. package/dist/index-B3sUrU_X.d.ts +1249 -0
  39. package/dist/index-B6wla7ZJ.d.cts +2751 -0
  40. package/dist/index-B6wla7ZJ.d.ts +2751 -0
  41. package/dist/index-BIv8RWWT.d.cts +1574 -0
  42. package/dist/index-BIv8RWWT.d.ts +1574 -0
  43. package/dist/index-BJv6hDHL.d.cts +3255 -0
  44. package/dist/index-BJv6hDHL.d.ts +3255 -0
  45. package/dist/index-BUCimS2e.d.cts +1393 -0
  46. package/dist/index-BUCimS2e.d.ts +1393 -0
  47. package/dist/index-Bw_nvNcG.d.cts +1275 -0
  48. package/dist/index-Bw_nvNcG.d.ts +1275 -0
  49. package/dist/index-ByG0gOtd.d.cts +1167 -0
  50. package/dist/index-ByG0gOtd.d.ts +1167 -0
  51. package/dist/index-CDGd2XXv.d.cts +2492 -0
  52. package/dist/index-CDGd2XXv.d.ts +2492 -0
  53. package/dist/index-CznAVeJ6.d.cts +1145 -0
  54. package/dist/index-CznAVeJ6.d.ts +1145 -0
  55. package/dist/index-DQD9IMh7.d.cts +2534 -0
  56. package/dist/index-DQD9IMh7.d.ts +2534 -0
  57. package/dist/index-Dl3qtJEI.d.cts +2178 -0
  58. package/dist/index-Dl3qtJEI.d.ts +2178 -0
  59. package/dist/index-DnE2A-Nz.d.cts +2603 -0
  60. package/dist/index-DnE2A-Nz.d.ts +2603 -0
  61. package/dist/index-DrOA6QmW.d.cts +2492 -0
  62. package/dist/index-DrOA6QmW.d.ts +2492 -0
  63. package/dist/index-Vpa3rPEM.d.cts +1402 -0
  64. package/dist/index-Vpa3rPEM.d.ts +1402 -0
  65. package/dist/index-jP6BomSd.d.cts +2640 -0
  66. package/dist/index-jP6BomSd.d.ts +2640 -0
  67. package/dist/index-wiGRwVyY.d.cts +3259 -0
  68. package/dist/index-wiGRwVyY.d.ts +3259 -0
  69. package/dist/index.cjs +7386 -0
  70. package/dist/index.d.cts +1 -0
  71. package/dist/index.d.ts +1 -0
  72. package/dist/index.js +263 -0
  73. package/dist/internal.cjs +7721 -0
  74. package/dist/internal.d.cts +704 -0
  75. package/dist/internal.d.ts +704 -0
  76. package/dist/internal.js +405 -0
  77. package/package.json +58 -0
@@ -0,0 +1,2263 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __esm = (fn, res) => function __init() {
6
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
7
+ };
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
+
22
+ // src/engine/history.ts
23
+ var history_exports = {};
24
+ __export(history_exports, {
25
+ canRedo: () => canRedo,
26
+ canUndo: () => canUndo,
27
+ clearHistory: () => clearHistory,
28
+ createHistory: () => createHistory,
29
+ getCurrentState: () => getCurrentState,
30
+ pushHistory: () => pushHistory,
31
+ redo: () => redo,
32
+ undo: () => undo
33
+ });
34
+ function createHistory(initialState, limit = 50) {
35
+ return {
36
+ past: [],
37
+ present: initialState,
38
+ future: [],
39
+ limit
40
+ };
41
+ }
42
+ function pushHistory(history, newState) {
43
+ const newPast = [...history.past, history.present];
44
+ if (newPast.length > history.limit) {
45
+ newPast.shift();
46
+ }
47
+ return {
48
+ ...history,
49
+ past: newPast,
50
+ present: newState,
51
+ future: []
52
+ // Clear future on new action
53
+ };
54
+ }
55
+ function undo(history) {
56
+ if (history.past.length === 0) {
57
+ return history;
58
+ }
59
+ const newPast = [...history.past];
60
+ const previous = newPast.pop();
61
+ return {
62
+ ...history,
63
+ past: newPast,
64
+ present: previous,
65
+ future: [history.present, ...history.future]
66
+ };
67
+ }
68
+ function redo(history) {
69
+ if (history.future.length === 0) {
70
+ return history;
71
+ }
72
+ const newFuture = [...history.future];
73
+ const next = newFuture.shift();
74
+ return {
75
+ ...history,
76
+ past: [...history.past, history.present],
77
+ present: next,
78
+ future: newFuture
79
+ };
80
+ }
81
+ function canUndo(history) {
82
+ return history.past.length > 0;
83
+ }
84
+ function canRedo(history) {
85
+ return history.future.length > 0;
86
+ }
87
+ function getCurrentState(history) {
88
+ return history.present;
89
+ }
90
+ function clearHistory(history) {
91
+ return {
92
+ ...history,
93
+ past: [],
94
+ future: []
95
+ };
96
+ }
97
+ var init_history = __esm({
98
+ "src/engine/history.ts"() {
99
+ "use strict";
100
+ }
101
+ });
102
+
103
+ // src/types/timeline.ts
104
+ var DEFAULT_SEQUENCE_SETTINGS = {
105
+ pixelAspectRatio: 1,
106
+ fieldOrder: "progressive",
107
+ colorSpace: "sRGB",
108
+ audioSampleRate: 48e3,
109
+ audioChannelCount: 2
110
+ };
111
+ function createTimeline(params) {
112
+ return {
113
+ id: params.id,
114
+ name: params.name,
115
+ fps: params.fps,
116
+ duration: params.duration,
117
+ startTimecode: params.startTimecode ?? "00:00:00:00",
118
+ tracks: params.tracks ?? [],
119
+ sequenceSettings: { ...DEFAULT_SEQUENCE_SETTINGS, ...params.sequenceSettings },
120
+ version: 0
121
+ };
122
+ }
123
+
124
+ // src/types/track.ts
125
+ function createTrack(params) {
126
+ return {
127
+ id: params.id,
128
+ name: params.name,
129
+ type: params.type,
130
+ clips: params.clips ?? [],
131
+ locked: params.locked ?? false,
132
+ muted: params.muted ?? false,
133
+ solo: params.solo ?? false,
134
+ height: params.height ?? 56
135
+ };
136
+ }
137
+ function sortTrackClips(track) {
138
+ return {
139
+ ...track,
140
+ clips: [...track.clips].sort((a, b) => a.timelineStart - b.timelineStart)
141
+ };
142
+ }
143
+
144
+ // src/types/clip.ts
145
+ function createClip(params) {
146
+ return {
147
+ id: params.id,
148
+ assetId: params.assetId,
149
+ trackId: params.trackId,
150
+ timelineStart: params.timelineStart,
151
+ timelineEnd: params.timelineEnd,
152
+ mediaIn: params.mediaIn,
153
+ mediaOut: params.mediaOut,
154
+ speed: params.speed ?? 1,
155
+ enabled: params.enabled ?? true,
156
+ reversed: params.reversed ?? false,
157
+ name: params.name ?? null,
158
+ color: params.color ?? null,
159
+ metadata: params.metadata ?? {}
160
+ };
161
+ }
162
+ function getClipDuration(clip) {
163
+ return clip.timelineEnd - clip.timelineStart;
164
+ }
165
+ function getClipMediaDuration(clip) {
166
+ return clip.mediaOut - clip.mediaIn;
167
+ }
168
+ function clipContainsFrame(clip, f) {
169
+ return f >= clip.timelineStart && f < clip.timelineEnd;
170
+ }
171
+ function clipsOverlap(a, b) {
172
+ return a.timelineStart < b.timelineEnd && b.timelineStart < a.timelineEnd;
173
+ }
174
+
175
+ // src/types/asset.ts
176
+ var toAssetId = (s) => s;
177
+ function createAsset(params) {
178
+ return {
179
+ id: params.id,
180
+ name: params.name,
181
+ mediaType: params.mediaType,
182
+ filePath: params.filePath,
183
+ intrinsicDuration: params.intrinsicDuration,
184
+ nativeFps: params.nativeFps,
185
+ sourceTimecodeOffset: params.sourceTimecodeOffset,
186
+ status: params.status ?? "online"
187
+ };
188
+ }
189
+
190
+ // src/types/state.ts
191
+ function createTimelineState(params) {
192
+ const registry = params.assetRegistry ?? (params.assets ? params.assets : /* @__PURE__ */ new Map());
193
+ return {
194
+ timeline: params.timeline,
195
+ assetRegistry: registry
196
+ };
197
+ }
198
+
199
+ // src/types/frame.ts
200
+ var toFrame = (n) => n;
201
+ function frame(value) {
202
+ const rounded = Math.round(value);
203
+ if (rounded < 0) {
204
+ throw new Error(`TimelineFrame must be non-negative, got: ${value}`);
205
+ }
206
+ return rounded;
207
+ }
208
+ var FrameRates = {
209
+ CINEMA: 24,
210
+ PAL: 25,
211
+ NTSC_DF: 29.97,
212
+ NTSC: 30,
213
+ PAL_HFR: 50,
214
+ NTSC_HFR: 59.94,
215
+ HFR: 60
216
+ };
217
+ function frameRate(value) {
218
+ const valid = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
219
+ if (!valid.includes(value)) {
220
+ throw new Error(
221
+ `FrameRate must be one of ${valid.join(", ")}, got: ${value}`
222
+ );
223
+ }
224
+ return value;
225
+ }
226
+ var toTimecode = (s) => s;
227
+ function isValidFrame(value) {
228
+ return Number.isInteger(value) && value >= 0;
229
+ }
230
+ function isDropFrame(fps) {
231
+ return fps === 29.97 || fps === 59.94;
232
+ }
233
+
234
+ // src/utils/frame.ts
235
+ function framesToSeconds(frames, fps) {
236
+ return frames / fps;
237
+ }
238
+ function secondsToFrames(seconds, fps) {
239
+ return toFrame(seconds * fps);
240
+ }
241
+ function framesToTimecode(frames, fps) {
242
+ const totalFrames = frames;
243
+ const framesPart = totalFrames % fps;
244
+ const totalSeconds = Math.floor(totalFrames / fps);
245
+ const secondsPart = totalSeconds % 60;
246
+ const totalMinutes = Math.floor(totalSeconds / 60);
247
+ const minutesPart = totalMinutes % 60;
248
+ const hoursPart = Math.floor(totalMinutes / 60);
249
+ return `${pad(hoursPart)}:${pad(minutesPart)}:${pad(secondsPart)}:${pad(framesPart)}`;
250
+ }
251
+ function framesToMinutesSeconds(frames, fps) {
252
+ const totalSeconds = Math.floor(frames / fps);
253
+ const minutes = Math.floor(totalSeconds / 60);
254
+ const seconds = totalSeconds % 60;
255
+ return `${minutes}:${pad(seconds)}`;
256
+ }
257
+ function clampFrame(value, min, max) {
258
+ return toFrame(Math.max(min, Math.min(max, value)));
259
+ }
260
+ function addFrames(a, b) {
261
+ return toFrame(a + b);
262
+ }
263
+ function subtractFrames(a, b) {
264
+ return toFrame(Math.max(0, a - b));
265
+ }
266
+ function frameDuration(start, end) {
267
+ return toFrame(end - start);
268
+ }
269
+ function pad(num, width = 2) {
270
+ return num.toString().padStart(width, "0");
271
+ }
272
+
273
+ // src/systems/queries.ts
274
+ function findClipById(state, clipId) {
275
+ for (const track of state.timeline.tracks) {
276
+ const clip = track.clips.find((c) => c.id === clipId);
277
+ if (clip) {
278
+ return clip;
279
+ }
280
+ }
281
+ return void 0;
282
+ }
283
+ function findTrackById(state, trackId) {
284
+ return state.timeline.tracks.find((t) => t.id === trackId);
285
+ }
286
+ function getClipsOnTrack(state, trackId) {
287
+ const track = findTrackById(state, trackId);
288
+ return track ? Array.from(track.clips) : [];
289
+ }
290
+ function getClipsAtFrame(state, frame2) {
291
+ const clips = [];
292
+ for (const track of state.timeline.tracks) {
293
+ for (const clip of track.clips) {
294
+ if (clipContainsFrame(clip, frame2)) {
295
+ clips.push(clip);
296
+ }
297
+ }
298
+ }
299
+ return clips;
300
+ }
301
+ function getClipsInRange(state, start, end) {
302
+ const clips = [];
303
+ for (const track of state.timeline.tracks) {
304
+ for (const clip of track.clips) {
305
+ if (clip.timelineStart < end && clip.timelineEnd > start) {
306
+ clips.push(clip);
307
+ }
308
+ }
309
+ }
310
+ return clips;
311
+ }
312
+ function getAllClips(state) {
313
+ const clips = [];
314
+ for (const track of state.timeline.tracks) {
315
+ clips.push(...track.clips);
316
+ }
317
+ return clips;
318
+ }
319
+ function getAllTracks(state) {
320
+ return state.timeline.tracks;
321
+ }
322
+ function findTrackIndex(state, trackId) {
323
+ return state.timeline.tracks.findIndex((t) => t.id === trackId);
324
+ }
325
+
326
+ // src/types/validation.ts
327
+ function validResult() {
328
+ return {
329
+ valid: true,
330
+ errors: []
331
+ };
332
+ }
333
+ function invalidResult(code, message, context) {
334
+ const error = { code, message };
335
+ if (context !== void 0) {
336
+ error.context = context;
337
+ }
338
+ return {
339
+ valid: false,
340
+ errors: [error]
341
+ };
342
+ }
343
+ function invalidResults(errors) {
344
+ return {
345
+ valid: false,
346
+ errors
347
+ };
348
+ }
349
+ function combineResults(...results) {
350
+ const allErrors = [];
351
+ for (const result of results) {
352
+ if (!result.valid) {
353
+ allErrors.push(...result.errors);
354
+ }
355
+ }
356
+ if (allErrors.length > 0) {
357
+ return invalidResults(allErrors);
358
+ }
359
+ return validResult();
360
+ }
361
+
362
+ // src/systems/asset-registry.ts
363
+ function registerAsset(state, asset) {
364
+ const next = new Map(state.assetRegistry);
365
+ next.set(asset.id, asset);
366
+ return { ...state, assetRegistry: next };
367
+ }
368
+ function getAsset(state, assetId) {
369
+ return state.assetRegistry.get(toAssetId(assetId));
370
+ }
371
+ function hasAsset(state, assetId) {
372
+ return state.assetRegistry.has(toAssetId(assetId));
373
+ }
374
+ function getAllAssets(state) {
375
+ return Array.from(state.assetRegistry.values());
376
+ }
377
+ function unregisterAsset(state, assetId) {
378
+ const next = new Map(state.assetRegistry);
379
+ next.delete(toAssetId(assetId));
380
+ return { ...state, assetRegistry: next };
381
+ }
382
+
383
+ // src/systems/validation.ts
384
+ function validateClip(state, clip) {
385
+ const errors = [];
386
+ const asset = getAsset(state, clip.assetId);
387
+ if (!asset) {
388
+ errors.push(invalidResult(
389
+ "ASSET_NOT_FOUND",
390
+ `Asset '${clip.assetId}' not found in registry`,
391
+ { clipId: clip.id, assetId: clip.assetId }
392
+ ));
393
+ return combineResults(...errors);
394
+ }
395
+ if (clip.timelineEnd <= clip.timelineStart) {
396
+ errors.push(invalidResult(
397
+ "INVALID_TIMELINE_BOUNDS",
398
+ `Clip timeline end (${clip.timelineEnd}) must be greater than start (${clip.timelineStart})`,
399
+ { clipId: clip.id, timelineStart: clip.timelineStart, timelineEnd: clip.timelineEnd }
400
+ ));
401
+ }
402
+ if (clip.mediaIn < 0) {
403
+ errors.push(invalidResult(
404
+ "INVALID_MEDIA_IN",
405
+ `Clip media in (${clip.mediaIn}) must be >= 0`,
406
+ { clipId: clip.id, mediaIn: clip.mediaIn }
407
+ ));
408
+ }
409
+ if (clip.mediaOut <= clip.mediaIn) {
410
+ errors.push(invalidResult(
411
+ "INVALID_MEDIA_BOUNDS",
412
+ `Clip media out (${clip.mediaOut}) must be greater than media in (${clip.mediaIn})`,
413
+ { clipId: clip.id, mediaIn: clip.mediaIn, mediaOut: clip.mediaOut }
414
+ ));
415
+ }
416
+ if (clip.mediaOut > asset.intrinsicDuration) {
417
+ errors.push(invalidResult(
418
+ "MEDIA_EXCEEDS_ASSET",
419
+ `Clip media out (${clip.mediaOut}) exceeds asset duration (${asset.intrinsicDuration})`,
420
+ { clipId: clip.id, mediaOut: clip.mediaOut, assetDuration: asset.intrinsicDuration }
421
+ ));
422
+ }
423
+ const timelineDuration = getClipDuration(clip);
424
+ const mediaDuration = getClipMediaDuration(clip);
425
+ if (timelineDuration !== mediaDuration) {
426
+ errors.push(invalidResult(
427
+ "DURATION_MISMATCH",
428
+ `Clip timeline duration (${timelineDuration}) must match media duration (${mediaDuration}) in Phase 1`,
429
+ { clipId: clip.id, timelineDuration, mediaDuration }
430
+ ));
431
+ }
432
+ return combineResults(...errors);
433
+ }
434
+ function validateTrack(state, track) {
435
+ const errors = [];
436
+ for (const clip of track.clips) {
437
+ const clipResult = validateClip(state, clip);
438
+ if (!clipResult.valid) {
439
+ errors.push(clipResult);
440
+ }
441
+ }
442
+ for (let i = 0; i < track.clips.length; i++) {
443
+ for (let j = i + 1; j < track.clips.length; j++) {
444
+ const clip1 = track.clips[i];
445
+ const clip2 = track.clips[j];
446
+ if (!clip1 || !clip2) {
447
+ continue;
448
+ }
449
+ if (clipsOverlap(clip1, clip2)) {
450
+ errors.push(invalidResult(
451
+ "CLIPS_OVERLAP",
452
+ `Clips '${clip1.id}' and '${clip2.id}' overlap on track '${track.id}'`,
453
+ {
454
+ trackId: track.id,
455
+ clip1Id: clip1.id,
456
+ clip2Id: clip2.id,
457
+ clip1Start: clip1.timelineStart,
458
+ clip1End: clip1.timelineEnd,
459
+ clip2Start: clip2.timelineStart,
460
+ clip2End: clip2.timelineEnd
461
+ }
462
+ ));
463
+ }
464
+ }
465
+ }
466
+ return combineResults(...errors);
467
+ }
468
+ function validateTimeline(state) {
469
+ const errors = [];
470
+ if (state.timeline.fps <= 0) {
471
+ errors.push(invalidResult(
472
+ "INVALID_FPS",
473
+ `Timeline FPS must be positive, got ${state.timeline.fps}`,
474
+ { fps: state.timeline.fps }
475
+ ));
476
+ }
477
+ if (state.timeline.duration <= 0) {
478
+ errors.push(invalidResult(
479
+ "INVALID_DURATION",
480
+ `Timeline duration must be positive, got ${state.timeline.duration}`,
481
+ { duration: state.timeline.duration }
482
+ ));
483
+ }
484
+ for (const track of state.timeline.tracks) {
485
+ const trackResult = validateTrack(state, track);
486
+ if (!trackResult.valid) {
487
+ errors.push(trackResult);
488
+ }
489
+ }
490
+ return combineResults(...errors);
491
+ }
492
+ function validateNoOverlap(track, clip) {
493
+ for (const existingClip of track.clips) {
494
+ if (existingClip.id === clip.id) {
495
+ continue;
496
+ }
497
+ if (clipsOverlap(existingClip, clip)) {
498
+ return invalidResult(
499
+ "CLIPS_OVERLAP",
500
+ `Clip '${clip.id}' would overlap with existing clip '${existingClip.id}' on track '${track.id}'`,
501
+ {
502
+ trackId: track.id,
503
+ newClipId: clip.id,
504
+ existingClipId: existingClip.id,
505
+ newClipStart: clip.timelineStart,
506
+ newClipEnd: clip.timelineEnd,
507
+ existingClipStart: existingClip.timelineStart,
508
+ existingClipEnd: existingClip.timelineEnd
509
+ }
510
+ );
511
+ }
512
+ }
513
+ return validResult();
514
+ }
515
+ function validateTrackTypeMatch(state, clip, targetTrack) {
516
+ const asset = getAsset(state, clip.assetId);
517
+ if (!asset) {
518
+ return invalidResult(
519
+ "ASSET_NOT_FOUND",
520
+ `Asset '${clip.assetId}' not found in registry`,
521
+ { clipId: clip.id, assetId: clip.assetId }
522
+ );
523
+ }
524
+ if (asset.mediaType === "video" && targetTrack.type !== "video") {
525
+ return invalidResult(
526
+ "TRACK_TYPE_MISMATCH",
527
+ `Cannot place video clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`,
528
+ {
529
+ clipId: clip.id,
530
+ assetType: asset.mediaType,
531
+ trackType: targetTrack.type,
532
+ trackId: targetTrack.id
533
+ }
534
+ );
535
+ }
536
+ if (asset.mediaType === "audio" && targetTrack.type !== "audio") {
537
+ return invalidResult(
538
+ "TRACK_TYPE_MISMATCH",
539
+ `Cannot place audio clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`,
540
+ {
541
+ clipId: clip.id,
542
+ assetType: asset.mediaType,
543
+ trackType: targetTrack.type,
544
+ trackId: targetTrack.id
545
+ }
546
+ );
547
+ }
548
+ return validResult();
549
+ }
550
+
551
+ // src/operations/clip-operations.ts
552
+ function addClip(state, trackId, clip) {
553
+ const trackIndex = state.timeline.tracks.findIndex((t) => t.id === trackId);
554
+ if (trackIndex === -1) {
555
+ return state;
556
+ }
557
+ const track = state.timeline.tracks[trackIndex];
558
+ if (!track) {
559
+ return state;
560
+ }
561
+ const newTrack = sortTrackClips({
562
+ ...track,
563
+ clips: [...track.clips, clip]
564
+ });
565
+ const newTracks = [...state.timeline.tracks];
566
+ newTracks[trackIndex] = newTrack;
567
+ return {
568
+ ...state,
569
+ timeline: {
570
+ ...state.timeline,
571
+ tracks: newTracks
572
+ }
573
+ };
574
+ }
575
+ function removeClip(state, clipId) {
576
+ const newTracks = state.timeline.tracks.map((track) => ({
577
+ ...track,
578
+ clips: track.clips.filter((c) => c.id !== clipId)
579
+ }));
580
+ return {
581
+ ...state,
582
+ timeline: {
583
+ ...state.timeline,
584
+ tracks: newTracks
585
+ }
586
+ };
587
+ }
588
+ function moveClip(state, clipId, newStart) {
589
+ const clip = findClipById(state, clipId);
590
+ if (!clip) {
591
+ return state;
592
+ }
593
+ const duration = clip.timelineEnd - clip.timelineStart;
594
+ const newEnd = newStart + duration;
595
+ return updateClip(state, clipId, {
596
+ timelineStart: newStart,
597
+ timelineEnd: newEnd
598
+ });
599
+ }
600
+ function resizeClip(state, clipId, newStart, newEnd) {
601
+ const clip = findClipById(state, clipId);
602
+ if (!clip) {
603
+ return state;
604
+ }
605
+ const startDelta = newStart - clip.timelineStart;
606
+ const endDelta = newEnd - clip.timelineEnd;
607
+ let newMediaIn = clip.mediaIn;
608
+ let newMediaOut = clip.mediaOut;
609
+ if (startDelta !== 0) {
610
+ newMediaIn = clip.mediaIn + startDelta;
611
+ }
612
+ if (endDelta !== 0) {
613
+ newMediaOut = clip.mediaOut + endDelta;
614
+ }
615
+ return updateClip(state, clipId, {
616
+ timelineStart: newStart,
617
+ timelineEnd: newEnd,
618
+ mediaIn: newMediaIn,
619
+ mediaOut: newMediaOut
620
+ });
621
+ }
622
+ function trimClip(state, clipId, newMediaIn, newMediaOut) {
623
+ return updateClip(state, clipId, {
624
+ mediaIn: newMediaIn,
625
+ mediaOut: newMediaOut
626
+ });
627
+ }
628
+ function updateClip(state, clipId, updates) {
629
+ const newTracks = state.timeline.tracks.map((track) => {
630
+ const clipIndex = track.clips.findIndex((c) => c.id === clipId);
631
+ if (clipIndex === -1) {
632
+ return track;
633
+ }
634
+ const newClips = [...track.clips];
635
+ const existingClip = newClips[clipIndex];
636
+ if (!existingClip) {
637
+ return track;
638
+ }
639
+ newClips[clipIndex] = {
640
+ ...existingClip,
641
+ ...updates
642
+ };
643
+ return sortTrackClips({
644
+ ...track,
645
+ clips: newClips
646
+ });
647
+ });
648
+ return {
649
+ ...state,
650
+ timeline: {
651
+ ...state.timeline,
652
+ tracks: newTracks
653
+ }
654
+ };
655
+ }
656
+ function moveClipToTrack(state, clipId, targetTrackId) {
657
+ const clip = findClipById(state, clipId);
658
+ if (!clip) {
659
+ return state;
660
+ }
661
+ const targetTrack = findTrackById(state, targetTrackId);
662
+ if (!targetTrack) {
663
+ return state;
664
+ }
665
+ const validationResult = validateTrackTypeMatch(state, clip, targetTrack);
666
+ if (!validationResult.valid) {
667
+ return state;
668
+ }
669
+ if (clip.trackId === targetTrackId) {
670
+ return state;
671
+ }
672
+ let newState = removeClip(state, clipId);
673
+ const updatedClip = { ...clip, trackId: targetTrackId };
674
+ newState = addClip(newState, targetTrackId, updatedClip);
675
+ return newState;
676
+ }
677
+
678
+ // src/operations/track-operations.ts
679
+ function addTrack(state, track) {
680
+ return {
681
+ ...state,
682
+ timeline: {
683
+ ...state.timeline,
684
+ tracks: [...state.timeline.tracks, track]
685
+ }
686
+ };
687
+ }
688
+ function removeTrack(state, trackId) {
689
+ return {
690
+ ...state,
691
+ timeline: {
692
+ ...state.timeline,
693
+ tracks: state.timeline.tracks.filter((t) => t.id !== trackId)
694
+ }
695
+ };
696
+ }
697
+ function moveTrack(state, trackId, newIndex) {
698
+ const currentIndex = findTrackIndex(state, trackId);
699
+ if (currentIndex === -1) {
700
+ return state;
701
+ }
702
+ const newTracks = [...state.timeline.tracks];
703
+ const [track] = newTracks.splice(currentIndex, 1);
704
+ if (!track) {
705
+ return state;
706
+ }
707
+ newTracks.splice(newIndex, 0, track);
708
+ return {
709
+ ...state,
710
+ timeline: {
711
+ ...state.timeline,
712
+ tracks: newTracks
713
+ }
714
+ };
715
+ }
716
+ function updateTrack(state, trackId, updates) {
717
+ const trackIndex = findTrackIndex(state, trackId);
718
+ if (trackIndex === -1) {
719
+ return state;
720
+ }
721
+ const newTracks = [...state.timeline.tracks];
722
+ const existingTrack = newTracks[trackIndex];
723
+ if (!existingTrack) {
724
+ return state;
725
+ }
726
+ newTracks[trackIndex] = {
727
+ ...existingTrack,
728
+ ...updates
729
+ };
730
+ return {
731
+ ...state,
732
+ timeline: {
733
+ ...state.timeline,
734
+ tracks: newTracks
735
+ }
736
+ };
737
+ }
738
+ function toggleTrackMute(state, trackId) {
739
+ const trackIndex = findTrackIndex(state, trackId);
740
+ if (trackIndex === -1) {
741
+ return state;
742
+ }
743
+ const track = state.timeline.tracks[trackIndex];
744
+ if (!track) {
745
+ return state;
746
+ }
747
+ return updateTrack(state, trackId, { muted: !track.muted });
748
+ }
749
+ function toggleTrackLock(state, trackId) {
750
+ const trackIndex = findTrackIndex(state, trackId);
751
+ if (trackIndex === -1) {
752
+ return state;
753
+ }
754
+ const track = state.timeline.tracks[trackIndex];
755
+ if (!track) {
756
+ return state;
757
+ }
758
+ return updateTrack(state, trackId, { locked: !track.locked });
759
+ }
760
+ function toggleTrackSolo(state, trackId) {
761
+ const trackIndex = findTrackIndex(state, trackId);
762
+ if (trackIndex === -1) {
763
+ return state;
764
+ }
765
+ const track = state.timeline.tracks[trackIndex];
766
+ if (!track) {
767
+ return state;
768
+ }
769
+ return updateTrack(state, trackId, { solo: !track.solo });
770
+ }
771
+ function setTrackHeight(state, trackId, height) {
772
+ return updateTrack(state, trackId, { height: Math.max(40, Math.min(200, height)) });
773
+ }
774
+
775
+ // src/operations/timeline-operations.ts
776
+ function setTimelineDuration(state, duration) {
777
+ return {
778
+ ...state,
779
+ timeline: {
780
+ ...state.timeline,
781
+ duration
782
+ }
783
+ };
784
+ }
785
+ function setTimelineName(state, name) {
786
+ return {
787
+ ...state,
788
+ timeline: {
789
+ ...state.timeline,
790
+ name
791
+ }
792
+ };
793
+ }
794
+
795
+ // src/engine/transactions.ts
796
+ function beginTransaction(state) {
797
+ return {
798
+ initialState: state,
799
+ currentState: state,
800
+ operations: [],
801
+ finalized: false
802
+ };
803
+ }
804
+ function applyOperation(tx, operation) {
805
+ if (tx.finalized) {
806
+ throw new Error("Cannot apply operation to finalized transaction");
807
+ }
808
+ const newState = operation(tx.currentState);
809
+ return {
810
+ ...tx,
811
+ currentState: newState,
812
+ operations: [...tx.operations, operation]
813
+ };
814
+ }
815
+ function commitTransaction(tx) {
816
+ if (tx.finalized) {
817
+ throw new Error("Transaction already finalized");
818
+ }
819
+ tx.finalized = true;
820
+ return tx.currentState;
821
+ }
822
+
823
+ // src/operations/ripple.ts
824
+ function rippleDelete(state, clipId) {
825
+ const clip = findClipById(state, clipId);
826
+ if (!clip) {
827
+ throw new Error(`Clip not found: ${clipId}`);
828
+ }
829
+ const track = findTrackById(state, clip.trackId);
830
+ if (!track) {
831
+ throw new Error(`Track not found: ${clip.trackId}`);
832
+ }
833
+ const clipDuration = getClipDuration(clip);
834
+ const deleteEnd = clip.timelineEnd;
835
+ const clipsToShift = track.clips.filter(
836
+ (c) => c.id !== clipId && c.timelineStart >= deleteEnd
837
+ );
838
+ let tx = beginTransaction(state);
839
+ tx = applyOperation(tx, (s) => removeClip(s, clipId));
840
+ for (const clipToShift of clipsToShift) {
841
+ const newStart = clipToShift.timelineStart - clipDuration;
842
+ tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
843
+ }
844
+ return commitTransaction(tx);
845
+ }
846
+ function rippleTrim(state, clipId, newEnd) {
847
+ const clip = findClipById(state, clipId);
848
+ if (!clip) {
849
+ throw new Error(`Clip not found: ${clipId}`);
850
+ }
851
+ const track = findTrackById(state, clip.trackId);
852
+ if (!track) {
853
+ throw new Error(`Track not found: ${clip.trackId}`);
854
+ }
855
+ if (newEnd <= clip.timelineStart) {
856
+ throw new Error("New end must be after clip start");
857
+ }
858
+ const delta = newEnd - clip.timelineEnd;
859
+ const clipsToShift = track.clips.filter(
860
+ (c) => c.id !== clipId && c.timelineStart >= clip.timelineEnd
861
+ );
862
+ let tx = beginTransaction(state);
863
+ tx = applyOperation(tx, (s) => resizeClip(s, clipId, clip.timelineStart, newEnd));
864
+ for (const clipToShift of clipsToShift) {
865
+ const newStart = clipToShift.timelineStart + delta;
866
+ tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
867
+ }
868
+ return commitTransaction(tx);
869
+ }
870
+ function insertEdit(state, trackId, clip, atFrame) {
871
+ const track = findTrackById(state, trackId);
872
+ if (!track) {
873
+ throw new Error(`Track not found: ${trackId}`);
874
+ }
875
+ const clipDuration = getClipDuration(clip);
876
+ const clipsToShift = track.clips.filter((c) => c.timelineStart >= atFrame);
877
+ const adjustedClip = {
878
+ ...clip,
879
+ timelineStart: atFrame,
880
+ timelineEnd: atFrame + clipDuration
881
+ };
882
+ let tx = beginTransaction(state);
883
+ for (const clipToShift of clipsToShift) {
884
+ const newStart = clipToShift.timelineStart + clipDuration;
885
+ tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, newStart));
886
+ }
887
+ tx = applyOperation(tx, (s) => addClip(s, trackId, adjustedClip));
888
+ return commitTransaction(tx);
889
+ }
890
+ function rippleMove(state, clipId, newStart) {
891
+ const clip = findClipById(state, clipId);
892
+ if (!clip) {
893
+ throw new Error(`Clip not found: ${clipId}`);
894
+ }
895
+ const track = findTrackById(state, clip.trackId);
896
+ if (!track) {
897
+ throw new Error(`Track not found: ${clip.trackId}`);
898
+ }
899
+ const clipDuration = getClipDuration(clip);
900
+ const newEnd = newStart + clipDuration;
901
+ if (newStart < 0) {
902
+ throw new Error("Cannot move clip before timeline start (frame 0)");
903
+ }
904
+ if (newEnd > state.timeline.duration) {
905
+ throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`);
906
+ }
907
+ const originalStart = clip.timelineStart;
908
+ const originalEnd = clip.timelineEnd;
909
+ if (newStart === originalStart) {
910
+ return state;
911
+ }
912
+ let tx = beginTransaction(state);
913
+ if (newStart > originalStart) {
914
+ const afterSource = track.clips.filter((c) => c.id !== clipId && c.timelineStart >= originalEnd).sort((a, b) => a.timelineStart - b.timelineStart);
915
+ for (const other of afterSource) {
916
+ const s = other.timelineStart - clipDuration;
917
+ tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
918
+ }
919
+ const anyClipBetween = afterSource.some((c) => c.timelineStart < newStart);
920
+ const collapsedDest = anyClipBetween ? newStart - clipDuration : newStart;
921
+ const currentTrack = tx.currentState.timeline.tracks.find((t) => t.id === track.id);
922
+ const atDest = currentTrack.clips.filter((c) => c.id !== clipId && c.timelineStart >= collapsedDest).sort((a, b) => b.timelineStart - a.timelineStart);
923
+ for (const other of atDest) {
924
+ const s = other.timelineStart + clipDuration;
925
+ tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
926
+ }
927
+ tx = applyOperation(tx, (st) => moveClip(st, clipId, collapsedDest));
928
+ } else {
929
+ const afterSource = track.clips.filter((c) => c.id !== clipId && c.timelineStart >= originalEnd).sort((a, b) => a.timelineStart - b.timelineStart);
930
+ for (const other of afterSource) {
931
+ const s = other.timelineStart - clipDuration;
932
+ tx = applyOperation(tx, (st) => moveClip(st, other.id, s));
933
+ }
934
+ tx = applyOperation(tx, (st) => moveClip(st, clipId, newStart));
935
+ }
936
+ return commitTransaction(tx);
937
+ }
938
+ function insertMove(state, clipId, newStart) {
939
+ const clip = findClipById(state, clipId);
940
+ if (!clip) {
941
+ throw new Error(`Clip not found: ${clipId}`);
942
+ }
943
+ const track = findTrackById(state, clip.trackId);
944
+ if (!track) {
945
+ throw new Error(`Track not found: ${clip.trackId}`);
946
+ }
947
+ const clipDuration = getClipDuration(clip);
948
+ const newEnd = newStart + clipDuration;
949
+ if (newStart < 0) {
950
+ throw new Error("Cannot move clip before timeline start (frame 0)");
951
+ }
952
+ if (newEnd > state.timeline.duration) {
953
+ throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`);
954
+ }
955
+ if (newStart === clip.timelineStart) {
956
+ return state;
957
+ }
958
+ let tx = beginTransaction(state);
959
+ const clipsToShift = track.clips.filter(
960
+ (c) => c.id !== clipId && c.timelineStart >= newStart
961
+ );
962
+ for (const clipToShift of clipsToShift) {
963
+ const shiftedStart = clipToShift.timelineStart + clipDuration;
964
+ tx = applyOperation(tx, (s) => moveClip(s, clipToShift.id, shiftedStart));
965
+ }
966
+ tx = applyOperation(tx, (s) => moveClip(s, clipId, newStart));
967
+ return commitTransaction(tx);
968
+ }
969
+
970
+ // src/engine/timeline-engine.ts
971
+ init_history();
972
+ function legacyDispatch(history, operation) {
973
+ const currentState = getCurrentState(history);
974
+ let newState;
975
+ try {
976
+ newState = operation(currentState);
977
+ } catch (err) {
978
+ return { accepted: false, errors: [{ code: "OPERATION_ERROR", message: String(err) }] };
979
+ }
980
+ const { pushHistory: pushHistory2 } = (init_history(), __toCommonJS(history_exports));
981
+ const newHistory = pushHistory2(history, newState);
982
+ return { accepted: true, history: newHistory };
983
+ }
984
+ var TimelineEngine = class {
985
+ history;
986
+ listeners = /* @__PURE__ */ new Set();
987
+ /**
988
+ * Create a new timeline engine
989
+ *
990
+ * @param initialState - Initial timeline state
991
+ * @param historyLimit - Maximum number of undo steps (default: 50)
992
+ */
993
+ constructor(initialState, historyLimit = 50) {
994
+ this.history = createHistory(initialState, historyLimit);
995
+ }
996
+ // ===== SUBSCRIPTION =====
997
+ /**
998
+ * Subscribe to state changes
999
+ *
1000
+ * The listener will be called whenever the timeline state changes,
1001
+ * with the new state passed as an argument.
1002
+ * This is used by framework adapters (e.g., React) to trigger re-renders.
1003
+ *
1004
+ * @param listener - Function to call on state changes, receives new state
1005
+ * @returns Unsubscribe function
1006
+ *
1007
+ * @example
1008
+ * ```typescript
1009
+ * const unsubscribe = engine.subscribe((state) => {
1010
+ * console.log('State changed:', state);
1011
+ * });
1012
+ *
1013
+ * // Later...
1014
+ * unsubscribe();
1015
+ * ```
1016
+ */
1017
+ subscribe(listener) {
1018
+ this.listeners.add(listener);
1019
+ return () => {
1020
+ this.listeners.delete(listener);
1021
+ };
1022
+ }
1023
+ /**
1024
+ * Notify all subscribers of a state change
1025
+ *
1026
+ * This is called internally after any operation that modifies state.
1027
+ * Framework adapters use this to trigger re-renders.
1028
+ */
1029
+ notify() {
1030
+ const state = this.getState();
1031
+ this.listeners.forEach((listener) => listener(state));
1032
+ }
1033
+ // ===== STATE ACCESS =====
1034
+ /**
1035
+ * Get the current timeline state
1036
+ *
1037
+ * @returns Current timeline state
1038
+ */
1039
+ getState() {
1040
+ return getCurrentState(this.history);
1041
+ }
1042
+ // ===== ASSET OPERATIONS =====
1043
+ /**
1044
+ * Register an asset
1045
+ *
1046
+ * @param asset - Asset to register
1047
+ * @returns Dispatch result
1048
+ */
1049
+ registerAsset(asset) {
1050
+ const result = legacyDispatch(
1051
+ this.history,
1052
+ (state) => registerAsset(state, asset)
1053
+ );
1054
+ if (result.accepted && result.history) {
1055
+ this.history = result.history;
1056
+ this.notify();
1057
+ }
1058
+ return result;
1059
+ }
1060
+ /**
1061
+ * Get an asset by ID
1062
+ *
1063
+ * @param assetId - Asset ID
1064
+ * @returns The asset, or undefined if not found
1065
+ */
1066
+ getAsset(assetId) {
1067
+ return getAsset(this.getState(), assetId);
1068
+ }
1069
+ // ===== CLIP OPERATIONS =====
1070
+ /**
1071
+ * Add a clip to a track
1072
+ *
1073
+ * @param trackId - ID of the track to add to
1074
+ * @param clip - Clip to add
1075
+ * @returns Dispatch result
1076
+ */
1077
+ addClip(trackId, clip) {
1078
+ const result = legacyDispatch(
1079
+ this.history,
1080
+ (state) => addClip(state, trackId, clip)
1081
+ );
1082
+ if (result.accepted && result.history) {
1083
+ this.history = result.history;
1084
+ this.notify();
1085
+ }
1086
+ return result;
1087
+ }
1088
+ /**
1089
+ * Remove a clip
1090
+ *
1091
+ * @param clipId - ID of the clip to remove
1092
+ * @returns Dispatch result
1093
+ */
1094
+ removeClip(clipId) {
1095
+ const result = legacyDispatch(
1096
+ this.history,
1097
+ (state) => removeClip(state, clipId)
1098
+ );
1099
+ if (result.accepted && result.history) {
1100
+ this.history = result.history;
1101
+ this.notify();
1102
+ }
1103
+ return result;
1104
+ }
1105
+ /**
1106
+ * Move a clip to a new timeline position
1107
+ *
1108
+ * @param clipId - ID of the clip to move
1109
+ * @param newStart - New timeline start frame
1110
+ * @returns Dispatch result
1111
+ */
1112
+ moveClip(clipId, newStart) {
1113
+ const result = legacyDispatch(
1114
+ this.history,
1115
+ (state) => moveClip(state, clipId, newStart)
1116
+ );
1117
+ if (result.accepted && result.history) {
1118
+ this.history = result.history;
1119
+ this.notify();
1120
+ }
1121
+ return result;
1122
+ }
1123
+ /**
1124
+ * Resize a clip
1125
+ *
1126
+ * @param clipId - ID of the clip to resize
1127
+ * @param newStart - New timeline start frame
1128
+ * @param newEnd - New timeline end frame
1129
+ * @returns Dispatch result
1130
+ */
1131
+ resizeClip(clipId, newStart, newEnd) {
1132
+ const result = legacyDispatch(
1133
+ this.history,
1134
+ (state) => resizeClip(state, clipId, newStart, newEnd)
1135
+ );
1136
+ if (result.accepted && result.history) {
1137
+ this.history = result.history;
1138
+ this.notify();
1139
+ }
1140
+ return result;
1141
+ }
1142
+ /**
1143
+ * Trim a clip (change media bounds)
1144
+ *
1145
+ * @param clipId - ID of the clip to trim
1146
+ * @param newMediaIn - New media in frame
1147
+ * @param newMediaOut - New media out frame
1148
+ * @returns Dispatch result
1149
+ */
1150
+ trimClip(clipId, newMediaIn, newMediaOut) {
1151
+ const result = legacyDispatch(
1152
+ this.history,
1153
+ (state) => trimClip(state, clipId, newMediaIn, newMediaOut)
1154
+ );
1155
+ if (result.accepted && result.history) {
1156
+ this.history = result.history;
1157
+ this.notify();
1158
+ }
1159
+ return result;
1160
+ }
1161
+ /**
1162
+ * Move a clip to a different track
1163
+ *
1164
+ * @param clipId - ID of the clip to move
1165
+ * @param targetTrackId - ID of the target track
1166
+ * @returns Dispatch result
1167
+ */
1168
+ moveClipToTrack(clipId, targetTrackId) {
1169
+ const result = legacyDispatch(
1170
+ this.history,
1171
+ (state) => moveClipToTrack(state, clipId, targetTrackId)
1172
+ );
1173
+ if (result.accepted && result.history) {
1174
+ this.history = result.history;
1175
+ this.notify();
1176
+ }
1177
+ return result;
1178
+ }
1179
+ // ===== TRACK OPERATIONS =====
1180
+ /**
1181
+ * Add a track
1182
+ *
1183
+ * @param track - Track to add
1184
+ * @returns Dispatch result
1185
+ */
1186
+ addTrack(track) {
1187
+ const result = legacyDispatch(
1188
+ this.history,
1189
+ (state) => addTrack(state, track)
1190
+ );
1191
+ if (result.accepted && result.history) {
1192
+ this.history = result.history;
1193
+ this.notify();
1194
+ }
1195
+ return result;
1196
+ }
1197
+ /**
1198
+ * Remove a track
1199
+ *
1200
+ * @param trackId - ID of the track to remove
1201
+ * @returns Dispatch result
1202
+ */
1203
+ removeTrack(trackId) {
1204
+ const result = legacyDispatch(
1205
+ this.history,
1206
+ (state) => removeTrack(state, trackId)
1207
+ );
1208
+ if (result.accepted && result.history) {
1209
+ this.history = result.history;
1210
+ this.notify();
1211
+ }
1212
+ return result;
1213
+ }
1214
+ /**
1215
+ * Move a track to a new position
1216
+ *
1217
+ * @param trackId - ID of the track to move
1218
+ * @param newIndex - New index position
1219
+ * @returns Dispatch result
1220
+ */
1221
+ moveTrack(trackId, newIndex) {
1222
+ const result = legacyDispatch(
1223
+ this.history,
1224
+ (state) => moveTrack(state, trackId, newIndex)
1225
+ );
1226
+ if (result.accepted && result.history) {
1227
+ this.history = result.history;
1228
+ this.notify();
1229
+ }
1230
+ return result;
1231
+ }
1232
+ /**
1233
+ * Toggle track mute
1234
+ *
1235
+ * @param trackId - ID of the track
1236
+ * @returns Dispatch result
1237
+ */
1238
+ toggleTrackMute(trackId) {
1239
+ const result = legacyDispatch(
1240
+ this.history,
1241
+ (state) => toggleTrackMute(state, trackId)
1242
+ );
1243
+ if (result.accepted && result.history) {
1244
+ this.history = result.history;
1245
+ this.notify();
1246
+ }
1247
+ return result;
1248
+ }
1249
+ /**
1250
+ * Toggle track lock
1251
+ *
1252
+ * @param trackId - ID of the track
1253
+ * @returns Dispatch result
1254
+ */
1255
+ toggleTrackLock(trackId) {
1256
+ const result = legacyDispatch(
1257
+ this.history,
1258
+ (state) => toggleTrackLock(state, trackId)
1259
+ );
1260
+ if (result.accepted && result.history) {
1261
+ this.history = result.history;
1262
+ this.notify();
1263
+ }
1264
+ return result;
1265
+ }
1266
+ /**
1267
+ * Toggle track solo
1268
+ *
1269
+ * @param trackId - ID of the track
1270
+ * @returns Dispatch result
1271
+ */
1272
+ toggleTrackSolo(trackId) {
1273
+ const result = legacyDispatch(
1274
+ this.history,
1275
+ (state) => toggleTrackSolo(state, trackId)
1276
+ );
1277
+ if (result.accepted && result.history) {
1278
+ this.history = result.history;
1279
+ this.notify();
1280
+ }
1281
+ return result;
1282
+ }
1283
+ /**
1284
+ * Set track height
1285
+ *
1286
+ * @param trackId - ID of the track
1287
+ * @param height - New height in pixels
1288
+ * @returns Dispatch result
1289
+ */
1290
+ setTrackHeight(trackId, height) {
1291
+ const result = legacyDispatch(
1292
+ this.history,
1293
+ (state) => setTrackHeight(state, trackId, height)
1294
+ );
1295
+ if (result.accepted && result.history) {
1296
+ this.history = result.history;
1297
+ this.notify();
1298
+ }
1299
+ return result;
1300
+ }
1301
+ // ===== TIMELINE OPERATIONS =====
1302
+ /**
1303
+ * Set timeline duration
1304
+ *
1305
+ * @param duration - New duration in frames
1306
+ * @returns Dispatch result
1307
+ */
1308
+ setTimelineDuration(duration) {
1309
+ const result = legacyDispatch(
1310
+ this.history,
1311
+ (state) => setTimelineDuration(state, duration)
1312
+ );
1313
+ if (result.accepted && result.history) {
1314
+ this.history = result.history;
1315
+ this.notify();
1316
+ }
1317
+ return result;
1318
+ }
1319
+ /**
1320
+ * Set timeline name
1321
+ *
1322
+ * @param name - New timeline name
1323
+ * @returns Dispatch result
1324
+ */
1325
+ setTimelineName(name) {
1326
+ const result = legacyDispatch(
1327
+ this.history,
1328
+ (state) => setTimelineName(state, name)
1329
+ );
1330
+ if (result.accepted && result.history) {
1331
+ this.history = result.history;
1332
+ this.notify();
1333
+ }
1334
+ return result;
1335
+ }
1336
+ // ===== HISTORY OPERATIONS =====
1337
+ /**
1338
+ * Undo the last action
1339
+ *
1340
+ * @returns true if undo was performed
1341
+ */
1342
+ undo() {
1343
+ if (!this.canUndo()) {
1344
+ return false;
1345
+ }
1346
+ this.history = undo(this.history);
1347
+ this.notify();
1348
+ return true;
1349
+ }
1350
+ /**
1351
+ * Redo the last undone action
1352
+ *
1353
+ * @returns true if redo was performed
1354
+ */
1355
+ redo() {
1356
+ if (!this.canRedo()) {
1357
+ return false;
1358
+ }
1359
+ this.history = redo(this.history);
1360
+ this.notify();
1361
+ return true;
1362
+ }
1363
+ /**
1364
+ * Check if undo is available
1365
+ *
1366
+ * @returns true if undo is available
1367
+ */
1368
+ canUndo() {
1369
+ return canUndo(this.history);
1370
+ }
1371
+ /**
1372
+ * Check if redo is available
1373
+ *
1374
+ * @returns true if redo is available
1375
+ */
1376
+ canRedo() {
1377
+ return canRedo(this.history);
1378
+ }
1379
+ // ===== QUERY OPERATIONS =====
1380
+ /**
1381
+ * Find a clip by ID
1382
+ *
1383
+ * @param clipId - Clip ID
1384
+ * @returns The clip, or undefined if not found
1385
+ */
1386
+ findClipById(clipId) {
1387
+ return findClipById(this.getState(), clipId);
1388
+ }
1389
+ /**
1390
+ * Find a track by ID
1391
+ *
1392
+ * @param trackId - Track ID
1393
+ * @returns The track, or undefined if not found
1394
+ */
1395
+ findTrackById(trackId) {
1396
+ return findTrackById(this.getState(), trackId);
1397
+ }
1398
+ /**
1399
+ * Get all clips on a track
1400
+ *
1401
+ * @param trackId - Track ID
1402
+ * @returns Array of clips on the track
1403
+ */
1404
+ getClipsOnTrack(trackId) {
1405
+ return getClipsOnTrack(this.getState(), trackId);
1406
+ }
1407
+ /**
1408
+ * Get all clips at a specific frame
1409
+ *
1410
+ * @param frame - Frame to check
1411
+ * @returns Array of clips at that frame
1412
+ */
1413
+ getClipsAtFrame(f) {
1414
+ return getClipsAtFrame(this.getState(), f);
1415
+ }
1416
+ /**
1417
+ * Get all clips in a frame range
1418
+ *
1419
+ * @param start - Start frame
1420
+ * @param end - End frame
1421
+ * @returns Array of clips in the range
1422
+ */
1423
+ getClipsInRange(start, end) {
1424
+ return getClipsInRange(this.getState(), start, end);
1425
+ }
1426
+ /**
1427
+ * Get all clips in the timeline
1428
+ *
1429
+ * @returns Array of all clips
1430
+ */
1431
+ getAllClips() {
1432
+ return getAllClips(this.getState());
1433
+ }
1434
+ /**
1435
+ * Get all tracks in the timeline
1436
+ *
1437
+ * @returns Array of all tracks
1438
+ */
1439
+ getAllTracks() {
1440
+ return getAllTracks(this.getState());
1441
+ }
1442
+ // ===== RIPPLE OPERATIONS =====
1443
+ /**
1444
+ * Ripple delete - delete clip and shift subsequent clips left
1445
+ *
1446
+ * @param clipId - ID of the clip to delete
1447
+ * @returns Dispatch result
1448
+ */
1449
+ rippleDelete(clipId) {
1450
+ const result = legacyDispatch(
1451
+ this.history,
1452
+ (state) => rippleDelete(state, clipId)
1453
+ );
1454
+ if (result.accepted && result.history) {
1455
+ this.history = result.history;
1456
+ this.notify();
1457
+ }
1458
+ return result;
1459
+ }
1460
+ /**
1461
+ * Ripple trim - trim clip end and shift subsequent clips
1462
+ *
1463
+ * @param clipId - ID of the clip to trim
1464
+ * @param newEnd - New end frame for the clip
1465
+ * @returns Dispatch result
1466
+ */
1467
+ rippleTrim(clipId, newEnd) {
1468
+ const result = legacyDispatch(
1469
+ this.history,
1470
+ (state) => rippleTrim(state, clipId, newEnd)
1471
+ );
1472
+ if (result.accepted && result.history) {
1473
+ this.history = result.history;
1474
+ this.notify();
1475
+ }
1476
+ return result;
1477
+ }
1478
+ /**
1479
+ * Insert edit - insert clip and shift subsequent clips right
1480
+ *
1481
+ * @param trackId - ID of the track to insert into
1482
+ * @param clip - Clip to insert
1483
+ * @param atFrame - Frame to insert at
1484
+ * @returns Dispatch result
1485
+ */
1486
+ insertEdit(trackId, clip, atFrame) {
1487
+ const result = legacyDispatch(
1488
+ this.history,
1489
+ (state) => insertEdit(state, trackId, clip, atFrame)
1490
+ );
1491
+ if (result.accepted && result.history) {
1492
+ this.history = result.history;
1493
+ this.notify();
1494
+ }
1495
+ return result;
1496
+ }
1497
+ /**
1498
+ * Ripple move - move clip and shift surrounding clips to accommodate
1499
+ *
1500
+ * This moves a clip to a new position while maintaining timeline continuity:
1501
+ * - Closes the gap at the source position
1502
+ * - Makes space at the destination position
1503
+ * - All operations are atomic (single undo entry)
1504
+ *
1505
+ * @param clipId - ID of the clip to move
1506
+ * @param newStart - New start frame for the clip
1507
+ * @returns Dispatch result
1508
+ */
1509
+ rippleMove(clipId, newStart) {
1510
+ const result = legacyDispatch(
1511
+ this.history,
1512
+ (state) => rippleMove(state, clipId, newStart)
1513
+ );
1514
+ if (result.accepted && result.history) {
1515
+ this.history = result.history;
1516
+ this.notify();
1517
+ } else if (!result.accepted && result.errors?.[0]?.code === "OPERATION_ERROR") {
1518
+ throw new Error(result.errors[0].message);
1519
+ }
1520
+ return result;
1521
+ }
1522
+ /**
1523
+ * Insert move - move clip and shift destination clips right
1524
+ *
1525
+ * This moves a clip to a new position without closing the gap at source:
1526
+ * - Leaves gap at the source position
1527
+ * - Pushes all clips at destination right to make space
1528
+ * - All operations are atomic (single undo entry)
1529
+ *
1530
+ * @param clipId - ID of the clip to move
1531
+ * @param newStart - New start frame for the clip
1532
+ * @returns Dispatch result
1533
+ */
1534
+ insertMove(clipId, newStart) {
1535
+ const result = legacyDispatch(
1536
+ this.history,
1537
+ (state) => insertMove(state, clipId, newStart)
1538
+ );
1539
+ if (result.accepted && result.history) {
1540
+ this.history = result.history;
1541
+ this.notify();
1542
+ } else if (!result.accepted && result.errors?.[0]?.code === "OPERATION_ERROR") {
1543
+ throw new Error(result.errors[0].message);
1544
+ }
1545
+ return result;
1546
+ }
1547
+ // Phase 2: Marker and WorkArea operations are gated to Phase 2.
1548
+ // They are intentionally omitted here to keep Phase 0 clean.
1549
+ };
1550
+
1551
+ // src/validation/invariants.ts
1552
+ function checkInvariants(state) {
1553
+ const violations = [];
1554
+ for (const track of state.timeline.tracks) {
1555
+ checkTrack(state, track, violations);
1556
+ }
1557
+ return violations;
1558
+ }
1559
+ function checkTrack(state, track, violations) {
1560
+ const clips = track.clips;
1561
+ for (let i = 1; i < clips.length; i++) {
1562
+ const prev = clips[i - 1];
1563
+ const curr = clips[i];
1564
+ if (prev.timelineStart > curr.timelineStart) {
1565
+ violations.push({
1566
+ type: "TRACK_NOT_SORTED",
1567
+ entityId: track.id,
1568
+ message: `Track '${track.id}': clip '${curr.id}' (start=${curr.timelineStart}) appears after clip '${prev.id}' (start=${prev.timelineStart}) \u2014 not sorted.`
1569
+ });
1570
+ }
1571
+ }
1572
+ for (let i = 0; i < clips.length; i++) {
1573
+ for (let j = i + 1; j < clips.length; j++) {
1574
+ const a = clips[i];
1575
+ const b = clips[j];
1576
+ if (clipsOverlap(a, b)) {
1577
+ violations.push({
1578
+ type: "OVERLAP",
1579
+ entityId: a.id,
1580
+ message: `Track '${track.id}': clips '${a.id}' [${a.timelineStart}-${a.timelineEnd}) and '${b.id}' [${b.timelineStart}-${b.timelineEnd}) overlap.`
1581
+ });
1582
+ }
1583
+ }
1584
+ }
1585
+ for (const clip of clips) {
1586
+ checkClip(state, track, clip, violations);
1587
+ }
1588
+ }
1589
+ function checkClip(state, track, clip, violations) {
1590
+ const asset = state.assetRegistry.get(clip.assetId);
1591
+ if (!asset) {
1592
+ violations.push({
1593
+ type: "ASSET_MISSING",
1594
+ entityId: clip.id,
1595
+ message: `Clip '${clip.id}' references asset '${clip.assetId}' which is not in the registry.`
1596
+ });
1597
+ return;
1598
+ }
1599
+ if (asset.mediaType !== track.type) {
1600
+ violations.push({
1601
+ type: "TRACK_TYPE_MISMATCH",
1602
+ entityId: clip.id,
1603
+ message: `Clip '${clip.id}' has asset mediaType '${asset.mediaType}' but is on a '${track.type}' track '${track.id}'.`
1604
+ });
1605
+ }
1606
+ if (clip.mediaIn < 0) {
1607
+ violations.push({
1608
+ type: "MEDIA_BOUNDS_INVALID",
1609
+ entityId: clip.id,
1610
+ message: `Clip '${clip.id}': mediaIn (${clip.mediaIn}) must be >= 0.`
1611
+ });
1612
+ }
1613
+ if (clip.mediaOut > asset.intrinsicDuration) {
1614
+ violations.push({
1615
+ type: "MEDIA_BOUNDS_INVALID",
1616
+ entityId: clip.id,
1617
+ message: `Clip '${clip.id}': mediaOut (${clip.mediaOut}) exceeds asset intrinsicDuration (${asset.intrinsicDuration}).`
1618
+ });
1619
+ }
1620
+ const mediaDuration = clip.mediaOut - clip.mediaIn;
1621
+ const timelineDuration = clip.timelineEnd - clip.timelineStart;
1622
+ const expectedMediaDuration = timelineDuration / clip.speed;
1623
+ if (Math.abs(mediaDuration - expectedMediaDuration) > 0.5) {
1624
+ violations.push({
1625
+ type: "DURATION_MISMATCH",
1626
+ entityId: clip.id,
1627
+ message: `Clip '${clip.id}': mediaDuration (${mediaDuration}) \u2260 timelineDuration/speed (${expectedMediaDuration.toFixed(2)}).`
1628
+ });
1629
+ }
1630
+ if (clip.timelineEnd > state.timeline.duration) {
1631
+ violations.push({
1632
+ type: "CLIP_BEYOND_TIMELINE",
1633
+ entityId: clip.id,
1634
+ message: `Clip '${clip.id}': timelineEnd (${clip.timelineEnd}) exceeds timeline duration (${state.timeline.duration}).`
1635
+ });
1636
+ }
1637
+ if (clip.speed <= 0) {
1638
+ violations.push({
1639
+ type: "SPEED_INVALID",
1640
+ entityId: clip.id,
1641
+ message: `Clip '${clip.id}': speed (${clip.speed}) must be > 0.`
1642
+ });
1643
+ }
1644
+ }
1645
+
1646
+ // src/engine/apply.ts
1647
+ function applyOperation2(state, op) {
1648
+ switch (op.type) {
1649
+ // — Clip operations ——————————————————————————————————————————————————
1650
+ case "INSERT_CLIP": {
1651
+ return updateTrack2(
1652
+ state,
1653
+ op.trackId,
1654
+ (track) => sortTrackClips({ ...track, clips: [...track.clips, op.clip] })
1655
+ );
1656
+ }
1657
+ case "DELETE_CLIP": {
1658
+ return updateTrackOfClip(state, op.clipId, (track) => ({
1659
+ ...track,
1660
+ clips: track.clips.filter((c) => c.id !== op.clipId)
1661
+ }));
1662
+ }
1663
+ case "MOVE_CLIP": {
1664
+ const targetTrackId = op.targetTrackId;
1665
+ let foundClip;
1666
+ for (const track of state.timeline.tracks) {
1667
+ const c = track.clips.find((c2) => c2.id === op.clipId);
1668
+ if (c) {
1669
+ foundClip = c;
1670
+ break;
1671
+ }
1672
+ }
1673
+ if (!foundClip) return state;
1674
+ const delta = op.newTimelineStart - foundClip.timelineStart;
1675
+ const movedClip = {
1676
+ ...foundClip,
1677
+ trackId: targetTrackId ?? foundClip.trackId,
1678
+ timelineStart: op.newTimelineStart,
1679
+ timelineEnd: foundClip.timelineEnd + delta
1680
+ };
1681
+ const effectiveTargetTrackId = targetTrackId ?? foundClip.trackId;
1682
+ const isCrossTrack = effectiveTargetTrackId !== foundClip.trackId;
1683
+ if (!isCrossTrack) {
1684
+ return updateClip2(state, op.clipId, () => movedClip);
1685
+ }
1686
+ const newTracks = state.timeline.tracks.map((track) => {
1687
+ if (track.id === foundClip.trackId) {
1688
+ return { ...track, clips: track.clips.filter((c) => c.id !== op.clipId) };
1689
+ }
1690
+ if (track.id === effectiveTargetTrackId) {
1691
+ return sortTrackClips({ ...track, clips: [...track.clips, movedClip] });
1692
+ }
1693
+ return track;
1694
+ });
1695
+ return { ...state, timeline: { ...state.timeline, tracks: newTracks } };
1696
+ }
1697
+ case "RESIZE_CLIP": {
1698
+ return updateClip2(state, op.clipId, (clip) => {
1699
+ if (op.edge === "start") {
1700
+ const delta = op.newFrame - clip.timelineStart;
1701
+ return {
1702
+ ...clip,
1703
+ timelineStart: op.newFrame,
1704
+ mediaIn: clip.mediaIn + delta
1705
+ };
1706
+ } else {
1707
+ const delta = op.newFrame - clip.timelineEnd;
1708
+ return {
1709
+ ...clip,
1710
+ timelineEnd: op.newFrame,
1711
+ mediaOut: clip.mediaOut + delta
1712
+ };
1713
+ }
1714
+ });
1715
+ }
1716
+ case "SLICE_CLIP": {
1717
+ return state;
1718
+ }
1719
+ case "SET_MEDIA_BOUNDS": {
1720
+ return updateClip2(state, op.clipId, (clip) => ({
1721
+ ...clip,
1722
+ mediaIn: op.mediaIn,
1723
+ mediaOut: op.mediaOut
1724
+ }));
1725
+ }
1726
+ case "SET_CLIP_ENABLED": {
1727
+ return updateClip2(state, op.clipId, (clip) => ({ ...clip, enabled: op.enabled }));
1728
+ }
1729
+ case "SET_CLIP_REVERSED": {
1730
+ return updateClip2(state, op.clipId, (clip) => ({ ...clip, reversed: op.reversed }));
1731
+ }
1732
+ case "SET_CLIP_SPEED": {
1733
+ return updateClip2(state, op.clipId, (clip) => ({ ...clip, speed: op.speed }));
1734
+ }
1735
+ case "SET_CLIP_COLOR": {
1736
+ return updateClip2(state, op.clipId, (clip) => ({ ...clip, color: op.color }));
1737
+ }
1738
+ case "SET_CLIP_NAME": {
1739
+ return updateClip2(state, op.clipId, (clip) => ({ ...clip, name: op.name }));
1740
+ }
1741
+ // — Track operations ——————————————————————————————————————————————————
1742
+ case "ADD_TRACK": {
1743
+ return {
1744
+ ...state,
1745
+ timeline: {
1746
+ ...state.timeline,
1747
+ tracks: [...state.timeline.tracks, op.track]
1748
+ }
1749
+ };
1750
+ }
1751
+ case "DELETE_TRACK": {
1752
+ return {
1753
+ ...state,
1754
+ timeline: {
1755
+ ...state.timeline,
1756
+ tracks: state.timeline.tracks.filter((t) => t.id !== op.trackId)
1757
+ }
1758
+ };
1759
+ }
1760
+ case "REORDER_TRACK": {
1761
+ const tracks = [...state.timeline.tracks];
1762
+ const idx = tracks.findIndex((t) => t.id === op.trackId);
1763
+ if (idx === -1) return state;
1764
+ const [track] = tracks.splice(idx, 1);
1765
+ if (!track) return state;
1766
+ tracks.splice(op.newIndex, 0, track);
1767
+ return { ...state, timeline: { ...state.timeline, tracks } };
1768
+ }
1769
+ case "SET_TRACK_HEIGHT": {
1770
+ return updateTrack2(state, op.trackId, (t) => ({ ...t, height: op.height }));
1771
+ }
1772
+ case "SET_TRACK_NAME": {
1773
+ return updateTrack2(state, op.trackId, (t) => ({ ...t, name: op.name }));
1774
+ }
1775
+ // — Asset operations ——————————————————————————————————————————————————
1776
+ case "REGISTER_ASSET": {
1777
+ const next = new Map(state.assetRegistry);
1778
+ next.set(op.asset.id, op.asset);
1779
+ return { ...state, assetRegistry: next };
1780
+ }
1781
+ case "UNREGISTER_ASSET": {
1782
+ const next = new Map(state.assetRegistry);
1783
+ next.delete(op.assetId);
1784
+ return { ...state, assetRegistry: next };
1785
+ }
1786
+ case "SET_ASSET_STATUS": {
1787
+ const asset = state.assetRegistry.get(op.assetId);
1788
+ if (!asset) return state;
1789
+ const next = new Map(state.assetRegistry);
1790
+ next.set(op.assetId, { ...asset, status: op.status });
1791
+ return { ...state, assetRegistry: next };
1792
+ }
1793
+ // — Timeline operations ———————————————————————————————————————————————
1794
+ case "RENAME_TIMELINE": {
1795
+ return { ...state, timeline: { ...state.timeline, name: op.name } };
1796
+ }
1797
+ case "SET_TIMELINE_DURATION": {
1798
+ return { ...state, timeline: { ...state.timeline, duration: op.duration } };
1799
+ }
1800
+ case "SET_TIMELINE_START_TC": {
1801
+ return { ...state, timeline: { ...state.timeline, startTimecode: op.startTimecode } };
1802
+ }
1803
+ case "SET_SEQUENCE_SETTINGS": {
1804
+ return {
1805
+ ...state,
1806
+ timeline: {
1807
+ ...state.timeline,
1808
+ sequenceSettings: { ...state.timeline.sequenceSettings, ...op.settings }
1809
+ }
1810
+ };
1811
+ }
1812
+ }
1813
+ }
1814
+ function updateTrack2(state, trackId, fn) {
1815
+ return {
1816
+ ...state,
1817
+ timeline: {
1818
+ ...state.timeline,
1819
+ tracks: state.timeline.tracks.map((t) => t.id === trackId ? fn(t) : t)
1820
+ }
1821
+ };
1822
+ }
1823
+ function updateTrackOfClip(state, clipId, fn) {
1824
+ return {
1825
+ ...state,
1826
+ timeline: {
1827
+ ...state.timeline,
1828
+ tracks: state.timeline.tracks.map(
1829
+ (t) => t.clips.some((c) => c.id === clipId) ? fn(t) : t
1830
+ )
1831
+ }
1832
+ };
1833
+ }
1834
+ function updateClip2(state, clipId, fn) {
1835
+ return {
1836
+ ...state,
1837
+ timeline: {
1838
+ ...state.timeline,
1839
+ tracks: state.timeline.tracks.map((track) => {
1840
+ if (!track.clips.some((c) => c.id === clipId)) return track;
1841
+ const updatedTrack = {
1842
+ ...track,
1843
+ clips: track.clips.map((c) => c.id === clipId ? fn(c) : c)
1844
+ };
1845
+ return sortTrackClips(updatedTrack);
1846
+ })
1847
+ }
1848
+ };
1849
+ }
1850
+
1851
+ // src/validation/validators.ts
1852
+ function validateOperation(state, op) {
1853
+ switch (op.type) {
1854
+ case "MOVE_CLIP":
1855
+ return validateMoveClip(state, op);
1856
+ case "RESIZE_CLIP":
1857
+ return validateResizeClip(state, op);
1858
+ case "SLICE_CLIP":
1859
+ return validateSliceClip(state, op);
1860
+ case "DELETE_CLIP":
1861
+ return validateDeleteClip(state, op);
1862
+ case "INSERT_CLIP":
1863
+ return validateInsertClip(state, op);
1864
+ case "SET_MEDIA_BOUNDS":
1865
+ return validateSetMediaBounds(state, op);
1866
+ case "SET_CLIP_SPEED":
1867
+ return validateSetClipSpeed(state, op);
1868
+ case "ADD_TRACK":
1869
+ return validateAddTrack(state, op);
1870
+ case "DELETE_TRACK":
1871
+ return validateDeleteTrack(state, op);
1872
+ case "UNREGISTER_ASSET":
1873
+ return validateUnregisterAsset(state, op);
1874
+ // All other ops are always valid at the primitive level
1875
+ default:
1876
+ return null;
1877
+ }
1878
+ }
1879
+ function validateMoveClip(state, op) {
1880
+ const clip = findClip(state, op.clipId);
1881
+ if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
1882
+ const targetTrackId = op.targetTrackId ?? clip.trackId;
1883
+ const track = state.timeline.tracks.find((t) => t.id === targetTrackId);
1884
+ if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${targetTrackId}' not found.` };
1885
+ if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${targetTrackId}' is locked.` };
1886
+ const duration = clip.timelineEnd - clip.timelineStart;
1887
+ const newEnd = op.newTimelineStart + duration;
1888
+ if (op.newTimelineStart < 0 || newEnd > state.timeline.duration) {
1889
+ return { reason: "OUT_OF_BOUNDS", message: `MOVE_CLIP would place clip '${op.clipId}' outside timeline bounds.` };
1890
+ }
1891
+ for (const existing of track.clips) {
1892
+ if (existing.id === op.clipId) continue;
1893
+ const overlaps = op.newTimelineStart < existing.timelineEnd && newEnd > existing.timelineStart;
1894
+ if (overlaps) {
1895
+ return { reason: "OVERLAP", message: `Clip '${op.clipId}' would overlap '${existing.id}' on track '${targetTrackId}'.` };
1896
+ }
1897
+ }
1898
+ return null;
1899
+ }
1900
+ function validateResizeClip(state, op) {
1901
+ const clip = findClip(state, op.clipId);
1902
+ if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
1903
+ if (op.edge === "start" && op.newFrame >= clip.timelineEnd) {
1904
+ return { reason: "OUT_OF_BOUNDS", message: `RESIZE_CLIP start edge must be < timelineEnd.` };
1905
+ }
1906
+ if (op.edge === "end" && op.newFrame <= clip.timelineStart) {
1907
+ return { reason: "OUT_OF_BOUNDS", message: `RESIZE_CLIP end edge must be > timelineStart.` };
1908
+ }
1909
+ return null;
1910
+ }
1911
+ function validateSliceClip(state, op) {
1912
+ const clip = findClip(state, op.clipId);
1913
+ if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
1914
+ if (op.atFrame <= clip.timelineStart || op.atFrame >= clip.timelineEnd) {
1915
+ return { reason: "OUT_OF_BOUNDS", message: `SLICE_CLIP atFrame must be strictly inside the clip bounds.` };
1916
+ }
1917
+ return null;
1918
+ }
1919
+ function validateDeleteClip(state, op) {
1920
+ const clip = findClip(state, op.clipId);
1921
+ if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
1922
+ const track = state.timeline.tracks.find((t) => t.id === clip.trackId);
1923
+ if (track?.locked) return { reason: "LOCKED_TRACK", message: `Track '${clip.trackId}' is locked.` };
1924
+ return null;
1925
+ }
1926
+ function validateInsertClip(state, op) {
1927
+ const track = state.timeline.tracks.find((t) => t.id === op.trackId);
1928
+ if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
1929
+ if (track.locked) return { reason: "LOCKED_TRACK", message: `Track '${op.trackId}' is locked.` };
1930
+ const asset = state.assetRegistry.get(op.clip.assetId);
1931
+ if (!asset) return { reason: "ASSET_MISSING", message: `Asset '${op.clip.assetId}' not in registry.` };
1932
+ if (asset.mediaType !== track.type) return { reason: "TYPE_MISMATCH", message: `Asset mediaType '${asset.mediaType}' \u2260 track type '${track.type}'.` };
1933
+ for (const existing of track.clips) {
1934
+ const overlaps = op.clip.timelineStart < existing.timelineEnd && op.clip.timelineEnd > existing.timelineStart;
1935
+ if (overlaps) {
1936
+ return { reason: "OVERLAP", message: `INSERT_CLIP would overlap '${existing.id}'.` };
1937
+ }
1938
+ }
1939
+ return null;
1940
+ }
1941
+ function validateSetMediaBounds(state, op) {
1942
+ const clip = findClip(state, op.clipId);
1943
+ if (!clip) return { reason: "ASSET_MISSING", message: `Clip '${op.clipId}' not found.` };
1944
+ const asset = state.assetRegistry.get(clip.assetId);
1945
+ if (!asset) return { reason: "ASSET_MISSING", message: `Asset '${clip.assetId}' not found.` };
1946
+ if (op.mediaIn < 0) return { reason: "MEDIA_BOUNDS_INVALID", message: `mediaIn must be >= 0.` };
1947
+ if (op.mediaOut > asset.intrinsicDuration) {
1948
+ return { reason: "MEDIA_BOUNDS_INVALID", message: `mediaOut (${op.mediaOut}) exceeds asset intrinsicDuration (${asset.intrinsicDuration}).` };
1949
+ }
1950
+ return null;
1951
+ }
1952
+ function validateSetClipSpeed(_state, op) {
1953
+ if (op.speed <= 0) return { reason: "SPEED_INVALID", message: `speed must be > 0, got ${op.speed}.` };
1954
+ return null;
1955
+ }
1956
+ function validateAddTrack(state, op) {
1957
+ if (state.timeline.tracks.some((t) => t.id === op.track.id)) {
1958
+ return { reason: "OVERLAP", message: `Track '${op.track.id}' already exists.` };
1959
+ }
1960
+ return null;
1961
+ }
1962
+ function validateDeleteTrack(state, op) {
1963
+ const track = state.timeline.tracks.find((t) => t.id === op.trackId);
1964
+ if (!track) return { reason: "OUT_OF_BOUNDS", message: `Track '${op.trackId}' not found.` };
1965
+ if (track.clips.length > 0) return { reason: "TRACK_NOT_EMPTY", message: `Cannot delete track '${op.trackId}': it has ${track.clips.length} clips. Delete all clips first.` };
1966
+ return null;
1967
+ }
1968
+ function validateUnregisterAsset(state, op) {
1969
+ for (const track of state.timeline.tracks) {
1970
+ for (const clip of track.clips) {
1971
+ if (clip.assetId === op.assetId) {
1972
+ return { reason: "ASSET_IN_USE", message: `Asset '${op.assetId}' is referenced by clip '${clip.id}'.` };
1973
+ }
1974
+ }
1975
+ }
1976
+ return null;
1977
+ }
1978
+ function findClip(state, clipId) {
1979
+ for (const track of state.timeline.tracks) {
1980
+ const clip = track.clips.find((c) => c.id === clipId);
1981
+ if (clip) return clip;
1982
+ }
1983
+ return void 0;
1984
+ }
1985
+
1986
+ // src/engine/dispatcher.ts
1987
+ function dispatch(state, transaction) {
1988
+ for (const op of transaction.operations) {
1989
+ const rejection = validateOperation(state, op);
1990
+ if (rejection) {
1991
+ return {
1992
+ accepted: false,
1993
+ reason: rejection.reason,
1994
+ message: rejection.message
1995
+ };
1996
+ }
1997
+ }
1998
+ let proposedState = state;
1999
+ for (const op of transaction.operations) {
2000
+ proposedState = applyOperation2(proposedState, op);
2001
+ }
2002
+ const violations = checkInvariants(proposedState);
2003
+ if (violations.length > 0) {
2004
+ return {
2005
+ accepted: false,
2006
+ reason: "INVARIANT_VIOLATED",
2007
+ message: violations.map((v) => v.message).join("; ")
2008
+ };
2009
+ }
2010
+ const nextState = {
2011
+ ...proposedState,
2012
+ timeline: {
2013
+ ...proposedState.timeline,
2014
+ version: state.timeline.version + 1
2015
+ }
2016
+ };
2017
+ return { accepted: true, nextState };
2018
+ }
2019
+
2020
+ // src/snap-index.ts
2021
+ var PRIORITIES = {
2022
+ Marker: 100,
2023
+ InPoint: 90,
2024
+ OutPoint: 90,
2025
+ ClipStart: 80,
2026
+ ClipEnd: 80,
2027
+ Playhead: 70,
2028
+ BeatGrid: 50
2029
+ };
2030
+ function buildSnapIndex(state, playheadFrame, enabled = true) {
2031
+ const points = [];
2032
+ for (const track of state.timeline.tracks) {
2033
+ for (const clip of track.clips) {
2034
+ points.push({
2035
+ frame: clip.timelineStart,
2036
+ type: "ClipStart",
2037
+ priority: PRIORITIES.ClipStart,
2038
+ trackId: track.id,
2039
+ sourceId: clip.id
2040
+ });
2041
+ points.push({
2042
+ frame: clip.timelineEnd,
2043
+ type: "ClipEnd",
2044
+ priority: PRIORITIES.ClipEnd,
2045
+ trackId: track.id,
2046
+ sourceId: clip.id
2047
+ });
2048
+ }
2049
+ }
2050
+ points.push({
2051
+ frame: playheadFrame,
2052
+ type: "Playhead",
2053
+ priority: PRIORITIES.Playhead,
2054
+ trackId: null,
2055
+ sourceId: "__playhead__"
2056
+ });
2057
+ points.sort((a, b) => a.frame - b.frame);
2058
+ return { points, builtAt: Date.now(), enabled };
2059
+ }
2060
+ function nearest(index, frame2, radiusFrames, exclude, allowedTypes) {
2061
+ if (!index.enabled) return null;
2062
+ const excludeSet = exclude ? new Set(exclude) : null;
2063
+ let best = null;
2064
+ let bestDist = Infinity;
2065
+ for (const point of index.points) {
2066
+ if (excludeSet && excludeSet.has(point.sourceId)) continue;
2067
+ if (allowedTypes && !allowedTypes.includes(point.type)) continue;
2068
+ const dist = Math.abs(point.frame - frame2);
2069
+ if (dist > radiusFrames) continue;
2070
+ if (dist < bestDist || dist === bestDist && best !== null && point.priority > best.priority) {
2071
+ best = point;
2072
+ bestDist = dist;
2073
+ }
2074
+ }
2075
+ return best;
2076
+ }
2077
+ function toggleSnap(index, enabled) {
2078
+ return { ...index, enabled };
2079
+ }
2080
+
2081
+ // src/tools/types.ts
2082
+ function toToolId(s) {
2083
+ return s;
2084
+ }
2085
+
2086
+ // src/tools/registry.ts
2087
+ function createRegistry(tools, defaultId) {
2088
+ const map = /* @__PURE__ */ new Map();
2089
+ for (const tool of tools) {
2090
+ map.set(tool.id, tool);
2091
+ }
2092
+ if (!map.has(defaultId)) {
2093
+ throw new Error(
2094
+ `createRegistry: defaultId "${defaultId}" not found in tools. Available: [${[...map.keys()].join(", ")}]`
2095
+ );
2096
+ }
2097
+ return { tools: map, activeToolId: defaultId };
2098
+ }
2099
+ function activateTool(registry, id) {
2100
+ getActiveTool(registry).onCancel();
2101
+ if (!registry.tools.has(id)) {
2102
+ throw new Error(
2103
+ `activateTool: unknown toolId "${id}". Registered: [${[...registry.tools.keys()].join(", ")}]`
2104
+ );
2105
+ }
2106
+ return { ...registry, activeToolId: id };
2107
+ }
2108
+ function getActiveTool(registry) {
2109
+ const tool = registry.tools.get(registry.activeToolId);
2110
+ if (!tool) {
2111
+ throw new Error(
2112
+ `getActiveTool: activeToolId "${registry.activeToolId}" is not registered. Registry is corrupt.`
2113
+ );
2114
+ }
2115
+ return tool;
2116
+ }
2117
+ function registerTool(registry, tool) {
2118
+ const next = new Map(registry.tools);
2119
+ next.set(tool.id, tool);
2120
+ return { ...registry, tools: next };
2121
+ }
2122
+ var NoOpTool = {
2123
+ id: toToolId("noop"),
2124
+ shortcutKey: "",
2125
+ getCursor(_ctx) {
2126
+ return "default";
2127
+ },
2128
+ getSnapCandidateTypes() {
2129
+ return [];
2130
+ },
2131
+ onPointerDown(_evt, _ctx) {
2132
+ },
2133
+ onPointerMove(_evt, _ctx) {
2134
+ return null;
2135
+ },
2136
+ onPointerUp(_evt, _ctx) {
2137
+ return null;
2138
+ },
2139
+ onKeyDown(_evt, _ctx) {
2140
+ return null;
2141
+ },
2142
+ onKeyUp(_evt, _ctx) {
2143
+ },
2144
+ onCancel() {
2145
+ }
2146
+ };
2147
+
2148
+ // src/tools/provisional.ts
2149
+ function createProvisionalManager() {
2150
+ return { current: null };
2151
+ }
2152
+ function setProvisional(_manager, state) {
2153
+ return { current: state };
2154
+ }
2155
+ function clearProvisional(_manager) {
2156
+ return { current: null };
2157
+ }
2158
+ function resolveClip(clipId, state, manager) {
2159
+ if (manager.current !== null) {
2160
+ const ghost = manager.current.clips.find((c) => c.id === clipId);
2161
+ if (ghost) return ghost;
2162
+ }
2163
+ for (const track of state.timeline.tracks) {
2164
+ const clip = track.clips.find((c) => c.id === clipId);
2165
+ if (clip) return clip;
2166
+ }
2167
+ return void 0;
2168
+ }
2169
+
2170
+ // src/public-api.ts
2171
+ init_history();
2172
+
2173
+ export {
2174
+ createTimeline,
2175
+ createTrack,
2176
+ sortTrackClips,
2177
+ createClip,
2178
+ getClipDuration,
2179
+ getClipMediaDuration,
2180
+ clipContainsFrame,
2181
+ clipsOverlap,
2182
+ createAsset,
2183
+ createTimelineState,
2184
+ toFrame,
2185
+ frame,
2186
+ FrameRates,
2187
+ frameRate,
2188
+ toTimecode,
2189
+ isValidFrame,
2190
+ isDropFrame,
2191
+ framesToSeconds,
2192
+ secondsToFrames,
2193
+ framesToTimecode,
2194
+ framesToMinutesSeconds,
2195
+ clampFrame,
2196
+ addFrames,
2197
+ subtractFrames,
2198
+ frameDuration,
2199
+ createHistory,
2200
+ pushHistory,
2201
+ undo,
2202
+ redo,
2203
+ canUndo,
2204
+ canRedo,
2205
+ getCurrentState,
2206
+ findClipById,
2207
+ findTrackById,
2208
+ getClipsOnTrack,
2209
+ getClipsAtFrame,
2210
+ getClipsInRange,
2211
+ getAllClips,
2212
+ getAllTracks,
2213
+ findTrackIndex,
2214
+ validResult,
2215
+ invalidResult,
2216
+ invalidResults,
2217
+ combineResults,
2218
+ registerAsset,
2219
+ getAsset,
2220
+ hasAsset,
2221
+ getAllAssets,
2222
+ unregisterAsset,
2223
+ validateClip,
2224
+ validateTrack,
2225
+ validateTimeline,
2226
+ validateNoOverlap,
2227
+ addClip,
2228
+ removeClip,
2229
+ moveClip,
2230
+ resizeClip,
2231
+ trimClip,
2232
+ updateClip,
2233
+ moveClipToTrack,
2234
+ addTrack,
2235
+ removeTrack,
2236
+ moveTrack,
2237
+ updateTrack,
2238
+ toggleTrackMute,
2239
+ toggleTrackLock,
2240
+ setTimelineDuration,
2241
+ setTimelineName,
2242
+ rippleDelete,
2243
+ rippleTrim,
2244
+ insertEdit,
2245
+ rippleMove,
2246
+ insertMove,
2247
+ TimelineEngine,
2248
+ checkInvariants,
2249
+ dispatch,
2250
+ buildSnapIndex,
2251
+ nearest,
2252
+ toggleSnap,
2253
+ toToolId,
2254
+ createRegistry,
2255
+ activateTool,
2256
+ getActiveTool,
2257
+ registerTool,
2258
+ NoOpTool,
2259
+ createProvisionalManager,
2260
+ setProvisional,
2261
+ clearProvisional,
2262
+ resolveClip
2263
+ };