@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/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# @bendyline/squisq-video-react
|
|
2
|
+
|
|
3
|
+
React components and hooks for exporting Squisq documents to MP4 video directly in the browser. Uses WebCodecs for hardware-accelerated H.264 encoding with html2canvas for frame capture.
|
|
4
|
+
|
|
5
|
+
Part of the [Squisq](https://github.com/bendyline/squisq) monorepo.
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@bendyline/squisq-video-react)
|
|
8
|
+
[](https://github.com/bendyline/squisq/blob/main/LICENSE)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @bendyline/squisq-video-react @bendyline/squisq-video @bendyline/squisq-react @bendyline/squisq
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Peer dependencies:** `react` and `react-dom` (v18 or v19).
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
### Drop-in Export Button
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { VideoExportButton } from '@bendyline/squisq-video-react';
|
|
24
|
+
|
|
25
|
+
function App() {
|
|
26
|
+
return <VideoExportButton doc={myDoc} images={imageMap} audio={audioMap} />;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Full Export Modal
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { VideoExportModal } from '@bendyline/squisq-video-react';
|
|
34
|
+
|
|
35
|
+
function App() {
|
|
36
|
+
const [open, setOpen] = useState(false);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<button onClick={() => setOpen(true)}>Export Video</button>
|
|
41
|
+
{open && (
|
|
42
|
+
<VideoExportModal
|
|
43
|
+
doc={myDoc}
|
|
44
|
+
images={imageMap}
|
|
45
|
+
audio={audioMap}
|
|
46
|
+
onClose={() => setOpen(false)}
|
|
47
|
+
/>
|
|
48
|
+
)}
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Components
|
|
55
|
+
|
|
56
|
+
| Component | Description |
|
|
57
|
+
| ------------------- | ----------------------------------------------------------------------- |
|
|
58
|
+
| `VideoExportModal` | Full modal UI — configure quality/fps/orientation, export, and download |
|
|
59
|
+
| `VideoExportButton` | Drop-in button that opens the export modal via portal |
|
|
60
|
+
|
|
61
|
+
## Hooks
|
|
62
|
+
|
|
63
|
+
| Hook | Description |
|
|
64
|
+
| ----------------- | ----------------------------------------------------------------------------- |
|
|
65
|
+
| `useVideoExport` | Orchestrates the full export lifecycle — capture, encode, download |
|
|
66
|
+
| `useFrameCapture` | Mounts a hidden DocPlayer and captures frames as ImageBitmaps via html2canvas |
|
|
67
|
+
|
|
68
|
+
## Export Options
|
|
69
|
+
|
|
70
|
+
The `VideoExportModal` lets users configure:
|
|
71
|
+
|
|
72
|
+
- **Quality:** draft, normal, or high
|
|
73
|
+
- **FPS:** 15, 24, or 30
|
|
74
|
+
- **Orientation:** landscape (1920x1080) or portrait (1080x1920)
|
|
75
|
+
- **Captions:** off, standard, or social
|
|
76
|
+
|
|
77
|
+
## Using the Hook Directly
|
|
78
|
+
|
|
79
|
+
For custom export UIs, use `useVideoExport` directly:
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
import { useVideoExport } from '@bendyline/squisq-video-react';
|
|
83
|
+
|
|
84
|
+
function CustomExport({ doc, images, audio }) {
|
|
85
|
+
const {
|
|
86
|
+
state, // 'idle' | 'preparing' | 'capturing' | 'encoding' | 'complete' | 'error'
|
|
87
|
+
progress, // 0–100
|
|
88
|
+
elapsed,
|
|
89
|
+
estimatedRemaining,
|
|
90
|
+
downloadUrl,
|
|
91
|
+
fileSize,
|
|
92
|
+
error,
|
|
93
|
+
startExport,
|
|
94
|
+
cancel,
|
|
95
|
+
reset,
|
|
96
|
+
} = useVideoExport();
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div>
|
|
100
|
+
<button onClick={() => startExport({ doc, images, audio, quality: 'normal', fps: 30 })}>
|
|
101
|
+
Export
|
|
102
|
+
</button>
|
|
103
|
+
{state === 'capturing' && <p>Progress: {progress}%</p>}
|
|
104
|
+
{downloadUrl && (
|
|
105
|
+
<a href={downloadUrl} download="video.mp4">
|
|
106
|
+
Download
|
|
107
|
+
</a>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Browser Requirements
|
|
115
|
+
|
|
116
|
+
WebCodecs H.264 encoding requires Chrome 94+ or Edge 94+. Use `supportsWebCodecs()` to check at runtime:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { supportsWebCodecs } from '@bendyline/squisq-video-react';
|
|
120
|
+
|
|
121
|
+
if (!supportsWebCodecs()) {
|
|
122
|
+
// Fall back or show unsupported message
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Related Packages
|
|
127
|
+
|
|
128
|
+
| Package | Description |
|
|
129
|
+
| -------------------------------------------------------------------------------- | ----------------------------------------------- |
|
|
130
|
+
| [@bendyline/squisq-video](https://www.npmjs.com/package/@bendyline/squisq-video) | Headless video rendering and WASM encoding |
|
|
131
|
+
| [@bendyline/squisq](https://www.npmjs.com/package/@bendyline/squisq) | Headless core — schemas, templates, markdown |
|
|
132
|
+
| [@bendyline/squisq-react](https://www.npmjs.com/package/@bendyline/squisq-react) | React components for rendering docs |
|
|
133
|
+
| [@bendyline/squisq-cli](https://www.npmjs.com/package/@bendyline/squisq-cli) | CLI for document conversion and video rendering |
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
[MIT](https://github.com/bendyline/squisq/blob/main/LICENSE)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoExportButton — Simple button that opens the VideoExportModal in a portal.
|
|
3
|
+
*
|
|
4
|
+
* Convenience wrapper for consumers who want a drop-in button.
|
|
5
|
+
* The modal renders via `createPortal` into `document.body`.
|
|
6
|
+
*/
|
|
7
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
8
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
9
|
+
export interface VideoExportButtonProps {
|
|
10
|
+
/** The document to export */
|
|
11
|
+
doc: Doc;
|
|
12
|
+
/** Player IIFE bundle source */
|
|
13
|
+
playerScript: string;
|
|
14
|
+
/** Optional media provider for resolving images/audio */
|
|
15
|
+
mediaProvider?: MediaProvider;
|
|
16
|
+
/** Pre-collected images map */
|
|
17
|
+
images?: Map<string, ArrayBuffer>;
|
|
18
|
+
/** Pre-collected audio map */
|
|
19
|
+
audio?: Map<string, ArrayBuffer>;
|
|
20
|
+
/** Button label (default: "Export Video") */
|
|
21
|
+
label?: string;
|
|
22
|
+
/** Additional inline styles for the button */
|
|
23
|
+
style?: React.CSSProperties;
|
|
24
|
+
/** Whether the button is disabled */
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
}
|
|
27
|
+
export declare function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, label, style, disabled, }: VideoExportButtonProps): import("react/jsx-runtime").JSX.Element;
|
|
28
|
+
//# sourceMappingURL=VideoExportButton.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoExportButton.d.ts","sourceRoot":"","sources":["../src/VideoExportButton.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAIH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,2BAA2B,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAG/D,MAAM,WAAW,sBAAsB;IACrC,6BAA6B;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,+BAA+B;IAC/B,MAAM,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAClC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACjC,6CAA6C;IAC7C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,KAAK,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC;IAC5B,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,iBAAiB,CAAC,EAChC,GAAG,EACH,YAAY,EACZ,aAAa,EACb,MAAM,EACN,KAAK,EACL,KAAsB,EACtB,KAAK,EACL,QAAQ,GACT,EAAE,sBAAsB,2CA0BxB"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* VideoExportButton — Simple button that opens the VideoExportModal in a portal.
|
|
4
|
+
*
|
|
5
|
+
* Convenience wrapper for consumers who want a drop-in button.
|
|
6
|
+
* The modal renders via `createPortal` into `document.body`.
|
|
7
|
+
*/
|
|
8
|
+
import { useState, useCallback } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import { VideoExportModal } from './VideoExportModal.js';
|
|
11
|
+
export function VideoExportButton({ doc, playerScript, mediaProvider, images, audio, label = 'Export Video', style, disabled, }) {
|
|
12
|
+
const [showModal, setShowModal] = useState(false);
|
|
13
|
+
const handleOpen = useCallback(() => setShowModal(true), []);
|
|
14
|
+
const handleClose = useCallback(() => setShowModal(false), []);
|
|
15
|
+
return (_jsxs(_Fragment, { children: [_jsx("button", { onClick: handleOpen, disabled: disabled, style: style, children: label }), showModal &&
|
|
16
|
+
createPortal(_jsx(VideoExportModal, { doc: doc, playerScript: playerScript, mediaProvider: mediaProvider, images: images, audio: audio, onClose: handleClose }), document.body)] }));
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=VideoExportButton.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoExportButton.js","sourceRoot":"","sources":["../src/VideoExportButton.tsx"],"names":[],"mappings":";AAAA;;;;;GAKG;AAEH,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AAC9C,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAGzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAqBzD,MAAM,UAAU,iBAAiB,CAAC,EAChC,GAAG,EACH,YAAY,EACZ,aAAa,EACb,MAAM,EACN,KAAK,EACL,KAAK,GAAG,cAAc,EACtB,KAAK,EACL,QAAQ,GACe;IACvB,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAC;IAElD,MAAM,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;IAC7D,MAAM,WAAW,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,EAAE,CAAC,CAAC;IAE/D,OAAO,CACL,8BACE,iBAAQ,OAAO,EAAE,UAAU,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,KAAK,YAC1D,KAAK,GACC,EAER,SAAS;gBACR,YAAY,CACV,KAAC,gBAAgB,IACf,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,YAAY,EAC1B,aAAa,EAAE,aAAa,EAC5B,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,WAAW,GACpB,EACF,QAAQ,CAAC,IAAI,CACd,IACF,CACJ,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoExportModal — Modal dialog for configuring and monitoring video export.
|
|
3
|
+
*
|
|
4
|
+
* States:
|
|
5
|
+
* configure → exporting (capturing + encoding) → complete | error
|
|
6
|
+
*
|
|
7
|
+
* Inline styles match the site's cream/gold palette (from FileToolbar).
|
|
8
|
+
*/
|
|
9
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
10
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
11
|
+
export interface VideoExportModalProps {
|
|
12
|
+
/** The document to export */
|
|
13
|
+
doc: Doc;
|
|
14
|
+
/** Player IIFE bundle source */
|
|
15
|
+
playerScript: string;
|
|
16
|
+
/** Optional media provider for resolving images/audio */
|
|
17
|
+
mediaProvider?: MediaProvider;
|
|
18
|
+
/** Pre-collected images map (alternative to mediaProvider) */
|
|
19
|
+
images?: Map<string, ArrayBuffer>;
|
|
20
|
+
/** Pre-collected audio map */
|
|
21
|
+
audio?: Map<string, ArrayBuffer>;
|
|
22
|
+
/** Called when the modal should close */
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, onClose, }: VideoExportModalProps): import("react/jsx-runtime").JSX.Element;
|
|
26
|
+
//# sourceMappingURL=VideoExportModal.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoExportModal.d.ts","sourceRoot":"","sources":["../src/VideoExportModal.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,2BAA2B,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAO/D,MAAM,WAAW,qBAAqB;IACpC,6BAA6B;IAC7B,GAAG,EAAE,GAAG,CAAC;IACT,gCAAgC;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,yDAAyD;IACzD,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,8DAA8D;IAC9D,MAAM,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IAClC,8BAA8B;IAC9B,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACjC,yCAAyC;IACzC,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAwGD,wBAAgB,gBAAgB,CAAC,EAC/B,GAAG,EACH,YAAY,EACZ,aAAa,EACb,MAAM,EACN,KAAK,EACL,OAAO,GACR,EAAE,qBAAqB,2CA+NvB"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* VideoExportModal — Modal dialog for configuring and monitoring video export.
|
|
4
|
+
*
|
|
5
|
+
* States:
|
|
6
|
+
* configure → exporting (capturing + encoding) → complete | error
|
|
7
|
+
*
|
|
8
|
+
* Inline styles match the site's cream/gold palette (from FileToolbar).
|
|
9
|
+
*/
|
|
10
|
+
import { useState, useCallback } from 'react';
|
|
11
|
+
import { useVideoExport } from './hooks/useVideoExport.js';
|
|
12
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
13
|
+
function formatDuration(seconds) {
|
|
14
|
+
if (seconds < 60)
|
|
15
|
+
return `${seconds}s`;
|
|
16
|
+
const m = Math.floor(seconds / 60);
|
|
17
|
+
const s = seconds % 60;
|
|
18
|
+
return `${m}m ${s}s`;
|
|
19
|
+
}
|
|
20
|
+
// ── Styles ─────────────────────────────────────────────────────────
|
|
21
|
+
const overlayStyle = {
|
|
22
|
+
position: 'fixed',
|
|
23
|
+
inset: 0,
|
|
24
|
+
background: 'rgba(0, 0, 0, 0.5)',
|
|
25
|
+
display: 'flex',
|
|
26
|
+
alignItems: 'center',
|
|
27
|
+
justifyContent: 'center',
|
|
28
|
+
zIndex: 10000,
|
|
29
|
+
};
|
|
30
|
+
const modalStyle = {
|
|
31
|
+
background: '#FFFDF7',
|
|
32
|
+
border: '1px solid #c9b98a',
|
|
33
|
+
borderRadius: 0,
|
|
34
|
+
padding: '24px 28px',
|
|
35
|
+
minWidth: 380,
|
|
36
|
+
maxWidth: 480,
|
|
37
|
+
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
38
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
39
|
+
color: '#4a3c1f',
|
|
40
|
+
};
|
|
41
|
+
const titleStyle = {
|
|
42
|
+
margin: '0 0 16px 0',
|
|
43
|
+
fontSize: 18,
|
|
44
|
+
fontWeight: 600,
|
|
45
|
+
color: '#2d2310',
|
|
46
|
+
};
|
|
47
|
+
const labelStyle = {
|
|
48
|
+
display: 'block',
|
|
49
|
+
fontSize: 13,
|
|
50
|
+
fontWeight: 500,
|
|
51
|
+
marginBottom: 4,
|
|
52
|
+
color: '#5a4a2a',
|
|
53
|
+
};
|
|
54
|
+
const selectStyle = {
|
|
55
|
+
width: '100%',
|
|
56
|
+
padding: '6px 8px',
|
|
57
|
+
fontSize: 13,
|
|
58
|
+
fontFamily: 'inherit',
|
|
59
|
+
border: '1px solid #c9b98a',
|
|
60
|
+
borderRadius: 0,
|
|
61
|
+
background: '#fff',
|
|
62
|
+
color: '#4a3c1f',
|
|
63
|
+
marginBottom: 12,
|
|
64
|
+
};
|
|
65
|
+
const btnPrimary = {
|
|
66
|
+
padding: '8px 20px',
|
|
67
|
+
fontSize: 14,
|
|
68
|
+
fontFamily: 'inherit',
|
|
69
|
+
fontWeight: 500,
|
|
70
|
+
cursor: 'pointer',
|
|
71
|
+
background: '#8B6914',
|
|
72
|
+
color: '#fff',
|
|
73
|
+
border: '1px solid #7a5c10',
|
|
74
|
+
borderRadius: 0,
|
|
75
|
+
};
|
|
76
|
+
const btnSecondary = {
|
|
77
|
+
padding: '8px 20px',
|
|
78
|
+
fontSize: 14,
|
|
79
|
+
fontFamily: 'inherit',
|
|
80
|
+
fontWeight: 500,
|
|
81
|
+
cursor: 'pointer',
|
|
82
|
+
background: '#E8DFC6',
|
|
83
|
+
color: '#4a3c1f',
|
|
84
|
+
border: '1px solid #c9b98a',
|
|
85
|
+
borderRadius: 0,
|
|
86
|
+
};
|
|
87
|
+
const progressBarOuterStyle = {
|
|
88
|
+
width: '100%',
|
|
89
|
+
height: 8,
|
|
90
|
+
background: '#E8DFC6',
|
|
91
|
+
borderRadius: 0,
|
|
92
|
+
overflow: 'hidden',
|
|
93
|
+
marginBottom: 8,
|
|
94
|
+
};
|
|
95
|
+
const footerStyle = {
|
|
96
|
+
display: 'flex',
|
|
97
|
+
justifyContent: 'flex-end',
|
|
98
|
+
gap: 8,
|
|
99
|
+
marginTop: 20,
|
|
100
|
+
};
|
|
101
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
102
|
+
export function VideoExportModal({ doc, playerScript, mediaProvider, images, audio, onClose, }) {
|
|
103
|
+
const [quality, setQuality] = useState('normal');
|
|
104
|
+
const [fps, setFps] = useState(24);
|
|
105
|
+
const [orientation, setOrientation] = useState('landscape');
|
|
106
|
+
const [captionMode, setCaptionMode] = useState('off');
|
|
107
|
+
const exportHook = useVideoExport();
|
|
108
|
+
const { state, progress, backend, downloadUrl, fileSize, error, elapsed, estimatedRemaining, startExport, cancel: cancelExport, reset: resetExport, } = exportHook;
|
|
109
|
+
const handleExport = useCallback(async () => {
|
|
110
|
+
const config = {
|
|
111
|
+
quality,
|
|
112
|
+
fps,
|
|
113
|
+
orientation,
|
|
114
|
+
captionMode,
|
|
115
|
+
images,
|
|
116
|
+
audio,
|
|
117
|
+
mediaProvider,
|
|
118
|
+
playerScript,
|
|
119
|
+
};
|
|
120
|
+
await startExport(doc, config);
|
|
121
|
+
}, [
|
|
122
|
+
doc,
|
|
123
|
+
quality,
|
|
124
|
+
fps,
|
|
125
|
+
orientation,
|
|
126
|
+
captionMode,
|
|
127
|
+
images,
|
|
128
|
+
audio,
|
|
129
|
+
mediaProvider,
|
|
130
|
+
playerScript,
|
|
131
|
+
startExport,
|
|
132
|
+
]);
|
|
133
|
+
const handleDownload = useCallback(() => {
|
|
134
|
+
if (!downloadUrl)
|
|
135
|
+
return;
|
|
136
|
+
const a = document.createElement('a');
|
|
137
|
+
a.href = downloadUrl;
|
|
138
|
+
const ts = new Date().toISOString().slice(0, 10);
|
|
139
|
+
a.download = `document-${ts}.mp4`;
|
|
140
|
+
document.body.appendChild(a);
|
|
141
|
+
a.click();
|
|
142
|
+
document.body.removeChild(a);
|
|
143
|
+
}, [downloadUrl]);
|
|
144
|
+
const handleClose = useCallback(() => {
|
|
145
|
+
if (state === 'capturing' || state === 'encoding' || state === 'preparing') {
|
|
146
|
+
cancelExport();
|
|
147
|
+
}
|
|
148
|
+
resetExport();
|
|
149
|
+
onClose();
|
|
150
|
+
}, [state, cancelExport, resetExport, onClose]);
|
|
151
|
+
const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';
|
|
152
|
+
return (_jsx("div", { style: overlayStyle, onClick: handleClose, children: _jsxs("div", { style: modalStyle, onClick: (e) => e.stopPropagation(), children: [_jsx("h2", { style: titleStyle, children: "Export Video" }), state === 'idle' && (_jsxs(_Fragment, { children: [_jsxs("div", { children: [_jsx("label", { style: labelStyle, children: "Quality" }), _jsxs("select", { style: selectStyle, value: quality, onChange: (e) => setQuality(e.target.value), children: [_jsx("option", { value: "draft", children: "Draft \u2014 fast, lower quality" }), _jsx("option", { value: "normal", children: "Normal \u2014 balanced" }), _jsx("option", { value: "high", children: "High \u2014 best quality, slower" })] })] }), _jsxs("div", { children: [_jsx("label", { style: labelStyle, children: "Frame Rate" }), _jsxs("select", { style: selectStyle, value: fps, onChange: (e) => setFps(Number(e.target.value)), children: [_jsx("option", { value: 15, children: "15 fps \u2014 fast export" }), _jsx("option", { value: 24, children: "24 fps \u2014 cinematic" }), _jsx("option", { value: 30, children: "30 fps \u2014 smooth" })] })] }), _jsxs("div", { children: [_jsx("label", { style: labelStyle, children: "Orientation" }), _jsxs("select", { style: selectStyle, value: orientation, onChange: (e) => setOrientation(e.target.value), children: [_jsx("option", { value: "landscape", children: "Landscape (1920 \u00D7 1080)" }), _jsx("option", { value: "portrait", children: "Portrait (1080 \u00D7 1920)" })] })] }), _jsxs("div", { children: [_jsx("label", { style: labelStyle, children: "Captions" }), _jsxs("select", { style: selectStyle, value: captionMode, onChange: (e) => setCaptionMode(e.target.value), children: [_jsx("option", { value: "off", children: "None" }), _jsx("option", { value: "standard", children: "Standard (top bar)" }), _jsx("option", { value: "social", children: "Social media (large words)" })] })] }), _jsxs("div", { style: footerStyle, children: [_jsx("button", { style: btnSecondary, onClick: handleClose, children: "Cancel" }), _jsx("button", { style: btnPrimary, onClick: handleExport, children: "Export Video" })] })] })), isExporting && (_jsxs(_Fragment, { children: [backend && (_jsx("p", { style: { fontSize: 12, color: '#8a7a5a', margin: '0 0 8px 0' }, children: "Encoder: WebCodecs (H.264)" })), _jsx("div", { style: progressBarOuterStyle, children: _jsx("div", { style: {
|
|
153
|
+
width: `${progress}%`,
|
|
154
|
+
height: '100%',
|
|
155
|
+
background: '#8B6914',
|
|
156
|
+
transition: 'width 0.3s ease',
|
|
157
|
+
} }) }), _jsxs("p", { style: { fontSize: 13, margin: '0 0 4px 0' }, children: [progress, "% complete"] }), _jsxs("p", { style: { fontSize: 12, color: '#8a7a5a', margin: 0 }, children: [formatDuration(elapsed), " elapsed", estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`] }), _jsx("div", { style: footerStyle, children: _jsx("button", { style: btnSecondary, onClick: cancelExport, children: "Cancel" }) })] })), state === 'complete' && (_jsxs(_Fragment, { children: [_jsx("p", { style: { fontSize: 14, margin: '0 0 8px 0', color: '#2d6a10' }, children: "Export complete!" }), _jsxs("p", { style: { fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }, children: ["File size: ", (fileSize / (1024 * 1024)).toFixed(1), " MB"] }), backend && (_jsx("p", { style: { fontSize: 12, color: '#8a7a5a', margin: '0 0 12px 0' }, children: "Encoded with WebCodecs (H.264)" })), _jsxs("div", { style: footerStyle, children: [_jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }), _jsx("button", { style: btnPrimary, onClick: handleDownload, children: "Download MP4" })] })] })), state === 'error' && (_jsxs(_Fragment, { children: [_jsx("p", { style: { fontSize: 14, margin: '0 0 8px 0', color: '#a03020' }, children: "Export failed" }), _jsx("p", { style: {
|
|
158
|
+
fontSize: 13,
|
|
159
|
+
color: '#5a4a2a',
|
|
160
|
+
margin: '0 0 12px 0',
|
|
161
|
+
wordBreak: 'break-word',
|
|
162
|
+
}, children: error }), _jsxs("div", { style: footerStyle, children: [_jsx("button", { style: btnSecondary, onClick: handleClose, children: "Close" }), _jsx("button", { style: btnPrimary, onClick: handleExport, children: "Retry" })] })] }))] }) }));
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=VideoExportModal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"VideoExportModal.js","sourceRoot":"","sources":["../src/VideoExportModal.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFrameCapture — Hidden div + html2canvas frame capture.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a DocPlayer in renderMode inside a hidden div (same document),
|
|
5
|
+
* then captures individual frames by seeking the player and rendering
|
|
6
|
+
* the DOM to a canvas via html2canvas.
|
|
7
|
+
*
|
|
8
|
+
* Uses React directly — no script injection, no iframes, no eval.
|
|
9
|
+
*
|
|
10
|
+
* Returns an ImageBitmap for each frame (transferable to a Worker).
|
|
11
|
+
*/
|
|
12
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
13
|
+
import type { RenderHtmlOptions } from '@bendyline/squisq-video';
|
|
14
|
+
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
15
|
+
export interface FrameCaptureHandle {
|
|
16
|
+
/** Initialize the hidden player. Returns the video duration in seconds. */
|
|
17
|
+
init: (doc: Doc, renderOptions: Omit<RenderHtmlOptions, 'playerScript'>, captionMode?: CaptionMode) => Promise<number>;
|
|
18
|
+
/** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
|
|
19
|
+
captureFrame: (time: number) => Promise<ImageBitmap>;
|
|
20
|
+
/** Clean up resources. */
|
|
21
|
+
destroy: () => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Hook that manages a hidden div for frame capture.
|
|
25
|
+
*/
|
|
26
|
+
export declare function useFrameCapture(): FrameCaptureHandle;
|
|
27
|
+
//# sourceMappingURL=useFrameCapture.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFrameCapture.d.ts","sourceRoot":"","sources":["../../src/hooks/useFrameCapture.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EAAE,GAAG,EAAiB,MAAM,2BAA2B,CAAC;AACpE,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,OAAO,KAAK,EAAgB,WAAW,EAAgB,MAAM,yBAAyB,CAAC;AAGvF,MAAM,WAAW,kBAAkB;IACjC,2EAA2E;IAC3E,IAAI,EAAE,CACJ,GAAG,EAAE,GAAG,EACR,aAAa,EAAE,IAAI,CAAC,iBAAiB,EAAE,cAAc,CAAC,EACtD,WAAW,CAAC,EAAE,WAAW,KACtB,OAAO,CAAC,MAAM,CAAC,CAAC;IACrB,kFAAkF;IAClF,YAAY,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IACrD,0BAA0B;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AA2DD;;GAEG;AACH,wBAAgB,eAAe,IAAI,kBAAkB,CAwKpD"}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFrameCapture — Hidden div + html2canvas frame capture.
|
|
3
|
+
*
|
|
4
|
+
* Mounts a DocPlayer in renderMode inside a hidden div (same document),
|
|
5
|
+
* then captures individual frames by seeking the player and rendering
|
|
6
|
+
* the DOM to a canvas via html2canvas.
|
|
7
|
+
*
|
|
8
|
+
* Uses React directly — no script injection, no iframes, no eval.
|
|
9
|
+
*
|
|
10
|
+
* Returns an ImageBitmap for each frame (transferable to a Worker).
|
|
11
|
+
*/
|
|
12
|
+
import { createElement } from 'react';
|
|
13
|
+
import { createRoot } from 'react-dom/client';
|
|
14
|
+
import { useRef, useCallback, useMemo } from 'react';
|
|
15
|
+
import { DocPlayer, MediaContext } from '@bendyline/squisq-react';
|
|
16
|
+
import html2canvas from 'html2canvas';
|
|
17
|
+
/** Extension → MIME type map (hoisted to avoid per-image allocation). */
|
|
18
|
+
const MIME_MAP = {
|
|
19
|
+
jpg: 'image/jpeg',
|
|
20
|
+
jpeg: 'image/jpeg',
|
|
21
|
+
png: 'image/png',
|
|
22
|
+
gif: 'image/gif',
|
|
23
|
+
webp: 'image/webp',
|
|
24
|
+
svg: 'image/svg+xml',
|
|
25
|
+
bmp: 'image/bmp',
|
|
26
|
+
avif: 'image/avif',
|
|
27
|
+
};
|
|
28
|
+
/** Convert an ArrayBuffer to a base64 data URI using chunked encoding (O(n)). */
|
|
29
|
+
function arrayBufferToDataUrl(buffer, mime) {
|
|
30
|
+
const bytes = new Uint8Array(buffer);
|
|
31
|
+
const chunks = [];
|
|
32
|
+
const CHUNK = 8192;
|
|
33
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
34
|
+
chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
|
|
35
|
+
}
|
|
36
|
+
return `data:${mime};base64,${btoa(chunks.join(''))}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create an inline MediaProvider from a map of paths to ArrayBuffers.
|
|
40
|
+
*/
|
|
41
|
+
function createInlineProvider(images) {
|
|
42
|
+
const dataUrls = new Map();
|
|
43
|
+
const mimeTypes = new Map();
|
|
44
|
+
for (const [path, buffer] of images) {
|
|
45
|
+
const ext = path.split('.').pop()?.toLowerCase() ?? '';
|
|
46
|
+
const mime = MIME_MAP[ext] ?? 'application/octet-stream';
|
|
47
|
+
dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
|
|
48
|
+
mimeTypes.set(path, mime);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
async resolveUrl(relativePath) {
|
|
52
|
+
return dataUrls.get(relativePath) ?? relativePath;
|
|
53
|
+
},
|
|
54
|
+
async listMedia() {
|
|
55
|
+
return [...dataUrls.keys()].map((name) => ({
|
|
56
|
+
name,
|
|
57
|
+
mimeType: mimeTypes.get(name) ?? 'application/octet-stream',
|
|
58
|
+
size: 0,
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
async addMedia() {
|
|
62
|
+
throw new Error('Read-only');
|
|
63
|
+
},
|
|
64
|
+
async removeMedia() {
|
|
65
|
+
throw new Error('Read-only');
|
|
66
|
+
},
|
|
67
|
+
dispose() { },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Hook that manages a hidden div for frame capture.
|
|
72
|
+
*/
|
|
73
|
+
export function useFrameCapture() {
|
|
74
|
+
const containerRef = useRef(null);
|
|
75
|
+
const rootRef = useRef(null);
|
|
76
|
+
const dimensionsRef = useRef({ width: 1920, height: 1080 });
|
|
77
|
+
const init = useCallback(async (doc, renderOptions, captionMode) => {
|
|
78
|
+
// Clean up any existing container.
|
|
79
|
+
// Defer unmount to avoid "synchronously unmount a root while React
|
|
80
|
+
// was already rendering" when init() is called from a React handler.
|
|
81
|
+
if (rootRef.current || containerRef.current) {
|
|
82
|
+
const oldRoot = rootRef.current;
|
|
83
|
+
const oldContainer = containerRef.current;
|
|
84
|
+
rootRef.current = null;
|
|
85
|
+
containerRef.current = null;
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
if (oldRoot)
|
|
89
|
+
oldRoot.unmount();
|
|
90
|
+
if (oldContainer)
|
|
91
|
+
oldContainer.remove();
|
|
92
|
+
resolve();
|
|
93
|
+
}, 0);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const width = renderOptions.width ?? 1920;
|
|
97
|
+
const height = renderOptions.height ?? 1080;
|
|
98
|
+
dimensionsRef.current = { width, height };
|
|
99
|
+
// Create a hidden container
|
|
100
|
+
const container = document.createElement('div');
|
|
101
|
+
container.style.cssText =
|
|
102
|
+
`position:fixed;left:0;top:0;width:${width}px;height:${height}px;` +
|
|
103
|
+
'opacity:0;pointer-events:none;z-index:-1;overflow:hidden;';
|
|
104
|
+
document.body.appendChild(container);
|
|
105
|
+
containerRef.current = container;
|
|
106
|
+
// Create render root
|
|
107
|
+
const renderRoot = document.createElement('div');
|
|
108
|
+
renderRoot.id = 'squisq-capture-root';
|
|
109
|
+
renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
|
|
110
|
+
container.appendChild(renderRoot);
|
|
111
|
+
// Build media provider from images
|
|
112
|
+
const mediaProvider = renderOptions.images
|
|
113
|
+
? createInlineProvider(renderOptions.images)
|
|
114
|
+
: null;
|
|
115
|
+
// Mount DocPlayer in renderMode via React
|
|
116
|
+
const root = createRoot(renderRoot);
|
|
117
|
+
rootRef.current = root;
|
|
118
|
+
// Derive caption props from captionMode
|
|
119
|
+
const captionsEnabled = captionMode !== undefined && captionMode !== 'off';
|
|
120
|
+
const captionStyle = captionMode === 'social' ? 'social' : 'standard';
|
|
121
|
+
const playerElement = createElement(DocPlayer, {
|
|
122
|
+
script: doc,
|
|
123
|
+
basePath: '.',
|
|
124
|
+
renderMode: true,
|
|
125
|
+
showControls: false,
|
|
126
|
+
autoPlay: false,
|
|
127
|
+
forceViewport: { width, height, name: 'export' },
|
|
128
|
+
captionsEnabled,
|
|
129
|
+
captionStyle,
|
|
130
|
+
});
|
|
131
|
+
// Defer rendering to the next microtask to avoid "synchronously unmount
|
|
132
|
+
// a root while React was already rendering" when init() is called during
|
|
133
|
+
// a React render cycle (e.g., from startExport in VideoExportModal).
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
135
|
+
if (mediaProvider) {
|
|
136
|
+
root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
root.render(playerElement);
|
|
140
|
+
}
|
|
141
|
+
// Wait for the render API to appear on window
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timeout = setTimeout(() => {
|
|
144
|
+
const w = window;
|
|
145
|
+
const hasSeek = typeof w.seekTo === 'function';
|
|
146
|
+
const hasDur = typeof w.getDuration === 'function';
|
|
147
|
+
const rootEl = containerRef.current?.querySelector('#squisq-capture-root');
|
|
148
|
+
const hasPlayer = rootEl ? rootEl.querySelector('.doc-player') !== null : false;
|
|
149
|
+
reject(new Error(`Render API did not initialize within 15s. ` +
|
|
150
|
+
`seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`));
|
|
151
|
+
}, 15000);
|
|
152
|
+
const checkApi = () => {
|
|
153
|
+
const w = window;
|
|
154
|
+
if (typeof w.getDuration === 'function' && typeof w.seekTo === 'function') {
|
|
155
|
+
clearTimeout(timeout);
|
|
156
|
+
const duration = w.getDuration();
|
|
157
|
+
resolve(duration);
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
requestAnimationFrame(checkApi);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
// Give React time to mount and run useEffects
|
|
164
|
+
setTimeout(checkApi, 500);
|
|
165
|
+
});
|
|
166
|
+
}, []);
|
|
167
|
+
const captureFrame = useCallback(async (time) => {
|
|
168
|
+
const container = containerRef.current;
|
|
169
|
+
const w = window;
|
|
170
|
+
if (!container || typeof w.seekTo !== 'function') {
|
|
171
|
+
throw new Error('Frame capture not initialized — call init() first');
|
|
172
|
+
}
|
|
173
|
+
const { width, height } = dimensionsRef.current;
|
|
174
|
+
// Seek the player to the target time
|
|
175
|
+
await w.seekTo(time);
|
|
176
|
+
// Wait for the DOM to update after seek
|
|
177
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
178
|
+
const root = container.querySelector('#squisq-capture-root');
|
|
179
|
+
if (!root) {
|
|
180
|
+
throw new Error('Capture root element not found');
|
|
181
|
+
}
|
|
182
|
+
// Render the DOM to a canvas via html2canvas.
|
|
183
|
+
// We're in the same document (no iframe), so COEP doesn't block cloning.
|
|
184
|
+
const canvas = await html2canvas(root, {
|
|
185
|
+
width,
|
|
186
|
+
height,
|
|
187
|
+
scale: 1,
|
|
188
|
+
useCORS: true,
|
|
189
|
+
allowTaint: true,
|
|
190
|
+
backgroundColor: '#000000',
|
|
191
|
+
logging: false,
|
|
192
|
+
});
|
|
193
|
+
// Convert to ImageBitmap (transferable to worker — zero-copy)
|
|
194
|
+
const bitmap = await createImageBitmap(canvas);
|
|
195
|
+
return bitmap;
|
|
196
|
+
}, []);
|
|
197
|
+
const destroy = useCallback(() => {
|
|
198
|
+
if (rootRef.current) {
|
|
199
|
+
rootRef.current.unmount();
|
|
200
|
+
rootRef.current = null;
|
|
201
|
+
}
|
|
202
|
+
if (containerRef.current) {
|
|
203
|
+
containerRef.current.remove();
|
|
204
|
+
containerRef.current = null;
|
|
205
|
+
}
|
|
206
|
+
}, []);
|
|
207
|
+
// Return a stable object to prevent useEffect cleanup loops
|
|
208
|
+
// in consumers that depend on the handle reference.
|
|
209
|
+
return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=useFrameCapture.js.map
|