@rendiv/studio 0.1.1 → 0.1.3

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.
@@ -0,0 +1,573 @@
1
+ import React, { useRef, useCallback, useMemo, useEffect, useState } from 'react';
2
+ import type { TimelineEditorProps, TrackEntry } from './timeline/types';
3
+ import { assignTracks } from './timeline/track-layout';
4
+ import { useTimelineZoom } from './timeline/use-timeline-zoom';
5
+ import { useTimelineDrag } from './timeline/use-timeline-drag';
6
+
7
+ const TRACK_HEIGHT = 32;
8
+ const TRACK_GAP = 2;
9
+ const RULER_HEIGHT = 28;
10
+ const LABEL_WIDTH = 140;
11
+ const TOOLBAR_HEIGHT = 32;
12
+ const EDGE_HIT_ZONE = 6;
13
+
14
+ // Block colors — cycle through a palette
15
+ const BLOCK_COLORS = [
16
+ '#1f6feb', '#238636', '#8957e5', '#da3633',
17
+ '#d29922', '#1a7f37', '#6639ba', '#cf222e',
18
+ ];
19
+
20
+ function getBlockColor(index: number): string {
21
+ return BLOCK_COLORS[index % BLOCK_COLORS.length];
22
+ }
23
+
24
+ function formatTimecode(frame: number, fps: number): string {
25
+ const totalSeconds = frame / fps;
26
+ const m = Math.floor(totalSeconds / 60);
27
+ const s = Math.floor(totalSeconds % 60);
28
+ const f = frame % fps;
29
+ return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`;
30
+ }
31
+
32
+ export const TimelineEditor: React.FC<TimelineEditorProps> = ({
33
+ entries,
34
+ currentFrame,
35
+ totalFrames,
36
+ fps,
37
+ compositionName,
38
+ onSeek,
39
+ overrides,
40
+ onOverrideChange,
41
+ onOverrideRemove,
42
+ onOverridesClear,
43
+ view,
44
+ onViewChange,
45
+ }) => {
46
+ const trackAreaRef = useRef<HTMLDivElement>(null);
47
+ const [selectedPath, setSelectedPath] = useState<string | null>(null);
48
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; namePath: string } | null>(null);
49
+
50
+ const { pixelsPerFrame, scrollLeft, setScrollLeft, setPixelsPerFrame, handleWheel } = useTimelineZoom({
51
+ totalFrames,
52
+ containerRef: trackAreaRef,
53
+ });
54
+
55
+ const tracks = useMemo(
56
+ () => assignTracks(entries, overrides),
57
+ [entries, overrides],
58
+ );
59
+
60
+ const tracksRef = useRef(tracks);
61
+ tracksRef.current = tracks;
62
+
63
+ // Check whether a block can be placed on a track without overlapping other blocks
64
+ const canPlaceOnTrack = useCallback((namePath: string, from: number, duration: number, targetTrack: number): boolean => {
65
+ const currentTracks = tracksRef.current;
66
+ if (targetTrack >= currentTracks.length) return true; // new empty track
67
+ const trackEntries = currentTracks[targetTrack]?.entries ?? [];
68
+ const end = from + duration;
69
+ return !trackEntries.some((te) => {
70
+ if (te.entry.namePath === namePath) return false; // skip self
71
+ const teEnd = te.entry.from + te.entry.durationInFrames;
72
+ return from < teEnd && end > te.entry.from; // overlap check
73
+ });
74
+ }, []);
75
+
76
+ const { startDrag } = useTimelineDrag({
77
+ pixelsPerFrame,
78
+ trackHeight: TRACK_HEIGHT,
79
+ trackGap: TRACK_GAP,
80
+ onOverrideChange,
81
+ canPlaceOnTrack,
82
+ });
83
+
84
+ // Attach wheel handler
85
+ useEffect(() => {
86
+ const el = trackAreaRef.current;
87
+ if (!el) return;
88
+ el.addEventListener('wheel', handleWheel, { passive: false });
89
+ return () => el.removeEventListener('wheel', handleWheel);
90
+ }, [handleWheel]);
91
+
92
+ // Sync scroll position
93
+ useEffect(() => {
94
+ const el = trackAreaRef.current;
95
+ if (el) el.scrollLeft = scrollLeft;
96
+ }, [scrollLeft]);
97
+
98
+ // Close context menu on click elsewhere
99
+ useEffect(() => {
100
+ if (!contextMenu) return;
101
+ const close = () => setContextMenu(null);
102
+ window.addEventListener('click', close);
103
+ return () => window.removeEventListener('click', close);
104
+ }, [contextMenu]);
105
+
106
+ const totalWidth = totalFrames * pixelsPerFrame;
107
+ const trackAreaHeight = Math.max(1, tracks.length) * (TRACK_HEIGHT + TRACK_GAP);
108
+
109
+ // Ruler tick computation
110
+ const rulerTicks = useMemo(() => {
111
+ const ticks: { frame: number; label: string; major: boolean }[] = [];
112
+ // Target ~100px between major ticks
113
+ const framesPerMajor = Math.max(1, Math.round(100 / pixelsPerFrame));
114
+ // Round to nice intervals
115
+ const nice = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600];
116
+ let interval = nice.find((n) => n >= framesPerMajor) ?? framesPerMajor;
117
+ // Make sure minor ticks divide evenly
118
+ const minorInterval = interval >= 10 ? interval / 5 : interval >= 2 ? interval / 2 : 1;
119
+
120
+ for (let f = 0; f <= totalFrames; f += minorInterval) {
121
+ const fr = Math.round(f);
122
+ if (fr > totalFrames) break;
123
+ const isMajor = fr % interval === 0;
124
+ ticks.push({ frame: fr, label: isMajor ? formatTimecode(fr, fps) : '', major: isMajor });
125
+ }
126
+ return ticks;
127
+ }, [totalFrames, pixelsPerFrame, fps]);
128
+
129
+ // Playhead seeking via ruler click/drag
130
+ const isSeekingRef = useRef(false);
131
+
132
+ const getFrameFromClientX = useCallback((clientX: number): number => {
133
+ const el = trackAreaRef.current;
134
+ if (!el) return 0;
135
+ const rect = el.getBoundingClientRect();
136
+ const x = clientX - rect.left + el.scrollLeft;
137
+ return Math.max(0, Math.min(totalFrames - 1, Math.round(x / pixelsPerFrame)));
138
+ }, [pixelsPerFrame, totalFrames]);
139
+
140
+ const handleRulerMouseDown = useCallback((e: React.MouseEvent) => {
141
+ e.preventDefault();
142
+ isSeekingRef.current = true;
143
+ onSeek(getFrameFromClientX(e.clientX));
144
+ }, [getFrameFromClientX, onSeek]);
145
+
146
+ useEffect(() => {
147
+ const handleMouseMove = (e: MouseEvent) => {
148
+ if (!isSeekingRef.current) return;
149
+ onSeek(getFrameFromClientX(e.clientX));
150
+ };
151
+ const handleMouseUp = () => { isSeekingRef.current = false; };
152
+ window.addEventListener('mousemove', handleMouseMove);
153
+ window.addEventListener('mouseup', handleMouseUp);
154
+ return () => {
155
+ window.removeEventListener('mousemove', handleMouseMove);
156
+ window.removeEventListener('mouseup', handleMouseUp);
157
+ };
158
+ }, [getFrameFromClientX, onSeek]);
159
+
160
+ // Block mouse handlers
161
+ const handleBlockMouseDown = useCallback((e: React.MouseEvent, te: TrackEntry) => {
162
+ e.stopPropagation();
163
+ if (e.button !== 0) return;
164
+
165
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
166
+ const relX = e.clientX - rect.left;
167
+ const width = rect.width;
168
+
169
+ let edge: 'body' | 'left' | 'right' = 'body';
170
+ if (relX <= EDGE_HIT_ZONE) edge = 'left';
171
+ else if (relX >= width - EDGE_HIT_ZONE) edge = 'right';
172
+
173
+ setSelectedPath(te.entry.namePath);
174
+ startDrag(te.entry.namePath, edge, e.clientX, e.clientY, te.entry.from, te.entry.durationInFrames, te.trackIndex);
175
+ }, [startDrag]);
176
+
177
+ const handleBlockContextMenu = useCallback((e: React.MouseEvent, te: TrackEntry) => {
178
+ e.preventDefault();
179
+ e.stopPropagation();
180
+ setContextMenu({ x: e.clientX, y: e.clientY, namePath: te.entry.namePath });
181
+ }, []);
182
+
183
+ // Track area click for seeking (when clicking empty space)
184
+ const handleTrackAreaClick = useCallback((e: React.MouseEvent) => {
185
+ if (e.target === e.currentTarget || (e.target as HTMLElement).dataset?.trackBg) {
186
+ onSeek(getFrameFromClientX(e.clientX));
187
+ setSelectedPath(null);
188
+ }
189
+ }, [getFrameFromClientX, onSeek]);
190
+
191
+ // Zoom slider
192
+ const handleZoomChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
193
+ setPixelsPerFrame(Number(e.target.value));
194
+ }, [setPixelsPerFrame]);
195
+
196
+ const playheadX = currentFrame * pixelsPerFrame;
197
+ const hasOverrides = overrides.size > 0;
198
+
199
+ return (
200
+ <div style={containerStyle}>
201
+ {/* Toolbar */}
202
+ <div style={toolbarStyle}>
203
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
204
+ <span style={{ fontSize: 11, color: '#8b949e' }}>{compositionName}</span>
205
+ <span style={{ fontSize: 11, color: '#484f58' }}>|</span>
206
+ <span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'monospace' }}>
207
+ {formatTimecode(currentFrame, fps)} / {formatTimecode(totalFrames, fps)}
208
+ </span>
209
+ </div>
210
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
211
+ {hasOverrides && (
212
+ <button onClick={onOverridesClear} style={resetBtnStyle} title="Reset all overrides">
213
+ Reset All
214
+ </button>
215
+ )}
216
+ <label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#8b949e' }}>
217
+ Zoom
218
+ <input
219
+ type="range"
220
+ min={0.5}
221
+ max={20}
222
+ step={0.1}
223
+ value={pixelsPerFrame}
224
+ onChange={handleZoomChange}
225
+ style={{ width: 80, accentColor: '#58a6ff' }}
226
+ />
227
+ </label>
228
+ <ViewToggle view={view} onChange={onViewChange} />
229
+ </div>
230
+ </div>
231
+
232
+ {/* Main area: labels + tracks + ruler */}
233
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
234
+ {/* Track labels */}
235
+ <div style={labelsContainerStyle}>
236
+ <div style={{ height: RULER_HEIGHT, borderBottom: '1px solid #30363d' }} />
237
+ {tracks.map((track) => (
238
+ <div key={track.id} style={trackLabelStyle}>
239
+ <span style={{ fontSize: 10, color: '#484f58' }}>Track {track.id + 1}</span>
240
+ </div>
241
+ ))}
242
+ {tracks.length === 0 && (
243
+ <div style={{ ...trackLabelStyle, color: '#484f58', fontStyle: 'italic' }}>
244
+ No sequences
245
+ </div>
246
+ )}
247
+ </div>
248
+
249
+ {/* Scrollable track area */}
250
+ <div
251
+ ref={trackAreaRef}
252
+ style={trackAreaContainerStyle}
253
+ onClick={handleTrackAreaClick}
254
+ onScroll={(e) => setScrollLeft((e.target as HTMLElement).scrollLeft)}
255
+ >
256
+ {/* Ruler */}
257
+ <div style={{ ...rulerStyle, width: totalWidth }}>
258
+ {rulerTicks.map((tick, i) => (
259
+ <div
260
+ key={i}
261
+ style={{
262
+ position: 'absolute',
263
+ left: tick.frame * pixelsPerFrame,
264
+ top: 0,
265
+ height: '100%',
266
+ display: 'flex',
267
+ flexDirection: 'column',
268
+ justifyContent: 'flex-end',
269
+ alignItems: 'center',
270
+ }}
271
+ >
272
+ <div style={{
273
+ width: 1,
274
+ height: tick.major ? 10 : 5,
275
+ backgroundColor: tick.major ? '#484f58' : '#30363d',
276
+ }} />
277
+ {tick.label && (
278
+ <span style={{
279
+ position: 'absolute',
280
+ top: 2,
281
+ left: 2,
282
+ fontSize: 9,
283
+ color: '#8b949e',
284
+ fontFamily: 'monospace',
285
+ whiteSpace: 'nowrap',
286
+ pointerEvents: 'none',
287
+ }}>
288
+ {tick.label}
289
+ </span>
290
+ )}
291
+ </div>
292
+ ))}
293
+ {/* Ruler seek overlay */}
294
+ <div
295
+ style={{ position: 'absolute', inset: 0, cursor: 'pointer' }}
296
+ onMouseDown={handleRulerMouseDown}
297
+ />
298
+ </div>
299
+
300
+ {/* Track rows */}
301
+ <div style={{ position: 'relative', width: totalWidth, minHeight: trackAreaHeight }}>
302
+ {/* Track background rows */}
303
+ {tracks.map((track) => (
304
+ <div
305
+ key={track.id}
306
+ data-track-bg="true"
307
+ style={{
308
+ position: 'absolute',
309
+ top: track.id * (TRACK_HEIGHT + TRACK_GAP),
310
+ left: 0,
311
+ width: totalWidth,
312
+ height: TRACK_HEIGHT,
313
+ backgroundColor: track.id % 2 === 0 ? '#161b22' : '#1c2128',
314
+ }}
315
+ />
316
+ ))}
317
+
318
+ {/* Blocks */}
319
+ {tracks.flatMap((track) =>
320
+ track.entries.map((te) => {
321
+ const left = te.entry.from * pixelsPerFrame;
322
+ const width = Math.max(2, te.entry.durationInFrames * pixelsPerFrame);
323
+ const top = te.trackIndex * (TRACK_HEIGHT + TRACK_GAP);
324
+ const isSelected = te.entry.namePath === selectedPath;
325
+ const color = getBlockColor(te.trackIndex);
326
+
327
+ return (
328
+ <div
329
+ key={te.entry.id}
330
+ onMouseDown={(e) => handleBlockMouseDown(e, te)}
331
+ onContextMenu={(e) => handleBlockContextMenu(e, te)}
332
+ title={`${te.entry.namePath}\nFrom: ${te.entry.from} Duration: ${te.entry.durationInFrames}`}
333
+ style={{
334
+ position: 'absolute',
335
+ left,
336
+ top: top + 2,
337
+ width,
338
+ height: TRACK_HEIGHT - 4,
339
+ backgroundColor: color,
340
+ opacity: isSelected ? 1 : 0.75,
341
+ borderRadius: 4,
342
+ border: isSelected ? '1px solid #58a6ff' : '1px solid transparent',
343
+ cursor: 'grab',
344
+ display: 'flex',
345
+ alignItems: 'center',
346
+ overflow: 'hidden',
347
+ userSelect: 'none',
348
+ boxSizing: 'border-box',
349
+ }}
350
+ >
351
+ {/* Left resize handle */}
352
+ <div style={{ ...edgeHandleStyle, left: 0, cursor: 'ew-resize' }} />
353
+
354
+ {/* Label */}
355
+ <span style={{
356
+ flex: 1,
357
+ fontSize: 10,
358
+ color: '#fff',
359
+ padding: '0 8px',
360
+ overflow: 'hidden',
361
+ textOverflow: 'ellipsis',
362
+ whiteSpace: 'nowrap',
363
+ pointerEvents: 'none',
364
+ fontWeight: 500,
365
+ }}>
366
+ {te.entry.name}
367
+ {te.hasOverride && (
368
+ <span style={{ marginLeft: 4, fontSize: 8, opacity: 0.7 }}>*</span>
369
+ )}
370
+ </span>
371
+
372
+ {/* Right resize handle */}
373
+ <div style={{ ...edgeHandleStyle, right: 0, cursor: 'ew-resize' }} />
374
+ </div>
375
+ );
376
+ }),
377
+ )}
378
+
379
+ {/* Playhead */}
380
+ <div style={{
381
+ position: 'absolute',
382
+ left: playheadX,
383
+ top: -RULER_HEIGHT,
384
+ width: 1,
385
+ height: trackAreaHeight + RULER_HEIGHT,
386
+ backgroundColor: '#f85149',
387
+ pointerEvents: 'none',
388
+ zIndex: 10,
389
+ }}>
390
+ {/* Playhead marker */}
391
+ <div style={{
392
+ position: 'absolute',
393
+ top: 0,
394
+ left: -5,
395
+ width: 0,
396
+ height: 0,
397
+ borderLeft: '5px solid transparent',
398
+ borderRight: '5px solid transparent',
399
+ borderTop: '8px solid #f85149',
400
+ }} />
401
+ </div>
402
+ </div>
403
+ </div>
404
+ </div>
405
+
406
+ {/* Selection info bar */}
407
+ {selectedPath && (
408
+ <div style={infoBarStyle}>
409
+ <span style={{ fontSize: 11, color: '#e6edf3', fontFamily: 'monospace' }}>{selectedPath}</span>
410
+ {overrides.has(selectedPath) && (
411
+ <button
412
+ onClick={() => { onOverrideRemove(selectedPath); setSelectedPath(null); }}
413
+ style={resetBtnStyle}
414
+ >
415
+ Reset Position
416
+ </button>
417
+ )}
418
+ </div>
419
+ )}
420
+
421
+ {/* Context menu */}
422
+ {contextMenu && (
423
+ <div style={{ ...contextMenuStyle, left: contextMenu.x, top: contextMenu.y }}>
424
+ {overrides.has(contextMenu.namePath) && (
425
+ <div
426
+ style={contextMenuItemStyle}
427
+ onClick={() => { onOverrideRemove(contextMenu.namePath); setContextMenu(null); }}
428
+ >
429
+ Reset Position
430
+ </div>
431
+ )}
432
+ <div
433
+ style={contextMenuItemStyle}
434
+ onClick={() => { setSelectedPath(contextMenu.namePath); setContextMenu(null); }}
435
+ >
436
+ Select
437
+ </div>
438
+ </div>
439
+ )}
440
+ </div>
441
+ );
442
+ };
443
+
444
+ // --- View Toggle (same style as StudioApp) ---
445
+ const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
446
+ <div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
447
+ {(['editor', 'tree'] as const).map((v) => (
448
+ <button
449
+ key={v}
450
+ onClick={() => onChange(v)}
451
+ style={{
452
+ padding: '3px 10px',
453
+ fontSize: 11,
454
+ fontWeight: 500,
455
+ border: 'none',
456
+ borderRadius: 4,
457
+ cursor: 'pointer',
458
+ backgroundColor: view === v ? '#30363d' : 'transparent',
459
+ color: view === v ? '#e6edf3' : '#8b949e',
460
+ fontFamily: 'system-ui, -apple-system, sans-serif',
461
+ }}
462
+ >
463
+ {v === 'editor' ? 'Tracks' : 'Tree'}
464
+ </button>
465
+ ))}
466
+ </div>
467
+ );
468
+
469
+ // --- Styles ---
470
+
471
+ const containerStyle: React.CSSProperties = {
472
+ display: 'flex',
473
+ flexDirection: 'column',
474
+ height: '100%',
475
+ backgroundColor: '#0d1117',
476
+ color: '#e6edf3',
477
+ fontFamily: 'system-ui, -apple-system, sans-serif',
478
+ fontSize: 13,
479
+ };
480
+
481
+ const toolbarStyle: React.CSSProperties = {
482
+ display: 'flex',
483
+ alignItems: 'center',
484
+ justifyContent: 'space-between',
485
+ height: TOOLBAR_HEIGHT,
486
+ padding: '0 12px',
487
+ backgroundColor: '#161b22',
488
+ borderBottom: '1px solid #30363d',
489
+ flexShrink: 0,
490
+ };
491
+
492
+ const labelsContainerStyle: React.CSSProperties = {
493
+ width: LABEL_WIDTH,
494
+ minWidth: LABEL_WIDTH,
495
+ flexShrink: 0,
496
+ backgroundColor: '#161b22',
497
+ borderRight: '1px solid #30363d',
498
+ overflow: 'hidden',
499
+ };
500
+
501
+ const trackLabelStyle: React.CSSProperties = {
502
+ height: TRACK_HEIGHT + TRACK_GAP,
503
+ display: 'flex',
504
+ alignItems: 'center',
505
+ padding: '0 8px',
506
+ fontSize: 11,
507
+ color: '#8b949e',
508
+ borderBottom: '1px solid #21262d',
509
+ };
510
+
511
+ const trackAreaContainerStyle: React.CSSProperties = {
512
+ flex: 1,
513
+ overflow: 'auto',
514
+ position: 'relative',
515
+ };
516
+
517
+ const rulerStyle: React.CSSProperties = {
518
+ position: 'sticky',
519
+ top: 0,
520
+ height: RULER_HEIGHT,
521
+ backgroundColor: '#161b22',
522
+ borderBottom: '1px solid #30363d',
523
+ zIndex: 5,
524
+ };
525
+
526
+ const edgeHandleStyle: React.CSSProperties = {
527
+ position: 'absolute',
528
+ top: 0,
529
+ width: EDGE_HIT_ZONE,
530
+ height: '100%',
531
+ zIndex: 1,
532
+ };
533
+
534
+ const resetBtnStyle: React.CSSProperties = {
535
+ padding: '2px 8px',
536
+ fontSize: 10,
537
+ fontWeight: 500,
538
+ color: '#f85149',
539
+ backgroundColor: 'transparent',
540
+ border: '1px solid #f8514933',
541
+ borderRadius: 4,
542
+ cursor: 'pointer',
543
+ fontFamily: 'system-ui, -apple-system, sans-serif',
544
+ };
545
+
546
+ const infoBarStyle: React.CSSProperties = {
547
+ display: 'flex',
548
+ alignItems: 'center',
549
+ justifyContent: 'space-between',
550
+ height: 24,
551
+ padding: '0 12px',
552
+ backgroundColor: '#161b22',
553
+ borderTop: '1px solid #30363d',
554
+ flexShrink: 0,
555
+ };
556
+
557
+ const contextMenuStyle: React.CSSProperties = {
558
+ position: 'fixed',
559
+ zIndex: 100,
560
+ backgroundColor: '#1c2128',
561
+ border: '1px solid #30363d',
562
+ borderRadius: 6,
563
+ padding: '4px 0',
564
+ boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
565
+ minWidth: 140,
566
+ };
567
+
568
+ const contextMenuItemStyle: React.CSSProperties = {
569
+ padding: '6px 12px',
570
+ fontSize: 12,
571
+ color: '#e6edf3',
572
+ cursor: 'pointer',
573
+ };
@@ -0,0 +1,94 @@
1
+ import type { TimelineEntry, TimelineOverride } from '@rendiv/core';
2
+ import type { Track, TrackEntry } from './types';
3
+
4
+ /**
5
+ * Assign entries to tracks, respecting pinned trackIndex from overrides.
6
+ *
7
+ * 1. Entries with a trackIndex override are placed on their assigned track.
8
+ * 2. Remaining entries are auto-assigned via greedy bin-packing,
9
+ * skipping occupied time ranges on each track.
10
+ * 3. Only leaf entries (entries with no children) appear as blocks.
11
+ *
12
+ * Track 0 (top) renders in front; higher track indices render behind.
13
+ */
14
+ export function assignTracks(
15
+ entries: TimelineEntry[],
16
+ overrides: Map<string, TimelineOverride>,
17
+ ): Track[] {
18
+ // Find parent IDs to identify leaves
19
+ const parentIds = new Set(entries.map((e) => e.parentId).filter(Boolean));
20
+ const leaves = entries.filter((e) => !parentIds.has(e.id));
21
+
22
+ // Sort by from position (start time)
23
+ const sorted = [...leaves].sort((a, b) => a.from - b.from);
24
+
25
+ // Separate pinned (has trackIndex override) from unpinned
26
+ const pinned: { entry: TimelineEntry; trackIndex: number; hasOverride: boolean }[] = [];
27
+ const unpinned: TimelineEntry[] = [];
28
+
29
+ for (const entry of sorted) {
30
+ const override = overrides.get(entry.namePath);
31
+ if (override?.trackIndex !== undefined) {
32
+ pinned.push({ entry, trackIndex: override.trackIndex, hasOverride: true });
33
+ } else {
34
+ unpinned.push(entry);
35
+ }
36
+ }
37
+
38
+ // Determine how many tracks we need (at minimum, enough for pinned entries)
39
+ let maxTrack = -1;
40
+ for (const p of pinned) {
41
+ if (p.trackIndex > maxTrack) maxTrack = p.trackIndex;
42
+ }
43
+
44
+ // Build track structures — at least enough for pinned entries
45
+ const trackCount = maxTrack + 1;
46
+ const tracks: Track[] = [];
47
+ const trackEnds: number[] = [];
48
+ for (let i = 0; i < trackCount; i++) {
49
+ tracks.push({ id: i, entries: [] });
50
+ trackEnds.push(0);
51
+ }
52
+
53
+ // Place pinned entries first
54
+ for (const p of pinned) {
55
+ const idx = p.trackIndex;
56
+ const te: TrackEntry = { entry: p.entry, trackIndex: idx, hasOverride: true };
57
+ tracks[idx].entries.push(te);
58
+ const end = p.entry.from + p.entry.durationInFrames;
59
+ if (end > trackEnds[idx]) trackEnds[idx] = end;
60
+ }
61
+
62
+ // Greedy bin-pack unpinned entries into existing or new tracks
63
+ for (const entry of unpinned) {
64
+ const end = entry.from + entry.durationInFrames;
65
+ let assigned = false;
66
+ const hasOverride = overrides.has(entry.namePath);
67
+
68
+ for (let i = 0; i < tracks.length; i++) {
69
+ // Check if this entry fits without overlapping any existing entry on this track
70
+ const fits = !tracks[i].entries.some((te) => {
71
+ const teEnd = te.entry.from + te.entry.durationInFrames;
72
+ return entry.from < teEnd && end > te.entry.from;
73
+ });
74
+
75
+ if (fits) {
76
+ const te: TrackEntry = { entry, trackIndex: i, hasOverride };
77
+ tracks[i].entries.push(te);
78
+ if (end > trackEnds[i]) trackEnds[i] = end;
79
+ assigned = true;
80
+ break;
81
+ }
82
+ }
83
+
84
+ if (!assigned) {
85
+ const idx = tracks.length;
86
+ const te: TrackEntry = { entry, trackIndex: idx, hasOverride };
87
+ tracks.push({ id: idx, entries: [te] });
88
+ trackEnds.push(end);
89
+ assigned = true;
90
+ }
91
+ }
92
+
93
+ return tracks;
94
+ }
@@ -0,0 +1,39 @@
1
+ import type { TimelineEntry, TimelineOverride } from '@rendiv/core';
2
+
3
+ export interface TrackEntry {
4
+ entry: TimelineEntry;
5
+ trackIndex: number;
6
+ hasOverride: boolean;
7
+ }
8
+
9
+ export interface Track {
10
+ id: number;
11
+ entries: TrackEntry[];
12
+ }
13
+
14
+ export type DragEdge = 'body' | 'left' | 'right';
15
+
16
+ export interface DragOperation {
17
+ namePath: string;
18
+ edge: DragEdge;
19
+ startClientX: number;
20
+ startClientY: number;
21
+ originalFrom: number;
22
+ originalDuration: number;
23
+ originalTrackIndex: number;
24
+ }
25
+
26
+ export interface TimelineEditorProps {
27
+ entries: TimelineEntry[];
28
+ currentFrame: number;
29
+ totalFrames: number;
30
+ fps: number;
31
+ compositionName: string;
32
+ onSeek: (frame: number) => void;
33
+ overrides: Map<string, TimelineOverride>;
34
+ onOverrideChange: (namePath: string, override: TimelineOverride) => void;
35
+ onOverrideRemove: (namePath: string) => void;
36
+ onOverridesClear: () => void;
37
+ view: 'editor' | 'tree';
38
+ onViewChange: (view: 'editor' | 'tree') => void;
39
+ }