@codellyson/framely-cli 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/commands/compositions.js +135 -0
- package/commands/preview.js +889 -0
- package/commands/render.js +295 -0
- package/commands/still.js +165 -0
- package/index.js +93 -0
- package/package.json +60 -0
- package/studio/App.css +605 -0
- package/studio/App.jsx +185 -0
- package/studio/CompositionsView.css +399 -0
- package/studio/CompositionsView.jsx +327 -0
- package/studio/PropsEditor.css +195 -0
- package/studio/PropsEditor.tsx +176 -0
- package/studio/RenderDialog.tsx +476 -0
- package/studio/ShareDialog.tsx +200 -0
- package/studio/index.ts +19 -0
- package/studio/player/Player.css +199 -0
- package/studio/player/Player.jsx +355 -0
- package/studio/styles/design-system.css +592 -0
- package/studio/styles/dialogs.css +420 -0
- package/studio/templates/AnimatedGradient.jsx +99 -0
- package/studio/templates/InstagramStory.jsx +172 -0
- package/studio/templates/LowerThird.jsx +139 -0
- package/studio/templates/ProductShowcase.jsx +162 -0
- package/studio/templates/SlideTransition.jsx +211 -0
- package/studio/templates/SocialIntro.jsx +122 -0
- package/studio/templates/SubscribeAnimation.jsx +186 -0
- package/studio/templates/TemplateCard.tsx +58 -0
- package/studio/templates/TemplateFilters.tsx +97 -0
- package/studio/templates/TemplatePreviewDialog.tsx +196 -0
- package/studio/templates/TemplatesMarketplace.css +686 -0
- package/studio/templates/TemplatesMarketplace.tsx +172 -0
- package/studio/templates/TextReveal.jsx +134 -0
- package/studio/templates/UseTemplateDialog.tsx +154 -0
- package/studio/templates/index.ts +45 -0
- package/utils/browser.js +188 -0
- package/utils/codecs.js +200 -0
- package/utils/logger.js +35 -0
- package/utils/props.js +42 -0
- package/utils/render.js +447 -0
- package/utils/validate.js +148 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RenderDialog Component
|
|
3
|
+
*
|
|
4
|
+
* Modal dialog for configuring and starting video renders.
|
|
5
|
+
* Supports codec selection, quality settings, and progress tracking.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback, useEffect, useRef, type MouseEvent, type ChangeEvent, type ReactElement } from 'react';
|
|
9
|
+
import './styles/dialogs.css';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types & Interfaces
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
interface Codec {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
ext: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface QualityPreset {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
crf: number;
|
|
26
|
+
description: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Composition {
|
|
30
|
+
id: string;
|
|
31
|
+
width: number;
|
|
32
|
+
height: number;
|
|
33
|
+
fps: number;
|
|
34
|
+
durationInFrames: number;
|
|
35
|
+
_templateId?: string;
|
|
36
|
+
_isTemplate?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface RenderProgressEvent {
|
|
40
|
+
type: 'progress';
|
|
41
|
+
percent: number;
|
|
42
|
+
frame: number;
|
|
43
|
+
total: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RenderStatusEvent {
|
|
47
|
+
type: 'status';
|
|
48
|
+
message: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RenderCompleteEvent {
|
|
52
|
+
type: 'complete';
|
|
53
|
+
durationMs?: number;
|
|
54
|
+
downloadUrl?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface RenderErrorEvent {
|
|
58
|
+
type: 'error';
|
|
59
|
+
message: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type RenderStreamEvent =
|
|
63
|
+
| RenderProgressEvent
|
|
64
|
+
| RenderStatusEvent
|
|
65
|
+
| RenderCompleteEvent
|
|
66
|
+
| RenderErrorEvent;
|
|
67
|
+
|
|
68
|
+
interface RenderResult {
|
|
69
|
+
durationMs?: number;
|
|
70
|
+
downloadUrl?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RenderDialogProps {
|
|
74
|
+
open: boolean;
|
|
75
|
+
onClose: () => void;
|
|
76
|
+
composition: Composition | null;
|
|
77
|
+
inputProps?: Record<string, unknown>;
|
|
78
|
+
onRender?: (event: RenderCompleteEvent) => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Constants
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const CODECS: Codec[] = [
|
|
86
|
+
{ id: 'h264', name: 'H.264 (MP4)', ext: 'mp4', description: 'Most compatible, good quality' },
|
|
87
|
+
{ id: 'h265', name: 'H.265 (HEVC)', ext: 'mp4', description: 'Better compression, less compatible' },
|
|
88
|
+
{ id: 'vp9', name: 'VP9 (WebM)', ext: 'webm', description: 'Good for web' },
|
|
89
|
+
{ id: 'prores', name: 'ProRes (MOV)', ext: 'mov', description: 'Professional editing' },
|
|
90
|
+
{ id: 'gif', name: 'GIF', ext: 'gif', description: 'Animated image, limited colors' },
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const QUALITY_PRESETS: QualityPreset[] = [
|
|
94
|
+
{ id: 'low', name: 'Low', crf: 28, description: 'Smaller file, lower quality' },
|
|
95
|
+
{ id: 'medium', name: 'Medium', crf: 23, description: 'Balanced' },
|
|
96
|
+
{ id: 'high', name: 'High', crf: 18, description: 'Recommended' },
|
|
97
|
+
{ id: 'lossless', name: 'Lossless', crf: 0, description: 'Best quality, large file' },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Component
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
export function RenderDialog({
|
|
105
|
+
open,
|
|
106
|
+
onClose,
|
|
107
|
+
composition,
|
|
108
|
+
inputProps = {},
|
|
109
|
+
onRender,
|
|
110
|
+
}: RenderDialogProps): ReactElement | null {
|
|
111
|
+
const [codec, setCodec] = useState<string>('h264');
|
|
112
|
+
const [quality, setQuality] = useState<string>('high');
|
|
113
|
+
const [customCrf, _setCustomCrf] = useState<number>(18);
|
|
114
|
+
const [scale, setScale] = useState<number>(1);
|
|
115
|
+
const [startFrame, setStartFrame] = useState<number>(0);
|
|
116
|
+
const [endFrame, setEndFrame] = useState<number>(composition?.durationInFrames ? composition.durationInFrames - 1 : 299);
|
|
117
|
+
const [muted, setMuted] = useState<boolean>(false);
|
|
118
|
+
const [parallel, setParallel] = useState<boolean>(false);
|
|
119
|
+
const [_concurrency, _setConcurrency] = useState<number>(4);
|
|
120
|
+
|
|
121
|
+
const [rendering, setRendering] = useState<boolean>(false);
|
|
122
|
+
const [progress, setProgress] = useState<number>(0);
|
|
123
|
+
const [statusMessage, setStatusMessage] = useState<string>('');
|
|
124
|
+
const [error, setError] = useState<string | null>(null);
|
|
125
|
+
const [result, setResult] = useState<RenderResult | null>(null);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (composition?.durationInFrames) {
|
|
129
|
+
setEndFrame(composition.durationInFrames - 1);
|
|
130
|
+
}
|
|
131
|
+
}, [composition?.durationInFrames]);
|
|
132
|
+
|
|
133
|
+
const getCrf = (): number => {
|
|
134
|
+
if (quality === 'custom') return customCrf;
|
|
135
|
+
const preset = QUALITY_PRESETS.find((p) => p.id === quality);
|
|
136
|
+
return preset ? preset.crf : 18;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const outputWidth: number = Math.round((composition?.width || 1920) * scale);
|
|
140
|
+
const outputHeight: number = Math.round((composition?.height || 1080) * scale);
|
|
141
|
+
const frameCount: number = endFrame - startFrame + 1;
|
|
142
|
+
const duration: number = frameCount / (composition?.fps || 30);
|
|
143
|
+
|
|
144
|
+
const handleRender = useCallback(async (): Promise<void> => {
|
|
145
|
+
if (!composition) return;
|
|
146
|
+
|
|
147
|
+
setRendering(true);
|
|
148
|
+
setProgress(0);
|
|
149
|
+
setStatusMessage('Starting render...');
|
|
150
|
+
setError(null);
|
|
151
|
+
setResult(null);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const response: Response = await fetch('/api/render', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'Content-Type': 'application/json' },
|
|
157
|
+
body: JSON.stringify({
|
|
158
|
+
compositionId: composition.id,
|
|
159
|
+
templateId: composition._templateId,
|
|
160
|
+
isTemplate: composition._isTemplate,
|
|
161
|
+
width: composition.width,
|
|
162
|
+
height: composition.height,
|
|
163
|
+
fps: composition.fps,
|
|
164
|
+
durationInFrames: composition.durationInFrames,
|
|
165
|
+
startFrame,
|
|
166
|
+
endFrame,
|
|
167
|
+
codec,
|
|
168
|
+
crf: getCrf(),
|
|
169
|
+
scale,
|
|
170
|
+
inputProps,
|
|
171
|
+
muted,
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error('Render request failed: ' + response.statusText);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const reader: ReadableStreamDefaultReader<Uint8Array> = response.body!.getReader();
|
|
180
|
+
const decoder = new TextDecoder();
|
|
181
|
+
let buffer = '';
|
|
182
|
+
|
|
183
|
+
while (true) {
|
|
184
|
+
const { done, value } = await reader.read();
|
|
185
|
+
if (done) break;
|
|
186
|
+
|
|
187
|
+
buffer += decoder.decode(value, { stream: true });
|
|
188
|
+
const lines: string[] = buffer.split('\n');
|
|
189
|
+
buffer = lines.pop()!;
|
|
190
|
+
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
if (!line.trim()) continue;
|
|
193
|
+
try {
|
|
194
|
+
const event: RenderStreamEvent = JSON.parse(line);
|
|
195
|
+
if (event.type === 'progress') {
|
|
196
|
+
setProgress(event.percent);
|
|
197
|
+
setStatusMessage(`Frame ${event.frame}/${event.total}`);
|
|
198
|
+
} else if (event.type === 'status') {
|
|
199
|
+
setStatusMessage(event.message);
|
|
200
|
+
} else if (event.type === 'complete') {
|
|
201
|
+
setProgress(100);
|
|
202
|
+
setResult(event);
|
|
203
|
+
if (onRender) onRender(event);
|
|
204
|
+
} else if (event.type === 'error') {
|
|
205
|
+
throw new Error(event.message);
|
|
206
|
+
}
|
|
207
|
+
} catch (parseErr) {
|
|
208
|
+
if (parseErr instanceof Error && parseErr.message !== 'Invalid JSON') throw parseErr;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (err: unknown) {
|
|
213
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
214
|
+
} finally {
|
|
215
|
+
setRendering(false);
|
|
216
|
+
setStatusMessage('');
|
|
217
|
+
}
|
|
218
|
+
}, [
|
|
219
|
+
composition, codec, quality, customCrf, scale,
|
|
220
|
+
startFrame, endFrame, muted, inputProps, onRender,
|
|
221
|
+
]);
|
|
222
|
+
|
|
223
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
224
|
+
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!open) return;
|
|
227
|
+
const handleKeyDown = (e: globalThis.KeyboardEvent): void => {
|
|
228
|
+
if (e.key === 'Escape' && !rendering) onClose?.();
|
|
229
|
+
};
|
|
230
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
231
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
232
|
+
}, [open, rendering, onClose]);
|
|
233
|
+
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!open || !dialogRef.current) return;
|
|
236
|
+
const dialog: HTMLDivElement = dialogRef.current;
|
|
237
|
+
const focusable: NodeListOf<HTMLElement> = dialog.querySelectorAll(
|
|
238
|
+
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
239
|
+
);
|
|
240
|
+
if (focusable.length > 0) focusable[0].focus();
|
|
241
|
+
|
|
242
|
+
const handleTab = (e: globalThis.KeyboardEvent): void => {
|
|
243
|
+
if (e.key !== 'Tab' || focusable.length === 0) return;
|
|
244
|
+
const first: HTMLElement = focusable[0];
|
|
245
|
+
const last: HTMLElement = focusable[focusable.length - 1];
|
|
246
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
last.focus();
|
|
249
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
first.focus();
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
document.addEventListener('keydown', handleTab);
|
|
255
|
+
return () => document.removeEventListener('keydown', handleTab);
|
|
256
|
+
}, [open]);
|
|
257
|
+
|
|
258
|
+
if (!open) return null;
|
|
259
|
+
|
|
260
|
+
const selectedCodec: Codec | undefined = CODECS.find((c) => c.id === codec);
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div
|
|
264
|
+
className="dialog-overlay"
|
|
265
|
+
role="dialog"
|
|
266
|
+
aria-modal="true"
|
|
267
|
+
aria-labelledby="render-dialog-title"
|
|
268
|
+
onClick={(e: MouseEvent<HTMLDivElement>) => {
|
|
269
|
+
if (e.target === e.currentTarget && !rendering) onClose?.();
|
|
270
|
+
}}
|
|
271
|
+
>
|
|
272
|
+
<div ref={dialogRef} className="dialog-panel">
|
|
273
|
+
{/* Header */}
|
|
274
|
+
<div className="dialog-header">
|
|
275
|
+
<h2 id="render-dialog-title" className="dialog-title">
|
|
276
|
+
Render Video
|
|
277
|
+
</h2>
|
|
278
|
+
<button
|
|
279
|
+
className="dialog-close-btn"
|
|
280
|
+
onClick={onClose}
|
|
281
|
+
disabled={rendering}
|
|
282
|
+
aria-label="Close render dialog"
|
|
283
|
+
>
|
|
284
|
+
×
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
|
|
288
|
+
{/* Content */}
|
|
289
|
+
<div className="dialog-body">
|
|
290
|
+
{/* Composition info */}
|
|
291
|
+
<div className="dialog-info-card">
|
|
292
|
+
<div className="dialog-info-card-title">
|
|
293
|
+
{composition?.id || 'Unknown'}
|
|
294
|
+
</div>
|
|
295
|
+
<div className="dialog-info-card-subtitle">
|
|
296
|
+
{composition?.width}x{composition?.height} @ {composition?.fps}fps · {composition?.durationInFrames} frames
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Codec selection */}
|
|
301
|
+
<div className="dialog-section">
|
|
302
|
+
<label className="dialog-label">Format</label>
|
|
303
|
+
<div className="dialog-option-grid dialog-option-grid--3col">
|
|
304
|
+
{CODECS.map((c: Codec) => (
|
|
305
|
+
<button
|
|
306
|
+
key={c.id}
|
|
307
|
+
className={`dialog-option-btn${codec === c.id ? ' active' : ''}`}
|
|
308
|
+
onClick={() => setCodec(c.id)}
|
|
309
|
+
disabled={rendering}
|
|
310
|
+
>
|
|
311
|
+
{c.name}
|
|
312
|
+
</button>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
<div className="dialog-option-hint">
|
|
316
|
+
{selectedCodec?.description}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
{/* Quality selection (not for GIF) */}
|
|
321
|
+
{codec !== 'gif' && (
|
|
322
|
+
<div className="dialog-section">
|
|
323
|
+
<label className="dialog-label">Quality</label>
|
|
324
|
+
<div className="dialog-option-grid dialog-option-grid--4col">
|
|
325
|
+
{QUALITY_PRESETS.map((p: QualityPreset) => (
|
|
326
|
+
<button
|
|
327
|
+
key={p.id}
|
|
328
|
+
className={`dialog-option-btn${quality === p.id ? ' active' : ''}`}
|
|
329
|
+
onClick={() => setQuality(p.id)}
|
|
330
|
+
disabled={rendering}
|
|
331
|
+
>
|
|
332
|
+
{p.name}
|
|
333
|
+
</button>
|
|
334
|
+
))}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
)}
|
|
338
|
+
|
|
339
|
+
{/* Scale */}
|
|
340
|
+
<div className="dialog-section">
|
|
341
|
+
<label className="dialog-label">
|
|
342
|
+
Scale ({outputWidth}x{outputHeight})
|
|
343
|
+
</label>
|
|
344
|
+
<input
|
|
345
|
+
className="dialog-range"
|
|
346
|
+
type="range"
|
|
347
|
+
min="0.25"
|
|
348
|
+
max="2"
|
|
349
|
+
step="0.25"
|
|
350
|
+
value={scale}
|
|
351
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setScale(parseFloat(e.target.value))}
|
|
352
|
+
disabled={rendering}
|
|
353
|
+
/>
|
|
354
|
+
<div className="dialog-range-labels">
|
|
355
|
+
<span>25%</span>
|
|
356
|
+
<span>{scale * 100}%</span>
|
|
357
|
+
<span>200%</span>
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
{/* Frame range */}
|
|
362
|
+
<div className="dialog-section">
|
|
363
|
+
<label className="dialog-label">
|
|
364
|
+
Frame Range ({frameCount} frames, {duration.toFixed(2)}s)
|
|
365
|
+
</label>
|
|
366
|
+
<div className="dialog-frame-range">
|
|
367
|
+
<input
|
|
368
|
+
className="dialog-input"
|
|
369
|
+
type="number"
|
|
370
|
+
value={startFrame}
|
|
371
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setStartFrame(Math.max(0, parseInt(e.target.value) || 0))}
|
|
372
|
+
disabled={rendering}
|
|
373
|
+
/>
|
|
374
|
+
<span className="dialog-frame-range-sep">to</span>
|
|
375
|
+
<input
|
|
376
|
+
className="dialog-input"
|
|
377
|
+
type="number"
|
|
378
|
+
value={endFrame}
|
|
379
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setEndFrame(Math.min(composition?.durationInFrames ? composition.durationInFrames - 1 : 299, parseInt(e.target.value) || 0))}
|
|
380
|
+
disabled={rendering}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
{/* Options */}
|
|
386
|
+
<div className="dialog-section">
|
|
387
|
+
<div className="dialog-checkbox-group">
|
|
388
|
+
<label className="dialog-checkbox-label">
|
|
389
|
+
<input
|
|
390
|
+
type="checkbox"
|
|
391
|
+
checked={muted}
|
|
392
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setMuted(e.target.checked)}
|
|
393
|
+
disabled={rendering || codec === 'gif'}
|
|
394
|
+
/>
|
|
395
|
+
Mute audio
|
|
396
|
+
</label>
|
|
397
|
+
<label className="dialog-checkbox-label">
|
|
398
|
+
<input
|
|
399
|
+
type="checkbox"
|
|
400
|
+
checked={parallel}
|
|
401
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => setParallel(e.target.checked)}
|
|
402
|
+
disabled={rendering}
|
|
403
|
+
/>
|
|
404
|
+
Parallel rendering
|
|
405
|
+
</label>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
{/* Error display */}
|
|
410
|
+
{error && (
|
|
411
|
+
<div className="dialog-alert dialog-alert--error">
|
|
412
|
+
{error}
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
415
|
+
|
|
416
|
+
{/* Result display */}
|
|
417
|
+
{result && (
|
|
418
|
+
<div className="dialog-alert dialog-alert--success">
|
|
419
|
+
<div className="dialog-alert-title">
|
|
420
|
+
Render complete!
|
|
421
|
+
</div>
|
|
422
|
+
<div className="dialog-alert-subtitle">
|
|
423
|
+
{result.durationMs && `Completed in ${(result.durationMs / 1000).toFixed(1)}s`}
|
|
424
|
+
</div>
|
|
425
|
+
{result.downloadUrl && (
|
|
426
|
+
<a
|
|
427
|
+
className="dialog-alert-link"
|
|
428
|
+
href={result.downloadUrl}
|
|
429
|
+
target="_blank"
|
|
430
|
+
rel="noopener noreferrer"
|
|
431
|
+
>
|
|
432
|
+
Download
|
|
433
|
+
</a>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
)}
|
|
437
|
+
|
|
438
|
+
{/* Progress bar */}
|
|
439
|
+
{rendering && (
|
|
440
|
+
<div className="dialog-progress">
|
|
441
|
+
<div className="dialog-progress-track">
|
|
442
|
+
<div
|
|
443
|
+
className="dialog-progress-fill"
|
|
444
|
+
style={{ width: `${progress}%` }}
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
<div className="dialog-progress-text">
|
|
448
|
+
{statusMessage || `Rendering... ${progress}%`}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
</div>
|
|
453
|
+
|
|
454
|
+
{/* Footer */}
|
|
455
|
+
<div className="dialog-footer">
|
|
456
|
+
<button
|
|
457
|
+
className="dialog-btn dialog-btn--secondary"
|
|
458
|
+
onClick={onClose}
|
|
459
|
+
disabled={rendering}
|
|
460
|
+
>
|
|
461
|
+
Cancel
|
|
462
|
+
</button>
|
|
463
|
+
<button
|
|
464
|
+
className="dialog-btn dialog-btn--primary"
|
|
465
|
+
onClick={handleRender}
|
|
466
|
+
disabled={rendering}
|
|
467
|
+
>
|
|
468
|
+
{rendering ? 'Rendering...' : 'Start Render'}
|
|
469
|
+
</button>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
</div>
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export default RenderDialog;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExportDialog Component
|
|
3
|
+
*
|
|
4
|
+
* Modal dialog for exporting compositions via CLI command
|
|
5
|
+
* or config file download.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useCallback, useEffect, useRef, type MouseEvent, type ReactElement } from 'react';
|
|
9
|
+
import './styles/dialogs.css';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types & Interfaces
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface Composition {
|
|
16
|
+
id: string;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
fps: number;
|
|
20
|
+
durationInFrames: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type CopyLabel = 'cli' | 'config' | null;
|
|
24
|
+
|
|
25
|
+
export interface ExportDialogProps {
|
|
26
|
+
open: boolean;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
composition: Composition;
|
|
29
|
+
inputProps?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Component
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export function ExportDialog({
|
|
37
|
+
open,
|
|
38
|
+
onClose,
|
|
39
|
+
composition,
|
|
40
|
+
inputProps = {},
|
|
41
|
+
}: ExportDialogProps): ReactElement | null {
|
|
42
|
+
const [copied, setCopied] = useState<CopyLabel>(null);
|
|
43
|
+
|
|
44
|
+
const copyToClipboard = useCallback((text: string, label: CopyLabel): void => {
|
|
45
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
46
|
+
setCopied(label);
|
|
47
|
+
setTimeout(() => setCopied(null), 2000);
|
|
48
|
+
});
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return;
|
|
55
|
+
const handleKeyDown = (e: globalThis.KeyboardEvent): void => {
|
|
56
|
+
if (e.key === 'Escape') onClose?.();
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
59
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
60
|
+
}, [open, onClose]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!open || !dialogRef.current) return;
|
|
64
|
+
const dialog: HTMLDivElement = dialogRef.current;
|
|
65
|
+
const focusable: NodeListOf<HTMLElement> = dialog.querySelectorAll(
|
|
66
|
+
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
67
|
+
);
|
|
68
|
+
if (focusable.length > 0) focusable[0].focus();
|
|
69
|
+
|
|
70
|
+
const handleTab = (e: globalThis.KeyboardEvent): void => {
|
|
71
|
+
if (e.key !== 'Tab' || focusable.length === 0) return;
|
|
72
|
+
const first: HTMLElement = focusable[0];
|
|
73
|
+
const last: HTMLElement = focusable[focusable.length - 1];
|
|
74
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
last.focus();
|
|
77
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
first.focus();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
document.addEventListener('keydown', handleTab);
|
|
83
|
+
return () => document.removeEventListener('keydown', handleTab);
|
|
84
|
+
}, [open]);
|
|
85
|
+
|
|
86
|
+
if (!open) return null;
|
|
87
|
+
|
|
88
|
+
const propsJson: string = Object.keys(inputProps).length > 0
|
|
89
|
+
? ` --props '${JSON.stringify(inputProps)}'`
|
|
90
|
+
: '';
|
|
91
|
+
const cliCommand: string = `framely render ${composition.id} out.mp4 --codec h264 --crf 18${propsJson}`;
|
|
92
|
+
|
|
93
|
+
const configJson: string = JSON.stringify({
|
|
94
|
+
compositionId: composition.id,
|
|
95
|
+
width: composition.width,
|
|
96
|
+
height: composition.height,
|
|
97
|
+
fps: composition.fps,
|
|
98
|
+
durationInFrames: composition.durationInFrames,
|
|
99
|
+
inputProps,
|
|
100
|
+
}, null, 2);
|
|
101
|
+
|
|
102
|
+
const handleDownloadConfig = (): void => {
|
|
103
|
+
const blob = new Blob([configJson], { type: 'application/json' });
|
|
104
|
+
const url: string = URL.createObjectURL(blob);
|
|
105
|
+
const a: HTMLAnchorElement = document.createElement('a');
|
|
106
|
+
a.href = url;
|
|
107
|
+
a.download = `${composition.id}.framely.json`;
|
|
108
|
+
a.click();
|
|
109
|
+
URL.revokeObjectURL(url);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
className="dialog-overlay"
|
|
115
|
+
role="dialog"
|
|
116
|
+
aria-modal="true"
|
|
117
|
+
aria-labelledby="export-dialog-title"
|
|
118
|
+
onClick={(e: MouseEvent<HTMLDivElement>) => {
|
|
119
|
+
if (e.target === e.currentTarget) onClose();
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<div ref={dialogRef} className="dialog-panel">
|
|
123
|
+
{/* Header */}
|
|
124
|
+
<div className="dialog-header">
|
|
125
|
+
<h2 id="export-dialog-title" className="dialog-title">
|
|
126
|
+
Export
|
|
127
|
+
</h2>
|
|
128
|
+
<button
|
|
129
|
+
className="dialog-close-btn"
|
|
130
|
+
onClick={onClose}
|
|
131
|
+
aria-label="Close export dialog"
|
|
132
|
+
>
|
|
133
|
+
×
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Content */}
|
|
138
|
+
<div className="dialog-body">
|
|
139
|
+
{/* CLI Command */}
|
|
140
|
+
<div className="dialog-section">
|
|
141
|
+
<label className="dialog-label">Render Command</label>
|
|
142
|
+
<textarea
|
|
143
|
+
className="dialog-code"
|
|
144
|
+
readOnly
|
|
145
|
+
value={cliCommand}
|
|
146
|
+
rows={2}
|
|
147
|
+
onClick={(e: MouseEvent<HTMLTextAreaElement>) => (e.target as HTMLTextAreaElement).select()}
|
|
148
|
+
/>
|
|
149
|
+
<div className="dialog-btn-row">
|
|
150
|
+
<button
|
|
151
|
+
className={`dialog-btn ${copied === 'cli' ? 'dialog-btn--success' : 'dialog-btn--subtle'}`}
|
|
152
|
+
onClick={() => copyToClipboard(cliCommand, 'cli')}
|
|
153
|
+
>
|
|
154
|
+
{copied === 'cli' ? 'Copied!' : 'Copy command'}
|
|
155
|
+
</button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Config File */}
|
|
160
|
+
<div className="dialog-section">
|
|
161
|
+
<label className="dialog-label">Config File</label>
|
|
162
|
+
<textarea
|
|
163
|
+
className="dialog-code"
|
|
164
|
+
readOnly
|
|
165
|
+
value={configJson}
|
|
166
|
+
rows={6}
|
|
167
|
+
onClick={(e: MouseEvent<HTMLTextAreaElement>) => (e.target as HTMLTextAreaElement).select()}
|
|
168
|
+
/>
|
|
169
|
+
<div className="dialog-btn-row">
|
|
170
|
+
<button
|
|
171
|
+
className={`dialog-btn ${copied === 'config' ? 'dialog-btn--success' : 'dialog-btn--subtle'}`}
|
|
172
|
+
onClick={() => copyToClipboard(configJson, 'config')}
|
|
173
|
+
>
|
|
174
|
+
{copied === 'config' ? 'Copied!' : 'Copy config'}
|
|
175
|
+
</button>
|
|
176
|
+
<button
|
|
177
|
+
className="dialog-btn dialog-btn--subtle"
|
|
178
|
+
onClick={handleDownloadConfig}
|
|
179
|
+
>
|
|
180
|
+
Download .framely.json
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{/* Footer */}
|
|
187
|
+
<div className="dialog-footer">
|
|
188
|
+
<button
|
|
189
|
+
className="dialog-btn dialog-btn--secondary"
|
|
190
|
+
onClick={onClose}
|
|
191
|
+
>
|
|
192
|
+
Close
|
|
193
|
+
</button>
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default ExportDialog;
|
package/studio/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framely Studio
|
|
3
|
+
*
|
|
4
|
+
* Studio UI components for the Framely preview and render dialogs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { CompositionsView } from './CompositionsView';
|
|
8
|
+
|
|
9
|
+
export { RenderDialog } from './RenderDialog';
|
|
10
|
+
export type {
|
|
11
|
+
Composition as RenderComposition,
|
|
12
|
+
RenderDialogProps,
|
|
13
|
+
} from './RenderDialog';
|
|
14
|
+
|
|
15
|
+
export { ExportDialog } from './ShareDialog';
|
|
16
|
+
export type {
|
|
17
|
+
Composition as ExportComposition,
|
|
18
|
+
ExportDialogProps,
|
|
19
|
+
} from './ShareDialog';
|