@bendyline/squisq-video-react 2.0.2 → 2.2.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.
Files changed (44) hide show
  1. package/COPYING.GPL-2.0.txt +339 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +39 -0
  4. package/README.md +34 -7
  5. package/dist/chunk-KQG6DZ7G.js +549 -0
  6. package/dist/chunk-VI5AIVOQ.js +1951 -0
  7. package/dist/chunk-VILZP3GL.js +856 -0
  8. package/dist/chunk-YZN7D4IC.js +300 -0
  9. package/dist/components/index.d.ts +90 -0
  10. package/dist/components/index.js +11 -0
  11. package/dist/encoder/index.d.ts +95 -0
  12. package/dist/encoder/index.js +13 -0
  13. package/dist/hooks/index.d.ts +32 -0
  14. package/dist/hooks/index.js +10 -0
  15. package/dist/index.d.ts +8 -277
  16. package/dist/index.js +12 -1503
  17. package/dist/useVideoExport-NtqZVgaz.d.ts +115 -0
  18. package/dist/workers/encode.worker.js +37 -20
  19. package/dist/workers/ffmpeg.class-worker.js +0 -1
  20. package/package.json +26 -8
  21. package/dist/chunk-6DKLHXX3.js +0 -47
  22. package/dist/chunk-6DKLHXX3.js.map +0 -1
  23. package/dist/index.js.map +0 -1
  24. package/dist/workers/encode.worker.js.map +0 -1
  25. package/dist/workers/ffmpeg.class-worker.js.map +0 -1
  26. package/src/VideoExportButton.tsx +0 -87
  27. package/src/VideoExportModal.tsx +0 -553
  28. package/src/__tests__/audioTrack.test.ts +0 -108
  29. package/src/__tests__/gifTranscode.test.ts +0 -118
  30. package/src/__tests__/mp4MuxAudio.test.ts +0 -72
  31. package/src/__tests__/useVideoExportGif.test.ts +0 -155
  32. package/src/__tests__/videoExportProps.test.tsx +0 -148
  33. package/src/__tests__/workerEncoder.test.ts +0 -143
  34. package/src/audioTrack.ts +0 -332
  35. package/src/gifTranscode.ts +0 -75
  36. package/src/hooks/useFrameCapture.ts +0 -268
  37. package/src/hooks/useVideoExport.ts +0 -647
  38. package/src/index.ts +0 -40
  39. package/src/mainThreadEncoder.ts +0 -158
  40. package/src/mp4Mux.ts +0 -123
  41. package/src/workerEncoder.ts +0 -175
  42. package/src/workers/encode.worker.ts +0 -439
  43. package/src/workers/ffmpeg.class-worker.ts +0 -11
  44. package/src/workers/workerTypes.ts +0 -89
package/dist/index.js CHANGED
@@ -1,1508 +1,18 @@
1
1
  import {
2
- createMp4Muxer
3
- } from "./chunk-6DKLHXX3.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, computeAudioTimeline, QUALITY_PRESETS } from "@bendyline/squisq-video";
11
-
12
- // src/mainThreadEncoder.ts
13
- import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
14
- function supportsWebCodecs() {
15
- return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
16
- }
17
- async function supportsWebCodecsH264(config) {
18
- if (!supportsWebCodecs()) return false;
19
- try {
20
- const support = await VideoEncoder.isConfigSupported({
21
- codec: "avc1.640028",
22
- width: config.width,
23
- height: config.height,
24
- bitrate: bitrateForQuality(config.quality, config.width, config.height),
25
- framerate: config.fps
26
- });
27
- return support.supported === true;
28
- } catch {
29
- return false;
30
- }
31
- }
32
- function createEncoder(config) {
33
- validateVideoExportOptions(config);
34
- if (!supportsWebCodecs()) {
35
- throw new Error(
36
- "WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
37
- );
38
- }
39
- const muxer = createMp4Muxer({
40
- width: config.width,
41
- height: config.height,
42
- fps: config.fps,
43
- ...config.audio ? { audio: config.audio } : {}
44
- });
45
- let closed = false;
46
- const frameDuration = 1e6 / config.fps;
47
- const encoder = new VideoEncoder({
48
- output(chunk, meta) {
49
- if (closed) return;
50
- muxer.addVideoChunk(chunk, meta ?? void 0);
51
- },
52
- error(err) {
53
- console.error("WebCodecs encoder error:", err.message);
54
- }
55
- });
56
- encoder.configure({
57
- // Deliberate profile split from the fallback worker (avc1.42001f, Baseline):
58
- // this primary WebCodecs path targets H.264 High@4.0 for better quality up
59
- // to 1080p; the wasm-fallback worker uses Baseline for max decoder compat.
60
- codec: "avc1.640028",
61
- // H.264 High profile, level 4.0 (supports up to 1080p)
62
- width: config.width,
63
- height: config.height,
64
- bitrate: bitrateForQuality(config.quality, config.width, config.height),
65
- framerate: config.fps
66
- });
67
- return {
68
- async encodeFrame(bitmap, frameIndex) {
69
- if (closed) {
70
- bitmap.close();
71
- throw new Error("Encoder already closed");
72
- }
73
- const timestamp = Math.round(frameIndex * frameDuration);
74
- const frame = new VideoFrame(bitmap, { timestamp });
75
- const keyFrame = frameIndex % 30 === 0;
76
- encoder.encode(frame, { keyFrame });
77
- frame.close();
78
- bitmap.close();
79
- },
80
- addAudioChunk(chunk, meta) {
81
- if (closed || !muxer.hasAudioTrack) return;
82
- muxer.addAudioChunk(chunk, meta);
83
- },
84
- async finalize() {
85
- if (closed) throw new Error("Encoder already closed");
86
- await encoder.flush();
87
- encoder.close();
88
- closed = true;
89
- return muxer.finalize();
90
- },
91
- close() {
92
- if (closed) return;
93
- closed = true;
94
- if (encoder.state !== "closed") {
95
- encoder.close();
96
- }
97
- }
98
- };
99
- }
100
-
101
- // src/workerEncoder.ts
102
- import { validateVideoExportOptions as validateVideoExportOptions2 } from "@bendyline/squisq-video";
103
- function createWorkerEncoder(config) {
104
- validateVideoExportOptions2(config);
105
- const worker = new Worker(new URL("./workers/encode.worker.js", import.meta.url), {
106
- type: "module"
107
- });
108
- let state = "open";
109
- let fatalError = null;
110
- let finalizeResolve = null;
111
- let finalizeReject = null;
112
- let readyResolve = null;
113
- let readyReject = null;
114
- let readySettled = false;
115
- const frameWaiters = /* @__PURE__ */ new Map();
116
- const ready = new Promise((resolve, reject) => {
117
- readyResolve = resolve;
118
- readyReject = reject;
119
- });
120
- const frameDuration = 1e6 / config.fps;
121
- function post(msg, transfer) {
122
- worker.postMessage(msg, transfer ?? []);
123
- }
124
- const currentState = () => state;
125
- worker.onmessage = (event) => {
126
- const msg = event.data;
127
- switch (msg.type) {
128
- case "capabilities":
129
- readySettled = true;
130
- readyResolve?.(msg.backend);
131
- readyResolve = readyReject = null;
132
- break;
133
- case "frame-complete": {
134
- const waiter = frameWaiters.get(msg.frameIndex);
135
- waiter?.resolve();
136
- frameWaiters.delete(msg.frameIndex);
137
- break;
138
- }
139
- case "complete":
140
- state = "closed";
141
- finalizeResolve?.(msg.data);
142
- finalizeResolve = finalizeReject = null;
143
- worker.terminate();
144
- break;
145
- case "error": {
146
- const err = new Error(msg.message);
147
- fatalError = err;
148
- state = "closed";
149
- readySettled = true;
150
- readyReject?.(err);
151
- finalizeReject?.(err);
152
- for (const waiter of frameWaiters.values()) waiter.reject(err);
153
- frameWaiters.clear();
154
- readyResolve = readyReject = null;
155
- finalizeResolve = finalizeReject = null;
156
- worker.terminate();
157
- break;
158
- }
159
- }
160
- };
161
- worker.onerror = (event) => {
162
- const err = new Error(event.message || "Worker error");
163
- fatalError = err;
164
- state = "closed";
165
- readySettled = true;
166
- readyReject?.(err);
167
- finalizeReject?.(err);
168
- for (const waiter of frameWaiters.values()) waiter.reject(err);
169
- frameWaiters.clear();
170
- readyResolve = readyReject = null;
171
- finalizeResolve = finalizeReject = null;
172
- worker.terminate();
173
- };
174
- post({
175
- type: "init",
176
- width: config.width,
177
- height: config.height,
178
- fps: config.fps,
179
- quality: config.quality,
180
- ...config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}
181
- });
182
- return {
183
- ready,
184
- encodeFrame(bitmap, frameIndex) {
185
- if (state !== "open" || fatalError) {
186
- bitmap.close();
187
- return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
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 });
200
- const timestamp = Math.round(frameIndex * frameDuration);
201
- post({ type: "frame", bitmap, frameIndex, timestamp }, [bitmap]);
202
- return promise;
203
- },
204
- async finalize() {
205
- if (state !== "open") throw new Error("Encoder already closed or finalizing");
206
- if (fatalError) throw fatalError;
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
- }
212
- return new Promise((resolve, reject) => {
213
- finalizeResolve = resolve;
214
- finalizeReject = reject;
215
- post({ type: "finalize" });
216
- });
217
- },
218
- close() {
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;
231
- post({ type: "cancel" });
232
- worker.terminate();
233
- }
234
- };
235
- }
236
-
237
- // src/audioTrack.ts
2
+ VideoExportButton,
3
+ VideoExportModal
4
+ } from "./chunk-KQG6DZ7G.js";
238
5
  import {
239
- ffmpegAudioMuxArgs
240
- } from "@bendyline/squisq-video";
241
- var EXPORT_AUDIO_SAMPLE_RATE = 48e3;
242
- var EXPORT_AUDIO_CHANNELS = 2;
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).";
244
- async function supportsWebCodecsAac(sampleRate = EXPORT_AUDIO_SAMPLE_RATE, channels = EXPORT_AUDIO_CHANNELS) {
245
- if (typeof AudioEncoder === "undefined") return false;
246
- try {
247
- const support = await AudioEncoder.isConfigSupported({
248
- codec: "mp4a.40.2",
249
- sampleRate,
250
- numberOfChannels: channels,
251
- bitrate: 128e3
252
- });
253
- return support.supported === true;
254
- } catch {
255
- return false;
256
- }
257
- }
258
- function selectAudioTier(inputs) {
259
- if (!inputs.hasClips) {
260
- return { tier: 3, reason: null };
261
- }
262
- if (inputs.aacSupported && inputs.canUseMainThreadWebCodecs) {
263
- return { tier: 1, reason: null };
264
- }
265
- if (inputs.sharedArrayBufferAvailable) {
266
- return { tier: 2, reason: null };
267
- }
268
- return { tier: 3, reason: REASON_NO_AAC_NO_SAB };
269
- }
270
- function resolveOfflineAudioContext() {
271
- const g = globalThis;
272
- const ctor = g.OfflineAudioContext ?? g.webkitOfflineAudioContext;
273
- if (!ctor) {
274
- throw new Error("OfflineAudioContext is not available in this environment.");
275
- }
276
- return ctor;
277
- }
278
- async function renderAudioTimeline(clips, buffers, totalDurationSec, sampleRate = EXPORT_AUDIO_SAMPLE_RATE) {
279
- const Ctor = resolveOfflineAudioContext();
280
- const channels = EXPORT_AUDIO_CHANNELS;
281
- const length = Math.max(1, Math.ceil(totalDurationSec * sampleRate));
282
- const ctx = new Ctor(channels, length, sampleRate);
283
- const decoded = /* @__PURE__ */ new Map();
284
- for (const [src, data] of buffers) {
285
- try {
286
- decoded.set(src, await ctx.decodeAudioData(data.slice(0)));
287
- } catch {
288
- }
289
- }
290
- for (const clip of clips) {
291
- const buffer = decoded.get(clip.src);
292
- if (!buffer) continue;
293
- const node = ctx.createBufferSource();
294
- node.buffer = buffer;
295
- node.connect(ctx.destination);
296
- const when = Math.max(0, clip.startSec);
297
- const offset = Math.max(0, clip.sourceInSec);
298
- const duration = Math.max(0, clip.durationSec);
299
- node.start(when, offset, duration);
300
- }
301
- return ctx.startRendering();
302
- }
303
- async function encodeAacTrack(audioBuffer, sink, bitrate) {
304
- if (typeof AudioEncoder === "undefined" || typeof AudioData === "undefined") {
305
- throw new Error("WebCodecs AudioEncoder is not available.");
306
- }
307
- const sampleRate = audioBuffer.sampleRate;
308
- const channels = audioBuffer.numberOfChannels;
309
- let encodeError = null;
310
- const encoder = new AudioEncoder({
311
- output: (chunk, meta) => sink.addAudioChunk(chunk, meta ?? void 0),
312
- error: (err) => {
313
- encodeError = err instanceof Error ? err : new Error(String(err));
314
- }
315
- });
316
- encoder.configure({ codec: "mp4a.40.2", sampleRate, numberOfChannels: channels, bitrate });
317
- const FRAME = 1024;
318
- const total = audioBuffer.length;
319
- const channelData = [];
320
- for (let ch = 0; ch < channels; ch++) {
321
- channelData.push(audioBuffer.getChannelData(ch));
322
- }
323
- for (let offset = 0; offset < total; offset += FRAME) {
324
- if (encodeError) break;
325
- const count = Math.min(FRAME, total - offset);
326
- const planar = new Float32Array(count * channels);
327
- for (let ch = 0; ch < channels; ch++) {
328
- planar.set(channelData[ch].subarray(offset, offset + count), ch * count);
329
- }
330
- const timestamp = Math.round(offset / sampleRate * 1e6);
331
- const audioData = new AudioData({
332
- format: "f32-planar",
333
- sampleRate,
334
- numberOfFrames: count,
335
- numberOfChannels: channels,
336
- timestamp,
337
- data: planar
338
- });
339
- encoder.encode(audioData);
340
- audioData.close();
341
- }
342
- await encoder.flush();
343
- encoder.close();
344
- if (encodeError) throw encodeError;
345
- }
346
- function audioBufferToWav(buffer) {
347
- const channels = buffer.numberOfChannels;
348
- const sampleRate = buffer.sampleRate;
349
- const frames = buffer.length;
350
- const bytesPerSample = 2;
351
- const blockAlign = channels * bytesPerSample;
352
- const dataSize = frames * blockAlign;
353
- const out = new ArrayBuffer(44 + dataSize);
354
- const view = new DataView(out);
355
- const writeStr = (offset, str) => {
356
- for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
357
- };
358
- writeStr(0, "RIFF");
359
- view.setUint32(4, 36 + dataSize, true);
360
- writeStr(8, "WAVE");
361
- writeStr(12, "fmt ");
362
- view.setUint32(16, 16, true);
363
- view.setUint16(20, 1, true);
364
- view.setUint16(22, channels, true);
365
- view.setUint32(24, sampleRate, true);
366
- view.setUint32(28, sampleRate * blockAlign, true);
367
- view.setUint16(32, blockAlign, true);
368
- view.setUint16(34, bytesPerSample * 8, true);
369
- writeStr(36, "data");
370
- view.setUint32(40, dataSize, true);
371
- const channelData = [];
372
- for (let ch = 0; ch < channels; ch++) channelData.push(buffer.getChannelData(ch));
373
- let pos = 44;
374
- for (let i = 0; i < frames; i++) {
375
- for (let ch = 0; ch < channels; ch++) {
376
- let sample = channelData[ch][i];
377
- sample = Math.max(-1, Math.min(1, sample));
378
- view.setInt16(pos, sample < 0 ? sample * 32768 : sample * 32767, true);
379
- pos += 2;
380
- }
381
- }
382
- return new Uint8Array(out);
383
- }
384
- async function muxAudioWithFfmpegWasm(videoMp4, wav, audioBitrate, loadConfig) {
385
- const { FFmpeg } = await import("@ffmpeg/ffmpeg");
386
- const ffmpeg = new FFmpeg();
387
- try {
388
- await ffmpeg.load({
389
- ...loadConfig,
390
- classWorkerURL: loadConfig?.classWorkerURL ?? new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
391
- });
392
- await ffmpeg.writeFile("video.mp4", videoMp4);
393
- await ffmpeg.writeFile("audio.wav", wav);
394
- const exitCode = await ffmpeg.exec([
395
- "-i",
396
- "video.mp4",
397
- "-i",
398
- "audio.wav",
399
- "-c:v",
400
- "copy",
401
- ...ffmpegAudioMuxArgs(audioBitrate),
402
- "out.mp4"
403
- ]);
404
- if (exitCode !== 0) {
405
- throw new Error(`ffmpeg.wasm audio mux failed with exit code ${exitCode}`);
406
- }
407
- const data = await ffmpeg.readFile("out.mp4");
408
- return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
409
- } finally {
410
- ffmpeg.terminate();
411
- }
412
- }
413
-
414
- // src/gifTranscode.ts
6
+ useFrameCapture,
7
+ useVideoExport
8
+ } from "./chunk-VILZP3GL.js";
415
9
  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
-
466
- // src/hooks/useFrameCapture.ts
467
- import { createElement } from "react";
468
- import { createRoot } from "react-dom/client";
469
- import { useRef, useCallback, useMemo } from "react";
470
- import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
471
- import html2canvas from "html2canvas";
472
- var MIME_MAP = {
473
- jpg: "image/jpeg",
474
- jpeg: "image/jpeg",
475
- png: "image/png",
476
- gif: "image/gif",
477
- webp: "image/webp",
478
- svg: "image/svg+xml",
479
- bmp: "image/bmp",
480
- avif: "image/avif"
481
- };
482
- function arrayBufferToDataUrl(buffer, mime) {
483
- const bytes = new Uint8Array(buffer);
484
- const chunks = [];
485
- const CHUNK = 8192;
486
- for (let i = 0; i < bytes.length; i += CHUNK) {
487
- chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
488
- }
489
- return `data:${mime};base64,${btoa(chunks.join(""))}`;
490
- }
491
- function createInlineProvider(images) {
492
- const dataUrls = /* @__PURE__ */ new Map();
493
- const mimeTypes = /* @__PURE__ */ new Map();
494
- for (const [path, buffer] of images) {
495
- const ext = path.split(".").pop()?.toLowerCase() ?? "";
496
- const mime = MIME_MAP[ext] ?? "application/octet-stream";
497
- dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
498
- mimeTypes.set(path, mime);
499
- }
500
- return {
501
- async resolveUrl(relativePath) {
502
- return dataUrls.get(relativePath) ?? relativePath;
503
- },
504
- async listMedia() {
505
- return [...dataUrls.keys()].map((name) => ({
506
- name,
507
- mimeType: mimeTypes.get(name) ?? "application/octet-stream",
508
- size: 0
509
- }));
510
- },
511
- async addMedia() {
512
- throw new Error("Read-only");
513
- },
514
- async removeMedia() {
515
- throw new Error("Read-only");
516
- },
517
- dispose() {
518
- }
519
- };
520
- }
521
- function useFrameCapture() {
522
- const containerRef = useRef(null);
523
- const rootRef = useRef(null);
524
- const renderAPIRef = useRef(null);
525
- const dimensionsRef = useRef({ width: 1920, height: 1080 });
526
- const init = useCallback(
527
- async (doc, renderOptions, captionMode) => {
528
- if (rootRef.current || containerRef.current) {
529
- const oldRoot = rootRef.current;
530
- const oldContainer = containerRef.current;
531
- rootRef.current = null;
532
- containerRef.current = null;
533
- renderAPIRef.current = null;
534
- await new Promise((resolve) => {
535
- setTimeout(() => {
536
- if (oldRoot) oldRoot.unmount();
537
- if (oldContainer) oldContainer.remove();
538
- resolve();
539
- }, 0);
540
- });
541
- }
542
- const width = renderOptions.width ?? 1920;
543
- const height = renderOptions.height ?? 1080;
544
- const animationsEnabled = renderOptions.animationsEnabled ?? true;
545
- dimensionsRef.current = { width, height };
546
- const container = document.createElement("div");
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;`;
548
- document.body.appendChild(container);
549
- containerRef.current = container;
550
- const renderRoot = document.createElement("div");
551
- renderRoot.id = "squisq-capture-root";
552
- renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
553
- container.appendChild(renderRoot);
554
- const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : null;
555
- const root = createRoot(renderRoot);
556
- rootRef.current = root;
557
- const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
558
- const captionStyle = captionMode === "social" ? "social" : "standard";
559
- let resolveRenderAPI;
560
- const renderAPIReady = new Promise((resolve) => {
561
- resolveRenderAPI = resolve;
562
- });
563
- const playerElement = createElement(DocPlayer, {
564
- doc,
565
- basePath: ".",
566
- renderMode: true,
567
- animationsEnabled,
568
- showControls: false,
569
- autoPlay: false,
570
- forceViewport: { width, height, name: "export" },
571
- captionsEnabled,
572
- captionStyle,
573
- onRenderAPIReady: (api) => {
574
- if (containerRef.current !== container) return;
575
- renderAPIRef.current = api;
576
- if (api) resolveRenderAPI(api);
577
- }
578
- });
579
- await new Promise((resolve) => setTimeout(resolve, 0));
580
- if (mediaProvider) {
581
- root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
582
- } else {
583
- root.render(playerElement);
584
- }
585
- return new Promise((resolve, reject) => {
586
- const timeout = setTimeout(() => {
587
- const api = renderAPIRef.current;
588
- const hasSeek = typeof api?.seekTo === "function";
589
- const hasDur = typeof api?.getDuration === "function";
590
- const rootEl = containerRef.current?.querySelector("#squisq-capture-root");
591
- const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
592
- reject(
593
- new Error(
594
- `Render API did not initialize within 15s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
595
- )
596
- );
597
- }, 15e3);
598
- void renderAPIReady.then((api) => {
599
- clearTimeout(timeout);
600
- resolve(api.getDuration());
601
- });
602
- });
603
- },
604
- []
605
- );
606
- const captureFrame = useCallback(async (time) => {
607
- const container = containerRef.current;
608
- const api = renderAPIRef.current;
609
- if (!container || !api) {
610
- throw new Error("Frame capture not initialized \u2014 call init() first");
611
- }
612
- const { width, height } = dimensionsRef.current;
613
- await api.seekTo(time);
614
- await new Promise(
615
- (resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
616
- );
617
- const root = container.querySelector("#squisq-capture-root");
618
- if (!root) {
619
- throw new Error("Capture root element not found");
620
- }
621
- const canvas = await html2canvas(root, {
622
- width,
623
- height,
624
- scale: 1,
625
- useCORS: true,
626
- allowTaint: true,
627
- backgroundColor: "#000000",
628
- logging: false
629
- });
630
- const bitmap = await createImageBitmap(canvas);
631
- return bitmap;
632
- }, []);
633
- const destroy = useCallback(() => {
634
- if (rootRef.current) {
635
- rootRef.current.unmount();
636
- rootRef.current = null;
637
- }
638
- if (containerRef.current) {
639
- containerRef.current.remove();
640
- containerRef.current = null;
641
- }
642
- renderAPIRef.current = null;
643
- }, []);
644
- return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
645
- }
646
-
647
- // src/hooks/useVideoExport.ts
648
- async function resolveAudioBuffers(clips, sources) {
649
- const srcs = new Set(clips.map((c) => c.src));
650
- const out = /* @__PURE__ */ new Map();
651
- for (const src of srcs) {
652
- let data = sources.audio?.get(src) ?? sources.images?.get(src);
653
- if (!data && sources.mediaProvider) {
654
- try {
655
- const url = await sources.mediaProvider.resolveUrl(src);
656
- const res = await fetch(url);
657
- if (res.ok) data = await res.arrayBuffer();
658
- } catch {
659
- }
660
- }
661
- if (data) out.set(src, data);
662
- }
663
- return out;
664
- }
665
- function useVideoExport() {
666
- const [state, setState] = useState("idle");
667
- const [progress, setProgress] = useState(0);
668
- const [phase, setPhase] = useState("");
669
- const [duration, setDuration] = useState(0);
670
- const [outputFormat, setOutputFormat] = useState("mp4");
671
- const [backend, setBackend] = useState(null);
672
- const [downloadUrl, setDownloadUrl] = useState(null);
673
- const [fileSize, setFileSize] = useState(0);
674
- const [audioIncluded, setAudioIncluded] = useState(false);
675
- const [audioSkippedReason, setAudioSkippedReason] = useState(null);
676
- const [error, setError] = useState(null);
677
- const [elapsed, setElapsed] = useState(0);
678
- const [estimatedRemaining, setEstimatedRemaining] = useState(0);
679
- const encoderRef = useRef2(null);
680
- const gifAbortRef = useRef2(null);
681
- const cancelledRef = useRef2(false);
682
- const downloadUrlRef = useRef2(null);
683
- const startTimeRef = useRef2(0);
684
- const elapsedTimerRef = useRef2(null);
685
- const frameCapture = useFrameCapture();
686
- useEffect(() => {
687
- return () => {
688
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
689
- if (downloadUrlRef.current) {
690
- URL.revokeObjectURL(downloadUrlRef.current);
691
- }
692
- if (encoderRef.current) {
693
- encoderRef.current.close();
694
- }
695
- gifAbortRef.current?.abort();
696
- frameCapture.destroy();
697
- };
698
- }, [frameCapture]);
699
- const reset = useCallback2(() => {
700
- if (downloadUrlRef.current) {
701
- URL.revokeObjectURL(downloadUrlRef.current);
702
- downloadUrlRef.current = null;
703
- }
704
- if (encoderRef.current) {
705
- encoderRef.current.close();
706
- encoderRef.current = null;
707
- }
708
- gifAbortRef.current?.abort();
709
- gifAbortRef.current = null;
710
- frameCapture.destroy();
711
- setState("idle");
712
- setProgress(0);
713
- setPhase("");
714
- setDuration(0);
715
- setOutputFormat("mp4");
716
- setBackend(null);
717
- setDownloadUrl(null);
718
- setFileSize(0);
719
- setAudioIncluded(false);
720
- setAudioSkippedReason(null);
721
- setError(null);
722
- setElapsed(0);
723
- setEstimatedRemaining(0);
724
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
725
- cancelledRef.current = false;
726
- }, [frameCapture]);
727
- const cancel = useCallback2(() => {
728
- cancelledRef.current = true;
729
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
730
- if (encoderRef.current) {
731
- encoderRef.current.close();
732
- encoderRef.current = null;
733
- }
734
- gifAbortRef.current?.abort();
735
- gifAbortRef.current = null;
736
- frameCapture.destroy();
737
- setState("idle");
738
- setProgress(0);
739
- setPhase("Cancelled");
740
- }, [frameCapture]);
741
- const startExport = useCallback2(
742
- async (doc, config) => {
743
- cancelledRef.current = false;
744
- if (downloadUrlRef.current) {
745
- URL.revokeObjectURL(downloadUrlRef.current);
746
- downloadUrlRef.current = null;
747
- }
748
- setDownloadUrl(null);
749
- setFileSize(0);
750
- setAudioIncluded(false);
751
- setAudioSkippedReason(null);
752
- setError(null);
753
- const quality = config.quality ?? "normal";
754
- const effectiveOutputFormat = config.outputFormat ?? "mp4";
755
- const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
756
- const orientation = config.orientation ?? "landscape";
757
- const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
758
- setOutputFormat(effectiveOutputFormat);
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
- });
768
- const webCodecsAvailable = supportsWebCodecs();
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
- }
775
- if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
776
- throw new Error(
777
- "No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
778
- );
779
- }
780
- setState("preparing");
781
- setPhase("Loading document\u2026");
782
- setProgress(0);
783
- setElapsed(0);
784
- setEstimatedRemaining(0);
785
- startTimeRef.current = performance.now();
786
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
787
- elapsedTimerRef.current = setInterval(() => {
788
- setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
789
- }, 1e3);
790
- let images = config.images;
791
- if (!images && config.mediaProvider) {
792
- images = /* @__PURE__ */ new Map();
793
- const entries = await config.mediaProvider.listMedia();
794
- for (const entry of entries) {
795
- const url2 = await config.mediaProvider.resolveUrl(entry.name);
796
- const res = await fetch(url2);
797
- if (res.ok) {
798
- const data = await res.arrayBuffer();
799
- images.set(entry.name, data);
800
- }
801
- }
802
- }
803
- const docDuration = await frameCapture.init(
804
- doc,
805
- { images, audio: config.audio, width, height, animationsEnabled },
806
- config.captionMode
807
- );
808
- if (cancelledRef.current) return;
809
- setDuration(docDuration);
810
- if (docDuration <= 0) {
811
- throw new Error("Document has zero duration \u2014 nothing to export");
812
- }
813
- setPhase("Starting encoder\u2026");
814
- setProgress(5);
815
- const canUseWebCodecs = webCodecsAvailable && await supportsWebCodecsH264({ width, height, fps, quality });
816
- const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
817
- const timeline = effectiveOutputFormat === "mp4" ? computeAudioTimeline(doc, 0) : [];
818
- const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
819
- const tierDecision = selectAudioTier({
820
- hasClips: timeline.length > 0,
821
- aacSupported,
822
- sharedArrayBufferAvailable,
823
- canUseMainThreadWebCodecs: canUseWebCodecs
824
- });
825
- let renderedAudio = null;
826
- let audioIncludedLocal = false;
827
- let audioReasonLocal = tierDecision.reason;
828
- if (tierDecision.tier === 1 || tierDecision.tier === 2) {
829
- setPhase("Preparing audio\u2026");
830
- try {
831
- const buffers = await resolveAudioBuffers(timeline, {
832
- audio: config.audio,
833
- images,
834
- mediaProvider: config.mediaProvider
835
- });
836
- if (buffers.size === 0) {
837
- audioReasonLocal = "Audio files for this document could not be loaded.";
838
- } else {
839
- const totalAudioDur = timeline.reduce(
840
- (max, c) => Math.max(max, c.startSec + c.durationSec),
841
- docDuration
842
- );
843
- renderedAudio = await renderAudioTimeline(
844
- timeline,
845
- buffers,
846
- totalAudioDur,
847
- EXPORT_AUDIO_SAMPLE_RATE
848
- );
849
- }
850
- } catch (audioErr) {
851
- renderedAudio = null;
852
- audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
853
- }
854
- }
855
- const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
856
- const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
857
- if (cancelledRef.current) return;
858
- let encoder;
859
- if (canUseWebCodecs) {
860
- encoder = createEncoder({
861
- width,
862
- height,
863
- fps,
864
- quality,
865
- ...useInlineAudio && renderedAudio ? {
866
- audio: {
867
- numberOfChannels: renderedAudio.numberOfChannels,
868
- sampleRate: renderedAudio.sampleRate
869
- }
870
- } : {}
871
- });
872
- setBackend("webcodecs");
873
- } else if (sharedArrayBufferAvailable) {
874
- const workerEncoder = createWorkerEncoder({
875
- width,
876
- height,
877
- fps,
878
- quality,
879
- ffmpegWasm: config.ffmpegWasm
880
- });
881
- encoder = workerEncoder;
882
- const selectedBackend = await workerEncoder.ready;
883
- setBackend(selectedBackend);
884
- setPhase(
885
- selectedBackend === "ffmpeg-wasm" ? "Starting encoder (ffmpeg.wasm)\u2026" : "Starting encoder\u2026"
886
- );
887
- } else {
888
- throw new Error(
889
- "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
890
- );
891
- }
892
- encoderRef.current = encoder;
893
- if (cancelledRef.current) return;
894
- setState("capturing");
895
- const totalFrames = Math.ceil(docDuration * fps);
896
- const captureStartTime = performance.now();
897
- const UI_UPDATE_INTERVAL = 10;
898
- for (let i = 0; i < totalFrames; i++) {
899
- if (cancelledRef.current) return;
900
- const time = i / fps;
901
- if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {
902
- const captureProgress = Math.round(i / totalFrames * 90);
903
- setProgress(5 + captureProgress);
904
- setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
905
- if (i > 0) {
906
- const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
907
- const avgPerFrame = elapsedCapture / i;
908
- const remaining = Math.round(avgPerFrame * (totalFrames - i));
909
- setEstimatedRemaining(remaining);
910
- }
911
- }
912
- const bitmap = await frameCapture.captureFrame(time);
913
- if (cancelledRef.current) {
914
- bitmap.close();
915
- return;
916
- }
917
- await encoder.encodeFrame(bitmap, i);
918
- }
919
- if (cancelledRef.current) return;
920
- if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
921
- setState("encoding");
922
- setPhase("Encoding audio\u2026");
923
- try {
924
- await encodeAacTrack(
925
- renderedAudio,
926
- { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
927
- audioBitrate
928
- );
929
- audioIncludedLocal = true;
930
- } catch (audioErr) {
931
- audioIncludedLocal = false;
932
- audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
933
- }
934
- }
935
- setState("encoding");
936
- setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
937
- setProgress(95);
938
- let outputBytes = await encoder.finalize();
939
- encoderRef.current = null;
940
- if (cancelledRef.current) return;
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) {
957
- setPhase("Muxing audio\u2026");
958
- try {
959
- const wav = audioBufferToWav(renderedAudio);
960
- const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
961
- outputBytes = await muxAudioWithFfmpegWasm(
962
- videoOnly,
963
- wav,
964
- audioBitrate,
965
- config.ffmpegWasm
966
- );
967
- audioIncludedLocal = true;
968
- } catch (audioErr) {
969
- audioIncludedLocal = false;
970
- audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
971
- }
972
- }
973
- if (cancelledRef.current) return;
974
- const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
975
- const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
976
- const blob = new Blob([finalBytes], { type: mimeType });
977
- const url = URL.createObjectURL(blob);
978
- downloadUrlRef.current = url;
979
- setDownloadUrl(url);
980
- setFileSize(finalBytes.byteLength);
981
- setAudioIncluded(audioIncludedLocal);
982
- setAudioSkippedReason(
983
- effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
984
- );
985
- setState("complete");
986
- setProgress(100);
987
- setPhase("Export complete");
988
- setEstimatedRemaining(0);
989
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
990
- frameCapture.destroy();
991
- } catch (err) {
992
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
993
- if (cancelledRef.current) return;
994
- const message = err instanceof Error ? err.message : String(err);
995
- setState("error");
996
- setError(message);
997
- setPhase("Export failed");
998
- if (encoderRef.current) {
999
- encoderRef.current.close();
1000
- encoderRef.current = null;
1001
- }
1002
- gifAbortRef.current?.abort();
1003
- gifAbortRef.current = null;
1004
- frameCapture.destroy();
1005
- }
1006
- },
1007
- [frameCapture]
1008
- );
1009
- return {
1010
- state,
1011
- progress,
1012
- phase,
1013
- duration,
1014
- outputFormat,
1015
- backend,
1016
- downloadUrl,
1017
- fileSize,
1018
- audioIncluded,
1019
- audioSkippedReason,
1020
- error,
1021
- elapsed,
1022
- estimatedRemaining,
1023
- startExport,
1024
- cancel,
1025
- reset
1026
- };
1027
- }
1028
-
1029
- // src/VideoExportModal.tsx
1030
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1031
- function formatDuration(seconds) {
1032
- if (seconds < 60) return `${seconds}s`;
1033
- const m = Math.floor(seconds / 60);
1034
- const s = seconds % 60;
1035
- return `${m}m ${s}s`;
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
- };
1075
- var overlayStyle = {
1076
- position: "fixed",
1077
- inset: 0,
1078
- display: "flex",
1079
- alignItems: "center",
1080
- justifyContent: "center",
1081
- zIndex: 1e4
1082
- };
1083
- var modalStyle = {
1084
- borderRadius: 0,
1085
- padding: "24px 28px",
1086
- minWidth: 380,
1087
- maxWidth: 480,
1088
- boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
1089
- fontFamily: "system-ui, -apple-system, sans-serif"
1090
- };
1091
- var titleStyle = {
1092
- margin: "0 0 16px 0",
1093
- fontSize: 18,
1094
- fontWeight: 600
1095
- };
1096
- var labelStyle = {
1097
- display: "block",
1098
- fontSize: 13,
1099
- fontWeight: 500,
1100
- marginBottom: 4
1101
- };
1102
- var selectStyle = {
1103
- width: "100%",
1104
- padding: "6px 8px",
1105
- fontSize: 13,
1106
- fontFamily: "inherit",
1107
- borderRadius: 0,
1108
- marginBottom: 12
1109
- };
1110
- var btnPrimary = {
1111
- padding: "8px 20px",
1112
- fontSize: 14,
1113
- fontFamily: "inherit",
1114
- fontWeight: 500,
1115
- cursor: "pointer",
1116
- color: "#fff",
1117
- borderRadius: 0
1118
- };
1119
- var btnSecondary = {
1120
- padding: "8px 20px",
1121
- fontSize: 14,
1122
- fontFamily: "inherit",
1123
- fontWeight: 500,
1124
- cursor: "pointer",
1125
- borderRadius: 0
1126
- };
1127
- var progressBarOuterStyle = {
1128
- width: "100%",
1129
- height: 8,
1130
- borderRadius: 0,
1131
- overflow: "hidden",
1132
- marginBottom: 8
1133
- };
1134
- var footerStyle = {
1135
- display: "flex",
1136
- justifyContent: "flex-end",
1137
- gap: 8,
1138
- marginTop: 20
1139
- };
1140
- function VideoExportModal({
1141
- doc,
1142
- playerScript,
1143
- mediaProvider,
1144
- images,
1145
- audio,
1146
- defaultConfig,
1147
- colorScheme = "light",
1148
- onClose
1149
- }) {
1150
- const initialOutputFormat = defaultConfig?.outputFormat ?? "mp4";
1151
- const [outputFormat, setOutputFormat] = useState2(initialOutputFormat);
1152
- const [quality, setQuality] = useState2(defaultConfig?.quality ?? "normal");
1153
- const [fps, setFps] = useState2(defaultConfig?.fps ?? (initialOutputFormat === "gif" ? 10 : 24));
1154
- const [orientation, setOrientation] = useState2(
1155
- defaultConfig?.orientation ?? "landscape"
1156
- );
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
- };
1189
- const exportHook = useVideoExport();
1190
- const {
1191
- state,
1192
- progress,
1193
- backend,
1194
- outputFormat: completedOutputFormat,
1195
- downloadUrl,
1196
- fileSize,
1197
- audioIncluded,
1198
- audioSkippedReason,
1199
- error,
1200
- elapsed,
1201
- estimatedRemaining,
1202
- startExport,
1203
- cancel: cancelExport,
1204
- reset: resetExport
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
- }, []);
1216
- const handleExport = useCallback3(async () => {
1217
- const config = {
1218
- // defaultConfig is the base; explicit props/selections win over it.
1219
- ...defaultConfig,
1220
- outputFormat,
1221
- animationsEnabled,
1222
- quality,
1223
- fps,
1224
- orientation,
1225
- captionMode,
1226
- images,
1227
- audio,
1228
- mediaProvider,
1229
- // Only thread the bundle through when the host actually supplied one.
1230
- ...playerScript !== void 0 ? { playerScript } : {}
1231
- };
1232
- await startExport(doc, config);
1233
- }, [
1234
- doc,
1235
- outputFormat,
1236
- animationsEnabled,
1237
- quality,
1238
- fps,
1239
- orientation,
1240
- captionMode,
1241
- images,
1242
- audio,
1243
- mediaProvider,
1244
- playerScript,
1245
- defaultConfig,
1246
- startExport
1247
- ]);
1248
- const handleDownload = useCallback3(() => {
1249
- if (!downloadUrl) return;
1250
- const a = document.createElement("a");
1251
- a.href = downloadUrl;
1252
- const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
1253
- a.download = `document-${ts}.${completedOutputFormat}`;
1254
- document.body.appendChild(a);
1255
- a.click();
1256
- document.body.removeChild(a);
1257
- }, [downloadUrl, completedOutputFormat]);
1258
- const handleClose = useCallback3(() => {
1259
- if (state === "capturing" || state === "encoding" || state === "preparing") {
1260
- cancelExport();
1261
- }
1262
- resetExport();
1263
- onClose();
1264
- }, [state, cancelExport, resetExport, onClose]);
1265
- const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
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
- ] })
1461
- ] })
1462
- }
1463
- );
1464
- }
1465
-
1466
- // src/VideoExportButton.tsx
1467
- import { useState as useState3, useCallback as useCallback4 } from "react";
1468
- import { createPortal } from "react-dom";
1469
- import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1470
- function VideoExportButton({
1471
- doc,
1472
- playerScript,
1473
- mediaProvider,
1474
- images,
1475
- audio,
1476
- defaultConfig,
1477
- colorScheme,
1478
- label,
1479
- style,
1480
- disabled
1481
- }) {
1482
- const [showModal, setShowModal] = useState3(false);
1483
- const resolvedLabel = label ?? (defaultConfig?.outputFormat === "gif" ? "Export GIF" : "Export Video");
1484
- const handleOpen = useCallback4(() => setShowModal(true), []);
1485
- const handleClose = useCallback4(() => setShowModal(false), []);
1486
- return /* @__PURE__ */ jsxs2(Fragment2, { children: [
1487
- /* @__PURE__ */ jsx2("button", { onClick: handleOpen, disabled, style, children: resolvedLabel }),
1488
- showModal && createPortal(
1489
- /* @__PURE__ */ jsx2(
1490
- VideoExportModal,
1491
- {
1492
- doc,
1493
- playerScript,
1494
- mediaProvider,
1495
- images,
1496
- audio,
1497
- defaultConfig,
1498
- colorScheme,
1499
- onClose: handleClose
1500
- }
1501
- ),
1502
- document.body
1503
- )
1504
- ] });
1505
- }
10
+ createEncoder,
11
+ supportsWebCodecs,
12
+ supportsWebCodecsAac,
13
+ supportsWebCodecsH264
14
+ } from "./chunk-YZN7D4IC.js";
15
+ import "./chunk-VI5AIVOQ.js";
1506
16
  export {
1507
17
  VideoExportButton,
1508
18
  VideoExportModal,
@@ -1513,4 +23,3 @@ export {
1513
23
  useFrameCapture,
1514
24
  useVideoExport
1515
25
  };
1516
- //# sourceMappingURL=index.js.map