@bendyline/squisq-video-react 1.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 ADDED
@@ -0,0 +1,816 @@
1
+ // src/VideoExportModal.tsx
2
+ import { useState as useState2, useCallback as useCallback3 } from "react";
3
+
4
+ // src/hooks/useVideoExport.ts
5
+ import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } from "react";
6
+ import { resolveDimensions } from "@bendyline/squisq-video";
7
+
8
+ // src/mp4Mux.ts
9
+ import { Muxer, ArrayBufferTarget } from "mp4-muxer";
10
+ function createMp4Muxer(options) {
11
+ const target = new ArrayBufferTarget();
12
+ const muxer = new Muxer({
13
+ target,
14
+ video: {
15
+ codec: "avc",
16
+ width: options.width,
17
+ height: options.height
18
+ },
19
+ fastStart: "in-memory"
20
+ });
21
+ return {
22
+ addVideoChunk(chunk, meta) {
23
+ muxer.addVideoChunk(chunk, meta);
24
+ },
25
+ finalize() {
26
+ muxer.finalize();
27
+ return target.buffer;
28
+ }
29
+ };
30
+ }
31
+
32
+ // src/mainThreadEncoder.ts
33
+ function supportsWebCodecs() {
34
+ return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
35
+ }
36
+ function bitrateForQuality(quality, width, height) {
37
+ const pixels = width * height;
38
+ const baseBitrate = pixels * 4;
39
+ switch (quality) {
40
+ case "draft":
41
+ return Math.round(baseBitrate * 0.5);
42
+ case "high":
43
+ return Math.round(baseBitrate * 2);
44
+ default:
45
+ return baseBitrate;
46
+ }
47
+ }
48
+ function createEncoder(config) {
49
+ if (!supportsWebCodecs()) {
50
+ throw new Error(
51
+ "WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
52
+ );
53
+ }
54
+ if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
55
+ throw new Error(
56
+ `Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`
57
+ );
58
+ }
59
+ const muxer = createMp4Muxer({
60
+ width: config.width,
61
+ height: config.height,
62
+ fps: config.fps
63
+ });
64
+ let closed = false;
65
+ const frameDuration = 1e6 / config.fps;
66
+ const encoder = new VideoEncoder({
67
+ output(chunk, meta) {
68
+ if (closed) return;
69
+ muxer.addVideoChunk(chunk, meta ?? void 0);
70
+ },
71
+ error(err) {
72
+ console.error("WebCodecs encoder error:", err.message);
73
+ }
74
+ });
75
+ encoder.configure({
76
+ codec: "avc1.640028",
77
+ // H.264 High profile, level 4.0 (supports up to 1080p)
78
+ width: config.width,
79
+ height: config.height,
80
+ bitrate: bitrateForQuality(config.quality, config.width, config.height),
81
+ framerate: config.fps
82
+ });
83
+ return {
84
+ encodeFrame(bitmap, frameIndex) {
85
+ if (closed) {
86
+ bitmap.close();
87
+ return;
88
+ }
89
+ const timestamp = Math.round(frameIndex * frameDuration);
90
+ const frame = new VideoFrame(bitmap, { timestamp });
91
+ const keyFrame = frameIndex % 30 === 0;
92
+ encoder.encode(frame, { keyFrame });
93
+ frame.close();
94
+ bitmap.close();
95
+ },
96
+ async finalize() {
97
+ if (closed) throw new Error("Encoder already closed");
98
+ await encoder.flush();
99
+ encoder.close();
100
+ closed = true;
101
+ return muxer.finalize();
102
+ },
103
+ close() {
104
+ if (closed) return;
105
+ closed = true;
106
+ if (encoder.state !== "closed") {
107
+ encoder.close();
108
+ }
109
+ }
110
+ };
111
+ }
112
+
113
+ // src/hooks/useFrameCapture.ts
114
+ import { createElement } from "react";
115
+ import { createRoot } from "react-dom/client";
116
+ import { useRef, useCallback, useMemo } from "react";
117
+ import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
118
+ import html2canvas from "html2canvas";
119
+ var MIME_MAP = {
120
+ jpg: "image/jpeg",
121
+ jpeg: "image/jpeg",
122
+ png: "image/png",
123
+ gif: "image/gif",
124
+ webp: "image/webp",
125
+ svg: "image/svg+xml",
126
+ bmp: "image/bmp",
127
+ avif: "image/avif"
128
+ };
129
+ function arrayBufferToDataUrl(buffer, mime) {
130
+ const bytes = new Uint8Array(buffer);
131
+ const chunks = [];
132
+ const CHUNK = 8192;
133
+ for (let i = 0; i < bytes.length; i += CHUNK) {
134
+ chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
135
+ }
136
+ return `data:${mime};base64,${btoa(chunks.join(""))}`;
137
+ }
138
+ function createInlineProvider(images) {
139
+ const dataUrls = /* @__PURE__ */ new Map();
140
+ const mimeTypes = /* @__PURE__ */ new Map();
141
+ for (const [path, buffer] of images) {
142
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
143
+ const mime = MIME_MAP[ext] ?? "application/octet-stream";
144
+ dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
145
+ mimeTypes.set(path, mime);
146
+ }
147
+ return {
148
+ async resolveUrl(relativePath) {
149
+ return dataUrls.get(relativePath) ?? relativePath;
150
+ },
151
+ async listMedia() {
152
+ return [...dataUrls.keys()].map((name) => ({
153
+ name,
154
+ mimeType: mimeTypes.get(name) ?? "application/octet-stream",
155
+ size: 0
156
+ }));
157
+ },
158
+ async addMedia() {
159
+ throw new Error("Read-only");
160
+ },
161
+ async removeMedia() {
162
+ throw new Error("Read-only");
163
+ },
164
+ dispose() {
165
+ }
166
+ };
167
+ }
168
+ function useFrameCapture() {
169
+ const containerRef = useRef(null);
170
+ const rootRef = useRef(null);
171
+ const dimensionsRef = useRef({ width: 1920, height: 1080 });
172
+ const init = useCallback(
173
+ async (doc, renderOptions, captionMode) => {
174
+ if (rootRef.current || containerRef.current) {
175
+ const oldRoot = rootRef.current;
176
+ const oldContainer = containerRef.current;
177
+ rootRef.current = null;
178
+ containerRef.current = null;
179
+ await new Promise((resolve) => {
180
+ setTimeout(() => {
181
+ if (oldRoot) oldRoot.unmount();
182
+ if (oldContainer) oldContainer.remove();
183
+ resolve();
184
+ }, 0);
185
+ });
186
+ }
187
+ const width = renderOptions.width ?? 1920;
188
+ const height = renderOptions.height ?? 1080;
189
+ dimensionsRef.current = { width, height };
190
+ const container = document.createElement("div");
191
+ 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;`;
192
+ document.body.appendChild(container);
193
+ containerRef.current = container;
194
+ const renderRoot = document.createElement("div");
195
+ renderRoot.id = "squisq-capture-root";
196
+ renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
197
+ container.appendChild(renderRoot);
198
+ const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : null;
199
+ const root = createRoot(renderRoot);
200
+ rootRef.current = root;
201
+ const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
202
+ const captionStyle = captionMode === "social" ? "social" : "standard";
203
+ const playerElement = createElement(DocPlayer, {
204
+ script: doc,
205
+ basePath: ".",
206
+ renderMode: true,
207
+ showControls: false,
208
+ autoPlay: false,
209
+ forceViewport: { width, height, name: "export" },
210
+ captionsEnabled,
211
+ captionStyle
212
+ });
213
+ await new Promise((resolve) => setTimeout(resolve, 0));
214
+ if (mediaProvider) {
215
+ root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
216
+ } else {
217
+ root.render(playerElement);
218
+ }
219
+ return new Promise((resolve, reject) => {
220
+ const timeout = setTimeout(() => {
221
+ const w = window;
222
+ const hasSeek = typeof w.seekTo === "function";
223
+ const hasDur = typeof w.getDuration === "function";
224
+ const rootEl = containerRef.current?.querySelector("#squisq-capture-root");
225
+ const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
226
+ reject(
227
+ new Error(
228
+ `Render API did not initialize within 15s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
229
+ )
230
+ );
231
+ }, 15e3);
232
+ const checkApi = () => {
233
+ const w = window;
234
+ if (typeof w.getDuration === "function" && typeof w.seekTo === "function") {
235
+ clearTimeout(timeout);
236
+ const duration = w.getDuration();
237
+ resolve(duration);
238
+ } else {
239
+ requestAnimationFrame(checkApi);
240
+ }
241
+ };
242
+ setTimeout(checkApi, 500);
243
+ });
244
+ },
245
+ []
246
+ );
247
+ const captureFrame = useCallback(async (time) => {
248
+ const container = containerRef.current;
249
+ const w = window;
250
+ if (!container || typeof w.seekTo !== "function") {
251
+ throw new Error("Frame capture not initialized \u2014 call init() first");
252
+ }
253
+ const { width, height } = dimensionsRef.current;
254
+ await w.seekTo(time);
255
+ await new Promise(
256
+ (resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
257
+ );
258
+ const root = container.querySelector("#squisq-capture-root");
259
+ if (!root) {
260
+ throw new Error("Capture root element not found");
261
+ }
262
+ const canvas = await html2canvas(root, {
263
+ width,
264
+ height,
265
+ scale: 1,
266
+ useCORS: true,
267
+ allowTaint: true,
268
+ backgroundColor: "#000000",
269
+ logging: false
270
+ });
271
+ const bitmap = await createImageBitmap(canvas);
272
+ return bitmap;
273
+ }, []);
274
+ const destroy = useCallback(() => {
275
+ if (rootRef.current) {
276
+ rootRef.current.unmount();
277
+ rootRef.current = null;
278
+ }
279
+ if (containerRef.current) {
280
+ containerRef.current.remove();
281
+ containerRef.current = null;
282
+ }
283
+ }, []);
284
+ return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
285
+ }
286
+
287
+ // src/hooks/useVideoExport.ts
288
+ function useVideoExport() {
289
+ const [state, setState] = useState("idle");
290
+ const [progress, setProgress] = useState(0);
291
+ const [phase, setPhase] = useState("");
292
+ const [duration, setDuration] = useState(0);
293
+ const [backend, setBackend] = useState(null);
294
+ const [downloadUrl, setDownloadUrl] = useState(null);
295
+ const [fileSize, setFileSize] = useState(0);
296
+ const [error, setError] = useState(null);
297
+ const [elapsed, setElapsed] = useState(0);
298
+ const [estimatedRemaining, setEstimatedRemaining] = useState(0);
299
+ const encoderRef = useRef2(null);
300
+ const cancelledRef = useRef2(false);
301
+ const downloadUrlRef = useRef2(null);
302
+ const startTimeRef = useRef2(0);
303
+ const elapsedTimerRef = useRef2(null);
304
+ const frameCapture = useFrameCapture();
305
+ useEffect(() => {
306
+ return () => {
307
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
308
+ if (downloadUrlRef.current) {
309
+ URL.revokeObjectURL(downloadUrlRef.current);
310
+ }
311
+ if (encoderRef.current) {
312
+ encoderRef.current.close();
313
+ }
314
+ frameCapture.destroy();
315
+ };
316
+ }, [frameCapture]);
317
+ const reset = useCallback2(() => {
318
+ if (downloadUrlRef.current) {
319
+ URL.revokeObjectURL(downloadUrlRef.current);
320
+ downloadUrlRef.current = null;
321
+ }
322
+ if (encoderRef.current) {
323
+ encoderRef.current.close();
324
+ encoderRef.current = null;
325
+ }
326
+ frameCapture.destroy();
327
+ setState("idle");
328
+ setProgress(0);
329
+ setPhase("");
330
+ setDuration(0);
331
+ setBackend(null);
332
+ setDownloadUrl(null);
333
+ setFileSize(0);
334
+ setError(null);
335
+ setElapsed(0);
336
+ setEstimatedRemaining(0);
337
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
338
+ cancelledRef.current = false;
339
+ }, [frameCapture]);
340
+ const cancel = useCallback2(() => {
341
+ cancelledRef.current = true;
342
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
343
+ if (encoderRef.current) {
344
+ encoderRef.current.close();
345
+ encoderRef.current = null;
346
+ }
347
+ frameCapture.destroy();
348
+ setState("idle");
349
+ setProgress(0);
350
+ setPhase("Cancelled");
351
+ }, [frameCapture]);
352
+ const startExport = useCallback2(
353
+ async (doc, config) => {
354
+ cancelledRef.current = false;
355
+ if (downloadUrlRef.current) {
356
+ URL.revokeObjectURL(downloadUrlRef.current);
357
+ downloadUrlRef.current = null;
358
+ }
359
+ setDownloadUrl(null);
360
+ setFileSize(0);
361
+ setError(null);
362
+ const quality = config.quality ?? "normal";
363
+ const fps = config.fps ?? 30;
364
+ const orientation = config.orientation ?? "landscape";
365
+ const { width, height } = resolveDimensions({ orientation });
366
+ try {
367
+ if (!supportsWebCodecs()) {
368
+ throw new Error(
369
+ "WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
370
+ );
371
+ }
372
+ setState("preparing");
373
+ setPhase("Loading document\u2026");
374
+ setProgress(0);
375
+ setElapsed(0);
376
+ setEstimatedRemaining(0);
377
+ startTimeRef.current = performance.now();
378
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
379
+ elapsedTimerRef.current = setInterval(() => {
380
+ setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
381
+ }, 1e3);
382
+ let images = config.images;
383
+ if (!images && config.mediaProvider) {
384
+ images = /* @__PURE__ */ new Map();
385
+ const entries = await config.mediaProvider.listMedia();
386
+ for (const entry of entries) {
387
+ const url2 = await config.mediaProvider.resolveUrl(entry.name);
388
+ const res = await fetch(url2);
389
+ if (res.ok) {
390
+ const data = await res.arrayBuffer();
391
+ images.set(entry.name, data);
392
+ }
393
+ }
394
+ }
395
+ const docDuration = await frameCapture.init(
396
+ doc,
397
+ { images, audio: config.audio, width, height },
398
+ config.captionMode
399
+ );
400
+ if (cancelledRef.current) return;
401
+ setDuration(docDuration);
402
+ if (docDuration <= 0) {
403
+ throw new Error("Document has zero duration \u2014 nothing to export");
404
+ }
405
+ setPhase("Starting encoder\u2026");
406
+ setProgress(5);
407
+ const encoder = createEncoder({ width, height, fps, quality });
408
+ encoderRef.current = encoder;
409
+ setBackend("webcodecs");
410
+ if (cancelledRef.current) return;
411
+ setState("capturing");
412
+ const totalFrames = Math.ceil(docDuration * fps);
413
+ const captureStartTime = performance.now();
414
+ const UI_UPDATE_INTERVAL = 10;
415
+ for (let i = 0; i < totalFrames; i++) {
416
+ if (cancelledRef.current) return;
417
+ const time = i / fps;
418
+ if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {
419
+ const captureProgress = Math.round(i / totalFrames * 90);
420
+ setProgress(5 + captureProgress);
421
+ setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
422
+ if (i > 0) {
423
+ const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
424
+ const avgPerFrame = elapsedCapture / i;
425
+ const remaining = Math.round(avgPerFrame * (totalFrames - i));
426
+ setEstimatedRemaining(remaining);
427
+ }
428
+ }
429
+ const bitmap = await frameCapture.captureFrame(time);
430
+ if (cancelledRef.current) {
431
+ bitmap.close();
432
+ return;
433
+ }
434
+ encoder.encodeFrame(bitmap, i);
435
+ }
436
+ if (cancelledRef.current) return;
437
+ setState("encoding");
438
+ setPhase("Finalizing video\u2026");
439
+ setProgress(95);
440
+ const mp4Buffer = await encoder.finalize();
441
+ encoderRef.current = null;
442
+ if (cancelledRef.current) return;
443
+ const blob = new Blob([mp4Buffer], { type: "video/mp4" });
444
+ const url = URL.createObjectURL(blob);
445
+ downloadUrlRef.current = url;
446
+ setDownloadUrl(url);
447
+ setFileSize(mp4Buffer.byteLength);
448
+ setState("complete");
449
+ setProgress(100);
450
+ setPhase("Export complete");
451
+ setEstimatedRemaining(0);
452
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
453
+ frameCapture.destroy();
454
+ } catch (err) {
455
+ if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
456
+ if (cancelledRef.current) return;
457
+ const message = err instanceof Error ? err.message : String(err);
458
+ setState("error");
459
+ setError(message);
460
+ setPhase("Export failed");
461
+ if (encoderRef.current) {
462
+ encoderRef.current.close();
463
+ encoderRef.current = null;
464
+ }
465
+ frameCapture.destroy();
466
+ }
467
+ },
468
+ [frameCapture]
469
+ );
470
+ return {
471
+ state,
472
+ progress,
473
+ phase,
474
+ duration,
475
+ backend,
476
+ downloadUrl,
477
+ fileSize,
478
+ error,
479
+ elapsed,
480
+ estimatedRemaining,
481
+ startExport,
482
+ cancel,
483
+ reset
484
+ };
485
+ }
486
+
487
+ // src/VideoExportModal.tsx
488
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
489
+ function formatDuration(seconds) {
490
+ if (seconds < 60) return `${seconds}s`;
491
+ const m = Math.floor(seconds / 60);
492
+ const s = seconds % 60;
493
+ return `${m}m ${s}s`;
494
+ }
495
+ var overlayStyle = {
496
+ position: "fixed",
497
+ inset: 0,
498
+ background: "rgba(0, 0, 0, 0.5)",
499
+ display: "flex",
500
+ alignItems: "center",
501
+ justifyContent: "center",
502
+ zIndex: 1e4
503
+ };
504
+ var modalStyle = {
505
+ background: "#FFFDF7",
506
+ border: "1px solid #c9b98a",
507
+ borderRadius: 0,
508
+ padding: "24px 28px",
509
+ minWidth: 380,
510
+ maxWidth: 480,
511
+ boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
512
+ fontFamily: "system-ui, -apple-system, sans-serif",
513
+ color: "#4a3c1f"
514
+ };
515
+ var titleStyle = {
516
+ margin: "0 0 16px 0",
517
+ fontSize: 18,
518
+ fontWeight: 600,
519
+ color: "#2d2310"
520
+ };
521
+ var labelStyle = {
522
+ display: "block",
523
+ fontSize: 13,
524
+ fontWeight: 500,
525
+ marginBottom: 4,
526
+ color: "#5a4a2a"
527
+ };
528
+ var selectStyle = {
529
+ width: "100%",
530
+ padding: "6px 8px",
531
+ fontSize: 13,
532
+ fontFamily: "inherit",
533
+ border: "1px solid #c9b98a",
534
+ borderRadius: 0,
535
+ background: "#fff",
536
+ color: "#4a3c1f",
537
+ marginBottom: 12
538
+ };
539
+ var btnPrimary = {
540
+ padding: "8px 20px",
541
+ fontSize: 14,
542
+ fontFamily: "inherit",
543
+ fontWeight: 500,
544
+ cursor: "pointer",
545
+ background: "#8B6914",
546
+ color: "#fff",
547
+ border: "1px solid #7a5c10",
548
+ borderRadius: 0
549
+ };
550
+ var btnSecondary = {
551
+ padding: "8px 20px",
552
+ fontSize: 14,
553
+ fontFamily: "inherit",
554
+ fontWeight: 500,
555
+ cursor: "pointer",
556
+ background: "#E8DFC6",
557
+ color: "#4a3c1f",
558
+ border: "1px solid #c9b98a",
559
+ borderRadius: 0
560
+ };
561
+ var progressBarOuterStyle = {
562
+ width: "100%",
563
+ height: 8,
564
+ background: "#E8DFC6",
565
+ borderRadius: 0,
566
+ overflow: "hidden",
567
+ marginBottom: 8
568
+ };
569
+ var footerStyle = {
570
+ display: "flex",
571
+ justifyContent: "flex-end",
572
+ gap: 8,
573
+ marginTop: 20
574
+ };
575
+ function VideoExportModal({
576
+ doc,
577
+ playerScript,
578
+ mediaProvider,
579
+ images,
580
+ audio,
581
+ onClose
582
+ }) {
583
+ const [quality, setQuality] = useState2("normal");
584
+ const [fps, setFps] = useState2(24);
585
+ const [orientation, setOrientation] = useState2("landscape");
586
+ const [captionMode, setCaptionMode] = useState2("off");
587
+ const exportHook = useVideoExport();
588
+ const {
589
+ state,
590
+ progress,
591
+ backend,
592
+ downloadUrl,
593
+ fileSize,
594
+ error,
595
+ elapsed,
596
+ estimatedRemaining,
597
+ startExport,
598
+ cancel: cancelExport,
599
+ reset: resetExport
600
+ } = exportHook;
601
+ const handleExport = useCallback3(async () => {
602
+ const config = {
603
+ quality,
604
+ fps,
605
+ orientation,
606
+ captionMode,
607
+ images,
608
+ audio,
609
+ mediaProvider,
610
+ playerScript
611
+ };
612
+ await startExport(doc, config);
613
+ }, [
614
+ doc,
615
+ quality,
616
+ fps,
617
+ orientation,
618
+ captionMode,
619
+ images,
620
+ audio,
621
+ mediaProvider,
622
+ playerScript,
623
+ startExport
624
+ ]);
625
+ const handleDownload = useCallback3(() => {
626
+ if (!downloadUrl) return;
627
+ const a = document.createElement("a");
628
+ a.href = downloadUrl;
629
+ const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
630
+ a.download = `document-${ts}.mp4`;
631
+ document.body.appendChild(a);
632
+ a.click();
633
+ document.body.removeChild(a);
634
+ }, [downloadUrl]);
635
+ const handleClose = useCallback3(() => {
636
+ if (state === "capturing" || state === "encoding" || state === "preparing") {
637
+ cancelExport();
638
+ }
639
+ resetExport();
640
+ onClose();
641
+ }, [state, cancelExport, resetExport, onClose]);
642
+ const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
643
+ return /* @__PURE__ */ jsx("div", { style: overlayStyle, onClick: handleClose, children: /* @__PURE__ */ jsxs("div", { style: modalStyle, onClick: (e) => e.stopPropagation(), children: [
644
+ /* @__PURE__ */ jsx("h2", { style: titleStyle, children: "Export Video" }),
645
+ state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
646
+ /* @__PURE__ */ jsxs("div", { children: [
647
+ /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Quality" }),
648
+ /* @__PURE__ */ jsxs(
649
+ "select",
650
+ {
651
+ style: selectStyle,
652
+ value: quality,
653
+ onChange: (e) => setQuality(e.target.value),
654
+ children: [
655
+ /* @__PURE__ */ jsx("option", { value: "draft", children: "Draft \u2014 fast, lower quality" }),
656
+ /* @__PURE__ */ jsx("option", { value: "normal", children: "Normal \u2014 balanced" }),
657
+ /* @__PURE__ */ jsx("option", { value: "high", children: "High \u2014 best quality, slower" })
658
+ ]
659
+ }
660
+ )
661
+ ] }),
662
+ /* @__PURE__ */ jsxs("div", { children: [
663
+ /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Frame Rate" }),
664
+ /* @__PURE__ */ jsxs(
665
+ "select",
666
+ {
667
+ style: selectStyle,
668
+ value: fps,
669
+ onChange: (e) => setFps(Number(e.target.value)),
670
+ children: [
671
+ /* @__PURE__ */ jsx("option", { value: 15, children: "15 fps \u2014 fast export" }),
672
+ /* @__PURE__ */ jsx("option", { value: 24, children: "24 fps \u2014 cinematic" }),
673
+ /* @__PURE__ */ jsx("option", { value: 30, children: "30 fps \u2014 smooth" })
674
+ ]
675
+ }
676
+ )
677
+ ] }),
678
+ /* @__PURE__ */ jsxs("div", { children: [
679
+ /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Orientation" }),
680
+ /* @__PURE__ */ jsxs(
681
+ "select",
682
+ {
683
+ style: selectStyle,
684
+ value: orientation,
685
+ onChange: (e) => setOrientation(e.target.value),
686
+ children: [
687
+ /* @__PURE__ */ jsx("option", { value: "landscape", children: "Landscape (1920 \xD7 1080)" }),
688
+ /* @__PURE__ */ jsx("option", { value: "portrait", children: "Portrait (1080 \xD7 1920)" })
689
+ ]
690
+ }
691
+ )
692
+ ] }),
693
+ /* @__PURE__ */ jsxs("div", { children: [
694
+ /* @__PURE__ */ jsx("label", { style: labelStyle, children: "Captions" }),
695
+ /* @__PURE__ */ jsxs(
696
+ "select",
697
+ {
698
+ style: selectStyle,
699
+ value: captionMode,
700
+ onChange: (e) => setCaptionMode(e.target.value),
701
+ children: [
702
+ /* @__PURE__ */ jsx("option", { value: "off", children: "None" }),
703
+ /* @__PURE__ */ jsx("option", { value: "standard", children: "Standard (top bar)" }),
704
+ /* @__PURE__ */ jsx("option", { value: "social", children: "Social media (large words)" })
705
+ ]
706
+ }
707
+ )
708
+ ] }),
709
+ /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
710
+ /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Cancel" }),
711
+ /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Export Video" })
712
+ ] })
713
+ ] }),
714
+ isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
715
+ backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 8px 0" }, children: "Encoder: WebCodecs (H.264)" }),
716
+ /* @__PURE__ */ jsx("div", { style: progressBarOuterStyle, children: /* @__PURE__ */ jsx(
717
+ "div",
718
+ {
719
+ style: {
720
+ width: `${progress}%`,
721
+ height: "100%",
722
+ background: "#8B6914",
723
+ transition: "width 0.3s ease"
724
+ }
725
+ }
726
+ ) }),
727
+ /* @__PURE__ */ jsxs("p", { style: { fontSize: 13, margin: "0 0 4px 0" }, children: [
728
+ progress,
729
+ "% complete"
730
+ ] }),
731
+ /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: 0 }, children: [
732
+ formatDuration(elapsed),
733
+ " elapsed",
734
+ estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
735
+ ] }),
736
+ /* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: cancelExport, children: "Cancel" }) })
737
+ ] }),
738
+ state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
739
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#2d6a10" }, children: "Export complete!" }),
740
+ /* @__PURE__ */ jsxs("p", { style: { fontSize: 13, color: "#5a4a2a", margin: "0 0 4px 0" }, children: [
741
+ "File size: ",
742
+ (fileSize / (1024 * 1024)).toFixed(1),
743
+ " MB"
744
+ ] }),
745
+ backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 12px 0" }, children: "Encoded with WebCodecs (H.264)" }),
746
+ /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
747
+ /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
748
+ /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleDownload, children: "Download MP4" })
749
+ ] })
750
+ ] }),
751
+ state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
752
+ /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#a03020" }, children: "Export failed" }),
753
+ /* @__PURE__ */ jsx(
754
+ "p",
755
+ {
756
+ style: {
757
+ fontSize: 13,
758
+ color: "#5a4a2a",
759
+ margin: "0 0 12px 0",
760
+ wordBreak: "break-word"
761
+ },
762
+ children: error
763
+ }
764
+ ),
765
+ /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
766
+ /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
767
+ /* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Retry" })
768
+ ] })
769
+ ] })
770
+ ] }) });
771
+ }
772
+
773
+ // src/VideoExportButton.tsx
774
+ import { useState as useState3, useCallback as useCallback4 } from "react";
775
+ import { createPortal } from "react-dom";
776
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
777
+ function VideoExportButton({
778
+ doc,
779
+ playerScript,
780
+ mediaProvider,
781
+ images,
782
+ audio,
783
+ label = "Export Video",
784
+ style,
785
+ disabled
786
+ }) {
787
+ const [showModal, setShowModal] = useState3(false);
788
+ const handleOpen = useCallback4(() => setShowModal(true), []);
789
+ const handleClose = useCallback4(() => setShowModal(false), []);
790
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
791
+ /* @__PURE__ */ jsx2("button", { onClick: handleOpen, disabled, style, children: label }),
792
+ showModal && createPortal(
793
+ /* @__PURE__ */ jsx2(
794
+ VideoExportModal,
795
+ {
796
+ doc,
797
+ playerScript,
798
+ mediaProvider,
799
+ images,
800
+ audio,
801
+ onClose: handleClose
802
+ }
803
+ ),
804
+ document.body
805
+ )
806
+ ] });
807
+ }
808
+ export {
809
+ VideoExportButton,
810
+ VideoExportModal,
811
+ createEncoder,
812
+ supportsWebCodecs,
813
+ useFrameCapture,
814
+ useVideoExport
815
+ };
816
+ //# sourceMappingURL=index.js.map