@rendiv/studio 0.1.2 → 0.1.4

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/ui/StudioApp.tsx CHANGED
@@ -5,20 +5,50 @@ import {
5
5
  CompositionManagerContext,
6
6
  type CompositionEntry,
7
7
  type TimelineEntry,
8
+ type TimelineOverride,
8
9
  } from '@rendiv/core';
9
10
  import { Sidebar } from './Sidebar';
10
11
  import { Preview } from './Preview';
11
12
  import { TopBar } from './TopBar';
12
13
  import { Timeline } from './Timeline';
14
+ import { TimelineEditor } from './TimelineEditor';
13
15
  import { RenderQueue, type RenderJob } from './RenderQueue';
14
- import { layoutStyles, scrollbarCSS } from './styles';
16
+ import { Terminal } from './Terminal';
17
+ import { layoutStyles, scrollbarCSS, colors, fonts } from './styles';
15
18
 
16
19
  // Read the entry point from the generated code's data attribute (set by studio-entry-code)
17
20
  const ENTRY_POINT = (window as Record<string, unknown>).__RENDIV_STUDIO_ENTRY__ as string ?? 'src/index.tsx';
18
21
 
22
+ const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
23
+ <div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
24
+ {(['editor', 'tree'] as const).map((v) => (
25
+ <button
26
+ key={v}
27
+ onClick={() => onChange(v)}
28
+ style={{
29
+ padding: '3px 10px',
30
+ fontSize: 11,
31
+ fontWeight: 500,
32
+ border: 'none',
33
+ borderRadius: 4,
34
+ cursor: 'pointer',
35
+ backgroundColor: view === v ? '#30363d' : 'transparent',
36
+ color: view === v ? '#e6edf3' : '#8b949e',
37
+ fontFamily: 'system-ui, -apple-system, sans-serif',
38
+ }}
39
+ >
40
+ {v === 'editor' ? 'Tracks' : 'Tree'}
41
+ </button>
42
+ ))}
43
+ </div>
44
+ );
45
+
19
46
  const StudioApp: React.FC = () => {
20
47
  const [compositions, setCompositions] = useState<CompositionEntry[]>([]);
21
- const [selectedId, setSelectedId] = useState<string | null>(null);
48
+ const [selectedId, setSelectedId] = useState<string | null>(() => {
49
+ const hash = window.location.hash.slice(1);
50
+ return hash || null;
51
+ });
22
52
  const [inputProps, setInputProps] = useState<Record<string, unknown>>({});
23
53
  const [playbackRate, setPlaybackRate] = useState(1);
24
54
  const [currentFrame, setCurrentFrame] = useState(0);
@@ -32,11 +62,34 @@ const StudioApp: React.FC = () => {
32
62
  const dragStartY = useRef(0);
33
63
  const dragStartHeight = useRef(0);
34
64
 
65
+ // Timeline override state
66
+ const [overrides, setOverrides] = useState<Map<string, TimelineOverride>>(new Map());
67
+ const [timelineView, setTimelineView] = useState<'editor' | 'tree'>(() => {
68
+ return (localStorage.getItem('rendiv-studio:timeline-view') as 'editor' | 'tree') || 'editor';
69
+ });
70
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
71
+ const selectedIdRef = useRef(selectedId);
72
+ selectedIdRef.current = selectedId;
73
+ const pruneTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74
+
35
75
  // Render queue state (server-driven)
36
76
  const [renderJobs, setRenderJobs] = useState<RenderJob[]>([]);
37
- const [queueOpen, setQueueOpen] = useState(false);
38
77
  const hasActiveRef = useRef(false);
39
78
 
79
+ // Right panel state — tabbed panel for Queue and Agent
80
+ const [rightPanel, setRightPanel] = useState<'queue' | 'agent' | null>(() => {
81
+ const stored = localStorage.getItem('rendiv-studio:right-panel');
82
+ if (stored === 'queue' || stored === 'agent') return stored;
83
+ return null;
84
+ });
85
+ const [rightPanelWidth, setRightPanelWidth] = useState(() => {
86
+ const stored = localStorage.getItem('rendiv-studio:right-panel-width');
87
+ return stored ? Number(stored) : 360;
88
+ });
89
+ const isDraggingPanel = useRef(false);
90
+ const panelDragStartX = useRef(0);
91
+ const panelDragStartWidth = useRef(0);
92
+
40
93
  const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
41
94
  e.preventDefault();
42
95
  isDraggingTimeline.current = true;
@@ -44,11 +97,23 @@ const StudioApp: React.FC = () => {
44
97
  dragStartHeight.current = timelineHeight;
45
98
  }, [timelineHeight]);
46
99
 
100
+ const handlePanelResizeMouseDown = useCallback((e: React.MouseEvent) => {
101
+ e.preventDefault();
102
+ isDraggingPanel.current = true;
103
+ panelDragStartX.current = e.clientX;
104
+ panelDragStartWidth.current = rightPanelWidth;
105
+ }, [rightPanelWidth]);
106
+
47
107
  useEffect(() => {
48
108
  const handleMouseMove = (e: MouseEvent) => {
49
- if (!isDraggingTimeline.current) return;
50
- const delta = dragStartY.current - e.clientY;
51
- setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
109
+ if (isDraggingTimeline.current) {
110
+ const delta = dragStartY.current - e.clientY;
111
+ setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
112
+ }
113
+ if (isDraggingPanel.current) {
114
+ const delta = panelDragStartX.current - e.clientX;
115
+ setRightPanelWidth(Math.max(280, Math.min(800, panelDragStartWidth.current + delta)));
116
+ }
52
117
  };
53
118
  const handleMouseUp = () => {
54
119
  if (isDraggingTimeline.current) {
@@ -58,6 +123,13 @@ const StudioApp: React.FC = () => {
58
123
  return h;
59
124
  });
60
125
  }
126
+ if (isDraggingPanel.current) {
127
+ isDraggingPanel.current = false;
128
+ setRightPanelWidth((w) => {
129
+ localStorage.setItem('rendiv-studio:right-panel-width', String(w));
130
+ return w;
131
+ });
132
+ }
61
133
  };
62
134
  window.addEventListener('mousemove', handleMouseMove);
63
135
  window.addEventListener('mouseup', handleMouseUp);
@@ -67,17 +139,137 @@ const StudioApp: React.FC = () => {
67
139
  };
68
140
  }, []);
69
141
 
142
+ // --- Timeline overrides ---
143
+
144
+ // Fetch overrides from server on mount, populate global Map
145
+ useEffect(() => {
146
+ fetch('/__rendiv_api__/timeline/overrides')
147
+ .then((res) => res.ok ? res.json() : { overrides: {} })
148
+ .then((data: { overrides: Record<string, TimelineOverride> }) => {
149
+ const map = new Map<string, TimelineOverride>(Object.entries(data.overrides));
150
+ (window as unknown as Record<string, unknown>).__RENDIV_TIMELINE_OVERRIDES__ = map;
151
+ setOverrides(map);
152
+ // Force re-render of composition tree
153
+ seekRef.current?.(currentFrame);
154
+ })
155
+ .catch(() => {});
156
+ }, []);
157
+
158
+ // Listen for external file changes pushed from the server via WebSocket
159
+ useEffect(() => {
160
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
+ const hot = (import.meta as any).hot as { on: (event: string, cb: (...args: any[]) => void) => void; off?: (event: string, cb: (...args: any[]) => void) => void } | undefined;
162
+ if (!hot) return;
163
+
164
+ const handler = (data: { overrides: Record<string, TimelineOverride> }) => {
165
+ const map = new Map<string, TimelineOverride>(Object.entries(data.overrides));
166
+ (window as unknown as Record<string, unknown>).__RENDIV_TIMELINE_OVERRIDES__ = map;
167
+ setOverrides(map);
168
+ seekRef.current?.(currentFrame);
169
+ };
170
+
171
+ hot.on('rendiv:overrides-update', handler);
172
+ return () => {
173
+ hot.off?.('rendiv:overrides-update', handler);
174
+ };
175
+ }, [currentFrame]);
176
+
177
+ // Debounced save to server
178
+ const saveOverridesToServer = useCallback((map: Map<string, TimelineOverride>) => {
179
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
180
+ saveTimerRef.current = setTimeout(() => {
181
+ const obj: Record<string, TimelineOverride> = {};
182
+ map.forEach((v, k) => { obj[k] = v; });
183
+ fetch('/__rendiv_api__/timeline/overrides', {
184
+ method: 'PUT',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({ overrides: obj }),
187
+ }).catch(() => {});
188
+ }, 300);
189
+ }, []);
190
+ const saveOverridesToServerRef = useRef(saveOverridesToServer);
191
+ saveOverridesToServerRef.current = saveOverridesToServer;
192
+
193
+ const handleOverrideChange = useCallback((namePath: string, override: TimelineOverride) => {
194
+ const w = window as unknown as Record<string, unknown>;
195
+ let map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
196
+ if (!map) {
197
+ map = new Map();
198
+ w.__RENDIV_TIMELINE_OVERRIDES__ = map;
199
+ }
200
+ map.set(namePath, override);
201
+ setOverrides(new Map(map));
202
+ seekRef.current?.(currentFrame);
203
+ saveOverridesToServer(map);
204
+ }, [currentFrame, saveOverridesToServer]);
205
+
206
+ const handleOverrideRemove = useCallback((namePath: string) => {
207
+ const w = window as unknown as Record<string, unknown>;
208
+ const map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
209
+ if (map) {
210
+ map.delete(namePath);
211
+ setOverrides(new Map(map));
212
+ seekRef.current?.(currentFrame);
213
+ saveOverridesToServer(map);
214
+ }
215
+ }, [currentFrame, saveOverridesToServer]);
216
+
217
+ const handleOverridesClear = useCallback(() => {
218
+ const w = window as unknown as Record<string, unknown>;
219
+ const map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
220
+ if (map) map.clear();
221
+ setOverrides(new Map());
222
+ seekRef.current?.(currentFrame);
223
+ fetch('/__rendiv_api__/timeline/overrides', { method: 'DELETE' }).catch(() => {});
224
+ }, [currentFrame]);
225
+
226
+ const handleTimelineViewChange = useCallback((view: 'editor' | 'tree') => {
227
+ setTimelineView(view);
228
+ localStorage.setItem('rendiv-studio:timeline-view', view);
229
+ }, []);
230
+
70
231
  // Timeline registry: reads from a shared global Map + listens for sync events.
232
+ // Orphan pruning is debounced to avoid reacting to transient states — when a
233
+ // Sequence effect re-runs (e.g. after an override changes absoluteFrom), React
234
+ // runs the cleanup (which deletes the entry and dispatches sync) before the new
235
+ // effect re-registers it. Without debouncing, the pruning would see the entry
236
+ // as missing during this gap and delete the override.
71
237
  useEffect(() => {
72
238
  const readEntries = () => {
73
239
  const w = window as unknown as Record<string, unknown>;
74
240
  const entries = w.__RENDIV_TIMELINE_ENTRIES__ as Map<string, TimelineEntry> | undefined;
75
- setTimelineEntries(entries ? Array.from(entries.values()) : []);
241
+ const list = entries ? Array.from(entries.values()) : [];
242
+ setTimelineEntries(list);
243
+
244
+ // Debounced orphan pruning — wait for effects to settle
245
+ if (pruneTimerRef.current) clearTimeout(pruneTimerRef.current);
246
+ pruneTimerRef.current = setTimeout(() => {
247
+ const currentSelectedId = selectedIdRef.current;
248
+ const overrideMap = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
249
+ const currentEntries = w.__RENDIV_TIMELINE_ENTRIES__ as Map<string, TimelineEntry> | undefined;
250
+ const currentList = currentEntries ? Array.from(currentEntries.values()) : [];
251
+ if (overrideMap && overrideMap.size > 0 && currentSelectedId) {
252
+ const prefix = `${currentSelectedId}/`;
253
+ const activeNamePaths = new Set(currentList.map((e) => e.namePath));
254
+ let pruned = false;
255
+ for (const key of overrideMap.keys()) {
256
+ if (key.startsWith(prefix) && !activeNamePaths.has(key)) {
257
+ overrideMap.delete(key);
258
+ pruned = true;
259
+ }
260
+ }
261
+ if (pruned) {
262
+ setOverrides(new Map(overrideMap));
263
+ saveOverridesToServerRef.current(overrideMap);
264
+ }
265
+ }
266
+ }, 500);
76
267
  };
77
268
  readEntries();
78
269
  document.addEventListener('rendiv:timeline-sync', readEntries);
79
270
  return () => {
80
271
  document.removeEventListener('rendiv:timeline-sync', readEntries);
272
+ if (pruneTimerRef.current) clearTimeout(pruneTimerRef.current);
81
273
  };
82
274
  }, []);
83
275
 
@@ -113,13 +305,31 @@ const StudioApp: React.FC = () => {
113
305
  [compositions, registerComposition, unregisterComposition, selectedId, inputProps],
114
306
  );
115
307
 
116
- // Auto-select first composition when compositions are registered
308
+ // Auto-select composition from URL hash, or fall back to first
117
309
  useEffect(() => {
118
- if (selectedId === null && compositions.length > 0) {
119
- setSelectedId(compositions[0].id);
120
- }
310
+ if (compositions.length === 0) return;
311
+ if (selectedId !== null && compositions.some((c: CompositionEntry) => c.id === selectedId)) return;
312
+ // Hash composition no longer exists or no selection — fall back to first
313
+ setSelectedId(compositions[0].id);
121
314
  }, [compositions, selectedId]);
122
315
 
316
+ // Sync URL hash when selection changes
317
+ useEffect(() => {
318
+ if (selectedId) {
319
+ window.location.hash = selectedId;
320
+ }
321
+ }, [selectedId]);
322
+
323
+ // Listen for browser back/forward navigation
324
+ useEffect(() => {
325
+ const handleHashChange = () => {
326
+ const hash = window.location.hash.slice(1);
327
+ if (hash) setSelectedId(hash);
328
+ };
329
+ window.addEventListener('hashchange', handleHashChange);
330
+ return () => window.removeEventListener('hashchange', handleHashChange);
331
+ }, []);
332
+
123
333
  // Reset input props when switching compositions
124
334
  useEffect(() => {
125
335
  setInputProps({});
@@ -174,7 +384,8 @@ const StudioApp: React.FC = () => {
174
384
  totalFrames: selectedComposition.durationInFrames,
175
385
  }),
176
386
  });
177
- setQueueOpen(true);
387
+ setRightPanel('queue');
388
+ localStorage.setItem('rendiv-studio:right-panel-tab', 'queue');
178
389
  }, [selectedComposition, inputProps]);
179
390
 
180
391
  const handleCancelJob = useCallback((jobId: string) => {
@@ -189,8 +400,18 @@ const StudioApp: React.FC = () => {
189
400
  fetch('/__rendiv_api__/render/queue/clear', { method: 'POST' });
190
401
  }, []);
191
402
 
192
- const handleToggleQueue = useCallback(() => {
193
- setQueueOpen((prev) => !prev);
403
+ const handleTogglePanel = useCallback(() => {
404
+ setRightPanel((prev) => {
405
+ if (prev !== null) {
406
+ // Close panel
407
+ localStorage.setItem('rendiv-studio:right-panel', '');
408
+ return null;
409
+ }
410
+ // Open to last active tab (default 'queue')
411
+ const tab = (localStorage.getItem('rendiv-studio:right-panel-tab') as 'queue' | 'agent') || 'queue';
412
+ localStorage.setItem('rendiv-studio:right-panel', tab);
413
+ return tab;
414
+ });
194
415
  }, []);
195
416
 
196
417
  const queueCount = renderJobs.filter((j) =>
@@ -207,8 +428,8 @@ const StudioApp: React.FC = () => {
207
428
  entryPoint={ENTRY_POINT}
208
429
  onRender={handleAddRender}
209
430
  queueCount={queueCount}
210
- queueOpen={queueOpen}
211
- onToggleQueue={handleToggleQueue}
431
+ panelOpen={rightPanel !== null}
432
+ onTogglePanel={handleTogglePanel}
212
433
  />
213
434
 
214
435
  <div style={layoutStyles.body}>
@@ -234,14 +455,67 @@ const StudioApp: React.FC = () => {
234
455
  </div>
235
456
  )}
236
457
 
237
- <RenderQueue
238
- jobs={renderJobs}
239
- open={queueOpen}
240
- onToggle={handleToggleQueue}
241
- onCancel={handleCancelJob}
242
- onRemove={handleRemoveJob}
243
- onClear={handleClearFinished}
244
- />
458
+ {/* Right panel — tabbed: Queue / Agent */}
459
+ {rightPanel !== null && (
460
+ <div style={{ ...rightPanelStyle, width: rightPanelWidth, minWidth: 280 }}>
461
+ <div
462
+ style={panelResizeHandleStyle}
463
+ onMouseDown={handlePanelResizeMouseDown}
464
+ />
465
+ <div style={tabBarStyle}>
466
+ <div style={{ display: 'flex', gap: 2, padding: 2, backgroundColor: colors.bg, borderRadius: 6 }}>
467
+ {(['queue', 'agent'] as const).map((tab) => (
468
+ <button
469
+ key={tab}
470
+ onClick={() => {
471
+ setRightPanel(tab);
472
+ localStorage.setItem('rendiv-studio:right-panel', tab);
473
+ localStorage.setItem('rendiv-studio:right-panel-tab', tab);
474
+ }}
475
+ style={{
476
+ padding: '3px 10px',
477
+ fontSize: 11,
478
+ fontWeight: 500,
479
+ border: 'none',
480
+ borderRadius: 4,
481
+ cursor: 'pointer',
482
+ backgroundColor: rightPanel === tab ? colors.border : 'transparent',
483
+ color: rightPanel === tab ? colors.textPrimary : colors.textSecondary,
484
+ fontFamily: fonts.sans,
485
+ }}
486
+ >
487
+ {tab === 'queue' ? 'Queue' : 'Agent'}
488
+ {tab === 'queue' && queueCount > 0 && (
489
+ <span style={{ marginLeft: 4, color: colors.accent, fontWeight: 600 }}>({queueCount})</span>
490
+ )}
491
+ </button>
492
+ ))}
493
+ </div>
494
+ <button
495
+ type="button"
496
+ onClick={() => {
497
+ setRightPanel(null);
498
+ localStorage.setItem('rendiv-studio:right-panel', '');
499
+ }}
500
+ style={tabCloseStyle}
501
+ title="Close panel"
502
+ >
503
+ {'\u2715'}
504
+ </button>
505
+ </div>
506
+ <div style={{ display: rightPanel === 'queue' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
507
+ <RenderQueue
508
+ jobs={renderJobs}
509
+ onCancel={handleCancelJob}
510
+ onRemove={handleRemoveJob}
511
+ onClear={handleClearFinished}
512
+ />
513
+ </div>
514
+ <div style={{ display: rightPanel === 'agent' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
515
+ <Terminal open={rightPanel === 'agent'} />
516
+ </div>
517
+ </div>
518
+ )}
245
519
  </div>
246
520
 
247
521
  {/* Timeline — full-width resizable row */}
@@ -252,14 +526,38 @@ const StudioApp: React.FC = () => {
252
526
  onMouseDown={handleResizeMouseDown}
253
527
  />
254
528
  <div style={{ flex: 1, overflow: 'auto' }}>
255
- <Timeline
256
- entries={timelineEntries}
257
- currentFrame={currentFrame}
258
- totalFrames={selectedComposition.durationInFrames}
259
- fps={selectedComposition.fps}
260
- onSeek={handleTimelineSeek}
261
- compositionName={selectedComposition.id}
262
- />
529
+ {timelineView === 'editor' ? (
530
+ <TimelineEditor
531
+ entries={timelineEntries}
532
+ currentFrame={currentFrame}
533
+ totalFrames={selectedComposition.durationInFrames}
534
+ fps={selectedComposition.fps}
535
+ compositionName={selectedComposition.id}
536
+ onSeek={handleTimelineSeek}
537
+ overrides={overrides}
538
+ onOverrideChange={handleOverrideChange}
539
+ onOverrideRemove={handleOverrideRemove}
540
+ onOverridesClear={handleOverridesClear}
541
+ view={timelineView}
542
+ onViewChange={handleTimelineViewChange}
543
+ />
544
+ ) : (
545
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
546
+ <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', backgroundColor: '#161b22', borderBottom: '1px solid #30363d' }}>
547
+ <ViewToggle view={timelineView} onChange={handleTimelineViewChange} />
548
+ </div>
549
+ <div style={{ flex: 1, overflow: 'auto' }}>
550
+ <Timeline
551
+ entries={timelineEntries}
552
+ currentFrame={currentFrame}
553
+ totalFrames={selectedComposition.durationInFrames}
554
+ fps={selectedComposition.fps}
555
+ onSeek={handleTimelineSeek}
556
+ compositionName={selectedComposition.id}
557
+ />
558
+ </div>
559
+ </div>
560
+ )}
263
561
  </div>
264
562
  </div>
265
563
  )}
@@ -274,6 +572,44 @@ const StudioApp: React.FC = () => {
274
572
  );
275
573
  };
276
574
 
575
+ const rightPanelStyle: React.CSSProperties = {
576
+ position: 'relative',
577
+ height: '100%',
578
+ backgroundColor: colors.surface,
579
+ borderLeft: `1px solid ${colors.border}`,
580
+ display: 'flex',
581
+ flexDirection: 'column',
582
+ flexShrink: 0,
583
+ };
584
+
585
+ const panelResizeHandleStyle: React.CSSProperties = {
586
+ position: 'absolute',
587
+ top: 0,
588
+ left: 0,
589
+ width: 4,
590
+ height: '100%',
591
+ cursor: 'col-resize',
592
+ zIndex: 10,
593
+ };
594
+
595
+ const tabBarStyle: React.CSSProperties = {
596
+ display: 'flex',
597
+ justifyContent: 'space-between',
598
+ alignItems: 'center',
599
+ padding: '6px 8px',
600
+ borderBottom: `1px solid ${colors.border}`,
601
+ flexShrink: 0,
602
+ };
603
+
604
+ const tabCloseStyle: React.CSSProperties = {
605
+ background: 'none',
606
+ border: 'none',
607
+ color: colors.textSecondary,
608
+ cursor: 'pointer',
609
+ fontSize: 12,
610
+ padding: '2px 4px',
611
+ };
612
+
277
613
  export function createStudioApp(container: HTMLElement | null): void {
278
614
  if (!container) {
279
615
  throw new Error('Rendiv Studio: Could not find #root element');