@bendyline/squisq-video-react 1.2.2 → 2.0.1
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/README.md +63 -22
- package/dist/index.d.ts +33 -13
- package/dist/index.js +511 -212
- package/dist/index.js.map +1 -1
- package/dist/workers/encode.worker.js +110 -80
- package/dist/workers/encode.worker.js.map +1 -1
- package/dist/workers/ffmpeg.class-worker.d.ts +2 -0
- package/dist/workers/ffmpeg.class-worker.js +180 -0
- package/dist/workers/ffmpeg.class-worker.js.map +1 -0
- package/package.json +7 -5
- package/src/VideoExportButton.tsx +12 -5
- package/src/VideoExportModal.tsx +219 -62
- package/src/__tests__/gifTranscode.test.ts +118 -0
- package/src/__tests__/useVideoExportGif.test.ts +155 -0
- package/src/__tests__/videoExportProps.test.tsx +82 -7
- package/src/__tests__/workerEncoder.test.ts +143 -0
- package/src/audioTrack.ts +18 -9
- package/src/gifTranscode.ts +75 -0
- package/src/hooks/useFrameCapture.ts +26 -21
- package/src/hooks/useVideoExport.ts +104 -15
- package/src/index.ts +2 -0
- package/src/mainThreadEncoder.ts +5 -10
- package/src/workerEncoder.ts +66 -14
- package/src/workers/encode.worker.ts +134 -86
- package/src/workers/ffmpeg.class-worker.ts +11 -0
- package/src/workers/workerTypes.ts +9 -1
package/dist/index.js
CHANGED
|
@@ -10,7 +10,7 @@ import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } f
|
|
|
10
10
|
import { resolveDimensions, computeAudioTimeline, QUALITY_PRESETS } from "@bendyline/squisq-video";
|
|
11
11
|
|
|
12
12
|
// src/mainThreadEncoder.ts
|
|
13
|
-
import { bitrateForQuality } from "@bendyline/squisq-video";
|
|
13
|
+
import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
|
|
14
14
|
function supportsWebCodecs() {
|
|
15
15
|
return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
|
|
16
16
|
}
|
|
@@ -30,16 +30,12 @@ async function supportsWebCodecsH264(config) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
function createEncoder(config) {
|
|
33
|
+
validateVideoExportOptions(config);
|
|
33
34
|
if (!supportsWebCodecs()) {
|
|
34
35
|
throw new Error(
|
|
35
36
|
"WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
|
|
36
37
|
);
|
|
37
38
|
}
|
|
38
|
-
if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
|
|
39
|
-
throw new Error(
|
|
40
|
-
`Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
39
|
const muxer = createMp4Muxer({
|
|
44
40
|
width: config.width,
|
|
45
41
|
height: config.height,
|
|
@@ -69,10 +65,10 @@ function createEncoder(config) {
|
|
|
69
65
|
framerate: config.fps
|
|
70
66
|
});
|
|
71
67
|
return {
|
|
72
|
-
encodeFrame(bitmap, frameIndex) {
|
|
68
|
+
async encodeFrame(bitmap, frameIndex) {
|
|
73
69
|
if (closed) {
|
|
74
70
|
bitmap.close();
|
|
75
|
-
|
|
71
|
+
throw new Error("Encoder already closed");
|
|
76
72
|
}
|
|
77
73
|
const timestamp = Math.round(frameIndex * frameDuration);
|
|
78
74
|
const frame = new VideoFrame(bitmap, { timestamp });
|
|
@@ -103,21 +99,20 @@ function createEncoder(config) {
|
|
|
103
99
|
}
|
|
104
100
|
|
|
105
101
|
// src/workerEncoder.ts
|
|
102
|
+
import { validateVideoExportOptions as validateVideoExportOptions2 } from "@bendyline/squisq-video";
|
|
106
103
|
function createWorkerEncoder(config) {
|
|
107
|
-
|
|
108
|
-
throw new Error(
|
|
109
|
-
`Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`
|
|
110
|
-
);
|
|
111
|
-
}
|
|
104
|
+
validateVideoExportOptions2(config);
|
|
112
105
|
const worker = new Worker(new URL("./workers/encode.worker.js", import.meta.url), {
|
|
113
106
|
type: "module"
|
|
114
107
|
});
|
|
115
|
-
let
|
|
108
|
+
let state = "open";
|
|
116
109
|
let fatalError = null;
|
|
117
110
|
let finalizeResolve = null;
|
|
118
111
|
let finalizeReject = null;
|
|
119
112
|
let readyResolve = null;
|
|
120
113
|
let readyReject = null;
|
|
114
|
+
let readySettled = false;
|
|
115
|
+
const frameWaiters = /* @__PURE__ */ new Map();
|
|
121
116
|
const ready = new Promise((resolve, reject) => {
|
|
122
117
|
readyResolve = resolve;
|
|
123
118
|
readyReject = reject;
|
|
@@ -126,14 +121,23 @@ function createWorkerEncoder(config) {
|
|
|
126
121
|
function post(msg, transfer) {
|
|
127
122
|
worker.postMessage(msg, transfer ?? []);
|
|
128
123
|
}
|
|
124
|
+
const currentState = () => state;
|
|
129
125
|
worker.onmessage = (event) => {
|
|
130
126
|
const msg = event.data;
|
|
131
127
|
switch (msg.type) {
|
|
132
128
|
case "capabilities":
|
|
129
|
+
readySettled = true;
|
|
133
130
|
readyResolve?.(msg.backend);
|
|
134
131
|
readyResolve = readyReject = null;
|
|
135
132
|
break;
|
|
133
|
+
case "frame-complete": {
|
|
134
|
+
const waiter = frameWaiters.get(msg.frameIndex);
|
|
135
|
+
waiter?.resolve();
|
|
136
|
+
frameWaiters.delete(msg.frameIndex);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
136
139
|
case "complete":
|
|
140
|
+
state = "closed";
|
|
137
141
|
finalizeResolve?.(msg.data);
|
|
138
142
|
finalizeResolve = finalizeReject = null;
|
|
139
143
|
worker.terminate();
|
|
@@ -141,8 +145,12 @@ function createWorkerEncoder(config) {
|
|
|
141
145
|
case "error": {
|
|
142
146
|
const err = new Error(msg.message);
|
|
143
147
|
fatalError = err;
|
|
148
|
+
state = "closed";
|
|
149
|
+
readySettled = true;
|
|
144
150
|
readyReject?.(err);
|
|
145
151
|
finalizeReject?.(err);
|
|
152
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
153
|
+
frameWaiters.clear();
|
|
146
154
|
readyResolve = readyReject = null;
|
|
147
155
|
finalizeResolve = finalizeReject = null;
|
|
148
156
|
worker.terminate();
|
|
@@ -153,8 +161,12 @@ function createWorkerEncoder(config) {
|
|
|
153
161
|
worker.onerror = (event) => {
|
|
154
162
|
const err = new Error(event.message || "Worker error");
|
|
155
163
|
fatalError = err;
|
|
164
|
+
state = "closed";
|
|
165
|
+
readySettled = true;
|
|
156
166
|
readyReject?.(err);
|
|
157
167
|
finalizeReject?.(err);
|
|
168
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
169
|
+
frameWaiters.clear();
|
|
158
170
|
readyResolve = readyReject = null;
|
|
159
171
|
finalizeResolve = finalizeReject = null;
|
|
160
172
|
worker.terminate();
|
|
@@ -164,22 +176,39 @@ function createWorkerEncoder(config) {
|
|
|
164
176
|
width: config.width,
|
|
165
177
|
height: config.height,
|
|
166
178
|
fps: config.fps,
|
|
167
|
-
quality: config.quality
|
|
179
|
+
quality: config.quality,
|
|
180
|
+
...config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}
|
|
168
181
|
});
|
|
169
182
|
return {
|
|
170
183
|
ready,
|
|
171
184
|
encodeFrame(bitmap, frameIndex) {
|
|
172
|
-
if (
|
|
185
|
+
if (state !== "open" || fatalError) {
|
|
173
186
|
bitmap.close();
|
|
174
|
-
return;
|
|
187
|
+
return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
|
|
175
188
|
}
|
|
189
|
+
if (frameWaiters.has(frameIndex)) {
|
|
190
|
+
bitmap.close();
|
|
191
|
+
return Promise.reject(new Error(`Frame ${frameIndex} was submitted more than once`));
|
|
192
|
+
}
|
|
193
|
+
let resolveFrame;
|
|
194
|
+
let rejectFrame;
|
|
195
|
+
const promise = new Promise((resolve, reject) => {
|
|
196
|
+
resolveFrame = resolve;
|
|
197
|
+
rejectFrame = reject;
|
|
198
|
+
});
|
|
199
|
+
frameWaiters.set(frameIndex, { promise, resolve: resolveFrame, reject: rejectFrame });
|
|
176
200
|
const timestamp = Math.round(frameIndex * frameDuration);
|
|
177
201
|
post({ type: "frame", bitmap, frameIndex, timestamp }, [bitmap]);
|
|
202
|
+
return promise;
|
|
178
203
|
},
|
|
179
204
|
async finalize() {
|
|
180
|
-
if (
|
|
205
|
+
if (state !== "open") throw new Error("Encoder already closed or finalizing");
|
|
181
206
|
if (fatalError) throw fatalError;
|
|
182
|
-
|
|
207
|
+
state = "finalizing";
|
|
208
|
+
await Promise.all(Array.from(frameWaiters.values(), (waiter) => waiter.promise));
|
|
209
|
+
if (currentState() === "closed") {
|
|
210
|
+
throw fatalError ?? new Error("Encoder closed during finalization");
|
|
211
|
+
}
|
|
183
212
|
return new Promise((resolve, reject) => {
|
|
184
213
|
finalizeResolve = resolve;
|
|
185
214
|
finalizeReject = reject;
|
|
@@ -187,8 +216,18 @@ function createWorkerEncoder(config) {
|
|
|
187
216
|
});
|
|
188
217
|
},
|
|
189
218
|
close() {
|
|
190
|
-
if (closed) return;
|
|
191
|
-
|
|
219
|
+
if (state === "closed") return;
|
|
220
|
+
state = "closed";
|
|
221
|
+
const err = new Error("Encoder closed");
|
|
222
|
+
if (!readySettled) {
|
|
223
|
+
readySettled = true;
|
|
224
|
+
readyReject?.(err);
|
|
225
|
+
}
|
|
226
|
+
finalizeReject?.(err);
|
|
227
|
+
for (const waiter of frameWaiters.values()) waiter.reject(err);
|
|
228
|
+
frameWaiters.clear();
|
|
229
|
+
readyResolve = readyReject = null;
|
|
230
|
+
finalizeResolve = finalizeReject = null;
|
|
192
231
|
post({ type: "cancel" });
|
|
193
232
|
worker.terminate();
|
|
194
233
|
}
|
|
@@ -196,6 +235,9 @@ function createWorkerEncoder(config) {
|
|
|
196
235
|
}
|
|
197
236
|
|
|
198
237
|
// src/audioTrack.ts
|
|
238
|
+
import {
|
|
239
|
+
ffmpegAudioMuxArgs
|
|
240
|
+
} from "@bendyline/squisq-video";
|
|
199
241
|
var EXPORT_AUDIO_SAMPLE_RATE = 48e3;
|
|
200
242
|
var EXPORT_AUDIO_CHANNELS = 2;
|
|
201
243
|
var REASON_NO_AAC_NO_SAB = "This browser cannot encode AAC audio (WebCodecs AudioEncoder unavailable) and the ffmpeg.wasm fallback requires cross-origin isolation (SharedArrayBuffer).";
|
|
@@ -339,27 +381,29 @@ function audioBufferToWav(buffer) {
|
|
|
339
381
|
}
|
|
340
382
|
return new Uint8Array(out);
|
|
341
383
|
}
|
|
342
|
-
async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate) {
|
|
384
|
+
async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate, loadConfig) {
|
|
343
385
|
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
|
|
344
386
|
const ffmpeg = new FFmpeg();
|
|
345
|
-
await ffmpeg.load();
|
|
346
387
|
try {
|
|
388
|
+
await ffmpeg.load({
|
|
389
|
+
...loadConfig,
|
|
390
|
+
classWorkerURL: loadConfig?.classWorkerURL ?? new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
|
|
391
|
+
});
|
|
347
392
|
await ffmpeg.writeFile("video.mp4", videoMp4);
|
|
348
393
|
await ffmpeg.writeFile("audio.wav", wav);
|
|
349
|
-
await ffmpeg.exec([
|
|
394
|
+
const exitCode = await ffmpeg.exec([
|
|
350
395
|
"-i",
|
|
351
396
|
"video.mp4",
|
|
352
397
|
"-i",
|
|
353
398
|
"audio.wav",
|
|
354
399
|
"-c:v",
|
|
355
400
|
"copy",
|
|
356
|
-
|
|
357
|
-
"aac",
|
|
358
|
-
"-b:a",
|
|
359
|
-
String(audioBitrate),
|
|
360
|
-
"-shortest",
|
|
401
|
+
...ffmpegAudioMuxArgs(audioBitrate),
|
|
361
402
|
"out.mp4"
|
|
362
403
|
]);
|
|
404
|
+
if (exitCode !== 0) {
|
|
405
|
+
throw new Error(`ffmpeg.wasm audio mux failed with exit code ${exitCode}`);
|
|
406
|
+
}
|
|
363
407
|
const data = await ffmpeg.readFile("out.mp4");
|
|
364
408
|
return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
365
409
|
} finally {
|
|
@@ -367,6 +411,58 @@ async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate) {
|
|
|
367
411
|
}
|
|
368
412
|
}
|
|
369
413
|
|
|
414
|
+
// src/gifTranscode.ts
|
|
415
|
+
import {
|
|
416
|
+
ffmpegGifOutputArgs
|
|
417
|
+
} from "@bendyline/squisq-video";
|
|
418
|
+
function buildGifFfmpegArgs(options) {
|
|
419
|
+
return ["-y", "-i", "video.mp4", ...ffmpegGifOutputArgs(options), "out.gif"];
|
|
420
|
+
}
|
|
421
|
+
async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
|
|
422
|
+
if (videoMp4.byteLength === 0) {
|
|
423
|
+
throw new Error("Cannot create an animated GIF from an empty MP4.");
|
|
424
|
+
}
|
|
425
|
+
if (signal?.aborted) {
|
|
426
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
427
|
+
}
|
|
428
|
+
const args = buildGifFfmpegArgs(options);
|
|
429
|
+
const { FFmpeg } = await import("@ffmpeg/ffmpeg");
|
|
430
|
+
const ffmpeg = new FFmpeg();
|
|
431
|
+
let terminated = false;
|
|
432
|
+
const terminate = () => {
|
|
433
|
+
if (terminated) return;
|
|
434
|
+
terminated = true;
|
|
435
|
+
ffmpeg.terminate();
|
|
436
|
+
};
|
|
437
|
+
const handleAbort = () => terminate();
|
|
438
|
+
signal?.addEventListener("abort", handleAbort, { once: true });
|
|
439
|
+
try {
|
|
440
|
+
await ffmpeg.load({
|
|
441
|
+
...loadConfig,
|
|
442
|
+
classWorkerURL: loadConfig?.classWorkerURL ?? new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
|
|
443
|
+
});
|
|
444
|
+
if (signal?.aborted) {
|
|
445
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
446
|
+
}
|
|
447
|
+
await ffmpeg.writeFile("video.mp4", videoMp4);
|
|
448
|
+
if (signal?.aborted) {
|
|
449
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
450
|
+
}
|
|
451
|
+
const exitCode = await ffmpeg.exec(args);
|
|
452
|
+
if (signal?.aborted) {
|
|
453
|
+
throw new DOMException("Animated GIF export was cancelled.", "AbortError");
|
|
454
|
+
}
|
|
455
|
+
if (exitCode !== 0) {
|
|
456
|
+
throw new Error(`ffmpeg.wasm GIF transcode failed with exit code ${exitCode}`);
|
|
457
|
+
}
|
|
458
|
+
const data = await ffmpeg.readFile("out.gif");
|
|
459
|
+
return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
460
|
+
} finally {
|
|
461
|
+
signal?.removeEventListener("abort", handleAbort);
|
|
462
|
+
terminate();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
370
466
|
// src/hooks/useFrameCapture.ts
|
|
371
467
|
import { createElement } from "react";
|
|
372
468
|
import { createRoot } from "react-dom/client";
|
|
@@ -425,6 +521,7 @@ function createInlineProvider(images) {
|
|
|
425
521
|
function useFrameCapture() {
|
|
426
522
|
const containerRef = useRef(null);
|
|
427
523
|
const rootRef = useRef(null);
|
|
524
|
+
const renderAPIRef = useRef(null);
|
|
428
525
|
const dimensionsRef = useRef({ width: 1920, height: 1080 });
|
|
429
526
|
const init = useCallback(
|
|
430
527
|
async (doc, renderOptions, captionMode) => {
|
|
@@ -433,6 +530,7 @@ function useFrameCapture() {
|
|
|
433
530
|
const oldContainer = containerRef.current;
|
|
434
531
|
rootRef.current = null;
|
|
435
532
|
containerRef.current = null;
|
|
533
|
+
renderAPIRef.current = null;
|
|
436
534
|
await new Promise((resolve) => {
|
|
437
535
|
setTimeout(() => {
|
|
438
536
|
if (oldRoot) oldRoot.unmount();
|
|
@@ -443,6 +541,7 @@ function useFrameCapture() {
|
|
|
443
541
|
}
|
|
444
542
|
const width = renderOptions.width ?? 1920;
|
|
445
543
|
const height = renderOptions.height ?? 1080;
|
|
544
|
+
const animationsEnabled = renderOptions.animationsEnabled ?? true;
|
|
446
545
|
dimensionsRef.current = { width, height };
|
|
447
546
|
const container = document.createElement("div");
|
|
448
547
|
container.style.cssText = `position:fixed;left:0;top:0;width:${width}px;height:${height}px;opacity:0;pointer-events:none;z-index:-1;overflow:hidden;`;
|
|
@@ -457,15 +556,25 @@ function useFrameCapture() {
|
|
|
457
556
|
rootRef.current = root;
|
|
458
557
|
const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
|
|
459
558
|
const captionStyle = captionMode === "social" ? "social" : "standard";
|
|
559
|
+
let resolveRenderAPI;
|
|
560
|
+
const renderAPIReady = new Promise((resolve) => {
|
|
561
|
+
resolveRenderAPI = resolve;
|
|
562
|
+
});
|
|
460
563
|
const playerElement = createElement(DocPlayer, {
|
|
461
564
|
doc,
|
|
462
565
|
basePath: ".",
|
|
463
566
|
renderMode: true,
|
|
567
|
+
animationsEnabled,
|
|
464
568
|
showControls: false,
|
|
465
569
|
autoPlay: false,
|
|
466
570
|
forceViewport: { width, height, name: "export" },
|
|
467
571
|
captionsEnabled,
|
|
468
|
-
captionStyle
|
|
572
|
+
captionStyle,
|
|
573
|
+
onRenderAPIReady: (api) => {
|
|
574
|
+
if (containerRef.current !== container) return;
|
|
575
|
+
renderAPIRef.current = api;
|
|
576
|
+
if (api) resolveRenderAPI(api);
|
|
577
|
+
}
|
|
469
578
|
});
|
|
470
579
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
471
580
|
if (mediaProvider) {
|
|
@@ -475,9 +584,9 @@ function useFrameCapture() {
|
|
|
475
584
|
}
|
|
476
585
|
return new Promise((resolve, reject) => {
|
|
477
586
|
const timeout = setTimeout(() => {
|
|
478
|
-
const
|
|
479
|
-
const hasSeek = typeof
|
|
480
|
-
const hasDur = typeof
|
|
587
|
+
const api = renderAPIRef.current;
|
|
588
|
+
const hasSeek = typeof api?.seekTo === "function";
|
|
589
|
+
const hasDur = typeof api?.getDuration === "function";
|
|
481
590
|
const rootEl = containerRef.current?.querySelector("#squisq-capture-root");
|
|
482
591
|
const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
|
|
483
592
|
reject(
|
|
@@ -486,29 +595,22 @@ function useFrameCapture() {
|
|
|
486
595
|
)
|
|
487
596
|
);
|
|
488
597
|
}, 15e3);
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const duration = w.getDuration();
|
|
494
|
-
resolve(duration);
|
|
495
|
-
} else {
|
|
496
|
-
requestAnimationFrame(checkApi);
|
|
497
|
-
}
|
|
498
|
-
};
|
|
499
|
-
setTimeout(checkApi, 500);
|
|
598
|
+
void renderAPIReady.then((api) => {
|
|
599
|
+
clearTimeout(timeout);
|
|
600
|
+
resolve(api.getDuration());
|
|
601
|
+
});
|
|
500
602
|
});
|
|
501
603
|
},
|
|
502
604
|
[]
|
|
503
605
|
);
|
|
504
606
|
const captureFrame = useCallback(async (time) => {
|
|
505
607
|
const container = containerRef.current;
|
|
506
|
-
const
|
|
507
|
-
if (!container ||
|
|
608
|
+
const api = renderAPIRef.current;
|
|
609
|
+
if (!container || !api) {
|
|
508
610
|
throw new Error("Frame capture not initialized \u2014 call init() first");
|
|
509
611
|
}
|
|
510
612
|
const { width, height } = dimensionsRef.current;
|
|
511
|
-
await
|
|
613
|
+
await api.seekTo(time);
|
|
512
614
|
await new Promise(
|
|
513
615
|
(resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
514
616
|
);
|
|
@@ -537,6 +639,7 @@ function useFrameCapture() {
|
|
|
537
639
|
containerRef.current.remove();
|
|
538
640
|
containerRef.current = null;
|
|
539
641
|
}
|
|
642
|
+
renderAPIRef.current = null;
|
|
540
643
|
}, []);
|
|
541
644
|
return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
|
|
542
645
|
}
|
|
@@ -564,6 +667,7 @@ function useVideoExport() {
|
|
|
564
667
|
const [progress, setProgress] = useState(0);
|
|
565
668
|
const [phase, setPhase] = useState("");
|
|
566
669
|
const [duration, setDuration] = useState(0);
|
|
670
|
+
const [outputFormat, setOutputFormat] = useState("mp4");
|
|
567
671
|
const [backend, setBackend] = useState(null);
|
|
568
672
|
const [downloadUrl, setDownloadUrl] = useState(null);
|
|
569
673
|
const [fileSize, setFileSize] = useState(0);
|
|
@@ -573,6 +677,7 @@ function useVideoExport() {
|
|
|
573
677
|
const [elapsed, setElapsed] = useState(0);
|
|
574
678
|
const [estimatedRemaining, setEstimatedRemaining] = useState(0);
|
|
575
679
|
const encoderRef = useRef2(null);
|
|
680
|
+
const gifAbortRef = useRef2(null);
|
|
576
681
|
const cancelledRef = useRef2(false);
|
|
577
682
|
const downloadUrlRef = useRef2(null);
|
|
578
683
|
const startTimeRef = useRef2(0);
|
|
@@ -587,6 +692,7 @@ function useVideoExport() {
|
|
|
587
692
|
if (encoderRef.current) {
|
|
588
693
|
encoderRef.current.close();
|
|
589
694
|
}
|
|
695
|
+
gifAbortRef.current?.abort();
|
|
590
696
|
frameCapture.destroy();
|
|
591
697
|
};
|
|
592
698
|
}, [frameCapture]);
|
|
@@ -599,11 +705,14 @@ function useVideoExport() {
|
|
|
599
705
|
encoderRef.current.close();
|
|
600
706
|
encoderRef.current = null;
|
|
601
707
|
}
|
|
708
|
+
gifAbortRef.current?.abort();
|
|
709
|
+
gifAbortRef.current = null;
|
|
602
710
|
frameCapture.destroy();
|
|
603
711
|
setState("idle");
|
|
604
712
|
setProgress(0);
|
|
605
713
|
setPhase("");
|
|
606
714
|
setDuration(0);
|
|
715
|
+
setOutputFormat("mp4");
|
|
607
716
|
setBackend(null);
|
|
608
717
|
setDownloadUrl(null);
|
|
609
718
|
setFileSize(0);
|
|
@@ -622,6 +731,8 @@ function useVideoExport() {
|
|
|
622
731
|
encoderRef.current.close();
|
|
623
732
|
encoderRef.current = null;
|
|
624
733
|
}
|
|
734
|
+
gifAbortRef.current?.abort();
|
|
735
|
+
gifAbortRef.current = null;
|
|
625
736
|
frameCapture.destroy();
|
|
626
737
|
setState("idle");
|
|
627
738
|
setProgress(0);
|
|
@@ -640,12 +751,27 @@ function useVideoExport() {
|
|
|
640
751
|
setAudioSkippedReason(null);
|
|
641
752
|
setError(null);
|
|
642
753
|
const quality = config.quality ?? "normal";
|
|
643
|
-
const
|
|
754
|
+
const effectiveOutputFormat = config.outputFormat ?? "mp4";
|
|
755
|
+
const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
|
|
644
756
|
const orientation = config.orientation ?? "landscape";
|
|
645
|
-
const
|
|
757
|
+
const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
|
|
758
|
+
setOutputFormat(effectiveOutputFormat);
|
|
646
759
|
try {
|
|
760
|
+
const gifDefaults = orientation === "portrait" ? { width: 540, height: 960 } : { width: 960, height: 540 };
|
|
761
|
+
const { width, height } = resolveDimensions({
|
|
762
|
+
orientation,
|
|
763
|
+
fps,
|
|
764
|
+
quality,
|
|
765
|
+
...config.width !== void 0 ? { width: config.width } : effectiveOutputFormat === "gif" ? { width: gifDefaults.width } : {},
|
|
766
|
+
...config.height !== void 0 ? { height: config.height } : effectiveOutputFormat === "gif" ? { height: gifDefaults.height } : {}
|
|
767
|
+
});
|
|
647
768
|
const webCodecsAvailable = supportsWebCodecs();
|
|
648
769
|
const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== "undefined";
|
|
770
|
+
if (effectiveOutputFormat === "gif" && !sharedArrayBufferAvailable) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
"Animated GIF export requires ffmpeg.wasm and SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
773
|
+
);
|
|
774
|
+
}
|
|
649
775
|
if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
|
|
650
776
|
throw new Error(
|
|
651
777
|
"No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
|
|
@@ -676,7 +802,7 @@ function useVideoExport() {
|
|
|
676
802
|
}
|
|
677
803
|
const docDuration = await frameCapture.init(
|
|
678
804
|
doc,
|
|
679
|
-
{ images, audio: config.audio, width, height },
|
|
805
|
+
{ images, audio: config.audio, width, height, animationsEnabled },
|
|
680
806
|
config.captionMode
|
|
681
807
|
);
|
|
682
808
|
if (cancelledRef.current) return;
|
|
@@ -688,7 +814,7 @@ function useVideoExport() {
|
|
|
688
814
|
setProgress(5);
|
|
689
815
|
const canUseWebCodecs = webCodecsAvailable && await supportsWebCodecsH264({ width, height, fps, quality });
|
|
690
816
|
const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
|
|
691
|
-
const timeline = computeAudioTimeline(doc, 0);
|
|
817
|
+
const timeline = effectiveOutputFormat === "mp4" ? computeAudioTimeline(doc, 0) : [];
|
|
692
818
|
const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
|
|
693
819
|
const tierDecision = selectAudioTier({
|
|
694
820
|
hasClips: timeline.length > 0,
|
|
@@ -745,7 +871,13 @@ function useVideoExport() {
|
|
|
745
871
|
});
|
|
746
872
|
setBackend("webcodecs");
|
|
747
873
|
} else if (sharedArrayBufferAvailable) {
|
|
748
|
-
const workerEncoder = createWorkerEncoder({
|
|
874
|
+
const workerEncoder = createWorkerEncoder({
|
|
875
|
+
width,
|
|
876
|
+
height,
|
|
877
|
+
fps,
|
|
878
|
+
quality,
|
|
879
|
+
ffmpegWasm: config.ffmpegWasm
|
|
880
|
+
});
|
|
749
881
|
encoder = workerEncoder;
|
|
750
882
|
const selectedBackend = await workerEncoder.ready;
|
|
751
883
|
setBackend(selectedBackend);
|
|
@@ -782,7 +914,7 @@ function useVideoExport() {
|
|
|
782
914
|
bitmap.close();
|
|
783
915
|
return;
|
|
784
916
|
}
|
|
785
|
-
encoder.encodeFrame(bitmap, i);
|
|
917
|
+
await encoder.encodeFrame(bitmap, i);
|
|
786
918
|
}
|
|
787
919
|
if (cancelledRef.current) return;
|
|
788
920
|
if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
|
|
@@ -801,17 +933,37 @@ function useVideoExport() {
|
|
|
801
933
|
}
|
|
802
934
|
}
|
|
803
935
|
setState("encoding");
|
|
804
|
-
setPhase("Finalizing video\u2026");
|
|
936
|
+
setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
|
|
805
937
|
setProgress(95);
|
|
806
938
|
let outputBytes = await encoder.finalize();
|
|
807
939
|
encoderRef.current = null;
|
|
808
940
|
if (cancelledRef.current) return;
|
|
809
|
-
if (
|
|
941
|
+
if (effectiveOutputFormat === "gif") {
|
|
942
|
+
setPhase("Generating GIF palette\u2026");
|
|
943
|
+
const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
944
|
+
const gifAbort = new AbortController();
|
|
945
|
+
gifAbortRef.current = gifAbort;
|
|
946
|
+
try {
|
|
947
|
+
outputBytes = await transcodeMp4ToGifWithFfmpegWasm(
|
|
948
|
+
videoOnly,
|
|
949
|
+
{ width, height, loop: 0 },
|
|
950
|
+
config.ffmpegWasm,
|
|
951
|
+
gifAbort.signal
|
|
952
|
+
);
|
|
953
|
+
} finally {
|
|
954
|
+
if (gifAbortRef.current === gifAbort) gifAbortRef.current = null;
|
|
955
|
+
}
|
|
956
|
+
} else if (useFfmpegAudio && renderedAudio) {
|
|
810
957
|
setPhase("Muxing audio\u2026");
|
|
811
958
|
try {
|
|
812
959
|
const wav = audioBufferToWav(renderedAudio);
|
|
813
960
|
const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
|
|
814
|
-
outputBytes = await muxAudioWithFfmpegWasm(
|
|
961
|
+
outputBytes = await muxAudioWithFfmpegWasm(
|
|
962
|
+
videoOnly,
|
|
963
|
+
wav,
|
|
964
|
+
audioBitrate,
|
|
965
|
+
config.ffmpegWasm
|
|
966
|
+
);
|
|
815
967
|
audioIncludedLocal = true;
|
|
816
968
|
} catch (audioErr) {
|
|
817
969
|
audioIncludedLocal = false;
|
|
@@ -820,13 +972,16 @@ function useVideoExport() {
|
|
|
820
972
|
}
|
|
821
973
|
if (cancelledRef.current) return;
|
|
822
974
|
const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
|
|
823
|
-
const
|
|
975
|
+
const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
|
|
976
|
+
const blob = new Blob([finalBytes], { type: mimeType });
|
|
824
977
|
const url = URL.createObjectURL(blob);
|
|
825
978
|
downloadUrlRef.current = url;
|
|
826
979
|
setDownloadUrl(url);
|
|
827
980
|
setFileSize(finalBytes.byteLength);
|
|
828
981
|
setAudioIncluded(audioIncludedLocal);
|
|
829
|
-
setAudioSkippedReason(
|
|
982
|
+
setAudioSkippedReason(
|
|
983
|
+
effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
|
|
984
|
+
);
|
|
830
985
|
setState("complete");
|
|
831
986
|
setProgress(100);
|
|
832
987
|
setPhase("Export complete");
|
|
@@ -844,6 +999,8 @@ function useVideoExport() {
|
|
|
844
999
|
encoderRef.current.close();
|
|
845
1000
|
encoderRef.current = null;
|
|
846
1001
|
}
|
|
1002
|
+
gifAbortRef.current?.abort();
|
|
1003
|
+
gifAbortRef.current = null;
|
|
847
1004
|
frameCapture.destroy();
|
|
848
1005
|
}
|
|
849
1006
|
},
|
|
@@ -854,6 +1011,7 @@ function useVideoExport() {
|
|
|
854
1011
|
progress,
|
|
855
1012
|
phase,
|
|
856
1013
|
duration,
|
|
1014
|
+
outputFormat,
|
|
857
1015
|
backend,
|
|
858
1016
|
downloadUrl,
|
|
859
1017
|
fileSize,
|
|
@@ -876,48 +1034,77 @@ function formatDuration(seconds) {
|
|
|
876
1034
|
const s = seconds % 60;
|
|
877
1035
|
return `${m}m ${s}s`;
|
|
878
1036
|
}
|
|
1037
|
+
function encoderLabel(outputFormat, backend) {
|
|
1038
|
+
if (outputFormat === "gif") {
|
|
1039
|
+
return backend === "webcodecs" ? "WebCodecs (H.264) \u2192 ffmpeg.wasm (GIF)" : "ffmpeg.wasm (H.264 \u2192 GIF)";
|
|
1040
|
+
}
|
|
1041
|
+
return backend === "webcodecs" ? "WebCodecs (H.264)" : "ffmpeg.wasm (H.264)";
|
|
1042
|
+
}
|
|
1043
|
+
var VIDEO_EXPORT_PALETTES = {
|
|
1044
|
+
light: {
|
|
1045
|
+
overlay: "rgba(0, 0, 0, 0.5)",
|
|
1046
|
+
surface: "#FFFDF7",
|
|
1047
|
+
control: "#ffffff",
|
|
1048
|
+
border: "#c9b98a",
|
|
1049
|
+
text: "#4a3c1f",
|
|
1050
|
+
heading: "#2d2310",
|
|
1051
|
+
label: "#5a4a2a",
|
|
1052
|
+
muted: "#8a7a5a",
|
|
1053
|
+
secondary: "#E8DFC6",
|
|
1054
|
+
primary: "#8B6914",
|
|
1055
|
+
primaryBorder: "#7a5c10",
|
|
1056
|
+
success: "#2d6a10",
|
|
1057
|
+
danger: "#a03020"
|
|
1058
|
+
},
|
|
1059
|
+
dark: {
|
|
1060
|
+
overlay: "rgba(2, 6, 23, 0.72)",
|
|
1061
|
+
surface: "#111827",
|
|
1062
|
+
control: "#0f172a",
|
|
1063
|
+
border: "#475569",
|
|
1064
|
+
text: "#e5e7eb",
|
|
1065
|
+
heading: "#f8fafc",
|
|
1066
|
+
label: "#cbd5e1",
|
|
1067
|
+
muted: "#94a3b8",
|
|
1068
|
+
secondary: "#1e293b",
|
|
1069
|
+
primary: "#9a7416",
|
|
1070
|
+
primaryBorder: "#d1a73b",
|
|
1071
|
+
success: "#86efac",
|
|
1072
|
+
danger: "#fca5a5"
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
879
1075
|
var overlayStyle = {
|
|
880
1076
|
position: "fixed",
|
|
881
1077
|
inset: 0,
|
|
882
|
-
background: "rgba(0, 0, 0, 0.5)",
|
|
883
1078
|
display: "flex",
|
|
884
1079
|
alignItems: "center",
|
|
885
1080
|
justifyContent: "center",
|
|
886
1081
|
zIndex: 1e4
|
|
887
1082
|
};
|
|
888
1083
|
var modalStyle = {
|
|
889
|
-
background: "#FFFDF7",
|
|
890
|
-
border: "1px solid #c9b98a",
|
|
891
1084
|
borderRadius: 0,
|
|
892
1085
|
padding: "24px 28px",
|
|
893
1086
|
minWidth: 380,
|
|
894
1087
|
maxWidth: 480,
|
|
895
1088
|
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
|
896
|
-
fontFamily: "system-ui, -apple-system, sans-serif"
|
|
897
|
-
color: "#4a3c1f"
|
|
1089
|
+
fontFamily: "system-ui, -apple-system, sans-serif"
|
|
898
1090
|
};
|
|
899
1091
|
var titleStyle = {
|
|
900
1092
|
margin: "0 0 16px 0",
|
|
901
1093
|
fontSize: 18,
|
|
902
|
-
fontWeight: 600
|
|
903
|
-
color: "#2d2310"
|
|
1094
|
+
fontWeight: 600
|
|
904
1095
|
};
|
|
905
1096
|
var labelStyle = {
|
|
906
1097
|
display: "block",
|
|
907
1098
|
fontSize: 13,
|
|
908
1099
|
fontWeight: 500,
|
|
909
|
-
marginBottom: 4
|
|
910
|
-
color: "#5a4a2a"
|
|
1100
|
+
marginBottom: 4
|
|
911
1101
|
};
|
|
912
1102
|
var selectStyle = {
|
|
913
1103
|
width: "100%",
|
|
914
1104
|
padding: "6px 8px",
|
|
915
1105
|
fontSize: 13,
|
|
916
1106
|
fontFamily: "inherit",
|
|
917
|
-
border: "1px solid #c9b98a",
|
|
918
1107
|
borderRadius: 0,
|
|
919
|
-
background: "#fff",
|
|
920
|
-
color: "#4a3c1f",
|
|
921
1108
|
marginBottom: 12
|
|
922
1109
|
};
|
|
923
1110
|
var btnPrimary = {
|
|
@@ -926,9 +1113,7 @@ var btnPrimary = {
|
|
|
926
1113
|
fontFamily: "inherit",
|
|
927
1114
|
fontWeight: 500,
|
|
928
1115
|
cursor: "pointer",
|
|
929
|
-
background: "#8B6914",
|
|
930
1116
|
color: "#fff",
|
|
931
|
-
border: "1px solid #7a5c10",
|
|
932
1117
|
borderRadius: 0
|
|
933
1118
|
};
|
|
934
1119
|
var btnSecondary = {
|
|
@@ -937,15 +1122,11 @@ var btnSecondary = {
|
|
|
937
1122
|
fontFamily: "inherit",
|
|
938
1123
|
fontWeight: 500,
|
|
939
1124
|
cursor: "pointer",
|
|
940
|
-
background: "#E8DFC6",
|
|
941
|
-
color: "#4a3c1f",
|
|
942
|
-
border: "1px solid #c9b98a",
|
|
943
1125
|
borderRadius: 0
|
|
944
1126
|
};
|
|
945
1127
|
var progressBarOuterStyle = {
|
|
946
1128
|
width: "100%",
|
|
947
1129
|
height: 8,
|
|
948
|
-
background: "#E8DFC6",
|
|
949
1130
|
borderRadius: 0,
|
|
950
1131
|
overflow: "hidden",
|
|
951
1132
|
marginBottom: 8
|
|
@@ -963,19 +1144,54 @@ function VideoExportModal({
|
|
|
963
1144
|
images,
|
|
964
1145
|
audio,
|
|
965
1146
|
defaultConfig,
|
|
1147
|
+
colorScheme = "light",
|
|
966
1148
|
onClose
|
|
967
1149
|
}) {
|
|
1150
|
+
const initialOutputFormat = defaultConfig?.outputFormat ?? "mp4";
|
|
1151
|
+
const [outputFormat, setOutputFormat] = useState2(initialOutputFormat);
|
|
968
1152
|
const [quality, setQuality] = useState2(defaultConfig?.quality ?? "normal");
|
|
969
|
-
const [fps, setFps] = useState2(defaultConfig?.fps ?? 24);
|
|
1153
|
+
const [fps, setFps] = useState2(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 : 24));
|
|
970
1154
|
const [orientation, setOrientation] = useState2(
|
|
971
1155
|
defaultConfig?.orientation ?? "landscape"
|
|
972
1156
|
);
|
|
973
1157
|
const [captionMode, setCaptionMode] = useState2(defaultConfig?.captionMode ?? "off");
|
|
1158
|
+
const [animationsEnabled, setAnimationsEnabled] = useState2(
|
|
1159
|
+
defaultConfig?.animationsEnabled ?? initialOutputFormat === "mp4"
|
|
1160
|
+
);
|
|
1161
|
+
const palette = VIDEO_EXPORT_PALETTES[colorScheme];
|
|
1162
|
+
const themedModalStyle = {
|
|
1163
|
+
...modalStyle,
|
|
1164
|
+
background: palette.surface,
|
|
1165
|
+
border: `1px solid ${palette.border}`,
|
|
1166
|
+
color: palette.text,
|
|
1167
|
+
colorScheme
|
|
1168
|
+
};
|
|
1169
|
+
const themedTitleStyle = { ...titleStyle, color: palette.heading };
|
|
1170
|
+
const themedLabelStyle = { ...labelStyle, color: palette.label };
|
|
1171
|
+
const themedSelectStyle = {
|
|
1172
|
+
...selectStyle,
|
|
1173
|
+
border: `1px solid ${palette.border}`,
|
|
1174
|
+
background: palette.control,
|
|
1175
|
+
color: palette.text,
|
|
1176
|
+
colorScheme
|
|
1177
|
+
};
|
|
1178
|
+
const themedPrimaryButtonStyle = {
|
|
1179
|
+
...btnPrimary,
|
|
1180
|
+
background: palette.primary,
|
|
1181
|
+
border: `1px solid ${palette.primaryBorder}`
|
|
1182
|
+
};
|
|
1183
|
+
const themedSecondaryButtonStyle = {
|
|
1184
|
+
...btnSecondary,
|
|
1185
|
+
background: palette.secondary,
|
|
1186
|
+
color: palette.text,
|
|
1187
|
+
border: `1px solid ${palette.border}`
|
|
1188
|
+
};
|
|
974
1189
|
const exportHook = useVideoExport();
|
|
975
1190
|
const {
|
|
976
1191
|
state,
|
|
977
1192
|
progress,
|
|
978
1193
|
backend,
|
|
1194
|
+
outputFormat: completedOutputFormat,
|
|
979
1195
|
downloadUrl,
|
|
980
1196
|
fileSize,
|
|
981
1197
|
audioIncluded,
|
|
@@ -987,10 +1203,22 @@ function VideoExportModal({
|
|
|
987
1203
|
cancel: cancelExport,
|
|
988
1204
|
reset: resetExport
|
|
989
1205
|
} = exportHook;
|
|
1206
|
+
const handleOutputFormatChange = useCallback3((next) => {
|
|
1207
|
+
setOutputFormat(next);
|
|
1208
|
+
if (next === "gif") {
|
|
1209
|
+
setFps(10);
|
|
1210
|
+
setAnimationsEnabled(false);
|
|
1211
|
+
} else {
|
|
1212
|
+
setFps(24);
|
|
1213
|
+
setAnimationsEnabled(true);
|
|
1214
|
+
}
|
|
1215
|
+
}, []);
|
|
990
1216
|
const handleExport = useCallback3(async () => {
|
|
991
1217
|
const config = {
|
|
992
1218
|
// defaultConfig is the base; explicit props/selections win over it.
|
|
993
1219
|
...defaultConfig,
|
|
1220
|
+
outputFormat,
|
|
1221
|
+
animationsEnabled,
|
|
994
1222
|
quality,
|
|
995
1223
|
fps,
|
|
996
1224
|
orientation,
|
|
@@ -1004,6 +1232,8 @@ function VideoExportModal({
|
|
|
1004
1232
|
await startExport(doc, config);
|
|
1005
1233
|
}, [
|
|
1006
1234
|
doc,
|
|
1235
|
+
outputFormat,
|
|
1236
|
+
animationsEnabled,
|
|
1007
1237
|
quality,
|
|
1008
1238
|
fps,
|
|
1009
1239
|
orientation,
|
|
@@ -1020,11 +1250,11 @@ function VideoExportModal({
|
|
|
1020
1250
|
const a = document.createElement("a");
|
|
1021
1251
|
a.href = downloadUrl;
|
|
1022
1252
|
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1023
|
-
a.download = `document-${ts}
|
|
1253
|
+
a.download = `document-${ts}.${completedOutputFormat}`;
|
|
1024
1254
|
document.body.appendChild(a);
|
|
1025
1255
|
a.click();
|
|
1026
1256
|
document.body.removeChild(a);
|
|
1027
|
-
}, [downloadUrl]);
|
|
1257
|
+
}, [downloadUrl, completedOutputFormat]);
|
|
1028
1258
|
const handleClose = useCallback3(() => {
|
|
1029
1259
|
if (state === "capturing" || state === "encoding" || state === "preparing") {
|
|
1030
1260
|
cancelExport();
|
|
@@ -1033,138 +1263,204 @@ function VideoExportModal({
|
|
|
1033
1263
|
onClose();
|
|
1034
1264
|
}, [state, cancelExport, resetExport, onClose]);
|
|
1035
1265
|
const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
|
|
1036
|
-
return /* @__PURE__ */ jsx(
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
children:
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1266
|
+
return /* @__PURE__ */ jsx(
|
|
1267
|
+
"div",
|
|
1268
|
+
{
|
|
1269
|
+
style: { ...overlayStyle, background: palette.overlay },
|
|
1270
|
+
"data-color-scheme": colorScheme,
|
|
1271
|
+
onClick: handleClose,
|
|
1272
|
+
children: /* @__PURE__ */ jsxs("div", { style: themedModalStyle, onClick: (e) => e.stopPropagation(), children: [
|
|
1273
|
+
/* @__PURE__ */ jsx("h2", { style: themedTitleStyle, children: outputFormat === "gif" ? "Export Animated GIF" : "Export Video" }),
|
|
1274
|
+
state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1275
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1276
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Format" }),
|
|
1277
|
+
/* @__PURE__ */ jsxs(
|
|
1278
|
+
"select",
|
|
1279
|
+
{
|
|
1280
|
+
"aria-label": "Format",
|
|
1281
|
+
style: themedSelectStyle,
|
|
1282
|
+
value: outputFormat,
|
|
1283
|
+
onChange: (e) => handleOutputFormatChange(e.target.value),
|
|
1284
|
+
children: [
|
|
1285
|
+
/* @__PURE__ */ jsx("option", { value: "mp4", children: "MP4 video" }),
|
|
1286
|
+
/* @__PURE__ */ jsx("option", { value: "gif", children: "Animated GIF" })
|
|
1287
|
+
]
|
|
1288
|
+
}
|
|
1289
|
+
)
|
|
1290
|
+
] }),
|
|
1291
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1292
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Quality" }),
|
|
1293
|
+
/* @__PURE__ */ jsxs(
|
|
1294
|
+
"select",
|
|
1295
|
+
{
|
|
1296
|
+
"aria-label": "Quality",
|
|
1297
|
+
style: themedSelectStyle,
|
|
1298
|
+
value: quality,
|
|
1299
|
+
onChange: (e) => setQuality(e.target.value),
|
|
1300
|
+
children: [
|
|
1301
|
+
/* @__PURE__ */ jsx("option", { value: "draft", children: "Draft \u2014 fast, lower quality" }),
|
|
1302
|
+
/* @__PURE__ */ jsx("option", { value: "normal", children: "Normal \u2014 balanced" }),
|
|
1303
|
+
/* @__PURE__ */ jsx("option", { value: "high", children: "High \u2014 best quality, slower" })
|
|
1304
|
+
]
|
|
1305
|
+
}
|
|
1306
|
+
)
|
|
1307
|
+
] }),
|
|
1308
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1309
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Frame Rate" }),
|
|
1310
|
+
/* @__PURE__ */ jsxs(
|
|
1311
|
+
"select",
|
|
1312
|
+
{
|
|
1313
|
+
"aria-label": "Frame Rate",
|
|
1314
|
+
style: themedSelectStyle,
|
|
1315
|
+
value: fps,
|
|
1316
|
+
onChange: (e) => setFps(Number(e.target.value)),
|
|
1317
|
+
children: [
|
|
1318
|
+
/* @__PURE__ */ jsx("option", { value: 10, children: "10 fps \u2014 recommended for GIF" }),
|
|
1319
|
+
/* @__PURE__ */ jsx("option", { value: 15, children: "15 fps \u2014 fast export" }),
|
|
1320
|
+
/* @__PURE__ */ jsx("option", { value: 24, children: "24 fps \u2014 cinematic" }),
|
|
1321
|
+
/* @__PURE__ */ jsx("option", { value: 30, children: "30 fps \u2014 smooth" })
|
|
1322
|
+
]
|
|
1323
|
+
}
|
|
1324
|
+
)
|
|
1325
|
+
] }),
|
|
1326
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1327
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Orientation" }),
|
|
1328
|
+
/* @__PURE__ */ jsxs(
|
|
1329
|
+
"select",
|
|
1330
|
+
{
|
|
1331
|
+
"aria-label": "Orientation",
|
|
1332
|
+
style: themedSelectStyle,
|
|
1333
|
+
value: orientation,
|
|
1334
|
+
onChange: (e) => setOrientation(e.target.value),
|
|
1335
|
+
children: [
|
|
1336
|
+
/* @__PURE__ */ jsxs("option", { value: "landscape", children: [
|
|
1337
|
+
"Landscape (",
|
|
1338
|
+
outputFormat === "gif" ? "960 \xD7 540" : "1920 \xD7 1080",
|
|
1339
|
+
")"
|
|
1340
|
+
] }),
|
|
1341
|
+
/* @__PURE__ */ jsxs("option", { value: "portrait", children: [
|
|
1342
|
+
"Portrait (",
|
|
1343
|
+
outputFormat === "gif" ? "540 \xD7 960" : "1080 \xD7 1920",
|
|
1344
|
+
")"
|
|
1345
|
+
] })
|
|
1346
|
+
]
|
|
1347
|
+
}
|
|
1348
|
+
)
|
|
1349
|
+
] }),
|
|
1350
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1351
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Captions" }),
|
|
1352
|
+
/* @__PURE__ */ jsxs(
|
|
1353
|
+
"select",
|
|
1354
|
+
{
|
|
1355
|
+
"aria-label": "Captions",
|
|
1356
|
+
style: themedSelectStyle,
|
|
1357
|
+
value: captionMode,
|
|
1358
|
+
onChange: (e) => setCaptionMode(e.target.value),
|
|
1359
|
+
children: [
|
|
1360
|
+
/* @__PURE__ */ jsx("option", { value: "off", children: "None" }),
|
|
1361
|
+
/* @__PURE__ */ jsx("option", { value: "standard", children: "Standard (top bar)" }),
|
|
1362
|
+
/* @__PURE__ */ jsx("option", { value: "social", children: "Social media (large words)" })
|
|
1363
|
+
]
|
|
1364
|
+
}
|
|
1365
|
+
)
|
|
1366
|
+
] }),
|
|
1367
|
+
/* @__PURE__ */ jsxs("div", { children: [
|
|
1368
|
+
/* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Animations & transitions" }),
|
|
1369
|
+
/* @__PURE__ */ jsxs(
|
|
1370
|
+
"select",
|
|
1371
|
+
{
|
|
1372
|
+
"aria-label": "Animations and transitions",
|
|
1373
|
+
style: themedSelectStyle,
|
|
1374
|
+
value: animationsEnabled ? "enabled" : "disabled",
|
|
1375
|
+
onChange: (e) => setAnimationsEnabled(e.target.value === "enabled"),
|
|
1376
|
+
children: [
|
|
1377
|
+
/* @__PURE__ */ jsx("option", { value: "enabled", children: "Enabled" }),
|
|
1378
|
+
/* @__PURE__ */ jsx("option", { value: "disabled", children: "Disabled \u2014 smaller files" })
|
|
1379
|
+
]
|
|
1380
|
+
}
|
|
1381
|
+
),
|
|
1382
|
+
outputFormat === "gif" && animationsEnabled && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: palette.muted, margin: "-6px 0 12px" }, children: "Disabling motion is recommended for much smaller GIFs." })
|
|
1383
|
+
] }),
|
|
1384
|
+
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
1385
|
+
/* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Cancel" }),
|
|
1386
|
+
/* @__PURE__ */ jsx("button", { style: themedPrimaryButtonStyle, onClick: handleExport, children: outputFormat === "gif" ? "Export GIF" : "Export Video" })
|
|
1387
|
+
] })
|
|
1388
|
+
] }),
|
|
1389
|
+
isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1390
|
+
backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 8px 0" }, children: [
|
|
1391
|
+
"Encoder: ",
|
|
1392
|
+
encoderLabel(completedOutputFormat, backend)
|
|
1393
|
+
] }),
|
|
1394
|
+
/* @__PURE__ */ jsx("div", { style: { ...progressBarOuterStyle, background: palette.secondary }, children: /* @__PURE__ */ jsx(
|
|
1395
|
+
"div",
|
|
1396
|
+
{
|
|
1397
|
+
style: {
|
|
1398
|
+
width: `${progress}%`,
|
|
1399
|
+
height: "100%",
|
|
1400
|
+
background: palette.primary,
|
|
1401
|
+
transition: "width 0.3s ease"
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
) }),
|
|
1405
|
+
/* @__PURE__ */ jsxs("p", { style: { fontSize: 13, margin: "0 0 4px 0" }, children: [
|
|
1406
|
+
progress,
|
|
1407
|
+
"% complete"
|
|
1408
|
+
] }),
|
|
1409
|
+
/* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: 0 }, children: [
|
|
1410
|
+
formatDuration(elapsed),
|
|
1411
|
+
" elapsed",
|
|
1412
|
+
estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
|
|
1413
|
+
] }),
|
|
1414
|
+
/* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: cancelExport, children: "Cancel" }) })
|
|
1415
|
+
] }),
|
|
1416
|
+
state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1417
|
+
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.success }, children: "Export complete!" }),
|
|
1418
|
+
/* @__PURE__ */ jsxs("p", { style: { fontSize: 13, color: palette.label, margin: "0 0 4px 0" }, children: [
|
|
1419
|
+
"File size: ",
|
|
1420
|
+
(fileSize / (1024 * 1024)).toFixed(1),
|
|
1421
|
+
" MB"
|
|
1422
|
+
] }),
|
|
1423
|
+
completedOutputFormat === "gif" ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 4px 0" }, children: "Animated GIF does not include audio." }) : audioIncluded ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: palette.success, margin: "0 0 4px 0" }, children: "Audio included \u2713" }) : /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 4px 0" }, children: [
|
|
1424
|
+
"Video only",
|
|
1425
|
+
audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
|
|
1426
|
+
] }),
|
|
1427
|
+
backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 12px 0" }, children: [
|
|
1428
|
+
"Encoded with ",
|
|
1429
|
+
encoderLabel(completedOutputFormat, backend)
|
|
1430
|
+
] }),
|
|
1431
|
+
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
1432
|
+
/* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
|
|
1433
|
+
/* @__PURE__ */ jsxs("button", { style: themedPrimaryButtonStyle, onClick: handleDownload, children: [
|
|
1434
|
+
"Download ",
|
|
1435
|
+
completedOutputFormat.toUpperCase()
|
|
1436
|
+
] })
|
|
1437
|
+
] })
|
|
1438
|
+
] }),
|
|
1439
|
+
state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1440
|
+
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.danger }, children: "Export failed" }),
|
|
1441
|
+
/* @__PURE__ */ jsx(
|
|
1442
|
+
"p",
|
|
1443
|
+
{
|
|
1444
|
+
style: {
|
|
1445
|
+
fontSize: 13,
|
|
1446
|
+
color: palette.label,
|
|
1447
|
+
margin: "0 0 12px 0",
|
|
1448
|
+
wordBreak: "break-word"
|
|
1449
|
+
},
|
|
1450
|
+
children: error
|
|
1451
|
+
}
|
|
1452
|
+
),
|
|
1453
|
+
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
1454
|
+
/* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
|
|
1455
|
+
/* @__PURE__ */ jsxs("button", { style: themedPrimaryButtonStyle, onClick: handleExport, children: [
|
|
1456
|
+
"Retry ",
|
|
1457
|
+
outputFormat === "gif" ? "GIF" : "video"
|
|
1458
|
+
] })
|
|
1459
|
+
] })
|
|
1460
|
+
] })
|
|
1165
1461
|
] })
|
|
1166
|
-
|
|
1167
|
-
|
|
1462
|
+
}
|
|
1463
|
+
);
|
|
1168
1464
|
}
|
|
1169
1465
|
|
|
1170
1466
|
// src/VideoExportButton.tsx
|
|
@@ -1178,15 +1474,17 @@ function VideoExportButton({
|
|
|
1178
1474
|
images,
|
|
1179
1475
|
audio,
|
|
1180
1476
|
defaultConfig,
|
|
1181
|
-
|
|
1477
|
+
colorScheme,
|
|
1478
|
+
label,
|
|
1182
1479
|
style,
|
|
1183
1480
|
disabled
|
|
1184
1481
|
}) {
|
|
1185
1482
|
const [showModal, setShowModal] = useState3(false);
|
|
1483
|
+
const resolvedLabel = label ?? (defaultConfig?.outputFormat === "gif" ? "Export GIF" : "Export Video");
|
|
1186
1484
|
const handleOpen = useCallback4(() => setShowModal(true), []);
|
|
1187
1485
|
const handleClose = useCallback4(() => setShowModal(false), []);
|
|
1188
1486
|
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1189
|
-
/* @__PURE__ */ jsx2("button", { onClick: handleOpen, disabled, style, children:
|
|
1487
|
+
/* @__PURE__ */ jsx2("button", { onClick: handleOpen, disabled, style, children: resolvedLabel }),
|
|
1190
1488
|
showModal && createPortal(
|
|
1191
1489
|
/* @__PURE__ */ jsx2(
|
|
1192
1490
|
VideoExportModal,
|
|
@@ -1197,6 +1495,7 @@ function VideoExportButton({
|
|
|
1197
1495
|
images,
|
|
1198
1496
|
audio,
|
|
1199
1497
|
defaultConfig,
|
|
1498
|
+
colorScheme,
|
|
1200
1499
|
onClose: handleClose
|
|
1201
1500
|
}
|
|
1202
1501
|
),
|