@bendyline/squisq-video-react 2.2.10 → 2.3.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.
@@ -1,1567 +0,0 @@
1
- import {
2
- EXPORT_AUDIO_CHANNELS,
3
- EXPORT_AUDIO_SAMPLE_RATE,
4
- audioBufferToWav,
5
- createEncoder,
6
- encodeAacTrack,
7
- muxAudioWithFfmpegWasm,
8
- renderAudioTimeline,
9
- selectAudioTier,
10
- supportsWebCodecs,
11
- supportsWebCodecsAac,
12
- supportsWebCodecsH264
13
- } from "./chunk-2XACUF6E.js";
14
-
15
- // src/hooks/useFrameCapture.ts
16
- import { createElement } from "react";
17
- import { createRoot } from "react-dom/client";
18
- import { useRef, useCallback, useMemo } from "react";
19
- import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
20
- import html2canvas from "html2canvas";
21
- var MIME_MAP = {
22
- jpg: "image/jpeg",
23
- jpeg: "image/jpeg",
24
- png: "image/png",
25
- gif: "image/gif",
26
- webp: "image/webp",
27
- svg: "image/svg+xml",
28
- bmp: "image/bmp",
29
- avif: "image/avif",
30
- mp3: "audio/mpeg",
31
- wav: "audio/wav",
32
- ogg: "audio/ogg",
33
- mp4: "video/mp4",
34
- webm: "video/webm"
35
- };
36
- var CAPTURE_ASSET_TIMEOUT_MS = 15e3;
37
- var RENDER_TIME_EPSILON_SECONDS = 1e-6;
38
- var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
39
- var CAPTURE_SVG_SELECTOR = "svg.block-svg";
40
- async function waitForImageDecode(image) {
41
- const src = image.currentSrc || image.src;
42
- if (!src) return;
43
- const decoded = typeof image.decode === "function" ? image.decode() : new Promise((resolve, reject) => {
44
- if (image.complete) {
45
- if (image.naturalWidth > 0) resolve();
46
- else reject(new Error(`Image could not be decoded: ${src}`));
47
- return;
48
- }
49
- image.addEventListener("load", () => resolve(), { once: true });
50
- image.addEventListener(
51
- "error",
52
- () => reject(new Error(`Image could not be loaded: ${src}`)),
53
- {
54
- once: true
55
- }
56
- );
57
- });
58
- let timeout;
59
- try {
60
- await Promise.race([
61
- decoded,
62
- new Promise((_resolve, reject) => {
63
- timeout = setTimeout(
64
- () => reject(new Error(`Image did not become ready within 15s: ${src}`)),
65
- CAPTURE_ASSET_TIMEOUT_MS
66
- );
67
- })
68
- ]);
69
- } finally {
70
- if (timeout !== void 0) clearTimeout(timeout);
71
- }
72
- }
73
- async function waitForCaptureAssets(captureRoot, decodedImages = /* @__PURE__ */ new WeakSet()) {
74
- const fonts = captureRoot.ownerDocument.fonts;
75
- if (fonts) await fonts.ready;
76
- const pendingImages = Array.from(captureRoot.querySelectorAll("img")).filter(
77
- (image) => !decodedImages.has(image)
78
- );
79
- await Promise.all(
80
- pendingImages.map(async (image) => {
81
- await waitForImageDecode(image);
82
- decodedImages.add(image);
83
- })
84
- );
85
- }
86
- function createInlineProvider(images) {
87
- const blobUrls = /* @__PURE__ */ new Map();
88
- const mimeTypes = /* @__PURE__ */ new Map();
89
- for (const [path, buffer] of images) {
90
- const ext = path.split(".").pop()?.toLowerCase() ?? "";
91
- const mime = MIME_MAP[ext] ?? "application/octet-stream";
92
- blobUrls.set(path, URL.createObjectURL(new Blob([buffer], { type: mime })));
93
- mimeTypes.set(path, mime);
94
- }
95
- return {
96
- async resolveUrl(relativePath) {
97
- return blobUrls.get(relativePath) ?? relativePath;
98
- },
99
- async listMedia() {
100
- return [...blobUrls.keys()].map((name) => ({
101
- name,
102
- mimeType: mimeTypes.get(name) ?? "application/octet-stream",
103
- size: images.get(name)?.byteLength ?? 0
104
- }));
105
- },
106
- async addMedia() {
107
- throw new Error("Read-only");
108
- },
109
- async removeMedia() {
110
- throw new Error("Read-only");
111
- },
112
- dispose() {
113
- blobUrls.forEach((url) => URL.revokeObjectURL(url));
114
- blobUrls.clear();
115
- }
116
- };
117
- }
118
- function shouldIgnoreCaptureSibling(element, captureRoot) {
119
- const { head } = captureRoot.ownerDocument;
120
- const isInDocumentHead = element === head || head.contains(element);
121
- const isInCaptureBranch = element === captureRoot || element.contains(captureRoot) || captureRoot.contains(element);
122
- return !isInDocumentHead && !isInCaptureBranch;
123
- }
124
- function finiteMediaTime(value) {
125
- return Number.isFinite(value) ? value.toFixed(6) : "unknown";
126
- }
127
- function coverSourceRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight) {
128
- const sourceRatio = sourceWidth / sourceHeight;
129
- const destinationRatio = destinationWidth / destinationHeight;
130
- if (sourceRatio > destinationRatio) {
131
- const sw = sourceHeight * destinationRatio;
132
- return { sx: (sourceWidth - sw) / 2, sy: 0, sw, sh: sourceHeight };
133
- }
134
- const sh = sourceWidth / destinationRatio;
135
- return { sx: 0, sy: (sourceHeight - sh) / 2, sw: sourceWidth, sh };
136
- }
137
- function videoFrameRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight, objectFit) {
138
- if (objectFit === "cover") {
139
- return {
140
- ...coverSourceRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight),
141
- dx: 0,
142
- dy: 0,
143
- dw: destinationWidth,
144
- dh: destinationHeight
145
- };
146
- }
147
- if (objectFit === "contain" || objectFit === "scale-down") {
148
- const containScale = Math.min(destinationWidth / sourceWidth, destinationHeight / sourceHeight);
149
- const scale = objectFit === "scale-down" ? Math.min(1, containScale) : containScale;
150
- const dw = sourceWidth * scale;
151
- const dh = sourceHeight * scale;
152
- return {
153
- sx: 0,
154
- sy: 0,
155
- sw: sourceWidth,
156
- sh: sourceHeight,
157
- dx: (destinationWidth - dw) / 2,
158
- dy: (destinationHeight - dh) / 2,
159
- dw,
160
- dh
161
- };
162
- }
163
- if (objectFit === "none") {
164
- return {
165
- sx: 0,
166
- sy: 0,
167
- sw: sourceWidth,
168
- sh: sourceHeight,
169
- dx: (destinationWidth - sourceWidth) / 2,
170
- dy: (destinationHeight - sourceHeight) / 2,
171
- dw: sourceWidth,
172
- dh: sourceHeight
173
- };
174
- }
175
- return {
176
- sx: 0,
177
- sy: 0,
178
- sw: sourceWidth,
179
- sh: sourceHeight,
180
- dx: 0,
181
- dy: 0,
182
- dw: destinationWidth,
183
- dh: destinationHeight
184
- };
185
- }
186
- function prepareScheduledVideoClones(originalRoot, clonedRoot) {
187
- const captureFamilies = [
188
- {
189
- original: ".doc-player__media-clips video[data-clip-id]",
190
- clone: ".doc-player__media-clips canvas"
191
- },
192
- {
193
- original: ".block-layer--video video[data-clip-start]",
194
- clone: ".block-layer--video canvas"
195
- }
196
- ];
197
- const pairs = captureFamilies.flatMap(({ original, clone }) => {
198
- const videos = Array.from(originalRoot.querySelectorAll(original));
199
- const canvases = Array.from(clonedRoot.querySelectorAll(clone));
200
- return videos.flatMap((video, index) => {
201
- const canvas = canvases[index];
202
- return canvas ? [{ video, canvas }] : [];
203
- });
204
- });
205
- const preparedCanvases = pairs.map(({ canvas }) => canvas);
206
- pairs.forEach(({ video, canvas }) => {
207
- canvas.className = video.className;
208
- canvas.style.cssText = video.style.cssText;
209
- for (const attribute of Array.from(video.attributes)) {
210
- if (attribute.name.startsWith("data-")) {
211
- canvas.setAttribute(attribute.name, attribute.value);
212
- }
213
- }
214
- canvas.dataset.videoCaptureClone = "true";
215
- const destinationWidth = Math.round(video.clientWidth || video.offsetWidth);
216
- const destinationHeight = Math.round(video.clientHeight || video.offsetHeight);
217
- if (video.videoWidth <= 0 || video.videoHeight <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
218
- return;
219
- }
220
- try {
221
- const view = video.ownerDocument.defaultView;
222
- const objectFit = video.style.objectFit || view?.getComputedStyle(video).objectFit || "fill";
223
- const frame = videoFrameRect(
224
- video.videoWidth,
225
- video.videoHeight,
226
- destinationWidth,
227
- destinationHeight,
228
- objectFit
229
- );
230
- const context = canvas.getContext("2d");
231
- if (!context) return;
232
- canvas.width = destinationWidth;
233
- canvas.height = destinationHeight;
234
- context.drawImage(
235
- video,
236
- frame.sx,
237
- frame.sy,
238
- frame.sw,
239
- frame.sh,
240
- frame.dx,
241
- frame.dy,
242
- frame.dw,
243
- frame.dh
244
- );
245
- const foreignObject = canvas.closest("foreignObject");
246
- const svg = canvas.closest("svg");
247
- if (foreignObject && svg) {
248
- const originalHost = video.closest(".doc-player__block") ?? originalRoot;
249
- const clonedHost = svg.closest(".doc-player__block") ?? clonedRoot;
250
- const videoRect = video.getBoundingClientRect();
251
- const hostRect = originalHost.getBoundingClientRect();
252
- const renderedWidth = videoRect.width || destinationWidth;
253
- const renderedHeight = videoRect.height || destinationHeight;
254
- const fallbackX = Number.parseFloat(foreignObject.getAttribute("x") ?? "0") || 0;
255
- const fallbackY = Number.parseFloat(foreignObject.getAttribute("y") ?? "0") || 0;
256
- const left = videoRect.width ? videoRect.left - hostRect.left : fallbackX;
257
- const top = videoRect.height ? videoRect.top - hostRect.top : fallbackY;
258
- foreignObject.remove();
259
- if (clonedHost === clonedRoot && !clonedHost.style.position) {
260
- clonedHost.style.position = "relative";
261
- }
262
- canvas.style.position = "absolute";
263
- canvas.style.left = `${left}px`;
264
- canvas.style.top = `${top}px`;
265
- canvas.style.width = `${renderedWidth}px`;
266
- canvas.style.height = `${renderedHeight}px`;
267
- canvas.style.zIndex = "3";
268
- canvas.style.margin = "0";
269
- canvas.style.transform = "none";
270
- clonedHost.appendChild(canvas);
271
- }
272
- } catch {
273
- }
274
- });
275
- return preparedCanvases;
276
- }
277
- function parseAbsoluteSvgLength(value) {
278
- if (!value) return 0;
279
- const match = /^\s*(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
280
- return match ? Number.parseFloat(match[1]) : 0;
281
- }
282
- function svgViewBoxSize(svg) {
283
- const values = (svg.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/).map(Number);
284
- if (values.length === 4 && values.every(Number.isFinite)) {
285
- return { width: Math.max(0, values[2]), height: Math.max(0, values[3]) };
286
- }
287
- return { width: 0, height: 0 };
288
- }
289
- function captureSvgRasterSize(clonedSvg, originalSvg) {
290
- const clonedRect = clonedSvg.getBoundingClientRect();
291
- const originalRect = originalSvg?.getBoundingClientRect();
292
- const clonedViewBox = svgViewBoxSize(clonedSvg);
293
- const originalViewBox = originalSvg ? svgViewBoxSize(originalSvg) : { width: 0, height: 0 };
294
- const width = clonedRect.width || originalRect?.width || parseAbsoluteSvgLength(clonedSvg.getAttribute("width")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("width")) : 0) || clonedViewBox.width || originalViewBox.width;
295
- const height = clonedRect.height || originalRect?.height || parseAbsoluteSvgLength(clonedSvg.getAttribute("height")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("height")) : 0) || clonedViewBox.height || originalViewBox.height;
296
- return {
297
- width: Math.max(1, Math.round(width)),
298
- height: Math.max(1, Math.round(height))
299
- };
300
- }
301
- function copyCaptureSvgPresentation(svg, canvas) {
302
- for (const attribute of Array.from(svg.attributes)) {
303
- if (attribute.name === "id" || attribute.name === "class" || attribute.name === "style" || attribute.name.startsWith("data-") || attribute.name.startsWith("aria-")) {
304
- canvas.setAttribute(attribute.name, attribute.value);
305
- }
306
- }
307
- canvas.dataset.svgCaptureClone = "true";
308
- }
309
- function captureImageMimeType(source, blob) {
310
- if (blob.type) return blob.type;
311
- const path = source.split(/[?#]/, 1)[0];
312
- const ext = path.split(".").pop()?.toLowerCase() ?? "";
313
- return MIME_MAP[ext] ?? "application/octet-stream";
314
- }
315
- function blobToDataUrl(blob, source) {
316
- const typedBlob = blob.type ? blob : blob.slice(0, blob.size, captureImageMimeType(source, blob));
317
- return new Promise((resolve, reject) => {
318
- const reader = new FileReader();
319
- reader.addEventListener(
320
- "load",
321
- () => {
322
- if (typeof reader.result === "string") resolve(reader.result);
323
- else reject(new Error(`Image could not be embedded for SVG capture: ${source}`));
324
- },
325
- { once: true }
326
- );
327
- reader.addEventListener(
328
- "error",
329
- () => reject(reader.error ?? new Error(`Image could not be read for SVG capture: ${source}`)),
330
- { once: true }
331
- );
332
- reader.readAsDataURL(typedBlob);
333
- });
334
- }
335
- function resolveCaptureImageDataUrl(source, cache) {
336
- const cached = cache.get(source);
337
- if (cached) return cached;
338
- const pending = fetch(source).then(async (response) => {
339
- if (!response.ok) return null;
340
- return blobToDataUrl(await response.blob(), source);
341
- }).catch(() => null);
342
- cache.set(source, pending);
343
- return pending;
344
- }
345
- function captureImageReference(element) {
346
- if (element.localName === "img") {
347
- const source2 = element.getAttribute("src") ?? "";
348
- return source2 ? {
349
- source: source2,
350
- replace: (dataUrl) => element.setAttribute("src", dataUrl)
351
- } : null;
352
- }
353
- const xlinkNamespace = "http://www.w3.org/1999/xlink";
354
- const source = element.getAttribute("href") ?? element.getAttributeNS(xlinkNamespace, "href") ?? "";
355
- return source ? {
356
- source,
357
- replace: (dataUrl) => {
358
- if (element.hasAttribute("href")) element.setAttribute("href", dataUrl);
359
- if (element.hasAttributeNS(xlinkNamespace, "href")) {
360
- element.setAttributeNS(xlinkNamespace, "href", dataUrl);
361
- }
362
- }
363
- } : null;
364
- }
365
- async function embedCaptureSvgImages(svg, cache) {
366
- const references = Array.from(svg.querySelectorAll("image, img")).map(captureImageReference).filter((reference) => reference !== null);
367
- for (const reference of references) {
368
- if (/^data:/i.test(reference.source) || reference.source.startsWith("#")) continue;
369
- const dataUrl = await resolveCaptureImageDataUrl(reference.source, cache);
370
- if (!dataUrl) return false;
371
- reference.replace(dataUrl);
372
- }
373
- return true;
374
- }
375
- async function rasterizeCaptureSvgClones(originalRoot, clonedRoot, transientCanvases = [], imageDataUrls = /* @__PURE__ */ new Map()) {
376
- if (typeof createImageBitmap !== "function") return transientCanvases;
377
- const originalSvgs = Array.from(
378
- originalRoot.querySelectorAll(CAPTURE_SVG_SELECTOR)
379
- );
380
- const clonedSvgs = Array.from(clonedRoot.querySelectorAll(CAPTURE_SVG_SELECTOR));
381
- for (const [index, svg] of clonedSvgs.entries()) {
382
- const { width, height } = captureSvgRasterSize(svg, originalSvgs[index]);
383
- svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
384
- svg.setAttribute("width", String(width));
385
- svg.setAttribute("height", String(height));
386
- let bitmap = null;
387
- let replacement = null;
388
- try {
389
- if (!await embedCaptureSvgImages(svg, imageDataUrls)) continue;
390
- const serializedSvg = new XMLSerializer().serializeToString(svg);
391
- bitmap = await createImageBitmap(new Blob([serializedSvg], { type: "image/svg+xml" }));
392
- replacement = svg.ownerDocument.createElement("canvas");
393
- replacement.width = width;
394
- replacement.height = height;
395
- copyCaptureSvgPresentation(svg, replacement);
396
- const context = replacement.getContext("2d");
397
- if (!context) {
398
- replacement.width = 0;
399
- replacement.height = 0;
400
- continue;
401
- }
402
- context.drawImage(bitmap, 0, 0, width, height);
403
- svg.replaceWith(replacement);
404
- transientCanvases.push(replacement);
405
- } catch {
406
- if (replacement && !replacement.isConnected) {
407
- replacement.width = 0;
408
- replacement.height = 0;
409
- }
410
- } finally {
411
- bitmap?.close();
412
- }
413
- }
414
- return transientCanvases;
415
- }
416
- function releaseCaptureCloneCanvases(canvases) {
417
- canvases.forEach((canvas) => {
418
- canvas.width = 0;
419
- canvas.height = 0;
420
- });
421
- }
422
- function getFrameVisualStateKey(captureRoot, timelineTime) {
423
- const markup = captureRoot.innerHTML;
424
- let needsTimelineKey = false;
425
- const animationStates = [];
426
- if (typeof captureRoot.getAnimations === "function") {
427
- const animations = captureRoot.getAnimations({ subtree: true });
428
- animations.forEach((animation, index) => {
429
- try {
430
- const timing = animation.effect?.getComputedTiming();
431
- if (!timing) {
432
- needsTimelineKey = true;
433
- return;
434
- }
435
- animationStates.push(
436
- `${index}:${animation.playState}:${String(timing.progress)}:${String(
437
- timing.currentIteration
438
- )}`
439
- );
440
- } catch {
441
- needsTimelineKey = true;
442
- }
443
- });
444
- } else if (/\b(?:anim-|transition-)|animation(?:-name)?\s*:/i.test(markup)) {
445
- needsTimelineKey = true;
446
- }
447
- const imageStates = Array.from(captureRoot.querySelectorAll("img")).map((image) => {
448
- const src = image.currentSrc || image.src;
449
- if (POTENTIALLY_ANIMATED_IMAGE_URL.test(src)) needsTimelineKey = true;
450
- return `${src}:${image.complete}:${image.naturalWidth}x${image.naturalHeight}`;
451
- });
452
- if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
453
- const videoStates = Array.from(captureRoot.querySelectorAll("video")).map(
454
- (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
455
- );
456
- if (captureRoot.querySelector(
457
- "canvas, iframe, object, embed, animate, animateMotion, animateTransform, set"
458
- )) {
459
- needsTimelineKey = true;
460
- }
461
- const fontStatus = captureRoot.ownerDocument.fonts?.status ?? "unsupported";
462
- return JSON.stringify({
463
- markup,
464
- animationStates,
465
- imageStates,
466
- videoStates,
467
- fontStatus,
468
- timelineTime: needsTimelineKey ? timelineTime.toFixed(6) : null
469
- });
470
- }
471
- function useFrameCapture() {
472
- const containerRef = useRef(null);
473
- const rootRef = useRef(null);
474
- const renderAPIRef = useRef(null);
475
- const mediaProviderRef = useRef(null);
476
- const captureCanvasRef = useRef(null);
477
- const lastVisualStateKeyRef = useRef(null);
478
- const hasCapturedFrameRef = useRef(false);
479
- const decodedImagesRef = useRef(/* @__PURE__ */ new WeakSet());
480
- const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
481
- const dimensionsRef = useRef({ width: 1920, height: 1080 });
482
- const init = useCallback(
483
- async (doc, renderOptions, captionMode) => {
484
- if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current) {
485
- const oldRoot = rootRef.current;
486
- const oldContainer = containerRef.current;
487
- const oldMediaProvider = mediaProviderRef.current;
488
- const oldCaptureCanvas = captureCanvasRef.current;
489
- rootRef.current = null;
490
- containerRef.current = null;
491
- renderAPIRef.current = null;
492
- mediaProviderRef.current = null;
493
- captureCanvasRef.current = null;
494
- lastVisualStateKeyRef.current = null;
495
- hasCapturedFrameRef.current = false;
496
- decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
497
- captureImageDataUrlsRef.current.clear();
498
- await new Promise((resolve) => {
499
- setTimeout(() => {
500
- if (oldRoot) oldRoot.unmount();
501
- if (oldContainer) oldContainer.remove();
502
- oldMediaProvider?.dispose();
503
- if (oldCaptureCanvas) {
504
- oldCaptureCanvas.width = 0;
505
- oldCaptureCanvas.height = 0;
506
- }
507
- resolve();
508
- }, 0);
509
- });
510
- }
511
- const width = renderOptions.width ?? 1920;
512
- const height = renderOptions.height ?? 1080;
513
- const animationsEnabled = renderOptions.animationsEnabled ?? true;
514
- dimensionsRef.current = { width, height };
515
- const captureCanvas = document.createElement("canvas");
516
- captureCanvas.width = width;
517
- captureCanvas.height = height;
518
- captureCanvas.style.width = `${width}px`;
519
- captureCanvas.style.height = `${height}px`;
520
- captureCanvasRef.current = captureCanvas;
521
- lastVisualStateKeyRef.current = null;
522
- hasCapturedFrameRef.current = false;
523
- decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
524
- captureImageDataUrlsRef.current.clear();
525
- const container = document.createElement("div");
526
- 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;`;
527
- document.body.appendChild(container);
528
- containerRef.current = container;
529
- const renderRoot = document.createElement("div");
530
- renderRoot.id = "squisq-capture-root";
531
- renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
532
- container.appendChild(renderRoot);
533
- const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : null;
534
- mediaProviderRef.current = mediaProvider;
535
- const root = createRoot(renderRoot);
536
- rootRef.current = root;
537
- const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
538
- const captionStyle = captionMode === "social" ? "social" : "standard";
539
- let resolveRenderAPI;
540
- const renderAPIReady = new Promise((resolve) => {
541
- resolveRenderAPI = resolve;
542
- });
543
- const playerElement = createElement(DocPlayer, {
544
- doc,
545
- basePath: ".",
546
- renderMode: true,
547
- animationsEnabled,
548
- showControls: false,
549
- autoPlay: false,
550
- forceViewport: { width, height, name: "export" },
551
- theme: renderOptions.theme,
552
- videoPresentation: renderOptions.videoPresentation,
553
- pipSize: renderOptions.pipSize,
554
- pipShape: renderOptions.pipShape,
555
- pipPosition: renderOptions.pipPosition,
556
- showCoverSlide: renderOptions.showCoverSlide,
557
- captionsEnabled,
558
- captionStyle,
559
- onRenderAPIReady: (api) => {
560
- if (containerRef.current !== container) return;
561
- renderAPIRef.current = api;
562
- if (api) resolveRenderAPI(api);
563
- }
564
- });
565
- await new Promise((resolve) => setTimeout(resolve, 0));
566
- if (mediaProvider) {
567
- root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
568
- } else {
569
- root.render(playerElement);
570
- }
571
- return new Promise((resolve, reject) => {
572
- const timeout = setTimeout(() => {
573
- const api = renderAPIRef.current;
574
- const hasSeek = typeof api?.seekTo === "function";
575
- const hasDur = typeof api?.getDuration === "function";
576
- const rootEl = containerRef.current?.querySelector("#squisq-capture-root");
577
- const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
578
- reject(
579
- new Error(
580
- `Render API did not initialize within 15s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
581
- )
582
- );
583
- }, 15e3);
584
- void renderAPIReady.then(async (api) => {
585
- try {
586
- const captureRoot = container.querySelector("#squisq-capture-root");
587
- if (!(captureRoot instanceof HTMLElement)) {
588
- throw new Error("Capture root element not found after player initialization.");
589
- }
590
- await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
591
- clearTimeout(timeout);
592
- resolve(api.getDuration());
593
- } catch (assetError) {
594
- clearTimeout(timeout);
595
- reject(assetError);
596
- }
597
- });
598
- });
599
- },
600
- []
601
- );
602
- const setCoverVisible = useCallback(async (visible) => {
603
- const api = renderAPIRef.current;
604
- if (!api) throw new Error("Frame capture not initialized \xE2\u20AC\u201D call init() first");
605
- if (visible) await api.showCover();
606
- else await api.hideCover();
607
- lastVisualStateKeyRef.current = null;
608
- }, []);
609
- const captureCanvasFrame = useCallback(
610
- async (time, options = {}) => {
611
- const container = containerRef.current;
612
- const api = renderAPIRef.current;
613
- const captureCanvas = captureCanvasRef.current;
614
- if (!container || !api || !captureCanvas) {
615
- throw new Error("Frame capture not initialized \u2014 call init() first");
616
- }
617
- const { width, height } = dimensionsRef.current;
618
- await api.seekTo(time);
619
- const renderedTime = api.getRenderedTime();
620
- if (Math.abs(renderedTime - time) > RENDER_TIME_EPSILON_SECONDS) {
621
- throw new Error(
622
- `Player committed ${renderedTime.toFixed(6)}s while capture requested ${time.toFixed(6)}s.`
623
- );
624
- }
625
- const root = container.querySelector("#squisq-capture-root");
626
- if (!root) {
627
- throw new Error("Capture root element not found");
628
- }
629
- await waitForCaptureAssets(root, decodedImagesRef.current);
630
- const visualStateKey = options.reuseIfUnchanged ? getFrameVisualStateKey(root, time) : null;
631
- if (visualStateKey !== null && hasCapturedFrameRef.current && lastVisualStateKeyRef.current === visualStateKey) {
632
- return captureCanvas;
633
- }
634
- const captureContext = captureCanvas.getContext("2d");
635
- if (!captureContext) throw new Error("Could not create the frame capture canvas context");
636
- captureContext.setTransform(1, 0, 0, 1, 0, 0);
637
- captureContext.clearRect(0, 0, width, height);
638
- const transientCloneCanvases = [];
639
- let canvas;
640
- try {
641
- canvas = await html2canvas(root, {
642
- canvas: captureCanvas,
643
- width,
644
- height,
645
- scale: 1,
646
- useCORS: true,
647
- allowTaint: true,
648
- backgroundColor: "#000000",
649
- logging: false,
650
- onclone: async (_clonedDocument, clonedRoot) => {
651
- transientCloneCanvases.push(...prepareScheduledVideoClones(root, clonedRoot));
652
- await rasterizeCaptureSvgClones(
653
- root,
654
- clonedRoot,
655
- transientCloneCanvases,
656
- captureImageDataUrlsRef.current
657
- );
658
- },
659
- // html2canvas starts cloning at documentElement. Do not clone the rest
660
- // of the editor/site UI on every frame; only the capture root, its
661
- // ancestors, descendants, and document styles can affect this render.
662
- ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
663
- });
664
- } finally {
665
- releaseCaptureCloneCanvases(transientCloneCanvases);
666
- }
667
- hasCapturedFrameRef.current = true;
668
- lastVisualStateKeyRef.current = visualStateKey;
669
- return canvas;
670
- },
671
- []
672
- );
673
- const captureFrame = useCallback(
674
- async (time, options = {}) => {
675
- const canvas = await captureCanvasFrame(time, options);
676
- return createImageBitmap(canvas);
677
- },
678
- [captureCanvasFrame]
679
- );
680
- const destroy = useCallback(() => {
681
- if (rootRef.current) {
682
- rootRef.current.unmount();
683
- rootRef.current = null;
684
- }
685
- if (containerRef.current) {
686
- containerRef.current.remove();
687
- containerRef.current = null;
688
- }
689
- mediaProviderRef.current?.dispose();
690
- mediaProviderRef.current = null;
691
- if (captureCanvasRef.current) {
692
- captureCanvasRef.current.width = 0;
693
- captureCanvasRef.current.height = 0;
694
- captureCanvasRef.current = null;
695
- }
696
- lastVisualStateKeyRef.current = null;
697
- hasCapturedFrameRef.current = false;
698
- decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
699
- captureImageDataUrlsRef.current.clear();
700
- renderAPIRef.current = null;
701
- }, []);
702
- return useMemo(
703
- () => ({ init, setCoverVisible, captureFrame, captureCanvasFrame, destroy }),
704
- [init, setCoverVisible, captureFrame, captureCanvasFrame, destroy]
705
- );
706
- }
707
-
708
- // src/hooks/useVideoExport.ts
709
- import { useState, useRef as useRef2, useCallback as useCallback2, useEffect } from "react";
710
- import {
711
- DEFAULT_INTERACTIVE_RESOURCE_POLICY,
712
- fetchResourceBytes
713
- } from "@bendyline/squisq/markdown";
714
- import {
715
- resolveDimensions,
716
- computeAudioTimeline,
717
- resolveFfmpegWasmLoad as resolveFfmpegWasmLoad2,
718
- QUALITY_PRESETS
719
- } from "@bendyline/squisq-video";
720
-
721
- // src/workerEncoder.ts
722
- import { validateVideoExportOptions } from "@bendyline/squisq-video";
723
- function createWorkerEncoder(config) {
724
- validateVideoExportOptions(config);
725
- const worker = new Worker(new URL("./workers/encode.worker.js", import.meta.url), {
726
- type: "module"
727
- });
728
- let state = "open";
729
- let fatalError = null;
730
- let finalizeResolve = null;
731
- let finalizeReject = null;
732
- let readyResolve = null;
733
- let readyReject = null;
734
- let readySettled = false;
735
- const frameWaiters = /* @__PURE__ */ new Map();
736
- const ready = new Promise((resolve, reject) => {
737
- readyResolve = resolve;
738
- readyReject = reject;
739
- });
740
- const frameDuration = 1e6 / config.fps;
741
- function post(msg, transfer) {
742
- worker.postMessage(msg, transfer ?? []);
743
- }
744
- const currentState = () => state;
745
- worker.onmessage = (event) => {
746
- const msg = event.data;
747
- switch (msg.type) {
748
- case "capabilities":
749
- readySettled = true;
750
- readyResolve?.(msg.backend);
751
- readyResolve = readyReject = null;
752
- break;
753
- case "frame-complete": {
754
- const waiter = frameWaiters.get(msg.frameIndex);
755
- waiter?.resolve();
756
- frameWaiters.delete(msg.frameIndex);
757
- break;
758
- }
759
- case "complete":
760
- state = "closed";
761
- finalizeResolve?.(msg.data);
762
- finalizeResolve = finalizeReject = null;
763
- worker.terminate();
764
- break;
765
- case "error": {
766
- const err = new Error(msg.message);
767
- fatalError = err;
768
- state = "closed";
769
- readySettled = true;
770
- readyReject?.(err);
771
- finalizeReject?.(err);
772
- for (const waiter of frameWaiters.values()) waiter.reject(err);
773
- frameWaiters.clear();
774
- readyResolve = readyReject = null;
775
- finalizeResolve = finalizeReject = null;
776
- worker.terminate();
777
- break;
778
- }
779
- }
780
- };
781
- worker.onerror = (event) => {
782
- const err = new Error(event.message || "Worker error");
783
- fatalError = err;
784
- state = "closed";
785
- readySettled = true;
786
- readyReject?.(err);
787
- finalizeReject?.(err);
788
- for (const waiter of frameWaiters.values()) waiter.reject(err);
789
- frameWaiters.clear();
790
- readyResolve = readyReject = null;
791
- finalizeResolve = finalizeReject = null;
792
- worker.terminate();
793
- };
794
- post({
795
- type: "init",
796
- width: config.width,
797
- height: config.height,
798
- fps: config.fps,
799
- quality: config.quality,
800
- ...config.totalFrames !== void 0 ? { totalFrames: config.totalFrames } : {},
801
- ...config.ffmpegWasm ? { ffmpegWasm: config.ffmpegWasm } : {}
802
- });
803
- return {
804
- ready,
805
- encodeFrame(frame, frameIndex) {
806
- if (typeof HTMLCanvasElement !== "undefined" && frame instanceof HTMLCanvasElement) {
807
- return Promise.reject(new Error("Worker encoding requires a transferable ImageBitmap"));
808
- }
809
- const bitmap = frame;
810
- if (state !== "open" || fatalError) {
811
- bitmap.close();
812
- return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
813
- }
814
- if (frameWaiters.has(frameIndex)) {
815
- bitmap.close();
816
- return Promise.reject(new Error(`Frame ${frameIndex} was submitted more than once`));
817
- }
818
- let resolveFrame;
819
- let rejectFrame;
820
- const promise = new Promise((resolve, reject) => {
821
- resolveFrame = resolve;
822
- rejectFrame = reject;
823
- });
824
- frameWaiters.set(frameIndex, { promise, resolve: resolveFrame, reject: rejectFrame });
825
- const timestamp = Math.round(frameIndex * frameDuration);
826
- post({ type: "frame", bitmap, frameIndex, timestamp }, [bitmap]);
827
- return promise;
828
- },
829
- async finalize() {
830
- if (state !== "open") throw new Error("Encoder already closed or finalizing");
831
- if (fatalError) throw fatalError;
832
- state = "finalizing";
833
- await Promise.all(Array.from(frameWaiters.values(), (waiter) => waiter.promise));
834
- if (currentState() === "closed") {
835
- throw fatalError ?? new Error("Encoder closed during finalization");
836
- }
837
- return new Promise((resolve, reject) => {
838
- finalizeResolve = resolve;
839
- finalizeReject = reject;
840
- post({ type: "finalize" });
841
- });
842
- },
843
- close() {
844
- if (state === "closed") return;
845
- state = "closed";
846
- const err = new Error("Encoder closed");
847
- if (!readySettled) {
848
- readySettled = true;
849
- readyReject?.(err);
850
- }
851
- finalizeReject?.(err);
852
- for (const waiter of frameWaiters.values()) waiter.reject(err);
853
- frameWaiters.clear();
854
- readyResolve = readyReject = null;
855
- finalizeResolve = finalizeReject = null;
856
- post({ type: "cancel" });
857
- worker.terminate();
858
- }
859
- };
860
- }
861
-
862
- // src/gifTranscode.ts
863
- import {
864
- ffmpegGifPaletteApplicationArgs,
865
- ffmpegGifPaletteGenerationFilter,
866
- resolveFfmpegWasmLoad
867
- } from "@bendyline/squisq-video";
868
- function buildGifPaletteFfmpegArgs(options) {
869
- return [
870
- "-y",
871
- "-i",
872
- "video.mp4",
873
- "-vf",
874
- ffmpegGifPaletteGenerationFilter(options),
875
- "-frames:v",
876
- "1",
877
- "palette.png"
878
- ];
879
- }
880
- function buildGifFfmpegArgs(options) {
881
- return [
882
- "-y",
883
- "-i",
884
- "video.mp4",
885
- "-i",
886
- "palette.png",
887
- ...ffmpegGifPaletteApplicationArgs(options),
888
- "out.gif"
889
- ];
890
- }
891
- var FFMPEG_ERRORISH = /error|invalid|failed|out of memory|memory access|abort|unable to/i;
892
- function ffmpegFailureDetail(logs) {
893
- const lines = logs.map((line) => line.trim()).filter(Boolean);
894
- return lines.find((line) => FFMPEG_ERRORISH.test(line)) ?? lines.at(-1) ?? null;
895
- }
896
- async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
897
- if (videoMp4.byteLength === 0) {
898
- throw new Error("Cannot create an animated GIF from an empty MP4.");
899
- }
900
- if (signal?.aborted) {
901
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
902
- }
903
- const load = resolveFfmpegWasmLoad(loadConfig, "Animated GIF export", {
904
- classWorkerURL: new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
905
- });
906
- const paletteArgs = buildGifPaletteFfmpegArgs(options);
907
- const gifArgs = buildGifFfmpegArgs(options);
908
- const { FFmpeg } = await import("@ffmpeg/ffmpeg");
909
- const ffmpeg = new FFmpeg();
910
- const recentLogs = [];
911
- const handleLog = ({ message }) => {
912
- recentLogs.push(message);
913
- if (recentLogs.length > 40) recentLogs.shift();
914
- };
915
- ffmpeg.on("log", handleLog);
916
- let terminated = false;
917
- const terminate = () => {
918
- if (terminated) return;
919
- terminated = true;
920
- ffmpeg.terminate();
921
- };
922
- const handleAbort = () => terminate();
923
- signal?.addEventListener("abort", handleAbort, { once: true });
924
- try {
925
- await ffmpeg.load(load);
926
- if (signal?.aborted) {
927
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
928
- }
929
- await ffmpeg.writeFile("video.mp4", videoMp4);
930
- if (signal?.aborted) {
931
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
932
- }
933
- const execPhase = async (args, phase) => {
934
- recentLogs.length = 0;
935
- let exitCode;
936
- try {
937
- exitCode = await ffmpeg.exec(args);
938
- } catch (caught) {
939
- if (signal?.aborted) {
940
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
941
- }
942
- const detail = ffmpegFailureDetail(recentLogs);
943
- const fallback = caught instanceof Error ? caught.message : String(caught);
944
- throw new Error(`ffmpeg.wasm GIF transcode failed during ${phase}: ${detail ?? fallback}`);
945
- }
946
- if (signal?.aborted) {
947
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
948
- }
949
- if (exitCode !== 0) {
950
- const detail = ffmpegFailureDetail(recentLogs);
951
- throw new Error(
952
- `ffmpeg.wasm GIF transcode failed during ${phase} with exit code ${exitCode}` + (detail ? `: ${detail}` : "")
953
- );
954
- }
955
- };
956
- await execPhase(paletteArgs, "palette generation");
957
- await execPhase(gifArgs, "palette application");
958
- await ffmpeg.deleteFile("video.mp4").catch(() => false);
959
- await ffmpeg.deleteFile("palette.png").catch(() => false);
960
- const data = await ffmpeg.readFile("out.gif");
961
- return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
962
- } finally {
963
- signal?.removeEventListener("abort", handleAbort);
964
- ffmpeg.off("log", handleLog);
965
- terminate();
966
- }
967
- }
968
-
969
- // src/hooks/useVideoExport.ts
970
- var MAX_EXPORT_MEDIA_FILES = 256;
971
- var ENCODER_PROBE_TIMEOUT_MS = 5e3;
972
- var ENCODER_START_TIMEOUT_MS = 6e4;
973
- var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
974
- var FRAME_ENCODE_TIMEOUT_MS = 6e4;
975
- var CAPTURE_PROGRESS_START = 7;
976
- var CAPTURE_PROGRESS_END = 95;
977
- var FRAME_RATE_WINDOW_SIZE = 30;
978
- var DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS = 2;
979
- function calculateRollingFramesPerSecond(frameBoundaryTimes) {
980
- if (frameBoundaryTimes.length < 2) return null;
981
- const firstIndex = Math.max(0, frameBoundaryTimes.length - (FRAME_RATE_WINDOW_SIZE + 1));
982
- const elapsedMs = frameBoundaryTimes[frameBoundaryTimes.length - 1] - frameBoundaryTimes[firstIndex];
983
- const completedFrames = frameBoundaryTimes.length - 1 - firstIndex;
984
- if (elapsedMs <= 0 || completedFrames <= 0) return null;
985
- return completedFrames * 1e3 / elapsedMs;
986
- }
987
- function releaseEncoderFrame(frame) {
988
- if ("close" in frame) frame.close();
989
- }
990
- function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
991
- return new Promise((resolve, reject) => {
992
- let settled = false;
993
- const timeout = globalThis.setTimeout(() => {
994
- settled = true;
995
- reject(new Error(timeoutMessage));
996
- }, timeoutMs);
997
- void operation.then(
998
- (value) => {
999
- if (settled) {
1000
- onLateResult?.(value);
1001
- return;
1002
- }
1003
- settled = true;
1004
- globalThis.clearTimeout(timeout);
1005
- resolve(value);
1006
- },
1007
- (caught) => {
1008
- if (settled) return;
1009
- settled = true;
1010
- globalThis.clearTimeout(timeout);
1011
- reject(caught);
1012
- }
1013
- );
1014
- });
1015
- }
1016
- function toArrayBuffer(bytes) {
1017
- return bytes.slice().buffer;
1018
- }
1019
- function resolveExportMediaResourcePolicy(declaredSize, policy) {
1020
- const knownSize = Number.isFinite(declaredSize) ? Math.max(0, declaredSize) : 0;
1021
- return {
1022
- ...DEFAULT_INTERACTIVE_RESOURCE_POLICY,
1023
- ...policy,
1024
- maxBytes: policy?.maxBytes ?? Math.max(DEFAULT_INTERACTIVE_RESOURCE_POLICY.maxBytes, knownSize)
1025
- };
1026
- }
1027
- function collectDocumentMediaReferences(doc) {
1028
- const references = /* @__PURE__ */ new Set();
1029
- const seen = /* @__PURE__ */ new WeakSet();
1030
- const visit = (value) => {
1031
- if (typeof value === "string") {
1032
- references.add(value);
1033
- if (value.startsWith("./")) references.add(value.slice(2));
1034
- return;
1035
- }
1036
- if (!value || typeof value !== "object" || seen.has(value)) return;
1037
- seen.add(value);
1038
- if (Array.isArray(value)) {
1039
- value.forEach(visit);
1040
- return;
1041
- }
1042
- Object.values(value).forEach(visit);
1043
- };
1044
- visit(doc);
1045
- return references;
1046
- }
1047
- async function resolveAudioBuffers(clips, sources) {
1048
- const srcs = new Set(clips.map((c) => c.src));
1049
- const out = /* @__PURE__ */ new Map();
1050
- for (const src of srcs) {
1051
- let data = sources.audio?.get(src) ?? sources.images?.get(src);
1052
- if (!data && sources.mediaProvider) {
1053
- try {
1054
- const url = await sources.mediaProvider.resolveUrl(src);
1055
- const resource = await fetchResourceBytes(url, {
1056
- policy: sources.resourcePolicy
1057
- });
1058
- data = toArrayBuffer(resource.bytes);
1059
- } catch {
1060
- }
1061
- }
1062
- if (data) out.set(src, data);
1063
- }
1064
- return out;
1065
- }
1066
- function resolveFrontmatterBoolean(value) {
1067
- if (typeof value === "boolean") return value;
1068
- if (typeof value !== "string") return void 0;
1069
- const normalized = value.trim().toLowerCase();
1070
- if (normalized === "true" || normalized === "yes" || normalized === "on" || normalized === "show" || normalized === "visible") {
1071
- return true;
1072
- }
1073
- if (normalized === "false" || normalized === "no" || normalized === "off" || normalized === "hide" || normalized === "hidden") {
1074
- return false;
1075
- }
1076
- return void 0;
1077
- }
1078
- function resolveVideoExportCover(doc, config = {}) {
1079
- const frontmatter = doc.frontmatter;
1080
- const frontmatterValue = frontmatter ? Object.prototype.hasOwnProperty.call(frontmatter, "squisq-cover-slide") ? frontmatter["squisq-cover-slide"] : frontmatter["cover-slide"] : void 0;
1081
- const showCoverSlide = config.showCoverSlide ?? resolveFrontmatterBoolean(frontmatterValue) ?? true;
1082
- const requestedPreRoll = config.coverPreRoll ?? DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS;
1083
- if (!Number.isFinite(requestedPreRoll) || requestedPreRoll < 0) {
1084
- throw new Error("Cover pre-roll must be a finite number of seconds greater than or equal to 0");
1085
- }
1086
- return {
1087
- showCoverSlide,
1088
- coverPreRoll: showCoverSlide && !!doc.startBlock ? requestedPreRoll : 0
1089
- };
1090
- }
1091
- function useVideoExport(options = {}) {
1092
- const [state, setState] = useState("idle");
1093
- const [progress, setProgress] = useState(0);
1094
- const [phase, setPhase] = useState("");
1095
- const [currentFrameTime, setCurrentFrameTime] = useState(null);
1096
- const [processingFps, setProcessingFps] = useState(null);
1097
- const [duration, setDuration] = useState(0);
1098
- const [outputFormat, setOutputFormat] = useState("mp4");
1099
- const [backend, setBackend] = useState(null);
1100
- const [downloadUrl, setDownloadUrl] = useState(null);
1101
- const [outputBlob, setOutputBlob] = useState(null);
1102
- const [fileSize, setFileSize] = useState(0);
1103
- const [audioIncluded, setAudioIncluded] = useState(false);
1104
- const [audioSkippedReason, setAudioSkippedReason] = useState(null);
1105
- const [error, setError] = useState(null);
1106
- const [elapsed, setElapsed] = useState(0);
1107
- const [estimatedRemaining, setEstimatedRemaining] = useState(0);
1108
- const encoderRef = useRef2(null);
1109
- const gifAbortRef = useRef2(null);
1110
- const cancelledRef = useRef2(false);
1111
- const downloadUrlRef = useRef2(null);
1112
- const startTimeRef = useRef2(0);
1113
- const elapsedTimerRef = useRef2(null);
1114
- const previewOptionsRef = useRef2(options);
1115
- previewOptionsRef.current = options;
1116
- const frameCapture = useFrameCapture();
1117
- useEffect(() => {
1118
- return () => {
1119
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1120
- if (downloadUrlRef.current) {
1121
- URL.revokeObjectURL(downloadUrlRef.current);
1122
- }
1123
- if (encoderRef.current) {
1124
- encoderRef.current.close();
1125
- }
1126
- gifAbortRef.current?.abort();
1127
- frameCapture.destroy();
1128
- };
1129
- }, [frameCapture]);
1130
- const reset = useCallback2(() => {
1131
- if (downloadUrlRef.current) {
1132
- URL.revokeObjectURL(downloadUrlRef.current);
1133
- downloadUrlRef.current = null;
1134
- }
1135
- if (encoderRef.current) {
1136
- encoderRef.current.close();
1137
- encoderRef.current = null;
1138
- }
1139
- gifAbortRef.current?.abort();
1140
- gifAbortRef.current = null;
1141
- frameCapture.destroy();
1142
- setState("idle");
1143
- setProgress(0);
1144
- setPhase("");
1145
- setCurrentFrameTime(null);
1146
- setProcessingFps(null);
1147
- setDuration(0);
1148
- setOutputFormat("mp4");
1149
- setBackend(null);
1150
- setDownloadUrl(null);
1151
- setOutputBlob(null);
1152
- setFileSize(0);
1153
- setAudioIncluded(false);
1154
- setAudioSkippedReason(null);
1155
- setError(null);
1156
- setElapsed(0);
1157
- setEstimatedRemaining(0);
1158
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1159
- cancelledRef.current = false;
1160
- }, [frameCapture]);
1161
- const cancel = useCallback2(() => {
1162
- cancelledRef.current = true;
1163
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1164
- if (encoderRef.current) {
1165
- encoderRef.current.close();
1166
- encoderRef.current = null;
1167
- }
1168
- gifAbortRef.current?.abort();
1169
- gifAbortRef.current = null;
1170
- frameCapture.destroy();
1171
- setState("idle");
1172
- setProgress(0);
1173
- setPhase("Cancelled");
1174
- }, [frameCapture]);
1175
- const startExport = useCallback2(
1176
- async (doc, config) => {
1177
- cancelledRef.current = false;
1178
- if (downloadUrlRef.current) {
1179
- URL.revokeObjectURL(downloadUrlRef.current);
1180
- downloadUrlRef.current = null;
1181
- }
1182
- setDownloadUrl(null);
1183
- setOutputBlob(null);
1184
- setFileSize(0);
1185
- setAudioIncluded(false);
1186
- setAudioSkippedReason(null);
1187
- setError(null);
1188
- setCurrentFrameTime(null);
1189
- setProcessingFps(null);
1190
- const quality = config.quality ?? "normal";
1191
- const effectiveOutputFormat = config.outputFormat ?? "mp4";
1192
- const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
1193
- const orientation = config.orientation ?? "landscape";
1194
- const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
1195
- const captionMode = config.captionMode ?? (effectiveOutputFormat === "gif" ? "standard" : "off");
1196
- const audioPolicy = config.audioPolicy ?? "require";
1197
- setOutputFormat(effectiveOutputFormat);
1198
- try {
1199
- const cover = resolveVideoExportCover(doc, config);
1200
- const gifDefaults = orientation === "portrait" ? { width: 540, height: 960 } : { width: 960, height: 540 };
1201
- const { width, height } = resolveDimensions({
1202
- orientation,
1203
- fps,
1204
- quality,
1205
- ...config.width !== void 0 ? { width: config.width } : effectiveOutputFormat === "gif" ? { width: gifDefaults.width } : {},
1206
- ...config.height !== void 0 ? { height: config.height } : effectiveOutputFormat === "gif" ? { height: gifDefaults.height } : {}
1207
- });
1208
- const webCodecsAvailable = supportsWebCodecs();
1209
- const sharedArrayBufferAvailable = typeof SharedArrayBuffer !== "undefined";
1210
- if (effectiveOutputFormat === "gif" && !sharedArrayBufferAvailable) {
1211
- throw new Error(
1212
- "Animated GIF export requires ffmpeg.wasm and SharedArrayBuffer (Cross-Origin-Isolation headers)."
1213
- );
1214
- }
1215
- if (effectiveOutputFormat === "gif") {
1216
- resolveFfmpegWasmLoad2(config.ffmpegWasm, "Animated GIF export");
1217
- }
1218
- if (!webCodecsAvailable && !sharedArrayBufferAvailable) {
1219
- throw new Error(
1220
- "No video encoder available. WebCodecs requires Chrome 94+ / Edge 94+, and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
1221
- );
1222
- }
1223
- setState("preparing");
1224
- setPhase("Loading document\u2026");
1225
- setProgress(0);
1226
- setElapsed(0);
1227
- setEstimatedRemaining(0);
1228
- startTimeRef.current = performance.now();
1229
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1230
- elapsedTimerRef.current = setInterval(() => {
1231
- setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1232
- }, 1e3);
1233
- let images = config.images;
1234
- if (!images && config.mediaProvider) {
1235
- images = /* @__PURE__ */ new Map();
1236
- const entries = await config.mediaProvider.listMedia();
1237
- const references = collectDocumentMediaReferences(doc);
1238
- const neededEntries = entries.filter(
1239
- (entry) => references.has(entry.name) || references.has(`./${entry.name}`)
1240
- );
1241
- if (neededEntries.length > MAX_EXPORT_MEDIA_FILES) {
1242
- throw new Error(
1243
- `Document references ${neededEntries.length} media files; browser export supports at most ${MAX_EXPORT_MEDIA_FILES}.`
1244
- );
1245
- }
1246
- for (const entry of neededEntries) {
1247
- if (cancelledRef.current) return;
1248
- const url2 = await config.mediaProvider.resolveUrl(entry.name);
1249
- const resource = await fetchResourceBytes(url2, {
1250
- policy: resolveExportMediaResourcePolicy(entry.size, config.resourcePolicy)
1251
- });
1252
- const data = toArrayBuffer(resource.bytes);
1253
- images.set(entry.name, data);
1254
- }
1255
- }
1256
- const docDuration = await frameCapture.init(
1257
- doc,
1258
- {
1259
- images,
1260
- audio: config.audio,
1261
- width,
1262
- height,
1263
- animationsEnabled,
1264
- theme: config.theme,
1265
- videoPresentation: config.videoPresentation,
1266
- pipSize: config.pipSize,
1267
- pipShape: config.pipShape,
1268
- pipPosition: config.pipPosition,
1269
- showCoverSlide: cover.showCoverSlide
1270
- },
1271
- captionMode
1272
- );
1273
- if (cancelledRef.current) return;
1274
- if (docDuration <= 0) {
1275
- throw new Error("Document has zero duration \u2014 nothing to export");
1276
- }
1277
- const coverFrameCount = Math.ceil(cover.coverPreRoll * fps);
1278
- const storyFrameCount = Math.ceil(docDuration * fps);
1279
- const totalFrames = coverFrameCount + storyFrameCount;
1280
- const exportDuration = totalFrames / fps;
1281
- setDuration(exportDuration);
1282
- setPhase("Checking video encoder\u2026");
1283
- setProgress(5);
1284
- const canUseWebCodecs = webCodecsAvailable && await settleWithin(
1285
- supportsWebCodecsH264({ width, height, fps, quality }),
1286
- ENCODER_PROBE_TIMEOUT_MS,
1287
- "The browser did not finish checking WebCodecs support."
1288
- ).catch(() => false);
1289
- const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
1290
- const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverFrameCount / fps) : [];
1291
- const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
1292
- const tierDecision = selectAudioTier({
1293
- hasClips: timeline.length > 0,
1294
- aacSupported,
1295
- sharedArrayBufferAvailable,
1296
- canUseMainThreadWebCodecs: canUseWebCodecs
1297
- });
1298
- let renderedAudio = null;
1299
- let audioIncludedLocal = false;
1300
- let audioReasonLocal = tierDecision.reason;
1301
- if (timeline.length > 0 && tierDecision.tier === 3 && audioPolicy === "require") {
1302
- throw new Error(tierDecision.reason ?? "This browser cannot include the document audio.");
1303
- }
1304
- if (tierDecision.tier === 1 || tierDecision.tier === 2) {
1305
- setPhase("Preparing audio\u2026");
1306
- try {
1307
- const buffers = await resolveAudioBuffers(timeline, {
1308
- audio: config.audio,
1309
- images,
1310
- mediaProvider: config.mediaProvider,
1311
- resourcePolicy: config.resourcePolicy
1312
- });
1313
- const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
1314
- (src) => !buffers.has(src)
1315
- );
1316
- if (missingSources.length > 0) {
1317
- audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
1318
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1319
- }
1320
- if (buffers.size === 0) {
1321
- audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
1322
- } else {
1323
- const totalAudioDur = timeline.reduce(
1324
- (max, c) => Math.max(max, c.startSec + c.durationSec),
1325
- exportDuration
1326
- );
1327
- renderedAudio = await renderAudioTimeline(
1328
- timeline,
1329
- buffers,
1330
- totalAudioDur,
1331
- EXPORT_AUDIO_SAMPLE_RATE
1332
- );
1333
- if (!renderedAudio) {
1334
- audioReasonLocal = "No included video source contained a decodable audio track.";
1335
- }
1336
- }
1337
- } catch (audioErr) {
1338
- renderedAudio = null;
1339
- audioReasonLocal = `Audio could not be prepared: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
1340
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1341
- }
1342
- }
1343
- const useInlineAudio = renderedAudio !== null && tierDecision.tier === 1;
1344
- const useFfmpegAudio = renderedAudio !== null && tierDecision.tier === 2;
1345
- if (cancelledRef.current) return;
1346
- let encoder;
1347
- if (canUseWebCodecs) {
1348
- encoder = createEncoder({
1349
- width,
1350
- height,
1351
- fps,
1352
- quality,
1353
- ...useInlineAudio && renderedAudio ? {
1354
- audio: {
1355
- numberOfChannels: renderedAudio.numberOfChannels,
1356
- sampleRate: renderedAudio.sampleRate
1357
- }
1358
- } : {}
1359
- });
1360
- encoderRef.current = encoder;
1361
- setBackend("webcodecs");
1362
- } else if (sharedArrayBufferAvailable) {
1363
- setProgress(6);
1364
- setPhase("Loading export engine\u2026");
1365
- const workerEncoder = createWorkerEncoder({
1366
- width,
1367
- height,
1368
- fps,
1369
- quality,
1370
- totalFrames,
1371
- ffmpegWasm: config.ffmpegWasm
1372
- });
1373
- encoder = workerEncoder;
1374
- encoderRef.current = workerEncoder;
1375
- const selectedBackend = await settleWithin(
1376
- workerEncoder.ready,
1377
- ENCODER_START_TIMEOUT_MS,
1378
- "The browser export engine did not start within 60 seconds."
1379
- );
1380
- setBackend(selectedBackend);
1381
- } else {
1382
- throw new Error(
1383
- "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
1384
- );
1385
- }
1386
- if (cancelledRef.current) return;
1387
- setProgress(CAPTURE_PROGRESS_START);
1388
- setPhase(`Capturing frame 1/${totalFrames}`);
1389
- setCurrentFrameTime(0);
1390
- setState("capturing");
1391
- const captureStartTime = performance.now();
1392
- const frameBoundaryTimes = [captureStartTime];
1393
- if (coverFrameCount > 0) await frameCapture.setCoverVisible(true);
1394
- for (let i = 0; i < totalFrames; i++) {
1395
- if (cancelledRef.current) return;
1396
- if (coverFrameCount > 0 && i === coverFrameCount) {
1397
- await frameCapture.setCoverVisible(false);
1398
- }
1399
- const time = i / fps;
1400
- const captureTime = i < coverFrameCount ? 0 : (i - coverFrameCount) / fps;
1401
- const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(captureTime, { reuseIfUnchanged: true }) : frameCapture.captureFrame(captureTime, { reuseIfUnchanged: true });
1402
- const frame = await settleWithin(
1403
- captureOperation,
1404
- FRAME_CAPTURE_TIMEOUT_MS,
1405
- `Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
1406
- releaseEncoderFrame
1407
- );
1408
- if (cancelledRef.current) {
1409
- releaseEncoderFrame(frame);
1410
- return;
1411
- }
1412
- const previewOptions = previewOptionsRef.current;
1413
- const previewInterval = Math.max(1, Math.floor(previewOptions.previewEveryNFrames ?? 1));
1414
- if (previewOptions.onFramePreview && (i === 0 || i === totalFrames - 1 || i % previewInterval === 0)) {
1415
- try {
1416
- previewOptions.onFramePreview({ source: frame, frameIndex: i, totalFrames, time });
1417
- } catch {
1418
- }
1419
- }
1420
- setPhase(`Encoding frame ${i + 1}/${totalFrames}`);
1421
- setCurrentFrameTime(time);
1422
- await settleWithin(
1423
- encoder.encodeFrame(frame, i),
1424
- FRAME_ENCODE_TIMEOUT_MS,
1425
- `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
1426
- );
1427
- const completedFrames = i + 1;
1428
- const completedAt = performance.now();
1429
- frameBoundaryTimes.push(completedAt);
1430
- if (frameBoundaryTimes.length > FRAME_RATE_WINDOW_SIZE + 1) {
1431
- frameBoundaryTimes.shift();
1432
- }
1433
- setProcessingFps(calculateRollingFramesPerSecond(frameBoundaryTimes));
1434
- setCurrentFrameTime(Math.min(completedFrames / fps, exportDuration));
1435
- setPhase(
1436
- completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames}` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
1437
- );
1438
- const captureRatio = completedFrames / totalFrames;
1439
- const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
1440
- setProgress(Math.round(captureProgress * 10) / 10);
1441
- const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
1442
- const avgPerFrame = elapsedCapture / completedFrames;
1443
- setEstimatedRemaining(Math.round(avgPerFrame * (totalFrames - completedFrames)));
1444
- setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1445
- }
1446
- if (cancelledRef.current) return;
1447
- if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
1448
- setState("encoding");
1449
- setPhase("Encoding audio\u2026");
1450
- try {
1451
- await encodeAacTrack(
1452
- renderedAudio,
1453
- { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
1454
- audioBitrate
1455
- );
1456
- audioIncludedLocal = true;
1457
- } catch (audioErr) {
1458
- audioIncludedLocal = false;
1459
- audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
1460
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1461
- }
1462
- }
1463
- setState("encoding");
1464
- setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
1465
- setProgress(95);
1466
- let outputBytes = await encoder.finalize();
1467
- encoderRef.current = null;
1468
- if (cancelledRef.current) return;
1469
- if (effectiveOutputFormat === "gif") {
1470
- setPhase("Generating GIF palette\u2026");
1471
- const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1472
- const gifAbort = new AbortController();
1473
- gifAbortRef.current = gifAbort;
1474
- try {
1475
- outputBytes = await transcodeMp4ToGifWithFfmpegWasm(
1476
- videoOnly,
1477
- { width, height, loop: 0 },
1478
- config.ffmpegWasm,
1479
- gifAbort.signal
1480
- );
1481
- } finally {
1482
- if (gifAbortRef.current === gifAbort) gifAbortRef.current = null;
1483
- }
1484
- } else if (useFfmpegAudio && renderedAudio) {
1485
- setPhase("Muxing audio\u2026");
1486
- try {
1487
- const wav = audioBufferToWav(renderedAudio);
1488
- const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1489
- outputBytes = await muxAudioWithFfmpegWasm(
1490
- videoOnly,
1491
- wav,
1492
- audioBitrate,
1493
- config.ffmpegWasm
1494
- );
1495
- audioIncludedLocal = true;
1496
- } catch (audioErr) {
1497
- audioIncludedLocal = false;
1498
- audioReasonLocal = `Audio muxing failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
1499
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1500
- }
1501
- }
1502
- if (cancelledRef.current) return;
1503
- const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
1504
- const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
1505
- const blob = new Blob([finalBytes], { type: mimeType });
1506
- const url = URL.createObjectURL(blob);
1507
- downloadUrlRef.current = url;
1508
- setDownloadUrl(url);
1509
- setOutputBlob(blob);
1510
- setFileSize(finalBytes.byteLength);
1511
- setAudioIncluded(audioIncludedLocal);
1512
- setAudioSkippedReason(
1513
- effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
1514
- );
1515
- setState("complete");
1516
- setProgress(100);
1517
- setPhase("Export complete");
1518
- setEstimatedRemaining(0);
1519
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1520
- frameCapture.destroy();
1521
- } catch (err) {
1522
- if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
1523
- if (cancelledRef.current) return;
1524
- const message = err instanceof Error ? err.message : String(err);
1525
- setState("error");
1526
- setError(message);
1527
- setPhase("Export failed");
1528
- if (encoderRef.current) {
1529
- encoderRef.current.close();
1530
- encoderRef.current = null;
1531
- }
1532
- gifAbortRef.current?.abort();
1533
- gifAbortRef.current = null;
1534
- frameCapture.destroy();
1535
- }
1536
- },
1537
- [frameCapture]
1538
- );
1539
- return {
1540
- state,
1541
- progress,
1542
- phase,
1543
- currentFrameTime,
1544
- processingFps,
1545
- duration,
1546
- outputFormat,
1547
- backend,
1548
- downloadUrl,
1549
- outputBlob,
1550
- fileSize,
1551
- audioIncluded,
1552
- audioSkippedReason,
1553
- error,
1554
- elapsed,
1555
- estimatedRemaining,
1556
- startExport,
1557
- cancel,
1558
- reset
1559
- };
1560
- }
1561
-
1562
- export {
1563
- useFrameCapture,
1564
- DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
1565
- resolveVideoExportCover,
1566
- useVideoExport
1567
- };