@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.
- package/LICENSE +1 -1
- package/dist/apply-overrides.d.ts +32 -0
- package/dist/apply-overrides.d.ts.map +1 -0
- package/dist/apply-overrides.js +388 -0
- package/dist/apply-overrides.js.map +1 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -1
- package/dist/vite-plugin-studio.js +81 -0
- package/dist/vite-plugin-studio.js.map +1 -1
- package/package.json +3 -3
- package/ui/Preview.tsx +1 -0
- package/ui/StudioApp.tsx +215 -14
- package/ui/TimelineEditor.tsx +573 -0
- package/ui/timeline/track-layout.ts +94 -0
- package/ui/timeline/types.ts +39 -0
- package/ui/timeline/use-timeline-drag.ts +98 -0
- package/ui/timeline/use-timeline-zoom.ts +65 -0
|
@@ -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
|
+
}
|