@bendyline/squisq-video-react 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
- return;
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
- if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
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 closed = false;
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 (closed || fatalError) {
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 (closed) throw new Error("Encoder already closed");
205
+ if (state !== "open") throw new Error("Encoder already closed or finalizing");
181
206
  if (fatalError) throw fatalError;
182
- closed = true;
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
- closed = true;
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
- "-c:a",
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 w = window;
479
- const hasSeek = typeof w.seekTo === "function";
480
- const hasDur = typeof w.getDuration === "function";
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
- const checkApi = () => {
490
- const w = window;
491
- if (typeof w.getDuration === "function" && typeof w.seekTo === "function") {
492
- clearTimeout(timeout);
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 w = window;
507
- if (!container || typeof w.seekTo !== "function") {
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 w.seekTo(time);
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 fps = config.fps ?? 30;
754
+ const effectiveOutputFormat = config.outputFormat ?? "mp4";
755
+ const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
644
756
  const orientation = config.orientation ?? "landscape";
645
- const { width, height } = resolveDimensions({ orientation });
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({ width, height, fps, quality });
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 (useFfmpegAudio && renderedAudio) {
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(videoOnly, wav, audioBitrate);
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 blob = new Blob([finalBytes], { type: "video/mp4" });
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(audioIncludedLocal ? null : audioReasonLocal);
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}.mp4`;
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("div", { style: overlayStyle, onClick: handleClose, children: /* @__PURE__ */ jsxs("div", { style: modalStyle, onClick: (e) => e.stopPropagation(), children: [
1037
- /* @__PURE__ */ jsx("h2", { style: titleStyle, children: "Export Video" }),
1038
- state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
1039
- /* @__PURE__ */ jsxs("div", { children: [
1040
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Quality" }),
1041
- /* @__PURE__ */ jsxs(
1042
- "select",
1043
- {
1044
- style: selectStyle,
1045
- value: quality,
1046
- onChange: (e) => setQuality(e.target.value),
1047
- children: [
1048
- /* @__PURE__ */ jsx("option", { value: "draft", children: "Draft \u2014 fast, lower quality" }),
1049
- /* @__PURE__ */ jsx("option", { value: "normal", children: "Normal \u2014 balanced" }),
1050
- /* @__PURE__ */ jsx("option", { value: "high", children: "High \u2014 best quality, slower" })
1051
- ]
1052
- }
1053
- )
1054
- ] }),
1055
- /* @__PURE__ */ jsxs("div", { children: [
1056
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Frame Rate" }),
1057
- /* @__PURE__ */ jsxs(
1058
- "select",
1059
- {
1060
- style: selectStyle,
1061
- value: fps,
1062
- onChange: (e) => setFps(Number(e.target.value)),
1063
- children: [
1064
- /* @__PURE__ */ jsx("option", { value: 15, children: "15 fps \u2014 fast export" }),
1065
- /* @__PURE__ */ jsx("option", { value: 24, children: "24 fps \u2014 cinematic" }),
1066
- /* @__PURE__ */ jsx("option", { value: 30, children: "30 fps \u2014 smooth" })
1067
- ]
1068
- }
1069
- )
1070
- ] }),
1071
- /* @__PURE__ */ jsxs("div", { children: [
1072
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Orientation" }),
1073
- /* @__PURE__ */ jsxs(
1074
- "select",
1075
- {
1076
- style: selectStyle,
1077
- value: orientation,
1078
- onChange: (e) => setOrientation(e.target.value),
1079
- children: [
1080
- /* @__PURE__ */ jsx("option", { value: "landscape", children: "Landscape (1920 \xD7 1080)" }),
1081
- /* @__PURE__ */ jsx("option", { value: "portrait", children: "Portrait (1080 \xD7 1920)" })
1082
- ]
1083
- }
1084
- )
1085
- ] }),
1086
- /* @__PURE__ */ jsxs("div", { children: [
1087
- /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Captions" }),
1088
- /* @__PURE__ */ jsxs(
1089
- "select",
1090
- {
1091
- style: selectStyle,
1092
- value: captionMode,
1093
- onChange: (e) => setCaptionMode(e.target.value),
1094
- children: [
1095
- /* @__PURE__ */ jsx("option", { value: "off", children: "None" }),
1096
- /* @__PURE__ */ jsx("option", { value: "standard", children: "Standard (top bar)" }),
1097
- /* @__PURE__ */ jsx("option", { value: "social", children: "Social media (large words)" })
1098
- ]
1099
- }
1100
- )
1101
- ] }),
1102
- /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
1103
- /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Cancel" }),
1104
- /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Export Video" })
1105
- ] })
1106
- ] }),
1107
- isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
1108
- backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 8px 0" }, children: "Encoder: WebCodecs (H.264)" }),
1109
- /* @__PURE__ */ jsx("div", { style: progressBarOuterStyle, children: /* @__PURE__ */ jsx(
1110
- "div",
1111
- {
1112
- style: {
1113
- width: `${progress}%`,
1114
- height: "100%",
1115
- background: "#8B6914",
1116
- transition: "width 0.3s ease"
1117
- }
1118
- }
1119
- ) }),
1120
- /* @__PURE__ */ jsxs("p", { style: { fontSize: 13, margin: "0 0 4px 0" }, children: [
1121
- progress,
1122
- "% complete"
1123
- ] }),
1124
- /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: 0 }, children: [
1125
- formatDuration(elapsed),
1126
- " elapsed",
1127
- estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
1128
- ] }),
1129
- /* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: cancelExport, children: "Cancel" }) })
1130
- ] }),
1131
- state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
1132
- /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#2d6a10" }, children: "Export complete!" }),
1133
- /* @__PURE__ */ jsxs("p", { style: { fontSize: 13, color: "#5a4a2a", margin: "0 0 4px 0" }, children: [
1134
- "File size: ",
1135
- (fileSize / (1024 * 1024)).toFixed(1),
1136
- " MB"
1137
- ] }),
1138
- audioIncluded ? /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#2d6a10", margin: "0 0 4px 0" }, children: "Audio included \u2713" }) : /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 4px 0" }, children: [
1139
- "Video only",
1140
- audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
1141
- ] }),
1142
- backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 12px 0" }, children: "Encoded with WebCodecs (H.264)" }),
1143
- /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
1144
- /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
1145
- /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleDownload, children: "Download MP4" })
1146
- ] })
1147
- ] }),
1148
- state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
1149
- /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#a03020" }, children: "Export failed" }),
1150
- /* @__PURE__ */ jsx(
1151
- "p",
1152
- {
1153
- style: {
1154
- fontSize: 13,
1155
- color: "#5a4a2a",
1156
- margin: "0 0 12px 0",
1157
- wordBreak: "break-word"
1158
- },
1159
- children: error
1160
- }
1161
- ),
1162
- /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
1163
- /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
1164
- /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Retry" })
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
- label = "Export Video",
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: label }),
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
  ),