@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/Preview.tsx
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { Player, type PlayerRef } from '@rendiv/player';
|
|
3
|
+
import type { CompositionEntry } from '@rendiv/core';
|
|
4
|
+
import { previewStyles, colors, fonts } from './styles';
|
|
5
|
+
|
|
6
|
+
interface PreviewProps {
|
|
7
|
+
composition: CompositionEntry;
|
|
8
|
+
inputProps: Record<string, unknown>;
|
|
9
|
+
playbackRate: number;
|
|
10
|
+
onPlaybackRateChange: (rate: number) => void;
|
|
11
|
+
onInputPropsChange: (props: Record<string, unknown>) => void;
|
|
12
|
+
onFrameUpdate?: (frame: number) => void;
|
|
13
|
+
seekRef?: React.MutableRefObject<((frame: number) => void) | null>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SPEED_STEPS = [0.25, 0.5, 1, 2, 4];
|
|
17
|
+
|
|
18
|
+
function formatTime(frame: number, fps: number): string {
|
|
19
|
+
const totalSeconds = frame / fps;
|
|
20
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
21
|
+
const seconds = Math.floor(totalSeconds % 60);
|
|
22
|
+
const ms = Math.floor((totalSeconds % 1) * 100);
|
|
23
|
+
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(ms).padStart(2, '0')}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const Preview: React.FC<PreviewProps> = ({
|
|
27
|
+
composition,
|
|
28
|
+
inputProps,
|
|
29
|
+
playbackRate,
|
|
30
|
+
onPlaybackRateChange,
|
|
31
|
+
onInputPropsChange,
|
|
32
|
+
onFrameUpdate,
|
|
33
|
+
seekRef,
|
|
34
|
+
}) => {
|
|
35
|
+
const playerRef = useRef<PlayerRef>(null);
|
|
36
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
const [currentFrame, setCurrentFrame] = useState(0);
|
|
38
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
39
|
+
const [propsOpen, setPropsOpen] = useState(false);
|
|
40
|
+
const [propsText, setPropsText] = useState('');
|
|
41
|
+
const [propsError, setPropsError] = useState(false);
|
|
42
|
+
const [wrapperSize, setWrapperSize] = useState({ width: 0, height: 0 });
|
|
43
|
+
|
|
44
|
+
// Track player wrapper dimensions to fit the player within both axes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const el = wrapperRef.current;
|
|
47
|
+
if (!el) return;
|
|
48
|
+
const observer = new ResizeObserver((entries) => {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
setWrapperSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
observer.observe(el);
|
|
54
|
+
return () => observer.disconnect();
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
// Expose seekTo to parent via mutable ref
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (seekRef) {
|
|
60
|
+
seekRef.current = (frame: number) => playerRef.current?.seekTo(frame);
|
|
61
|
+
}
|
|
62
|
+
return () => {
|
|
63
|
+
if (seekRef) seekRef.current = null;
|
|
64
|
+
};
|
|
65
|
+
}, [seekRef]);
|
|
66
|
+
|
|
67
|
+
const mergedProps = { ...composition.defaultProps, ...inputProps };
|
|
68
|
+
|
|
69
|
+
// Sync props editor when composition changes.
|
|
70
|
+
// Timeline entries are managed by Sequence mount/unmount lifecycle — no need to clear here.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setPropsText(JSON.stringify({ ...composition.defaultProps, ...inputProps }, null, 2));
|
|
73
|
+
setPropsError(false);
|
|
74
|
+
}, [composition.id]);
|
|
75
|
+
|
|
76
|
+
// Track frame updates and play state
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const player = playerRef.current;
|
|
79
|
+
if (!player) return;
|
|
80
|
+
|
|
81
|
+
const onFrame = (data: { frame: number }) => {
|
|
82
|
+
setCurrentFrame(data.frame);
|
|
83
|
+
onFrameUpdate?.(data.frame);
|
|
84
|
+
};
|
|
85
|
+
const onPlay = () => setIsPlaying(true);
|
|
86
|
+
const onPause = () => setIsPlaying(false);
|
|
87
|
+
|
|
88
|
+
player.addEventListener('frameupdate', onFrame);
|
|
89
|
+
player.addEventListener('play', onPlay);
|
|
90
|
+
player.addEventListener('pause', onPause);
|
|
91
|
+
return () => {
|
|
92
|
+
player.removeEventListener('frameupdate', onFrame);
|
|
93
|
+
player.removeEventListener('play', onPlay);
|
|
94
|
+
player.removeEventListener('pause', onPause);
|
|
95
|
+
};
|
|
96
|
+
}, [composition.id]);
|
|
97
|
+
|
|
98
|
+
// Global keyboard shortcuts — work regardless of which panel has focus
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
101
|
+
// Skip when user is typing in an input or textarea
|
|
102
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
103
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
|
104
|
+
|
|
105
|
+
if (e.key === ' ' || e.key === 'k' || e.key === 'K') {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
if (isPlaying) {
|
|
108
|
+
playerRef.current?.pause();
|
|
109
|
+
} else {
|
|
110
|
+
playerRef.current?.play();
|
|
111
|
+
}
|
|
112
|
+
} else if (e.key === 'ArrowLeft') {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
playerRef.current?.seekTo(Math.max(0, currentFrame - 1));
|
|
115
|
+
} else if (e.key === 'ArrowRight') {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
playerRef.current?.seekTo(Math.min(composition.durationInFrames - 1, currentFrame + 1));
|
|
118
|
+
} else if (e.key === '0') {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
playerRef.current?.seekTo(0);
|
|
121
|
+
} else if (e.key === 'j' || e.key === 'J') {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
const currentIdx = SPEED_STEPS.indexOf(playbackRate);
|
|
124
|
+
if (currentIdx > 0) {
|
|
125
|
+
onPlaybackRateChange(SPEED_STEPS[currentIdx - 1]);
|
|
126
|
+
} else if (currentIdx === 0) {
|
|
127
|
+
playerRef.current?.pause();
|
|
128
|
+
}
|
|
129
|
+
} else if (e.key === 'l' || e.key === 'L') {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
const currentIdx = SPEED_STEPS.indexOf(playbackRate);
|
|
132
|
+
if (currentIdx < SPEED_STEPS.length - 1) {
|
|
133
|
+
onPlaybackRateChange(SPEED_STEPS[currentIdx + 1]);
|
|
134
|
+
playerRef.current?.play();
|
|
135
|
+
} else if (currentIdx === -1) {
|
|
136
|
+
onPlaybackRateChange(1);
|
|
137
|
+
playerRef.current?.play();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
142
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
143
|
+
}, [playbackRate, onPlaybackRateChange, isPlaying, currentFrame, composition.durationInFrames]);
|
|
144
|
+
|
|
145
|
+
const handlePropsChange = useCallback(
|
|
146
|
+
(text: string) => {
|
|
147
|
+
setPropsText(text);
|
|
148
|
+
try {
|
|
149
|
+
const parsed = JSON.parse(text);
|
|
150
|
+
setPropsError(false);
|
|
151
|
+
onInputPropsChange(parsed);
|
|
152
|
+
} catch {
|
|
153
|
+
setPropsError(true);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[onInputPropsChange],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const durationSeconds = (composition.durationInFrames / composition.fps).toFixed(1);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div style={previewStyles.container}>
|
|
163
|
+
{/* Metadata bar */}
|
|
164
|
+
<div style={previewStyles.metadataBar}>
|
|
165
|
+
<span>
|
|
166
|
+
<span style={previewStyles.metadataLabel}>Size </span>
|
|
167
|
+
<span style={previewStyles.metadataValue}>
|
|
168
|
+
{composition.width}x{composition.height}
|
|
169
|
+
</span>
|
|
170
|
+
</span>
|
|
171
|
+
<span>
|
|
172
|
+
<span style={previewStyles.metadataLabel}>FPS </span>
|
|
173
|
+
<span style={previewStyles.metadataValue}>{composition.fps}</span>
|
|
174
|
+
</span>
|
|
175
|
+
<span>
|
|
176
|
+
<span style={previewStyles.metadataLabel}>Frames </span>
|
|
177
|
+
<span style={previewStyles.metadataValue}>{composition.durationInFrames}</span>
|
|
178
|
+
</span>
|
|
179
|
+
<span>
|
|
180
|
+
<span style={previewStyles.metadataLabel}>Duration </span>
|
|
181
|
+
<span style={previewStyles.metadataValue}>{durationSeconds}s</span>
|
|
182
|
+
</span>
|
|
183
|
+
<span>
|
|
184
|
+
<span style={previewStyles.metadataLabel}>Frame </span>
|
|
185
|
+
<span style={previewStyles.metadataValue}>
|
|
186
|
+
{currentFrame} / {composition.durationInFrames - 1}
|
|
187
|
+
</span>
|
|
188
|
+
</span>
|
|
189
|
+
<span>
|
|
190
|
+
<span style={previewStyles.metadataLabel}>Time </span>
|
|
191
|
+
<span style={previewStyles.metadataValue}>
|
|
192
|
+
{formatTime(currentFrame, composition.fps)}
|
|
193
|
+
</span>
|
|
194
|
+
</span>
|
|
195
|
+
{playbackRate !== 1 && (
|
|
196
|
+
<span style={previewStyles.speedIndicator}>{playbackRate}x</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Player */}
|
|
201
|
+
<div ref={wrapperRef} style={previewStyles.playerWrapper}>
|
|
202
|
+
<Player
|
|
203
|
+
key={composition.id}
|
|
204
|
+
ref={playerRef}
|
|
205
|
+
component={composition.component}
|
|
206
|
+
durationInFrames={composition.durationInFrames}
|
|
207
|
+
fps={composition.fps}
|
|
208
|
+
compositionWidth={composition.width}
|
|
209
|
+
compositionHeight={composition.height}
|
|
210
|
+
inputProps={mergedProps}
|
|
211
|
+
playbackRate={playbackRate}
|
|
212
|
+
loop
|
|
213
|
+
style={{
|
|
214
|
+
width: wrapperSize.width > 0 && wrapperSize.height > 0
|
|
215
|
+
? Math.min(wrapperSize.width, wrapperSize.height * (composition.width / composition.height))
|
|
216
|
+
: '100%',
|
|
217
|
+
}}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
{/* Playback controls */}
|
|
222
|
+
<div style={controlsBarStyle}>
|
|
223
|
+
<div style={controlsLeftStyle}>
|
|
224
|
+
<button
|
|
225
|
+
style={controlBtnStyle}
|
|
226
|
+
onClick={() => playerRef.current?.seekTo(0)}
|
|
227
|
+
title="Go to start (0)"
|
|
228
|
+
>
|
|
229
|
+
⏮
|
|
230
|
+
</button>
|
|
231
|
+
<button
|
|
232
|
+
style={controlBtnStyle}
|
|
233
|
+
onClick={() => playerRef.current?.seekTo(Math.max(0, currentFrame - 1))}
|
|
234
|
+
title="Step back (←)"
|
|
235
|
+
>
|
|
236
|
+
⏴
|
|
237
|
+
</button>
|
|
238
|
+
<button
|
|
239
|
+
style={{ ...controlBtnStyle, fontSize: 16, width: 36, height: 28 }}
|
|
240
|
+
onClick={() => (isPlaying ? playerRef.current?.pause() : playerRef.current?.play())}
|
|
241
|
+
title="Play/Pause (Space)"
|
|
242
|
+
>
|
|
243
|
+
{isPlaying ? '\u23F8' : '\u25B6'}
|
|
244
|
+
</button>
|
|
245
|
+
<button
|
|
246
|
+
style={controlBtnStyle}
|
|
247
|
+
onClick={() => playerRef.current?.seekTo(Math.min(composition.durationInFrames - 1, currentFrame + 1))}
|
|
248
|
+
title="Step forward (→)"
|
|
249
|
+
>
|
|
250
|
+
⏵
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
style={controlBtnStyle}
|
|
254
|
+
onClick={() => playerRef.current?.seekTo(composition.durationInFrames - 1)}
|
|
255
|
+
title="Go to end"
|
|
256
|
+
>
|
|
257
|
+
⏭
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
<div style={controlsRightStyle}>
|
|
261
|
+
<span style={frameCounterStyle}>
|
|
262
|
+
{currentFrame} / {composition.durationInFrames - 1}
|
|
263
|
+
</span>
|
|
264
|
+
<span style={timeDisplayStyle}>
|
|
265
|
+
{formatTime(currentFrame, composition.fps)}
|
|
266
|
+
</span>
|
|
267
|
+
<div style={speedGroupStyle}>
|
|
268
|
+
{SPEED_STEPS.map((s) => (
|
|
269
|
+
<button
|
|
270
|
+
key={s}
|
|
271
|
+
style={{
|
|
272
|
+
...speedBtnStyle,
|
|
273
|
+
backgroundColor: playbackRate === s ? colors.accentMuted : 'transparent',
|
|
274
|
+
color: playbackRate === s ? '#fff' : colors.textSecondary,
|
|
275
|
+
}}
|
|
276
|
+
onClick={() => onPlaybackRateChange(s)}
|
|
277
|
+
>
|
|
278
|
+
{s}x
|
|
279
|
+
</button>
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Props editor */}
|
|
286
|
+
{Object.keys(composition.defaultProps).length > 0 && (
|
|
287
|
+
<div style={previewStyles.propsContainer}>
|
|
288
|
+
<div
|
|
289
|
+
style={previewStyles.propsHeader}
|
|
290
|
+
onClick={() => setPropsOpen(!propsOpen)}
|
|
291
|
+
>
|
|
292
|
+
<span>{propsOpen ? '\u25BC' : '\u25B6'} Input Props</span>
|
|
293
|
+
{propsError && (
|
|
294
|
+
<span style={{ color: colors.error, fontSize: 11 }}>Invalid JSON</span>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
{propsOpen && (
|
|
298
|
+
<textarea
|
|
299
|
+
style={{
|
|
300
|
+
...previewStyles.propsTextarea,
|
|
301
|
+
borderColor: propsError ? colors.error : undefined,
|
|
302
|
+
}}
|
|
303
|
+
value={propsText}
|
|
304
|
+
onChange={(e) => handlePropsChange(e.target.value)}
|
|
305
|
+
spellCheck={false}
|
|
306
|
+
/>
|
|
307
|
+
)}
|
|
308
|
+
</div>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Inline styles for the playback controls bar
|
|
315
|
+
|
|
316
|
+
const controlsBarStyle: React.CSSProperties = {
|
|
317
|
+
display: 'flex',
|
|
318
|
+
alignItems: 'center',
|
|
319
|
+
justifyContent: 'space-between',
|
|
320
|
+
padding: '6px 12px',
|
|
321
|
+
backgroundColor: colors.surface,
|
|
322
|
+
borderRadius: 8,
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const controlsLeftStyle: React.CSSProperties = {
|
|
326
|
+
display: 'flex',
|
|
327
|
+
alignItems: 'center',
|
|
328
|
+
gap: 4,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const controlsRightStyle: React.CSSProperties = {
|
|
332
|
+
display: 'flex',
|
|
333
|
+
alignItems: 'center',
|
|
334
|
+
gap: 12,
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const controlBtnStyle: React.CSSProperties = {
|
|
338
|
+
background: 'none',
|
|
339
|
+
border: 'none',
|
|
340
|
+
color: colors.textPrimary,
|
|
341
|
+
cursor: 'pointer',
|
|
342
|
+
fontSize: 14,
|
|
343
|
+
width: 28,
|
|
344
|
+
height: 28,
|
|
345
|
+
display: 'flex',
|
|
346
|
+
alignItems: 'center',
|
|
347
|
+
justifyContent: 'center',
|
|
348
|
+
borderRadius: 4,
|
|
349
|
+
padding: 0,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const frameCounterStyle: React.CSSProperties = {
|
|
353
|
+
fontSize: 12,
|
|
354
|
+
fontFamily: fonts.mono,
|
|
355
|
+
color: colors.textSecondary,
|
|
356
|
+
fontVariantNumeric: 'tabular-nums',
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const timeDisplayStyle: React.CSSProperties = {
|
|
360
|
+
fontSize: 12,
|
|
361
|
+
fontFamily: fonts.mono,
|
|
362
|
+
color: colors.textPrimary,
|
|
363
|
+
fontVariantNumeric: 'tabular-nums',
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const speedGroupStyle: React.CSSProperties = {
|
|
367
|
+
display: 'flex',
|
|
368
|
+
alignItems: 'center',
|
|
369
|
+
gap: 2,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const speedBtnStyle: React.CSSProperties = {
|
|
373
|
+
border: 'none',
|
|
374
|
+
cursor: 'pointer',
|
|
375
|
+
fontSize: 11,
|
|
376
|
+
fontFamily: fonts.mono,
|
|
377
|
+
padding: '2px 6px',
|
|
378
|
+
borderRadius: 4,
|
|
379
|
+
};
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { colors, fonts } from './styles';
|
|
3
|
+
|
|
4
|
+
export type RenderJobStatus = 'queued' | 'bundling' | 'rendering' | 'encoding' | 'done' | 'error' | 'cancelled';
|
|
5
|
+
|
|
6
|
+
export interface RenderJob {
|
|
7
|
+
id: string;
|
|
8
|
+
compositionId: string;
|
|
9
|
+
compositionName: string;
|
|
10
|
+
codec: 'mp4' | 'webm';
|
|
11
|
+
outputPath: string;
|
|
12
|
+
inputProps: Record<string, unknown>;
|
|
13
|
+
status: RenderJobStatus;
|
|
14
|
+
progress: number;
|
|
15
|
+
renderedFrames: number;
|
|
16
|
+
totalFrames: number;
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RenderQueueProps {
|
|
21
|
+
jobs: RenderJob[];
|
|
22
|
+
open: boolean;
|
|
23
|
+
onToggle: () => void;
|
|
24
|
+
onCancel: (jobId: string) => void;
|
|
25
|
+
onRemove: (jobId: string) => void;
|
|
26
|
+
onClear: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function statusLabel(job: RenderJob): string {
|
|
30
|
+
switch (job.status) {
|
|
31
|
+
case 'queued':
|
|
32
|
+
return 'Queued';
|
|
33
|
+
case 'bundling':
|
|
34
|
+
return 'Bundling...';
|
|
35
|
+
case 'rendering':
|
|
36
|
+
return `Rendering ${job.renderedFrames}/${job.totalFrames}`;
|
|
37
|
+
case 'encoding':
|
|
38
|
+
return 'Encoding...';
|
|
39
|
+
case 'done':
|
|
40
|
+
return 'Done';
|
|
41
|
+
case 'error':
|
|
42
|
+
return 'Failed';
|
|
43
|
+
case 'cancelled':
|
|
44
|
+
return 'Cancelled';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function statusColor(status: RenderJobStatus): string {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case 'done':
|
|
51
|
+
return colors.badge;
|
|
52
|
+
case 'error':
|
|
53
|
+
return colors.error;
|
|
54
|
+
case 'cancelled':
|
|
55
|
+
return colors.textSecondary;
|
|
56
|
+
default:
|
|
57
|
+
return colors.accent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function overallProgress(job: RenderJob): number {
|
|
62
|
+
switch (job.status) {
|
|
63
|
+
case 'queued':
|
|
64
|
+
return 0;
|
|
65
|
+
case 'bundling':
|
|
66
|
+
return job.progress * 0.1;
|
|
67
|
+
case 'rendering':
|
|
68
|
+
return 0.1 + job.progress * 0.8;
|
|
69
|
+
case 'encoding':
|
|
70
|
+
return 0.9;
|
|
71
|
+
case 'done':
|
|
72
|
+
return 1;
|
|
73
|
+
default:
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const RenderQueue: React.FC<RenderQueueProps> = ({
|
|
79
|
+
jobs,
|
|
80
|
+
open,
|
|
81
|
+
onToggle,
|
|
82
|
+
onCancel,
|
|
83
|
+
onRemove,
|
|
84
|
+
onClear,
|
|
85
|
+
}) => {
|
|
86
|
+
const activeCount = jobs.filter((j) => j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding').length;
|
|
87
|
+
const hasFinished = jobs.some((j) => j.status === 'done' || j.status === 'error' || j.status === 'cancelled');
|
|
88
|
+
|
|
89
|
+
const handleCancel = useCallback((e: React.MouseEvent, jobId: string) => {
|
|
90
|
+
e.stopPropagation();
|
|
91
|
+
onCancel(jobId);
|
|
92
|
+
}, [onCancel]);
|
|
93
|
+
|
|
94
|
+
const handleRemove = useCallback((e: React.MouseEvent, jobId: string) => {
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
onRemove(jobId);
|
|
97
|
+
}, [onRemove]);
|
|
98
|
+
|
|
99
|
+
if (!open) return null;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div style={panelStyle}>
|
|
103
|
+
{/* Header */}
|
|
104
|
+
<div style={headerStyle}>
|
|
105
|
+
<span style={{ fontWeight: 600, fontSize: 11, textTransform: 'uppercase' as const, letterSpacing: '0.05em' }}>
|
|
106
|
+
Render Queue
|
|
107
|
+
{activeCount > 0 && (
|
|
108
|
+
<span style={{ marginLeft: 6, color: colors.accent, fontWeight: 600 }}>
|
|
109
|
+
({activeCount})
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</span>
|
|
113
|
+
<div style={{ display: 'flex', gap: 8 }}>
|
|
114
|
+
{hasFinished && (
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
style={clearBtnStyle}
|
|
118
|
+
onClick={onClear}
|
|
119
|
+
title="Clear finished jobs"
|
|
120
|
+
>
|
|
121
|
+
Clear
|
|
122
|
+
</button>
|
|
123
|
+
)}
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
style={closeBtnStyle}
|
|
127
|
+
onClick={onToggle}
|
|
128
|
+
title="Close panel"
|
|
129
|
+
>
|
|
130
|
+
{'\u2715'}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Job list */}
|
|
136
|
+
<div style={listStyle}>
|
|
137
|
+
{jobs.length === 0 ? (
|
|
138
|
+
<div style={emptyStyle}>
|
|
139
|
+
No render jobs
|
|
140
|
+
</div>
|
|
141
|
+
) : (
|
|
142
|
+
jobs.map((job) => {
|
|
143
|
+
const isActive = job.status === 'bundling' || job.status === 'rendering' || job.status === 'encoding';
|
|
144
|
+
const isQueued = job.status === 'queued';
|
|
145
|
+
const isDone = job.status === 'done' || job.status === 'error' || job.status === 'cancelled';
|
|
146
|
+
const progress = overallProgress(job);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div key={job.id} style={jobStyle}>
|
|
150
|
+
{/* Job info */}
|
|
151
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
|
152
|
+
<span style={{ fontSize: 12, fontWeight: 500, color: colors.textPrimary }}>
|
|
153
|
+
{job.compositionName}
|
|
154
|
+
</span>
|
|
155
|
+
{(isActive || isQueued) && (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
style={cancelBtnStyle}
|
|
159
|
+
onClick={(e) => handleCancel(e, job.id)}
|
|
160
|
+
title="Cancel"
|
|
161
|
+
>
|
|
162
|
+
{'\u2715'}
|
|
163
|
+
</button>
|
|
164
|
+
)}
|
|
165
|
+
{isDone && (
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
style={cancelBtnStyle}
|
|
169
|
+
onClick={(e) => handleRemove(e, job.id)}
|
|
170
|
+
title="Remove"
|
|
171
|
+
>
|
|
172
|
+
{'\u2715'}
|
|
173
|
+
</button>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Status line */}
|
|
178
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
|
179
|
+
<span style={{ fontSize: 11, color: statusColor(job.status) }}>
|
|
180
|
+
{statusLabel(job)}
|
|
181
|
+
</span>
|
|
182
|
+
<span style={{ fontSize: 10, color: colors.textSecondary, fontFamily: fonts.mono }}>
|
|
183
|
+
{job.outputPath}
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{/* Error message */}
|
|
188
|
+
{job.status === 'error' && job.error && (
|
|
189
|
+
<div style={{ fontSize: 10, color: colors.error, marginBottom: 4, wordBreak: 'break-all' as const }}>
|
|
190
|
+
{job.error}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{/* Progress bar */}
|
|
195
|
+
{(isActive || isQueued) && (
|
|
196
|
+
<div style={progressTrackStyle}>
|
|
197
|
+
<div
|
|
198
|
+
style={{
|
|
199
|
+
...progressBarStyle,
|
|
200
|
+
width: `${progress * 100}%`,
|
|
201
|
+
backgroundColor: isQueued ? colors.textSecondary : colors.accent,
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Done indicator */}
|
|
208
|
+
{job.status === 'done' && (
|
|
209
|
+
<div style={progressTrackStyle}>
|
|
210
|
+
<div style={{ ...progressBarStyle, width: '100%', backgroundColor: colors.badge }} />
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
})
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// Styles
|
|
223
|
+
|
|
224
|
+
const panelStyle: React.CSSProperties = {
|
|
225
|
+
width: 300,
|
|
226
|
+
minWidth: 300,
|
|
227
|
+
height: '100%',
|
|
228
|
+
backgroundColor: colors.surface,
|
|
229
|
+
borderLeft: `1px solid ${colors.border}`,
|
|
230
|
+
display: 'flex',
|
|
231
|
+
flexDirection: 'column',
|
|
232
|
+
flexShrink: 0,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const headerStyle: React.CSSProperties = {
|
|
236
|
+
display: 'flex',
|
|
237
|
+
justifyContent: 'space-between',
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
padding: '12px 16px',
|
|
240
|
+
color: colors.textSecondary,
|
|
241
|
+
borderBottom: `1px solid ${colors.border}`,
|
|
242
|
+
flexShrink: 0,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const listStyle: React.CSSProperties = {
|
|
246
|
+
flex: 1,
|
|
247
|
+
overflowY: 'auto',
|
|
248
|
+
padding: 8,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const emptyStyle: React.CSSProperties = {
|
|
252
|
+
display: 'flex',
|
|
253
|
+
alignItems: 'center',
|
|
254
|
+
justifyContent: 'center',
|
|
255
|
+
height: 80,
|
|
256
|
+
color: colors.textSecondary,
|
|
257
|
+
fontSize: 12,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const jobStyle: React.CSSProperties = {
|
|
261
|
+
padding: '10px 12px',
|
|
262
|
+
backgroundColor: colors.bg,
|
|
263
|
+
borderRadius: 6,
|
|
264
|
+
marginBottom: 6,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const progressTrackStyle: React.CSSProperties = {
|
|
268
|
+
height: 3,
|
|
269
|
+
backgroundColor: colors.border,
|
|
270
|
+
borderRadius: 2,
|
|
271
|
+
overflow: 'hidden',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const progressBarStyle: React.CSSProperties = {
|
|
275
|
+
height: '100%',
|
|
276
|
+
borderRadius: 2,
|
|
277
|
+
transition: 'width 0.3s ease',
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const closeBtnStyle: React.CSSProperties = {
|
|
281
|
+
background: 'none',
|
|
282
|
+
border: 'none',
|
|
283
|
+
color: colors.textSecondary,
|
|
284
|
+
cursor: 'pointer',
|
|
285
|
+
fontSize: 12,
|
|
286
|
+
padding: '2px 4px',
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const clearBtnStyle: React.CSSProperties = {
|
|
290
|
+
background: 'none',
|
|
291
|
+
border: `1px solid ${colors.border}`,
|
|
292
|
+
color: colors.textSecondary,
|
|
293
|
+
cursor: 'pointer',
|
|
294
|
+
fontSize: 10,
|
|
295
|
+
padding: '2px 8px',
|
|
296
|
+
borderRadius: 4,
|
|
297
|
+
fontFamily: fonts.sans,
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const cancelBtnStyle: React.CSSProperties = {
|
|
301
|
+
background: 'none',
|
|
302
|
+
border: 'none',
|
|
303
|
+
color: colors.textSecondary,
|
|
304
|
+
cursor: 'pointer',
|
|
305
|
+
fontSize: 10,
|
|
306
|
+
padding: '2px 4px',
|
|
307
|
+
lineHeight: 1,
|
|
308
|
+
};
|