@bendyline/squisq-video-react 2.4.11 → 2.4.13

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,10 +1,256 @@
1
+ import {
2
+ BlobSource,
3
+ Input,
4
+ MATROSKA,
5
+ MP4,
6
+ QTFF,
7
+ VideoSampleSink,
8
+ WEBM
9
+ } from "./chunk-4GFZ3GUE.js";
10
+
1
11
  // src/hooks/useFrameCapture.ts
2
12
  import { createElement } from "react";
3
13
  import { createRoot } from "react-dom/client";
4
14
  import { useRef, useCallback, useMemo } from "react";
15
+ import { parseMediaCrop } from "@bendyline/squisq/mediaEdit";
5
16
  import { resolveDashboardStyleId } from "@bendyline/squisq/doc";
6
17
  import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
7
18
  import html2canvas from "html2canvas";
19
+
20
+ // src/hooks/captureVideoFrames.ts
21
+ var FRAME_TIME_EPSILON_SECONDS = 1e-4;
22
+ var MAX_SEQUENTIAL_DECODE_GAP_SECONDS = 3;
23
+ var DECODABLE_SOURCE_URL = /^(?:blob|data):/i;
24
+ var INPUT_FORMATS = [MP4, QTFF, WEBM, MATROSKA];
25
+ var decodedFrames = /* @__PURE__ */ new WeakMap();
26
+ function getDecodedCaptureFrame(video) {
27
+ const frame = decodedFrames.get(video);
28
+ if (!frame || frame.timestamp === null) return null;
29
+ return {
30
+ canvas: frame.canvas,
31
+ width: frame.width,
32
+ height: frame.height,
33
+ timestamp: frame.timestamp
34
+ };
35
+ }
36
+ function resolveCaptureVideoImage(video) {
37
+ const decoded = getDecodedCaptureFrame(video);
38
+ if (decoded) return { source: decoded.canvas, width: decoded.width, height: decoded.height };
39
+ return { source: video, width: video.videoWidth, height: video.videoHeight };
40
+ }
41
+ function captureVideoSource(video) {
42
+ return video.currentSrc || video.src;
43
+ }
44
+ var DecodedVideoCursor = class _DecodedVideoCursor {
45
+ /**
46
+ * @param source Sequential sample iterator factory for one video track.
47
+ * @param releaseSource Frees the demuxer behind `source`.
48
+ */
49
+ constructor(source, releaseSource, canvas, width, height) {
50
+ this.source = source;
51
+ this.releaseSource = releaseSource;
52
+ this.iterator = null;
53
+ this.lookahead = null;
54
+ /** True while showing a track's first frame for a target before it. */
55
+ this.showingLeadingFrame = false;
56
+ this.queue = Promise.resolve();
57
+ this.disposed = false;
58
+ canvas.width = width;
59
+ canvas.height = height;
60
+ this.frame = { canvas, width, height, timestamp: null };
61
+ }
62
+ /** Open a local source, or null when it has no decodable video track. */
63
+ static async open(blob, ownerDocument) {
64
+ const input = new Input({ source: new BlobSource(blob), formats: INPUT_FORMATS });
65
+ try {
66
+ const track = await input.getPrimaryVideoTrack();
67
+ if (!track || !await track.canDecode()) {
68
+ input.dispose();
69
+ return null;
70
+ }
71
+ const width = track.displayWidth;
72
+ const height = track.displayHeight;
73
+ if (width <= 0 || height <= 0) {
74
+ input.dispose();
75
+ return null;
76
+ }
77
+ return new _DecodedVideoCursor(
78
+ new VideoSampleSink(track),
79
+ () => input.dispose(),
80
+ ownerDocument.createElement("canvas"),
81
+ width,
82
+ height
83
+ );
84
+ } catch {
85
+ input.dispose();
86
+ return null;
87
+ }
88
+ }
89
+ /** Draw the frame displayed at `targetTime` (seconds). Calls run in order. */
90
+ selectFrame(targetTime) {
91
+ const selection = this.queue.then(() => this.advanceTo(targetTime));
92
+ this.queue = selection.catch(() => void 0);
93
+ return selection;
94
+ }
95
+ dispose() {
96
+ if (this.disposed) return;
97
+ this.disposed = true;
98
+ this.lookahead?.close();
99
+ this.lookahead = null;
100
+ void this.iterator?.return();
101
+ this.iterator = null;
102
+ this.releaseSource();
103
+ this.frame.canvas.width = 0;
104
+ this.frame.canvas.height = 0;
105
+ this.frame.timestamp = null;
106
+ }
107
+ async advanceTo(targetTime) {
108
+ if (this.disposed) throw new Error("Decoded capture video was released.");
109
+ const target = targetTime + FRAME_TIME_EPSILON_SECONDS;
110
+ const shown = this.frame.timestamp;
111
+ const continues = this.iterator !== null && shown !== null && target - shown <= MAX_SEQUENTIAL_DECODE_GAP_SECONDS && (target >= shown || this.showingLeadingFrame);
112
+ if (continues && target < shown) return;
113
+ let candidate = null;
114
+ try {
115
+ if (!continues) {
116
+ await this.restartAt(target);
117
+ candidate = await this.pull();
118
+ if (!candidate) return;
119
+ }
120
+ for (; ; ) {
121
+ const next = this.lookahead ?? await this.pull();
122
+ this.lookahead = null;
123
+ if (!next) break;
124
+ if (next.timestamp > target) {
125
+ this.lookahead = next;
126
+ break;
127
+ }
128
+ candidate?.close();
129
+ candidate = next;
130
+ }
131
+ if (candidate && !this.disposed) {
132
+ this.present(candidate);
133
+ this.showingLeadingFrame = candidate.timestamp > target;
134
+ }
135
+ } finally {
136
+ candidate?.close();
137
+ }
138
+ }
139
+ async restartAt(target) {
140
+ this.lookahead?.close();
141
+ this.lookahead = null;
142
+ await this.iterator?.return();
143
+ this.iterator = this.source.samples(target);
144
+ }
145
+ async pull() {
146
+ const result = await this.iterator?.next();
147
+ return result && !result.done ? result.value : null;
148
+ }
149
+ present(sample) {
150
+ const { frame } = this;
151
+ if (sample.displayWidth !== frame.width || sample.displayHeight !== frame.height) {
152
+ frame.width = sample.displayWidth;
153
+ frame.height = sample.displayHeight;
154
+ frame.canvas.width = frame.width;
155
+ frame.canvas.height = frame.height;
156
+ }
157
+ const context = frame.canvas.getContext("2d");
158
+ if (!context) throw new Error("Could not create the decoded video frame canvas context");
159
+ sample.draw(context, 0, 0, frame.width, frame.height);
160
+ frame.timestamp = sample.timestamp;
161
+ }
162
+ };
163
+ var CaptureVideoFrameRegistry = class {
164
+ constructor() {
165
+ this.bindings = /* @__PURE__ */ new Map();
166
+ this.blobs = /* @__PURE__ */ new Map();
167
+ /** Sources that failed to open or decode; they keep the element path. */
168
+ this.undecodableSources = /* @__PURE__ */ new Set();
169
+ this.opening = /* @__PURE__ */ new Map();
170
+ this.disposed = false;
171
+ this.selectFrame = async (video, targetTime) => {
172
+ const binding = this.bindings.get(video);
173
+ if (!binding || binding.src !== captureVideoSource(video)) return false;
174
+ try {
175
+ await binding.cursor.selectFrame(targetTime);
176
+ return true;
177
+ } catch {
178
+ this.undecodableSources.add(binding.src);
179
+ this.release(video);
180
+ return false;
181
+ }
182
+ };
183
+ }
184
+ /**
185
+ * Bind decoders to every video under `root` that lacks one for its current
186
+ * source, and release bindings whose element left the DOM or changed
187
+ * source. Returns how many elements were newly bound: their pixels now
188
+ * come from the decoder, so a caller must re-run its seek barrier.
189
+ */
190
+ async attach(root) {
191
+ for (const [video, binding] of this.bindings) {
192
+ if (!video.isConnected || captureVideoSource(video) !== binding.src) this.release(video);
193
+ }
194
+ const results = await Promise.all(
195
+ Array.from(root.querySelectorAll("video")).filter((video) => !this.bindings.has(video)).map((video) => this.bind(video))
196
+ );
197
+ return results.filter(Boolean).length;
198
+ }
199
+ /** Whether capture draws this element from decoded frames. */
200
+ isDecoded(video) {
201
+ return this.bindings.has(video);
202
+ }
203
+ dispose() {
204
+ this.disposed = true;
205
+ for (const video of [...this.bindings.keys()]) this.release(video);
206
+ this.blobs.clear();
207
+ this.opening.clear();
208
+ }
209
+ bind(video) {
210
+ const pending = this.opening.get(video);
211
+ if (pending) return pending;
212
+ const opening = this.open(video).finally(() => this.opening.delete(video));
213
+ this.opening.set(video, opening);
214
+ return opening;
215
+ }
216
+ async open(video) {
217
+ const src = captureVideoSource(video);
218
+ if (!src || !DECODABLE_SOURCE_URL.test(src) || this.undecodableSources.has(src)) return false;
219
+ const blob = await this.loadBlob(src);
220
+ const cursor = blob ? await DecodedVideoCursor.open(blob, video.ownerDocument) : null;
221
+ if (!cursor) {
222
+ this.undecodableSources.add(src);
223
+ return false;
224
+ }
225
+ if (this.disposed || !video.isConnected || captureVideoSource(video) !== src) {
226
+ cursor.dispose();
227
+ return false;
228
+ }
229
+ this.bindings.set(video, { src, cursor });
230
+ decodedFrames.set(video, cursor.frame);
231
+ video.pause();
232
+ video.dataset.captureDecoded = "true";
233
+ return true;
234
+ }
235
+ loadBlob(src) {
236
+ let blob = this.blobs.get(src);
237
+ if (!blob) {
238
+ blob = fetch(src).then((response) => response.ok ? response.blob() : null).catch(() => null);
239
+ this.blobs.set(src, blob);
240
+ }
241
+ return blob;
242
+ }
243
+ release(video) {
244
+ const binding = this.bindings.get(video);
245
+ if (!binding) return;
246
+ this.bindings.delete(video);
247
+ if (decodedFrames.get(video) === binding.cursor.frame) decodedFrames.delete(video);
248
+ delete video.dataset.captureDecoded;
249
+ binding.cursor.dispose();
250
+ }
251
+ };
252
+
253
+ // src/hooks/useFrameCapture.ts
8
254
  var MIME_MAP = {
9
255
  jpg: "image/jpeg",
10
256
  jpeg: "image/jpeg",
@@ -21,7 +267,10 @@ var MIME_MAP = {
21
267
  webm: "video/webm"
22
268
  };
23
269
  var VISUAL_UPDATE_FALLBACK_MS = 100;
270
+ var CAPTURE_URL_COMMIT_MAX_WAITS = 10;
24
271
  var CAPTURE_ASSET_TIMEOUT_MS = 15e3;
272
+ var RENDER_API_TIMEOUT_MS = 15e3;
273
+ var CAPTURE_SETUP_TIMEOUT_MS = 6e4;
25
274
  var RENDER_TIME_EPSILON_SECONDS = 1e-6;
26
275
  var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
27
276
  var CAPTURE_SVG_SELECTOR = "svg.block-svg";
@@ -147,6 +396,7 @@ function waitForCaptureVideoState(video, description, isReady, update) {
147
396
  });
148
397
  }
149
398
  function captureVideoNeedsPriming(video, primedVideos) {
399
+ if (video.dataset.captureDecoded === "true") return false;
150
400
  if (video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth <= 0 && video.videoHeight <= 0) {
151
401
  return false;
152
402
  }
@@ -172,8 +422,8 @@ async function primeIndeterminateCaptureVideos(captureRoot, primedVideos = /* @_
172
422
  primedVideos.add(video);
173
423
  return;
174
424
  }
425
+ video.dataset.captureSequential = "true";
175
426
  if (Number.isFinite(video.duration)) {
176
- video.dataset.captureSequential = "true";
177
427
  primedVideos.add(video);
178
428
  return;
179
429
  }
@@ -196,7 +446,6 @@ async function primeIndeterminateCaptureVideos(captureRoot, primedVideos = /* @_
196
446
  video.currentTime = reachableRestoreTime;
197
447
  }
198
448
  );
199
- video.dataset.captureSequential = "true";
200
449
  primedVideos.add(video);
201
450
  primedCount += 1;
202
451
  })
@@ -240,6 +489,7 @@ function createInlineProvider(images) {
240
489
  }
241
490
  function createCaptureMediaResolutionTracker(provider) {
242
491
  const pending = /* @__PURE__ */ new Set();
492
+ const resolvedUrls = /* @__PURE__ */ new Map();
243
493
  let resolutionGeneration = 0;
244
494
  let observedGeneration = 0;
245
495
  const trackedProvider = {
@@ -247,7 +497,8 @@ function createCaptureMediaResolutionTracker(provider) {
247
497
  resolutionGeneration += 1;
248
498
  const resolution = provider.resolveUrl(relativePath);
249
499
  const completion = resolution.then(
250
- () => {
500
+ (url) => {
501
+ resolvedUrls.set(relativePath, url);
251
502
  pending.delete(completion);
252
503
  },
253
504
  () => {
@@ -274,13 +525,37 @@ function createCaptureMediaResolutionTracker(provider) {
274
525
  const changed = resolutionGeneration !== observedGeneration;
275
526
  observedGeneration = resolutionGeneration;
276
527
  return changed;
277
- }
528
+ },
529
+ resolvedUrls: () => resolvedUrls
278
530
  };
279
531
  }
280
- async function waitForCaptureMediaResolutions(tracker) {
281
- while (await tracker?.waitForSettled()) {
532
+ function stripRelativePrefix(path) {
533
+ return path.replace(/^(?:\.\/)+/, "");
534
+ }
535
+ function showsUncommittedMediaUrl(root, resolved) {
536
+ const mapped = /* @__PURE__ */ new Set();
537
+ for (const [path, url] of resolved) {
538
+ if (url !== path) mapped.add(stripRelativePrefix(path));
539
+ }
540
+ if (mapped.size === 0) return false;
541
+ return Array.from(root.querySelectorAll("img[src], video[src], audio[src], source[src]")).some(
542
+ (element) => mapped.has(stripRelativePrefix(element.getAttribute("src") ?? ""))
543
+ );
544
+ }
545
+ async function waitForCaptureMediaResolutions(tracker, captureRoot) {
546
+ if (!tracker) return;
547
+ let changed = false;
548
+ while (await tracker.waitForSettled()) {
549
+ changed = true;
282
550
  await waitForVisualUpdate();
283
551
  }
552
+ if (!changed || !captureRoot) return;
553
+ for (let attempt = 0; attempt < CAPTURE_URL_COMMIT_MAX_WAITS && showsUncommittedMediaUrl(captureRoot, tracker.resolvedUrls()); attempt++) {
554
+ await waitForVisualUpdate();
555
+ while (await tracker.waitForSettled()) {
556
+ await waitForVisualUpdate();
557
+ }
558
+ }
284
559
  }
285
560
  function shouldIgnoreCaptureSibling(element, captureRoot) {
286
561
  const { head } = captureRoot.ownerDocument;
@@ -350,6 +625,30 @@ function videoFrameRect(sourceWidth, sourceHeight, destinationWidth, destination
350
625
  dh: destinationHeight
351
626
  };
352
627
  }
628
+ function croppedVideoFrameRect(sourceWidth, sourceHeight, crop, destinationWidth, destinationHeight, objectFit) {
629
+ if (!crop) {
630
+ return videoFrameRect(
631
+ sourceWidth,
632
+ sourceHeight,
633
+ destinationWidth,
634
+ destinationHeight,
635
+ objectFit
636
+ );
637
+ }
638
+ const cx = crop.x * sourceWidth;
639
+ const cy = crop.y * sourceHeight;
640
+ const rect = videoFrameRect(
641
+ crop.w * sourceWidth,
642
+ crop.h * sourceHeight,
643
+ destinationWidth,
644
+ destinationHeight,
645
+ objectFit
646
+ );
647
+ return { ...rect, sx: rect.sx + cx, sy: rect.sy + cy };
648
+ }
649
+ function videoCrop(video) {
650
+ return parseMediaCrop(video.getAttribute("data-crop") ?? void 0);
651
+ }
353
652
  function prepareScheduledVideoClones(originalRoot, clonedRoot) {
354
653
  const captureFamilies = [
355
654
  {
@@ -381,15 +680,17 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
381
680
  canvas.dataset.videoCaptureClone = "true";
382
681
  const destinationWidth = Math.round(video.clientWidth || video.offsetWidth);
383
682
  const destinationHeight = Math.round(video.clientHeight || video.offsetHeight);
384
- if (video.videoWidth <= 0 || video.videoHeight <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
683
+ const image = resolveCaptureVideoImage(video);
684
+ if (image.width <= 0 || image.height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
385
685
  return;
386
686
  }
387
687
  try {
388
688
  const view = video.ownerDocument.defaultView;
389
689
  const objectFit = video.style.objectFit || view?.getComputedStyle(video).objectFit || "fill";
390
- const frame = videoFrameRect(
391
- video.videoWidth,
392
- video.videoHeight,
690
+ const frame = croppedVideoFrameRect(
691
+ image.width,
692
+ image.height,
693
+ videoCrop(video),
393
694
  destinationWidth,
394
695
  destinationHeight,
395
696
  objectFit
@@ -399,7 +700,7 @@ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
399
700
  canvas.width = destinationWidth;
400
701
  canvas.height = destinationHeight;
401
702
  context.drawImage(
402
- video,
703
+ image.source,
403
704
  frame.sx,
404
705
  frame.sy,
405
706
  frame.sw,
@@ -650,7 +951,8 @@ function releaseCaptureCloneCanvases(canvases) {
650
951
  });
651
952
  }
652
953
  function scheduledVideoIsVisual(video) {
653
- return video.videoWidth > 0 && video.videoHeight > 0 && video.dataset.active === "true";
954
+ const { width, height } = resolveCaptureVideoImage(video);
955
+ return width > 0 && height > 0 && video.dataset.active === "true";
654
956
  }
655
957
  function scheduledVideoPresentation(video) {
656
958
  return video.closest(SCHEDULED_MEDIA_SELECTOR)?.dataset.presentation;
@@ -786,9 +1088,11 @@ function drawScheduledVideosOnto(destination, captureRoot, videos) {
786
1088
  0,
787
1089
  outerRadius - Math.max(borderLeft * scaleX, borderTop * scaleY)
788
1090
  );
789
- const source = videoFrameRect(
790
- video.videoWidth,
791
- video.videoHeight,
1091
+ const image = resolveCaptureVideoImage(video);
1092
+ const source = croppedVideoFrameRect(
1093
+ image.width,
1094
+ image.height,
1095
+ videoCrop(video),
792
1096
  contentWidth,
793
1097
  contentHeight,
794
1098
  style.objectFit || "fill"
@@ -806,7 +1110,7 @@ function drawScheduledVideosOnto(destination, captureRoot, videos) {
806
1110
  addRoundedRect(context, innerX, innerY, innerWidth, innerHeight, innerRadius);
807
1111
  context.clip();
808
1112
  context.drawImage(
809
- video,
1113
+ image.source,
810
1114
  source.sx,
811
1115
  source.sy,
812
1116
  source.sw,
@@ -852,10 +1156,20 @@ function getFrameVisualStateKey(captureRoot, timelineTime, options = {}) {
852
1156
  });
853
1157
  if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
854
1158
  const videoStates = Array.from(captureRoot.querySelectorAll("video")).filter(
855
- (video) => video.videoWidth > 0 && video.videoHeight > 0 && (!options.ignoreScheduledVideoFrames || !video.closest(SCHEDULED_MEDIA_SELECTOR))
856
- ).map(
857
- (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
858
- );
1159
+ (video) => !options.ignoreScheduledVideoFrames || !video.closest(SCHEDULED_MEDIA_SELECTOR)
1160
+ ).flatMap((video) => {
1161
+ const src = video.currentSrc || video.src;
1162
+ const decoded = getDecodedCaptureFrame(video);
1163
+ if (decoded) {
1164
+ return [
1165
+ `${src}:decoded:${finiteMediaTime(decoded.timestamp)}:${decoded.width}x${decoded.height}`
1166
+ ];
1167
+ }
1168
+ if (video.videoWidth <= 0 || video.videoHeight <= 0) return [];
1169
+ return [
1170
+ `${src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
1171
+ ];
1172
+ });
859
1173
  if (captureRoot.querySelector(
860
1174
  "canvas, iframe, object, embed, animate, animateMotion, animateTransform, set"
861
1175
  )) {
@@ -886,13 +1200,16 @@ function useFrameCapture() {
886
1200
  const captureImageDataUrlsRef = useRef(/* @__PURE__ */ new Map());
887
1201
  const captureSvgRasterCacheRef = useRef(createCaptureSvgRasterCache());
888
1202
  const primedCaptureVideosRef = useRef(/* @__PURE__ */ new WeakSet());
1203
+ const videoFramesRef = useRef(null);
889
1204
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
890
1205
  const captionLayerCanvasRef = useRef(null);
891
1206
  const captionLayerKeyRef = useRef(null);
892
1207
  const captionLayerHasContentRef = useRef(false);
893
1208
  const init = useCallback(
894
1209
  async (doc, renderOptions, captionMode) => {
895
- if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current) {
1210
+ if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current || captureBaseCanvasRef.current || videoFramesRef.current) {
1211
+ const oldVideoFrames = videoFramesRef.current;
1212
+ videoFramesRef.current = null;
896
1213
  const oldRoot = rootRef.current;
897
1214
  const oldContainer = containerRef.current;
898
1215
  const oldMediaProvider = mediaProviderRef.current;
@@ -922,6 +1239,7 @@ function useFrameCapture() {
922
1239
  setTimeout(() => {
923
1240
  if (oldRoot) oldRoot.unmount();
924
1241
  if (oldContainer) oldContainer.remove();
1242
+ oldVideoFrames?.dispose();
925
1243
  if (oldOwnsMediaProvider) oldMediaProvider?.dispose();
926
1244
  if (oldCaptureCanvas) {
927
1245
  oldCaptureCanvas.width = 0;
@@ -979,6 +1297,8 @@ function useFrameCapture() {
979
1297
  ownsMediaProviderRef.current = !!renderOptions.images;
980
1298
  const root = createRoot(renderRoot);
981
1299
  rootRef.current = root;
1300
+ const videoFrames = new CaptureVideoFrameRegistry();
1301
+ videoFramesRef.current = videoFrames;
982
1302
  const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
983
1303
  const captionStyle = captionMode === "social" ? "social" : "standard";
984
1304
  let resolveRenderAPI;
@@ -1013,6 +1333,7 @@ function useFrameCapture() {
1013
1333
  coverSlideTemplate: renderOptions.coverSlideTemplate,
1014
1334
  captionsEnabled,
1015
1335
  captionStyle,
1336
+ renderVideoFrameSelector: videoFrames.selectFrame,
1016
1337
  onRenderAPIReady: (api) => {
1017
1338
  if (containerRef.current !== container) return;
1018
1339
  renderAPIRef.current = api;
@@ -1032,7 +1353,7 @@ function useFrameCapture() {
1032
1353
  root.render(playerElement);
1033
1354
  }
1034
1355
  return new Promise((resolve, reject) => {
1035
- const timeout = setTimeout(() => {
1356
+ const apiTimeout = setTimeout(() => {
1036
1357
  const api = renderAPIRef.current;
1037
1358
  const hasSeek = typeof api?.seekTo === "function";
1038
1359
  const hasDur = typeof api?.getDuration === "function";
@@ -1040,23 +1361,36 @@ function useFrameCapture() {
1040
1361
  const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
1041
1362
  reject(
1042
1363
  new Error(
1043
- `Render API did not initialize within 15s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
1364
+ `Render API did not initialize within ${RENDER_API_TIMEOUT_MS / 1e3}s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
1044
1365
  )
1045
1366
  );
1046
- }, 15e3);
1367
+ }, RENDER_API_TIMEOUT_MS);
1047
1368
  void renderAPIReady.then(async (api) => {
1369
+ clearTimeout(apiTimeout);
1370
+ let stage = "resolving media URLs";
1371
+ const setupTimeout = setTimeout(() => {
1372
+ reject(
1373
+ new Error(
1374
+ `Frame capture setup did not finish within ${CAPTURE_SETUP_TIMEOUT_MS / 1e3}s while ${stage}.`
1375
+ )
1376
+ );
1377
+ }, CAPTURE_SETUP_TIMEOUT_MS);
1048
1378
  try {
1049
1379
  const captureRoot = container.querySelector("#squisq-capture-root");
1050
1380
  if (!(captureRoot instanceof HTMLElement)) {
1051
1381
  throw new Error("Capture root element not found after player initialization.");
1052
1382
  }
1053
- await waitForCaptureMediaResolutions(mediaResolutionTrackerRef.current);
1383
+ await waitForCaptureMediaResolutions(mediaResolutionTrackerRef.current, captureRoot);
1384
+ stage = "loading fonts and images";
1054
1385
  await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
1386
+ stage = "opening video decoders";
1387
+ await videoFrames.attach(captureRoot);
1388
+ stage = "indexing video sources";
1055
1389
  await primeIndeterminateCaptureVideos(captureRoot, primedCaptureVideosRef.current);
1056
- clearTimeout(timeout);
1390
+ clearTimeout(setupTimeout);
1057
1391
  resolve(api.getDuration());
1058
1392
  } catch (assetError) {
1059
- clearTimeout(timeout);
1393
+ clearTimeout(setupTimeout);
1060
1394
  reject(assetError);
1061
1395
  }
1062
1396
  });
@@ -1085,16 +1419,18 @@ function useFrameCapture() {
1085
1419
  if (!root) {
1086
1420
  throw new Error("Capture root element not found");
1087
1421
  }
1088
- await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current);
1422
+ const videoFrames = videoFramesRef.current;
1423
+ const prepareCaptureVideos = async () => (await videoFrames?.attach(root) ?? 0) + await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current);
1424
+ await prepareCaptureVideos();
1089
1425
  try {
1090
1426
  await api.seekTo(time);
1091
1427
  } catch (seekError) {
1092
- if (!await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
1428
+ if (!await prepareCaptureVideos()) {
1093
1429
  throw seekError;
1094
1430
  }
1095
1431
  await api.seekTo(time);
1096
1432
  }
1097
- if (await primeIndeterminateCaptureVideos(root, primedCaptureVideosRef.current)) {
1433
+ if (await prepareCaptureVideos()) {
1098
1434
  await api.seekTo(time);
1099
1435
  }
1100
1436
  const renderedTime = api.getRenderedTime();
@@ -1103,7 +1439,7 @@ function useFrameCapture() {
1103
1439
  `Player committed ${renderedTime.toFixed(6)}s while capture requested ${time.toFixed(6)}s.`
1104
1440
  );
1105
1441
  }
1106
- await waitForCaptureMediaResolutions(mediaResolutionTrackerRef.current);
1442
+ await waitForCaptureMediaResolutions(mediaResolutionTrackerRef.current, root);
1107
1443
  await waitForCaptureAssets(root, decodedImagesRef.current);
1108
1444
  const compositePlan = resolveScheduledVideoCompositePlan(root, {
1109
1445
  includeFullFrameOverlays: true
@@ -1280,6 +1616,8 @@ function useFrameCapture() {
1280
1616
  releaseCaptureSvgRasterCache(captureSvgRasterCacheRef.current);
1281
1617
  captureSvgRasterCacheRef.current = createCaptureSvgRasterCache();
1282
1618
  primedCaptureVideosRef.current = /* @__PURE__ */ new WeakSet();
1619
+ videoFramesRef.current?.dispose();
1620
+ videoFramesRef.current = null;
1283
1621
  renderAPIRef.current = null;
1284
1622
  }, []);
1285
1623
  return useMemo(