@bendyline/squisq-video-react 1.1.1 → 1.1.2

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