@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/ui/StudioApp.tsx CHANGED
@@ -5,20 +5,49 @@ 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
16
  import { layoutStyles, scrollbarCSS } from './styles';
15
17
 
16
18
  // Read the entry point from the generated code's data attribute (set by studio-entry-code)
17
19
  const ENTRY_POINT = (window as Record<string, unknown>).__RENDIV_STUDIO_ENTRY__ as string ?? 'src/index.tsx';
18
20
 
21
+ const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
22
+ <div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
23
+ {(['editor', 'tree'] as const).map((v) => (
24
+ <button
25
+ key={v}
26
+ onClick={() => onChange(v)}
27
+ style={{
28
+ padding: '3px 10px',
29
+ fontSize: 11,
30
+ fontWeight: 500,
31
+ border: 'none',
32
+ borderRadius: 4,
33
+ cursor: 'pointer',
34
+ backgroundColor: view === v ? '#30363d' : 'transparent',
35
+ color: view === v ? '#e6edf3' : '#8b949e',
36
+ fontFamily: 'system-ui, -apple-system, sans-serif',
37
+ }}
38
+ >
39
+ {v === 'editor' ? 'Tracks' : 'Tree'}
40
+ </button>
41
+ ))}
42
+ </div>
43
+ );
44
+
19
45
  const StudioApp: React.FC = () => {
20
46
  const [compositions, setCompositions] = useState<CompositionEntry[]>([]);
21
- const [selectedId, setSelectedId] = useState<string | null>(null);
47
+ const [selectedId, setSelectedId] = useState<string | null>(() => {
48
+ const hash = window.location.hash.slice(1);
49
+ return hash || null;
50
+ });
22
51
  const [inputProps, setInputProps] = useState<Record<string, unknown>>({});
23
52
  const [playbackRate, setPlaybackRate] = useState(1);
24
53
  const [currentFrame, setCurrentFrame] = useState(0);
@@ -32,6 +61,16 @@ const StudioApp: React.FC = () => {
32
61
  const dragStartY = useRef(0);
33
62
  const dragStartHeight = useRef(0);
34
63
 
64
+ // Timeline override state
65
+ const [overrides, setOverrides] = useState<Map<string, TimelineOverride>>(new Map());
66
+ const [timelineView, setTimelineView] = useState<'editor' | 'tree'>(() => {
67
+ return (localStorage.getItem('rendiv-studio:timeline-view') as 'editor' | 'tree') || 'editor';
68
+ });
69
+ const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
70
+ const selectedIdRef = useRef(selectedId);
71
+ selectedIdRef.current = selectedId;
72
+ const pruneTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
73
+
35
74
  // Render queue state (server-driven)
36
75
  const [renderJobs, setRenderJobs] = useState<RenderJob[]>([]);
37
76
  const [queueOpen, setQueueOpen] = useState(false);
@@ -67,17 +106,137 @@ const StudioApp: React.FC = () => {
67
106
  };
68
107
  }, []);
69
108
 
109
+ // --- Timeline overrides ---
110
+
111
+ // Fetch overrides from server on mount, populate global Map
112
+ useEffect(() => {
113
+ fetch('/__rendiv_api__/timeline/overrides')
114
+ .then((res) => res.ok ? res.json() : { overrides: {} })
115
+ .then((data: { overrides: Record<string, TimelineOverride> }) => {
116
+ const map = new Map<string, TimelineOverride>(Object.entries(data.overrides));
117
+ (window as unknown as Record<string, unknown>).__RENDIV_TIMELINE_OVERRIDES__ = map;
118
+ setOverrides(map);
119
+ // Force re-render of composition tree
120
+ seekRef.current?.(currentFrame);
121
+ })
122
+ .catch(() => {});
123
+ }, []);
124
+
125
+ // Listen for external file changes pushed from the server via WebSocket
126
+ useEffect(() => {
127
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
128
+ 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;
129
+ if (!hot) return;
130
+
131
+ const handler = (data: { overrides: Record<string, TimelineOverride> }) => {
132
+ const map = new Map<string, TimelineOverride>(Object.entries(data.overrides));
133
+ (window as unknown as Record<string, unknown>).__RENDIV_TIMELINE_OVERRIDES__ = map;
134
+ setOverrides(map);
135
+ seekRef.current?.(currentFrame);
136
+ };
137
+
138
+ hot.on('rendiv:overrides-update', handler);
139
+ return () => {
140
+ hot.off?.('rendiv:overrides-update', handler);
141
+ };
142
+ }, [currentFrame]);
143
+
144
+ // Debounced save to server
145
+ const saveOverridesToServer = useCallback((map: Map<string, TimelineOverride>) => {
146
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
147
+ saveTimerRef.current = setTimeout(() => {
148
+ const obj: Record<string, TimelineOverride> = {};
149
+ map.forEach((v, k) => { obj[k] = v; });
150
+ fetch('/__rendiv_api__/timeline/overrides', {
151
+ method: 'PUT',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify({ overrides: obj }),
154
+ }).catch(() => {});
155
+ }, 300);
156
+ }, []);
157
+ const saveOverridesToServerRef = useRef(saveOverridesToServer);
158
+ saveOverridesToServerRef.current = saveOverridesToServer;
159
+
160
+ const handleOverrideChange = useCallback((namePath: string, override: TimelineOverride) => {
161
+ const w = window as unknown as Record<string, unknown>;
162
+ let map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
163
+ if (!map) {
164
+ map = new Map();
165
+ w.__RENDIV_TIMELINE_OVERRIDES__ = map;
166
+ }
167
+ map.set(namePath, override);
168
+ setOverrides(new Map(map));
169
+ seekRef.current?.(currentFrame);
170
+ saveOverridesToServer(map);
171
+ }, [currentFrame, saveOverridesToServer]);
172
+
173
+ const handleOverrideRemove = useCallback((namePath: string) => {
174
+ const w = window as unknown as Record<string, unknown>;
175
+ const map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
176
+ if (map) {
177
+ map.delete(namePath);
178
+ setOverrides(new Map(map));
179
+ seekRef.current?.(currentFrame);
180
+ saveOverridesToServer(map);
181
+ }
182
+ }, [currentFrame, saveOverridesToServer]);
183
+
184
+ const handleOverridesClear = useCallback(() => {
185
+ const w = window as unknown as Record<string, unknown>;
186
+ const map = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
187
+ if (map) map.clear();
188
+ setOverrides(new Map());
189
+ seekRef.current?.(currentFrame);
190
+ fetch('/__rendiv_api__/timeline/overrides', { method: 'DELETE' }).catch(() => {});
191
+ }, [currentFrame]);
192
+
193
+ const handleTimelineViewChange = useCallback((view: 'editor' | 'tree') => {
194
+ setTimelineView(view);
195
+ localStorage.setItem('rendiv-studio:timeline-view', view);
196
+ }, []);
197
+
70
198
  // Timeline registry: reads from a shared global Map + listens for sync events.
199
+ // Orphan pruning is debounced to avoid reacting to transient states — when a
200
+ // Sequence effect re-runs (e.g. after an override changes absoluteFrom), React
201
+ // runs the cleanup (which deletes the entry and dispatches sync) before the new
202
+ // effect re-registers it. Without debouncing, the pruning would see the entry
203
+ // as missing during this gap and delete the override.
71
204
  useEffect(() => {
72
205
  const readEntries = () => {
73
206
  const w = window as unknown as Record<string, unknown>;
74
207
  const entries = w.__RENDIV_TIMELINE_ENTRIES__ as Map<string, TimelineEntry> | undefined;
75
- setTimelineEntries(entries ? Array.from(entries.values()) : []);
208
+ const list = entries ? Array.from(entries.values()) : [];
209
+ setTimelineEntries(list);
210
+
211
+ // Debounced orphan pruning — wait for effects to settle
212
+ if (pruneTimerRef.current) clearTimeout(pruneTimerRef.current);
213
+ pruneTimerRef.current = setTimeout(() => {
214
+ const currentSelectedId = selectedIdRef.current;
215
+ const overrideMap = w.__RENDIV_TIMELINE_OVERRIDES__ as Map<string, TimelineOverride> | undefined;
216
+ const currentEntries = w.__RENDIV_TIMELINE_ENTRIES__ as Map<string, TimelineEntry> | undefined;
217
+ const currentList = currentEntries ? Array.from(currentEntries.values()) : [];
218
+ if (overrideMap && overrideMap.size > 0 && currentSelectedId) {
219
+ const prefix = `${currentSelectedId}/`;
220
+ const activeNamePaths = new Set(currentList.map((e) => e.namePath));
221
+ let pruned = false;
222
+ for (const key of overrideMap.keys()) {
223
+ if (key.startsWith(prefix) && !activeNamePaths.has(key)) {
224
+ overrideMap.delete(key);
225
+ pruned = true;
226
+ }
227
+ }
228
+ if (pruned) {
229
+ setOverrides(new Map(overrideMap));
230
+ saveOverridesToServerRef.current(overrideMap);
231
+ }
232
+ }
233
+ }, 500);
76
234
  };
77
235
  readEntries();
78
236
  document.addEventListener('rendiv:timeline-sync', readEntries);
79
237
  return () => {
80
238
  document.removeEventListener('rendiv:timeline-sync', readEntries);
239
+ if (pruneTimerRef.current) clearTimeout(pruneTimerRef.current);
81
240
  };
82
241
  }, []);
83
242
 
@@ -113,13 +272,31 @@ const StudioApp: React.FC = () => {
113
272
  [compositions, registerComposition, unregisterComposition, selectedId, inputProps],
114
273
  );
115
274
 
116
- // Auto-select first composition when compositions are registered
275
+ // Auto-select composition from URL hash, or fall back to first
117
276
  useEffect(() => {
118
- if (selectedId === null && compositions.length > 0) {
119
- setSelectedId(compositions[0].id);
120
- }
277
+ if (compositions.length === 0) return;
278
+ if (selectedId !== null && compositions.some((c: CompositionEntry) => c.id === selectedId)) return;
279
+ // Hash composition no longer exists or no selection — fall back to first
280
+ setSelectedId(compositions[0].id);
121
281
  }, [compositions, selectedId]);
122
282
 
283
+ // Sync URL hash when selection changes
284
+ useEffect(() => {
285
+ if (selectedId) {
286
+ window.location.hash = selectedId;
287
+ }
288
+ }, [selectedId]);
289
+
290
+ // Listen for browser back/forward navigation
291
+ useEffect(() => {
292
+ const handleHashChange = () => {
293
+ const hash = window.location.hash.slice(1);
294
+ if (hash) setSelectedId(hash);
295
+ };
296
+ window.addEventListener('hashchange', handleHashChange);
297
+ return () => window.removeEventListener('hashchange', handleHashChange);
298
+ }, []);
299
+
123
300
  // Reset input props when switching compositions
124
301
  useEffect(() => {
125
302
  setInputProps({});
@@ -252,14 +429,38 @@ const StudioApp: React.FC = () => {
252
429
  onMouseDown={handleResizeMouseDown}
253
430
  />
254
431
  <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
- />
432
+ {timelineView === 'editor' ? (
433
+ <TimelineEditor
434
+ entries={timelineEntries}
435
+ currentFrame={currentFrame}
436
+ totalFrames={selectedComposition.durationInFrames}
437
+ fps={selectedComposition.fps}
438
+ compositionName={selectedComposition.id}
439
+ onSeek={handleTimelineSeek}
440
+ overrides={overrides}
441
+ onOverrideChange={handleOverrideChange}
442
+ onOverrideRemove={handleOverrideRemove}
443
+ onOverridesClear={handleOverridesClear}
444
+ view={timelineView}
445
+ onViewChange={handleTimelineViewChange}
446
+ />
447
+ ) : (
448
+ <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
449
+ <div style={{ display: 'flex', justifyContent: 'flex-end', padding: '4px 8px', backgroundColor: '#161b22', borderBottom: '1px solid #30363d' }}>
450
+ <ViewToggle view={timelineView} onChange={handleTimelineViewChange} />
451
+ </div>
452
+ <div style={{ flex: 1, overflow: 'auto' }}>
453
+ <Timeline
454
+ entries={timelineEntries}
455
+ currentFrame={currentFrame}
456
+ totalFrames={selectedComposition.durationInFrames}
457
+ fps={selectedComposition.fps}
458
+ onSeek={handleTimelineSeek}
459
+ compositionName={selectedComposition.id}
460
+ />
461
+ </div>
462
+ </div>
463
+ )}
263
464
  </div>
264
465
  </div>
265
466
  )}