@bendyline/squisq-video-react 1.0.0 → 1.0.2
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 +137 -0
- package/dist/VideoExportButton.d.ts +28 -0
- package/dist/VideoExportButton.d.ts.map +1 -0
- package/dist/VideoExportButton.js +18 -0
- package/dist/VideoExportButton.js.map +1 -0
- package/dist/VideoExportModal.d.ts +26 -0
- package/dist/VideoExportModal.d.ts.map +1 -0
- package/dist/VideoExportModal.js +164 -0
- package/dist/VideoExportModal.js.map +1 -0
- package/dist/hooks/useFrameCapture.d.ts +27 -0
- package/dist/hooks/useFrameCapture.d.ts.map +1 -0
- package/dist/hooks/useFrameCapture.js +211 -0
- package/dist/hooks/useFrameCapture.js.map +1 -0
- package/dist/hooks/useVideoExport.d.ts +75 -0
- package/dist/hooks/useVideoExport.d.ts.map +1 -0
- package/dist/hooks/useVideoExport.js +241 -0
- package/dist/hooks/useVideoExport.js.map +1 -0
- package/dist/index.d.ts +19 -171
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -815
- package/dist/index.js.map +1 -1
- package/dist/mainThreadEncoder.d.ts +37 -0
- package/dist/mainThreadEncoder.d.ts.map +1 -0
- package/dist/mainThreadEncoder.js +100 -0
- package/dist/mainThreadEncoder.js.map +1 -0
- package/dist/mp4Mux.d.ts +22 -0
- package/dist/mp4Mux.d.ts.map +1 -0
- package/dist/mp4Mux.js +32 -0
- package/dist/mp4Mux.js.map +1 -0
- package/dist/workers/encode.worker.d.ts +13 -0
- package/dist/workers/encode.worker.d.ts.map +1 -0
- package/dist/workers/encode.worker.js +318 -0
- package/dist/workers/encode.worker.js.map +1 -0
- package/dist/workers/workerTypes.d.ts +63 -0
- package/dist/workers/workerTypes.d.ts.map +1 -0
- package/dist/workers/workerTypes.js +8 -0
- package/dist/workers/workerTypes.js.map +1 -0
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,816 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
addVideoChunk(chunk, meta) {
|
|
23
|
-
muxer.addVideoChunk(chunk, meta);
|
|
24
|
-
},
|
|
25
|
-
finalize() {
|
|
26
|
-
muxer.finalize();
|
|
27
|
-
return target.buffer;
|
|
28
|
-
}
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// src/mainThreadEncoder.ts
|
|
33
|
-
function supportsWebCodecs() {
|
|
34
|
-
return typeof VideoEncoder !== "undefined" && typeof VideoFrame !== "undefined";
|
|
35
|
-
}
|
|
36
|
-
function bitrateForQuality(quality, width, height) {
|
|
37
|
-
const pixels = width * height;
|
|
38
|
-
const baseBitrate = pixels * 4;
|
|
39
|
-
switch (quality) {
|
|
40
|
-
case "draft":
|
|
41
|
-
return Math.round(baseBitrate * 0.5);
|
|
42
|
-
case "high":
|
|
43
|
-
return Math.round(baseBitrate * 2);
|
|
44
|
-
default:
|
|
45
|
-
return baseBitrate;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
function createEncoder(config) {
|
|
49
|
-
if (!supportsWebCodecs()) {
|
|
50
|
-
throw new Error(
|
|
51
|
-
"WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
if (!config.fps || config.fps <= 0 || !config.width || !config.height) {
|
|
55
|
-
throw new Error(
|
|
56
|
-
`Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
const muxer = createMp4Muxer({
|
|
60
|
-
width: config.width,
|
|
61
|
-
height: config.height,
|
|
62
|
-
fps: config.fps
|
|
63
|
-
});
|
|
64
|
-
let closed = false;
|
|
65
|
-
const frameDuration = 1e6 / config.fps;
|
|
66
|
-
const encoder = new VideoEncoder({
|
|
67
|
-
output(chunk, meta) {
|
|
68
|
-
if (closed) return;
|
|
69
|
-
muxer.addVideoChunk(chunk, meta ?? void 0);
|
|
70
|
-
},
|
|
71
|
-
error(err) {
|
|
72
|
-
console.error("WebCodecs encoder error:", err.message);
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
encoder.configure({
|
|
76
|
-
codec: "avc1.640028",
|
|
77
|
-
// H.264 High profile, level 4.0 (supports up to 1080p)
|
|
78
|
-
width: config.width,
|
|
79
|
-
height: config.height,
|
|
80
|
-
bitrate: bitrateForQuality(config.quality, config.width, config.height),
|
|
81
|
-
framerate: config.fps
|
|
82
|
-
});
|
|
83
|
-
return {
|
|
84
|
-
encodeFrame(bitmap, frameIndex) {
|
|
85
|
-
if (closed) {
|
|
86
|
-
bitmap.close();
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const timestamp = Math.round(frameIndex * frameDuration);
|
|
90
|
-
const frame = new VideoFrame(bitmap, { timestamp });
|
|
91
|
-
const keyFrame = frameIndex % 30 === 0;
|
|
92
|
-
encoder.encode(frame, { keyFrame });
|
|
93
|
-
frame.close();
|
|
94
|
-
bitmap.close();
|
|
95
|
-
},
|
|
96
|
-
async finalize() {
|
|
97
|
-
if (closed) throw new Error("Encoder already closed");
|
|
98
|
-
await encoder.flush();
|
|
99
|
-
encoder.close();
|
|
100
|
-
closed = true;
|
|
101
|
-
return muxer.finalize();
|
|
102
|
-
},
|
|
103
|
-
close() {
|
|
104
|
-
if (closed) return;
|
|
105
|
-
closed = true;
|
|
106
|
-
if (encoder.state !== "closed") {
|
|
107
|
-
encoder.close();
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// src/hooks/useFrameCapture.ts
|
|
114
|
-
import { createElement } from "react";
|
|
115
|
-
import { createRoot } from "react-dom/client";
|
|
116
|
-
import { useRef, useCallback, useMemo } from "react";
|
|
117
|
-
import { DocPlayer, MediaContext } from "@bendyline/squisq-react";
|
|
118
|
-
import html2canvas from "html2canvas";
|
|
119
|
-
var MIME_MAP = {
|
|
120
|
-
jpg: "image/jpeg",
|
|
121
|
-
jpeg: "image/jpeg",
|
|
122
|
-
png: "image/png",
|
|
123
|
-
gif: "image/gif",
|
|
124
|
-
webp: "image/webp",
|
|
125
|
-
svg: "image/svg+xml",
|
|
126
|
-
bmp: "image/bmp",
|
|
127
|
-
avif: "image/avif"
|
|
128
|
-
};
|
|
129
|
-
function arrayBufferToDataUrl(buffer, mime) {
|
|
130
|
-
const bytes = new Uint8Array(buffer);
|
|
131
|
-
const chunks = [];
|
|
132
|
-
const CHUNK = 8192;
|
|
133
|
-
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
134
|
-
chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
|
|
135
|
-
}
|
|
136
|
-
return `data:${mime};base64,${btoa(chunks.join(""))}`;
|
|
137
|
-
}
|
|
138
|
-
function createInlineProvider(images) {
|
|
139
|
-
const dataUrls = /* @__PURE__ */ new Map();
|
|
140
|
-
const mimeTypes = /* @__PURE__ */ new Map();
|
|
141
|
-
for (const [path, buffer] of images) {
|
|
142
|
-
const ext = path.split(".").pop()?.toLowerCase() ?? "";
|
|
143
|
-
const mime = MIME_MAP[ext] ?? "application/octet-stream";
|
|
144
|
-
dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
|
|
145
|
-
mimeTypes.set(path, mime);
|
|
146
|
-
}
|
|
147
|
-
return {
|
|
148
|
-
async resolveUrl(relativePath) {
|
|
149
|
-
return dataUrls.get(relativePath) ?? relativePath;
|
|
150
|
-
},
|
|
151
|
-
async listMedia() {
|
|
152
|
-
return [...dataUrls.keys()].map((name) => ({
|
|
153
|
-
name,
|
|
154
|
-
mimeType: mimeTypes.get(name) ?? "application/octet-stream",
|
|
155
|
-
size: 0
|
|
156
|
-
}));
|
|
157
|
-
},
|
|
158
|
-
async addMedia() {
|
|
159
|
-
throw new Error("Read-only");
|
|
160
|
-
},
|
|
161
|
-
async removeMedia() {
|
|
162
|
-
throw new Error("Read-only");
|
|
163
|
-
},
|
|
164
|
-
dispose() {
|
|
165
|
-
}
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
function useFrameCapture() {
|
|
169
|
-
const containerRef = useRef(null);
|
|
170
|
-
const rootRef = useRef(null);
|
|
171
|
-
const dimensionsRef = useRef({ width: 1920, height: 1080 });
|
|
172
|
-
const init = useCallback(
|
|
173
|
-
async (doc, renderOptions, captionMode) => {
|
|
174
|
-
if (rootRef.current || containerRef.current) {
|
|
175
|
-
const oldRoot = rootRef.current;
|
|
176
|
-
const oldContainer = containerRef.current;
|
|
177
|
-
rootRef.current = null;
|
|
178
|
-
containerRef.current = null;
|
|
179
|
-
await new Promise((resolve) => {
|
|
180
|
-
setTimeout(() => {
|
|
181
|
-
if (oldRoot) oldRoot.unmount();
|
|
182
|
-
if (oldContainer) oldContainer.remove();
|
|
183
|
-
resolve();
|
|
184
|
-
}, 0);
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
const width = renderOptions.width ?? 1920;
|
|
188
|
-
const height = renderOptions.height ?? 1080;
|
|
189
|
-
dimensionsRef.current = { width, height };
|
|
190
|
-
const container = document.createElement("div");
|
|
191
|
-
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;`;
|
|
192
|
-
document.body.appendChild(container);
|
|
193
|
-
containerRef.current = container;
|
|
194
|
-
const renderRoot = document.createElement("div");
|
|
195
|
-
renderRoot.id = "squisq-capture-root";
|
|
196
|
-
renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
|
|
197
|
-
container.appendChild(renderRoot);
|
|
198
|
-
const mediaProvider = renderOptions.images ? createInlineProvider(renderOptions.images) : null;
|
|
199
|
-
const root = createRoot(renderRoot);
|
|
200
|
-
rootRef.current = root;
|
|
201
|
-
const captionsEnabled = captionMode !== void 0 && captionMode !== "off";
|
|
202
|
-
const captionStyle = captionMode === "social" ? "social" : "standard";
|
|
203
|
-
const playerElement = createElement(DocPlayer, {
|
|
204
|
-
script: doc,
|
|
205
|
-
basePath: ".",
|
|
206
|
-
renderMode: true,
|
|
207
|
-
showControls: false,
|
|
208
|
-
autoPlay: false,
|
|
209
|
-
forceViewport: { width, height, name: "export" },
|
|
210
|
-
captionsEnabled,
|
|
211
|
-
captionStyle
|
|
212
|
-
});
|
|
213
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
214
|
-
if (mediaProvider) {
|
|
215
|
-
root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
|
|
216
|
-
} else {
|
|
217
|
-
root.render(playerElement);
|
|
218
|
-
}
|
|
219
|
-
return new Promise((resolve, reject) => {
|
|
220
|
-
const timeout = setTimeout(() => {
|
|
221
|
-
const w = window;
|
|
222
|
-
const hasSeek = typeof w.seekTo === "function";
|
|
223
|
-
const hasDur = typeof w.getDuration === "function";
|
|
224
|
-
const rootEl = containerRef.current?.querySelector("#squisq-capture-root");
|
|
225
|
-
const hasPlayer = rootEl ? rootEl.querySelector(".doc-player") !== null : false;
|
|
226
|
-
reject(
|
|
227
|
-
new Error(
|
|
228
|
-
`Render API did not initialize within 15s. seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`
|
|
229
|
-
)
|
|
230
|
-
);
|
|
231
|
-
}, 15e3);
|
|
232
|
-
const checkApi = () => {
|
|
233
|
-
const w = window;
|
|
234
|
-
if (typeof w.getDuration === "function" && typeof w.seekTo === "function") {
|
|
235
|
-
clearTimeout(timeout);
|
|
236
|
-
const duration = w.getDuration();
|
|
237
|
-
resolve(duration);
|
|
238
|
-
} else {
|
|
239
|
-
requestAnimationFrame(checkApi);
|
|
240
|
-
}
|
|
241
|
-
};
|
|
242
|
-
setTimeout(checkApi, 500);
|
|
243
|
-
});
|
|
244
|
-
},
|
|
245
|
-
[]
|
|
246
|
-
);
|
|
247
|
-
const captureFrame = useCallback(async (time) => {
|
|
248
|
-
const container = containerRef.current;
|
|
249
|
-
const w = window;
|
|
250
|
-
if (!container || typeof w.seekTo !== "function") {
|
|
251
|
-
throw new Error("Frame capture not initialized \u2014 call init() first");
|
|
252
|
-
}
|
|
253
|
-
const { width, height } = dimensionsRef.current;
|
|
254
|
-
await w.seekTo(time);
|
|
255
|
-
await new Promise(
|
|
256
|
-
(resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
|
257
|
-
);
|
|
258
|
-
const root = container.querySelector("#squisq-capture-root");
|
|
259
|
-
if (!root) {
|
|
260
|
-
throw new Error("Capture root element not found");
|
|
261
|
-
}
|
|
262
|
-
const canvas = await html2canvas(root, {
|
|
263
|
-
width,
|
|
264
|
-
height,
|
|
265
|
-
scale: 1,
|
|
266
|
-
useCORS: true,
|
|
267
|
-
allowTaint: true,
|
|
268
|
-
backgroundColor: "#000000",
|
|
269
|
-
logging: false
|
|
270
|
-
});
|
|
271
|
-
const bitmap = await createImageBitmap(canvas);
|
|
272
|
-
return bitmap;
|
|
273
|
-
}, []);
|
|
274
|
-
const destroy = useCallback(() => {
|
|
275
|
-
if (rootRef.current) {
|
|
276
|
-
rootRef.current.unmount();
|
|
277
|
-
rootRef.current = null;
|
|
278
|
-
}
|
|
279
|
-
if (containerRef.current) {
|
|
280
|
-
containerRef.current.remove();
|
|
281
|
-
containerRef.current = null;
|
|
282
|
-
}
|
|
283
|
-
}, []);
|
|
284
|
-
return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// src/hooks/useVideoExport.ts
|
|
288
|
-
function useVideoExport() {
|
|
289
|
-
const [state, setState] = useState("idle");
|
|
290
|
-
const [progress, setProgress] = useState(0);
|
|
291
|
-
const [phase, setPhase] = useState("");
|
|
292
|
-
const [duration, setDuration] = useState(0);
|
|
293
|
-
const [backend, setBackend] = useState(null);
|
|
294
|
-
const [downloadUrl, setDownloadUrl] = useState(null);
|
|
295
|
-
const [fileSize, setFileSize] = useState(0);
|
|
296
|
-
const [error, setError] = useState(null);
|
|
297
|
-
const [elapsed, setElapsed] = useState(0);
|
|
298
|
-
const [estimatedRemaining, setEstimatedRemaining] = useState(0);
|
|
299
|
-
const encoderRef = useRef2(null);
|
|
300
|
-
const cancelledRef = useRef2(false);
|
|
301
|
-
const downloadUrlRef = useRef2(null);
|
|
302
|
-
const startTimeRef = useRef2(0);
|
|
303
|
-
const elapsedTimerRef = useRef2(null);
|
|
304
|
-
const frameCapture = useFrameCapture();
|
|
305
|
-
useEffect(() => {
|
|
306
|
-
return () => {
|
|
307
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
308
|
-
if (downloadUrlRef.current) {
|
|
309
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
310
|
-
}
|
|
311
|
-
if (encoderRef.current) {
|
|
312
|
-
encoderRef.current.close();
|
|
313
|
-
}
|
|
314
|
-
frameCapture.destroy();
|
|
315
|
-
};
|
|
316
|
-
}, [frameCapture]);
|
|
317
|
-
const reset = useCallback2(() => {
|
|
318
|
-
if (downloadUrlRef.current) {
|
|
319
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
320
|
-
downloadUrlRef.current = null;
|
|
321
|
-
}
|
|
322
|
-
if (encoderRef.current) {
|
|
323
|
-
encoderRef.current.close();
|
|
324
|
-
encoderRef.current = null;
|
|
325
|
-
}
|
|
326
|
-
frameCapture.destroy();
|
|
327
|
-
setState("idle");
|
|
328
|
-
setProgress(0);
|
|
329
|
-
setPhase("");
|
|
330
|
-
setDuration(0);
|
|
331
|
-
setBackend(null);
|
|
332
|
-
setDownloadUrl(null);
|
|
333
|
-
setFileSize(0);
|
|
334
|
-
setError(null);
|
|
335
|
-
setElapsed(0);
|
|
336
|
-
setEstimatedRemaining(0);
|
|
337
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
338
|
-
cancelledRef.current = false;
|
|
339
|
-
}, [frameCapture]);
|
|
340
|
-
const cancel = useCallback2(() => {
|
|
341
|
-
cancelledRef.current = true;
|
|
342
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
343
|
-
if (encoderRef.current) {
|
|
344
|
-
encoderRef.current.close();
|
|
345
|
-
encoderRef.current = null;
|
|
346
|
-
}
|
|
347
|
-
frameCapture.destroy();
|
|
348
|
-
setState("idle");
|
|
349
|
-
setProgress(0);
|
|
350
|
-
setPhase("Cancelled");
|
|
351
|
-
}, [frameCapture]);
|
|
352
|
-
const startExport = useCallback2(
|
|
353
|
-
async (doc, config) => {
|
|
354
|
-
cancelledRef.current = false;
|
|
355
|
-
if (downloadUrlRef.current) {
|
|
356
|
-
URL.revokeObjectURL(downloadUrlRef.current);
|
|
357
|
-
downloadUrlRef.current = null;
|
|
358
|
-
}
|
|
359
|
-
setDownloadUrl(null);
|
|
360
|
-
setFileSize(0);
|
|
361
|
-
setError(null);
|
|
362
|
-
const quality = config.quality ?? "normal";
|
|
363
|
-
const fps = config.fps ?? 30;
|
|
364
|
-
const orientation = config.orientation ?? "landscape";
|
|
365
|
-
const { width, height } = resolveDimensions({ orientation });
|
|
366
|
-
try {
|
|
367
|
-
if (!supportsWebCodecs()) {
|
|
368
|
-
throw new Error(
|
|
369
|
-
"WebCodecs is not available in this browser. Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser."
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
setState("preparing");
|
|
373
|
-
setPhase("Loading document\u2026");
|
|
374
|
-
setProgress(0);
|
|
375
|
-
setElapsed(0);
|
|
376
|
-
setEstimatedRemaining(0);
|
|
377
|
-
startTimeRef.current = performance.now();
|
|
378
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
379
|
-
elapsedTimerRef.current = setInterval(() => {
|
|
380
|
-
setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1e3));
|
|
381
|
-
}, 1e3);
|
|
382
|
-
let images = config.images;
|
|
383
|
-
if (!images && config.mediaProvider) {
|
|
384
|
-
images = /* @__PURE__ */ new Map();
|
|
385
|
-
const entries = await config.mediaProvider.listMedia();
|
|
386
|
-
for (const entry of entries) {
|
|
387
|
-
const url2 = await config.mediaProvider.resolveUrl(entry.name);
|
|
388
|
-
const res = await fetch(url2);
|
|
389
|
-
if (res.ok) {
|
|
390
|
-
const data = await res.arrayBuffer();
|
|
391
|
-
images.set(entry.name, data);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
const docDuration = await frameCapture.init(
|
|
396
|
-
doc,
|
|
397
|
-
{ images, audio: config.audio, width, height },
|
|
398
|
-
config.captionMode
|
|
399
|
-
);
|
|
400
|
-
if (cancelledRef.current) return;
|
|
401
|
-
setDuration(docDuration);
|
|
402
|
-
if (docDuration <= 0) {
|
|
403
|
-
throw new Error("Document has zero duration \u2014 nothing to export");
|
|
404
|
-
}
|
|
405
|
-
setPhase("Starting encoder\u2026");
|
|
406
|
-
setProgress(5);
|
|
407
|
-
const encoder = createEncoder({ width, height, fps, quality });
|
|
408
|
-
encoderRef.current = encoder;
|
|
409
|
-
setBackend("webcodecs");
|
|
410
|
-
if (cancelledRef.current) return;
|
|
411
|
-
setState("capturing");
|
|
412
|
-
const totalFrames = Math.ceil(docDuration * fps);
|
|
413
|
-
const captureStartTime = performance.now();
|
|
414
|
-
const UI_UPDATE_INTERVAL = 10;
|
|
415
|
-
for (let i = 0; i < totalFrames; i++) {
|
|
416
|
-
if (cancelledRef.current) return;
|
|
417
|
-
const time = i / fps;
|
|
418
|
-
if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {
|
|
419
|
-
const captureProgress = Math.round(i / totalFrames * 90);
|
|
420
|
-
setProgress(5 + captureProgress);
|
|
421
|
-
setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);
|
|
422
|
-
if (i > 0) {
|
|
423
|
-
const elapsedCapture = (performance.now() - captureStartTime) / 1e3;
|
|
424
|
-
const avgPerFrame = elapsedCapture / i;
|
|
425
|
-
const remaining = Math.round(avgPerFrame * (totalFrames - i));
|
|
426
|
-
setEstimatedRemaining(remaining);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
const bitmap = await frameCapture.captureFrame(time);
|
|
430
|
-
if (cancelledRef.current) {
|
|
431
|
-
bitmap.close();
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
encoder.encodeFrame(bitmap, i);
|
|
435
|
-
}
|
|
436
|
-
if (cancelledRef.current) return;
|
|
437
|
-
setState("encoding");
|
|
438
|
-
setPhase("Finalizing video\u2026");
|
|
439
|
-
setProgress(95);
|
|
440
|
-
const mp4Buffer = await encoder.finalize();
|
|
441
|
-
encoderRef.current = null;
|
|
442
|
-
if (cancelledRef.current) return;
|
|
443
|
-
const blob = new Blob([mp4Buffer], { type: "video/mp4" });
|
|
444
|
-
const url = URL.createObjectURL(blob);
|
|
445
|
-
downloadUrlRef.current = url;
|
|
446
|
-
setDownloadUrl(url);
|
|
447
|
-
setFileSize(mp4Buffer.byteLength);
|
|
448
|
-
setState("complete");
|
|
449
|
-
setProgress(100);
|
|
450
|
-
setPhase("Export complete");
|
|
451
|
-
setEstimatedRemaining(0);
|
|
452
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
453
|
-
frameCapture.destroy();
|
|
454
|
-
} catch (err) {
|
|
455
|
-
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
|
456
|
-
if (cancelledRef.current) return;
|
|
457
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
458
|
-
setState("error");
|
|
459
|
-
setError(message);
|
|
460
|
-
setPhase("Export failed");
|
|
461
|
-
if (encoderRef.current) {
|
|
462
|
-
encoderRef.current.close();
|
|
463
|
-
encoderRef.current = null;
|
|
464
|
-
}
|
|
465
|
-
frameCapture.destroy();
|
|
466
|
-
}
|
|
467
|
-
},
|
|
468
|
-
[frameCapture]
|
|
469
|
-
);
|
|
470
|
-
return {
|
|
471
|
-
state,
|
|
472
|
-
progress,
|
|
473
|
-
phase,
|
|
474
|
-
duration,
|
|
475
|
-
backend,
|
|
476
|
-
downloadUrl,
|
|
477
|
-
fileSize,
|
|
478
|
-
error,
|
|
479
|
-
elapsed,
|
|
480
|
-
estimatedRemaining,
|
|
481
|
-
startExport,
|
|
482
|
-
cancel,
|
|
483
|
-
reset
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// src/VideoExportModal.tsx
|
|
488
|
-
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
489
|
-
function formatDuration(seconds) {
|
|
490
|
-
if (seconds < 60) return `${seconds}s`;
|
|
491
|
-
const m = Math.floor(seconds / 60);
|
|
492
|
-
const s = seconds % 60;
|
|
493
|
-
return `${m}m ${s}s`;
|
|
494
|
-
}
|
|
495
|
-
var overlayStyle = {
|
|
496
|
-
position: "fixed",
|
|
497
|
-
inset: 0,
|
|
498
|
-
background: "rgba(0, 0, 0, 0.5)",
|
|
499
|
-
display: "flex",
|
|
500
|
-
alignItems: "center",
|
|
501
|
-
justifyContent: "center",
|
|
502
|
-
zIndex: 1e4
|
|
503
|
-
};
|
|
504
|
-
var modalStyle = {
|
|
505
|
-
background: "#FFFDF7",
|
|
506
|
-
border: "1px solid #c9b98a",
|
|
507
|
-
borderRadius: 0,
|
|
508
|
-
padding: "24px 28px",
|
|
509
|
-
minWidth: 380,
|
|
510
|
-
maxWidth: 480,
|
|
511
|
-
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
|
512
|
-
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
513
|
-
color: "#4a3c1f"
|
|
514
|
-
};
|
|
515
|
-
var titleStyle = {
|
|
516
|
-
margin: "0 0 16px 0",
|
|
517
|
-
fontSize: 18,
|
|
518
|
-
fontWeight: 600,
|
|
519
|
-
color: "#2d2310"
|
|
520
|
-
};
|
|
521
|
-
var labelStyle = {
|
|
522
|
-
display: "block",
|
|
523
|
-
fontSize: 13,
|
|
524
|
-
fontWeight: 500,
|
|
525
|
-
marginBottom: 4,
|
|
526
|
-
color: "#5a4a2a"
|
|
527
|
-
};
|
|
528
|
-
var selectStyle = {
|
|
529
|
-
width: "100%",
|
|
530
|
-
padding: "6px 8px",
|
|
531
|
-
fontSize: 13,
|
|
532
|
-
fontFamily: "inherit",
|
|
533
|
-
border: "1px solid #c9b98a",
|
|
534
|
-
borderRadius: 0,
|
|
535
|
-
background: "#fff",
|
|
536
|
-
color: "#4a3c1f",
|
|
537
|
-
marginBottom: 12
|
|
538
|
-
};
|
|
539
|
-
var btnPrimary = {
|
|
540
|
-
padding: "8px 20px",
|
|
541
|
-
fontSize: 14,
|
|
542
|
-
fontFamily: "inherit",
|
|
543
|
-
fontWeight: 500,
|
|
544
|
-
cursor: "pointer",
|
|
545
|
-
background: "#8B6914",
|
|
546
|
-
color: "#fff",
|
|
547
|
-
border: "1px solid #7a5c10",
|
|
548
|
-
borderRadius: 0
|
|
549
|
-
};
|
|
550
|
-
var btnSecondary = {
|
|
551
|
-
padding: "8px 20px",
|
|
552
|
-
fontSize: 14,
|
|
553
|
-
fontFamily: "inherit",
|
|
554
|
-
fontWeight: 500,
|
|
555
|
-
cursor: "pointer",
|
|
556
|
-
background: "#E8DFC6",
|
|
557
|
-
color: "#4a3c1f",
|
|
558
|
-
border: "1px solid #c9b98a",
|
|
559
|
-
borderRadius: 0
|
|
560
|
-
};
|
|
561
|
-
var progressBarOuterStyle = {
|
|
562
|
-
width: "100%",
|
|
563
|
-
height: 8,
|
|
564
|
-
background: "#E8DFC6",
|
|
565
|
-
borderRadius: 0,
|
|
566
|
-
overflow: "hidden",
|
|
567
|
-
marginBottom: 8
|
|
568
|
-
};
|
|
569
|
-
var footerStyle = {
|
|
570
|
-
display: "flex",
|
|
571
|
-
justifyContent: "flex-end",
|
|
572
|
-
gap: 8,
|
|
573
|
-
marginTop: 20
|
|
574
|
-
};
|
|
575
|
-
function VideoExportModal({
|
|
576
|
-
doc,
|
|
577
|
-
playerScript,
|
|
578
|
-
mediaProvider,
|
|
579
|
-
images,
|
|
580
|
-
audio,
|
|
581
|
-
onClose
|
|
582
|
-
}) {
|
|
583
|
-
const [quality, setQuality] = useState2("normal");
|
|
584
|
-
const [fps, setFps] = useState2(24);
|
|
585
|
-
const [orientation, setOrientation] = useState2("landscape");
|
|
586
|
-
const [captionMode, setCaptionMode] = useState2("off");
|
|
587
|
-
const exportHook = useVideoExport();
|
|
588
|
-
const {
|
|
589
|
-
state,
|
|
590
|
-
progress,
|
|
591
|
-
backend,
|
|
592
|
-
downloadUrl,
|
|
593
|
-
fileSize,
|
|
594
|
-
error,
|
|
595
|
-
elapsed,
|
|
596
|
-
estimatedRemaining,
|
|
597
|
-
startExport,
|
|
598
|
-
cancel: cancelExport,
|
|
599
|
-
reset: resetExport
|
|
600
|
-
} = exportHook;
|
|
601
|
-
const handleExport = useCallback3(async () => {
|
|
602
|
-
const config = {
|
|
603
|
-
quality,
|
|
604
|
-
fps,
|
|
605
|
-
orientation,
|
|
606
|
-
captionMode,
|
|
607
|
-
images,
|
|
608
|
-
audio,
|
|
609
|
-
mediaProvider,
|
|
610
|
-
playerScript
|
|
611
|
-
};
|
|
612
|
-
await startExport(doc, config);
|
|
613
|
-
}, [
|
|
614
|
-
doc,
|
|
615
|
-
quality,
|
|
616
|
-
fps,
|
|
617
|
-
orientation,
|
|
618
|
-
captionMode,
|
|
619
|
-
images,
|
|
620
|
-
audio,
|
|
621
|
-
mediaProvider,
|
|
622
|
-
playerScript,
|
|
623
|
-
startExport
|
|
624
|
-
]);
|
|
625
|
-
const handleDownload = useCallback3(() => {
|
|
626
|
-
if (!downloadUrl) return;
|
|
627
|
-
const a = document.createElement("a");
|
|
628
|
-
a.href = downloadUrl;
|
|
629
|
-
const ts = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
630
|
-
a.download = `document-${ts}.mp4`;
|
|
631
|
-
document.body.appendChild(a);
|
|
632
|
-
a.click();
|
|
633
|
-
document.body.removeChild(a);
|
|
634
|
-
}, [downloadUrl]);
|
|
635
|
-
const handleClose = useCallback3(() => {
|
|
636
|
-
if (state === "capturing" || state === "encoding" || state === "preparing") {
|
|
637
|
-
cancelExport();
|
|
638
|
-
}
|
|
639
|
-
resetExport();
|
|
640
|
-
onClose();
|
|
641
|
-
}, [state, cancelExport, resetExport, onClose]);
|
|
642
|
-
const isExporting = state === "preparing" || state === "capturing" || state === "encoding";
|
|
643
|
-
return /* @__PURE__ */ jsx("div", { style: overlayStyle, onClick: handleClose, children: /* @__PURE__ */ jsxs("div", { style: modalStyle, onClick: (e) => e.stopPropagation(), children: [
|
|
644
|
-
/* @__PURE__ */ jsx("h2", { style: titleStyle, children: "Export Video" }),
|
|
645
|
-
state === "idle" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
646
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
647
|
-
/* @__PURE__ */ jsx("label", { style: labelStyle, children: "Quality" }),
|
|
648
|
-
/* @__PURE__ */ jsxs(
|
|
649
|
-
"select",
|
|
650
|
-
{
|
|
651
|
-
style: selectStyle,
|
|
652
|
-
value: quality,
|
|
653
|
-
onChange: (e) => setQuality(e.target.value),
|
|
654
|
-
children: [
|
|
655
|
-
/* @__PURE__ */ jsx("option", { value: "draft", children: "Draft \u2014 fast, lower quality" }),
|
|
656
|
-
/* @__PURE__ */ jsx("option", { value: "normal", children: "Normal \u2014 balanced" }),
|
|
657
|
-
/* @__PURE__ */ jsx("option", { value: "high", children: "High \u2014 best quality, slower" })
|
|
658
|
-
]
|
|
659
|
-
}
|
|
660
|
-
)
|
|
661
|
-
] }),
|
|
662
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
663
|
-
/* @__PURE__ */ jsx("label", { style: labelStyle, children: "Frame Rate" }),
|
|
664
|
-
/* @__PURE__ */ jsxs(
|
|
665
|
-
"select",
|
|
666
|
-
{
|
|
667
|
-
style: selectStyle,
|
|
668
|
-
value: fps,
|
|
669
|
-
onChange: (e) => setFps(Number(e.target.value)),
|
|
670
|
-
children: [
|
|
671
|
-
/* @__PURE__ */ jsx("option", { value: 15, children: "15 fps \u2014 fast export" }),
|
|
672
|
-
/* @__PURE__ */ jsx("option", { value: 24, children: "24 fps \u2014 cinematic" }),
|
|
673
|
-
/* @__PURE__ */ jsx("option", { value: 30, children: "30 fps \u2014 smooth" })
|
|
674
|
-
]
|
|
675
|
-
}
|
|
676
|
-
)
|
|
677
|
-
] }),
|
|
678
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
679
|
-
/* @__PURE__ */ jsx("label", { style: labelStyle, children: "Orientation" }),
|
|
680
|
-
/* @__PURE__ */ jsxs(
|
|
681
|
-
"select",
|
|
682
|
-
{
|
|
683
|
-
style: selectStyle,
|
|
684
|
-
value: orientation,
|
|
685
|
-
onChange: (e) => setOrientation(e.target.value),
|
|
686
|
-
children: [
|
|
687
|
-
/* @__PURE__ */ jsx("option", { value: "landscape", children: "Landscape (1920 \xD7 1080)" }),
|
|
688
|
-
/* @__PURE__ */ jsx("option", { value: "portrait", children: "Portrait (1080 \xD7 1920)" })
|
|
689
|
-
]
|
|
690
|
-
}
|
|
691
|
-
)
|
|
692
|
-
] }),
|
|
693
|
-
/* @__PURE__ */ jsxs("div", { children: [
|
|
694
|
-
/* @__PURE__ */ jsx("label", { style: labelStyle, children: "Captions" }),
|
|
695
|
-
/* @__PURE__ */ jsxs(
|
|
696
|
-
"select",
|
|
697
|
-
{
|
|
698
|
-
style: selectStyle,
|
|
699
|
-
value: captionMode,
|
|
700
|
-
onChange: (e) => setCaptionMode(e.target.value),
|
|
701
|
-
children: [
|
|
702
|
-
/* @__PURE__ */ jsx("option", { value: "off", children: "None" }),
|
|
703
|
-
/* @__PURE__ */ jsx("option", { value: "standard", children: "Standard (top bar)" }),
|
|
704
|
-
/* @__PURE__ */ jsx("option", { value: "social", children: "Social media (large words)" })
|
|
705
|
-
]
|
|
706
|
-
}
|
|
707
|
-
)
|
|
708
|
-
] }),
|
|
709
|
-
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
710
|
-
/* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Cancel" }),
|
|
711
|
-
/* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Export Video" })
|
|
712
|
-
] })
|
|
713
|
-
] }),
|
|
714
|
-
isExporting && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
715
|
-
backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 8px 0" }, children: "Encoder: WebCodecs (H.264)" }),
|
|
716
|
-
/* @__PURE__ */ jsx("div", { style: progressBarOuterStyle, children: /* @__PURE__ */ jsx(
|
|
717
|
-
"div",
|
|
718
|
-
{
|
|
719
|
-
style: {
|
|
720
|
-
width: `${progress}%`,
|
|
721
|
-
height: "100%",
|
|
722
|
-
background: "#8B6914",
|
|
723
|
-
transition: "width 0.3s ease"
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
) }),
|
|
727
|
-
/* @__PURE__ */ jsxs("p", { style: { fontSize: 13, margin: "0 0 4px 0" }, children: [
|
|
728
|
-
progress,
|
|
729
|
-
"% complete"
|
|
730
|
-
] }),
|
|
731
|
-
/* @__PURE__ */ jsxs("p", { style: { fontSize: 12, color: "#8a7a5a", margin: 0 }, children: [
|
|
732
|
-
formatDuration(elapsed),
|
|
733
|
-
" elapsed",
|
|
734
|
-
estimatedRemaining > 0 && ` \xB7 ~${formatDuration(estimatedRemaining)} remaining`
|
|
735
|
-
] }),
|
|
736
|
-
/* @__PURE__ */ jsx("div", { style: footerStyle, children: /* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: cancelExport, children: "Cancel" }) })
|
|
737
|
-
] }),
|
|
738
|
-
state === "complete" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
739
|
-
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#2d6a10" }, children: "Export complete!" }),
|
|
740
|
-
/* @__PURE__ */ jsxs("p", { style: { fontSize: 13, color: "#5a4a2a", margin: "0 0 4px 0" }, children: [
|
|
741
|
-
"File size: ",
|
|
742
|
-
(fileSize / (1024 * 1024)).toFixed(1),
|
|
743
|
-
" MB"
|
|
744
|
-
] }),
|
|
745
|
-
backend && /* @__PURE__ */ jsx("p", { style: { fontSize: 12, color: "#8a7a5a", margin: "0 0 12px 0" }, children: "Encoded with WebCodecs (H.264)" }),
|
|
746
|
-
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
747
|
-
/* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
|
|
748
|
-
/* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleDownload, children: "Download MP4" })
|
|
749
|
-
] })
|
|
750
|
-
] }),
|
|
751
|
-
state === "error" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
752
|
-
/* @__PURE__ */ jsx("p", { style: { fontSize: 14, margin: "0 0 8px 0", color: "#a03020" }, children: "Export failed" }),
|
|
753
|
-
/* @__PURE__ */ jsx(
|
|
754
|
-
"p",
|
|
755
|
-
{
|
|
756
|
-
style: {
|
|
757
|
-
fontSize: 13,
|
|
758
|
-
color: "#5a4a2a",
|
|
759
|
-
margin: "0 0 12px 0",
|
|
760
|
-
wordBreak: "break-word"
|
|
761
|
-
},
|
|
762
|
-
children: error
|
|
763
|
-
}
|
|
764
|
-
),
|
|
765
|
-
/* @__PURE__ */ jsxs("div", { style: footerStyle, children: [
|
|
766
|
-
/* @__PURE__ */ jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }),
|
|
767
|
-
/* @__PURE__ */ jsx("button", { style: btnPrimary, onClick: handleExport, children: "Retry" })
|
|
768
|
-
] })
|
|
769
|
-
] })
|
|
770
|
-
] }) });
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
// src/VideoExportButton.tsx
|
|
774
|
-
import { useState as useState3, useCallback as useCallback4 } from "react";
|
|
775
|
-
import { createPortal } from "react-dom";
|
|
776
|
-
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
777
|
-
function VideoExportButton({
|
|
778
|
-
doc,
|
|
779
|
-
playerScript,
|
|
780
|
-
mediaProvider,
|
|
781
|
-
images,
|
|
782
|
-
audio,
|
|
783
|
-
label = "Export Video",
|
|
784
|
-
style,
|
|
785
|
-
disabled
|
|
786
|
-
}) {
|
|
787
|
-
const [showModal, setShowModal] = useState3(false);
|
|
788
|
-
const handleOpen = useCallback4(() => setShowModal(true), []);
|
|
789
|
-
const handleClose = useCallback4(() => setShowModal(false), []);
|
|
790
|
-
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
791
|
-
/* @__PURE__ */ jsx2("button", { onClick: handleOpen, disabled, style, children: label }),
|
|
792
|
-
showModal && createPortal(
|
|
793
|
-
/* @__PURE__ */ jsx2(
|
|
794
|
-
VideoExportModal,
|
|
795
|
-
{
|
|
796
|
-
doc,
|
|
797
|
-
playerScript,
|
|
798
|
-
mediaProvider,
|
|
799
|
-
images,
|
|
800
|
-
audio,
|
|
801
|
-
onClose: handleClose
|
|
802
|
-
}
|
|
803
|
-
),
|
|
804
|
-
document.body
|
|
805
|
-
)
|
|
806
|
-
] });
|
|
807
|
-
}
|
|
808
|
-
export {
|
|
809
|
-
VideoExportButton,
|
|
810
|
-
VideoExportModal,
|
|
811
|
-
createEncoder,
|
|
812
|
-
supportsWebCodecs,
|
|
813
|
-
useFrameCapture,
|
|
814
|
-
useVideoExport
|
|
815
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* @bendyline/squisq-video-react — Browser Video Export for Squisq Documents
|
|
3
|
+
*
|
|
4
|
+
* Provides React components and hooks for exporting Squisq documents
|
|
5
|
+
* to MP4 video directly in the browser.
|
|
6
|
+
*
|
|
7
|
+
* - VideoExportModal: Full modal UI for configure → export → download
|
|
8
|
+
* - VideoExportButton: Drop-in button that opens the modal
|
|
9
|
+
* - useVideoExport: Low-level hook for custom UIs
|
|
10
|
+
* - useFrameCapture: Frame capture via hidden iframe + html2canvas
|
|
11
|
+
*
|
|
12
|
+
* Encoding: WebCodecs (H.264 via hardware-accelerated VideoEncoder, Chrome 94+)
|
|
13
|
+
*/
|
|
14
|
+
// ── Components ─────────────────────────────────────────────────────
|
|
15
|
+
export { VideoExportModal } from './VideoExportModal.js';
|
|
16
|
+
export { VideoExportButton } from './VideoExportButton.js';
|
|
17
|
+
// ── Hooks ──────────────────────────────────────────────────────────
|
|
18
|
+
export { useVideoExport } from './hooks/useVideoExport.js';
|
|
19
|
+
export { useFrameCapture } from './hooks/useFrameCapture.js';
|
|
20
|
+
// ── Encoder Utilities (for advanced usage) ─────────────────────────
|
|
21
|
+
export { supportsWebCodecs, createEncoder } from './mainThreadEncoder.js';
|
|
816
22
|
//# sourceMappingURL=index.js.map
|