@bendyline/squisq-video-react 2.2.9 → 2.2.11

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.
@@ -10,7 +10,7 @@ import {
10
10
  supportsWebCodecs,
11
11
  supportsWebCodecsAac,
12
12
  supportsWebCodecsH264
13
- } from "./chunk-2XACUF6E.js";
13
+ } from "./chunk-KJ5RKG67.js";
14
14
 
15
15
  // src/hooks/useFrameCapture.ts
16
16
  import { createElement } from "react";
@@ -36,6 +36,11 @@ var MIME_MAP = {
36
36
  var CAPTURE_ASSET_TIMEOUT_MS = 15e3;
37
37
  var RENDER_TIME_EPSILON_SECONDS = 1e-6;
38
38
  var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
39
+ var CAPTURE_SVG_SELECTOR = "svg.block-svg";
40
+ var CAPTURE_VIDEO_READINESS_POLL_MS = 16;
41
+ var CAPTURE_VIDEO_END_PROBE_TIME = 1e101;
42
+ var SCHEDULED_MEDIA_SELECTOR = ".doc-player__media-clips";
43
+ var SCHEDULED_VIDEO_SELECTOR = `${SCHEDULED_MEDIA_SELECTOR} video[data-clip-id]`;
39
44
  async function waitForImageDecode(image) {
40
45
  const src = image.currentSrc || image.src;
41
46
  if (!src) return;
@@ -82,14 +87,111 @@ async function waitForCaptureAssets(captureRoot, decodedImages = /* @__PURE__ */
82
87
  })
83
88
  );
84
89
  }
90
+ function waitForCaptureVideoState(video, description, isReady, update) {
91
+ if (isReady()) return Promise.resolve();
92
+ return new Promise((resolve, reject) => {
93
+ let settled = false;
94
+ const events = ["loadedmetadata", "durationchange", "loadeddata", "canplay", "seeked"];
95
+ const cleanup = () => {
96
+ clearTimeout(timeout);
97
+ clearInterval(poll);
98
+ events.forEach((eventName) => video.removeEventListener(eventName, check));
99
+ video.removeEventListener("error", fail);
100
+ };
101
+ const finish = () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ cleanup();
105
+ resolve();
106
+ };
107
+ const fail = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ cleanup();
111
+ reject(
112
+ new Error(
113
+ `Video did not become ready while ${description} within 15s: ${video.currentSrc || video.src}`
114
+ )
115
+ );
116
+ };
117
+ function check() {
118
+ if (isReady()) finish();
119
+ }
120
+ const timeout = setTimeout(fail, CAPTURE_ASSET_TIMEOUT_MS);
121
+ const poll = setInterval(check, CAPTURE_VIDEO_READINESS_POLL_MS);
122
+ events.forEach((eventName) => video.addEventListener(eventName, check));
123
+ video.addEventListener("error", fail, { once: true });
124
+ try {
125
+ update?.();
126
+ queueMicrotask(check);
127
+ } catch (error) {
128
+ settled = true;
129
+ cleanup();
130
+ reject(error);
131
+ }
132
+ });
133
+ }
134
+ async function primeIndeterminateCaptureVideos(captureRoot, primedVideos = /* @__PURE__ */ new WeakSet()) {
135
+ const videos = Array.from(captureRoot.querySelectorAll("video")).filter(
136
+ (video) => !primedVideos.has(video)
137
+ );
138
+ let primedCount = 0;
139
+ await Promise.all(
140
+ videos.map(async (video) => {
141
+ const source = video.currentSrc || video.src;
142
+ if (!source) {
143
+ primedVideos.add(video);
144
+ return;
145
+ }
146
+ await waitForCaptureVideoState(
147
+ video,
148
+ "loading capture metadata",
149
+ () => video.readyState >= HTMLMediaElement.HAVE_METADATA
150
+ );
151
+ if (video.videoWidth <= 0 && video.videoHeight <= 0) {
152
+ primedVideos.add(video);
153
+ return;
154
+ }
155
+ if (Number.isFinite(video.duration)) {
156
+ primedVideos.add(video);
157
+ return;
158
+ }
159
+ const restoreTime = Number.isFinite(video.currentTime) ? Math.max(0, video.currentTime) : 0;
160
+ video.pause();
161
+ await waitForCaptureVideoState(
162
+ video,
163
+ "indexing an indeterminate-duration capture source",
164
+ () => Number.isFinite(video.duration) && video.duration > 0 && !video.seeking && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA,
165
+ () => {
166
+ video.currentTime = CAPTURE_VIDEO_END_PROBE_TIME;
167
+ }
168
+ );
169
+ const reachableRestoreTime = Math.min(restoreTime, video.duration);
170
+ await waitForCaptureVideoState(
171
+ video,
172
+ "restoring the capture source after indexing",
173
+ () => !video.seeking && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && Math.abs(video.currentTime - reachableRestoreTime) <= 0.01,
174
+ () => {
175
+ video.currentTime = reachableRestoreTime;
176
+ }
177
+ );
178
+ video.dataset.captureSequential = "true";
179
+ primedVideos.add(video);
180
+ primedCount += 1;
181
+ })
182
+ );
183
+ return primedCount;
184
+ }
85
185
  function createInlineProvider(images) {
86
186
  const blobUrls = /* @__PURE__ */ new Map();
87
187
  const mimeTypes = /* @__PURE__ */ new Map();
188
+ const sizes = /* @__PURE__ */ new Map();
88
189
  for (const [path, buffer] of images) {
89
190
  const ext = path.split(".").pop()?.toLowerCase() ?? "";
90
191
  const mime = MIME_MAP[ext] ?? "application/octet-stream";
91
192
  blobUrls.set(path, URL.createObjectURL(new Blob([buffer], { type: mime })));
92
193
  mimeTypes.set(path, mime);
194
+ sizes.set(path, buffer.byteLength);
93
195
  }
94
196
  return {
95
197
  async resolveUrl(relativePath) {
@@ -99,7 +201,7 @@ function createInlineProvider(images) {
99
201
  return [...blobUrls.keys()].map((name) => ({
100
202
  name,
101
203
  mimeType: mimeTypes.get(name) ?? "application/octet-stream",
102
- size: images.get(name)?.byteLength ?? 0
204
+ size: sizes.get(name) ?? 0
103
205
  }));
104
206
  },
105
207
  async addMedia() {
@@ -111,6 +213,7 @@ function createInlineProvider(images) {
111
213
  dispose() {
112
214
  blobUrls.forEach((url) => URL.revokeObjectURL(url));
113
215
  blobUrls.clear();
216
+ sizes.clear();
114
217
  }
115
218
  };
116
219
  }
@@ -201,6 +304,7 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
201
304
  return canvas ? [{ video, canvas }] : [];
202
305
  });
203
306
  });
307
+ const preparedCanvases = pairs.map(({ canvas }) => canvas);
204
308
  pairs.forEach(({ video, canvas }) => {
205
309
  canvas.className = video.className;
206
310
  canvas.style.cssText = video.style.cssText;
@@ -225,13 +329,11 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
225
329
  destinationHeight,
226
330
  objectFit
227
331
  );
228
- const stagingCanvas = canvas.ownerDocument.createElement("canvas");
229
- stagingCanvas.width = destinationWidth;
230
- stagingCanvas.height = destinationHeight;
231
- const stagingContext = stagingCanvas.getContext("2d");
232
332
  const context = canvas.getContext("2d");
233
- if (!stagingContext || !context) return;
234
- stagingContext.drawImage(
333
+ if (!context) return;
334
+ canvas.width = destinationWidth;
335
+ canvas.height = destinationHeight;
336
+ context.drawImage(
235
337
  video,
236
338
  frame.sx,
237
339
  frame.sy,
@@ -242,9 +344,6 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
242
344
  frame.dw,
243
345
  frame.dh
244
346
  );
245
- canvas.width = destinationWidth;
246
- canvas.height = destinationHeight;
247
- context.drawImage(stagingCanvas, 0, 0);
248
347
  const foreignObject = canvas.closest("foreignObject");
249
348
  const svg = canvas.closest("svg");
250
349
  if (foreignObject && svg) {
@@ -275,8 +374,334 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
275
374
  } catch {
276
375
  }
277
376
  });
377
+ return preparedCanvases;
378
+ }
379
+ function createCaptureSvgRasterCache() {
380
+ return /* @__PURE__ */ new Map();
381
+ }
382
+ function releaseCaptureSvgRasterEntry(entry) {
383
+ entry.canvas.width = 0;
384
+ entry.canvas.height = 0;
385
+ }
386
+ function releaseCaptureSvgRasterCache(cache) {
387
+ cache.forEach(releaseCaptureSvgRasterEntry);
388
+ cache.clear();
389
+ }
390
+ function parseAbsoluteSvgLength(value) {
391
+ if (!value) return 0;
392
+ const match = /^\s*(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
393
+ return match ? Number.parseFloat(match[1]) : 0;
394
+ }
395
+ function svgViewBoxSize(svg) {
396
+ const values = (svg.getAttribute("viewBox") ?? "").trim().split(/[\s,]+/).map(Number);
397
+ if (values.length === 4 && values.every(Number.isFinite)) {
398
+ return { width: Math.max(0, values[2]), height: Math.max(0, values[3]) };
399
+ }
400
+ return { width: 0, height: 0 };
401
+ }
402
+ function captureSvgRasterSize(clonedSvg, originalSvg) {
403
+ const clonedRect = clonedSvg.getBoundingClientRect();
404
+ const originalRect = originalSvg?.getBoundingClientRect();
405
+ const clonedViewBox = svgViewBoxSize(clonedSvg);
406
+ const originalViewBox = originalSvg ? svgViewBoxSize(originalSvg) : { width: 0, height: 0 };
407
+ const width = clonedRect.width || originalRect?.width || parseAbsoluteSvgLength(clonedSvg.getAttribute("width")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("width")) : 0) || clonedViewBox.width || originalViewBox.width;
408
+ const height = clonedRect.height || originalRect?.height || parseAbsoluteSvgLength(clonedSvg.getAttribute("height")) || (originalSvg ? parseAbsoluteSvgLength(originalSvg.getAttribute("height")) : 0) || clonedViewBox.height || originalViewBox.height;
409
+ return {
410
+ width: Math.max(1, Math.round(width)),
411
+ height: Math.max(1, Math.round(height))
412
+ };
413
+ }
414
+ function copyCaptureSvgPresentation(svg, canvas) {
415
+ for (const attribute of Array.from(svg.attributes)) {
416
+ if (attribute.name === "id" || attribute.name === "class" || attribute.name === "style" || attribute.name.startsWith("data-") || attribute.name.startsWith("aria-")) {
417
+ canvas.setAttribute(attribute.name, attribute.value);
418
+ }
419
+ }
420
+ canvas.dataset.svgCaptureClone = "true";
421
+ }
422
+ function captureImageMimeType(source, blob) {
423
+ if (blob.type) return blob.type;
424
+ const path = source.split(/[?#]/, 1)[0];
425
+ const ext = path.split(".").pop()?.toLowerCase() ?? "";
426
+ return MIME_MAP[ext] ?? "application/octet-stream";
427
+ }
428
+ function blobToDataUrl(blob, source) {
429
+ const typedBlob = blob.type ? blob : blob.slice(0, blob.size, captureImageMimeType(source, blob));
430
+ return new Promise((resolve, reject) => {
431
+ const reader = new FileReader();
432
+ reader.addEventListener(
433
+ "load",
434
+ () => {
435
+ if (typeof reader.result === "string") resolve(reader.result);
436
+ else reject(new Error(`Image could not be embedded for SVG capture: ${source}`));
437
+ },
438
+ { once: true }
439
+ );
440
+ reader.addEventListener(
441
+ "error",
442
+ () => reject(reader.error ?? new Error(`Image could not be read for SVG capture: ${source}`)),
443
+ { once: true }
444
+ );
445
+ reader.readAsDataURL(typedBlob);
446
+ });
447
+ }
448
+ function resolveCaptureImageDataUrl(source, cache) {
449
+ const cached = cache.get(source);
450
+ if (cached) return cached;
451
+ const pending = fetch(source).then(async (response) => {
452
+ if (!response.ok) {
453
+ throw new Error(`Image could not be loaded for SVG capture: ${source}`);
454
+ }
455
+ return blobToDataUrl(await response.blob(), source);
456
+ });
457
+ cache.set(source, pending);
458
+ return pending;
459
+ }
460
+ function captureImageReference(element) {
461
+ if (element.localName === "img") {
462
+ const source2 = element.getAttribute("src") ?? "";
463
+ return source2 ? {
464
+ source: source2,
465
+ replace: (dataUrl) => element.setAttribute("src", dataUrl)
466
+ } : null;
467
+ }
468
+ const xlinkNamespace = "http://www.w3.org/1999/xlink";
469
+ const source = element.getAttribute("href") ?? element.getAttributeNS(xlinkNamespace, "href") ?? "";
470
+ return source ? {
471
+ source,
472
+ replace: (dataUrl) => {
473
+ if (element.hasAttribute("href")) element.setAttribute("href", dataUrl);
474
+ if (element.hasAttributeNS(xlinkNamespace, "href")) {
475
+ element.setAttributeNS(xlinkNamespace, "href", dataUrl);
476
+ }
477
+ }
478
+ } : null;
479
+ }
480
+ async function embedCaptureSvgImages(svg, cache) {
481
+ const references = Array.from(svg.querySelectorAll("image, img")).map(captureImageReference).filter((reference) => reference !== null);
482
+ for (const reference of references) {
483
+ if (/^data:/i.test(reference.source) || reference.source.startsWith("#")) continue;
484
+ const dataUrl = await resolveCaptureImageDataUrl(reference.source, cache);
485
+ reference.replace(dataUrl);
486
+ }
487
+ }
488
+ function captureSvgRasterCacheKey(svg, index) {
489
+ const blockId = svg.dataset.blockId;
490
+ return blockId ? `block:${blockId}` : `index:${index}`;
491
+ }
492
+ async function rasterizeCaptureSvgClones(originalRoot, clonedRoot, transientCanvases = [], imageDataUrls = /* @__PURE__ */ new Map(), rasterCache) {
493
+ const cache = rasterCache ?? createCaptureSvgRasterCache();
494
+ const ownsRasterCache = rasterCache === void 0;
495
+ const originalSvgs = Array.from(
496
+ originalRoot.querySelectorAll(CAPTURE_SVG_SELECTOR)
497
+ );
498
+ const clonedSvgs = Array.from(clonedRoot.querySelectorAll(CAPTURE_SVG_SELECTOR));
499
+ const activeCacheKeys = /* @__PURE__ */ new Set();
500
+ try {
501
+ for (const [index, svg] of clonedSvgs.entries()) {
502
+ const originalSvg = originalSvgs[index];
503
+ const { width, height } = captureSvgRasterSize(svg, originalSvg);
504
+ const cacheKey = captureSvgRasterCacheKey(originalSvg ?? svg, index);
505
+ activeCacheKeys.add(cacheKey);
506
+ svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
507
+ svg.setAttribute("width", String(width));
508
+ svg.setAttribute("height", String(height));
509
+ let bitmap = null;
510
+ let replacement = null;
511
+ let image = null;
512
+ let rasterCanvas = null;
513
+ try {
514
+ await embedCaptureSvgImages(svg, imageDataUrls);
515
+ const serializedSvg = new XMLSerializer().serializeToString(svg);
516
+ let entry = cache.get(cacheKey);
517
+ if (!entry || entry.serializedSvg !== serializedSvg || entry.width !== width || entry.height !== height) {
518
+ const containsForeignObject = svg.querySelector("foreignObject") !== null;
519
+ image = svg.ownerDocument.createElement("img");
520
+ image.decoding = "sync";
521
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(serializedSvg)}`;
522
+ await waitForImageDecode(image);
523
+ if (!containsForeignObject && typeof createImageBitmap === "function") {
524
+ try {
525
+ bitmap = await createImageBitmap(image);
526
+ } catch {
527
+ bitmap = null;
528
+ }
529
+ }
530
+ rasterCanvas = entry?.canvas ?? originalRoot.ownerDocument.createElement("canvas");
531
+ if (rasterCanvas.width !== width || rasterCanvas.height !== height) {
532
+ rasterCanvas.width = width;
533
+ rasterCanvas.height = height;
534
+ }
535
+ const rasterContext = rasterCanvas.getContext("2d");
536
+ if (!rasterContext) {
537
+ throw new Error("Could not create the cached SVG raster canvas context");
538
+ }
539
+ rasterContext.clearRect(0, 0, width, height);
540
+ rasterContext.drawImage(bitmap ?? image, 0, 0, width, height);
541
+ entry = { serializedSvg, width, height, canvas: rasterCanvas };
542
+ cache.set(cacheKey, entry);
543
+ }
544
+ replacement = svg.ownerDocument.createElement("canvas");
545
+ replacement.width = width;
546
+ replacement.height = height;
547
+ copyCaptureSvgPresentation(svg, replacement);
548
+ const context = replacement.getContext("2d");
549
+ if (!context) {
550
+ throw new Error("Could not create the SVG capture canvas context");
551
+ }
552
+ context.drawImage(entry.canvas, 0, 0, width, height);
553
+ svg.replaceWith(replacement);
554
+ transientCanvases.push(replacement);
555
+ } catch (error) {
556
+ if (replacement && !replacement.isConnected) {
557
+ replacement.width = 0;
558
+ replacement.height = 0;
559
+ }
560
+ if (rasterCanvas && cache.get(cacheKey)?.canvas !== rasterCanvas) {
561
+ rasterCanvas.width = 0;
562
+ rasterCanvas.height = 0;
563
+ }
564
+ const detail = error instanceof Error ? error.message : String(error);
565
+ throw new Error(`Could not rasterize a full-slide SVG for frame capture: ${detail}`);
566
+ } finally {
567
+ bitmap?.close();
568
+ image?.removeAttribute("src");
569
+ }
570
+ }
571
+ for (const [cacheKey, entry] of cache) {
572
+ if (activeCacheKeys.has(cacheKey)) continue;
573
+ releaseCaptureSvgRasterEntry(entry);
574
+ cache.delete(cacheKey);
575
+ }
576
+ return transientCanvases;
577
+ } finally {
578
+ if (ownsRasterCache) releaseCaptureSvgRasterCache(cache);
579
+ }
580
+ }
581
+ function releaseCaptureCloneCanvases(canvases) {
582
+ canvases.forEach((canvas) => {
583
+ canvas.width = 0;
584
+ canvas.height = 0;
585
+ });
586
+ }
587
+ function scheduledVideoIsVisual(video) {
588
+ return video.videoWidth > 0 && video.videoHeight > 0 && video.dataset.active === "true";
589
+ }
590
+ function scheduledVideoPresentation(video) {
591
+ return video.closest(SCHEDULED_MEDIA_SELECTOR)?.dataset.presentation;
592
+ }
593
+ function canCompositeScheduledPipVideos(captureRoot) {
594
+ const activeVisualVideos = Array.from(
595
+ captureRoot.querySelectorAll(SCHEDULED_VIDEO_SELECTOR)
596
+ ).filter(scheduledVideoIsVisual);
597
+ return activeVisualVideos.length > 0 && activeVisualVideos.every((video) => scheduledVideoPresentation(video) === "picture-in-picture");
598
+ }
599
+ function cssPixelValue(value) {
600
+ const parsed = Number.parseFloat(value);
601
+ return Number.isFinite(parsed) ? parsed : 0;
602
+ }
603
+ function cssRadius(value, width, height) {
604
+ if (value.trim().endsWith("%")) {
605
+ return cssPixelValue(value) / 100 * Math.min(width, height);
606
+ }
607
+ return cssPixelValue(value);
608
+ }
609
+ function applyFirstBoxShadow(context, boxShadow, scaleX, scaleY) {
610
+ if (!boxShadow || boxShadow === "none") return;
611
+ const color = boxShadow.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/i)?.[0];
612
+ if (!color) return;
613
+ const lengths = Array.from(
614
+ boxShadow.replace(color, "").matchAll(/(-?\d+(?:\.\d+)?)px/g),
615
+ (match) => Number.parseFloat(match[1])
616
+ );
617
+ context.shadowColor = color;
618
+ context.shadowOffsetX = (lengths[0] ?? 0) * scaleX;
619
+ context.shadowOffsetY = (lengths[1] ?? 0) * scaleY;
620
+ context.shadowBlur = (lengths[2] ?? 0) * Math.max(scaleX, scaleY);
621
+ }
622
+ function addRoundedRect(context, x, y, width, height, radius) {
623
+ context.beginPath();
624
+ if (typeof context.roundRect === "function") {
625
+ context.roundRect(x, y, width, height, Math.max(0, radius));
626
+ } else {
627
+ context.rect(x, y, width, height);
628
+ }
278
629
  }
279
- function getFrameVisualStateKey(captureRoot, timelineTime) {
630
+ function compositeScheduledPipVideos(captureRoot, destination) {
631
+ const context = destination.getContext("2d");
632
+ if (!context) throw new Error("Could not create the PiP compositor canvas context");
633
+ const rootRect = captureRoot.getBoundingClientRect();
634
+ if (rootRect.width <= 0 || rootRect.height <= 0) return 0;
635
+ const scaleX = destination.width / rootRect.width;
636
+ const scaleY = destination.height / rootRect.height;
637
+ const videos = Array.from(
638
+ captureRoot.querySelectorAll(SCHEDULED_VIDEO_SELECTOR)
639
+ ).filter(
640
+ (video) => scheduledVideoIsVisual(video) && scheduledVideoPresentation(video) === "picture-in-picture"
641
+ );
642
+ for (const video of videos) {
643
+ const rect = video.getBoundingClientRect();
644
+ if (rect.width <= 0 || rect.height <= 0) continue;
645
+ const style = video.ownerDocument.defaultView?.getComputedStyle(video);
646
+ if (!style || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse") {
647
+ continue;
648
+ }
649
+ const opacity = Number.parseFloat(style.opacity || "1");
650
+ if (opacity <= 0) continue;
651
+ const borderLeft = cssPixelValue(style.borderLeftWidth);
652
+ const borderRight = cssPixelValue(style.borderRightWidth);
653
+ const borderTop = cssPixelValue(style.borderTopWidth);
654
+ const borderBottom = cssPixelValue(style.borderBottomWidth);
655
+ const contentWidth = Math.max(1, rect.width - borderLeft - borderRight);
656
+ const contentHeight = Math.max(1, rect.height - borderTop - borderBottom);
657
+ const outerX = (rect.left - rootRect.left) * scaleX;
658
+ const outerY = (rect.top - rootRect.top) * scaleY;
659
+ const outerWidth = rect.width * scaleX;
660
+ const outerHeight = rect.height * scaleY;
661
+ const innerX = outerX + borderLeft * scaleX;
662
+ const innerY = outerY + borderTop * scaleY;
663
+ const innerWidth = contentWidth * scaleX;
664
+ const innerHeight = contentHeight * scaleY;
665
+ const outerRadius = cssRadius(style.borderTopLeftRadius, rect.width, rect.height) * Math.max(scaleX, scaleY);
666
+ const innerRadius = Math.max(
667
+ 0,
668
+ outerRadius - Math.max(borderLeft * scaleX, borderTop * scaleY)
669
+ );
670
+ const source = videoFrameRect(
671
+ video.videoWidth,
672
+ video.videoHeight,
673
+ contentWidth,
674
+ contentHeight,
675
+ style.objectFit || "fill"
676
+ );
677
+ context.save();
678
+ context.globalAlpha = Number.isFinite(opacity) ? opacity : 1;
679
+ applyFirstBoxShadow(context, style.boxShadow, scaleX, scaleY);
680
+ addRoundedRect(context, outerX, outerY, outerWidth, outerHeight, outerRadius);
681
+ context.fillStyle = style.borderTopStyle === "none" || borderTop <= 0 ? "rgba(0, 0, 0, 0.001)" : style.borderTopColor;
682
+ context.fill();
683
+ context.shadowColor = "rgba(0, 0, 0, 0)";
684
+ context.shadowBlur = 0;
685
+ context.shadowOffsetX = 0;
686
+ context.shadowOffsetY = 0;
687
+ addRoundedRect(context, innerX, innerY, innerWidth, innerHeight, innerRadius);
688
+ context.clip();
689
+ context.drawImage(
690
+ video,
691
+ source.sx,
692
+ source.sy,
693
+ source.sw,
694
+ source.sh,
695
+ innerX,
696
+ innerY,
697
+ innerWidth,
698
+ innerHeight
699
+ );
700
+ context.restore();
701
+ }
702
+ return videos.length;
703
+ }
704
+ function getFrameVisualStateKey(captureRoot, timelineTime, options = {}) {
280
705
  const markup = captureRoot.innerHTML;
281
706
  let needsTimelineKey = false;
282
707
  const animationStates = [];
@@ -307,7 +732,9 @@ function getFrameVisualStateKey(captureRoot, timelineTime) {
307
732
  return `${src}:${image.complete}:${image.naturalWidth}x${image.naturalHeight}`;
308
733
  });
309
734
  if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
310
- const videoStates = Array.from(captureRoot.querySelectorAll("video")).map(
735
+ const videoStates = Array.from(captureRoot.querySelectorAll("video")).filter(
736
+ (video) => video.videoWidth > 0 && video.videoHeight > 0 && (!options.ignoreScheduledVideoFrames || !video.closest(SCHEDULED_MEDIA_SELECTOR))
737
+ ).map(
311
738
  (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
312
739
  );
313
740
  if (captureRoot.querySelector(
@@ -331,25 +758,35 @@ function useFrameCapture() {
331
758
  const renderAPIRef = useRef(null);
332
759
  const mediaProviderRef = useRef(null);
333
760
  const captureCanvasRef = useRef(null);
761
+ const captureBaseCanvasRef = useRef(null);
334
762
  const lastVisualStateKeyRef = useRef(null);
335
763
  const hasCapturedFrameRef = useRef(false);
336
764
  const decodedImagesRef = useRef(/* @__PURE__ */ new WeakSet());
765
+ const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
766
+ const captureSvgRasterCacheRef = useRef(createCaptureSvgRasterCache());
767
+ const primedCaptureVideosRef = useRef(/* @__PURE__ */ new WeakSet());
337
768
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
338
769
  const init = useCallback(
339
770
  async (doc, renderOptions, captionMode) => {
340
- if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current) {
771
+ if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current) {
341
772
  const oldRoot = rootRef.current;
342
773
  const oldContainer = containerRef.current;
343
774
  const oldMediaProvider = mediaProviderRef.current;
344
775
  const oldCaptureCanvas = captureCanvasRef.current;
776
+ const oldCaptureBaseCanvas = captureBaseCanvasRef.current;
345
777
  rootRef.current = null;
346
778
  containerRef.current = null;
347
779
  renderAPIRef.current = null;
348
780
  mediaProviderRef.current = null;
349
781
  captureCanvasRef.current = null;
782
+ captureBaseCanvasRef.current = null;
350
783
  lastVisualStateKeyRef.current = null;
351
784
  hasCapturedFrameRef.current = false;
352
785
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
786
+ captureImageDataUrlsRef.current.clear();
787
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
788
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
789
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
353
790
  await new Promise((resolve) => {
354
791
  setTimeout(() => {
355
792
  if (oldRoot) oldRoot.unmount();
@@ -359,6 +796,10 @@ function useFrameCapture() {
359
796
  oldCaptureCanvas.width = 0;
360
797
  oldCaptureCanvas.height = 0;
361
798
  }
799
+ if (oldCaptureBaseCanvas) {
800
+ oldCaptureBaseCanvas.width = 0;
801
+ oldCaptureBaseCanvas.height = 0;
802
+ }
362
803
  resolve();
363
804
  }, 0);
364
805
  });
@@ -373,9 +814,19 @@ function useFrameCapture() {
373
814
  captureCanvas.style.width = `${width}px`;
374
815
  captureCanvas.style.height = `${height}px`;
375
816
  captureCanvasRef.current = captureCanvas;
817
+ const captureBaseCanvas = document.createElement("canvas");
818
+ captureBaseCanvas.width = width;
819
+ captureBaseCanvas.height = height;
820
+ captureBaseCanvas.style.width = `${width}px`;
821
+ captureBaseCanvas.style.height = `${height}px`;
822
+ captureBaseCanvasRef.current = captureBaseCanvas;
376
823
  lastVisualStateKeyRef.current = null;
377
824
  hasCapturedFrameRef.current = false;
378
825
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
826
+ captureImageDataUrlsRef.current.clear();
827
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
828
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
829
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
379
830
  const container = document.createElement("div");
380
831
  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;`;
381
832
  document.body.appendChild(container);
@@ -442,6 +893,7 @@ function useFrameCapture() {
442
893
  throw new Error("Capture root element not found after player initialization.");
443
894
  }
444
895
  await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
896
+ await primeIndeterminateCaptureVideos(captureRoot, primedCaptureVideosRef.current);
445
897
  clearTimeout(timeout);
446
898
  resolve(api.getDuration());
447
899
  } catch (assetError) {
@@ -465,50 +917,84 @@ function useFrameCapture() {
465
917
  const container = containerRef.current;
466
918
  const api = renderAPIRef.current;
467
919
  const captureCanvas = captureCanvasRef.current;
468
- if (!container || !api || !captureCanvas) {
920
+ const captureBaseCanvas = captureBaseCanvasRef.current;
921
+ if (!container || !api || !captureCanvas || !captureBaseCanvas) {
469
922
  throw new Error("Frame capture not initialized \u2014 call init() first");
470
923
  }
471
924
  const { width, height } = dimensionsRef.current;
472
925
  await api.seekTo(time);
926
+ const root = container.querySelector("#squisq-capture-root");
927
+ if (!root) {
928
+ throw new Error("Capture root element not found");
929
+ }
930
+ if (await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
931
+ await api.seekTo(time);
932
+ }
473
933
  const renderedTime = api.getRenderedTime();
474
934
  if (Math.abs(renderedTime - time) > RENDER_TIME_EPSILON_SECONDS) {
475
935
  throw new Error(
476
936
  `Player committed ${renderedTime.toFixed(6)}s while capture requested ${time.toFixed(6)}s.`
477
937
  );
478
938
  }
479
- const root = container.querySelector("#squisq-capture-root");
480
- if (!root) {
481
- throw new Error("Capture root element not found");
482
- }
483
939
  await waitForCaptureAssets(root, decodedImagesRef.current);
484
- const visualStateKey = options.reuseIfUnchanged ? getFrameVisualStateKey(root, time) : null;
485
- if (visualStateKey !== null && hasCapturedFrameRef.current && lastVisualStateKeyRef.current === visualStateKey) {
486
- return captureCanvas;
487
- }
488
- const captureContext = captureCanvas.getContext("2d");
940
+ const compositePip = canCompositeScheduledPipVideos(root);
941
+ const visualStateKey = options.reuseIfUnchanged ? `${compositePip ? "base" : "full"}:${getFrameVisualStateKey(root, time, {
942
+ ignoreScheduledVideoFrames: compositePip
943
+ })}` : null;
944
+ const shouldRasterize = visualStateKey === null || !hasCapturedFrameRef.current || lastVisualStateKeyRef.current !== visualStateKey;
945
+ if (!shouldRasterize && !compositePip) return captureCanvas;
946
+ const rasterCanvas = compositePip ? captureBaseCanvas : captureCanvas;
947
+ const captureContext = rasterCanvas.getContext("2d");
489
948
  if (!captureContext) throw new Error("Could not create the frame capture canvas context");
490
- captureContext.setTransform(1, 0, 0, 1, 0, 0);
491
- captureContext.clearRect(0, 0, width, height);
492
- const canvas = await html2canvas(root, {
493
- canvas: captureCanvas,
494
- width,
495
- height,
496
- scale: 1,
497
- useCORS: true,
498
- allowTaint: true,
499
- backgroundColor: "#000000",
500
- logging: false,
501
- onclone: (_clonedDocument, clonedRoot) => {
502
- prepareScheduledVideoClones(root, clonedRoot);
503
- },
504
- // html2canvas starts cloning at documentElement. Do not clone the rest
505
- // of the editor/site UI on every frame; only the capture root, its
506
- // ancestors, descendants, and document styles can affect this render.
507
- ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
508
- });
509
- hasCapturedFrameRef.current = true;
510
- lastVisualStateKeyRef.current = visualStateKey;
511
- return canvas;
949
+ if (shouldRasterize) {
950
+ captureContext.setTransform(1, 0, 0, 1, 0, 0);
951
+ captureContext.clearRect(0, 0, width, height);
952
+ }
953
+ const transientCloneCanvases = [];
954
+ if (shouldRasterize) {
955
+ try {
956
+ await html2canvas(root, {
957
+ canvas: rasterCanvas,
958
+ width,
959
+ height,
960
+ scale: 1,
961
+ useCORS: true,
962
+ allowTaint: true,
963
+ backgroundColor: "#000000",
964
+ logging: false,
965
+ onclone: async (_clonedDocument, clonedRoot) => {
966
+ if (compositePip) {
967
+ clonedRoot.querySelectorAll(SCHEDULED_MEDIA_SELECTOR).forEach((element) => element.remove());
968
+ }
969
+ transientCloneCanvases.push(...prepareScheduledVideoClones(root, clonedRoot));
970
+ await rasterizeCaptureSvgClones(
971
+ root,
972
+ clonedRoot,
973
+ transientCloneCanvases,
974
+ captureImageDataUrlsRef.current,
975
+ captureSvgRasterCacheRef.current
976
+ );
977
+ },
978
+ // html2canvas starts cloning at documentElement. Do not clone the rest
979
+ // of the editor/site UI on every frame; only the capture root, its
980
+ // ancestors, descendants, and document styles can affect this render.
981
+ ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
982
+ });
983
+ } finally {
984
+ releaseCaptureCloneCanvases(transientCloneCanvases);
985
+ }
986
+ hasCapturedFrameRef.current = true;
987
+ lastVisualStateKeyRef.current = visualStateKey;
988
+ }
989
+ if (compositePip) {
990
+ const outputContext = captureCanvas.getContext("2d");
991
+ if (!outputContext) throw new Error("Could not create the frame output canvas context");
992
+ outputContext.setTransform(1, 0, 0, 1, 0, 0);
993
+ outputContext.clearRect(0, 0, width, height);
994
+ outputContext.drawImage(captureBaseCanvas, 0, 0);
995
+ compositeScheduledPipVideos(root, captureCanvas);
996
+ }
997
+ return captureCanvas;
512
998
  },
513
999
  []
514
1000
  );
@@ -535,9 +1021,18 @@ function useFrameCapture() {
535
1021
  captureCanvasRef.current.height = 0;
536
1022
  captureCanvasRef.current = null;
537
1023
  }
1024
+ if (captureBaseCanvasRef.current) {
1025
+ captureBaseCanvasRef.current.width = 0;
1026
+ captureBaseCanvasRef.current.height = 0;
1027
+ captureBaseCanvasRef.current = null;
1028
+ }
538
1029
  lastVisualStateKeyRef.current = null;
539
1030
  hasCapturedFrameRef.current = false;
540
1031
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
1032
+ captureImageDataUrlsRef.current.clear();
1033
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
1034
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
1035
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
541
1036
  renderAPIRef.current = null;
542
1037
  }, []);
543
1038
  return useMemo(
@@ -828,13 +1323,41 @@ function calculateRollingFramesPerSecond(frameBoundaryTimes) {
828
1323
  function releaseEncoderFrame(frame) {
829
1324
  if ("close" in frame) frame.close();
830
1325
  }
831
- function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
1326
+ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult, activityDocument) {
832
1327
  return new Promise((resolve, reject) => {
833
1328
  let settled = false;
834
- const timeout = globalThis.setTimeout(() => {
1329
+ let timeout = null;
1330
+ const isInactive = () => activityDocument !== void 0 && activityDocument.visibilityState !== "visible";
1331
+ const clearDeadline = () => {
1332
+ if (timeout === null) return;
1333
+ globalThis.clearTimeout(timeout);
1334
+ timeout = null;
1335
+ };
1336
+ const cleanup = () => {
1337
+ clearDeadline();
1338
+ activityDocument?.removeEventListener("visibilitychange", handleVisibilityChange);
1339
+ };
1340
+ const fail = () => {
1341
+ timeout = null;
1342
+ if (settled || isInactive()) return;
835
1343
  settled = true;
1344
+ cleanup();
836
1345
  reject(new Error(timeoutMessage));
837
- }, timeoutMs);
1346
+ };
1347
+ const armDeadline = () => {
1348
+ clearDeadline();
1349
+ if (settled || isInactive()) return;
1350
+ timeout = globalThis.setTimeout(fail, timeoutMs);
1351
+ };
1352
+ function handleVisibilityChange() {
1353
+ if (activityDocument?.visibilityState !== "visible") {
1354
+ clearDeadline();
1355
+ return;
1356
+ }
1357
+ armDeadline();
1358
+ }
1359
+ activityDocument?.addEventListener("visibilitychange", handleVisibilityChange);
1360
+ armDeadline();
838
1361
  void operation.then(
839
1362
  (value) => {
840
1363
  if (settled) {
@@ -842,13 +1365,13 @@ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
842
1365
  return;
843
1366
  }
844
1367
  settled = true;
845
- globalThis.clearTimeout(timeout);
1368
+ cleanup();
846
1369
  resolve(value);
847
1370
  },
848
1371
  (caught) => {
849
1372
  if (settled) return;
850
1373
  settled = true;
851
- globalThis.clearTimeout(timeout);
1374
+ cleanup();
852
1375
  reject(caught);
853
1376
  }
854
1377
  );
@@ -939,6 +1462,7 @@ function useVideoExport(options = {}) {
939
1462
  const [outputFormat, setOutputFormat] = useState("mp4");
940
1463
  const [backend, setBackend] = useState(null);
941
1464
  const [downloadUrl, setDownloadUrl] = useState(null);
1465
+ const [outputBlob, setOutputBlob] = useState(null);
942
1466
  const [fileSize, setFileSize] = useState(0);
943
1467
  const [audioIncluded, setAudioIncluded] = useState(false);
944
1468
  const [audioSkippedReason, setAudioSkippedReason] = useState(null);
@@ -988,6 +1512,7 @@ function useVideoExport(options = {}) {
988
1512
  setOutputFormat("mp4");
989
1513
  setBackend(null);
990
1514
  setDownloadUrl(null);
1515
+ setOutputBlob(null);
991
1516
  setFileSize(0);
992
1517
  setAudioIncluded(false);
993
1518
  setAudioSkippedReason(null);
@@ -1019,6 +1544,7 @@ function useVideoExport(options = {}) {
1019
1544
  downloadUrlRef.current = null;
1020
1545
  }
1021
1546
  setDownloadUrl(null);
1547
+ setOutputBlob(null);
1022
1548
  setFileSize(0);
1023
1549
  setAudioIncluded(false);
1024
1550
  setAudioSkippedReason(null);
@@ -1069,8 +1595,10 @@ function useVideoExport(options = {}) {
1069
1595
  setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1070
1596
  }, 1e3);
1071
1597
  let images = config.images;
1598
+ let ownsLoadedImages = false;
1072
1599
  if (!images && config.mediaProvider) {
1073
1600
  images = /* @__PURE__ */ new Map();
1601
+ ownsLoadedImages = true;
1074
1602
  const entries = await config.mediaProvider.listMedia();
1075
1603
  const references = collectDocumentMediaReferences(doc);
1076
1604
  const neededEntries = entries.filter(
@@ -1122,7 +1650,9 @@ function useVideoExport(options = {}) {
1122
1650
  const canUseWebCodecs = webCodecsAvailable && await settleWithin(
1123
1651
  supportsWebCodecsH264({ width, height, fps, quality }),
1124
1652
  ENCODER_PROBE_TIMEOUT_MS,
1125
- "The browser did not finish checking WebCodecs support."
1653
+ "The browser did not finish checking WebCodecs support.",
1654
+ void 0,
1655
+ document
1126
1656
  ).catch(() => false);
1127
1657
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
1128
1658
  const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverFrameCount / fps) : [];
@@ -1148,29 +1678,33 @@ function useVideoExport(options = {}) {
1148
1678
  mediaProvider: config.mediaProvider,
1149
1679
  resourcePolicy: config.resourcePolicy
1150
1680
  });
1151
- const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
1152
- (src) => !buffers.has(src)
1153
- );
1154
- if (missingSources.length > 0) {
1155
- audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
1156
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1157
- }
1158
- if (buffers.size === 0) {
1159
- audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
1160
- } else {
1161
- const totalAudioDur = timeline.reduce(
1162
- (max, c) => Math.max(max, c.startSec + c.durationSec),
1163
- exportDuration
1164
- );
1165
- renderedAudio = await renderAudioTimeline(
1166
- timeline,
1167
- buffers,
1168
- totalAudioDur,
1169
- EXPORT_AUDIO_SAMPLE_RATE
1681
+ try {
1682
+ const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
1683
+ (src) => !buffers.has(src)
1170
1684
  );
1171
- if (!renderedAudio) {
1172
- audioReasonLocal = "No included video source contained a decodable audio track.";
1685
+ if (missingSources.length > 0) {
1686
+ audioReasonLocal = `Audio files could not be loaded: ${missingSources.join(", ")}`;
1687
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
1688
+ }
1689
+ if (buffers.size === 0) {
1690
+ audioReasonLocal ?? (audioReasonLocal = "Audio files for this document could not be loaded.");
1691
+ } else {
1692
+ const totalAudioDur = timeline.reduce(
1693
+ (max, c) => Math.max(max, c.startSec + c.durationSec),
1694
+ exportDuration
1695
+ );
1696
+ renderedAudio = await renderAudioTimeline(
1697
+ timeline,
1698
+ buffers,
1699
+ totalAudioDur,
1700
+ EXPORT_AUDIO_SAMPLE_RATE
1701
+ );
1702
+ if (!renderedAudio) {
1703
+ audioReasonLocal = "No included video source contained a decodable audio track.";
1704
+ }
1173
1705
  }
1706
+ } finally {
1707
+ buffers.clear();
1174
1708
  }
1175
1709
  } catch (audioErr) {
1176
1710
  renderedAudio = null;
@@ -1213,7 +1747,9 @@ function useVideoExport(options = {}) {
1213
1747
  const selectedBackend = await settleWithin(
1214
1748
  workerEncoder.ready,
1215
1749
  ENCODER_START_TIMEOUT_MS,
1216
- "The browser export engine did not start within 60 seconds."
1750
+ "The browser export engine did not start within 60 seconds.",
1751
+ void 0,
1752
+ document
1217
1753
  );
1218
1754
  setBackend(selectedBackend);
1219
1755
  } else {
@@ -1221,6 +1757,25 @@ function useVideoExport(options = {}) {
1221
1757
  "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
1222
1758
  );
1223
1759
  }
1760
+ if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
1761
+ setPhase("Encoding audio\u2026");
1762
+ try {
1763
+ await encodeAacTrack(
1764
+ renderedAudio,
1765
+ { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
1766
+ audioBitrate
1767
+ );
1768
+ audioIncludedLocal = true;
1769
+ } catch (audioErr) {
1770
+ audioIncludedLocal = false;
1771
+ audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
1772
+ if (audioPolicy === "require") throw new Error(audioReasonLocal);
1773
+ } finally {
1774
+ renderedAudio = null;
1775
+ }
1776
+ }
1777
+ if (ownsLoadedImages) images?.clear();
1778
+ images = void 0;
1224
1779
  if (cancelledRef.current) return;
1225
1780
  setProgress(CAPTURE_PROGRESS_START);
1226
1781
  setPhase(`Capturing frame 1/${totalFrames}`);
@@ -1241,7 +1796,8 @@ function useVideoExport(options = {}) {
1241
1796
  captureOperation,
1242
1797
  FRAME_CAPTURE_TIMEOUT_MS,
1243
1798
  `Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
1244
- releaseEncoderFrame
1799
+ releaseEncoderFrame,
1800
+ document
1245
1801
  );
1246
1802
  if (cancelledRef.current) {
1247
1803
  releaseEncoderFrame(frame);
@@ -1260,7 +1816,9 @@ function useVideoExport(options = {}) {
1260
1816
  await settleWithin(
1261
1817
  encoder.encodeFrame(frame, i),
1262
1818
  FRAME_ENCODE_TIMEOUT_MS,
1263
- `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
1819
+ `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`,
1820
+ void 0,
1821
+ document
1264
1822
  );
1265
1823
  const completedFrames = i + 1;
1266
1824
  const completedAt = performance.now();
@@ -1282,31 +1840,15 @@ function useVideoExport(options = {}) {
1282
1840
  setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1283
1841
  }
1284
1842
  if (cancelledRef.current) return;
1285
- if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
1286
- setState("encoding");
1287
- setPhase("Encoding audio\u2026");
1288
- try {
1289
- await encodeAacTrack(
1290
- renderedAudio,
1291
- { addAudioChunk: encoder.addAudioChunk.bind(encoder) },
1292
- audioBitrate
1293
- );
1294
- audioIncludedLocal = true;
1295
- } catch (audioErr) {
1296
- audioIncludedLocal = false;
1297
- audioReasonLocal = `Audio encoding failed: ${audioErr instanceof Error ? audioErr.message : String(audioErr)}`;
1298
- if (audioPolicy === "require") throw new Error(audioReasonLocal);
1299
- }
1300
- }
1301
1843
  setState("encoding");
1302
1844
  setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
1303
1845
  setProgress(95);
1304
- let outputBytes = await encoder.finalize();
1846
+ let outputBytes = effectiveOutputFormat === "mp4" && !useFfmpegAudio && encoder.finalizeBlob ? await encoder.finalizeBlob() : await encoder.finalize();
1305
1847
  encoderRef.current = null;
1306
1848
  if (cancelledRef.current) return;
1307
1849
  if (effectiveOutputFormat === "gif") {
1308
1850
  setPhase("Generating GIF palette\u2026");
1309
- const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1851
+ const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1310
1852
  const gifAbort = new AbortController();
1311
1853
  gifAbortRef.current = gifAbort;
1312
1854
  try {
@@ -1323,7 +1865,7 @@ function useVideoExport(options = {}) {
1323
1865
  setPhase("Muxing audio\u2026");
1324
1866
  try {
1325
1867
  const wav = audioBufferToWav(renderedAudio);
1326
- const videoOnly = outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1868
+ const videoOnly = outputBytes instanceof Blob ? new Uint8Array(await outputBytes.arrayBuffer()) : outputBytes instanceof Uint8Array ? outputBytes : new Uint8Array(outputBytes);
1327
1869
  outputBytes = await muxAudioWithFfmpegWasm(
1328
1870
  videoOnly,
1329
1871
  wav,
@@ -1338,13 +1880,18 @@ function useVideoExport(options = {}) {
1338
1880
  }
1339
1881
  }
1340
1882
  if (cancelledRef.current) return;
1341
- const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
1342
1883
  const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
1343
- const blob = new Blob([finalBytes], { type: mimeType });
1884
+ const blob = outputBytes instanceof Blob ? outputBytes : new Blob(
1885
+ [
1886
+ outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes)
1887
+ ],
1888
+ { type: mimeType }
1889
+ );
1344
1890
  const url = URL.createObjectURL(blob);
1345
1891
  downloadUrlRef.current = url;
1346
1892
  setDownloadUrl(url);
1347
- setFileSize(finalBytes.byteLength);
1893
+ setOutputBlob(blob);
1894
+ setFileSize(blob.size);
1348
1895
  setAudioIncluded(audioIncludedLocal);
1349
1896
  setAudioSkippedReason(
1350
1897
  effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal
@@ -1383,6 +1930,7 @@ function useVideoExport(options = {}) {
1383
1930
  outputFormat,
1384
1931
  backend,
1385
1932
  downloadUrl,
1933
+ outputBlob,
1386
1934
  fileSize,
1387
1935
  audioIncluded,
1388
1936
  audioSkippedReason,