@aura3d/editor-runtime 1.0.6 → 1.1.0

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 (82) hide show
  1. package/dist/AssetDropZone.d.ts +52 -0
  2. package/dist/AssetDropZone.d.ts.map +1 -0
  3. package/dist/AssetDropZone.js +115 -0
  4. package/dist/AssetDropZone.js.map +1 -0
  5. package/dist/CameraPathEditor.d.ts +48 -0
  6. package/dist/CameraPathEditor.d.ts.map +1 -0
  7. package/dist/CameraPathEditor.js +181 -0
  8. package/dist/CameraPathEditor.js.map +1 -0
  9. package/dist/CartoonSceneEditor.d.ts +111 -0
  10. package/dist/CartoonSceneEditor.d.ts.map +1 -0
  11. package/dist/CartoonSceneEditor.js +285 -0
  12. package/dist/CartoonSceneEditor.js.map +1 -0
  13. package/dist/CurveEditor.d.ts +38 -0
  14. package/dist/CurveEditor.d.ts.map +1 -0
  15. package/dist/CurveEditor.js +112 -0
  16. package/dist/CurveEditor.js.map +1 -0
  17. package/dist/EpisodeReviewPanel.d.ts +67 -0
  18. package/dist/EpisodeReviewPanel.d.ts.map +1 -0
  19. package/dist/EpisodeReviewPanel.js +162 -0
  20. package/dist/EpisodeReviewPanel.js.map +1 -0
  21. package/dist/KeyframeEditor.d.ts +49 -0
  22. package/dist/KeyframeEditor.d.ts.map +1 -0
  23. package/dist/KeyframeEditor.js +161 -0
  24. package/dist/KeyframeEditor.js.map +1 -0
  25. package/dist/MultiUserReviewWorkflow.d.ts +77 -0
  26. package/dist/MultiUserReviewWorkflow.d.ts.map +1 -0
  27. package/dist/MultiUserReviewWorkflow.js +162 -0
  28. package/dist/MultiUserReviewWorkflow.js.map +1 -0
  29. package/dist/NonlinearAnimationEditor.d.ts +81 -0
  30. package/dist/NonlinearAnimationEditor.d.ts.map +1 -0
  31. package/dist/NonlinearAnimationEditor.js +180 -0
  32. package/dist/NonlinearAnimationEditor.js.map +1 -0
  33. package/dist/PropertyPanel.d.ts +21 -0
  34. package/dist/PropertyPanel.d.ts.map +1 -0
  35. package/dist/PropertyPanel.js +72 -0
  36. package/dist/PropertyPanel.js.map +1 -0
  37. package/dist/RenderQueuePanel.d.ts +38 -0
  38. package/dist/RenderQueuePanel.d.ts.map +1 -0
  39. package/dist/RenderQueuePanel.js +72 -0
  40. package/dist/RenderQueuePanel.js.map +1 -0
  41. package/dist/SceneOutliner.d.ts +19 -0
  42. package/dist/SceneOutliner.d.ts.map +1 -0
  43. package/dist/SceneOutliner.js +64 -0
  44. package/dist/SceneOutliner.js.map +1 -0
  45. package/dist/TimelineEditorController.d.ts +102 -0
  46. package/dist/TimelineEditorController.d.ts.map +1 -0
  47. package/dist/TimelineEditorController.js +449 -0
  48. package/dist/TimelineEditorController.js.map +1 -0
  49. package/dist/TimelineTrackTypes.d.ts +25 -0
  50. package/dist/TimelineTrackTypes.d.ts.map +1 -0
  51. package/dist/TimelineTrackTypes.js +48 -0
  52. package/dist/TimelineTrackTypes.js.map +1 -0
  53. package/dist/TimelineUI.d.ts +27 -0
  54. package/dist/TimelineUI.d.ts.map +1 -0
  55. package/dist/TimelineUI.js +123 -0
  56. package/dist/TimelineUI.js.map +1 -0
  57. package/dist/VisualReviewDashboard.d.ts +43 -0
  58. package/dist/VisualReviewDashboard.d.ts.map +1 -0
  59. package/dist/VisualReviewDashboard.js +47 -0
  60. package/dist/VisualReviewDashboard.js.map +1 -0
  61. package/dist/index.d.ts +30 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +15 -0
  64. package/dist/index.js.map +1 -1
  65. package/package.json +3 -3
  66. package/src/AssetDropZone.ts +144 -0
  67. package/src/CameraPathEditor.ts +223 -0
  68. package/src/CartoonSceneEditor.ts +368 -0
  69. package/src/CurveEditor.ts +152 -0
  70. package/src/EpisodeReviewPanel.ts +236 -0
  71. package/src/KeyframeEditor.ts +214 -0
  72. package/src/MultiUserReviewWorkflow.ts +229 -0
  73. package/src/NonlinearAnimationEditor.ts +262 -0
  74. package/src/PropertyPanel.ts +82 -0
  75. package/src/RenderQueuePanel.ts +106 -0
  76. package/src/SceneOutliner.ts +78 -0
  77. package/src/TimelineEditorController.ts +525 -0
  78. package/src/TimelineTrackTypes.ts +89 -0
  79. package/src/TimelineUI.ts +153 -0
  80. package/src/VisualReviewDashboard.ts +87 -0
  81. package/src/index.ts +53 -0
  82. package/tests/editor-runtime.test.ts +490 -0
@@ -0,0 +1,525 @@
1
+ import type { Command } from "./Command";
2
+ import { CommandHistory } from "./CommandHistory";
3
+ import { Selection, type SelectionId } from "./Selection";
4
+ import { TimelineClip, TimelineModel, TimelineTrack, type TimelineClipConfig, type TimelineModelConfig, type TimelineTrackConfig } from "./TimelineModel";
5
+ import { createTimelineTrackConfig, timelineTrackKindFromConfig, timelineTrackTypeDefinition, type TimelineEditorTrackKind } from "./TimelineTrackTypes";
6
+
7
+ export interface TimelineEditorControllerOptions {
8
+ readonly timeline?: TimelineModel;
9
+ readonly history?: CommandHistory;
10
+ readonly selection?: Selection;
11
+ readonly zoomPixelsPerSecond?: number;
12
+ readonly snapEnabled?: boolean;
13
+ readonly snapInterval?: number;
14
+ readonly onPreviewTime?: (time: number, snapshot: TimelineEditorSnapshot) => void;
15
+ readonly routeBinding?: TimelineRoutePlaybackBinding;
16
+ }
17
+
18
+ export interface TimelineRoutePlaybackBinding {
19
+ play?(): void;
20
+ pause?(): void;
21
+ scrub(time: number, snapshot: TimelineEditorSnapshot): void;
22
+ jumpToShot?(shotId: string, time: number, snapshot: TimelineEditorSnapshot): void;
23
+ }
24
+
25
+ export interface TimelineEditorClipboard {
26
+ readonly clips: readonly TimelineClipConfig[];
27
+ }
28
+
29
+ export interface TimelineEditorSnapshot {
30
+ readonly timeline: ReturnType<TimelineModel["snapshot"]>;
31
+ readonly zoomPixelsPerSecond: number;
32
+ readonly scrollX: number;
33
+ readonly scrollY: number;
34
+ readonly snapEnabled: boolean;
35
+ readonly snapInterval: number;
36
+ readonly selectedIds: readonly SelectionId[];
37
+ readonly clipboardClipCount: number;
38
+ readonly canUndo: boolean;
39
+ readonly canRedo: boolean;
40
+ readonly tracks: readonly {
41
+ readonly id: string;
42
+ readonly name: string;
43
+ readonly kind: TimelineEditorTrackKind;
44
+ readonly laneHeight: number;
45
+ readonly color: string;
46
+ readonly clipIds: readonly string[];
47
+ }[];
48
+ readonly evidence: {
49
+ readonly clipEditing: boolean;
50
+ readonly keyframeReadyTracks: boolean;
51
+ readonly timelineScrubbing: true;
52
+ readonly undoRedo: boolean;
53
+ readonly serialization: true;
54
+ };
55
+ }
56
+
57
+ export class TimelineEditorController {
58
+ readonly timeline: TimelineModel;
59
+ readonly history: CommandHistory;
60
+ readonly selection: Selection;
61
+ private zoomPixelsPerSecondInternal: number;
62
+ private scrollXInternal = 0;
63
+ private scrollYInternal = 0;
64
+ private snapEnabledInternal: boolean;
65
+ private snapIntervalInternal: number;
66
+ private clipboardInternal: TimelineEditorClipboard = { clips: [] };
67
+ private readonly preview?: (time: number, snapshot: TimelineEditorSnapshot) => void;
68
+ private routeBinding?: TimelineRoutePlaybackBinding;
69
+
70
+ constructor(options: TimelineEditorControllerOptions = {}) {
71
+ this.timeline = options.timeline ?? new TimelineModel({ duration: 10, frameRate: 30, loopMode: "none" });
72
+ this.history = options.history ?? new CommandHistory();
73
+ this.selection = options.selection ?? new Selection();
74
+ this.zoomPixelsPerSecondInternal = positive(options.zoomPixelsPerSecond ?? 96, "Timeline zoomPixelsPerSecond");
75
+ this.snapEnabledInternal = options.snapEnabled ?? true;
76
+ this.snapIntervalInternal = positive(options.snapInterval ?? (this.timeline.frameRate > 0 ? 1 / this.timeline.frameRate : 1 / 30), "Timeline snapInterval");
77
+ this.preview = options.onPreviewTime;
78
+ this.routeBinding = options.routeBinding;
79
+ }
80
+
81
+ get zoomPixelsPerSecond(): number {
82
+ return this.zoomPixelsPerSecondInternal;
83
+ }
84
+
85
+ get snapEnabled(): boolean {
86
+ return this.snapEnabledInternal;
87
+ }
88
+
89
+ get snapInterval(): number {
90
+ return this.snapIntervalInternal;
91
+ }
92
+
93
+ get scrollX(): number {
94
+ return this.scrollXInternal;
95
+ }
96
+
97
+ get scrollY(): number {
98
+ return this.scrollYInternal;
99
+ }
100
+
101
+ async addTrack(kind: TimelineEditorTrackKind, name?: string, options: Omit<TimelineTrackConfig, "name" | "type"> = {}): Promise<TimelineTrack> {
102
+ const track = new TimelineTrack(createTimelineTrackConfig(kind, name, options));
103
+ await this.history.execute(new AddTrackCommand(this.timeline, track));
104
+ return track;
105
+ }
106
+
107
+ async addClip(trackId: string, config: TimelineClipConfig): Promise<TimelineClip> {
108
+ const track = this.requireTrack(trackId);
109
+ const clip = new TimelineClip({ ...config, startTime: this.snapTime(config.startTime) });
110
+ await this.history.execute(new AddClipCommand(track, clip));
111
+ return clip;
112
+ }
113
+
114
+ async deleteClip(clipId: string): Promise<void> {
115
+ const match = this.requireClip(clipId);
116
+ await this.history.execute(new RemoveClipCommand(match.track, match.clip));
117
+ this.selection.remove(clipId);
118
+ }
119
+
120
+ async moveClip(clipId: string, startTime: number): Promise<TimelineClip> {
121
+ const { clip } = this.requireClip(clipId);
122
+ await this.history.execute(new MoveClipCommand(clip, this.snapTime(startTime)));
123
+ return clip;
124
+ }
125
+
126
+ async resizeClip(clipId: string, duration: number, startTime?: number): Promise<TimelineClip> {
127
+ const { clip } = this.requireClip(clipId);
128
+ await this.history.execute(new ResizeClipCommand(clip, positive(duration, "Timeline clip duration"), startTime === undefined ? undefined : this.snapTime(startTime)));
129
+ return clip;
130
+ }
131
+
132
+ async splitClip(clipId: string, splitTime: number): Promise<readonly TimelineClip[]> {
133
+ const { track, clip } = this.requireClip(clipId);
134
+ const snapped = this.snapTime(splitTime);
135
+ if (snapped <= clip.startTime || snapped >= clip.endTime) {
136
+ throw new RangeError("Timeline split time must be inside the clip range.");
137
+ }
138
+ const left = new TimelineClip({ ...clip.toConfig(), id: `${clip.id}-a`, duration: snapped - clip.startTime });
139
+ const right = new TimelineClip({
140
+ ...clip.toConfig(),
141
+ id: `${clip.id}-b`,
142
+ startTime: snapped,
143
+ duration: clip.endTime - snapped,
144
+ clipInOffset: clip.clipInOffset + (snapped - clip.startTime) * clip.speedMultiplier
145
+ });
146
+ await this.history.execute(new ReplaceClipsCommand(track, [clip], [left, right], `Split clip ${clip.id}`));
147
+ this.selection.set([left.id, right.id]);
148
+ return [left, right];
149
+ }
150
+
151
+ async duplicateClip(clipId: string, timeOffset?: number): Promise<TimelineClip> {
152
+ const { track, clip } = this.requireClip(clipId);
153
+ const offset = timeOffset ?? clip.duration;
154
+ const duplicate = new TimelineClip({
155
+ ...clip.toConfig(),
156
+ id: `${clip.id}-copy-${track.clips.length + 1}`,
157
+ name: `${clip.name} Copy`,
158
+ startTime: this.snapTime(clip.startTime + offset)
159
+ });
160
+ await this.history.execute(new AddClipCommand(track, duplicate));
161
+ this.selection.set([duplicate.id]);
162
+ return duplicate;
163
+ }
164
+
165
+ copySelection(): TimelineEditorClipboard {
166
+ const selected = new Set(this.selection.current());
167
+ const clips = this.timeline.tracks.flatMap((track) => track.clips.filter((clip) => selected.has(clip.id)).map((clip) => clip.toConfig()));
168
+ this.clipboardInternal = { clips };
169
+ return this.clipboardInternal;
170
+ }
171
+
172
+ async pasteClips(trackId: string, startTime: number): Promise<readonly TimelineClip[]> {
173
+ const track = this.requireTrack(trackId);
174
+ if (this.clipboardInternal.clips.length === 0) return [];
175
+ const earliest = Math.min(...this.clipboardInternal.clips.map((clip) => clip.startTime));
176
+ const pasted = this.clipboardInternal.clips.map((clip, index) => new TimelineClip({
177
+ ...clip,
178
+ id: `${clip.id}-paste-${index + 1}`,
179
+ startTime: this.snapTime(startTime + (clip.startTime - earliest))
180
+ }));
181
+ await this.history.execute(new ReplaceClipsCommand(track, [], pasted, `Paste ${pasted.length} clips`));
182
+ this.selection.set(pasted.map((clip) => clip.id));
183
+ return pasted;
184
+ }
185
+
186
+ select(ids: readonly SelectionId[]): void {
187
+ this.selection.set(ids);
188
+ }
189
+
190
+ selectClip(clipId: string, additive = false): void {
191
+ this.requireClip(clipId);
192
+ if (additive) this.selection.add(clipId);
193
+ else this.selection.set([clipId]);
194
+ }
195
+
196
+ clearSelection(): void {
197
+ this.selection.clear();
198
+ }
199
+
200
+ setZoom(pixelsPerSecond: number): void {
201
+ this.zoomPixelsPerSecondInternal = positive(pixelsPerSecond, "Timeline zoomPixelsPerSecond");
202
+ }
203
+
204
+ zoomBy(multiplier: number): number {
205
+ this.setZoom(clamp(this.zoomPixelsPerSecondInternal * positive(multiplier, "Timeline zoom multiplier"), 12, 800));
206
+ return this.zoomPixelsPerSecondInternal;
207
+ }
208
+
209
+ scrollTo(x: number, y = this.scrollYInternal): void {
210
+ this.scrollXInternal = Math.max(0, finite(x, "Timeline scrollX"));
211
+ this.scrollYInternal = Math.max(0, finite(y, "Timeline scrollY"));
212
+ }
213
+
214
+ configureSnap(options: { readonly enabled?: boolean; readonly interval?: number }): void {
215
+ if (options.enabled !== undefined) this.snapEnabledInternal = options.enabled;
216
+ if (options.interval !== undefined) this.snapIntervalInternal = positive(options.interval, "Timeline snapInterval");
217
+ }
218
+
219
+ scrubTo(time: number): void {
220
+ this.timeline.seek(this.snapTime(time));
221
+ const snapshot = this.snapshot();
222
+ this.preview?.(this.timeline.currentTime, snapshot);
223
+ this.routeBinding?.scrub(this.timeline.currentTime, snapshot);
224
+ }
225
+
226
+ stepFrames(frames: number): void {
227
+ if (!Number.isInteger(frames)) throw new RangeError("Timeline stepFrames expects an integer frame count.");
228
+ const frameDuration = this.timeline.frameRate > 0 ? 1 / this.timeline.frameRate : this.snapIntervalInternal;
229
+ this.timeline.seek(Math.max(0, this.timeline.currentTime + frames * frameDuration));
230
+ this.preview?.(this.timeline.currentTime, this.snapshot());
231
+ }
232
+
233
+ togglePlayback(): void {
234
+ if (this.timeline.isPlaying) {
235
+ this.timeline.pause();
236
+ this.routeBinding?.pause?.();
237
+ } else {
238
+ this.timeline.play();
239
+ this.routeBinding?.play?.();
240
+ }
241
+ }
242
+
243
+ bindRoutePlayback(binding: TimelineRoutePlaybackBinding | undefined): void {
244
+ this.routeBinding = binding;
245
+ }
246
+
247
+ jumpToShot(shotId: string): number {
248
+ const target = this.timeline.tracks
249
+ .filter((track) => timelineTrackKindFromConfig(track.toConfig()) === "shot")
250
+ .flatMap((track) => track.clips)
251
+ .find((clip) => clip.id === shotId || clip.properties.shotId === shotId || clip.name === shotId);
252
+ if (!target) throw new Error(`Timeline shot does not exist: ${shotId}`);
253
+ this.timeline.seek(target.startTime);
254
+ const snapshot = this.snapshot();
255
+ this.preview?.(this.timeline.currentTime, snapshot);
256
+ this.routeBinding?.scrub(this.timeline.currentTime, snapshot);
257
+ this.routeBinding?.jumpToShot?.(shotId, this.timeline.currentTime, snapshot);
258
+ return this.timeline.currentTime;
259
+ }
260
+
261
+ handleKeyboardShortcut(key: string): boolean {
262
+ if (key === " " || key === "Space") {
263
+ this.togglePlayback();
264
+ return true;
265
+ }
266
+ if (key === "ArrowLeft") {
267
+ this.stepFrames(-1);
268
+ return true;
269
+ }
270
+ if (key === "ArrowRight") {
271
+ this.stepFrames(1);
272
+ return true;
273
+ }
274
+ if (key === "Home") {
275
+ this.scrubTo(0);
276
+ return true;
277
+ }
278
+ if (key === "End") {
279
+ this.scrubTo(this.timeline.duration);
280
+ return true;
281
+ }
282
+ return false;
283
+ }
284
+
285
+ async undo(): Promise<void> {
286
+ await this.history.undo();
287
+ }
288
+
289
+ async redo(): Promise<void> {
290
+ await this.history.redo();
291
+ }
292
+
293
+ serializeTimeline(): TimelineModelConfig {
294
+ return this.timeline.toConfig();
295
+ }
296
+
297
+ snapshot(): TimelineEditorSnapshot {
298
+ return {
299
+ timeline: this.timeline.snapshot(),
300
+ zoomPixelsPerSecond: this.zoomPixelsPerSecondInternal,
301
+ scrollX: this.scrollXInternal,
302
+ scrollY: this.scrollYInternal,
303
+ snapEnabled: this.snapEnabledInternal,
304
+ snapInterval: this.snapIntervalInternal,
305
+ selectedIds: this.selection.current(),
306
+ clipboardClipCount: this.clipboardInternal.clips.length,
307
+ canUndo: this.history.canUndo,
308
+ canRedo: this.history.canRedo,
309
+ tracks: this.timeline.tracks.map((track) => {
310
+ const kind = timelineTrackKindFromConfig(track.toConfig());
311
+ const definition = timelineTrackTypeDefinition(kind);
312
+ return {
313
+ id: track.id,
314
+ name: track.name,
315
+ kind,
316
+ laneHeight: definition.laneHeight,
317
+ color: definition.color,
318
+ clipIds: track.clips.map((clip) => clip.id)
319
+ };
320
+ }),
321
+ evidence: {
322
+ clipEditing: this.timeline.tracks.some((track) => track.clips.length > 0),
323
+ keyframeReadyTracks: this.timeline.tracks.some((track) => {
324
+ const kind = timelineTrackKindFromConfig(track.toConfig());
325
+ return timelineTrackTypeDefinition(kind).acceptsKeyframes;
326
+ }),
327
+ timelineScrubbing: true,
328
+ undoRedo: this.history.canUndo || this.history.canRedo,
329
+ serialization: true
330
+ }
331
+ };
332
+ }
333
+
334
+ private snapTime(time: number): number {
335
+ const value = finite(time, "Timeline time");
336
+ if (!this.snapEnabledInternal) return Math.max(0, value);
337
+ return Math.max(0, Number((Math.round(value / this.snapIntervalInternal) * this.snapIntervalInternal).toFixed(4)));
338
+ }
339
+
340
+ private requireTrack(trackId: string): TimelineTrack {
341
+ const track = this.timeline.tracks.find((candidate) => candidate.id === trackId);
342
+ if (!track) throw new Error(`Timeline track does not exist: ${trackId}`);
343
+ return track;
344
+ }
345
+
346
+ private requireClip(clipId: string): { readonly track: TimelineTrack; readonly clip: TimelineClip } {
347
+ for (const track of this.timeline.tracks) {
348
+ const clip = track.clips.find((candidate) => candidate.id === clipId);
349
+ if (clip) return { track, clip };
350
+ }
351
+ throw new Error(`Timeline clip does not exist: ${clipId}`);
352
+ }
353
+ }
354
+
355
+ class AddTrackCommand implements Command {
356
+ readonly name: string;
357
+
358
+ constructor(private readonly timeline: TimelineModel, private readonly track: TimelineTrack) {
359
+ this.name = `Add track ${track.id}`;
360
+ }
361
+
362
+ execute(): void {
363
+ if (!this.timeline.tracks.includes(this.track)) {
364
+ this.timeline.addTrack(this.track);
365
+ }
366
+ }
367
+
368
+ undo(): void {
369
+ removeItem(this.timeline.tracks as TimelineTrack[], this.track);
370
+ }
371
+ }
372
+
373
+ class AddClipCommand implements Command {
374
+ readonly name: string;
375
+
376
+ constructor(private readonly track: TimelineTrack, private readonly clip: TimelineClip) {
377
+ this.name = `Add clip ${clip.id}`;
378
+ }
379
+
380
+ execute(): void {
381
+ if (!this.track.clips.includes(this.clip)) {
382
+ this.track.addClip(this.clip);
383
+ }
384
+ }
385
+
386
+ undo(): void {
387
+ removeItem(this.track.clips as TimelineClip[], this.clip);
388
+ }
389
+ }
390
+
391
+ class RemoveClipCommand implements Command {
392
+ readonly name: string;
393
+
394
+ constructor(private readonly track: TimelineTrack, private readonly clip: TimelineClip) {
395
+ this.name = `Delete clip ${clip.id}`;
396
+ }
397
+
398
+ execute(): void {
399
+ removeItem(this.track.clips as TimelineClip[], this.clip);
400
+ }
401
+
402
+ undo(): void {
403
+ if (!this.track.clips.includes(this.clip)) {
404
+ this.track.addClip(this.clip, { ignoreLock: true });
405
+ }
406
+ }
407
+ }
408
+
409
+ class MoveClipCommand implements Command {
410
+ readonly name: string;
411
+ private readonly before: number;
412
+
413
+ constructor(protected readonly clip: TimelineClip, protected readonly after: number) {
414
+ this.name = `Move clip ${clip.id}`;
415
+ this.before = clip.startTime;
416
+ }
417
+
418
+ execute(): void {
419
+ this.clip.startTime = this.after;
420
+ }
421
+
422
+ undo(): void {
423
+ this.clip.startTime = this.before;
424
+ }
425
+
426
+ canMerge(next: Command): boolean {
427
+ return next instanceof MoveClipCommand && next.targetClip() === this.clip;
428
+ }
429
+
430
+ merge(next: Command): Command {
431
+ if (!(next instanceof MoveClipCommand) || next.targetClip() !== this.clip) {
432
+ throw new Error("MoveClipCommand can only merge commands for the same clip.");
433
+ }
434
+ return new MoveClipCommandWithBefore(this.clip, this.before, next.targetStartTime());
435
+ }
436
+
437
+ targetClip(): TimelineClip {
438
+ return this.clip;
439
+ }
440
+
441
+ targetStartTime(): number {
442
+ return this.after;
443
+ }
444
+ }
445
+
446
+ class MoveClipCommandWithBefore extends MoveClipCommand {
447
+ constructor(clip: TimelineClip, private readonly originalStartTime: number, after: number) {
448
+ super(clip, after);
449
+ }
450
+
451
+ override undo(): void {
452
+ this.clip.startTime = this.originalStartTime;
453
+ }
454
+ }
455
+
456
+ class ResizeClipCommand implements Command {
457
+ readonly name: string;
458
+ private readonly beforeStart: number;
459
+ private readonly beforeDuration: number;
460
+
461
+ constructor(private readonly clip: TimelineClip, private readonly afterDuration: number, private readonly afterStart?: number) {
462
+ this.name = `Resize clip ${clip.id}`;
463
+ this.beforeStart = clip.startTime;
464
+ this.beforeDuration = clip.duration;
465
+ }
466
+
467
+ execute(): void {
468
+ if (this.afterStart !== undefined) this.clip.startTime = this.afterStart;
469
+ this.clip.duration = this.afterDuration;
470
+ }
471
+
472
+ undo(): void {
473
+ this.clip.startTime = this.beforeStart;
474
+ this.clip.duration = this.beforeDuration;
475
+ }
476
+ }
477
+
478
+ class ReplaceClipsCommand implements Command {
479
+ readonly name: string;
480
+
481
+ constructor(
482
+ private readonly track: TimelineTrack,
483
+ private readonly remove: readonly TimelineClip[],
484
+ private readonly add: readonly TimelineClip[],
485
+ name: string
486
+ ) {
487
+ this.name = name;
488
+ }
489
+
490
+ execute(): void {
491
+ const clips = this.track.clips as TimelineClip[];
492
+ for (const clip of this.remove) removeItem(clips, clip);
493
+ for (const clip of this.add) {
494
+ if (!clips.includes(clip)) this.track.addClip(clip, { ignoreLock: true });
495
+ }
496
+ }
497
+
498
+ undo(): void {
499
+ const clips = this.track.clips as TimelineClip[];
500
+ for (const clip of this.add) removeItem(clips, clip);
501
+ for (const clip of this.remove) {
502
+ if (!clips.includes(clip)) this.track.addClip(clip, { ignoreLock: true });
503
+ }
504
+ }
505
+ }
506
+
507
+ function removeItem<T>(items: T[], item: T): void {
508
+ const index = items.indexOf(item);
509
+ if (index >= 0) items.splice(index, 1);
510
+ }
511
+
512
+ function finite(value: number, label: string): number {
513
+ if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite.`);
514
+ return value;
515
+ }
516
+
517
+ function positive(value: number, label: string): number {
518
+ finite(value, label);
519
+ if (value <= 0) throw new RangeError(`${label} must be positive.`);
520
+ return value;
521
+ }
522
+
523
+ function clamp(value: number, min: number, max: number): number {
524
+ return Math.max(min, Math.min(max, value));
525
+ }
@@ -0,0 +1,89 @@
1
+ import type { TimelineTrackConfig } from "./TimelineModel";
2
+
3
+ export type TimelineEditorTrackKind =
4
+ | "shot"
5
+ | "animation"
6
+ | "audio"
7
+ | "viseme"
8
+ | "camera"
9
+ | "dialogue"
10
+ | "sfx"
11
+ | "caption"
12
+ | "selection"
13
+ | "signal"
14
+ | "generic";
15
+
16
+ export interface TimelineTrackTypeDefinition {
17
+ readonly kind: TimelineEditorTrackKind;
18
+ readonly timelineType: TimelineTrackConfig["type"];
19
+ readonly label: string;
20
+ readonly laneHeight: number;
21
+ readonly color: string;
22
+ readonly acceptsClips: boolean;
23
+ readonly acceptsKeyframes: boolean;
24
+ readonly waveform: boolean;
25
+ readonly lockedByDefault?: boolean;
26
+ }
27
+
28
+ export const TIMELINE_TRACK_TYPES: readonly TimelineTrackTypeDefinition[] = [
29
+ { kind: "shot", timelineType: "generic", label: "Shots", laneHeight: 36, color: "#2563eb", acceptsClips: true, acceptsKeyframes: false, waveform: false },
30
+ { kind: "animation", timelineType: "animation", label: "Animation", laneHeight: 44, color: "#16a34a", acceptsClips: true, acceptsKeyframes: true, waveform: false },
31
+ { kind: "audio", timelineType: "audio", label: "Audio", laneHeight: 48, color: "#db2777", acceptsClips: true, acceptsKeyframes: false, waveform: true },
32
+ { kind: "viseme", timelineType: "generic", label: "Visemes", laneHeight: 34, color: "#ea580c", acceptsClips: true, acceptsKeyframes: true, waveform: true },
33
+ { kind: "camera", timelineType: "camera", label: "Camera", laneHeight: 40, color: "#7c3aed", acceptsClips: true, acceptsKeyframes: true, waveform: false },
34
+ { kind: "dialogue", timelineType: "audio", label: "Dialogue", laneHeight: 42, color: "#0891b2", acceptsClips: true, acceptsKeyframes: false, waveform: true },
35
+ { kind: "sfx", timelineType: "audio", label: "SFX", laneHeight: 36, color: "#c2410c", acceptsClips: true, acceptsKeyframes: false, waveform: true },
36
+ { kind: "caption", timelineType: "generic", label: "Captions", laneHeight: 32, color: "#475569", acceptsClips: true, acceptsKeyframes: false, waveform: false },
37
+ { kind: "selection", timelineType: "selection", label: "Selection", laneHeight: 32, color: "#64748b", acceptsClips: false, acceptsKeyframes: false, waveform: false },
38
+ { kind: "signal", timelineType: "signal", label: "Signals", laneHeight: 32, color: "#ca8a04", acceptsClips: true, acceptsKeyframes: false, waveform: false },
39
+ { kind: "generic", timelineType: "generic", label: "Generic", laneHeight: 32, color: "#334155", acceptsClips: true, acceptsKeyframes: false, waveform: false }
40
+ ];
41
+
42
+ export function timelineTrackTypeDefinition(kind: TimelineEditorTrackKind): TimelineTrackTypeDefinition {
43
+ const definition = TIMELINE_TRACK_TYPES.find((candidate) => candidate.kind === kind);
44
+ if (!definition) {
45
+ throw new Error(`Unknown timeline editor track kind: ${kind}`);
46
+ }
47
+ return definition;
48
+ }
49
+
50
+ export function timelineTrackKindFromConfig(track: Pick<TimelineTrackConfig, "type" | "properties">): TimelineEditorTrackKind {
51
+ const storedKind = track.properties?.auraTrackKind;
52
+ if (typeof storedKind === "string" && TIMELINE_TRACK_TYPES.some((definition) => definition.kind === storedKind)) {
53
+ return storedKind as TimelineEditorTrackKind;
54
+ }
55
+ if (track.type === "animation" || track.type === "audio" || track.type === "camera" || track.type === "selection" || track.type === "signal") {
56
+ return track.type;
57
+ }
58
+ return "generic";
59
+ }
60
+
61
+ export interface CreateTimelineTrackConfigOptions {
62
+ readonly id?: string;
63
+ readonly muted?: boolean;
64
+ readonly locked?: boolean;
65
+ readonly weight?: number;
66
+ readonly properties?: TimelineTrackConfig["properties"];
67
+ }
68
+
69
+ export function createTimelineTrackConfig(
70
+ kind: TimelineEditorTrackKind,
71
+ name = timelineTrackTypeDefinition(kind).label,
72
+ options: CreateTimelineTrackConfigOptions = {}
73
+ ): TimelineTrackConfig {
74
+ const definition = timelineTrackTypeDefinition(kind);
75
+ return {
76
+ id: options.id,
77
+ name,
78
+ type: definition.timelineType,
79
+ muted: options.muted,
80
+ locked: options.locked ?? definition.lockedByDefault,
81
+ weight: options.weight,
82
+ properties: {
83
+ ...(options.properties ?? {}),
84
+ auraTrackKind: kind,
85
+ laneHeight: definition.laneHeight,
86
+ color: definition.color
87
+ }
88
+ };
89
+ }