@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.
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/start-studio.d.ts +11 -0
- package/dist/start-studio.d.ts.map +1 -0
- package/dist/start-studio.js +74 -0
- package/dist/start-studio.js.map +1 -0
- package/dist/studio-entry-code.d.ts +13 -0
- package/dist/studio-entry-code.d.ts.map +1 -0
- package/dist/studio-entry-code.js +48 -0
- package/dist/studio-entry-code.js.map +1 -0
- package/dist/vite-plugin-studio.d.ts +7 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -0
- package/dist/vite-plugin-studio.js +173 -0
- package/dist/vite-plugin-studio.js.map +1 -0
- package/package.json +35 -0
- package/ui/Preview.tsx +379 -0
- package/ui/RenderQueue.tsx +308 -0
- package/ui/Sidebar.tsx +125 -0
- package/ui/StudioApp.tsx +282 -0
- package/ui/Timeline.tsx +464 -0
- package/ui/TopBar.tsx +128 -0
- package/ui/logo.svg +12 -0
- package/ui/styles.ts +283 -0
package/ui/Timeline.tsx
ADDED
|
@@ -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>
|