@bendyline/squisq-video-react 2.0.1 → 2.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/COPYING.GPL-2.0.txt +339 -0
- package/LICENSE +21 -0
- package/NOTICE.md +49 -0
- package/README.md +34 -7
- package/dist/{chunk-6DKLHXX3.js → chunk-2PGWBGAT.js} +0 -1
- package/dist/index.d.ts +41 -3
- package/dist/index.js +395 -234
- package/dist/workers/encode.worker.js +37 -20
- package/dist/workers/ffmpeg.class-worker.js +0 -1
- package/package.json +8 -5
- package/dist/chunk-6DKLHXX3.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/workers/encode.worker.js.map +0 -1
- package/dist/workers/ffmpeg.class-worker.js.map +0 -1
- package/src/VideoExportButton.tsx +0 -87
- package/src/VideoExportModal.tsx +0 -553
- package/src/__tests__/audioTrack.test.ts +0 -108
- package/src/__tests__/gifTranscode.test.ts +0 -118
- package/src/__tests__/mp4MuxAudio.test.ts +0 -72
- package/src/__tests__/useVideoExportGif.test.ts +0 -155
- package/src/__tests__/videoExportProps.test.tsx +0 -148
- package/src/__tests__/workerEncoder.test.ts +0 -143
- package/src/audioTrack.ts +0 -332
- package/src/gifTranscode.ts +0 -75
- package/src/hooks/useFrameCapture.ts +0 -268
- package/src/hooks/useVideoExport.ts +0 -647
- package/src/index.ts +0 -40
- package/src/mainThreadEncoder.ts +0 -158
- package/src/mp4Mux.ts +0 -123
- package/src/workerEncoder.ts +0 -175
- package/src/workers/encode.worker.ts +0 -439
- package/src/workers/ffmpeg.class-worker.ts +0 -11
- package/src/workers/workerTypes.ts +0 -89
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VideoExportButton — Simple button that opens the VideoExportModal in a portal.
|
|
3
|
-
*
|
|
4
|
-
* Convenience wrapper for consumers who want a drop-in button.
|
|
5
|
-
* The modal renders via `createPortal` into `document.body`.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { useState, useCallback } from 'react';
|
|
9
|
-
import { createPortal } from 'react-dom';
|
|
10
|
-
import type { Doc } from '@bendyline/squisq/schemas';
|
|
11
|
-
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
12
|
-
import { VideoExportModal } from './VideoExportModal.js';
|
|
13
|
-
import type { VideoExportConfig } from './hooks/useVideoExport.js';
|
|
14
|
-
|
|
15
|
-
export interface VideoExportButtonProps {
|
|
16
|
-
/** The document to export */
|
|
17
|
-
doc: Doc;
|
|
18
|
-
/**
|
|
19
|
-
* Player IIFE bundle source. Unused by the browser export path (frames
|
|
20
|
-
* are captured from a live in-page DocPlayer); only forwarded for
|
|
21
|
-
* CLI/Playwright-style pipelines that render standalone HTML.
|
|
22
|
-
*/
|
|
23
|
-
playerScript?: string;
|
|
24
|
-
/** Optional media provider for resolving images/audio */
|
|
25
|
-
mediaProvider?: MediaProvider;
|
|
26
|
-
/** Pre-collected images map */
|
|
27
|
-
images?: Map<string, ArrayBuffer>;
|
|
28
|
-
/** Pre-collected audio map */
|
|
29
|
-
audio?: Map<string, ArrayBuffer>;
|
|
30
|
-
/**
|
|
31
|
-
* Seeds the modal's initial output format and export settings and is merged
|
|
32
|
-
* as a base into the config passed to the export hook. Forwarded to
|
|
33
|
-
* {@link VideoExportModal}.
|
|
34
|
-
*/
|
|
35
|
-
defaultConfig?: Partial<VideoExportConfig>;
|
|
36
|
-
/** Visual color scheme forwarded to the portaled modal. Defaults to light. */
|
|
37
|
-
colorScheme?: 'light' | 'dark';
|
|
38
|
-
/** Button label (defaults to "Export Video", or "Export GIF" for a GIF default config) */
|
|
39
|
-
label?: string;
|
|
40
|
-
/** Additional inline styles for the button */
|
|
41
|
-
style?: React.CSSProperties;
|
|
42
|
-
/** Whether the button is disabled */
|
|
43
|
-
disabled?: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function VideoExportButton({
|
|
47
|
-
doc,
|
|
48
|
-
playerScript,
|
|
49
|
-
mediaProvider,
|
|
50
|
-
images,
|
|
51
|
-
audio,
|
|
52
|
-
defaultConfig,
|
|
53
|
-
colorScheme,
|
|
54
|
-
label,
|
|
55
|
-
style,
|
|
56
|
-
disabled,
|
|
57
|
-
}: VideoExportButtonProps) {
|
|
58
|
-
const [showModal, setShowModal] = useState(false);
|
|
59
|
-
const resolvedLabel =
|
|
60
|
-
label ?? (defaultConfig?.outputFormat === 'gif' ? 'Export GIF' : 'Export Video');
|
|
61
|
-
|
|
62
|
-
const handleOpen = useCallback(() => setShowModal(true), []);
|
|
63
|
-
const handleClose = useCallback(() => setShowModal(false), []);
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<>
|
|
67
|
-
<button onClick={handleOpen} disabled={disabled} style={style}>
|
|
68
|
-
{resolvedLabel}
|
|
69
|
-
</button>
|
|
70
|
-
|
|
71
|
-
{showModal &&
|
|
72
|
-
createPortal(
|
|
73
|
-
<VideoExportModal
|
|
74
|
-
doc={doc}
|
|
75
|
-
playerScript={playerScript}
|
|
76
|
-
mediaProvider={mediaProvider}
|
|
77
|
-
images={images}
|
|
78
|
-
audio={audio}
|
|
79
|
-
defaultConfig={defaultConfig}
|
|
80
|
-
colorScheme={colorScheme}
|
|
81
|
-
onClose={handleClose}
|
|
82
|
-
/>,
|
|
83
|
-
document.body,
|
|
84
|
-
)}
|
|
85
|
-
</>
|
|
86
|
-
);
|
|
87
|
-
}
|
package/src/VideoExportModal.tsx
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VideoExportModal — Modal dialog for configuring and monitoring video export.
|
|
3
|
-
*
|
|
4
|
-
* States:
|
|
5
|
-
* configure → exporting (capturing + encoding) → complete | error
|
|
6
|
-
*
|
|
7
|
-
* Hosts may supply a resolved color scheme so this portaled dialog matches
|
|
8
|
-
* the surface that opened it.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { useState, useCallback } from 'react';
|
|
12
|
-
import type { Doc } from '@bendyline/squisq/schemas';
|
|
13
|
-
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
14
|
-
import type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';
|
|
15
|
-
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
16
|
-
import {
|
|
17
|
-
useVideoExport,
|
|
18
|
-
type VideoExportConfig,
|
|
19
|
-
type VideoOutputFormat,
|
|
20
|
-
} from './hooks/useVideoExport.js';
|
|
21
|
-
|
|
22
|
-
// ── Types ──────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export interface VideoExportModalProps {
|
|
25
|
-
/** The document to export */
|
|
26
|
-
doc: Doc;
|
|
27
|
-
/**
|
|
28
|
-
* Player IIFE bundle source. Unused by the browser export path (frames
|
|
29
|
-
* are captured from a live in-page DocPlayer); only forwarded for
|
|
30
|
-
* CLI/Playwright-style pipelines that render standalone HTML.
|
|
31
|
-
*/
|
|
32
|
-
playerScript?: string;
|
|
33
|
-
/** Optional media provider for resolving images/audio */
|
|
34
|
-
mediaProvider?: MediaProvider;
|
|
35
|
-
/** Pre-collected images map (alternative to mediaProvider) */
|
|
36
|
-
images?: Map<string, ArrayBuffer>;
|
|
37
|
-
/** Pre-collected audio map */
|
|
38
|
-
audio?: Map<string, ArrayBuffer>;
|
|
39
|
-
/**
|
|
40
|
-
* Seeds the modal's initial format, motion, quality, FPS, orientation, and
|
|
41
|
-
* caption selections. It is merged (as a base) into the config passed to the
|
|
42
|
-
* export hook, so a host can share one config shape with `useVideoExport`.
|
|
43
|
-
* The individual `images`/`audio`/`mediaProvider`/`playerScript` props still
|
|
44
|
-
* take precedence over any matching key here.
|
|
45
|
-
*/
|
|
46
|
-
defaultConfig?: Partial<VideoExportConfig>;
|
|
47
|
-
/** Visual color scheme for the portaled dialog. Defaults to light. */
|
|
48
|
-
colorScheme?: 'light' | 'dark';
|
|
49
|
-
/** Called when the modal should close */
|
|
50
|
-
onClose: () => void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Helpers ────────────────────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
function formatDuration(seconds: number): string {
|
|
56
|
-
if (seconds < 60) return `${seconds}s`;
|
|
57
|
-
const m = Math.floor(seconds / 60);
|
|
58
|
-
const s = seconds % 60;
|
|
59
|
-
return `${m}m ${s}s`;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function encoderLabel(
|
|
63
|
-
outputFormat: VideoOutputFormat,
|
|
64
|
-
backend: 'webcodecs' | 'ffmpeg-wasm',
|
|
65
|
-
): string {
|
|
66
|
-
if (outputFormat === 'gif') {
|
|
67
|
-
return backend === 'webcodecs'
|
|
68
|
-
? 'WebCodecs (H.264) → ffmpeg.wasm (GIF)'
|
|
69
|
-
: 'ffmpeg.wasm (H.264 → GIF)';
|
|
70
|
-
}
|
|
71
|
-
return backend === 'webcodecs' ? 'WebCodecs (H.264)' : 'ffmpeg.wasm (H.264)';
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// ── Styles ─────────────────────────────────────────────────────────
|
|
75
|
-
|
|
76
|
-
interface VideoExportPalette {
|
|
77
|
-
overlay: string;
|
|
78
|
-
surface: string;
|
|
79
|
-
control: string;
|
|
80
|
-
border: string;
|
|
81
|
-
text: string;
|
|
82
|
-
heading: string;
|
|
83
|
-
label: string;
|
|
84
|
-
muted: string;
|
|
85
|
-
secondary: string;
|
|
86
|
-
primary: string;
|
|
87
|
-
primaryBorder: string;
|
|
88
|
-
success: string;
|
|
89
|
-
danger: string;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const VIDEO_EXPORT_PALETTES: Record<'light' | 'dark', VideoExportPalette> = {
|
|
93
|
-
light: {
|
|
94
|
-
overlay: 'rgba(0, 0, 0, 0.5)',
|
|
95
|
-
surface: '#FFFDF7',
|
|
96
|
-
control: '#ffffff',
|
|
97
|
-
border: '#c9b98a',
|
|
98
|
-
text: '#4a3c1f',
|
|
99
|
-
heading: '#2d2310',
|
|
100
|
-
label: '#5a4a2a',
|
|
101
|
-
muted: '#8a7a5a',
|
|
102
|
-
secondary: '#E8DFC6',
|
|
103
|
-
primary: '#8B6914',
|
|
104
|
-
primaryBorder: '#7a5c10',
|
|
105
|
-
success: '#2d6a10',
|
|
106
|
-
danger: '#a03020',
|
|
107
|
-
},
|
|
108
|
-
dark: {
|
|
109
|
-
overlay: 'rgba(2, 6, 23, 0.72)',
|
|
110
|
-
surface: '#111827',
|
|
111
|
-
control: '#0f172a',
|
|
112
|
-
border: '#475569',
|
|
113
|
-
text: '#e5e7eb',
|
|
114
|
-
heading: '#f8fafc',
|
|
115
|
-
label: '#cbd5e1',
|
|
116
|
-
muted: '#94a3b8',
|
|
117
|
-
secondary: '#1e293b',
|
|
118
|
-
primary: '#9a7416',
|
|
119
|
-
primaryBorder: '#d1a73b',
|
|
120
|
-
success: '#86efac',
|
|
121
|
-
danger: '#fca5a5',
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const overlayStyle: React.CSSProperties = {
|
|
126
|
-
position: 'fixed',
|
|
127
|
-
inset: 0,
|
|
128
|
-
display: 'flex',
|
|
129
|
-
alignItems: 'center',
|
|
130
|
-
justifyContent: 'center',
|
|
131
|
-
zIndex: 10000,
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
const modalStyle: React.CSSProperties = {
|
|
135
|
-
borderRadius: 0,
|
|
136
|
-
padding: '24px 28px',
|
|
137
|
-
minWidth: 380,
|
|
138
|
-
maxWidth: 480,
|
|
139
|
-
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
140
|
-
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const titleStyle: React.CSSProperties = {
|
|
144
|
-
margin: '0 0 16px 0',
|
|
145
|
-
fontSize: 18,
|
|
146
|
-
fontWeight: 600,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const labelStyle: React.CSSProperties = {
|
|
150
|
-
display: 'block',
|
|
151
|
-
fontSize: 13,
|
|
152
|
-
fontWeight: 500,
|
|
153
|
-
marginBottom: 4,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const selectStyle: React.CSSProperties = {
|
|
157
|
-
width: '100%',
|
|
158
|
-
padding: '6px 8px',
|
|
159
|
-
fontSize: 13,
|
|
160
|
-
fontFamily: 'inherit',
|
|
161
|
-
borderRadius: 0,
|
|
162
|
-
marginBottom: 12,
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
const btnPrimary: React.CSSProperties = {
|
|
166
|
-
padding: '8px 20px',
|
|
167
|
-
fontSize: 14,
|
|
168
|
-
fontFamily: 'inherit',
|
|
169
|
-
fontWeight: 500,
|
|
170
|
-
cursor: 'pointer',
|
|
171
|
-
color: '#fff',
|
|
172
|
-
borderRadius: 0,
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
const btnSecondary: React.CSSProperties = {
|
|
176
|
-
padding: '8px 20px',
|
|
177
|
-
fontSize: 14,
|
|
178
|
-
fontFamily: 'inherit',
|
|
179
|
-
fontWeight: 500,
|
|
180
|
-
cursor: 'pointer',
|
|
181
|
-
borderRadius: 0,
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const progressBarOuterStyle: React.CSSProperties = {
|
|
185
|
-
width: '100%',
|
|
186
|
-
height: 8,
|
|
187
|
-
borderRadius: 0,
|
|
188
|
-
overflow: 'hidden',
|
|
189
|
-
marginBottom: 8,
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
const footerStyle: React.CSSProperties = {
|
|
193
|
-
display: 'flex',
|
|
194
|
-
justifyContent: 'flex-end',
|
|
195
|
-
gap: 8,
|
|
196
|
-
marginTop: 20,
|
|
197
|
-
};
|
|
198
|
-
|
|
199
|
-
// ── Component ──────────────────────────────────────────────────────
|
|
200
|
-
|
|
201
|
-
export function VideoExportModal({
|
|
202
|
-
doc,
|
|
203
|
-
playerScript,
|
|
204
|
-
mediaProvider,
|
|
205
|
-
images,
|
|
206
|
-
audio,
|
|
207
|
-
defaultConfig,
|
|
208
|
-
colorScheme = 'light',
|
|
209
|
-
onClose,
|
|
210
|
-
}: VideoExportModalProps) {
|
|
211
|
-
const initialOutputFormat = defaultConfig?.outputFormat ?? 'mp4';
|
|
212
|
-
const [outputFormat, setOutputFormat] = useState<VideoOutputFormat>(initialOutputFormat);
|
|
213
|
-
const [quality, setQuality] = useState<VideoQuality>(defaultConfig?.quality ?? 'normal');
|
|
214
|
-
const [fps, setFps] = useState(defaultConfig?.fps ?? (initialOutputFormat === 'gif' ? 10 : 24));
|
|
215
|
-
const [orientation, setOrientation] = useState<VideoOrientation>(
|
|
216
|
-
defaultConfig?.orientation ?? 'landscape',
|
|
217
|
-
);
|
|
218
|
-
const [captionMode, setCaptionMode] = useState<CaptionMode>(defaultConfig?.captionMode ?? 'off');
|
|
219
|
-
const [animationsEnabled, setAnimationsEnabled] = useState(
|
|
220
|
-
defaultConfig?.animationsEnabled ?? initialOutputFormat === 'mp4',
|
|
221
|
-
);
|
|
222
|
-
const palette = VIDEO_EXPORT_PALETTES[colorScheme];
|
|
223
|
-
const themedModalStyle: React.CSSProperties = {
|
|
224
|
-
...modalStyle,
|
|
225
|
-
background: palette.surface,
|
|
226
|
-
border: `1px solid ${palette.border}`,
|
|
227
|
-
color: palette.text,
|
|
228
|
-
colorScheme,
|
|
229
|
-
};
|
|
230
|
-
const themedTitleStyle: React.CSSProperties = { ...titleStyle, color: palette.heading };
|
|
231
|
-
const themedLabelStyle: React.CSSProperties = { ...labelStyle, color: palette.label };
|
|
232
|
-
const themedSelectStyle: React.CSSProperties = {
|
|
233
|
-
...selectStyle,
|
|
234
|
-
border: `1px solid ${palette.border}`,
|
|
235
|
-
background: palette.control,
|
|
236
|
-
color: palette.text,
|
|
237
|
-
colorScheme,
|
|
238
|
-
};
|
|
239
|
-
const themedPrimaryButtonStyle: React.CSSProperties = {
|
|
240
|
-
...btnPrimary,
|
|
241
|
-
background: palette.primary,
|
|
242
|
-
border: `1px solid ${palette.primaryBorder}`,
|
|
243
|
-
};
|
|
244
|
-
const themedSecondaryButtonStyle: React.CSSProperties = {
|
|
245
|
-
...btnSecondary,
|
|
246
|
-
background: palette.secondary,
|
|
247
|
-
color: palette.text,
|
|
248
|
-
border: `1px solid ${palette.border}`,
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const exportHook = useVideoExport();
|
|
252
|
-
const {
|
|
253
|
-
state,
|
|
254
|
-
progress,
|
|
255
|
-
backend,
|
|
256
|
-
outputFormat: completedOutputFormat,
|
|
257
|
-
downloadUrl,
|
|
258
|
-
fileSize,
|
|
259
|
-
audioIncluded,
|
|
260
|
-
audioSkippedReason,
|
|
261
|
-
error,
|
|
262
|
-
elapsed,
|
|
263
|
-
estimatedRemaining,
|
|
264
|
-
startExport,
|
|
265
|
-
cancel: cancelExport,
|
|
266
|
-
reset: resetExport,
|
|
267
|
-
} = exportHook;
|
|
268
|
-
|
|
269
|
-
const handleOutputFormatChange = useCallback((next: VideoOutputFormat) => {
|
|
270
|
-
setOutputFormat(next);
|
|
271
|
-
if (next === 'gif') {
|
|
272
|
-
setFps(10);
|
|
273
|
-
setAnimationsEnabled(false);
|
|
274
|
-
} else {
|
|
275
|
-
setFps(24);
|
|
276
|
-
setAnimationsEnabled(true);
|
|
277
|
-
}
|
|
278
|
-
}, []);
|
|
279
|
-
|
|
280
|
-
const handleExport = useCallback(async () => {
|
|
281
|
-
const config: VideoExportConfig = {
|
|
282
|
-
// defaultConfig is the base; explicit props/selections win over it.
|
|
283
|
-
...defaultConfig,
|
|
284
|
-
outputFormat,
|
|
285
|
-
animationsEnabled,
|
|
286
|
-
quality,
|
|
287
|
-
fps,
|
|
288
|
-
orientation,
|
|
289
|
-
captionMode,
|
|
290
|
-
images,
|
|
291
|
-
audio,
|
|
292
|
-
mediaProvider,
|
|
293
|
-
// Only thread the bundle through when the host actually supplied one.
|
|
294
|
-
...(playerScript !== undefined ? { playerScript } : {}),
|
|
295
|
-
};
|
|
296
|
-
await startExport(doc, config);
|
|
297
|
-
}, [
|
|
298
|
-
doc,
|
|
299
|
-
outputFormat,
|
|
300
|
-
animationsEnabled,
|
|
301
|
-
quality,
|
|
302
|
-
fps,
|
|
303
|
-
orientation,
|
|
304
|
-
captionMode,
|
|
305
|
-
images,
|
|
306
|
-
audio,
|
|
307
|
-
mediaProvider,
|
|
308
|
-
playerScript,
|
|
309
|
-
defaultConfig,
|
|
310
|
-
startExport,
|
|
311
|
-
]);
|
|
312
|
-
|
|
313
|
-
const handleDownload = useCallback(() => {
|
|
314
|
-
if (!downloadUrl) return;
|
|
315
|
-
const a = document.createElement('a');
|
|
316
|
-
a.href = downloadUrl;
|
|
317
|
-
const ts = new Date().toISOString().slice(0, 10);
|
|
318
|
-
a.download = `document-${ts}.${completedOutputFormat}`;
|
|
319
|
-
document.body.appendChild(a);
|
|
320
|
-
a.click();
|
|
321
|
-
document.body.removeChild(a);
|
|
322
|
-
}, [downloadUrl, completedOutputFormat]);
|
|
323
|
-
|
|
324
|
-
const handleClose = useCallback(() => {
|
|
325
|
-
if (state === 'capturing' || state === 'encoding' || state === 'preparing') {
|
|
326
|
-
cancelExport();
|
|
327
|
-
}
|
|
328
|
-
resetExport();
|
|
329
|
-
onClose();
|
|
330
|
-
}, [state, cancelExport, resetExport, onClose]);
|
|
331
|
-
|
|
332
|
-
const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';
|
|
333
|
-
|
|
334
|
-
return (
|
|
335
|
-
<div
|
|
336
|
-
style={{ ...overlayStyle, background: palette.overlay }}
|
|
337
|
-
data-color-scheme={colorScheme}
|
|
338
|
-
onClick={handleClose}
|
|
339
|
-
>
|
|
340
|
-
<div style={themedModalStyle} onClick={(e) => e.stopPropagation()}>
|
|
341
|
-
<h2 style={themedTitleStyle}>
|
|
342
|
-
{outputFormat === 'gif' ? 'Export Animated GIF' : 'Export Video'}
|
|
343
|
-
</h2>
|
|
344
|
-
|
|
345
|
-
{/* ── Configure State ── */}
|
|
346
|
-
{state === 'idle' && (
|
|
347
|
-
<>
|
|
348
|
-
<div>
|
|
349
|
-
<label style={themedLabelStyle}>Format</label>
|
|
350
|
-
<select
|
|
351
|
-
aria-label="Format"
|
|
352
|
-
style={themedSelectStyle}
|
|
353
|
-
value={outputFormat}
|
|
354
|
-
onChange={(e) => handleOutputFormatChange(e.target.value as VideoOutputFormat)}
|
|
355
|
-
>
|
|
356
|
-
<option value="mp4">MP4 video</option>
|
|
357
|
-
<option value="gif">Animated GIF</option>
|
|
358
|
-
</select>
|
|
359
|
-
</div>
|
|
360
|
-
|
|
361
|
-
<div>
|
|
362
|
-
<label style={themedLabelStyle}>Quality</label>
|
|
363
|
-
<select
|
|
364
|
-
aria-label="Quality"
|
|
365
|
-
style={themedSelectStyle}
|
|
366
|
-
value={quality}
|
|
367
|
-
onChange={(e) => setQuality(e.target.value as VideoQuality)}
|
|
368
|
-
>
|
|
369
|
-
<option value="draft">Draft — fast, lower quality</option>
|
|
370
|
-
<option value="normal">Normal — balanced</option>
|
|
371
|
-
<option value="high">High — best quality, slower</option>
|
|
372
|
-
</select>
|
|
373
|
-
</div>
|
|
374
|
-
|
|
375
|
-
<div>
|
|
376
|
-
<label style={themedLabelStyle}>Frame Rate</label>
|
|
377
|
-
<select
|
|
378
|
-
aria-label="Frame Rate"
|
|
379
|
-
style={themedSelectStyle}
|
|
380
|
-
value={fps}
|
|
381
|
-
onChange={(e) => setFps(Number(e.target.value))}
|
|
382
|
-
>
|
|
383
|
-
<option value={10}>10 fps — recommended for GIF</option>
|
|
384
|
-
<option value={15}>15 fps — fast export</option>
|
|
385
|
-
<option value={24}>24 fps — cinematic</option>
|
|
386
|
-
<option value={30}>30 fps — smooth</option>
|
|
387
|
-
</select>
|
|
388
|
-
</div>
|
|
389
|
-
|
|
390
|
-
<div>
|
|
391
|
-
<label style={themedLabelStyle}>Orientation</label>
|
|
392
|
-
<select
|
|
393
|
-
aria-label="Orientation"
|
|
394
|
-
style={themedSelectStyle}
|
|
395
|
-
value={orientation}
|
|
396
|
-
onChange={(e) => setOrientation(e.target.value as VideoOrientation)}
|
|
397
|
-
>
|
|
398
|
-
<option value="landscape">
|
|
399
|
-
Landscape ({outputFormat === 'gif' ? '960 × 540' : '1920 × 1080'})
|
|
400
|
-
</option>
|
|
401
|
-
<option value="portrait">
|
|
402
|
-
Portrait ({outputFormat === 'gif' ? '540 × 960' : '1080 × 1920'})
|
|
403
|
-
</option>
|
|
404
|
-
</select>
|
|
405
|
-
</div>
|
|
406
|
-
|
|
407
|
-
<div>
|
|
408
|
-
<label style={themedLabelStyle}>Captions</label>
|
|
409
|
-
<select
|
|
410
|
-
aria-label="Captions"
|
|
411
|
-
style={themedSelectStyle}
|
|
412
|
-
value={captionMode}
|
|
413
|
-
onChange={(e) => setCaptionMode(e.target.value as CaptionMode)}
|
|
414
|
-
>
|
|
415
|
-
<option value="off">None</option>
|
|
416
|
-
<option value="standard">Standard (top bar)</option>
|
|
417
|
-
<option value="social">Social media (large words)</option>
|
|
418
|
-
</select>
|
|
419
|
-
</div>
|
|
420
|
-
|
|
421
|
-
<div>
|
|
422
|
-
<label style={themedLabelStyle}>Animations & transitions</label>
|
|
423
|
-
<select
|
|
424
|
-
aria-label="Animations and transitions"
|
|
425
|
-
style={themedSelectStyle}
|
|
426
|
-
value={animationsEnabled ? 'enabled' : 'disabled'}
|
|
427
|
-
onChange={(e) => setAnimationsEnabled(e.target.value === 'enabled')}
|
|
428
|
-
>
|
|
429
|
-
<option value="enabled">Enabled</option>
|
|
430
|
-
<option value="disabled">Disabled — smaller files</option>
|
|
431
|
-
</select>
|
|
432
|
-
{outputFormat === 'gif' && animationsEnabled && (
|
|
433
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '-6px 0 12px' }}>
|
|
434
|
-
Disabling motion is recommended for much smaller GIFs.
|
|
435
|
-
</p>
|
|
436
|
-
)}
|
|
437
|
-
</div>
|
|
438
|
-
|
|
439
|
-
<div style={footerStyle}>
|
|
440
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
441
|
-
Cancel
|
|
442
|
-
</button>
|
|
443
|
-
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
444
|
-
{outputFormat === 'gif' ? 'Export GIF' : 'Export Video'}
|
|
445
|
-
</button>
|
|
446
|
-
</div>
|
|
447
|
-
</>
|
|
448
|
-
)}
|
|
449
|
-
|
|
450
|
-
{/* ── Exporting State ── */}
|
|
451
|
-
{isExporting && (
|
|
452
|
-
<>
|
|
453
|
-
{backend && (
|
|
454
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 8px 0' }}>
|
|
455
|
-
Encoder: {encoderLabel(completedOutputFormat, backend)}
|
|
456
|
-
</p>
|
|
457
|
-
)}
|
|
458
|
-
|
|
459
|
-
<div style={{ ...progressBarOuterStyle, background: palette.secondary }}>
|
|
460
|
-
<div
|
|
461
|
-
style={{
|
|
462
|
-
width: `${progress}%`,
|
|
463
|
-
height: '100%',
|
|
464
|
-
background: palette.primary,
|
|
465
|
-
transition: 'width 0.3s ease',
|
|
466
|
-
}}
|
|
467
|
-
/>
|
|
468
|
-
</div>
|
|
469
|
-
|
|
470
|
-
<p style={{ fontSize: 13, margin: '0 0 4px 0' }}>{progress}% complete</p>
|
|
471
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: 0 }}>
|
|
472
|
-
{formatDuration(elapsed)} elapsed
|
|
473
|
-
{estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`}
|
|
474
|
-
</p>
|
|
475
|
-
|
|
476
|
-
<div style={footerStyle}>
|
|
477
|
-
<button style={themedSecondaryButtonStyle} onClick={cancelExport}>
|
|
478
|
-
Cancel
|
|
479
|
-
</button>
|
|
480
|
-
</div>
|
|
481
|
-
</>
|
|
482
|
-
)}
|
|
483
|
-
|
|
484
|
-
{/* ── Complete State ── */}
|
|
485
|
-
{state === 'complete' && (
|
|
486
|
-
<>
|
|
487
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: palette.success }}>
|
|
488
|
-
Export complete!
|
|
489
|
-
</p>
|
|
490
|
-
<p style={{ fontSize: 13, color: palette.label, margin: '0 0 4px 0' }}>
|
|
491
|
-
File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
|
|
492
|
-
</p>
|
|
493
|
-
{completedOutputFormat === 'gif' ? (
|
|
494
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 4px 0' }}>
|
|
495
|
-
Animated GIF does not include audio.
|
|
496
|
-
</p>
|
|
497
|
-
) : audioIncluded ? (
|
|
498
|
-
<p style={{ fontSize: 12, color: palette.success, margin: '0 0 4px 0' }}>
|
|
499
|
-
Audio included ✓
|
|
500
|
-
</p>
|
|
501
|
-
) : (
|
|
502
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 4px 0' }}>
|
|
503
|
-
Video only{audioSkippedReason ? ` — ${audioSkippedReason}` : ''}
|
|
504
|
-
</p>
|
|
505
|
-
)}
|
|
506
|
-
{backend && (
|
|
507
|
-
<p style={{ fontSize: 12, color: palette.muted, margin: '0 0 12px 0' }}>
|
|
508
|
-
Encoded with {encoderLabel(completedOutputFormat, backend)}
|
|
509
|
-
</p>
|
|
510
|
-
)}
|
|
511
|
-
|
|
512
|
-
<div style={footerStyle}>
|
|
513
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
514
|
-
Close
|
|
515
|
-
</button>
|
|
516
|
-
<button style={themedPrimaryButtonStyle} onClick={handleDownload}>
|
|
517
|
-
Download {completedOutputFormat.toUpperCase()}
|
|
518
|
-
</button>
|
|
519
|
-
</div>
|
|
520
|
-
</>
|
|
521
|
-
)}
|
|
522
|
-
|
|
523
|
-
{/* ── Error State ── */}
|
|
524
|
-
{state === 'error' && (
|
|
525
|
-
<>
|
|
526
|
-
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: palette.danger }}>
|
|
527
|
-
Export failed
|
|
528
|
-
</p>
|
|
529
|
-
<p
|
|
530
|
-
style={{
|
|
531
|
-
fontSize: 13,
|
|
532
|
-
color: palette.label,
|
|
533
|
-
margin: '0 0 12px 0',
|
|
534
|
-
wordBreak: 'break-word',
|
|
535
|
-
}}
|
|
536
|
-
>
|
|
537
|
-
{error}
|
|
538
|
-
</p>
|
|
539
|
-
|
|
540
|
-
<div style={footerStyle}>
|
|
541
|
-
<button style={themedSecondaryButtonStyle} onClick={handleClose}>
|
|
542
|
-
Close
|
|
543
|
-
</button>
|
|
544
|
-
<button style={themedPrimaryButtonStyle} onClick={handleExport}>
|
|
545
|
-
Retry {outputFormat === 'gif' ? 'GIF' : 'video'}
|
|
546
|
-
</button>
|
|
547
|
-
</div>
|
|
548
|
-
</>
|
|
549
|
-
)}
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
);
|
|
553
|
-
}
|