@bendyline/squisq-video-react 2.2.11 → 2.3.1

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
@@ -76,10 +76,15 @@ function App() {
76
76
 
77
77
  ## Components
78
78
 
79
- | Component | Description |
80
- | ------------------- | ---------------------------------------------------------------------------------- |
81
- | `VideoExportModal` | Full modal UI — configure MP4/GIF, captions, motion, quality, fps, and orientation |
82
- | `VideoExportButton` | Drop-in button that opens the export modal via portal |
79
+ | Component | Description |
80
+ | ----------------------- | ---------------------------------------------------------------------------------- |
81
+ | `VideoExportModal` | Full modal UI — configure MP4/GIF, captions, motion, quality, fps, and orientation |
82
+ | `VideoExportButton` | Drop-in button that opens the export modal via portal |
83
+ | `CoverImageExportModal` | Save the managed cover as PNG, JPEG, or WebP with bounded resolution controls |
84
+
85
+ Import `CoverImageExportModal` from
86
+ `@bendyline/squisq-video-react/cover-image` when a surface only needs cover
87
+ capture. This entry point excludes the MP4/GIF encoder worker graph.
83
88
 
84
89
  ## Hooks
85
90
 
@@ -99,6 +104,11 @@ The `VideoExportModal` lets users configure:
99
104
  - **Captions:** off, standard, or social
100
105
  - **Animations & transitions:** enabled by default for MP4 and disabled by default for GIF
101
106
 
107
+ Managed covers inherit `squisq-cover-duration` and `squisq-cover-playback`
108
+ from document frontmatter. `preroll` adds the cover before story frame zero
109
+ and shifts audio; `overlay` keeps the exported duration unchanged while the
110
+ story and audio advance underneath the visible cover.
111
+
102
112
  ## Using the Hook Directly
103
113
 
104
114
  For custom export UIs, use `useVideoExport` directly:
@@ -1,8 +1,9 @@
1
1
  import {
2
+ DEFAULT_MP4_SPILL_THRESHOLD_BYTES,
2
3
  applyWebCodecsBackpressure,
3
4
  createMp4Muxer,
4
5
  resolveWebCodecsQueueLimit
5
- } from "./chunk-I4SXMCDF.js";
6
+ } from "./chunk-5MFQMJ5Z.js";
6
7
 
7
8
  // src/mainThreadEncoder.ts
8
9
  import { bitrateForQuality, validateVideoExportOptions } from "@bendyline/squisq-video";
@@ -43,7 +44,8 @@ function createEncoder(config) {
43
44
  width: config.width,
44
45
  height: config.height,
45
46
  fps: config.fps,
46
- ...config.audio ? { audio: config.audio } : {}
47
+ ...config.audio ? { audio: config.audio } : {},
48
+ ...config.spillOutputToBlob ? { spillToBlobThresholdBytes: DEFAULT_MP4_SPILL_THRESHOLD_BYTES } : {}
47
49
  });
48
50
  let closed = false;
49
51
  let fatalError = null;
@@ -1905,9 +1905,13 @@ ensureNotFinalized_fn = function() {
1905
1905
  };
1906
1906
 
1907
1907
  // src/mp4Mux.ts
1908
+ var DEFAULT_MP4_SPILL_THRESHOLD_BYTES = 32 * 1024 * 1024;
1908
1909
  var ChunkedMp4Output = class {
1909
- constructor() {
1910
+ constructor(spillThresholdBytes = null) {
1911
+ this.spillThresholdBytes = spillThresholdBytes;
1910
1912
  this.writes = [];
1913
+ this.spilled = [];
1914
+ this.bufferedBytes = 0;
1911
1915
  this.length = 0;
1912
1916
  }
1913
1917
  write(data, position) {
@@ -1915,7 +1919,9 @@ var ChunkedMp4Output = class {
1915
1919
  const end = position + owned.byteLength;
1916
1920
  if (position >= this.length) {
1917
1921
  this.writes.push({ position, data: owned });
1922
+ this.bufferedBytes += owned.byteLength;
1918
1923
  this.length = end;
1924
+ this.maybeSpill();
1919
1925
  return;
1920
1926
  }
1921
1927
  const updated = [];
@@ -1941,34 +1947,106 @@ var ChunkedMp4Output = class {
1941
1947
  updated.push({ position, data: owned });
1942
1948
  updated.sort((left, right) => left.position - right.position);
1943
1949
  this.writes = updated;
1950
+ this.bufferedBytes = updated.reduce((sum, write) => sum + write.data.byteLength, 0);
1944
1951
  this.length = Math.max(this.length, end);
1952
+ this.maybeSpill();
1953
+ }
1954
+ overlapsSpilled(write) {
1955
+ const end = write.position + write.data.byteLength;
1956
+ return this.spilled.some(
1957
+ (part) => part.position < end && part.position + part.size > write.position
1958
+ );
1959
+ }
1960
+ /**
1961
+ * Consolidate buffered writes into Blob parts once they exceed the
1962
+ * threshold. Writes overlapping an already-spilled region are patches over
1963
+ * Blob bytes; they stay in memory (they are tiny) and win at assembly.
1964
+ */
1965
+ maybeSpill() {
1966
+ if (this.spillThresholdBytes === null || this.bufferedBytes < this.spillThresholdBytes) return;
1967
+ const spillable = this.writes.filter((write) => !this.overlapsSpilled(write)).sort((left, right) => left.position - right.position);
1968
+ if (spillable.length === 0) return;
1969
+ const keep = new Set(spillable);
1970
+ let run = [];
1971
+ const flushRun = () => {
1972
+ if (run.length === 0) return;
1973
+ const position = run[0].position;
1974
+ const size = run.reduce((sum, write) => sum + write.data.byteLength, 0);
1975
+ this.spilled.push({
1976
+ position,
1977
+ size,
1978
+ blob: new Blob(run.map((write) => write.data))
1979
+ });
1980
+ run = [];
1981
+ };
1982
+ for (const write of spillable) {
1983
+ const previous = run[run.length - 1];
1984
+ if (previous && previous.position + previous.data.byteLength !== write.position) flushRun();
1985
+ run.push(write);
1986
+ }
1987
+ flushRun();
1988
+ this.spilled.sort((left, right) => left.position - right.position);
1989
+ this.writes = this.writes.filter((write) => !keep.has(write));
1990
+ this.bufferedBytes = this.writes.reduce((sum, write) => sum + write.data.byteLength, 0);
1991
+ }
1992
+ /** Regions in position order; in-memory writes take precedence over Blobs. */
1993
+ assembleParts() {
1994
+ const boundaries = /* @__PURE__ */ new Set([0, this.length]);
1995
+ for (const write of this.writes) {
1996
+ boundaries.add(write.position);
1997
+ boundaries.add(write.position + write.data.byteLength);
1998
+ }
1999
+ for (const part of this.spilled) {
2000
+ boundaries.add(part.position);
2001
+ boundaries.add(part.position + part.size);
2002
+ }
2003
+ const sorted = [...boundaries].sort((left, right) => left - right);
2004
+ const parts = [];
2005
+ for (let i = 0; i + 1 < sorted.length; i++) {
2006
+ const start = sorted[i];
2007
+ const end = sorted[i + 1];
2008
+ if (end <= start) continue;
2009
+ const write = this.writes.find(
2010
+ (candidate) => candidate.position <= start && candidate.position + candidate.data.byteLength >= end
2011
+ );
2012
+ if (write) {
2013
+ parts.push(write.data.subarray(start - write.position, end - write.position));
2014
+ continue;
2015
+ }
2016
+ const part = this.spilled.find(
2017
+ (candidate) => candidate.position <= start && candidate.position + candidate.size >= end
2018
+ );
2019
+ if (part) {
2020
+ parts.push(part.blob.slice(start - part.position, end - part.position));
2021
+ continue;
2022
+ }
2023
+ parts.push(new Uint8Array(end - start));
2024
+ }
2025
+ return parts;
1945
2026
  }
1946
2027
  toArrayBuffer() {
2028
+ if (this.spilled.length > 0) {
2029
+ throw new Error("Spilled MP4 output can only finalize to a Blob");
2030
+ }
1947
2031
  const output = new Uint8Array(this.length);
1948
2032
  for (const write of this.writes) output.set(write.data, write.position);
1949
2033
  this.release();
1950
2034
  return output.buffer;
1951
2035
  }
1952
2036
  toBlob() {
1953
- const parts = [];
1954
- let position = 0;
1955
- for (const write of this.writes) {
1956
- if (write.position > position) parts.push(new Uint8Array(write.position - position));
1957
- parts.push(write.data);
1958
- position = write.position + write.data.byteLength;
1959
- }
1960
- if (position < this.length) parts.push(new Uint8Array(this.length - position));
1961
- const blob = new Blob(parts, { type: "video/mp4" });
2037
+ const blob = new Blob(this.assembleParts(), { type: "video/mp4" });
1962
2038
  this.release();
1963
2039
  return blob;
1964
2040
  }
1965
2041
  release() {
1966
2042
  this.writes = [];
2043
+ this.spilled = [];
2044
+ this.bufferedBytes = 0;
1967
2045
  this.length = 0;
1968
2046
  }
1969
2047
  };
1970
2048
  function createMp4Muxer(options) {
1971
- const output = new ChunkedMp4Output();
2049
+ const output = new ChunkedMp4Output(options.spillToBlobThresholdBytes ?? null);
1972
2050
  const target = new StreamTarget({
1973
2051
  onData: (data, position) => output.write(data, position)
1974
2052
  });
@@ -2048,6 +2126,7 @@ function shouldEncodeFfmpegBatch(frameCount, byteLength, fps) {
2048
2126
  }
2049
2127
 
2050
2128
  export {
2129
+ DEFAULT_MP4_SPILL_THRESHOLD_BYTES,
2051
2130
  createMp4Muxer,
2052
2131
  resolveWebCodecsQueueLimit,
2053
2132
  applyWebCodecsBackpressure,
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  useVideoExport
3
- } from "./chunk-F2XUI32B.js";
3
+ } from "./chunk-U3RSDWQL.js";
4
4
 
5
5
  // src/VideoExportModal.tsx
6
6
  import { useState, useCallback, useId, useRef } from "react";
@@ -0,0 +1,401 @@
1
+ import {
2
+ useFrameCapture
3
+ } from "./chunk-YTEDBL6F.js";
4
+
5
+ // src/CoverImageExportModal.tsx
6
+ import { useCallback, useId, useRef, useState } from "react";
7
+ import { useModalDialog } from "@bendyline/squisq-react";
8
+ import { jsx, jsxs } from "react/jsx-runtime";
9
+ var MIN_DIMENSION = 64;
10
+ var MAX_DIMENSION = 7680;
11
+ var MAX_PIXELS = 33177600;
12
+ var COVER_IMAGE_SIZE_PRESETS = [{ label: "YouTube cover", width: 1280, height: 720 }];
13
+ var FORMAT_DETAILS = {
14
+ png: { extension: "png", mime: "image/png", label: "PNG \u2014 lossless" },
15
+ jpeg: { extension: "jpg", mime: "image/jpeg", label: "JPEG \u2014 smaller file" },
16
+ webp: { extension: "webp", mime: "image/webp", label: "WebP \u2014 compact" }
17
+ };
18
+ function validateCoverImageDimensions(width, height) {
19
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) {
20
+ return "Width and height must be whole numbers.";
21
+ }
22
+ if (width < MIN_DIMENSION || height < MIN_DIMENSION || width > MAX_DIMENSION || height > MAX_DIMENSION) {
23
+ return `Width and height must be between ${MIN_DIMENSION} and ${MAX_DIMENSION} pixels.`;
24
+ }
25
+ if (width * height > MAX_PIXELS) {
26
+ return "Resolution is too large. Choose a size at or below 33 megapixels.";
27
+ }
28
+ return null;
29
+ }
30
+ function coverImageFilename(requestedName, format) {
31
+ const extension = FORMAT_DETAILS[format].extension;
32
+ const base = requestedName?.replace(/\.[^.]+$/, "").replace(/[<>:"/\\|?*]/g, "-").split("").map((character) => character.charCodeAt(0) < 32 ? "-" : character).join("").trim().replace(/[. ]+$/g, "") || "document";
33
+ return `${base}-cover.${extension}`;
34
+ }
35
+ function canvasToBlob(canvas, format, quality) {
36
+ return new Promise((resolve, reject) => {
37
+ canvas.toBlob(
38
+ (blob) => {
39
+ if (blob) resolve(blob);
40
+ else reject(new Error("The browser could not encode the cover image."));
41
+ },
42
+ FORMAT_DETAILS[format].mime,
43
+ format === "png" ? void 0 : quality
44
+ );
45
+ });
46
+ }
47
+ async function chooseSaveTarget(filename, format) {
48
+ const picker = window.showSaveFilePicker;
49
+ if (!picker) return void 0;
50
+ const details = FORMAT_DETAILS[format];
51
+ try {
52
+ return await picker.call(window, {
53
+ suggestedName: filename,
54
+ types: [
55
+ {
56
+ description: `${details.label.split(" \u2014")[0]} image`,
57
+ accept: { [details.mime]: [`.${details.extension}`] }
58
+ }
59
+ ]
60
+ });
61
+ } catch (caught) {
62
+ if (caught instanceof DOMException && caught.name === "AbortError") return null;
63
+ throw caught;
64
+ }
65
+ }
66
+ function downloadBlob(blob, filename) {
67
+ const url = URL.createObjectURL(blob);
68
+ const anchor = document.createElement("a");
69
+ anchor.href = url;
70
+ anchor.download = filename;
71
+ document.body.appendChild(anchor);
72
+ anchor.click();
73
+ anchor.remove();
74
+ window.setTimeout(() => URL.revokeObjectURL(url), 0);
75
+ }
76
+ var overlayStyle = {
77
+ position: "fixed",
78
+ inset: 0,
79
+ zIndex: 1e4,
80
+ display: "flex",
81
+ alignItems: "center",
82
+ justifyContent: "center",
83
+ background: "rgba(0, 0, 0, 0.55)"
84
+ };
85
+ var rowStyle = {
86
+ display: "grid",
87
+ gridTemplateColumns: "1fr 1fr",
88
+ gap: 12
89
+ };
90
+ var labelStyle = {
91
+ display: "grid",
92
+ gap: 5,
93
+ marginBottom: 12,
94
+ fontSize: 13,
95
+ fontWeight: 600
96
+ };
97
+ function CoverImageExportModal({
98
+ doc,
99
+ mediaProvider,
100
+ theme,
101
+ coverSlideTemplate,
102
+ defaultWidth = 1920,
103
+ defaultHeight = 1080,
104
+ defaultFileName,
105
+ colorScheme = "light",
106
+ saveOutput,
107
+ onClose
108
+ }) {
109
+ const overlayRef = useRef(null);
110
+ const dialogRef = useRef(null);
111
+ const titleId = useId();
112
+ const capture = useFrameCapture();
113
+ const [format, setFormat] = useState("png");
114
+ const [width, setWidth] = useState(defaultWidth);
115
+ const [height, setHeight] = useState(defaultHeight);
116
+ const [quality, setQuality] = useState(0.92);
117
+ const [busy, setBusy] = useState(false);
118
+ const [error, setError] = useState(null);
119
+ const dark = colorScheme === "dark";
120
+ const surface = dark ? "#111827" : "#ffffff";
121
+ const control = dark ? "#0f172a" : "#ffffff";
122
+ const text = dark ? "#f8fafc" : "#1f2937";
123
+ const muted = dark ? "#94a3b8" : "#6b7280";
124
+ const border = dark ? "#475569" : "#cbd5e1";
125
+ const filename = coverImageFilename(defaultFileName, format);
126
+ const dimensionError = validateCoverImageDimensions(width, height);
127
+ const handleClose = useCallback(() => {
128
+ if (busy) return;
129
+ capture.destroy();
130
+ onClose();
131
+ }, [busy, capture, onClose]);
132
+ useModalDialog({
133
+ rootRef: overlayRef,
134
+ dialogRef,
135
+ closeOnEscape: !busy,
136
+ onClose: handleClose
137
+ });
138
+ const handleExport = useCallback(async () => {
139
+ if (dimensionError || !doc.startBlock) return;
140
+ setError(null);
141
+ try {
142
+ const saveTarget = saveOutput ? void 0 : await chooseSaveTarget(filename, format);
143
+ if (saveTarget === null) return;
144
+ setBusy(true);
145
+ await capture.init(
146
+ doc,
147
+ {
148
+ width,
149
+ height,
150
+ animationsEnabled: false,
151
+ theme,
152
+ mediaProvider: mediaProvider ?? void 0,
153
+ showCoverSlide: true,
154
+ coverSlideTemplate
155
+ },
156
+ "off"
157
+ );
158
+ await capture.setCoverVisible(true);
159
+ const canvas = await capture.captureCanvasFrame(0);
160
+ const blob = await canvasToBlob(canvas, format, quality);
161
+ if (saveOutput) {
162
+ const saved = await saveOutput(blob, filename);
163
+ if (saved === false) {
164
+ capture.destroy();
165
+ return;
166
+ }
167
+ } else if (saveTarget) {
168
+ const writable = await saveTarget.createWritable();
169
+ await writable.write(blob);
170
+ await writable.close();
171
+ } else {
172
+ downloadBlob(blob, filename);
173
+ }
174
+ capture.destroy();
175
+ onClose();
176
+ } catch (caught) {
177
+ capture.destroy();
178
+ setError(caught instanceof Error ? caught.message : "The cover image could not be exported.");
179
+ } finally {
180
+ setBusy(false);
181
+ }
182
+ }, [
183
+ capture,
184
+ coverSlideTemplate,
185
+ dimensionError,
186
+ doc,
187
+ filename,
188
+ format,
189
+ height,
190
+ mediaProvider,
191
+ onClose,
192
+ quality,
193
+ saveOutput,
194
+ theme,
195
+ width
196
+ ]);
197
+ const fieldStyle = {
198
+ boxSizing: "border-box",
199
+ width: "100%",
200
+ minHeight: 34,
201
+ border: `1px solid ${border}`,
202
+ borderRadius: 4,
203
+ background: control,
204
+ color: text,
205
+ padding: "6px 8px",
206
+ colorScheme
207
+ };
208
+ return /* @__PURE__ */ jsx(
209
+ "div",
210
+ {
211
+ ref: overlayRef,
212
+ style: overlayStyle,
213
+ "data-color-scheme": colorScheme,
214
+ onClick: (event) => event.stopPropagation(),
215
+ children: /* @__PURE__ */ jsxs(
216
+ "div",
217
+ {
218
+ ref: dialogRef,
219
+ role: "dialog",
220
+ "aria-modal": "true",
221
+ "aria-labelledby": titleId,
222
+ tabIndex: -1,
223
+ style: {
224
+ position: "relative",
225
+ boxSizing: "border-box",
226
+ width: "min(440px, calc(100vw - 32px))",
227
+ padding: 24,
228
+ border: `1px solid ${border}`,
229
+ borderRadius: 8,
230
+ background: surface,
231
+ color: text,
232
+ boxShadow: "0 18px 48px rgba(0, 0, 0, 0.28)",
233
+ fontFamily: "system-ui, -apple-system, sans-serif",
234
+ colorScheme
235
+ },
236
+ onClick: (event) => event.stopPropagation(),
237
+ children: [
238
+ /* @__PURE__ */ jsx("h2", { id: titleId, style: { margin: "0 36px 18px 0", fontSize: 19 }, children: "Export cover slide" }),
239
+ /* @__PURE__ */ jsx(
240
+ "button",
241
+ {
242
+ type: "button",
243
+ "aria-label": "Close cover image export",
244
+ disabled: busy,
245
+ onClick: handleClose,
246
+ style: {
247
+ position: "absolute",
248
+ top: 12,
249
+ right: 12,
250
+ width: 32,
251
+ height: 32,
252
+ border: 0,
253
+ background: "transparent",
254
+ color: text,
255
+ fontSize: 24,
256
+ cursor: busy ? "default" : "pointer"
257
+ },
258
+ children: /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: "\xD7" })
259
+ }
260
+ ),
261
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
262
+ "Format",
263
+ /* @__PURE__ */ jsx(
264
+ "select",
265
+ {
266
+ "aria-label": "Image format",
267
+ value: format,
268
+ disabled: busy,
269
+ onChange: (event) => setFormat(event.target.value),
270
+ style: fieldStyle,
271
+ children: Object.entries(FORMAT_DETAILS).map(([value, details]) => /* @__PURE__ */ jsx("option", { value, children: details.label }, value))
272
+ }
273
+ )
274
+ ] }),
275
+ /* @__PURE__ */ jsxs("div", { style: rowStyle, children: [
276
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
277
+ "Width",
278
+ /* @__PURE__ */ jsx(
279
+ "input",
280
+ {
281
+ "aria-label": "Image width",
282
+ type: "number",
283
+ min: MIN_DIMENSION,
284
+ max: MAX_DIMENSION,
285
+ step: 1,
286
+ value: width,
287
+ disabled: busy,
288
+ onChange: (event) => setWidth(Number(event.target.value)),
289
+ style: fieldStyle
290
+ }
291
+ )
292
+ ] }),
293
+ /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
294
+ "Height",
295
+ /* @__PURE__ */ jsx(
296
+ "input",
297
+ {
298
+ "aria-label": "Image height",
299
+ type: "number",
300
+ min: MIN_DIMENSION,
301
+ max: MAX_DIMENSION,
302
+ step: 1,
303
+ value: height,
304
+ disabled: busy,
305
+ onChange: (event) => setHeight(Number(event.target.value)),
306
+ style: fieldStyle
307
+ }
308
+ )
309
+ ] })
310
+ ] }),
311
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", flexWrap: "wrap", gap: 8, margin: "-2px 0 14px" }, children: [
312
+ /* @__PURE__ */ jsx(
313
+ "button",
314
+ {
315
+ type: "button",
316
+ disabled: busy,
317
+ onClick: () => {
318
+ setWidth(defaultWidth);
319
+ setHeight(defaultHeight);
320
+ },
321
+ children: "1\xD7"
322
+ }
323
+ ),
324
+ /* @__PURE__ */ jsx(
325
+ "button",
326
+ {
327
+ type: "button",
328
+ disabled: busy,
329
+ onClick: () => {
330
+ setWidth(defaultWidth * 2);
331
+ setHeight(defaultHeight * 2);
332
+ },
333
+ children: "2\xD7"
334
+ }
335
+ ),
336
+ COVER_IMAGE_SIZE_PRESETS.map((preset) => /* @__PURE__ */ jsx(
337
+ "button",
338
+ {
339
+ type: "button",
340
+ disabled: busy,
341
+ title: `${preset.width} \xD7 ${preset.height} pixels`,
342
+ onClick: () => {
343
+ setWidth(preset.width);
344
+ setHeight(preset.height);
345
+ },
346
+ children: preset.label
347
+ },
348
+ preset.label
349
+ )),
350
+ /* @__PURE__ */ jsxs("span", { style: { alignSelf: "center", color: muted, fontSize: 12 }, children: [
351
+ width.toLocaleString(),
352
+ " \xD7 ",
353
+ height.toLocaleString(),
354
+ " pixels"
355
+ ] })
356
+ ] }),
357
+ format !== "png" && /* @__PURE__ */ jsxs("label", { style: labelStyle, children: [
358
+ "Quality: ",
359
+ Math.round(quality * 100),
360
+ "%",
361
+ /* @__PURE__ */ jsx(
362
+ "input",
363
+ {
364
+ "aria-label": "Image quality",
365
+ type: "range",
366
+ min: 0.5,
367
+ max: 1,
368
+ step: 0.05,
369
+ value: quality,
370
+ disabled: busy,
371
+ onChange: (event) => setQuality(Number(event.target.value))
372
+ }
373
+ )
374
+ ] }),
375
+ (dimensionError || error) && /* @__PURE__ */ jsx("p", { role: "alert", style: { margin: "0 0 12px", color: dark ? "#fca5a5" : "#b91c1c" }, children: dimensionError ?? error }),
376
+ !doc.startBlock && /* @__PURE__ */ jsx("p", { role: "alert", style: { margin: "0 0 12px", color: dark ? "#fca5a5" : "#b91c1c" }, children: "This document does not have a cover slide to export." }),
377
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8 }, children: [
378
+ /* @__PURE__ */ jsx("button", { type: "button", disabled: busy, onClick: handleClose, children: "Cancel" }),
379
+ /* @__PURE__ */ jsx(
380
+ "button",
381
+ {
382
+ type: "button",
383
+ disabled: busy || !!dimensionError || !doc.startBlock,
384
+ onClick: () => void handleExport(),
385
+ style: { minWidth: 132 },
386
+ children: busy ? "Rendering\u2026" : "Choose location\u2026"
387
+ }
388
+ )
389
+ ] })
390
+ ]
391
+ }
392
+ )
393
+ }
394
+ );
395
+ }
396
+
397
+ export {
398
+ validateCoverImageDimensions,
399
+ coverImageFilename,
400
+ CoverImageExportModal
401
+ };