@rendiv/studio 0.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.
@@ -0,0 +1,464 @@
1
+ import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
2
+ import type { TimelineEntry } from '@rendiv/core';
3
+ import { colors, fonts } from './styles';
4
+
5
+ interface TimelineProps {
6
+ entries: TimelineEntry[];
7
+ currentFrame: number;
8
+ totalFrames: number;
9
+ fps: number;
10
+ onSeek: (frame: number) => void;
11
+ compositionName: string;
12
+ }
13
+
14
+ const LABEL_WIDTH = 280;
15
+ const RULER_HEIGHT = 28;
16
+ const TRACK_HEIGHT = 40;
17
+ const INDENT_PER_DEPTH = 16;
18
+ const COMPOSITION_ROW_ID = '__composition__';
19
+
20
+ const BLOCK_COLORS = [
21
+ { bg: 'rgba(74, 158, 255, 0.5)', border: '#4a9eff' },
22
+ { bg: 'rgba(188, 140, 255, 0.5)', border: '#bc8cff' },
23
+ { bg: 'rgba(63, 185, 80, 0.5)', border: '#3fb950' },
24
+ { bg: 'rgba(240, 136, 62, 0.5)', border: '#f0883e' },
25
+ { bg: 'rgba(248, 81, 73, 0.5)', border: '#f85149' },
26
+ { bg: 'rgba(219, 171, 9, 0.5)', border: '#dbab09' },
27
+ ];
28
+
29
+ interface TreeNode {
30
+ entry: TimelineEntry;
31
+ children: TreeNode[];
32
+ }
33
+
34
+ interface VisibleRow {
35
+ kind: 'composition' | 'entry';
36
+ depth: number;
37
+ id: string;
38
+ name: string;
39
+ from: number;
40
+ durationInFrames: number;
41
+ hasChildren: boolean;
42
+ isExpanded: boolean;
43
+ }
44
+
45
+ function buildTree(entries: TimelineEntry[]): TreeNode[] {
46
+ const nodeMap = new Map<string, TreeNode>();
47
+ for (const entry of entries) {
48
+ nodeMap.set(entry.id, { entry, children: [] });
49
+ }
50
+ const roots: TreeNode[] = [];
51
+ for (const entry of entries) {
52
+ const node = nodeMap.get(entry.id)!;
53
+ if (entry.parentId === null) {
54
+ roots.push(node);
55
+ } else {
56
+ const parent = nodeMap.get(entry.parentId);
57
+ if (parent) {
58
+ parent.children.push(node);
59
+ } else {
60
+ roots.push(node);
61
+ }
62
+ }
63
+ }
64
+ return roots;
65
+ }
66
+
67
+ function getRulerTicks(
68
+ totalFrames: number,
69
+ fps: number,
70
+ scale: number,
71
+ ): { frame: number; label: string }[] {
72
+ if (scale <= 0 || totalFrames <= 0) return [];
73
+
74
+ const totalSeconds = totalFrames / fps;
75
+ const minPixelsBetweenTicks = 80;
76
+ const minSecondsBetweenTicks = minPixelsBetweenTicks / (scale * fps);
77
+
78
+ const niceIntervals = [0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300];
79
+ const interval = niceIntervals.find((i) => i >= minSecondsBetweenTicks) ?? 300;
80
+
81
+ const ticks: { frame: number; label: string }[] = [];
82
+ for (let s = 0; s <= totalSeconds + 0.001; s += interval) {
83
+ const frame = Math.round(s * fps);
84
+ if (frame > totalFrames) break;
85
+ const minutes = Math.floor(s / 60);
86
+ const secs = Math.floor(s % 60);
87
+ ticks.push({
88
+ frame,
89
+ label: `${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`,
90
+ });
91
+ }
92
+
93
+ return ticks;
94
+ }
95
+
96
+ export const Timeline: React.FC<TimelineProps> = ({
97
+ entries,
98
+ currentFrame,
99
+ totalFrames,
100
+ fps,
101
+ onSeek,
102
+ compositionName,
103
+ }) => {
104
+ const timelineRef = useRef<HTMLDivElement>(null);
105
+ const [timelineWidth, setTimelineWidth] = useState(0);
106
+ const [isDragging, setIsDragging] = useState(false);
107
+ const [expandedIds, setExpandedIds] = useState<Set<string>>(
108
+ () => new Set([COMPOSITION_ROW_ID]),
109
+ );
110
+
111
+ const toggleExpanded = useCallback((id: string) => {
112
+ setExpandedIds((prev) => {
113
+ const next = new Set(prev);
114
+ if (next.has(id)) next.delete(id);
115
+ else next.add(id);
116
+ return next;
117
+ });
118
+ }, []);
119
+
120
+ // Track timeline area width
121
+ useEffect(() => {
122
+ const el = timelineRef.current;
123
+ if (!el) return;
124
+ const observer = new ResizeObserver((resizeEntries) => {
125
+ for (const e of resizeEntries) {
126
+ setTimelineWidth(e.contentRect.width);
127
+ }
128
+ });
129
+ observer.observe(el);
130
+ return () => observer.disconnect();
131
+ }, []);
132
+
133
+ // Build visible rows from tree
134
+ const visibleRows = useMemo(() => {
135
+ const rows: VisibleRow[] = [];
136
+ const tree = buildTree(entries);
137
+
138
+ // Virtual composition root row
139
+ rows.push({
140
+ kind: 'composition',
141
+ depth: 0,
142
+ id: COMPOSITION_ROW_ID,
143
+ name: compositionName,
144
+ from: 0,
145
+ durationInFrames: totalFrames,
146
+ hasChildren: tree.length > 0,
147
+ isExpanded: expandedIds.has(COMPOSITION_ROW_ID),
148
+ });
149
+
150
+ if (!expandedIds.has(COMPOSITION_ROW_ID)) return rows;
151
+
152
+ function walk(nodes: TreeNode[], depth: number) {
153
+ const sorted = [...nodes].sort((a, b) => a.entry.from - b.entry.from);
154
+ for (const node of sorted) {
155
+ const hasChildren = node.children.length > 0;
156
+ const isExpanded = expandedIds.has(node.entry.id);
157
+ rows.push({
158
+ kind: 'entry',
159
+ depth,
160
+ id: node.entry.id,
161
+ name: node.entry.name,
162
+ from: node.entry.from,
163
+ durationInFrames: node.entry.durationInFrames,
164
+ hasChildren,
165
+ isExpanded,
166
+ });
167
+ if (hasChildren && isExpanded) {
168
+ walk(node.children, depth + 1);
169
+ }
170
+ }
171
+ }
172
+
173
+ walk(tree, 1);
174
+ return rows;
175
+ }, [entries, totalFrames, compositionName, expandedIds]);
176
+
177
+ // Calculate scale: pixels per frame
178
+ const availableWidth = timelineWidth - LABEL_WIDTH;
179
+ const scale = availableWidth > 0 && totalFrames > 0 ? availableWidth / totalFrames : 0;
180
+
181
+ // Ruler ticks
182
+ const ticks = useMemo(
183
+ () => getRulerTicks(totalFrames, fps, scale),
184
+ [totalFrames, fps, scale],
185
+ );
186
+
187
+ // Seek from mouse position
188
+ const frameFromMouseEvent = useCallback(
189
+ (e: React.MouseEvent | MouseEvent) => {
190
+ const el = timelineRef.current;
191
+ if (!el || scale <= 0) return -1;
192
+ const rect = el.getBoundingClientRect();
193
+ const x = e.clientX - rect.left - LABEL_WIDTH;
194
+ const frame = Math.round(x / scale);
195
+ return Math.max(0, Math.min(totalFrames - 1, frame));
196
+ },
197
+ [scale, totalFrames],
198
+ );
199
+
200
+ const handleMouseDown = useCallback(
201
+ (e: React.MouseEvent) => {
202
+ const el = timelineRef.current;
203
+ if (!el) return;
204
+ const rect = el.getBoundingClientRect();
205
+ const x = e.clientX - rect.left;
206
+ if (x < LABEL_WIDTH) return;
207
+
208
+ const frame = frameFromMouseEvent(e);
209
+ if (frame >= 0) {
210
+ onSeek(frame);
211
+ setIsDragging(true);
212
+ }
213
+ },
214
+ [frameFromMouseEvent, onSeek],
215
+ );
216
+
217
+ // Global mouse handlers for drag-to-seek
218
+ useEffect(() => {
219
+ if (!isDragging) return;
220
+ const handleMouseMove = (e: MouseEvent) => {
221
+ const frame = frameFromMouseEvent(e);
222
+ if (frame >= 0) onSeek(frame);
223
+ };
224
+ const handleMouseUp = () => setIsDragging(false);
225
+ window.addEventListener('mousemove', handleMouseMove);
226
+ window.addEventListener('mouseup', handleMouseUp);
227
+ return () => {
228
+ window.removeEventListener('mousemove', handleMouseMove);
229
+ window.removeEventListener('mouseup', handleMouseUp);
230
+ };
231
+ }, [isDragging, frameFromMouseEvent, onSeek]);
232
+
233
+ const playheadLeft = LABEL_WIDTH + currentFrame * scale;
234
+ const totalHeight = RULER_HEIGHT + visibleRows.length * TRACK_HEIGHT;
235
+
236
+ return (
237
+ <div
238
+ ref={timelineRef}
239
+ style={{
240
+ position: 'relative',
241
+ backgroundColor: colors.surface,
242
+ borderRadius: 8,
243
+ overflow: 'hidden',
244
+ userSelect: 'none',
245
+ cursor: isDragging ? 'col-resize' : 'default',
246
+ }}
247
+ onMouseDown={handleMouseDown}
248
+ >
249
+ {/* Ruler */}
250
+ <div
251
+ style={{
252
+ display: 'flex',
253
+ height: RULER_HEIGHT,
254
+ borderBottom: `1px solid ${colors.border}`,
255
+ }}
256
+ >
257
+ <div
258
+ style={{
259
+ width: LABEL_WIDTH,
260
+ minWidth: LABEL_WIDTH,
261
+ borderRight: `1px solid ${colors.border}`,
262
+ }}
263
+ />
264
+ <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
265
+ {ticks.map((tick) => (
266
+ <div
267
+ key={tick.frame}
268
+ style={{
269
+ position: 'absolute',
270
+ left: tick.frame * scale,
271
+ top: 0,
272
+ height: '100%',
273
+ display: 'flex',
274
+ flexDirection: 'column',
275
+ alignItems: 'flex-start',
276
+ }}
277
+ >
278
+ <span
279
+ style={{
280
+ fontSize: 10,
281
+ color: colors.textSecondary,
282
+ fontFamily: fonts.mono,
283
+ padding: '6px 4px 0',
284
+ whiteSpace: 'nowrap',
285
+ }}
286
+ >
287
+ {tick.label}
288
+ </span>
289
+ <div
290
+ style={{
291
+ width: 1,
292
+ flex: 1,
293
+ backgroundColor: colors.border,
294
+ }}
295
+ />
296
+ </div>
297
+ ))}
298
+ </div>
299
+ </div>
300
+
301
+ {/* Rows */}
302
+ {visibleRows.map((row, rowIdx) => {
303
+ const isComposition = row.kind === 'composition';
304
+ const colorIdx = rowIdx % BLOCK_COLORS.length;
305
+ const color = BLOCK_COLORS[colorIdx];
306
+ const cappedDuration = Number.isFinite(row.durationInFrames)
307
+ ? row.durationInFrames
308
+ : totalFrames - row.from;
309
+ const blockLeft = row.from * scale;
310
+ const blockWidth = Math.max(2, cappedDuration * scale);
311
+ const indent = row.depth * INDENT_PER_DEPTH;
312
+
313
+ return (
314
+ <div
315
+ key={row.id}
316
+ style={{
317
+ display: 'flex',
318
+ height: TRACK_HEIGHT,
319
+ borderBottom:
320
+ rowIdx < visibleRows.length - 1
321
+ ? `1px solid ${colors.border}`
322
+ : undefined,
323
+ }}
324
+ >
325
+ {/* Label column */}
326
+ <div
327
+ style={{
328
+ width: LABEL_WIDTH,
329
+ minWidth: LABEL_WIDTH,
330
+ borderRight: `1px solid ${colors.border}`,
331
+ display: 'flex',
332
+ alignItems: 'center',
333
+ paddingLeft: 12 + indent,
334
+ gap: 6,
335
+ fontSize: 11,
336
+ color: isComposition ? colors.textPrimary : colors.textSecondary,
337
+ fontFamily: fonts.sans,
338
+ cursor: row.hasChildren ? 'pointer' : 'default',
339
+ userSelect: 'none',
340
+ }}
341
+ onClick={row.hasChildren ? () => toggleExpanded(row.id) : undefined}
342
+ >
343
+ {row.hasChildren ? (
344
+ <span style={{ fontSize: 9, width: 10, flexShrink: 0 }}>
345
+ {row.isExpanded ? '\u25BC' : '\u25B6'}
346
+ </span>
347
+ ) : (
348
+ <span style={{ width: 10, flexShrink: 0 }} />
349
+ )}
350
+ <span
351
+ style={{
352
+ overflow: 'hidden',
353
+ textOverflow: 'ellipsis',
354
+ whiteSpace: 'nowrap',
355
+ fontWeight: isComposition ? 600 : 400,
356
+ }}
357
+ >
358
+ {row.name}
359
+ </span>
360
+ </div>
361
+
362
+ {/* Track area */}
363
+ <div style={{ flex: 1, position: 'relative' }}>
364
+ {isComposition ? (
365
+ <div
366
+ style={{
367
+ position: 'absolute',
368
+ left: 0,
369
+ top: 4,
370
+ width: Math.max(2, totalFrames * scale),
371
+ height: TRACK_HEIGHT - 8,
372
+ backgroundColor: 'rgba(88, 166, 255, 0.15)',
373
+ border: '1px solid rgba(88, 166, 255, 0.3)',
374
+ borderRadius: 4,
375
+ display: 'flex',
376
+ alignItems: 'center',
377
+ paddingLeft: 8,
378
+ overflow: 'hidden',
379
+ }}
380
+ >
381
+ <span
382
+ style={{
383
+ fontSize: 11,
384
+ fontWeight: 600,
385
+ color: colors.accent,
386
+ whiteSpace: 'nowrap',
387
+ overflow: 'hidden',
388
+ textOverflow: 'ellipsis',
389
+ fontFamily: fonts.sans,
390
+ }}
391
+ >
392
+ {row.name}
393
+ </span>
394
+ </div>
395
+ ) : (
396
+ <div
397
+ style={{
398
+ position: 'absolute',
399
+ left: blockLeft,
400
+ top: 4,
401
+ height: TRACK_HEIGHT - 8,
402
+ width: blockWidth,
403
+ backgroundColor: color.bg,
404
+ border: `1px solid ${color.border}`,
405
+ borderRadius: 4,
406
+ display: 'flex',
407
+ alignItems: 'center',
408
+ paddingLeft: 8,
409
+ paddingRight: 4,
410
+ overflow: 'hidden',
411
+ cursor: 'pointer',
412
+ }}
413
+ title={`${row.name} (frame ${row.from}\u2013${row.from + cappedDuration})`}
414
+ >
415
+ <span
416
+ style={{
417
+ fontSize: 11,
418
+ fontWeight: 500,
419
+ color: '#fff',
420
+ whiteSpace: 'nowrap',
421
+ overflow: 'hidden',
422
+ textOverflow: 'ellipsis',
423
+ fontFamily: fonts.sans,
424
+ }}
425
+ >
426
+ {row.name}
427
+ </span>
428
+ </div>
429
+ )}
430
+ </div>
431
+ </div>
432
+ );
433
+ })}
434
+
435
+ {/* Playhead */}
436
+ {scale > 0 && (
437
+ <div
438
+ style={{
439
+ position: 'absolute',
440
+ left: playheadLeft,
441
+ top: 0,
442
+ width: 2,
443
+ height: totalHeight,
444
+ backgroundColor: colors.accent,
445
+ pointerEvents: 'none',
446
+ zIndex: 10,
447
+ }}
448
+ >
449
+ <div
450
+ style={{
451
+ position: 'absolute',
452
+ top: -2,
453
+ left: -5,
454
+ width: 12,
455
+ height: 12,
456
+ backgroundColor: colors.accent,
457
+ clipPath: 'polygon(0 0, 100% 0, 50% 100%)',
458
+ }}
459
+ />
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ };
package/ui/TopBar.tsx ADDED
@@ -0,0 +1,128 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import type { CompositionEntry } from '@rendiv/core';
3
+ import { topBarStyles, colors } from './styles';
4
+ // @ts-ignore — Vite asset import, no types needed
5
+ import logoUrl from './logo.svg';
6
+
7
+ interface TopBarProps {
8
+ composition: CompositionEntry | null;
9
+ entryPoint: string;
10
+ onRender: () => void;
11
+ queueCount: number;
12
+ queueOpen: boolean;
13
+ onToggleQueue: () => void;
14
+ }
15
+
16
+ const RenderIcon: React.FC = () => (
17
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
18
+ <path d="M4 2L13 8L4 14V2Z" fill="currentColor" />
19
+ </svg>
20
+ );
21
+
22
+ const QueueIcon: React.FC<{ open: boolean }> = ({ open }) => (
23
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
24
+ <rect x="10" y="2" width="4" height="12" rx="1" fill={open ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.5" />
25
+ <rect x="2" y="2" width="6" height="12" rx="1" stroke="currentColor" strokeWidth="1.5" />
26
+ </svg>
27
+ );
28
+
29
+ export const TopBar: React.FC<TopBarProps> = ({ composition, entryPoint, onRender, queueCount, queueOpen, onToggleQueue }) => {
30
+ const [copied, setCopied] = useState(false);
31
+
32
+ const handleCopyCommand = useCallback(() => {
33
+ if (!composition) return;
34
+ const cmd = `rendiv render ${entryPoint} ${composition.id} out/${composition.id}.mp4`;
35
+ navigator.clipboard.writeText(cmd).then(() => {
36
+ setCopied(true);
37
+ setTimeout(() => setCopied(false), 2000);
38
+ });
39
+ }, [composition, entryPoint]);
40
+
41
+ return (
42
+ <div style={topBarStyles.container}>
43
+ <img src={logoUrl} alt="Rendiv" width="120" height="28" />
44
+
45
+ <span style={topBarStyles.compositionName}>
46
+ {composition ? composition.id : 'No composition selected'}
47
+ </span>
48
+
49
+ <div style={topBarStyles.actions}>
50
+ <button
51
+ type="button"
52
+ style={{
53
+ ...topBarStyles.button,
54
+ opacity: composition ? 1 : 0.5,
55
+ cursor: composition ? 'pointer' : 'default',
56
+ }}
57
+ onClick={handleCopyCommand}
58
+ disabled={!composition}
59
+ onMouseEnter={(e) => {
60
+ if (composition) {
61
+ e.currentTarget.style.backgroundColor = colors.border;
62
+ }
63
+ }}
64
+ onMouseLeave={(e) => {
65
+ e.currentTarget.style.backgroundColor = colors.surfaceHover;
66
+ }}
67
+ >
68
+ {copied ? 'Copied!' : 'Copy render cmd'}
69
+ </button>
70
+
71
+ <button
72
+ type="button"
73
+ style={{
74
+ ...topBarStyles.renderButton,
75
+ display: 'flex',
76
+ alignItems: 'center',
77
+ gap: 6,
78
+ opacity: composition ? 1 : 0.5,
79
+ cursor: composition ? 'pointer' : 'default',
80
+ }}
81
+ onClick={onRender}
82
+ disabled={!composition}
83
+ title="Add render job to queue"
84
+ >
85
+ <RenderIcon />
86
+ Render
87
+ </button>
88
+
89
+ <button
90
+ type="button"
91
+ style={{
92
+ ...topBarStyles.button,
93
+ display: 'flex',
94
+ alignItems: 'center',
95
+ gap: 6,
96
+ position: 'relative' as const,
97
+ color: queueOpen ? colors.accent : colors.textPrimary,
98
+ borderColor: queueOpen ? colors.accent : colors.border,
99
+ }}
100
+ onClick={onToggleQueue}
101
+ title="Toggle render queue panel"
102
+ >
103
+ <QueueIcon open={queueOpen} />
104
+ {queueCount > 0 && (
105
+ <span style={badgeStyle}>{queueCount}</span>
106
+ )}
107
+ </button>
108
+ </div>
109
+ </div>
110
+ );
111
+ };
112
+
113
+ const badgeStyle: React.CSSProperties = {
114
+ position: 'absolute',
115
+ top: -4,
116
+ right: -4,
117
+ minWidth: 16,
118
+ height: 16,
119
+ borderRadius: 8,
120
+ backgroundColor: colors.accent,
121
+ color: '#fff',
122
+ fontSize: 10,
123
+ fontWeight: 700,
124
+ display: 'flex',
125
+ alignItems: 'center',
126
+ justifyContent: 'center',
127
+ padding: '0 4px',
128
+ };
package/ui/logo.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 48" fill="none">
2
+ <rect x="6" y="8" width="26" height="20" rx="3" stroke="#58a6ff" stroke-width="2" opacity="0.35"/>
3
+ <rect x="12" y="14" width="26" height="20" rx="3" stroke="#58a6ff" stroke-width="2"/>
4
+ <clipPath id="rv-frame">
5
+ <rect x="12" y="14" width="26" height="20" rx="3"/>
6
+ </clipPath>
7
+ <g clip-path="url(#rv-frame)">
8
+ <polygon points="12,34 30,14 38,14 38,34" fill="#58a6ff" opacity="0.25"/>
9
+ </g>
10
+ <path d="M22 20L30 24L22 28Z" fill="#58a6ff"/>
11
+ <text x="52" y="33" font-family="system-ui, -apple-system, sans-serif" font-size="26" font-weight="700" fill="#e6edf3" letter-spacing="-0.5">rendiv</text>
12
+ </svg>