@bendyline/squisq-video-react 2.2.10 → 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";
@@ -37,6 +37,10 @@ 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
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]`;
40
44
  async function waitForImageDecode(image) {
41
45
  const src = image.currentSrc || image.src;
42
46
  if (!src) return;
@@ -83,14 +87,111 @@ async function waitForCaptureAssets(captureRoot, decodedImages = /* @__PURE__ */
83
87
  })
84
88
  );
85
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
+ }
86
185
  function createInlineProvider(images) {
87
186
  const blobUrls = /* @__PURE__ */ new Map();
88
187
  const mimeTypes = /* @__PURE__ */ new Map();
188
+ const sizes = /* @__PURE__ */ new Map();
89
189
  for (const [path, buffer] of images) {
90
190
  const ext = path.split(".").pop()?.toLowerCase() ?? "";
91
191
  const mime = MIME_MAP[ext] ?? "application/octet-stream";
92
192
  blobUrls.set(path, URL.createObjectURL(new Blob([buffer], { type: mime })));
93
193
  mimeTypes.set(path, mime);
194
+ sizes.set(path, buffer.byteLength);
94
195
  }
95
196
  return {
96
197
  async resolveUrl(relativePath) {
@@ -100,7 +201,7 @@ function createInlineProvider(images) {
100
201
  return [...blobUrls.keys()].map((name) => ({
101
202
  name,
102
203
  mimeType: mimeTypes.get(name) ?? "application/octet-stream",
103
- size: images.get(name)?.byteLength ?? 0
204
+ size: sizes.get(name) ?? 0
104
205
  }));
105
206
  },
106
207
  async addMedia() {
@@ -112,6 +213,7 @@ function createInlineProvider(images) {
112
213
  dispose() {
113
214
  blobUrls.forEach((url) => URL.revokeObjectURL(url));
114
215
  blobUrls.clear();
216
+ sizes.clear();
115
217
  }
116
218
  };
117
219
  }
@@ -274,6 +376,17 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
274
376
  });
275
377
  return preparedCanvases;
276
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
+ }
277
390
  function parseAbsoluteSvgLength(value) {
278
391
  if (!value) return 0;
279
392
  const match = /^\s*(\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/i.exec(value);
@@ -336,9 +449,11 @@ function resolveCaptureImageDataUrl(source, cache) {
336
449
  const cached = cache.get(source);
337
450
  if (cached) return cached;
338
451
  const pending = fetch(source).then(async (response) => {
339
- if (!response.ok) return null;
452
+ if (!response.ok) {
453
+ throw new Error(`Image could not be loaded for SVG capture: ${source}`);
454
+ }
340
455
  return blobToDataUrl(await response.blob(), source);
341
- }).catch(() => null);
456
+ });
342
457
  cache.set(source, pending);
343
458
  return pending;
344
459
  }
@@ -367,51 +482,101 @@ async function embedCaptureSvgImages(svg, cache) {
367
482
  for (const reference of references) {
368
483
  if (/^data:/i.test(reference.source) || reference.source.startsWith("#")) continue;
369
484
  const dataUrl = await resolveCaptureImageDataUrl(reference.source, cache);
370
- if (!dataUrl) return false;
371
485
  reference.replace(dataUrl);
372
486
  }
373
- return true;
374
487
  }
375
- async function rasterizeCaptureSvgClones(originalRoot, clonedRoot, transientCanvases = [], imageDataUrls = /* @__PURE__ */ new Map()) {
376
- if (typeof createImageBitmap !== "function") return transientCanvases;
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;
377
495
  const originalSvgs = Array.from(
378
496
  originalRoot.querySelectorAll(CAPTURE_SVG_SELECTOR)
379
497
  );
380
498
  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;
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");
409
569
  }
410
- } finally {
411
- bitmap?.close();
412
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);
413
579
  }
414
- return transientCanvases;
415
580
  }
416
581
  function releaseCaptureCloneCanvases(canvases) {
417
582
  canvases.forEach((canvas) => {
@@ -419,7 +584,124 @@ function releaseCaptureCloneCanvases(canvases) {
419
584
  canvas.height = 0;
420
585
  });
421
586
  }
422
- function getFrameVisualStateKey(captureRoot, timelineTime) {
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
+ }
629
+ }
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 = {}) {
423
705
  const markup = captureRoot.innerHTML;
424
706
  let needsTimelineKey = false;
425
707
  const animationStates = [];
@@ -450,7 +732,9 @@ function getFrameVisualStateKey(captureRoot, timelineTime) {
450
732
  return `${src}:${image.complete}:${image.naturalWidth}x${image.naturalHeight}`;
451
733
  });
452
734
  if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
453
- 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(
454
738
  (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
455
739
  );
456
740
  if (captureRoot.querySelector(
@@ -474,27 +758,35 @@ function useFrameCapture() {
474
758
  const renderAPIRef = useRef(null);
475
759
  const mediaProviderRef = useRef(null);
476
760
  const captureCanvasRef = useRef(null);
761
+ const captureBaseCanvasRef = useRef(null);
477
762
  const lastVisualStateKeyRef = useRef(null);
478
763
  const hasCapturedFrameRef = useRef(false);
479
764
  const decodedImagesRef = useRef(/* @__PURE__ */ new WeakSet());
480
765
  const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
766
+ const captureSvgRasterCacheRef = useRef(createCaptureSvgRasterCache());
767
+ const primedCaptureVideosRef = useRef(/* @__PURE__ */ new WeakSet());
481
768
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
482
769
  const init = useCallback(
483
770
  async (doc, renderOptions, captionMode) => {
484
- if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current) {
771
+ if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current) {
485
772
  const oldRoot = rootRef.current;
486
773
  const oldContainer = containerRef.current;
487
774
  const oldMediaProvider = mediaProviderRef.current;
488
775
  const oldCaptureCanvas = captureCanvasRef.current;
776
+ const oldCaptureBaseCanvas = captureBaseCanvasRef.current;
489
777
  rootRef.current = null;
490
778
  containerRef.current = null;
491
779
  renderAPIRef.current = null;
492
780
  mediaProviderRef.current = null;
493
781
  captureCanvasRef.current = null;
782
+ captureBaseCanvasRef.current = null;
494
783
  lastVisualStateKeyRef.current = null;
495
784
  hasCapturedFrameRef.current = false;
496
785
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
497
786
  captureImageDataUrlsRef.current.clear();
787
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
788
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
789
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
498
790
  await new Promise((resolve) => {
499
791
  setTimeout(() => {
500
792
  if (oldRoot) oldRoot.unmount();
@@ -504,6 +796,10 @@ function useFrameCapture() {
504
796
  oldCaptureCanvas.width = 0;
505
797
  oldCaptureCanvas.height = 0;
506
798
  }
799
+ if (oldCaptureBaseCanvas) {
800
+ oldCaptureBaseCanvas.width = 0;
801
+ oldCaptureBaseCanvas.height = 0;
802
+ }
507
803
  resolve();
508
804
  }, 0);
509
805
  });
@@ -518,10 +814,19 @@ function useFrameCapture() {
518
814
  captureCanvas.style.width = `${width}px`;
519
815
  captureCanvas.style.height = `${height}px`;
520
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;
521
823
  lastVisualStateKeyRef.current = null;
522
824
  hasCapturedFrameRef.current = false;
523
825
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
524
826
  captureImageDataUrlsRef.current.clear();
827
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
828
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
829
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
525
830
  const container = document.createElement("div");
526
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;`;
527
832
  document.body.appendChild(container);
@@ -588,6 +893,7 @@ function useFrameCapture() {
588
893
  throw new Error("Capture root element not found after player initialization.");
589
894
  }
590
895
  await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
896
+ await primeIndeterminateCaptureVideos(captureRoot, primedCaptureVideosRef.current);
591
897
  clearTimeout(timeout);
592
898
  resolve(api.getDuration());
593
899
  } catch (assetError) {
@@ -611,62 +917,84 @@ function useFrameCapture() {
611
917
  const container = containerRef.current;
612
918
  const api = renderAPIRef.current;
613
919
  const captureCanvas = captureCanvasRef.current;
614
- if (!container || !api || !captureCanvas) {
920
+ const captureBaseCanvas = captureBaseCanvasRef.current;
921
+ if (!container || !api || !captureCanvas || !captureBaseCanvas) {
615
922
  throw new Error("Frame capture not initialized \u2014 call init() first");
616
923
  }
617
924
  const { width, height } = dimensionsRef.current;
618
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
+ }
619
933
  const renderedTime = api.getRenderedTime();
620
934
  if (Math.abs(renderedTime - time) > RENDER_TIME_EPSILON_SECONDS) {
621
935
  throw new Error(
622
936
  `Player committed ${renderedTime.toFixed(6)}s while capture requested ${time.toFixed(6)}s.`
623
937
  );
624
938
  }
625
- const root = container.querySelector("#squisq-capture-root");
626
- if (!root) {
627
- throw new Error("Capture root element not found");
628
- }
629
939
  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");
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");
635
948
  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);
949
+ if (shouldRasterize) {
950
+ captureContext.setTransform(1, 0, 0, 1, 0, 0);
951
+ captureContext.clearRect(0, 0, width, height);
952
+ }
638
953
  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);
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);
666
996
  }
667
- hasCapturedFrameRef.current = true;
668
- lastVisualStateKeyRef.current = visualStateKey;
669
- return canvas;
997
+ return captureCanvas;
670
998
  },
671
999
  []
672
1000
  );
@@ -693,10 +1021,18 @@ function useFrameCapture() {
693
1021
  captureCanvasRef.current.height = 0;
694
1022
  captureCanvasRef.current = null;
695
1023
  }
1024
+ if (captureBaseCanvasRef.current) {
1025
+ captureBaseCanvasRef.current.width = 0;
1026
+ captureBaseCanvasRef.current.height = 0;
1027
+ captureBaseCanvasRef.current = null;
1028
+ }
696
1029
  lastVisualStateKeyRef.current = null;
697
1030
  hasCapturedFrameRef.current = false;
698
1031
  decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
699
1032
  captureImageDataUrlsRef.current.clear();
1033
+ releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
1034
+ captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
1035
+ primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
700
1036
  renderAPIRef.current = null;
701
1037
  }, []);
702
1038
  return useMemo(
@@ -987,13 +1323,41 @@ function calculateRollingFramesPerSecond(frameBoundaryTimes) {
987
1323
  function releaseEncoderFrame(frame) {
988
1324
  if ("close" in frame) frame.close();
989
1325
  }
990
- function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
1326
+ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult, activityDocument) {
991
1327
  return new Promise((resolve, reject) => {
992
1328
  let settled = false;
993
- 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;
994
1343
  settled = true;
1344
+ cleanup();
995
1345
  reject(new Error(timeoutMessage));
996
- }, 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();
997
1361
  void operation.then(
998
1362
  (value) => {
999
1363
  if (settled) {
@@ -1001,13 +1365,13 @@ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
1001
1365
  return;
1002
1366
  }
1003
1367
  settled = true;
1004
- globalThis.clearTimeout(timeout);
1368
+ cleanup();
1005
1369
  resolve(value);
1006
1370
  },
1007
1371
  (caught) => {
1008
1372
  if (settled) return;
1009
1373
  settled = true;
1010
- globalThis.clearTimeout(timeout);
1374
+ cleanup();
1011
1375
  reject(caught);
1012
1376
  }
1013
1377
  );
@@ -1231,8 +1595,10 @@ function useVideoExport(options = {}) {
1231
1595
  setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1232
1596
  }, 1e3);
1233
1597
  let images = config.images;
1598
+ let ownsLoadedImages = false;
1234
1599
  if (!images && config.mediaProvider) {
1235
1600
  images = /* @__PURE__ */ new Map();
1601
+ ownsLoadedImages = true;
1236
1602
  const entries = await config.mediaProvider.listMedia();
1237
1603
  const references = collectDocumentMediaReferences(doc);
1238
1604
  const neededEntries = entries.filter(
@@ -1284,7 +1650,9 @@ function useVideoExport(options = {}) {
1284
1650
  const canUseWebCodecs = webCodecsAvailable && await settleWithin(
1285
1651
  supportsWebCodecsH264({ width, height, fps, quality }),
1286
1652
  ENCODER_PROBE_TIMEOUT_MS,
1287
- "The browser did not finish checking WebCodecs support."
1653
+ "The browser did not finish checking WebCodecs support.",
1654
+ void 0,
1655
+ document
1288
1656
  ).catch(() => false);
1289
1657
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
1290
1658
  const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverFrameCount / fps) : [];
@@ -1310,29 +1678,33 @@ function useVideoExport(options = {}) {
1310
1678
  mediaProvider: config.mediaProvider,
1311
1679
  resourcePolicy: config.resourcePolicy
1312
1680
  });
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
1681
+ try {
1682
+ const missingSources = [...new Set(timeline.map((clip) => clip.src))].filter(
1683
+ (src) => !buffers.has(src)
1332
1684
  );
1333
- if (!renderedAudio) {
1334
- 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
+ }
1335
1705
  }
1706
+ } finally {
1707
+ buffers.clear();
1336
1708
  }
1337
1709
  } catch (audioErr) {
1338
1710
  renderedAudio = null;
@@ -1375,7 +1747,9 @@ function useVideoExport(options = {}) {
1375
1747
  const selectedBackend = await settleWithin(
1376
1748
  workerEncoder.ready,
1377
1749
  ENCODER_START_TIMEOUT_MS,
1378
- "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
1379
1753
  );
1380
1754
  setBackend(selectedBackend);
1381
1755
  } else {
@@ -1383,6 +1757,25 @@ function useVideoExport(options = {}) {
1383
1757
  "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
1384
1758
  );
1385
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;
1386
1779
  if (cancelledRef.current) return;
1387
1780
  setProgress(CAPTURE_PROGRESS_START);
1388
1781
  setPhase(`Capturing frame 1/${totalFrames}`);
@@ -1403,7 +1796,8 @@ function useVideoExport(options = {}) {
1403
1796
  captureOperation,
1404
1797
  FRAME_CAPTURE_TIMEOUT_MS,
1405
1798
  `Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
1406
- releaseEncoderFrame
1799
+ releaseEncoderFrame,
1800
+ document
1407
1801
  );
1408
1802
  if (cancelledRef.current) {
1409
1803
  releaseEncoderFrame(frame);
@@ -1422,7 +1816,9 @@ function useVideoExport(options = {}) {
1422
1816
  await settleWithin(
1423
1817
  encoder.encodeFrame(frame, i),
1424
1818
  FRAME_ENCODE_TIMEOUT_MS,
1425
- `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
1819
+ `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`,
1820
+ void 0,
1821
+ document
1426
1822
  );
1427
1823
  const completedFrames = i + 1;
1428
1824
  const completedAt = performance.now();
@@ -1444,31 +1840,15 @@ function useVideoExport(options = {}) {
1444
1840
  setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
1445
1841
  }
1446
1842
  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
1843
  setState("encoding");
1464
1844
  setPhase(effectiveOutputFormat === "gif" ? "Finalizing GIF frames\u2026" : "Finalizing video\u2026");
1465
1845
  setProgress(95);
1466
- let outputBytes = await encoder.finalize();
1846
+ let outputBytes = effectiveOutputFormat === "mp4" && !useFfmpegAudio && encoder.finalizeBlob ? await encoder.finalizeBlob() : await encoder.finalize();
1467
1847
  encoderRef.current = null;
1468
1848
  if (cancelledRef.current) return;
1469
1849
  if (effectiveOutputFormat === "gif") {
1470
1850
  setPhase("Generating GIF palette\u2026");
1471
- 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);
1472
1852
  const gifAbort = new AbortController();
1473
1853
  gifAbortRef.current = gifAbort;
1474
1854
  try {
@@ -1485,7 +1865,7 @@ function useVideoExport(options = {}) {
1485
1865
  setPhase("Muxing audio\u2026");
1486
1866
  try {
1487
1867
  const wav = audioBufferToWav(renderedAudio);
1488
- 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);
1489
1869
  outputBytes = await muxAudioWithFfmpegWasm(
1490
1870
  videoOnly,
1491
1871
  wav,
@@ -1500,14 +1880,18 @@ function useVideoExport(options = {}) {
1500
1880
  }
1501
1881
  }
1502
1882
  if (cancelledRef.current) return;
1503
- const finalBytes = outputBytes instanceof Uint8Array ? outputBytes.slice() : new Uint8Array(outputBytes);
1504
1883
  const mimeType = effectiveOutputFormat === "gif" ? "image/gif" : "video/mp4";
1505
- 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
+ );
1506
1890
  const url = URL.createObjectURL(blob);
1507
1891
  downloadUrlRef.current = url;
1508
1892
  setDownloadUrl(url);
1509
1893
  setOutputBlob(blob);
1510
- setFileSize(finalBytes.byteLength);
1894
+ setFileSize(blob.size);
1511
1895
  setAudioIncluded(audioIncludedLocal);
1512
1896
  setAudioSkippedReason(
1513
1897
  effectiveOutputFormat === "gif" || audioIncludedLocal ? null : audioReasonLocal