@bendyline/squisq-video-react 2.0.2 → 2.1.0

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.
@@ -1,268 +0,0 @@
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
-
13
- import { createElement } from 'react';
14
- import { createRoot, type Root } from 'react-dom/client';
15
- import { useRef, useCallback, useMemo } from 'react';
16
- import type { Doc, MediaProvider } from '@bendyline/squisq/schemas';
17
- import type { RenderHtmlOptions } from '@bendyline/squisq-video';
18
- import { DocPlayer, MediaContext } from '@bendyline/squisq-react';
19
- import type { SquisqRenderAPI, CaptionMode, CaptionStyle } from '@bendyline/squisq-react';
20
- import html2canvas from 'html2canvas';
21
-
22
- export interface FrameCaptureHandle {
23
- /** Initialize the hidden player. Returns the video duration in seconds. */
24
- init: (
25
- doc: Doc,
26
- renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,
27
- captionMode?: CaptionMode,
28
- ) => Promise<number>;
29
- /** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */
30
- captureFrame: (time: number) => Promise<ImageBitmap>;
31
- /** Clean up resources. */
32
- destroy: () => void;
33
- }
34
-
35
- /** Extension → MIME type map (hoisted to avoid per-image allocation). */
36
- const MIME_MAP: Record<string, string> = {
37
- jpg: 'image/jpeg',
38
- jpeg: 'image/jpeg',
39
- png: 'image/png',
40
- gif: 'image/gif',
41
- webp: 'image/webp',
42
- svg: 'image/svg+xml',
43
- bmp: 'image/bmp',
44
- avif: 'image/avif',
45
- };
46
-
47
- /** Convert an ArrayBuffer to a base64 data URI using chunked encoding (O(n)). */
48
- function arrayBufferToDataUrl(buffer: ArrayBuffer, mime: string): string {
49
- const bytes = new Uint8Array(buffer);
50
- const chunks: string[] = [];
51
- const CHUNK = 8192;
52
- for (let i = 0; i < bytes.length; i += CHUNK) {
53
- chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));
54
- }
55
- return `data:${mime};base64,${btoa(chunks.join(''))}`;
56
- }
57
-
58
- /**
59
- * Create an inline MediaProvider from a map of paths to ArrayBuffers.
60
- */
61
- function createInlineProvider(images: Map<string, ArrayBuffer>): MediaProvider {
62
- const dataUrls = new Map<string, string>();
63
- const mimeTypes = new Map<string, string>();
64
- for (const [path, buffer] of images) {
65
- const ext = path.split('.').pop()?.toLowerCase() ?? '';
66
- const mime = MIME_MAP[ext] ?? 'application/octet-stream';
67
- dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));
68
- mimeTypes.set(path, mime);
69
- }
70
-
71
- return {
72
- async resolveUrl(relativePath: string): Promise<string> {
73
- return dataUrls.get(relativePath) ?? relativePath;
74
- },
75
- async listMedia() {
76
- return [...dataUrls.keys()].map((name) => ({
77
- name,
78
- mimeType: mimeTypes.get(name) ?? 'application/octet-stream',
79
- size: 0,
80
- }));
81
- },
82
- async addMedia() {
83
- throw new Error('Read-only');
84
- },
85
- async removeMedia() {
86
- throw new Error('Read-only');
87
- },
88
- dispose() {},
89
- };
90
- }
91
-
92
- /**
93
- * Hook that manages a hidden div for frame capture.
94
- */
95
- export function useFrameCapture(): FrameCaptureHandle {
96
- const containerRef = useRef<HTMLDivElement | null>(null);
97
- const rootRef = useRef<Root | null>(null);
98
- const renderAPIRef = useRef<SquisqRenderAPI | null>(null);
99
- const dimensionsRef = useRef<{ width: number; height: number }>({ width: 1920, height: 1080 });
100
-
101
- const init = useCallback(
102
- async (
103
- doc: Doc,
104
- renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,
105
- captionMode?: CaptionMode,
106
- ): Promise<number> => {
107
- // Clean up any existing container.
108
- // Defer unmount to avoid "synchronously unmount a root while React
109
- // was already rendering" when init() is called from a React handler.
110
- if (rootRef.current || containerRef.current) {
111
- const oldRoot = rootRef.current;
112
- const oldContainer = containerRef.current;
113
- rootRef.current = null;
114
- containerRef.current = null;
115
- renderAPIRef.current = null;
116
- await new Promise<void>((resolve) => {
117
- setTimeout(() => {
118
- if (oldRoot) oldRoot.unmount();
119
- if (oldContainer) oldContainer.remove();
120
- resolve();
121
- }, 0);
122
- });
123
- }
124
-
125
- const width = renderOptions.width ?? 1920;
126
- const height = renderOptions.height ?? 1080;
127
- const animationsEnabled = renderOptions.animationsEnabled ?? true;
128
- dimensionsRef.current = { width, height };
129
-
130
- // Create a hidden container
131
- const container = document.createElement('div');
132
- container.style.cssText =
133
- `position:fixed;left:0;top:0;width:${width}px;height:${height}px;` +
134
- 'opacity:0;pointer-events:none;z-index:-1;overflow:hidden;';
135
- document.body.appendChild(container);
136
- containerRef.current = container;
137
-
138
- // Create render root
139
- const renderRoot = document.createElement('div');
140
- renderRoot.id = 'squisq-capture-root';
141
- renderRoot.style.cssText = `width:${width}px;height:${height}px;`;
142
- container.appendChild(renderRoot);
143
-
144
- // Build media provider from images
145
- const mediaProvider = renderOptions.images
146
- ? createInlineProvider(renderOptions.images)
147
- : null;
148
-
149
- // Mount DocPlayer in renderMode via React
150
- const root = createRoot(renderRoot);
151
- rootRef.current = root;
152
-
153
- // Derive caption props from captionMode
154
- const captionsEnabled = captionMode !== undefined && captionMode !== 'off';
155
- const captionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';
156
- let resolveRenderAPI!: (api: SquisqRenderAPI) => void;
157
- const renderAPIReady = new Promise<SquisqRenderAPI>((resolve) => {
158
- resolveRenderAPI = resolve;
159
- });
160
-
161
- const playerElement = createElement(DocPlayer, {
162
- doc,
163
- basePath: '.',
164
- renderMode: true,
165
- animationsEnabled,
166
- showControls: false,
167
- autoPlay: false,
168
- forceViewport: { width, height, name: 'export' },
169
- captionsEnabled,
170
- captionStyle,
171
- onRenderAPIReady: (api: SquisqRenderAPI | null) => {
172
- if (containerRef.current !== container) return;
173
- renderAPIRef.current = api;
174
- if (api) resolveRenderAPI(api);
175
- },
176
- });
177
-
178
- // Defer rendering to the next microtask to avoid "synchronously unmount
179
- // a root while React was already rendering" when init() is called during
180
- // a React render cycle (e.g., from startExport in VideoExportModal).
181
- await new Promise<void>((resolve) => setTimeout(resolve, 0));
182
-
183
- if (mediaProvider) {
184
- root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));
185
- } else {
186
- root.render(playerElement);
187
- }
188
-
189
- // Wait for this exact player's instance API.
190
- return new Promise<number>((resolve, reject) => {
191
- const timeout = setTimeout(() => {
192
- const api = renderAPIRef.current;
193
- const hasSeek = typeof api?.seekTo === 'function';
194
- const hasDur = typeof api?.getDuration === 'function';
195
- const rootEl = containerRef.current?.querySelector('#squisq-capture-root');
196
- const hasPlayer = rootEl ? rootEl.querySelector('.doc-player') !== null : false;
197
- reject(
198
- new Error(
199
- `Render API did not initialize within 15s. ` +
200
- `seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`,
201
- ),
202
- );
203
- }, 15000);
204
-
205
- void renderAPIReady.then((api) => {
206
- clearTimeout(timeout);
207
- resolve(api.getDuration());
208
- });
209
- });
210
- },
211
- [],
212
- );
213
-
214
- const captureFrame = useCallback(async (time: number): Promise<ImageBitmap> => {
215
- const container = containerRef.current;
216
- const api = renderAPIRef.current;
217
- if (!container || !api) {
218
- throw new Error('Frame capture not initialized — call init() first');
219
- }
220
-
221
- const { width, height } = dimensionsRef.current;
222
-
223
- // Seek the player to the target time
224
- await api.seekTo(time);
225
-
226
- // Wait for the DOM to update after seek
227
- await new Promise<void>((resolve) =>
228
- requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
229
- );
230
-
231
- const root = container.querySelector('#squisq-capture-root') as HTMLElement;
232
- if (!root) {
233
- throw new Error('Capture root element not found');
234
- }
235
-
236
- // Render the DOM to a canvas via html2canvas.
237
- // We're in the same document (no iframe), so COEP doesn't block cloning.
238
- const canvas = await html2canvas(root, {
239
- width,
240
- height,
241
- scale: 1,
242
- useCORS: true,
243
- allowTaint: true,
244
- backgroundColor: '#000000',
245
- logging: false,
246
- });
247
-
248
- // Convert to ImageBitmap (transferable to worker — zero-copy)
249
- const bitmap = await createImageBitmap(canvas);
250
- return bitmap;
251
- }, []);
252
-
253
- const destroy = useCallback(() => {
254
- if (rootRef.current) {
255
- rootRef.current.unmount();
256
- rootRef.current = null;
257
- }
258
- if (containerRef.current) {
259
- containerRef.current.remove();
260
- containerRef.current = null;
261
- }
262
- renderAPIRef.current = null;
263
- }, []);
264
-
265
- // Return a stable object to prevent useEffect cleanup loops
266
- // in consumers that depend on the handle reference.
267
- return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);
268
- }