@bendyline/squisq-video-react 2.2.1 → 2.2.3

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.
package/README.md CHANGED
@@ -33,6 +33,10 @@ To open directly in the compact GIF preset:
33
33
  <VideoExportButton doc={myDoc} defaultConfig={{ outputFormat: 'gif' }} />
34
34
  ```
35
35
 
36
+ Animated GIF export defaults to standard captions when the document has a
37
+ caption track. Pass `captionMode: 'off'` in `defaultConfig` to disable them, or
38
+ choose None in the export modal. MP4 continues to default to captions off.
39
+
36
40
  **v1.5:** `playerScript` is now **optional** — the browser export captures frames
37
41
  from a live in-page `DocPlayer`, so the standalone bundle is only needed for
38
42
  CLI/Playwright-style pipelines. A new `defaultConfig?: Partial<VideoExportConfig>`
@@ -69,10 +73,10 @@ function App() {
69
73
 
70
74
  ## Components
71
75
 
72
- | Component | Description |
73
- | ------------------- | ------------------------------------------------------------------------ |
74
- | `VideoExportModal` | Full modal UI — configure MP4/GIF, motion, quality, fps, and orientation |
75
- | `VideoExportButton` | Drop-in button that opens the export modal via portal |
76
+ | Component | Description |
77
+ | ------------------- | ---------------------------------------------------------------------------------- |
78
+ | `VideoExportModal` | Full modal UI — configure MP4/GIF, captions, motion, quality, fps, and orientation |
79
+ | `VideoExportButton` | Drop-in button that opens the export modal via portal |
76
80
 
77
81
  ## Hooks
78
82
 
@@ -10,7 +10,7 @@ import {
10
10
  supportsWebCodecs,
11
11
  supportsWebCodecsAac,
12
12
  supportsWebCodecsH264
13
- } from "./chunk-YZN7D4IC.js";
13
+ } from "./chunk-ZQJBUX75.js";
14
14
 
15
15
  // src/hooks/useFrameCapture.ts
16
16
  import { createElement } from "react";
@@ -28,6 +28,33 @@ var MIME_MAP = {
28
28
  bmp: "image/bmp",
29
29
  avif: "image/avif"
30
30
  };
31
+ var VISUAL_UPDATE_FALLBACK_MS = 100;
32
+ var POTENTIALLY_ANIMATED_IMAGE_URL = /(?:^data:image\/(?:gif|webp|avif)[;,]|\.(?:gif|webp|avif)(?:[?#]|$))/i;
33
+ function waitForVisualUpdate(frameCount = 1) {
34
+ return new Promise((resolve) => {
35
+ let settled = false;
36
+ let animationFrame = null;
37
+ const finish = () => {
38
+ if (settled) return;
39
+ settled = true;
40
+ window.clearTimeout(fallback);
41
+ if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
42
+ resolve();
43
+ };
44
+ const fallback = window.setTimeout(
45
+ finish,
46
+ document.visibilityState === "visible" ? VISUAL_UPDATE_FALLBACK_MS : 0
47
+ );
48
+ if (document.visibilityState !== "visible") return;
49
+ const waitForFrame = (remaining) => {
50
+ animationFrame = window.requestAnimationFrame(() => {
51
+ if (remaining <= 1) finish();
52
+ else waitForFrame(remaining - 1);
53
+ });
54
+ };
55
+ waitForFrame(Math.max(1, frameCount));
56
+ });
57
+ }
31
58
  function createInlineProvider(images) {
32
59
  const blobUrls = /* @__PURE__ */ new Map();
33
60
  const mimeTypes = /* @__PURE__ */ new Map();
@@ -60,27 +87,96 @@ function createInlineProvider(images) {
60
87
  }
61
88
  };
62
89
  }
90
+ function shouldIgnoreCaptureSibling(element, captureRoot) {
91
+ const { head } = captureRoot.ownerDocument;
92
+ const isInDocumentHead = element === head || head.contains(element);
93
+ const isInCaptureBranch = element === captureRoot || element.contains(captureRoot) || captureRoot.contains(element);
94
+ return !isInDocumentHead && !isInCaptureBranch;
95
+ }
96
+ function finiteMediaTime(value) {
97
+ return Number.isFinite(value) ? value.toFixed(6) : "unknown";
98
+ }
99
+ function getFrameVisualStateKey(captureRoot, timelineTime) {
100
+ const markup = captureRoot.innerHTML;
101
+ let needsTimelineKey = false;
102
+ const animationStates = [];
103
+ if (typeof captureRoot.getAnimations === "function") {
104
+ const animations = captureRoot.getAnimations({ subtree: true });
105
+ animations.forEach((animation, index) => {
106
+ try {
107
+ const timing = animation.effect?.getComputedTiming();
108
+ if (!timing) {
109
+ needsTimelineKey = true;
110
+ return;
111
+ }
112
+ animationStates.push(
113
+ `${index}:${animation.playState}:${String(timing.progress)}:${String(
114
+ timing.currentIteration
115
+ )}`
116
+ );
117
+ } catch {
118
+ needsTimelineKey = true;
119
+ }
120
+ });
121
+ } else if (/\b(?:anim-|transition-)|animation(?:-name)?\s*:/i.test(markup)) {
122
+ needsTimelineKey = true;
123
+ }
124
+ const imageStates = Array.from(captureRoot.querySelectorAll("img")).map((image) => {
125
+ const src = image.currentSrc || image.src;
126
+ if (POTENTIALLY_ANIMATED_IMAGE_URL.test(src)) needsTimelineKey = true;
127
+ return `${src}:${image.complete}:${image.naturalWidth}x${image.naturalHeight}`;
128
+ });
129
+ if (POTENTIALLY_ANIMATED_IMAGE_URL.test(markup)) needsTimelineKey = true;
130
+ const videoStates = Array.from(captureRoot.querySelectorAll("video")).map(
131
+ (video) => `${video.currentSrc || video.src}:${finiteMediaTime(video.currentTime)}:${video.readyState}:${video.videoWidth}x${video.videoHeight}`
132
+ );
133
+ if (captureRoot.querySelector(
134
+ "canvas, iframe, object, embed, animate, animateMotion, animateTransform, set"
135
+ )) {
136
+ needsTimelineKey = true;
137
+ }
138
+ const fontStatus = captureRoot.ownerDocument.fonts?.status ?? "unsupported";
139
+ return JSON.stringify({
140
+ markup,
141
+ animationStates,
142
+ imageStates,
143
+ videoStates,
144
+ fontStatus,
145
+ timelineTime: needsTimelineKey ? timelineTime.toFixed(6) : null
146
+ });
147
+ }
63
148
  function useFrameCapture() {
64
149
  const containerRef = useRef(null);
65
150
  const rootRef = useRef(null);
66
151
  const renderAPIRef = useRef(null);
67
152
  const mediaProviderRef = useRef(null);
153
+ const captureCanvasRef = useRef(null);
154
+ const lastVisualStateKeyRef = useRef(null);
155
+ const hasCapturedFrameRef = useRef(false);
68
156
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
69
157
  const init = useCallback(
70
158
  async (doc, renderOptions, captionMode) => {
71
- if (rootRef.current || containerRef.current || mediaProviderRef.current) {
159
+ if (rootRef.current || containerRef.current || mediaProviderRef.current || captureCanvasRef.current) {
72
160
  const oldRoot = rootRef.current;
73
161
  const oldContainer = containerRef.current;
74
162
  const oldMediaProvider = mediaProviderRef.current;
163
+ const oldCaptureCanvas = captureCanvasRef.current;
75
164
  rootRef.current = null;
76
165
  containerRef.current = null;
77
166
  renderAPIRef.current = null;
78
167
  mediaProviderRef.current = null;
168
+ captureCanvasRef.current = null;
169
+ lastVisualStateKeyRef.current = null;
170
+ hasCapturedFrameRef.current = false;
79
171
  await new Promise((resolve) => {
80
172
  setTimeout(() => {
81
173
  if (oldRoot) oldRoot.unmount();
82
174
  if (oldContainer) oldContainer.remove();
83
175
  oldMediaProvider?.dispose();
176
+ if (oldCaptureCanvas) {
177
+ oldCaptureCanvas.width = 0;
178
+ oldCaptureCanvas.height = 0;
179
+ }
84
180
  resolve();
85
181
  }, 0);
86
182
  });
@@ -89,6 +185,14 @@ function useFrameCapture() {
89
185
  const height = renderOptions.height ?? 1080;
90
186
  const animationsEnabled = renderOptions.animationsEnabled ?? true;
91
187
  dimensionsRef.current = { width, height };
188
+ const captureCanvas = document.createElement("canvas");
189
+ captureCanvas.width = width;
190
+ captureCanvas.height = height;
191
+ captureCanvas.style.width = `${width}px`;
192
+ captureCanvas.style.height = `${height}px`;
193
+ captureCanvasRef.current = captureCanvas;
194
+ lastVisualStateKeyRef.current = null;
195
+ hasCapturedFrameRef.current = false;
92
196
  const container = document.createElement("div");
93
197
  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;`;
94
198
  document.body.appendChild(container);
@@ -150,33 +254,56 @@ function useFrameCapture() {
150
254
  },
151
255
  []
152
256
  );
153
- const captureFrame = useCallback(async (time) => {
154
- const container = containerRef.current;
155
- const api = renderAPIRef.current;
156
- if (!container || !api) {
157
- throw new Error("Frame capture not initialized \u2014 call init() first");
158
- }
159
- const { width, height } = dimensionsRef.current;
160
- await api.seekTo(time);
161
- await new Promise(
162
- (resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
163
- );
164
- const root = container.querySelector("#squisq-capture-root");
165
- if (!root) {
166
- throw new Error("Capture root element not found");
167
- }
168
- const canvas = await html2canvas(root, {
169
- width,
170
- height,
171
- scale: 1,
172
- useCORS: true,
173
- allowTaint: true,
174
- backgroundColor: "#000000",
175
- logging: false
176
- });
177
- const bitmap = await createImageBitmap(canvas);
178
- return bitmap;
179
- }, []);
257
+ const captureCanvasFrame = useCallback(
258
+ async (time, options = {}) => {
259
+ const container = containerRef.current;
260
+ const api = renderAPIRef.current;
261
+ const captureCanvas = captureCanvasRef.current;
262
+ if (!container || !api || !captureCanvas) {
263
+ throw new Error("Frame capture not initialized \u2014 call init() first");
264
+ }
265
+ const { width, height } = dimensionsRef.current;
266
+ await api.seekTo(time);
267
+ await waitForVisualUpdate(2);
268
+ const root = container.querySelector("#squisq-capture-root");
269
+ if (!root) {
270
+ throw new Error("Capture root element not found");
271
+ }
272
+ const visualStateKey = options.reuseIfUnchanged ? getFrameVisualStateKey(root, time) : null;
273
+ if (visualStateKey !== null && hasCapturedFrameRef.current && lastVisualStateKeyRef.current === visualStateKey) {
274
+ return captureCanvas;
275
+ }
276
+ const captureContext = captureCanvas.getContext("2d");
277
+ if (!captureContext) throw new Error("Could not create the frame capture canvas context");
278
+ captureContext.setTransform(1, 0, 0, 1, 0, 0);
279
+ captureContext.clearRect(0, 0, width, height);
280
+ const canvas = await html2canvas(root, {
281
+ canvas: captureCanvas,
282
+ width,
283
+ height,
284
+ scale: 1,
285
+ useCORS: true,
286
+ allowTaint: true,
287
+ backgroundColor: "#000000",
288
+ logging: false,
289
+ // html2canvas starts cloning at documentElement. Do not clone the rest
290
+ // of the editor/site UI on every frame; only the capture root, its
291
+ // ancestors, descendants, and document styles can affect this render.
292
+ ignoreElements: (element) => shouldIgnoreCaptureSibling(element, root)
293
+ });
294
+ hasCapturedFrameRef.current = true;
295
+ lastVisualStateKeyRef.current = visualStateKey;
296
+ return canvas;
297
+ },
298
+ []
299
+ );
300
+ const captureFrame = useCallback(
301
+ async (time, options = {}) => {
302
+ const canvas = await captureCanvasFrame(time, options);
303
+ return createImageBitmap(canvas);
304
+ },
305
+ [captureCanvasFrame]
306
+ );
180
307
  const destroy = useCallback(() => {
181
308
  if (rootRef.current) {
182
309
  rootRef.current.unmount();
@@ -188,9 +315,19 @@ function useFrameCapture() {
188
315
  }
189
316
  mediaProviderRef.current?.dispose();
190
317
  mediaProviderRef.current = null;
318
+ if (captureCanvasRef.current) {
319
+ captureCanvasRef.current.width = 0;
320
+ captureCanvasRef.current.height = 0;
321
+ captureCanvasRef.current = null;
322
+ }
323
+ lastVisualStateKeyRef.current = null;
324
+ hasCapturedFrameRef.current = false;
191
325
  renderAPIRef.current = null;
192
326
  }, []);
193
- return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
327
+ return useMemo(
328
+ () => ({ init, captureFrame, captureCanvasFrame, destroy }),
329
+ [init, captureFrame, captureCanvasFrame, destroy]
330
+ );
194
331
  }
195
332
 
196
333
  // src/hooks/useVideoExport.ts
@@ -290,7 +427,11 @@ function createWorkerEncoder(config) {
290
427
  });
291
428
  return {
292
429
  ready,
293
- encodeFrame(bitmap, frameIndex) {
430
+ encodeFrame(frame, frameIndex) {
431
+ if (typeof HTMLCanvasElement !== "undefined" && frame instanceof HTMLCanvasElement) {
432
+ return Promise.reject(new Error("Worker encoding requires a transferable ImageBitmap"));
433
+ }
434
+ const bitmap = frame;
294
435
  if (state !== "open" || fatalError) {
295
436
  bitmap.close();
296
437
  return Promise.reject(fatalError ?? new Error("Encoder is not accepting frames"));
@@ -345,11 +486,37 @@ function createWorkerEncoder(config) {
345
486
 
346
487
  // src/gifTranscode.ts
347
488
  import {
348
- ffmpegGifOutputArgs,
489
+ ffmpegGifPaletteApplicationArgs,
490
+ ffmpegGifPaletteGenerationFilter,
349
491
  resolveFfmpegWasmLoad
350
492
  } from "@bendyline/squisq-video";
493
+ function buildGifPaletteFfmpegArgs(options) {
494
+ return [
495
+ "-y",
496
+ "-i",
497
+ "video.mp4",
498
+ "-vf",
499
+ ffmpegGifPaletteGenerationFilter(options),
500
+ "-frames:v",
501
+ "1",
502
+ "palette.png"
503
+ ];
504
+ }
351
505
  function buildGifFfmpegArgs(options) {
352
- return ["-y", "-i", "video.mp4", ...ffmpegGifOutputArgs(options), "out.gif"];
506
+ return [
507
+ "-y",
508
+ "-i",
509
+ "video.mp4",
510
+ "-i",
511
+ "palette.png",
512
+ ...ffmpegGifPaletteApplicationArgs(options),
513
+ "out.gif"
514
+ ];
515
+ }
516
+ var FFMPEG_ERRORISH = /error|invalid|failed|out of memory|memory access|abort|unable to/i;
517
+ function ffmpegFailureDetail(logs) {
518
+ const lines = logs.map((line) => line.trim()).filter(Boolean);
519
+ return lines.find((line) => FFMPEG_ERRORISH.test(line)) ?? lines.at(-1) ?? null;
353
520
  }
354
521
  async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, signal) {
355
522
  if (videoMp4.byteLength === 0) {
@@ -361,9 +528,16 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
361
528
  const load = resolveFfmpegWasmLoad(loadConfig, "Animated GIF export", {
362
529
  classWorkerURL: new URL("./workers/ffmpeg.class-worker.js", import.meta.url).href
363
530
  });
364
- const args = buildGifFfmpegArgs(options);
531
+ const paletteArgs = buildGifPaletteFfmpegArgs(options);
532
+ const gifArgs = buildGifFfmpegArgs(options);
365
533
  const { FFmpeg } = await import("@ffmpeg/ffmpeg");
366
534
  const ffmpeg = new FFmpeg();
535
+ const recentLogs = [];
536
+ const handleLog = ({ message }) => {
537
+ recentLogs.push(message);
538
+ if (recentLogs.length > 40) recentLogs.shift();
539
+ };
540
+ ffmpeg.on("log", handleLog);
367
541
  let terminated = false;
368
542
  const terminate = () => {
369
543
  if (terminated) return;
@@ -381,17 +555,38 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
381
555
  if (signal?.aborted) {
382
556
  throw new DOMException("Animated GIF export was cancelled.", "AbortError");
383
557
  }
384
- const exitCode = await ffmpeg.exec(args);
385
- if (signal?.aborted) {
386
- throw new DOMException("Animated GIF export was cancelled.", "AbortError");
387
- }
388
- if (exitCode !== 0) {
389
- throw new Error(`ffmpeg.wasm GIF transcode failed with exit code ${exitCode}`);
390
- }
558
+ const execPhase = async (args, phase) => {
559
+ recentLogs.length = 0;
560
+ let exitCode;
561
+ try {
562
+ exitCode = await ffmpeg.exec(args);
563
+ } catch (caught) {
564
+ if (signal?.aborted) {
565
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
566
+ }
567
+ const detail = ffmpegFailureDetail(recentLogs);
568
+ const fallback = caught instanceof Error ? caught.message : String(caught);
569
+ throw new Error(`ffmpeg.wasm GIF transcode failed during ${phase}: ${detail ?? fallback}`);
570
+ }
571
+ if (signal?.aborted) {
572
+ throw new DOMException("Animated GIF export was cancelled.", "AbortError");
573
+ }
574
+ if (exitCode !== 0) {
575
+ const detail = ffmpegFailureDetail(recentLogs);
576
+ throw new Error(
577
+ `ffmpeg.wasm GIF transcode failed during ${phase} with exit code ${exitCode}` + (detail ? `: ${detail}` : "")
578
+ );
579
+ }
580
+ };
581
+ await execPhase(paletteArgs, "palette generation");
582
+ await execPhase(gifArgs, "palette application");
583
+ await ffmpeg.deleteFile("video.mp4").catch(() => false);
584
+ await ffmpeg.deleteFile("palette.png").catch(() => false);
391
585
  const data = await ffmpeg.readFile("out.gif");
392
586
  return data instanceof Uint8Array ? data : new TextEncoder().encode(data);
393
587
  } finally {
394
588
  signal?.removeEventListener("abort", handleAbort);
589
+ ffmpeg.off("log", handleLog);
395
590
  terminate();
396
591
  }
397
592
  }
@@ -400,6 +595,41 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
400
595
  var MAX_EXPORT_MEDIA_FILES = 256;
401
596
  var MAX_EXPORT_MEDIA_FILE_BYTES = 64 * 1024 * 1024;
402
597
  var MAX_EXPORT_MEDIA_TOTAL_BYTES = 256 * 1024 * 1024;
598
+ var ENCODER_PROBE_TIMEOUT_MS = 5e3;
599
+ var ENCODER_START_TIMEOUT_MS = 6e4;
600
+ var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
601
+ var FRAME_ENCODE_TIMEOUT_MS = 6e4;
602
+ var CAPTURE_PROGRESS_START = 7;
603
+ var CAPTURE_PROGRESS_END = 95;
604
+ function releaseEncoderFrame(frame) {
605
+ if ("close" in frame) frame.close();
606
+ }
607
+ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
608
+ return new Promise((resolve, reject) => {
609
+ let settled = false;
610
+ const timeout = globalThis.setTimeout(() => {
611
+ settled = true;
612
+ reject(new Error(timeoutMessage));
613
+ }, timeoutMs);
614
+ void operation.then(
615
+ (value) => {
616
+ if (settled) {
617
+ onLateResult?.(value);
618
+ return;
619
+ }
620
+ settled = true;
621
+ globalThis.clearTimeout(timeout);
622
+ resolve(value);
623
+ },
624
+ (caught) => {
625
+ if (settled) return;
626
+ settled = true;
627
+ globalThis.clearTimeout(timeout);
628
+ reject(caught);
629
+ }
630
+ );
631
+ });
632
+ }
403
633
  function toArrayBuffer(bytes) {
404
634
  return bytes.slice().buffer;
405
635
  }
@@ -535,6 +765,7 @@ function useVideoExport() {
535
765
  const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
536
766
  const orientation = config.orientation ?? "landscape";
537
767
  const animationsEnabled = config.animationsEnabled ?? effectiveOutputFormat === "mp4";
768
+ const captionMode = config.captionMode ?? (effectiveOutputFormat === "gif" ? "standard" : "off");
538
769
  const audioPolicy = config.audioPolicy ?? "require";
539
770
  setOutputFormat(effectiveOutputFormat);
540
771
  try {
@@ -612,7 +843,7 @@ function useVideoExport() {
612
843
  const docDuration = await frameCapture.init(
613
844
  doc,
614
845
  { images, audio: config.audio, width, height, animationsEnabled },
615
- config.captionMode
846
+ captionMode
616
847
  );
617
848
  if (cancelledRef.current) return;
618
849
  setDuration(docDuration);
@@ -620,9 +851,13 @@ function useVideoExport() {
620
851
  throw new Error("Document has zero duration \u2014 nothing to export");
621
852
  }
622
853
  const totalFrames = Math.ceil(docDuration * fps);
623
- setPhase("Starting encoder\u2026");
854
+ setPhase("Checking video encoder\u2026");
624
855
  setProgress(5);
625
- const canUseWebCodecs = webCodecsAvailable && await supportsWebCodecsH264({ width, height, fps, quality });
856
+ const canUseWebCodecs = webCodecsAvailable && await settleWithin(
857
+ supportsWebCodecsH264({ width, height, fps, quality }),
858
+ ENCODER_PROBE_TIMEOUT_MS,
859
+ "The browser did not finish checking WebCodecs support."
860
+ ).catch(() => false);
626
861
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
627
862
  const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, 0) : [];
628
863
  const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
@@ -691,8 +926,11 @@ function useVideoExport() {
691
926
  }
692
927
  } : {}
693
928
  });
929
+ encoderRef.current = encoder;
694
930
  setBackend("webcodecs");
695
931
  } else if (sharedArrayBufferAvailable) {
932
+ setProgress(6);
933
+ setPhase("Loading export engine\u2026");
696
934
  const workerEncoder = createWorkerEncoder({
697
935
  width,
698
936
  height,
@@ -702,41 +940,54 @@ function useVideoExport() {
702
940
  ffmpegWasm: config.ffmpegWasm
703
941
  });
704
942
  encoder = workerEncoder;
705
- const selectedBackend = await workerEncoder.ready;
706
- setBackend(selectedBackend);
707
- setPhase(
708
- selectedBackend === "ffmpeg-wasm" ? "Starting encoder (ffmpeg.wasm)\u2026" : "Starting encoder\u2026"
943
+ encoderRef.current = workerEncoder;
944
+ const selectedBackend = await settleWithin(
945
+ workerEncoder.ready,
946
+ ENCODER_START_TIMEOUT_MS,
947
+ "The browser export engine did not start within 60 seconds."
709
948
  );
949
+ setBackend(selectedBackend);
710
950
  } else {
711
951
  throw new Error(
712
952
  "WebCodecs H.264 is unavailable in this browser and the ffmpeg.wasm fallback requires SharedArrayBuffer (Cross-Origin-Isolation headers)."
713
953
  );
714
954
  }
715
- encoderRef.current = encoder;
716
955
  if (cancelledRef.current) return;
956
+ setProgress(CAPTURE_PROGRESS_START);
957
+ setPhase(`Capturing frame 1/${totalFrames} (0.0s)`);
717
958
  setState("capturing");
718
959
  const captureStartTime = performance.now();
719
- const UI_UPDATE_INTERVAL = 10;
720
960
  for (let i = 0; i < totalFrames; i++) {
721
961
  if (cancelledRef.current) return;
722
962
  const time = i / fps;
723
- if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {
724
- const captureProgress = Math.round(i / totalFrames * 90);
725
- setProgress(5 + captureProgress);
726
- setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
727
- if (i > 0) {
728
- const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
729
- const avgPerFrame = elapsedCapture / i;
730
- const remaining = Math.round(avgPerFrame * (totalFrames - i));
731
- setEstimatedRemaining(remaining);
732
- }
733
- }
734
- const bitmap = await frameCapture.captureFrame(time);
963
+ const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(time, { reuseIfUnchanged: true }) : frameCapture.captureFrame(time, { reuseIfUnchanged: true });
964
+ const frame = await settleWithin(
965
+ captureOperation,
966
+ FRAME_CAPTURE_TIMEOUT_MS,
967
+ `Frame capture stopped responding at frame ${i + 1}/${totalFrames}.`,
968
+ releaseEncoderFrame
969
+ );
735
970
  if (cancelledRef.current) {
736
- bitmap.close();
971
+ releaseEncoderFrame(frame);
737
972
  return;
738
973
  }
739
- await encoder.encodeFrame(bitmap, i);
974
+ setPhase(`Encoding frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
975
+ await settleWithin(
976
+ encoder.encodeFrame(frame, i),
977
+ FRAME_ENCODE_TIMEOUT_MS,
978
+ `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
979
+ );
980
+ const completedFrames = i + 1;
981
+ setPhase(
982
+ completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames} (${(completedFrames / fps).toFixed(1)}s)` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
983
+ );
984
+ const captureRatio = completedFrames / totalFrames;
985
+ const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
986
+ setProgress(Math.round(captureProgress * 10) / 10);
987
+ const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
988
+ const avgPerFrame = elapsedCapture / completedFrames;
989
+ setEstimatedRemaining(Math.round(avgPerFrame * (totalFrames - completedFrames)));
990
+ setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
740
991
  }
741
992
  if (cancelledRef.current) return;
742
993
  if (useInlineAudio && renderedAudio && encoder.addAudioChunk) {
@@ -1923,7 +1923,12 @@ function createMp4Muxer(options) {
1923
1923
  sampleRate: options.audio.sampleRate
1924
1924
  }
1925
1925
  } : {},
1926
- fastStart: "in-memory"
1926
+ // The result is already complete before it is downloaded or passed to the
1927
+ // GIF transcode, so browser-style fast start provides no benefit here.
1928
+ // `in-memory` retains every encoded sample until finalize; regular MP4
1929
+ // layout writes each compressed chunk into the output as it arrives and
1930
+ // releases the sample payload immediately.
1931
+ fastStart: false
1927
1932
  });
1928
1933
  return {
1929
1934
  hasAudioTrack: options.audio !== void 0,
@@ -1946,6 +1951,30 @@ function createMp4Muxer(options) {
1946
1951
  };
1947
1952
  }
1948
1953
 
1954
+ // src/encoderMemory.ts
1955
+ var MAX_WEBCODECS_QUEUE_BYTES = 64 * 1024 * 1024;
1956
+ var MAX_WEBCODECS_QUEUE_SECONDS = 1;
1957
+ var MAX_FFMPEG_FRAME_BATCH_BYTES = 64 * 1024 * 1024;
1958
+ var MAX_FFMPEG_FRAME_BATCH_SECONDS = 6;
1959
+ function resolveWebCodecsQueueLimit({ width, height, fps }) {
1960
+ const bytesPerFrame = Math.max(1, width * height * 4);
1961
+ const framesByBytes = Math.max(1, Math.floor(MAX_WEBCODECS_QUEUE_BYTES / bytesPerFrame));
1962
+ const framesByTime = Math.max(1, Math.ceil(fps * MAX_WEBCODECS_QUEUE_SECONDS));
1963
+ return Math.min(framesByBytes, framesByTime);
1964
+ }
1965
+ async function applyWebCodecsBackpressure(encoder, queueLimit, submittedSinceFlush = encoder.encodeQueueSize) {
1966
+ if (encoder.encodeQueueSize < queueLimit && submittedSinceFlush < queueLimit) return false;
1967
+ await encoder.flush();
1968
+ return true;
1969
+ }
1970
+ function shouldEncodeFfmpegBatch(frameCount, byteLength, fps) {
1971
+ const framesByTime = Math.max(1, Math.ceil(fps * MAX_FFMPEG_FRAME_BATCH_SECONDS));
1972
+ return frameCount >= framesByTime || byteLength >= MAX_FFMPEG_FRAME_BATCH_BYTES;
1973
+ }
1974
+
1949
1975
  export {
1950
- createMp4Muxer
1976
+ createMp4Muxer,
1977
+ resolveWebCodecsQueueLimit,
1978
+ applyWebCodecsBackpressure,
1979
+ shouldEncodeFfmpegBatch
1951
1980
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-VILZP3GL.js";
3
+ } from "./chunk-IDY64F4K.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
6
  import { useState, useCallback, useId, useRef } from "react";
@@ -12,12 +12,6 @@ function formatDuration(seconds) {
12
12
  const s = seconds % 60;
13
13
  return `${m}m ${s}s`;
14
14
  }
15
- function encoderLabel(outputFormat, backend) {
16
- if (outputFormat === "gif") {
17
- return backend === "webcodecs" ? "WebCodecs (H.264) \u2192 ffmpeg.wasm (GIF)" : "ffmpeg.wasm (H.264 \u2192 GIF)";
18
- }
19
- return backend === "webcodecs" ? "WebCodecs (H.264)" : "ffmpeg.wasm (H.264)";
20
- }
21
15
  var VIDEO_EXPORT_PALETTES = {
22
16
  light: {
23
17
  overlay: "rgba(0, 0, 0, 0.5)",
@@ -61,6 +55,7 @@ var overlayStyle = {
61
55
  zIndex: 1e4
62
56
  };
63
57
  var modalStyle = {
58
+ position: "relative",
64
59
  borderRadius: 0,
65
60
  padding: "24px 28px",
66
61
  minWidth: 380,
@@ -70,9 +65,24 @@ var modalStyle = {
70
65
  };
71
66
  var titleStyle = {
72
67
  margin: "0 0 16px 0",
68
+ paddingRight: 32,
73
69
  fontSize: 18,
74
70
  fontWeight: 600
75
71
  };
72
+ var closeButtonStyle = {
73
+ position: "absolute",
74
+ top: 12,
75
+ right: 12,
76
+ width: 32,
77
+ height: 32,
78
+ padding: 0,
79
+ border: 0,
80
+ background: "transparent",
81
+ fontFamily: "inherit",
82
+ fontSize: 24,
83
+ lineHeight: 1,
84
+ cursor: "pointer"
85
+ };
76
86
  var labelStyle = {
77
87
  display: "block",
78
88
  fontSize: 13,
@@ -137,7 +147,9 @@ function VideoExportModal({
137
147
  const [orientation, setOrientation] = useState(
138
148
  defaultConfig?.orientation ?? "landscape"
139
149
  );
140
- const [captionMode, setCaptionMode] = useState(defaultConfig?.captionMode ?? "off");
150
+ const [captionMode, setCaptionMode] = useState(
151
+ defaultConfig?.captionMode ?? (initialOutputFormat === "gif" ? "standard" : "off")
152
+ );
141
153
  const [animationsEnabled, setAnimationsEnabled] = useState(
142
154
  defaultConfig?.animationsEnabled ?? initialOutputFormat === "mp4"
143
155
  );
@@ -180,7 +192,7 @@ function VideoExportModal({
180
192
  const {
181
193
  state,
182
194
  progress,
183
- backend,
195
+ phase,
184
196
  outputFormat: completedOutputFormat,
185
197
  downloadUrl,
186
198
  fileSize,
@@ -198,9 +210,11 @@ function VideoExportModal({
198
210
  if (next === "gif") {
199
211
  setFps(10);
200
212
  setAnimationsEnabled(false);
213
+ setCaptionMode("standard");
201
214
  } else {
202
215
  setFps(24);
203
216
  setAnimationsEnabled(true);
217
+ setCaptionMode("off");
204
218
  }
205
219
  }, []);
206
220
  const handleExport = useCallback(async () => {
@@ -250,19 +264,25 @@ function VideoExportModal({
250
264
  const handleClose = useCallback(() => {
251
265
  if (state === "capturing" || state === "encoding" || state === "preparing") {
252
266
  cancelExport();
267
+ } else {
268
+ resetExport();
253
269
  }
254
- resetExport();
255
270
  onClose();
256
271
  }, [state, cancelExport, resetExport, onClose]);
257
272
  const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
258
- useModalDialog({ rootRef: overlayRef, dialogRef, onClose: handleClose });
273
+ useModalDialog({
274
+ rootRef: overlayRef,
275
+ dialogRef,
276
+ closeOnEscape: false,
277
+ onClose: handleClose
278
+ });
259
279
  return /* @__PURE__ */ jsx(
260
280
  "div",
261
281
  {
262
282
  ref: overlayRef,
263
283
  style: { ...overlayStyle, background: palette.overlay },
264
284
  "data-color-scheme": colorScheme,
265
- onClick: handleClose,
285
+ onClick: (event) => event.stopPropagation(),
266
286
  children: /* @__PURE__ */ jsxs(
267
287
  "div",
268
288
  {
@@ -276,6 +296,16 @@ function VideoExportModal({
276
296
  onClick: (e) => e.stopPropagation(),
277
297
  children: [
278
298
  /* @__PURE__ */ jsx("h2", { id: titleId, style: themedTitleStyle, children: outputFormat === "gif" ? "Export Animated GIF" : "Export Video" }),
299
+ /* @__PURE__ */ jsx(
300
+ "button",
301
+ {
302
+ type: "button",
303
+ "aria-label": "Close export dialog",
304
+ style: { ...closeButtonStyle, color: palette.label },
305
+ onClick: handleClose,
306
+ children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" })
307
+ }
308
+ ),
279
309
  state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
280
310
  /* @__PURE__ */ jsxs("div", { children: [
281
311
  /* @__PURE__ */ jsx("label", { style: themedLabelStyle, children: "Format" }),
@@ -409,10 +439,6 @@ function VideoExportModal({
409
439
  ] })
410
440
  ] }),
411
441
  isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
412
- backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 8px 0" }, children: [
413
- "Encoder: ",
414
- encoderLabel(completedOutputFormat, backend)
415
- ] }),
416
442
  /* @__PURE__ */ jsx(
417
443
  "div",
418
444
  {
@@ -441,12 +467,20 @@ function VideoExportModal({
441
467
  progress,
442
468
  "% complete"
443
469
  ] }),
470
+ phase && /* @__PURE__ */ jsx(
471
+ "p",
472
+ {
473
+ style: { fontSize: 12, color: palette.label, margin: "0 0 4px 0" },
474
+ "data-squisq-video-export-phase": true,
475
+ children: phase
476
+ }
477
+ ),
444
478
  /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: 0 }, children: [
445
479
  formatDuration(elapsed),
446
480
  " elapsed",
447
481
  estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
448
482
  ] }),
449
- /* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: cancelExport, children: "Cancel" }) })
483
+ /* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Cancel" }) })
450
484
  ] }),
451
485
  state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
452
486
  /* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: palette.success }, children: "Export complete!" }),
@@ -459,10 +493,6 @@ function VideoExportModal({
459
493
  "Video only",
460
494
  audioSkippedReason ? ` \u2014 ${audioSkippedReason}` : ""
461
495
  ] }),
462
- backend && /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: "0 0 12px 0" }, children: [
463
- "Encoded with ",
464
- encoderLabel(completedOutputFormat, backend)
465
- ] }),
466
496
  /* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
467
497
  /* @__PURE__ */ jsx("button", { style: themedSecondaryButtonStyle, onClick: handleClose, children: "Close" }),
468
498
  /* @__PURE__ */ jsxs("button", { style: themedPrimaryButtonStyle, onClick: handleDownload, children: [
@@ -1,6 +1,8 @@
1
1
  import {
2
- createMp4Muxer
3
- } from "./chunk-VI5AIVOQ.js";
2
+ applyWebCodecsBackpressure,
3
+ createMp4Muxer,
4
+ resolveWebCodecsQueueLimit
5
+ } from "./chunk-MEPETH5V.js";
4
6
 
5
7
  // src/mainThreadEncoder.ts
6
8
  import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
@@ -38,6 +40,8 @@ function createEncoder(config) {
38
40
  let closed = false;
39
41
  let fatalError = null;
40
42
  const frameDuration = 1e6 / config.fps;
43
+ const queueLimit = resolveWebCodecsQueueLimit(config);
44
+ let framesSinceFlush = 0;
41
45
  function fail(err) {
42
46
  closed = true;
43
47
  if (encoder.state !== "closed") encoder.close();
@@ -64,21 +68,34 @@ function createEncoder(config) {
64
68
  framerate: config.fps
65
69
  });
66
70
  return {
67
- async encodeFrame(bitmap, frameIndex) {
71
+ async encodeFrame(source, frameIndex) {
68
72
  if (fatalError) {
69
- bitmap.close();
73
+ if ("close" in source) source.close();
70
74
  throw fail(fatalError);
71
75
  }
72
76
  if (closed) {
73
- bitmap.close();
77
+ if ("close" in source) source.close();
74
78
  throw new Error("Encoder already closed");
75
79
  }
76
- const timestamp = Math.round(frameIndex * frameDuration);
77
- const frame = new VideoFrame(bitmap, { timestamp });
78
- const keyFrame = frameIndex % 30 === 0;
79
- encoder.encode(frame, { keyFrame });
80
- frame.close();
81
- bitmap.close();
80
+ try {
81
+ const drained = await applyWebCodecsBackpressure(encoder, queueLimit, framesSinceFlush);
82
+ if (drained) framesSinceFlush = 0;
83
+ if (fatalError) throw fail(fatalError);
84
+ if (closed) throw new Error("Encoder already closed");
85
+ const timestamp = Math.round(frameIndex * frameDuration);
86
+ const frame = new VideoFrame(source, { timestamp });
87
+ try {
88
+ const keyFrame = frameIndex % 30 === 0;
89
+ encoder.encode(frame, { keyFrame });
90
+ framesSinceFlush++;
91
+ } finally {
92
+ frame.close();
93
+ }
94
+ } catch (err) {
95
+ throw fail(err instanceof Error ? err : new Error(String(err)));
96
+ } finally {
97
+ if ("close" in source) source.close();
98
+ }
82
99
  },
83
100
  addAudioChunk(chunk, meta) {
84
101
  if (closed || !muxer.hasAudioTrack) return;
@@ -1,6 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { Doc, MediaProvider } from '@bendyline/squisq/schemas';
3
- import { a as VideoExportConfig } from '../useVideoExport-NtqZVgaz.js';
3
+ import { a as VideoExportConfig } from '../useVideoExport-DAEglCva.js';
4
4
  import '@bendyline/squisq/markdown';
5
5
  import '@bendyline/squisq-video';
6
6
  import '@bendyline/squisq-react';
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "../chunk-KQG6DZ7G.js";
5
- import "../chunk-VILZP3GL.js";
6
- import "../chunk-YZN7D4IC.js";
7
- import "../chunk-VI5AIVOQ.js";
4
+ } from "../chunk-ULNKIV4A.js";
5
+ import "../chunk-IDY64F4K.js";
6
+ import "../chunk-ZQJBUX75.js";
7
+ import "../chunk-MEPETH5V.js";
8
8
  export {
9
9
  VideoExportButton,
10
10
  VideoExportModal
@@ -34,9 +34,10 @@ interface EncoderConfig {
34
34
  sampleRate: number;
35
35
  };
36
36
  }
37
+ type EncoderFrameSource = ImageBitmap | HTMLCanvasElement;
37
38
  interface MainThreadEncoder {
38
- /** Encode a single frame. The bitmap is closed after encoding. */
39
- encodeFrame(bitmap: ImageBitmap, frameIndex: number): Promise<void>;
39
+ /** Encode a single frame. ImageBitmap sources are closed after encoding. */
40
+ encodeFrame(frame: EncoderFrameSource, frameIndex: number): Promise<void>;
40
41
  /**
41
42
  * Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
42
43
  * Only valid when the encoder was created with an `audio` config; otherwise a
@@ -92,4 +93,4 @@ declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
92
93
  */
93
94
  declare function supportsWebCodecsAac(sampleRate?: number, channels?: number): Promise<boolean>;
94
95
 
95
- export { type EncoderConfig, type MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 };
96
+ export { type EncoderConfig, type EncoderFrameSource, type MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 };
@@ -3,8 +3,8 @@ import {
3
3
  supportsWebCodecs,
4
4
  supportsWebCodecsAac,
5
5
  supportsWebCodecsH264
6
- } from "../chunk-YZN7D4IC.js";
7
- import "../chunk-VI5AIVOQ.js";
6
+ } from "../chunk-ZQJBUX75.js";
7
+ import "../chunk-MEPETH5V.js";
8
8
  export {
9
9
  createEncoder,
10
10
  supportsWebCodecs,
@@ -1,4 +1,4 @@
1
- export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from '../useVideoExport-NtqZVgaz.js';
1
+ export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from '../useVideoExport-DAEglCva.js';
2
2
  import { Doc } from '@bendyline/squisq/schemas';
3
3
  import { RenderHtmlOptions } from '@bendyline/squisq-video';
4
4
  import { CaptionMode } from '@bendyline/squisq-react';
@@ -13,14 +13,27 @@ import '@bendyline/squisq/markdown';
13
13
  *
14
14
  * Uses React directly — no script injection, no iframes, no eval.
15
15
  *
16
- * Returns an ImageBitmap for each frame (transferable to a Worker).
16
+ * Returns either a reusable canvas or an ImageBitmap snapshot for a Worker.
17
17
  */
18
18
 
19
+ interface FrameCaptureOptions {
20
+ /**
21
+ * Reuse the previous raster when seeking produced the same visual state.
22
+ * The timeline is still advanced so animations, captions, and media remain
23
+ * correct; only the expensive html2canvas pass is skipped.
24
+ */
25
+ reuseIfUnchanged?: boolean;
26
+ }
19
27
  interface FrameCaptureHandle {
20
28
  /** Initialize the hidden player. Returns the video duration in seconds. */
21
29
  init: (doc: Doc, renderOptions: Omit<RenderHtmlOptions, 'playerScript'>, captionMode?: CaptionMode) => Promise<number>;
22
30
  /** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
23
- captureFrame: (time: number) => Promise<ImageBitmap>;
31
+ captureFrame: (time: number, options?: FrameCaptureOptions) => Promise<ImageBitmap>;
32
+ /**
33
+ * Render into the hook's reusable canvas without allocating an ImageBitmap.
34
+ * The canvas remains valid until the next capture or destroy call.
35
+ */
36
+ captureCanvasFrame: (time: number, options?: FrameCaptureOptions) => Promise<HTMLCanvasElement>;
24
37
  /** Clean up resources. */
25
38
  destroy: () => void;
26
39
  }
@@ -29,4 +42,4 @@ interface FrameCaptureHandle {
29
42
  */
30
43
  declare function useFrameCapture(): FrameCaptureHandle;
31
44
 
32
- export { type FrameCaptureHandle, useFrameCapture };
45
+ export { type FrameCaptureHandle, type FrameCaptureOptions, useFrameCapture };
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  useFrameCapture,
3
3
  useVideoExport
4
- } from "../chunk-VILZP3GL.js";
5
- import "../chunk-YZN7D4IC.js";
6
- import "../chunk-VI5AIVOQ.js";
4
+ } from "../chunk-IDY64F4K.js";
5
+ import "../chunk-ZQJBUX75.js";
6
+ import "../chunk-MEPETH5V.js";
7
7
  export {
8
8
  useFrameCapture,
9
9
  useVideoExport
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { VideoExportButton, VideoExportButtonProps, VideoExportModal, VideoExportModalProps, VideoExportPalette } from './components/index.js';
2
- export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from './useVideoExport-NtqZVgaz.js';
3
- export { FrameCaptureHandle, useFrameCapture } from './hooks/index.js';
4
- export { EncoderConfig, MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 } from './encoder/index.js';
2
+ export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from './useVideoExport-DAEglCva.js';
3
+ export { FrameCaptureHandle, FrameCaptureOptions, useFrameCapture } from './hooks/index.js';
4
+ export { EncoderConfig, EncoderFrameSource, MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 } from './encoder/index.js';
5
5
  export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
6
6
  import 'react/jsx-runtime';
7
7
  import '@bendyline/squisq/schemas';
package/dist/index.js CHANGED
@@ -1,18 +1,18 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "./chunk-KQG6DZ7G.js";
4
+ } from "./chunk-ULNKIV4A.js";
5
5
  import {
6
6
  useFrameCapture,
7
7
  useVideoExport
8
- } from "./chunk-VILZP3GL.js";
8
+ } from "./chunk-IDY64F4K.js";
9
9
  import {
10
10
  createEncoder,
11
11
  supportsWebCodecs,
12
12
  supportsWebCodecsAac,
13
13
  supportsWebCodecsH264
14
- } from "./chunk-YZN7D4IC.js";
15
- import "./chunk-VI5AIVOQ.js";
14
+ } from "./chunk-ZQJBUX75.js";
15
+ import "./chunk-MEPETH5V.js";
16
16
  export {
17
17
  VideoExportButton,
18
18
  VideoExportModal,
@@ -60,7 +60,7 @@ interface VideoExportConfig {
60
60
  mediaProvider?: MediaProvider;
61
61
  /** Policy and byte/time limits for MediaProvider URLs. */
62
62
  resourcePolicy?: ResourcePolicy;
63
- /** Caption mode for the exported video (default: 'off') */
63
+ /** Caption mode for the exported video (default: 'off' for MP4, 'standard' for GIF). */
64
64
  captionMode?: CaptionMode;
65
65
  /** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */
66
66
  playerScript?: string;
@@ -1,6 +1,9 @@
1
1
  import {
2
- createMp4Muxer
3
- } from "../chunk-VI5AIVOQ.js";
2
+ applyWebCodecsBackpressure,
3
+ createMp4Muxer,
4
+ resolveWebCodecsQueueLimit,
5
+ shouldEncodeFfmpegBatch
6
+ } from "../chunk-MEPETH5V.js";
4
7
 
5
8
  // src/workers/encode.worker.ts
6
9
  import {
@@ -12,8 +15,11 @@ var backend = null;
12
15
  var cancelled = false;
13
16
  var videoEncoder = null;
14
17
  var muxer = null;
18
+ var webCodecsQueueLimit = 1;
19
+ var webCodecsFramesSinceFlush = 0;
15
20
  var ffmpegInstance = null;
16
21
  var ffmpegFrames = [];
22
+ var ffmpegFrameBytes = 0;
17
23
  var ffmpegConfig = null;
18
24
  var totalFramesReceived = 0;
19
25
  var totalFramesExpected = 0;
@@ -84,18 +90,34 @@ function initWebCodecs(config) {
84
90
  bitrate: bitrateForQuality(config.quality, config.width, config.height),
85
91
  framerate: config.fps
86
92
  });
93
+ webCodecsQueueLimit = resolveWebCodecsQueueLimit(config);
94
+ webCodecsFramesSinceFlush = 0;
87
95
  }
88
96
  async function encodeFrameWebCodecs(msg) {
89
97
  if (!videoEncoder || cancelled) {
90
98
  msg.bitmap.close();
91
99
  return;
92
100
  }
93
- const frame = new VideoFrame(msg.bitmap, { timestamp: msg.timestamp });
94
- const keyFrame = msg.frameIndex % 30 === 0;
95
- videoEncoder.encode(frame, { keyFrame });
96
- frame.close();
97
- msg.bitmap.close();
98
- totalFramesReceived++;
101
+ try {
102
+ const drained = await applyWebCodecsBackpressure(
103
+ videoEncoder,
104
+ webCodecsQueueLimit,
105
+ webCodecsFramesSinceFlush
106
+ );
107
+ if (drained) webCodecsFramesSinceFlush = 0;
108
+ if (cancelled || videoEncoder.state === "closed") return;
109
+ const frame = new VideoFrame(msg.bitmap, { timestamp: msg.timestamp });
110
+ try {
111
+ const keyFrame = msg.frameIndex % 30 === 0;
112
+ videoEncoder.encode(frame, { keyFrame });
113
+ webCodecsFramesSinceFlush++;
114
+ } finally {
115
+ frame.close();
116
+ }
117
+ totalFramesReceived++;
118
+ } finally {
119
+ msg.bitmap.close();
120
+ }
99
121
  postFrameProgress(50, frameLabel("Encoding"));
100
122
  }
101
123
  function frameLabel(verb) {
@@ -115,6 +137,7 @@ async function finalizeWebCodecs() {
115
137
  async function initFfmpegWasm(config) {
116
138
  ffmpegConfig = config;
117
139
  ffmpegFrames = [];
140
+ ffmpegFrameBytes = 0;
118
141
  ffmpegSegments.length = 0;
119
142
  totalFramesReceived = 0;
120
143
  const load = resolveFfmpegWasmLoad(config.ffmpegWasm, "The ffmpeg.wasm encoder fallback", {
@@ -150,11 +173,12 @@ async function encodeFrameFfmpeg(msg) {
150
173
  msg.bitmap.close();
151
174
  const blob = await canvas.convertToBlob({ type: "image/png" });
152
175
  const arrayBuffer = await blob.arrayBuffer();
153
- ffmpegFrames.push({ data: new Uint8Array(arrayBuffer), index: msg.frameIndex });
176
+ const frameData = new Uint8Array(arrayBuffer);
177
+ ffmpegFrames.push({ data: frameData, index: msg.frameIndex });
178
+ ffmpegFrameBytes += frameData.byteLength;
154
179
  totalFramesReceived++;
155
180
  postFrameProgress(80, frameLabel("Collecting"));
156
- const batchSize = (ffmpegConfig?.fps ?? 30) * 10;
157
- if (ffmpegFrames.length >= batchSize) {
181
+ if (shouldEncodeFfmpegBatch(ffmpegFrames.length, ffmpegFrameBytes, ffmpegConfig?.fps ?? 30)) {
158
182
  await encodeFfmpegBatch();
159
183
  }
160
184
  }
@@ -212,6 +236,7 @@ async function encodeFfmpegBatch() {
212
236
  }
213
237
  await ffmpeg.deleteFile(segmentName);
214
238
  ffmpegFrames = [];
239
+ ffmpegFrameBytes = 0;
215
240
  }
216
241
  async function finalizeFfmpeg() {
217
242
  if (!ffmpegInstance || cancelled) return;
@@ -305,6 +330,7 @@ async function handleMessage(msg) {
305
330
  muxer = null;
306
331
  disposeFfmpeg();
307
332
  ffmpegFrames = [];
333
+ ffmpegFrameBytes = 0;
308
334
  ffmpegSegments.length = 0;
309
335
  totalFramesExpected = 0;
310
336
  backend = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-video-react",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "React components for browser-based MP4 and animated-GIF export of Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -65,9 +65,9 @@
65
65
  "react-dom": "^18.0.0 || ^19.0.0"
66
66
  },
67
67
  "dependencies": {
68
- "@bendyline/squisq": "2.3.1",
69
- "@bendyline/squisq-video": "2.2.1",
70
- "@bendyline/squisq-react": "2.3.1",
68
+ "@bendyline/squisq": "2.3.3",
69
+ "@bendyline/squisq-video": "2.2.3",
70
+ "@bendyline/squisq-react": "2.3.3",
71
71
  "@ffmpeg/core": "0.12.9",
72
72
  "@ffmpeg/ffmpeg": "0.12.15",
73
73
  "@ffmpeg/util": "0.12.2",