@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
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>(
|
|
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
|
-
|
|
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
|
|
275
|
+
// Auto-select composition from URL hash, or fall back to first
|
|
117
276
|
useEffect(() => {
|
|
118
|
-
if (
|
|
119
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
)}
|