@bendyline/squisq-video-react 2.2.3 → 2.2.5

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.
@@ -26,34 +26,61 @@ var MIME_MAP = {
26
26
  webp: "image/webp",
27
27
  svg: "image/svg+xml",
28
28
  bmp: "image/bmp",
29
- avif: "image/avif"
29
+ avif: "image/avif",
30
+ mp3: "audio/mpeg",
31
+ wav: "audio/wav",
32
+ ogg: "audio/ogg",
33
+ mp4: "video/mp4",
34
+ webm: "video/webm"
30
35
  };
31
- var VISUAL_UPDATE_FALLBACK_MS = 100;
36
+ var CAPTURE_ASSET_TIMEOUT_MS = 15e3;
37
+ var RENDER_TIME_EPSILON_SECONDS = 1e-6;
32
38
  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
39
+ async function waitForImageDecode(image) {
40
+ const src = image.currentSrc || image.src;
41
+ if (!src) return;
42
+ const decoded = typeof image.decode === "function" ? image.decode() : new Promise((resolve, reject) => {
43
+ if (image.complete) {
44
+ if (image.naturalWidth > 0) resolve();
45
+ else reject(new Error(`Image could not be decoded: ${src}`));
46
+ return;
47
+ }
48
+ image.addEventListener("load", () => resolve(), { once: true });
49
+ image.addEventListener(
50
+ "error",
51
+ () => reject(new Error(`Image could not be loaded: ${src}`)),
52
+ {
53
+ once: true
54
+ }
47
55
  );
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
56
  });
57
+ let timeout;
58
+ try {
59
+ await Promise.race([
60
+ decoded,
61
+ new Promise((_resolve, reject) => {
62
+ timeout = setTimeout(
63
+ () => reject(new Error(`Image did not become ready within 15s: ${src}`)),
64
+ CAPTURE_ASSET_TIMEOUT_MS
65
+ );
66
+ })
67
+ ]);
68
+ } finally {
69
+ if (timeout !== void 0) clearTimeout(timeout);
70
+ }
71
+ }
72
+ async function waitForCaptureAssets(captureRoot, decodedImages = /* @__PURE__ */ new WeakSet()) {
73
+ const fonts = captureRoot.ownerDocument.fonts;
74
+ if (fonts) await fonts.ready;
75
+ const pendingImages = Array.from(captureRoot.querySelectorAll("img")).filter(
76
+ (image) => !decodedImages.has(image)
77
+ );
78
+ await Promise.all(
79
+ pendingImages.map(async (image) => {
80
+ await waitForImageDecode(image);
81
+ decodedImages.add(image);
82
+ })
83
+ );
57
84
  }
58
85
  function createInlineProvider(images) {
59
86
  const blobUrls = /* @__PURE__ */ new Map();
@@ -96,6 +123,159 @@ function shouldIgnoreCaptureSibling(element, captureRoot) {
96
123
  function finiteMediaTime(value) {
97
124
  return Number.isFinite(value) ? value.toFixed(6) : "unknown";
98
125
  }
126
+ function coverSourceRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight) {
127
+ const sourceRatio = sourceWidth / sourceHeight;
128
+ const destinationRatio = destinationWidth / destinationHeight;
129
+ if (sourceRatio > destinationRatio) {
130
+ const sw = sourceHeight * destinationRatio;
131
+ return { sx: (sourceWidth - sw) / 2, sy: 0, sw, sh: sourceHeight };
132
+ }
133
+ const sh = sourceWidth / destinationRatio;
134
+ return { sx: 0, sy: (sourceHeight - sh) / 2, sw: sourceWidth, sh };
135
+ }
136
+ function videoFrameRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight, objectFit) {
137
+ if (objectFit === "cover") {
138
+ return {
139
+ ...coverSourceRect(sourceWidth, sourceHeight, destinationWidth, destinationHeight),
140
+ dx: 0,
141
+ dy: 0,
142
+ dw: destinationWidth,
143
+ dh: destinationHeight
144
+ };
145
+ }
146
+ if (objectFit === "contain" || objectFit === "scale-down") {
147
+ const containScale = Math.min(destinationWidth / sourceWidth, destinationHeight / sourceHeight);
148
+ const scale = objectFit === "scale-down" ? Math.min(1, containScale) : containScale;
149
+ const dw = sourceWidth * scale;
150
+ const dh = sourceHeight * scale;
151
+ return {
152
+ sx: 0,
153
+ sy: 0,
154
+ sw: sourceWidth,
155
+ sh: sourceHeight,
156
+ dx: (destinationWidth - dw) / 2,
157
+ dy: (destinationHeight - dh) / 2,
158
+ dw,
159
+ dh
160
+ };
161
+ }
162
+ if (objectFit === "none") {
163
+ return {
164
+ sx: 0,
165
+ sy: 0,
166
+ sw: sourceWidth,
167
+ sh: sourceHeight,
168
+ dx: (destinationWidth - sourceWidth) / 2,
169
+ dy: (destinationHeight - sourceHeight) / 2,
170
+ dw: sourceWidth,
171
+ dh: sourceHeight
172
+ };
173
+ }
174
+ return {
175
+ sx: 0,
176
+ sy: 0,
177
+ sw: sourceWidth,
178
+ sh: sourceHeight,
179
+ dx: 0,
180
+ dy: 0,
181
+ dw: destinationWidth,
182
+ dh: destinationHeight
183
+ };
184
+ }
185
+ function prepareScheduledVideoClones(originalRoot, clonedRoot) {
186
+ const captureFamilies = [
187
+ {
188
+ original: ".doc-player__media-clips video[data-clip-id]",
189
+ clone: ".doc-player__media-clips canvas"
190
+ },
191
+ {
192
+ original: ".block-layer--video video[data-clip-start]",
193
+ clone: ".block-layer--video canvas"
194
+ }
195
+ ];
196
+ const pairs = captureFamilies.flatMap(({ original, clone }) => {
197
+ const videos = Array.from(originalRoot.querySelectorAll(original));
198
+ const canvases = Array.from(clonedRoot.querySelectorAll(clone));
199
+ return videos.flatMap((video, index) => {
200
+ const canvas = canvases[index];
201
+ return canvas ? [{ video, canvas }] : [];
202
+ });
203
+ });
204
+ pairs.forEach(({ video, canvas }) => {
205
+ canvas.className = video.className;
206
+ canvas.style.cssText = video.style.cssText;
207
+ for (const attribute of Array.from(video.attributes)) {
208
+ if (attribute.name.startsWith("data-")) {
209
+ canvas.setAttribute(attribute.name, attribute.value);
210
+ }
211
+ }
212
+ canvas.dataset.videoCaptureClone = "true";
213
+ const destinationWidth = Math.round(video.clientWidth || video.offsetWidth);
214
+ const destinationHeight = Math.round(video.clientHeight || video.offsetHeight);
215
+ if (video.videoWidth <= 0 || video.videoHeight <= 0 || destinationWidth <= 0 || destinationHeight <= 0) {
216
+ return;
217
+ }
218
+ try {
219
+ const view = video.ownerDocument.defaultView;
220
+ const objectFit = video.style.objectFit || view?.getComputedStyle(video).objectFit || "fill";
221
+ const frame = videoFrameRect(
222
+ video.videoWidth,
223
+ video.videoHeight,
224
+ destinationWidth,
225
+ destinationHeight,
226
+ objectFit
227
+ );
228
+ const stagingCanvas = canvas.ownerDocument.createElement("canvas");
229
+ stagingCanvas.width = destinationWidth;
230
+ stagingCanvas.height = destinationHeight;
231
+ const stagingContext = stagingCanvas.getContext("2d");
232
+ const context = canvas.getContext("2d");
233
+ if (!stagingContext || !context) return;
234
+ stagingContext.drawImage(
235
+ video,
236
+ frame.sx,
237
+ frame.sy,
238
+ frame.sw,
239
+ frame.sh,
240
+ frame.dx,
241
+ frame.dy,
242
+ frame.dw,
243
+ frame.dh
244
+ );
245
+ canvas.width = destinationWidth;
246
+ canvas.height = destinationHeight;
247
+ context.drawImage(stagingCanvas, 0, 0);
248
+ const foreignObject = canvas.closest("foreignObject");
249
+ const svg = canvas.closest("svg");
250
+ if (foreignObject && svg) {
251
+ const originalHost = video.closest(".doc-player__block") ?? originalRoot;
252
+ const clonedHost = svg.closest(".doc-player__block") ?? clonedRoot;
253
+ const videoRect = video.getBoundingClientRect();
254
+ const hostRect = originalHost.getBoundingClientRect();
255
+ const renderedWidth = videoRect.width || destinationWidth;
256
+ const renderedHeight = videoRect.height || destinationHeight;
257
+ const fallbackX = Number.parseFloat(foreignObject.getAttribute("x") ?? "0") || 0;
258
+ const fallbackY = Number.parseFloat(foreignObject.getAttribute("y") ?? "0") || 0;
259
+ const left = videoRect.width ? videoRect.left - hostRect.left : fallbackX;
260
+ const top = videoRect.height ? videoRect.top - hostRect.top : fallbackY;
261
+ foreignObject.remove();
262
+ if (clonedHost === clonedRoot && !clonedHost.style.position) {
263
+ clonedHost.style.position = "relative";
264
+ }
265
+ canvas.style.position = "absolute";
266
+ canvas.style.left = `${left}px`;
267
+ canvas.style.top = `${top}px`;
268
+ canvas.style.width = `${renderedWidth}px`;
269
+ canvas.style.height = `${renderedHeight}px`;
270
+ canvas.style.zIndex = "3";
271
+ canvas.style.margin = "0";
272
+ canvas.style.transform = "none";
273
+ clonedHost.appendChild(canvas);
274
+ }
275
+ } catch {
276
+ }
277
+ });
278
+ }
99
279
  function getFrameVisualStateKey(captureRoot, timelineTime) {
100
280
  const markup = captureRoot.innerHTML;
101
281
  let needsTimelineKey = false;
@@ -153,6 +333,7 @@ function useFrameCapture() {
153
333
  const captureCanvasRef = useRef(null);
154
334
  const lastVisualStateKeyRef = useRef(null);
155
335
  const hasCapturedFrameRef = useRef(false);
336
+ const decodedImagesRef = useRef(/* @__PURE__ */ new WeakSet());
156
337
  const dimensionsRef = useRef({ width: 1920, height: 1080 });
157
338
  const init = useCallback(
158
339
  async (doc, renderOptions, captionMode) => {
@@ -168,6 +349,7 @@ function useFrameCapture() {
168
349
  captureCanvasRef.current = null;
169
350
  lastVisualStateKeyRef.current = null;
170
351
  hasCapturedFrameRef.current = false;
352
+ decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
171
353
  await new Promise((resolve) => {
172
354
  setTimeout(() => {
173
355
  if (oldRoot) oldRoot.unmount();
@@ -193,6 +375,7 @@ function useFrameCapture() {
193
375
  captureCanvasRef.current = captureCanvas;
194
376
  lastVisualStateKeyRef.current = null;
195
377
  hasCapturedFrameRef.current = false;
378
+ decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
196
379
  const container = document.createElement("div");
197
380
  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;`;
198
381
  document.body.appendChild(container);
@@ -219,6 +402,12 @@ function useFrameCapture() {
219
402
  showControls: false,
220
403
  autoPlay: false,
221
404
  forceViewport: { width, height, name: "export" },
405
+ theme: renderOptions.theme,
406
+ videoPresentation: renderOptions.videoPresentation,
407
+ pipSize: renderOptions.pipSize,
408
+ pipShape: renderOptions.pipShape,
409
+ pipPosition: renderOptions.pipPosition,
410
+ showCoverSlide: renderOptions.showCoverSlide,
222
411
  captionsEnabled,
223
412
  captionStyle,
224
413
  onRenderAPIReady: (api) => {
@@ -246,14 +435,31 @@ function useFrameCapture() {
246
435
  )
247
436
  );
248
437
  }, 15e3);
249
- void renderAPIReady.then((api) => {
250
- clearTimeout(timeout);
251
- resolve(api.getDuration());
438
+ void renderAPIReady.then(async (api) => {
439
+ try {
440
+ const captureRoot = container.querySelector("#squisq-capture-root");
441
+ if (!(captureRoot instanceof HTMLElement)) {
442
+ throw new Error("Capture root element not found after player initialization.");
443
+ }
444
+ await waitForCaptureAssets(captureRoot, decodedImagesRef.current);
445
+ clearTimeout(timeout);
446
+ resolve(api.getDuration());
447
+ } catch (assetError) {
448
+ clearTimeout(timeout);
449
+ reject(assetError);
450
+ }
252
451
  });
253
452
  });
254
453
  },
255
454
  []
256
455
  );
456
+ const setCoverVisible = useCallback(async (visible) => {
457
+ const api = renderAPIRef.current;
458
+ if (!api) throw new Error("Frame capture not initialized \xE2\u20AC\u201D call init() first");
459
+ if (visible) await api.showCover();
460
+ else await api.hideCover();
461
+ lastVisualStateKeyRef.current = null;
462
+ }, []);
257
463
  const captureCanvasFrame = useCallback(
258
464
  async (time, options = {}) => {
259
465
  const container = containerRef.current;
@@ -264,11 +470,17 @@ function useFrameCapture() {
264
470
  }
265
471
  const { width, height } = dimensionsRef.current;
266
472
  await api.seekTo(time);
267
- await waitForVisualUpdate(2);
473
+ const renderedTime = api.getRenderedTime();
474
+ if (Math.abs(renderedTime - time) > RENDER_TIME_EPSILON_SECONDS) {
475
+ throw new Error(
476
+ `Player committed ${renderedTime.toFixed(6)}s while capture requested ${time.toFixed(6)}s.`
477
+ );
478
+ }
268
479
  const root = container.querySelector("#squisq-capture-root");
269
480
  if (!root) {
270
481
  throw new Error("Capture root element not found");
271
482
  }
483
+ await waitForCaptureAssets(root, decodedImagesRef.current);
272
484
  const visualStateKey = options.reuseIfUnchanged ? getFrameVisualStateKey(root, time) : null;
273
485
  if (visualStateKey !== null && hasCapturedFrameRef.current && lastVisualStateKeyRef.current === visualStateKey) {
274
486
  return captureCanvas;
@@ -286,6 +498,9 @@ function useFrameCapture() {
286
498
  allowTaint: true,
287
499
  backgroundColor: "#000000",
288
500
  logging: false,
501
+ onclone: (_clonedDocument, clonedRoot) => {
502
+ prepareScheduledVideoClones(root, clonedRoot);
503
+ },
289
504
  // html2canvas starts cloning at documentElement. Do not clone the rest
290
505
  // of the editor/site UI on every frame; only the capture root, its
291
506
  // ancestors, descendants, and document styles can affect this render.
@@ -322,11 +537,12 @@ function useFrameCapture() {
322
537
  }
323
538
  lastVisualStateKeyRef.current = null;
324
539
  hasCapturedFrameRef.current = false;
540
+ decodedImagesRef.current = /* @__PURE__ */ new WeakSet();
325
541
  renderAPIRef.current = null;
326
542
  }, []);
327
543
  return useMemo(
328
- () => ({ init, captureFrame, captureCanvasFrame, destroy }),
329
- [init, captureFrame, captureCanvasFrame, destroy]
544
+ () => ({ init, setCoverVisible, captureFrame, captureCanvasFrame, destroy }),
545
+ [init, setCoverVisible, captureFrame, captureCanvasFrame, destroy]
330
546
  );
331
547
  }
332
548
 
@@ -593,14 +809,22 @@ async function transcodeMp4ToGifWithFfmpegWasm(videoMp4, options, loadConfig, si
593
809
 
594
810
  // src/hooks/useVideoExport.ts
595
811
  var MAX_EXPORT_MEDIA_FILES = 256;
596
- var MAX_EXPORT_MEDIA_FILE_BYTES = 64 * 1024 * 1024;
597
- var MAX_EXPORT_MEDIA_TOTAL_BYTES = 256 * 1024 * 1024;
598
812
  var ENCODER_PROBE_TIMEOUT_MS = 5e3;
599
813
  var ENCODER_START_TIMEOUT_MS = 6e4;
600
814
  var FRAME_CAPTURE_TIMEOUT_MS = 6e4;
601
815
  var FRAME_ENCODE_TIMEOUT_MS = 6e4;
602
816
  var CAPTURE_PROGRESS_START = 7;
603
817
  var CAPTURE_PROGRESS_END = 95;
818
+ var FRAME_RATE_WINDOW_SIZE = 30;
819
+ var DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS = 2;
820
+ function calculateRollingFramesPerSecond(frameBoundaryTimes) {
821
+ if (frameBoundaryTimes.length < 2) return null;
822
+ const firstIndex = Math.max(0, frameBoundaryTimes.length - (FRAME_RATE_WINDOW_SIZE + 1));
823
+ const elapsedMs = frameBoundaryTimes[frameBoundaryTimes.length - 1] - frameBoundaryTimes[firstIndex];
824
+ const completedFrames = frameBoundaryTimes.length - 1 - firstIndex;
825
+ if (elapsedMs <= 0 || completedFrames <= 0) return null;
826
+ return completedFrames * 1e3 / elapsedMs;
827
+ }
604
828
  function releaseEncoderFrame(frame) {
605
829
  if ("close" in frame) frame.close();
606
830
  }
@@ -633,6 +857,14 @@ function settleWithin(operation, timeoutMs, timeoutMessage, onLateResult) {
633
857
  function toArrayBuffer(bytes) {
634
858
  return bytes.slice().buffer;
635
859
  }
860
+ function resolveExportMediaResourcePolicy(declaredSize, policy) {
861
+ const knownSize = Number.isFinite(declaredSize) ? Math.max(0, declaredSize) : 0;
862
+ return {
863
+ ...DEFAULT_INTERACTIVE_RESOURCE_POLICY,
864
+ ...policy,
865
+ maxBytes: policy?.maxBytes ?? Math.max(DEFAULT_INTERACTIVE_RESOURCE_POLICY.maxBytes, knownSize)
866
+ };
867
+ }
636
868
  function collectDocumentMediaReferences(doc) {
637
869
  const references = /* @__PURE__ */ new Set();
638
870
  const seen = /* @__PURE__ */ new WeakSet();
@@ -672,10 +904,37 @@ async function resolveAudioBuffers(clips, sources) {
672
904
  }
673
905
  return out;
674
906
  }
675
- function useVideoExport() {
907
+ function resolveFrontmatterBoolean(value) {
908
+ if (typeof value === "boolean") return value;
909
+ if (typeof value !== "string") return void 0;
910
+ const normalized = value.trim().toLowerCase();
911
+ if (normalized === "true" || normalized === "yes" || normalized === "on" || normalized === "show" || normalized === "visible") {
912
+ return true;
913
+ }
914
+ if (normalized === "false" || normalized === "no" || normalized === "off" || normalized === "hide" || normalized === "hidden") {
915
+ return false;
916
+ }
917
+ return void 0;
918
+ }
919
+ function resolveVideoExportCover(doc, config = {}) {
920
+ const frontmatter = doc.frontmatter;
921
+ const frontmatterValue = frontmatter ? Object.prototype.hasOwnProperty.call(frontmatter, "squisq-cover-slide") ? frontmatter["squisq-cover-slide"] : frontmatter["cover-slide"] : void 0;
922
+ const showCoverSlide = config.showCoverSlide ?? resolveFrontmatterBoolean(frontmatterValue) ?? true;
923
+ const requestedPreRoll = config.coverPreRoll ?? DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS;
924
+ if (!Number.isFinite(requestedPreRoll) || requestedPreRoll < 0) {
925
+ throw new Error("Cover pre-roll must be a finite number of seconds greater than or equal to 0");
926
+ }
927
+ return {
928
+ showCoverSlide,
929
+ coverPreRoll: showCoverSlide && !!doc.startBlock ? requestedPreRoll : 0
930
+ };
931
+ }
932
+ function useVideoExport(options = {}) {
676
933
  const [state, setState] = useState("idle");
677
934
  const [progress, setProgress] = useState(0);
678
935
  const [phase, setPhase] = useState("");
936
+ const [currentFrameTime, setCurrentFrameTime] = useState(null);
937
+ const [processingFps, setProcessingFps] = useState(null);
679
938
  const [duration, setDuration] = useState(0);
680
939
  const [outputFormat, setOutputFormat] = useState("mp4");
681
940
  const [backend, setBackend] = useState(null);
@@ -692,6 +951,8 @@ function useVideoExport() {
692
951
  const downloadUrlRef = useRef2(null);
693
952
  const startTimeRef = useRef2(0);
694
953
  const elapsedTimerRef = useRef2(null);
954
+ const previewOptionsRef = useRef2(options);
955
+ previewOptionsRef.current = options;
695
956
  const frameCapture = useFrameCapture();
696
957
  useEffect(() => {
697
958
  return () => {
@@ -721,6 +982,8 @@ function useVideoExport() {
721
982
  setState("idle");
722
983
  setProgress(0);
723
984
  setPhase("");
985
+ setCurrentFrameTime(null);
986
+ setProcessingFps(null);
724
987
  setDuration(0);
725
988
  setOutputFormat("mp4");
726
989
  setBackend(null);
@@ -760,6 +1023,8 @@ function useVideoExport() {
760
1023
  setAudioIncluded(false);
761
1024
  setAudioSkippedReason(null);
762
1025
  setError(null);
1026
+ setCurrentFrameTime(null);
1027
+ setProcessingFps(null);
763
1028
  const quality = config.quality ?? "normal";
764
1029
  const effectiveOutputFormat = config.outputFormat ?? "mp4";
765
1030
  const fps = config.fps ?? (effectiveOutputFormat === "gif" ? 10 : 30);
@@ -769,6 +1034,7 @@ function useVideoExport() {
769
1034
  const audioPolicy = config.audioPolicy ?? "require";
770
1035
  setOutputFormat(effectiveOutputFormat);
771
1036
  try {
1037
+ const cover = resolveVideoExportCover(doc, config);
772
1038
  const gifDefaults = orientation === "portrait" ? { width: 540, height: 960 } : { width: 960, height: 540 };
773
1039
  const { width, height } = resolveDimensions({
774
1040
  orientation,
@@ -815,42 +1081,42 @@ function useVideoExport() {
815
1081
  `Document references ${neededEntries.length} media files; browser export supports at most ${MAX_EXPORT_MEDIA_FILES}.`
816
1082
  );
817
1083
  }
818
- let totalMediaBytes = 0;
819
1084
  for (const entry of neededEntries) {
820
1085
  if (cancelledRef.current) return;
821
- if (entry.size > MAX_EXPORT_MEDIA_FILE_BYTES) {
822
- throw new Error(`Media file "${entry.name}" is too large for browser video export.`);
823
- }
824
1086
  const url2 = await config.mediaProvider.resolveUrl(entry.name);
825
1087
  const resource = await fetchResourceBytes(url2, {
826
- policy: {
827
- ...DEFAULT_INTERACTIVE_RESOURCE_POLICY,
828
- ...config.resourcePolicy,
829
- maxBytes: Math.min(
830
- config.resourcePolicy?.maxBytes ?? MAX_EXPORT_MEDIA_FILE_BYTES,
831
- MAX_EXPORT_MEDIA_FILE_BYTES
832
- )
833
- }
1088
+ policy: resolveExportMediaResourcePolicy(entry.size, config.resourcePolicy)
834
1089
  });
835
1090
  const data = toArrayBuffer(resource.bytes);
836
- totalMediaBytes += data.byteLength;
837
- if (totalMediaBytes > MAX_EXPORT_MEDIA_TOTAL_BYTES) {
838
- throw new Error("Referenced media exceeds the browser video export memory limit.");
839
- }
840
1091
  images.set(entry.name, data);
841
1092
  }
842
1093
  }
843
1094
  const docDuration = await frameCapture.init(
844
1095
  doc,
845
- { images, audio: config.audio, width, height, animationsEnabled },
1096
+ {
1097
+ images,
1098
+ audio: config.audio,
1099
+ width,
1100
+ height,
1101
+ animationsEnabled,
1102
+ theme: config.theme,
1103
+ videoPresentation: config.videoPresentation,
1104
+ pipSize: config.pipSize,
1105
+ pipShape: config.pipShape,
1106
+ pipPosition: config.pipPosition,
1107
+ showCoverSlide: cover.showCoverSlide
1108
+ },
846
1109
  captionMode
847
1110
  );
848
1111
  if (cancelledRef.current) return;
849
- setDuration(docDuration);
850
1112
  if (docDuration <= 0) {
851
1113
  throw new Error("Document has zero duration \u2014 nothing to export");
852
1114
  }
853
- const totalFrames = Math.ceil(docDuration * fps);
1115
+ const coverFrameCount = Math.ceil(cover.coverPreRoll * fps);
1116
+ const storyFrameCount = Math.ceil(docDuration * fps);
1117
+ const totalFrames = coverFrameCount + storyFrameCount;
1118
+ const exportDuration = totalFrames / fps;
1119
+ setDuration(exportDuration);
854
1120
  setPhase("Checking video encoder\u2026");
855
1121
  setProgress(5);
856
1122
  const canUseWebCodecs = webCodecsAvailable && await settleWithin(
@@ -859,7 +1125,7 @@ function useVideoExport() {
859
1125
  "The browser did not finish checking WebCodecs support."
860
1126
  ).catch(() => false);
861
1127
  const audioBitrate = (QUALITY_PRESETS[quality] ?? QUALITY_PRESETS.normal).audioBitrate;
862
- const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, 0) : [];
1128
+ const timeline = effectiveOutputFormat === "mp4" && audioPolicy !== "omit" ? computeAudioTimeline(doc, coverFrameCount / fps) : [];
863
1129
  const aacSupported = timeline.length > 0 ? await supportsWebCodecsAac(EXPORT_AUDIO_SAMPLE_RATE, EXPORT_AUDIO_CHANNELS) : false;
864
1130
  const tierDecision = selectAudioTier({
865
1131
  hasClips: timeline.length > 0,
@@ -894,7 +1160,7 @@ function useVideoExport() {
894
1160
  } else {
895
1161
  const totalAudioDur = timeline.reduce(
896
1162
  (max, c) => Math.max(max, c.startSec + c.durationSec),
897
- docDuration
1163
+ exportDuration
898
1164
  );
899
1165
  renderedAudio = await renderAudioTimeline(
900
1166
  timeline,
@@ -954,13 +1220,20 @@ function useVideoExport() {
954
1220
  }
955
1221
  if (cancelledRef.current) return;
956
1222
  setProgress(CAPTURE_PROGRESS_START);
957
- setPhase(`Capturing frame 1/${totalFrames} (0.0s)`);
1223
+ setPhase(`Capturing frame 1/${totalFrames}`);
1224
+ setCurrentFrameTime(0);
958
1225
  setState("capturing");
959
1226
  const captureStartTime = performance.now();
1227
+ const frameBoundaryTimes = [captureStartTime];
1228
+ if (coverFrameCount > 0) await frameCapture.setCoverVisible(true);
960
1229
  for (let i = 0; i < totalFrames; i++) {
961
1230
  if (cancelledRef.current) return;
1231
+ if (coverFrameCount > 0 && i === coverFrameCount) {
1232
+ await frameCapture.setCoverVisible(false);
1233
+ }
962
1234
  const time = i / fps;
963
- const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(time, { reuseIfUnchanged: true }) : frameCapture.captureFrame(time, { reuseIfUnchanged: true });
1235
+ const captureTime = i < coverFrameCount ? 0 : (i - coverFrameCount) / fps;
1236
+ const captureOperation = canUseWebCodecs ? frameCapture.captureCanvasFrame(captureTime, { reuseIfUnchanged: true }) : frameCapture.captureFrame(captureTime, { reuseIfUnchanged: true });
964
1237
  const frame = await settleWithin(
965
1238
  captureOperation,
966
1239
  FRAME_CAPTURE_TIMEOUT_MS,
@@ -971,15 +1244,31 @@ function useVideoExport() {
971
1244
  releaseEncoderFrame(frame);
972
1245
  return;
973
1246
  }
974
- setPhase(`Encoding frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
1247
+ const previewOptions = previewOptionsRef.current;
1248
+ const previewInterval = Math.max(1, Math.floor(previewOptions.previewEveryNFrames ?? 1));
1249
+ if (previewOptions.onFramePreview && (i === 0 || i === totalFrames - 1 || i % previewInterval === 0)) {
1250
+ try {
1251
+ previewOptions.onFramePreview({ source: frame, frameIndex: i, totalFrames, time });
1252
+ } catch {
1253
+ }
1254
+ }
1255
+ setPhase(`Encoding frame ${i + 1}/${totalFrames}`);
1256
+ setCurrentFrameTime(time);
975
1257
  await settleWithin(
976
1258
  encoder.encodeFrame(frame, i),
977
1259
  FRAME_ENCODE_TIMEOUT_MS,
978
1260
  `Video encoding stopped responding at frame ${i + 1}/${totalFrames}.`
979
1261
  );
980
1262
  const completedFrames = i + 1;
1263
+ const completedAt = performance.now();
1264
+ frameBoundaryTimes.push(completedAt);
1265
+ if (frameBoundaryTimes.length > FRAME_RATE_WINDOW_SIZE + 1) {
1266
+ frameBoundaryTimes.shift();
1267
+ }
1268
+ setProcessingFps(calculateRollingFramesPerSecond(frameBoundaryTimes));
1269
+ setCurrentFrameTime(Math.min(completedFrames / fps, exportDuration));
981
1270
  setPhase(
982
- completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames} (${(completedFrames / fps).toFixed(1)}s)` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
1271
+ completedFrames < totalFrames ? `Capturing frame ${completedFrames + 1}/${totalFrames}` : `Captured ${totalFrames.toLocaleString()} frames\u2026`
983
1272
  );
984
1273
  const captureRatio = completedFrames / totalFrames;
985
1274
  const captureProgress = CAPTURE_PROGRESS_START + captureRatio * (CAPTURE_PROGRESS_END - CAPTURE_PROGRESS_START);
@@ -1085,6 +1374,8 @@ function useVideoExport() {
1085
1374
  state,
1086
1375
  progress,
1087
1376
  phase,
1377
+ currentFrameTime,
1378
+ processingFps,
1088
1379
  duration,
1089
1380
  outputFormat,
1090
1381
  backend,
@@ -1103,5 +1394,7 @@ function useVideoExport() {
1103
1394
 
1104
1395
  export {
1105
1396
  useFrameCapture,
1397
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
1398
+ resolveVideoExportCover,
1106
1399
  useVideoExport
1107
1400
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-IDY64F4K.js";
3
+ } from "./chunk-NZYFSB2T.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
6
  import { useState, useCallback, useId, useRef } from "react";
@@ -12,6 +12,31 @@ function formatDuration(seconds) {
12
12
  const s = seconds % 60;
13
13
  return `${m}m ${s}s`;
14
14
  }
15
+ function formatProcessingFps(framesPerSecond) {
16
+ return `${framesPerSecond.toFixed(2)} fps`;
17
+ }
18
+ function formatRealtimeMultiplier(processingFps, outputFps) {
19
+ return `${(processingFps / outputFps).toFixed(2)}\xD7 realtime`;
20
+ }
21
+ var FRAME_PREVIEW_INTERVAL = 15;
22
+ var FRAME_PREVIEW_WIDTH = 480;
23
+ var FRAME_PREVIEW_HEIGHT = 270;
24
+ function drawVideoExportPreview(canvas, source) {
25
+ const context = canvas.getContext("2d");
26
+ if (!context || source.width <= 0 || source.height <= 0) return;
27
+ context.fillStyle = "#000000";
28
+ context.fillRect(0, 0, canvas.width, canvas.height);
29
+ const scale = Math.min(canvas.width / source.width, canvas.height / source.height);
30
+ const width = source.width * scale;
31
+ const height = source.height * scale;
32
+ context.drawImage(
33
+ source,
34
+ (canvas.width - width) / 2,
35
+ (canvas.height - height) / 2,
36
+ width,
37
+ height
38
+ );
39
+ }
15
40
  var VIDEO_EXPORT_PALETTES = {
16
41
  light: {
17
42
  overlay: "rgba(0, 0, 0, 0.5)",
@@ -139,6 +164,7 @@ function VideoExportModal({
139
164
  }) {
140
165
  const overlayRef = useRef(null);
141
166
  const dialogRef = useRef(null);
167
+ const previewCanvasRef = useRef(null);
142
168
  const titleId = useId();
143
169
  const initialOutputFormat = defaultConfig?.outputFormat ?? "mp4";
144
170
  const [outputFormat, setOutputFormat] = useState(initialOutputFormat);
@@ -188,11 +214,20 @@ function VideoExportModal({
188
214
  color: palette.text,
189
215
  border: `1px solid ${palette.border}`
190
216
  };
191
- const exportHook = useVideoExport();
217
+ const handleFramePreview = useCallback((preview) => {
218
+ const canvas = previewCanvasRef.current;
219
+ if (canvas) drawVideoExportPreview(canvas, preview.source);
220
+ }, []);
221
+ const exportHook = useVideoExport({
222
+ onFramePreview: handleFramePreview,
223
+ previewEveryNFrames: FRAME_PREVIEW_INTERVAL
224
+ });
192
225
  const {
193
226
  state,
194
227
  progress,
195
228
  phase,
229
+ currentFrameTime,
230
+ processingFps,
196
231
  outputFormat: completedOutputFormat,
197
232
  downloadUrl,
198
233
  fileSize,
@@ -218,6 +253,12 @@ function VideoExportModal({
218
253
  }
219
254
  }, []);
220
255
  const handleExport = useCallback(async () => {
256
+ const previewCanvas = previewCanvasRef.current;
257
+ const previewContext = previewCanvas?.getContext("2d");
258
+ if (previewCanvas && previewContext) {
259
+ previewContext.fillStyle = "#000000";
260
+ previewContext.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
261
+ }
221
262
  const config = {
222
263
  // defaultConfig is the base; explicit props/selections win over it.
223
264
  ...defaultConfig,
@@ -439,6 +480,27 @@ function VideoExportModal({
439
480
  ] })
440
481
  ] }),
441
482
  isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
483
+ /* @__PURE__ */ jsx(
484
+ "canvas",
485
+ {
486
+ ref: previewCanvasRef,
487
+ width: FRAME_PREVIEW_WIDTH,
488
+ height: FRAME_PREVIEW_HEIGHT,
489
+ role: "img",
490
+ "aria-label": "Latest rendered video frame",
491
+ "data-squisq-video-export-preview": true,
492
+ style: {
493
+ display: "block",
494
+ width: "100%",
495
+ height: "auto",
496
+ aspectRatio: "16 / 9",
497
+ boxSizing: "border-box",
498
+ background: "#000000",
499
+ border: `1px solid ${palette.border}`,
500
+ marginBottom: 12
501
+ }
502
+ }
503
+ ),
442
504
  /* @__PURE__ */ jsx(
443
505
  "div",
444
506
  {
@@ -463,16 +525,40 @@ function VideoExportModal({
463
525
  )
464
526
  }
465
527
  ),
466
- /* @__PURE__ */ jsxs("p", { style: { fontSize: 13, margin: "0 0 4px 0" }, children: [
467
- progress,
468
- "% complete"
469
- ] }),
470
- phase && /* @__PURE__ */ jsx(
528
+ /* @__PURE__ */ jsxs(
471
529
  "p",
472
530
  {
473
- style: { fontSize: 12, color: palette.label, margin: "0 0 4px 0" },
531
+ style: { fontSize: 13, fontVariantNumeric: "tabular-nums", margin: "0 0 4px 0" },
532
+ "data-squisq-video-export-progress-label": true,
533
+ children: [
534
+ progress.toFixed(1),
535
+ "% complete",
536
+ currentFrameTime != null && `, @ ${currentFrameTime.toFixed(1)} seconds`
537
+ ]
538
+ }
539
+ ),
540
+ phase && /* @__PURE__ */ jsxs(
541
+ "p",
542
+ {
543
+ style: {
544
+ fontSize: 12,
545
+ fontVariantNumeric: "tabular-nums",
546
+ color: palette.label,
547
+ margin: "0 0 4px 0"
548
+ },
474
549
  "data-squisq-video-export-phase": true,
475
- children: phase
550
+ children: [
551
+ phase,
552
+ processingFps != null && /* @__PURE__ */ jsxs(Fragment, { children: [
553
+ " - ",
554
+ /* @__PURE__ */ jsxs("span", { "data-squisq-video-export-frame-metrics": true, children: [
555
+ formatProcessingFps(processingFps),
556
+ " \xB7",
557
+ " ",
558
+ formatRealtimeMultiplier(processingFps, fps)
559
+ ] })
560
+ ] })
561
+ ]
476
562
  }
477
563
  ),
478
564
  /* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: palette.muted, margin: 0 }, children: [
@@ -1,9 +1,10 @@
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-DAEglCva.js';
3
+ import { a as VideoExportConfig } from '../useVideoExport-Okwrf8gg.js';
4
4
  import '@bendyline/squisq/markdown';
5
5
  import '@bendyline/squisq-video';
6
6
  import '@bendyline/squisq-react';
7
+ import '../mainThreadEncoder-BgcFyYvO.js';
7
8
 
8
9
  interface VideoExportModalProps {
9
10
  /** The document to export */
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "../chunk-ULNKIV4A.js";
5
- import "../chunk-IDY64F4K.js";
4
+ } from "../chunk-U2TST3MN.js";
5
+ import "../chunk-NZYFSB2T.js";
6
6
  import "../chunk-ZQJBUX75.js";
7
7
  import "../chunk-MEPETH5V.js";
8
8
  export {
@@ -1,72 +1,6 @@
1
+ export { E as EncoderConfig, a as EncoderFrameSource, M as MainThreadEncoder, c as createEncoder, s as supportsWebCodecs, b as supportsWebCodecsH264 } from '../mainThreadEncoder-BgcFyYvO.js';
1
2
  export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
2
3
 
3
- /**
4
- * Main-thread WebCodecs encoder.
5
- *
6
- * Encodes video frames to MP4 using the WebCodecs API and mp4-muxer,
7
- * running directly on the main thread. This is simpler and avoids
8
- * worker module-resolution issues with bundlers. Since frame capture
9
- * via html2canvas (~100-200ms per frame) is the bottleneck — not
10
- * encoding (~1ms per frame with hardware-accelerated WebCodecs) —
11
- * worker offloading provides minimal benefit.
12
- *
13
- * Requirements: Chrome 94+ / Edge 94+ (WebCodecs support).
14
- */
15
- interface EncoderConfig {
16
- width: number;
17
- height: number;
18
- fps: number;
19
- quality: 'draft' | 'normal' | 'high';
20
- /**
21
- * Total frames the caller intends to submit, when known. Unused by the
22
- * main-thread encoder (the caller owns the progress bar) but forwarded by
23
- * {@link createWorkerEncoder} so the worker can report real progress instead
24
- * of guessing from the frames it happens to have seen.
25
- */
26
- totalFrames?: number;
27
- /**
28
- * When present, the underlying muxer is configured with an AAC audio track
29
- * and {@link MainThreadEncoder.addAudioChunk} becomes usable. Absent → the
30
- * encoder produces a video-only MP4 exactly as before.
31
- */
32
- audio?: {
33
- numberOfChannels: number;
34
- sampleRate: number;
35
- };
36
- }
37
- type EncoderFrameSource = ImageBitmap | HTMLCanvasElement;
38
- interface MainThreadEncoder {
39
- /** Encode a single frame. ImageBitmap sources are closed after encoding. */
40
- encodeFrame(frame: EncoderFrameSource, frameIndex: number): Promise<void>;
41
- /**
42
- * Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
43
- * Only valid when the encoder was created with an `audio` config; otherwise a
44
- * no-op. Must be called before {@link finalize}.
45
- */
46
- addAudioChunk?(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
47
- /** Flush pending frames and finalize the MP4. Returns the MP4 ArrayBuffer. */
48
- finalize(): Promise<ArrayBuffer>;
49
- /** Close the encoder without producing output (e.g., on cancel). */
50
- close(): void;
51
- }
52
- /**
53
- * Check whether the browser supports WebCodecs video encoding.
54
- */
55
- declare function supportsWebCodecs(): boolean;
56
- /**
57
- * Probe whether the WebCodecs encoder actually supports the H.264 profile
58
- * we use. The `VideoEncoder` global can exist while the specific codec is
59
- * unavailable — this is the case on Linux Chromium, which ships without
60
- * the proprietary H.264 encoder.
61
- */
62
- declare function supportsWebCodecsH264(config: EncoderConfig): Promise<boolean>;
63
- /**
64
- * Create a main-thread WebCodecs encoder.
65
- *
66
- * Throws if WebCodecs is not available.
67
- */
68
- declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
69
-
70
4
  /**
71
5
  * audioTrack — In-browser audio rendering + AAC encoding for MP4 export.
72
6
  *
@@ -93,4 +27,4 @@ declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
93
27
  */
94
28
  declare function supportsWebCodecsAac(sampleRate?: number, channels?: number): Promise<boolean>;
95
29
 
96
- export { type EncoderConfig, type EncoderFrameSource, type MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 };
30
+ export { supportsWebCodecsAac };
@@ -1,8 +1,9 @@
1
- export { V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportResult, c as VideoExportState, d as VideoOutputFormat, u as useVideoExport } from '../useVideoExport-DAEglCva.js';
1
+ export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from '../useVideoExport-Okwrf8gg.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';
5
5
  import '@bendyline/squisq/markdown';
6
+ import '../mainThreadEncoder-BgcFyYvO.js';
6
7
 
7
8
  /**
8
9
  * useFrameCapture — Hidden div + html2canvas frame capture.
@@ -24,9 +25,15 @@ interface FrameCaptureOptions {
24
25
  */
25
26
  reuseIfUnchanged?: boolean;
26
27
  }
28
+ interface FrameCaptureRenderOptions extends Omit<RenderHtmlOptions, 'playerScript'> {
29
+ /** Whether the hidden player should materialize its managed cover. */
30
+ showCoverSlide?: boolean;
31
+ }
27
32
  interface FrameCaptureHandle {
28
33
  /** Initialize the hidden player. Returns the video duration in seconds. */
29
- init: (doc: Doc, renderOptions: Omit<RenderHtmlOptions, 'playerScript'>, captionMode?: CaptionMode) => Promise<number>;
34
+ init: (doc: Doc, renderOptions: FrameCaptureRenderOptions, captionMode?: CaptionMode) => Promise<number>;
35
+ /** Force the managed cover on or off before capturing export frames. */
36
+ setCoverVisible: (visible: boolean) => Promise<void>;
30
37
  /** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
31
38
  captureFrame: (time: number, options?: FrameCaptureOptions) => Promise<ImageBitmap>;
32
39
  /**
@@ -42,4 +49,4 @@ interface FrameCaptureHandle {
42
49
  */
43
50
  declare function useFrameCapture(): FrameCaptureHandle;
44
51
 
45
- export { type FrameCaptureHandle, type FrameCaptureOptions, useFrameCapture };
52
+ export { type FrameCaptureHandle, type FrameCaptureOptions, type FrameCaptureRenderOptions, useFrameCapture };
@@ -1,10 +1,14 @@
1
1
  import {
2
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
3
+ resolveVideoExportCover,
2
4
  useFrameCapture,
3
5
  useVideoExport
4
- } from "../chunk-IDY64F4K.js";
6
+ } from "../chunk-NZYFSB2T.js";
5
7
  import "../chunk-ZQJBUX75.js";
6
8
  import "../chunk-MEPETH5V.js";
7
9
  export {
10
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
11
+ resolveVideoExportCover,
8
12
  useFrameCapture,
9
13
  useVideoExport
10
14
  };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
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-DAEglCva.js';
3
- export { FrameCaptureHandle, FrameCaptureOptions, useFrameCapture } from './hooks/index.js';
4
- export { EncoderConfig, EncoderFrameSource, MainThreadEncoder, createEncoder, supportsWebCodecs, supportsWebCodecsAac, supportsWebCodecsH264 } from './encoder/index.js';
2
+ export { D as DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS, R as ResolvedVideoExportCover, U as UseVideoExportOptions, V as VideoAudioPolicy, a as VideoExportConfig, b as VideoExportFramePreview, c as VideoExportResult, d as VideoExportState, e as VideoOutputFormat, r as resolveVideoExportCover, u as useVideoExport } from './useVideoExport-Okwrf8gg.js';
3
+ export { FrameCaptureHandle, FrameCaptureOptions, FrameCaptureRenderOptions, useFrameCapture } from './hooks/index.js';
4
+ export { E as EncoderConfig, a as EncoderFrameSource, M as MainThreadEncoder, c as createEncoder, s as supportsWebCodecs, b as supportsWebCodecsH264 } from './mainThreadEncoder-BgcFyYvO.js';
5
5
  export { FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
6
+ export { supportsWebCodecsAac } from './encoder/index.js';
6
7
  import 'react/jsx-runtime';
7
8
  import '@bendyline/squisq/schemas';
8
9
  import '@bendyline/squisq/markdown';
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  VideoExportButton,
3
3
  VideoExportModal
4
- } from "./chunk-ULNKIV4A.js";
4
+ } from "./chunk-U2TST3MN.js";
5
5
  import {
6
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
7
+ resolveVideoExportCover,
6
8
  useFrameCapture,
7
9
  useVideoExport
8
- } from "./chunk-IDY64F4K.js";
10
+ } from "./chunk-NZYFSB2T.js";
9
11
  import {
10
12
  createEncoder,
11
13
  supportsWebCodecs,
@@ -14,9 +16,11 @@ import {
14
16
  } from "./chunk-ZQJBUX75.js";
15
17
  import "./chunk-MEPETH5V.js";
16
18
  export {
19
+ DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS,
17
20
  VideoExportButton,
18
21
  VideoExportModal,
19
22
  createEncoder,
23
+ resolveVideoExportCover,
20
24
  supportsWebCodecs,
21
25
  supportsWebCodecsAac,
22
26
  supportsWebCodecsH264,
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Main-thread WebCodecs encoder.
3
+ *
4
+ * Encodes video frames to MP4 using the WebCodecs API and mp4-muxer,
5
+ * running directly on the main thread. This is simpler and avoids
6
+ * worker module-resolution issues with bundlers. Since frame capture
7
+ * via html2canvas (~100-200ms per frame) is the bottleneck — not
8
+ * encoding (~1ms per frame with hardware-accelerated WebCodecs) —
9
+ * worker offloading provides minimal benefit.
10
+ *
11
+ * Requirements: Chrome 94+ / Edge 94+ (WebCodecs support).
12
+ */
13
+ interface EncoderConfig {
14
+ width: number;
15
+ height: number;
16
+ fps: number;
17
+ quality: 'draft' | 'normal' | 'high';
18
+ /**
19
+ * Total frames the caller intends to submit, when known. Unused by the
20
+ * main-thread encoder (the caller owns the progress bar) but forwarded by
21
+ * {@link createWorkerEncoder} so the worker can report real progress instead
22
+ * of guessing from the frames it happens to have seen.
23
+ */
24
+ totalFrames?: number;
25
+ /**
26
+ * When present, the underlying muxer is configured with an AAC audio track
27
+ * and {@link MainThreadEncoder.addAudioChunk} becomes usable. Absent → the
28
+ * encoder produces a video-only MP4 exactly as before.
29
+ */
30
+ audio?: {
31
+ numberOfChannels: number;
32
+ sampleRate: number;
33
+ };
34
+ }
35
+ type EncoderFrameSource = ImageBitmap | HTMLCanvasElement;
36
+ interface MainThreadEncoder {
37
+ /** Encode a single frame. ImageBitmap sources are closed after encoding. */
38
+ encodeFrame(frame: EncoderFrameSource, frameIndex: number): Promise<void>;
39
+ /**
40
+ * Hand an encoded audio chunk (from a WebCodecs `AudioEncoder`) to the muxer.
41
+ * Only valid when the encoder was created with an `audio` config; otherwise a
42
+ * no-op. Must be called before {@link finalize}.
43
+ */
44
+ addAudioChunk?(chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata): void;
45
+ /** Flush pending frames and finalize the MP4. Returns the MP4 ArrayBuffer. */
46
+ finalize(): Promise<ArrayBuffer>;
47
+ /** Close the encoder without producing output (e.g., on cancel). */
48
+ close(): void;
49
+ }
50
+ /**
51
+ * Check whether the browser supports WebCodecs video encoding.
52
+ */
53
+ declare function supportsWebCodecs(): boolean;
54
+ /**
55
+ * Probe whether the WebCodecs encoder actually supports the H.264 profile
56
+ * we use. The `VideoEncoder` global can exist while the specific codec is
57
+ * unavailable — this is the case on Linux Chromium, which ships without
58
+ * the proprietary H.264 encoder.
59
+ */
60
+ declare function supportsWebCodecsH264(config: EncoderConfig): Promise<boolean>;
61
+ /**
62
+ * Create a main-thread WebCodecs encoder.
63
+ *
64
+ * Throws if WebCodecs is not available.
65
+ */
66
+ declare function createEncoder(config: EncoderConfig): MainThreadEncoder;
67
+
68
+ export { type EncoderConfig as E, type MainThreadEncoder as M, type EncoderFrameSource as a, supportsWebCodecsH264 as b, createEncoder as c, supportsWebCodecs as s };
@@ -1,7 +1,8 @@
1
- import { MediaProvider, Doc } from '@bendyline/squisq/schemas';
1
+ import { MediaProvider, Theme, VideoPresentation, VideoPipSize, VideoPipShape, VideoPipPosition, Doc } from '@bendyline/squisq/schemas';
2
2
  import { ResourcePolicy } from '@bendyline/squisq/markdown';
3
3
  import { VideoQuality, VideoOrientation, FfmpegWasmLoadConfig } from '@bendyline/squisq-video';
4
4
  import { CaptionMode } from '@bendyline/squisq-react';
5
+ import { a as EncoderFrameSource } from './mainThreadEncoder-BgcFyYvO.js';
5
6
 
6
7
  /**
7
8
  * useVideoExport — Main orchestration hook for browser video export.
@@ -20,6 +21,7 @@ import { CaptionMode } from '@bendyline/squisq-react';
20
21
  * <button onClick={() => startExport(doc, options)}>Export</button>
21
22
  */
22
23
 
24
+ declare const DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS = 2;
23
25
  type VideoExportState = 'idle' | 'preparing' | 'capturing' | 'encoding' | 'complete' | 'error';
24
26
  /** Browser export container format. */
25
27
  type VideoOutputFormat = 'mp4' | 'gif';
@@ -62,11 +64,31 @@ interface VideoExportConfig {
62
64
  resourcePolicy?: ResourcePolicy;
63
65
  /** Caption mode for the exported video (default: 'off' for MP4, 'standard' for GIF). */
64
66
  captionMode?: CaptionMode;
67
+ /** Theme override for capture. Omitted values resolve from the Doc. */
68
+ theme?: Theme;
69
+ /** Player-level video placement override. Omitted values resolve from Doc frontmatter. */
70
+ videoPresentation?: VideoPresentation;
71
+ /** Picture-in-picture size override. Omitted values resolve from Doc frontmatter. */
72
+ pipSize?: VideoPipSize;
73
+ /** Picture-in-picture shape override. Omitted values resolve from Doc frontmatter. */
74
+ pipShape?: VideoPipShape;
75
+ /** Picture-in-picture corner override. Omitted values resolve from Doc frontmatter. */
76
+ pipPosition?: VideoPipPosition;
77
+ /** Managed-cover visibility override. Omitted values resolve from Doc frontmatter. */
78
+ showCoverSlide?: boolean;
79
+ /** Seconds to hold an enabled managed cover before story frame zero (default: 2). */
80
+ coverPreRoll?: number;
65
81
  /** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */
66
82
  playerScript?: string;
67
83
  /** Optional self-hosted ffmpeg.wasm core URLs for fallback/offline/CSP use. */
68
84
  ffmpegWasm?: FfmpegWasmLoadConfig;
69
85
  }
86
+ interface ResolvedVideoExportCover {
87
+ showCoverSlide: boolean;
88
+ coverPreRoll: number;
89
+ }
90
+ /** Resolve the cover segment shared by browser MP4/GIF capture. */
91
+ declare function resolveVideoExportCover(doc: Doc, config?: Pick<VideoExportConfig, 'showCoverSlide' | 'coverPreRoll'>): ResolvedVideoExportCover;
70
92
  interface VideoExportResult {
71
93
  /** Current export state */
72
94
  state: VideoExportState;
@@ -74,6 +96,10 @@ interface VideoExportResult {
74
96
  progress: number;
75
97
  /** Human-readable description of the current phase */
76
98
  phase: string;
99
+ /** Current timestamp being captured from the output timeline (seconds). */
100
+ currentFrameTime: number | null;
101
+ /** Rolling throughput over at most the last 30 completed frames. */
102
+ processingFps: number | null;
77
103
  /** Video duration detected from the doc (seconds) */
78
104
  duration: number;
79
105
  /** Effective output format for the current or most recent export. */
@@ -110,6 +136,26 @@ interface VideoExportResult {
110
136
  /** Reset state back to idle (e.g., after complete or error) */
111
137
  reset: () => void;
112
138
  }
113
- declare function useVideoExport(): VideoExportResult;
139
+ interface VideoExportFramePreview {
140
+ /** The raster that is about to be submitted to the encoder. Valid only during the callback. */
141
+ source: EncoderFrameSource;
142
+ /** Zero-based frame index. */
143
+ frameIndex: number;
144
+ /** Total number of frames in the export. */
145
+ totalFrames: number;
146
+ /** Timestamp on the output timeline, in seconds. */
147
+ time: number;
148
+ }
149
+ interface UseVideoExportOptions {
150
+ /**
151
+ * Observe an occasional captured frame without performing another rasterization.
152
+ * The source may be reused or closed as soon as this synchronous callback returns.
153
+ * Observer errors are ignored so display-only work cannot fail an export.
154
+ */
155
+ onFramePreview?: (preview: VideoExportFramePreview) => void;
156
+ /** Preview the first, last, and every Nth frame. Defaults to 1. */
157
+ previewEveryNFrames?: number;
158
+ }
159
+ declare function useVideoExport(options?: UseVideoExportOptions): VideoExportResult;
114
160
 
115
- export { type VideoAudioPolicy as V, type VideoExportConfig as a, type VideoExportResult as b, type VideoExportState as c, type VideoOutputFormat as d, useVideoExport as u };
161
+ export { DEFAULT_VIDEO_COVER_PRE_ROLL_SECONDS as D, type ResolvedVideoExportCover as R, type UseVideoExportOptions as U, type VideoAudioPolicy as V, type VideoExportConfig as a, type VideoExportFramePreview as b, type VideoExportResult as c, type VideoExportState as d, type VideoOutputFormat as e, resolveVideoExportCover as r, useVideoExport as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bendyline/squisq-video-react",
3
- "version": "2.2.3",
3
+ "version": "2.2.5",
4
4
  "description": "React components for browser-based MP4 and animated-GIF export of Squisq documents",
5
5
  "license": "MIT",
6
6
  "author": "Bendyline",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "type": "module",
26
26
  "engines": {
27
- "node": ">=22.14.0"
27
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
28
28
  },
29
29
  "main": "./dist/index.js",
30
30
  "types": "./dist/index.d.ts",
@@ -65,9 +65,9 @@
65
65
  "react-dom": "^18.0.0 || ^19.0.0"
66
66
  },
67
67
  "dependencies": {
68
- "@bendyline/squisq": "2.3.3",
69
- "@bendyline/squisq-video": "2.2.3",
70
- "@bendyline/squisq-react": "2.3.3",
68
+ "@bendyline/squisq": "2.4.1",
69
+ "@bendyline/squisq-video": "2.2.5",
70
+ "@bendyline/squisq-react": "2.4.1",
71
71
  "@ffmpeg/core": "0.12.9",
72
72
  "@ffmpeg/ffmpeg": "0.12.15",
73
73
  "@ffmpeg/util": "0.12.2",