@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/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
|
+
};
|
package/ui/StudioApp.tsx
ADDED
|
@@ -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
|
+
}
|