@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/ui/Sidebar.tsx ADDED
@@ -0,0 +1,125 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import type { CompositionEntry } from '@rendiv/core';
3
+ import { sidebarStyles, colors } from './styles';
4
+
5
+ interface SidebarProps {
6
+ compositions: CompositionEntry[];
7
+ selectedId: string | null;
8
+ onSelect: (id: string) => void;
9
+ }
10
+
11
+ function formatDuration(durationInFrames: number, fps: number): string {
12
+ const seconds = durationInFrames / fps;
13
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
14
+ const m = Math.floor(seconds / 60);
15
+ const s = Math.floor(seconds % 60);
16
+ return `${m}:${String(s).padStart(2, '0')}`;
17
+ }
18
+
19
+ interface FolderGroup {
20
+ name: string;
21
+ compositions: CompositionEntry[];
22
+ }
23
+
24
+ export const Sidebar: React.FC<SidebarProps> = ({ compositions, selectedId, onSelect }) => {
25
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
26
+
27
+ const { ungrouped, folders } = useMemo(() => {
28
+ const ungrouped: CompositionEntry[] = [];
29
+ const folderMap = new Map<string, CompositionEntry[]>();
30
+
31
+ for (const comp of compositions) {
32
+ if (comp.group === null) {
33
+ ungrouped.push(comp);
34
+ } else {
35
+ if (!folderMap.has(comp.group)) folderMap.set(comp.group, []);
36
+ folderMap.get(comp.group)!.push(comp);
37
+ }
38
+ }
39
+
40
+ const folders: FolderGroup[] = Array.from(folderMap.entries())
41
+ .sort(([a], [b]) => a.localeCompare(b))
42
+ .map(([name, compositions]) => ({ name, compositions }));
43
+
44
+ return { ungrouped, folders };
45
+ }, [compositions]);
46
+
47
+ const toggleFolder = (name: string) => {
48
+ setCollapsedFolders((prev) => {
49
+ const next = new Set(prev);
50
+ if (next.has(name)) next.delete(name);
51
+ else next.add(name);
52
+ return next;
53
+ });
54
+ };
55
+
56
+ const renderItem = (comp: CompositionEntry) => {
57
+ const isSelected = comp.id === selectedId;
58
+ const style = isSelected ? sidebarStyles.itemSelected : sidebarStyles.item;
59
+
60
+ return (
61
+ <div
62
+ key={comp.id}
63
+ style={style}
64
+ onClick={() => onSelect(comp.id)}
65
+ onMouseEnter={(e) => {
66
+ if (!isSelected) {
67
+ e.currentTarget.style.backgroundColor = colors.surfaceHover;
68
+ }
69
+ }}
70
+ onMouseLeave={(e) => {
71
+ if (!isSelected) {
72
+ e.currentTarget.style.backgroundColor = 'transparent';
73
+ }
74
+ }}
75
+ >
76
+ <span>{comp.id}</span>
77
+ <span
78
+ style={{
79
+ ...sidebarStyles.badge,
80
+ backgroundColor: comp.type === 'still' ? colors.badgeStill : colors.badge,
81
+ }}
82
+ >
83
+ {comp.type === 'still' ? 'still' : 'comp'}
84
+ </span>
85
+ <span style={sidebarStyles.duration}>
86
+ {formatDuration(comp.durationInFrames, comp.fps)}
87
+ </span>
88
+ </div>
89
+ );
90
+ };
91
+
92
+ return (
93
+ <div style={sidebarStyles.container}>
94
+ <div style={sidebarStyles.header}>Compositions</div>
95
+
96
+ {ungrouped.map(renderItem)}
97
+
98
+ {folders.map((folder) => {
99
+ const isCollapsed = collapsedFolders.has(folder.name);
100
+
101
+ return (
102
+ <div key={folder.name}>
103
+ <div
104
+ style={sidebarStyles.folderHeader}
105
+ onClick={() => toggleFolder(folder.name)}
106
+ >
107
+ <span style={{ fontSize: 10 }}>{isCollapsed ? '\u25B6' : '\u25BC'}</span>
108
+ <span>{folder.name}</span>
109
+ <span style={{ color: colors.textSecondary, fontSize: 11 }}>
110
+ ({folder.compositions.length})
111
+ </span>
112
+ </div>
113
+ {!isCollapsed && folder.compositions.map(renderItem)}
114
+ </div>
115
+ );
116
+ })}
117
+
118
+ {compositions.length === 0 && (
119
+ <div style={{ padding: '16px', color: colors.textSecondary, fontSize: 12 }}>
120
+ No compositions registered.
121
+ </div>
122
+ )}
123
+ </div>
124
+ );
125
+ };
@@ -0,0 +1,282 @@
1
+ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import {
4
+ getRootComponent,
5
+ CompositionManagerContext,
6
+ type CompositionEntry,
7
+ type TimelineEntry,
8
+ } from '@rendiv/core';
9
+ import { Sidebar } from './Sidebar';
10
+ import { Preview } from './Preview';
11
+ import { TopBar } from './TopBar';
12
+ import { Timeline } from './Timeline';
13
+ import { RenderQueue, type RenderJob } from './RenderQueue';
14
+ import { layoutStyles, scrollbarCSS } from './styles';
15
+
16
+ // Read the entry point from the generated code's data attribute (set by studio-entry-code)
17
+ const ENTRY_POINT = (window as Record<string, unknown>).__RENDIV_STUDIO_ENTRY__ as string ?? 'src/index.tsx';
18
+
19
+ const StudioApp: React.FC = () => {
20
+ const [compositions, setCompositions] = useState<CompositionEntry[]>([]);
21
+ const [selectedId, setSelectedId] = useState<string | null>(null);
22
+ const [inputProps, setInputProps] = useState<Record<string, unknown>>({});
23
+ const [playbackRate, setPlaybackRate] = useState(1);
24
+ const [currentFrame, setCurrentFrame] = useState(0);
25
+ const [timelineEntries, setTimelineEntries] = useState<TimelineEntry[]>([]);
26
+ const seekRef = useRef<((frame: number) => void) | null>(null);
27
+ const [timelineHeight, setTimelineHeight] = useState(() => {
28
+ const stored = localStorage.getItem('rendiv-studio:timeline-height');
29
+ return stored ? Number(stored) : 180;
30
+ });
31
+ const isDraggingTimeline = useRef(false);
32
+ const dragStartY = useRef(0);
33
+ const dragStartHeight = useRef(0);
34
+
35
+ // Render queue state (server-driven)
36
+ const [renderJobs, setRenderJobs] = useState<RenderJob[]>([]);
37
+ const [queueOpen, setQueueOpen] = useState(false);
38
+ const hasActiveRef = useRef(false);
39
+
40
+ const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
41
+ e.preventDefault();
42
+ isDraggingTimeline.current = true;
43
+ dragStartY.current = e.clientY;
44
+ dragStartHeight.current = timelineHeight;
45
+ }, [timelineHeight]);
46
+
47
+ useEffect(() => {
48
+ const handleMouseMove = (e: MouseEvent) => {
49
+ if (!isDraggingTimeline.current) return;
50
+ const delta = dragStartY.current - e.clientY;
51
+ setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
52
+ };
53
+ const handleMouseUp = () => {
54
+ if (isDraggingTimeline.current) {
55
+ isDraggingTimeline.current = false;
56
+ setTimelineHeight((h) => {
57
+ localStorage.setItem('rendiv-studio:timeline-height', String(h));
58
+ return h;
59
+ });
60
+ }
61
+ };
62
+ window.addEventListener('mousemove', handleMouseMove);
63
+ window.addEventListener('mouseup', handleMouseUp);
64
+ return () => {
65
+ window.removeEventListener('mousemove', handleMouseMove);
66
+ window.removeEventListener('mouseup', handleMouseUp);
67
+ };
68
+ }, []);
69
+
70
+ // Timeline registry: reads from a shared global Map + listens for sync events.
71
+ useEffect(() => {
72
+ const readEntries = () => {
73
+ const w = window as unknown as Record<string, unknown>;
74
+ const entries = w.__RENDIV_TIMELINE_ENTRIES__ as Map<string, TimelineEntry> | undefined;
75
+ setTimelineEntries(entries ? Array.from(entries.values()) : []);
76
+ };
77
+ readEntries();
78
+ document.addEventListener('rendiv:timeline-sync', readEntries);
79
+ return () => {
80
+ document.removeEventListener('rendiv:timeline-sync', readEntries);
81
+ };
82
+ }, []);
83
+
84
+ const handleTimelineSeek = useCallback((frame: number) => {
85
+ seekRef.current?.(frame);
86
+ }, []);
87
+
88
+ const registerComposition = useCallback((comp: CompositionEntry) => {
89
+ setCompositions((prev) => {
90
+ const existing = prev.findIndex((c) => c.id === comp.id);
91
+ if (existing >= 0) {
92
+ const next = [...prev];
93
+ next[existing] = comp;
94
+ return next;
95
+ }
96
+ return [...prev, comp];
97
+ });
98
+ }, []);
99
+
100
+ const unregisterComposition = useCallback((id: string) => {
101
+ setCompositions((prev) => prev.filter((c) => c.id !== id));
102
+ }, []);
103
+
104
+ const managerValue = useMemo(
105
+ () => ({
106
+ compositions,
107
+ registerComposition,
108
+ unregisterComposition,
109
+ currentCompositionId: selectedId,
110
+ setCurrentCompositionId: setSelectedId,
111
+ inputProps,
112
+ }),
113
+ [compositions, registerComposition, unregisterComposition, selectedId, inputProps],
114
+ );
115
+
116
+ // Auto-select first composition when compositions are registered
117
+ useEffect(() => {
118
+ if (selectedId === null && compositions.length > 0) {
119
+ setSelectedId(compositions[0].id);
120
+ }
121
+ }, [compositions, selectedId]);
122
+
123
+ // Reset input props when switching compositions
124
+ useEffect(() => {
125
+ setInputProps({});
126
+ setPlaybackRate(1);
127
+ }, [selectedId]);
128
+
129
+ const selectedComposition = compositions.find((c) => c.id === selectedId) ?? null;
130
+
131
+ // --- Render queue (server-driven) ---
132
+
133
+ // Poll server for job state
134
+ useEffect(() => {
135
+ hasActiveRef.current = renderJobs.some((j) =>
136
+ j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding'
137
+ );
138
+ }, [renderJobs]);
139
+
140
+ useEffect(() => {
141
+ let timeoutId: ReturnType<typeof setTimeout>;
142
+ let cancelled = false;
143
+
144
+ const poll = async () => {
145
+ try {
146
+ const res = await fetch('/__rendiv_api__/render/queue');
147
+ if (res.ok) {
148
+ const data = await res.json();
149
+ if (!cancelled) setRenderJobs(data.jobs);
150
+ }
151
+ } catch {
152
+ // server unreachable, ignore
153
+ }
154
+ if (!cancelled) {
155
+ timeoutId = setTimeout(poll, hasActiveRef.current ? 500 : 1000);
156
+ }
157
+ };
158
+
159
+ poll();
160
+ return () => { cancelled = true; clearTimeout(timeoutId); };
161
+ }, []);
162
+
163
+ const handleAddRender = useCallback(() => {
164
+ if (!selectedComposition) return;
165
+ fetch('/__rendiv_api__/render/queue', {
166
+ method: 'POST',
167
+ headers: { 'Content-Type': 'application/json' },
168
+ body: JSON.stringify({
169
+ compositionId: selectedComposition.id,
170
+ compositionName: selectedComposition.id,
171
+ codec: 'mp4',
172
+ outputPath: `out/${selectedComposition.id}.mp4`,
173
+ inputProps: { ...selectedComposition.defaultProps, ...inputProps },
174
+ totalFrames: selectedComposition.durationInFrames,
175
+ }),
176
+ });
177
+ setQueueOpen(true);
178
+ }, [selectedComposition, inputProps]);
179
+
180
+ const handleCancelJob = useCallback((jobId: string) => {
181
+ fetch(`/__rendiv_api__/render/queue/${jobId}/cancel`, { method: 'POST' });
182
+ }, []);
183
+
184
+ const handleRemoveJob = useCallback((jobId: string) => {
185
+ fetch(`/__rendiv_api__/render/queue/${jobId}`, { method: 'DELETE' });
186
+ }, []);
187
+
188
+ const handleClearFinished = useCallback(() => {
189
+ fetch('/__rendiv_api__/render/queue/clear', { method: 'POST' });
190
+ }, []);
191
+
192
+ const handleToggleQueue = useCallback(() => {
193
+ setQueueOpen((prev) => !prev);
194
+ }, []);
195
+
196
+ const queueCount = renderJobs.filter((j) =>
197
+ j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding'
198
+ ).length;
199
+
200
+ const Root = getRootComponent();
201
+
202
+ return (
203
+ <div style={layoutStyles.root}>
204
+ <style dangerouslySetInnerHTML={{ __html: scrollbarCSS }} />
205
+ <TopBar
206
+ composition={selectedComposition}
207
+ entryPoint={ENTRY_POINT}
208
+ onRender={handleAddRender}
209
+ queueCount={queueCount}
210
+ queueOpen={queueOpen}
211
+ onToggleQueue={handleToggleQueue}
212
+ />
213
+
214
+ <div style={layoutStyles.body}>
215
+ <Sidebar
216
+ compositions={compositions}
217
+ selectedId={selectedId}
218
+ onSelect={setSelectedId}
219
+ />
220
+
221
+ {selectedComposition ? (
222
+ <Preview
223
+ composition={selectedComposition}
224
+ inputProps={inputProps}
225
+ playbackRate={playbackRate}
226
+ onPlaybackRateChange={setPlaybackRate}
227
+ onInputPropsChange={setInputProps}
228
+ onFrameUpdate={setCurrentFrame}
229
+ seekRef={seekRef}
230
+ />
231
+ ) : (
232
+ <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#8b949e' }}>
233
+ {compositions.length === 0 ? 'Loading compositions...' : 'Select a composition'}
234
+ </div>
235
+ )}
236
+
237
+ <RenderQueue
238
+ jobs={renderJobs}
239
+ open={queueOpen}
240
+ onToggle={handleToggleQueue}
241
+ onCancel={handleCancelJob}
242
+ onRemove={handleRemoveJob}
243
+ onClear={handleClearFinished}
244
+ />
245
+ </div>
246
+
247
+ {/* Timeline — full-width resizable row */}
248
+ {selectedComposition && (
249
+ <div style={{ ...layoutStyles.timeline, height: timelineHeight }}>
250
+ <div
251
+ style={layoutStyles.timelineResizeHandle}
252
+ onMouseDown={handleResizeMouseDown}
253
+ />
254
+ <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
+ />
263
+ </div>
264
+ </div>
265
+ )}
266
+
267
+ {/* Hidden: render Root to trigger composition registration via useEffect */}
268
+ <CompositionManagerContext.Provider value={managerValue}>
269
+ <div style={{ display: 'none' }}>
270
+ {Root ? <Root /> : null}
271
+ </div>
272
+ </CompositionManagerContext.Provider>
273
+ </div>
274
+ );
275
+ };
276
+
277
+ export function createStudioApp(container: HTMLElement | null): void {
278
+ if (!container) {
279
+ throw new Error('Rendiv Studio: Could not find #root element');
280
+ }
281
+ createRoot(container).render(<StudioApp />);
282
+ }