@bendyline/squisq-video-react 1.0.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.
- package/dist/index.d.ts +176 -0
- package/dist/index.js +816 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/VideoExportButton.tsx +68 -0
- package/src/VideoExportModal.tsx +366 -0
- package/src/hooks/useFrameCapture.ts +263 -0
- package/src/hooks/useVideoExport.ts +343 -0
- package/src/index.ts +35 -0
- package/src/mainThreadEncoder.ts +128 -0
- package/src/mp4Mux.ts +49 -0
- package/src/workers/encode.worker.ts +384 -0
- package/src/workers/workerTypes.ts +81 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/VideoExportModal.tsx","../src/hooks/useVideoExport.ts","../src/mp4Mux.ts","../src/mainThreadEncoder.ts","../src/hooks/useFrameCapture.ts","../src/VideoExportButton.tsx"],"sourcesContent":["/**\n * VideoExportModal — Modal dialog for configuring and monitoring video export.\n *\n * States:\n * configure → exporting (capturing + encoding) → complete | error\n *\n * Inline styles match the site's cream/gold palette (from FileToolbar).\n */\n\nimport { useState, useCallback } from 'react';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport type { MediaProvider } from '@bendyline/squisq/schemas';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport type { CaptionMode } from '@bendyline/squisq-react';\nimport { useVideoExport, type VideoExportConfig } from './hooks/useVideoExport.js';\n\n// ── Types ──────────────────────────────────────────────────────────\n\nexport interface VideoExportModalProps {\n /** The document to export */\n doc: Doc;\n /** Player IIFE bundle source */\n playerScript: string;\n /** Optional media provider for resolving images/audio */\n mediaProvider?: MediaProvider;\n /** Pre-collected images map (alternative to mediaProvider) */\n images?: Map<string, ArrayBuffer>;\n /** Pre-collected audio map */\n audio?: Map<string, ArrayBuffer>;\n /** Called when the modal should close */\n onClose: () => void;\n}\n\n// ── Helpers ────────────────────────────────────────────────────\n\nfunction formatDuration(seconds: number): string {\n if (seconds < 60) return `${seconds}s`;\n const m = Math.floor(seconds / 60);\n const s = seconds % 60;\n return `${m}m ${s}s`;\n}\n\n// ── Styles ─────────────────────────────────────────────────────────\n\nconst overlayStyle: React.CSSProperties = {\n position: 'fixed',\n inset: 0,\n background: 'rgba(0, 0, 0, 0.5)',\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'center',\n zIndex: 10000,\n};\n\nconst modalStyle: React.CSSProperties = {\n background: '#FFFDF7',\n border: '1px solid #c9b98a',\n borderRadius: 0,\n padding: '24px 28px',\n minWidth: 380,\n maxWidth: 480,\n boxShadow: '0 8px 32px rgba(0,0,0,0.18)',\n fontFamily: 'system-ui, -apple-system, sans-serif',\n color: '#4a3c1f',\n};\n\nconst titleStyle: React.CSSProperties = {\n margin: '0 0 16px 0',\n fontSize: 18,\n fontWeight: 600,\n color: '#2d2310',\n};\n\nconst labelStyle: React.CSSProperties = {\n display: 'block',\n fontSize: 13,\n fontWeight: 500,\n marginBottom: 4,\n color: '#5a4a2a',\n};\n\nconst selectStyle: React.CSSProperties = {\n width: '100%',\n padding: '6px 8px',\n fontSize: 13,\n fontFamily: 'inherit',\n border: '1px solid #c9b98a',\n borderRadius: 0,\n background: '#fff',\n color: '#4a3c1f',\n marginBottom: 12,\n};\n\nconst btnPrimary: React.CSSProperties = {\n padding: '8px 20px',\n fontSize: 14,\n fontFamily: 'inherit',\n fontWeight: 500,\n cursor: 'pointer',\n background: '#8B6914',\n color: '#fff',\n border: '1px solid #7a5c10',\n borderRadius: 0,\n};\n\nconst btnSecondary: React.CSSProperties = {\n padding: '8px 20px',\n fontSize: 14,\n fontFamily: 'inherit',\n fontWeight: 500,\n cursor: 'pointer',\n background: '#E8DFC6',\n color: '#4a3c1f',\n border: '1px solid #c9b98a',\n borderRadius: 0,\n};\n\nconst progressBarOuterStyle: React.CSSProperties = {\n width: '100%',\n height: 8,\n background: '#E8DFC6',\n borderRadius: 0,\n overflow: 'hidden',\n marginBottom: 8,\n};\n\nconst footerStyle: React.CSSProperties = {\n display: 'flex',\n justifyContent: 'flex-end',\n gap: 8,\n marginTop: 20,\n};\n\n// ── Component ──────────────────────────────────────────────────────\n\nexport function VideoExportModal({\n doc,\n playerScript,\n mediaProvider,\n images,\n audio,\n onClose,\n}: VideoExportModalProps) {\n const [quality, setQuality] = useState<VideoQuality>('normal');\n const [fps, setFps] = useState(24);\n const [orientation, setOrientation] = useState<VideoOrientation>('landscape');\n const [captionMode, setCaptionMode] = useState<CaptionMode>('off');\n\n const exportHook = useVideoExport();\n const {\n state,\n progress,\n backend,\n downloadUrl,\n fileSize,\n error,\n elapsed,\n estimatedRemaining,\n startExport,\n cancel: cancelExport,\n reset: resetExport,\n } = exportHook;\n\n const handleExport = useCallback(async () => {\n const config: VideoExportConfig = {\n quality,\n fps,\n orientation,\n captionMode,\n images,\n audio,\n mediaProvider,\n playerScript,\n };\n await startExport(doc, config);\n }, [\n doc,\n quality,\n fps,\n orientation,\n captionMode,\n images,\n audio,\n mediaProvider,\n playerScript,\n startExport,\n ]);\n\n const handleDownload = useCallback(() => {\n if (!downloadUrl) return;\n const a = document.createElement('a');\n a.href = downloadUrl;\n const ts = new Date().toISOString().slice(0, 10);\n a.download = `document-${ts}.mp4`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n }, [downloadUrl]);\n\n const handleClose = useCallback(() => {\n if (state === 'capturing' || state === 'encoding' || state === 'preparing') {\n cancelExport();\n }\n resetExport();\n onClose();\n }, [state, cancelExport, resetExport, onClose]);\n\n const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';\n\n return (\n <div style={overlayStyle} onClick={handleClose}>\n <div style={modalStyle} onClick={(e) => e.stopPropagation()}>\n <h2 style={titleStyle}>Export Video</h2>\n\n {/* ── Configure State ── */}\n {state === 'idle' && (\n <>\n <div>\n <label style={labelStyle}>Quality</label>\n <select\n style={selectStyle}\n value={quality}\n onChange={(e) => setQuality(e.target.value as VideoQuality)}\n >\n <option value=\"draft\">Draft — fast, lower quality</option>\n <option value=\"normal\">Normal — balanced</option>\n <option value=\"high\">High — best quality, slower</option>\n </select>\n </div>\n\n <div>\n <label style={labelStyle}>Frame Rate</label>\n <select\n style={selectStyle}\n value={fps}\n onChange={(e) => setFps(Number(e.target.value))}\n >\n <option value={15}>15 fps — fast export</option>\n <option value={24}>24 fps — cinematic</option>\n <option value={30}>30 fps — smooth</option>\n </select>\n </div>\n\n <div>\n <label style={labelStyle}>Orientation</label>\n <select\n style={selectStyle}\n value={orientation}\n onChange={(e) => setOrientation(e.target.value as VideoOrientation)}\n >\n <option value=\"landscape\">Landscape (1920 × 1080)</option>\n <option value=\"portrait\">Portrait (1080 × 1920)</option>\n </select>\n </div>\n\n <div>\n <label style={labelStyle}>Captions</label>\n <select\n style={selectStyle}\n value={captionMode}\n onChange={(e) => setCaptionMode(e.target.value as CaptionMode)}\n >\n <option value=\"off\">None</option>\n <option value=\"standard\">Standard (top bar)</option>\n <option value=\"social\">Social media (large words)</option>\n </select>\n </div>\n\n <div style={footerStyle}>\n <button style={btnSecondary} onClick={handleClose}>\n Cancel\n </button>\n <button style={btnPrimary} onClick={handleExport}>\n Export Video\n </button>\n </div>\n </>\n )}\n\n {/* ── Exporting State ── */}\n {isExporting && (\n <>\n {backend && (\n <p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 8px 0' }}>\n Encoder: WebCodecs (H.264)\n </p>\n )}\n\n <div style={progressBarOuterStyle}>\n <div\n style={{\n width: `${progress}%`,\n height: '100%',\n background: '#8B6914',\n transition: 'width 0.3s ease',\n }}\n />\n </div>\n\n <p style={{ fontSize: 13, margin: '0 0 4px 0' }}>{progress}% complete</p>\n <p style={{ fontSize: 12, color: '#8a7a5a', margin: 0 }}>\n {formatDuration(elapsed)} elapsed\n {estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`}\n </p>\n\n <div style={footerStyle}>\n <button style={btnSecondary} onClick={cancelExport}>\n Cancel\n </button>\n </div>\n </>\n )}\n\n {/* ── Complete State ── */}\n {state === 'complete' && (\n <>\n <p style={{ fontSize: 14, margin: '0 0 8px 0', color: '#2d6a10' }}>Export complete!</p>\n <p style={{ fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }}>\n File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB\n </p>\n {backend && (\n <p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 12px 0' }}>\n Encoded with WebCodecs (H.264)\n </p>\n )}\n\n <div style={footerStyle}>\n <button style={btnSecondary} onClick={handleClose}>\n Close\n </button>\n <button style={btnPrimary} onClick={handleDownload}>\n Download MP4\n </button>\n </div>\n </>\n )}\n\n {/* ── Error State ── */}\n {state === 'error' && (\n <>\n <p style={{ fontSize: 14, margin: '0 0 8px 0', color: '#a03020' }}>Export failed</p>\n <p\n style={{\n fontSize: 13,\n color: '#5a4a2a',\n margin: '0 0 12px 0',\n wordBreak: 'break-word',\n }}\n >\n {error}\n </p>\n\n <div style={footerStyle}>\n <button style={btnSecondary} onClick={handleClose}>\n Close\n </button>\n <button style={btnPrimary} onClick={handleExport}>\n Retry\n </button>\n </div>\n </>\n )}\n </div>\n </div>\n );\n}\n","/**\n * useVideoExport — Main orchestration hook for browser video export.\n *\n * Coordinates frame capture (hidden iframe + html2canvas) with\n * main-thread WebCodecs encoding via mp4-muxer. Manages the full\n * lifecycle: prepare → capture + encode → download.\n *\n * Encoding runs on the main thread because frame capture via html2canvas\n * (~100-200ms per frame) is the bottleneck, not encoding (~1ms per frame\n * with hardware-accelerated WebCodecs). Worker offloading would add\n * complexity with minimal benefit.\n *\n * Usage:\n * const { state, progress, phase, startExport, cancel, downloadUrl } = useVideoExport();\n * <button onClick={() => startExport(doc, options)}>Export</button>\n */\n\nimport { useState, useRef, useCallback, useEffect } from 'react';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport type { MediaProvider } from '@bendyline/squisq/schemas';\nimport type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';\nimport { resolveDimensions } from '@bendyline/squisq-video';\nimport type { CaptionMode } from '@bendyline/squisq-react';\nimport { createEncoder, supportsWebCodecs, type MainThreadEncoder } from '../mainThreadEncoder.js';\nimport { useFrameCapture } from './useFrameCapture.js';\n\n// ── Types ──────────────────────────────────────────────────────────\n\nexport type VideoExportState =\n | 'idle'\n | 'preparing'\n | 'capturing'\n | 'encoding'\n | 'complete'\n | 'error';\n\nexport interface VideoExportConfig {\n /** Encoding quality preset (default: 'normal') */\n quality?: VideoQuality;\n /** Frames per second (default: 30) */\n fps?: number;\n /** Viewport orientation (default: 'landscape') */\n orientation?: VideoOrientation;\n /**\n * Map of relative image paths to binary data.\n * Used to embed images into the render HTML.\n */\n images?: Map<string, ArrayBuffer>;\n /**\n * Map of audio segment names to binary data.\n * Used to embed audio into the render HTML.\n */\n audio?: Map<string, ArrayBuffer>;\n /** MediaProvider to resolve media URLs (alternative to passing images directly) */\n mediaProvider?: MediaProvider;\n /** Caption mode for the exported video (default: 'off') */\n captionMode?: CaptionMode;\n /** Player IIFE bundle (unused in browser export, kept for CLI/Playwright path) */\n playerScript?: string;\n}\n\nexport interface VideoExportResult {\n /** Current export state */\n state: VideoExportState;\n /** 0–100 progress percentage */\n progress: number;\n /** Human-readable description of the current phase */\n phase: string;\n /** Video duration detected from the doc (seconds) */\n duration: number;\n /** Encoder backend ('webcodecs' when active, null when idle) */\n backend: 'webcodecs' | null;\n /** Blob download URL (populated when state === 'complete') */\n downloadUrl: string | null;\n /** File size in bytes (populated when state === 'complete') */\n fileSize: number;\n /** Error message (populated when state === 'error') */\n error: string | null;\n /** Seconds elapsed since export started */\n elapsed: number;\n /** Estimated seconds remaining (0 when idle or complete) */\n estimatedRemaining: number;\n /** Start a new export */\n startExport: (doc: Doc, config: VideoExportConfig) => Promise<void>;\n /** Cancel an in-progress export */\n cancel: () => void;\n /** Reset state back to idle (e.g., after complete or error) */\n reset: () => void;\n}\n\n// ── Hook ───────────────────────────────────────────────────────────\n\nexport function useVideoExport(): VideoExportResult {\n const [state, setState] = useState<VideoExportState>('idle');\n const [progress, setProgress] = useState(0);\n const [phase, setPhase] = useState('');\n const [duration, setDuration] = useState(0);\n const [backend, setBackend] = useState<'webcodecs' | null>(null);\n const [downloadUrl, setDownloadUrl] = useState<string | null>(null);\n const [fileSize, setFileSize] = useState(0);\n const [error, setError] = useState<string | null>(null);\n\n const [elapsed, setElapsed] = useState(0);\n const [estimatedRemaining, setEstimatedRemaining] = useState(0);\n\n const encoderRef = useRef<MainThreadEncoder | null>(null);\n const cancelledRef = useRef(false);\n const downloadUrlRef = useRef<string | null>(null);\n const startTimeRef = useRef<number>(0);\n const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);\n\n const frameCapture = useFrameCapture();\n\n // Clean up on unmount\n useEffect(() => {\n return () => {\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n if (downloadUrlRef.current) {\n URL.revokeObjectURL(downloadUrlRef.current);\n }\n if (encoderRef.current) {\n encoderRef.current.close();\n }\n frameCapture.destroy();\n };\n }, [frameCapture]);\n\n const reset = useCallback(() => {\n if (downloadUrlRef.current) {\n URL.revokeObjectURL(downloadUrlRef.current);\n downloadUrlRef.current = null;\n }\n if (encoderRef.current) {\n encoderRef.current.close();\n encoderRef.current = null;\n }\n frameCapture.destroy();\n setState('idle');\n setProgress(0);\n setPhase('');\n setDuration(0);\n setBackend(null);\n setDownloadUrl(null);\n setFileSize(0);\n setError(null);\n setElapsed(0);\n setEstimatedRemaining(0);\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n cancelledRef.current = false;\n }, [frameCapture]);\n\n const cancel = useCallback(() => {\n cancelledRef.current = true;\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n if (encoderRef.current) {\n encoderRef.current.close();\n encoderRef.current = null;\n }\n frameCapture.destroy();\n setState('idle');\n setProgress(0);\n setPhase('Cancelled');\n }, [frameCapture]);\n\n const startExport = useCallback(\n async (doc: Doc, config: VideoExportConfig) => {\n // Clear previous state\n cancelledRef.current = false;\n if (downloadUrlRef.current) {\n URL.revokeObjectURL(downloadUrlRef.current);\n downloadUrlRef.current = null;\n }\n setDownloadUrl(null);\n setFileSize(0);\n setError(null);\n\n const quality = config.quality ?? 'normal';\n const fps = config.fps ?? 30;\n const orientation = config.orientation ?? 'landscape';\n const { width, height } = resolveDimensions({ orientation });\n\n try {\n // ── Check browser support ─────────────────────────────────\n if (!supportsWebCodecs()) {\n throw new Error(\n 'WebCodecs is not available in this browser. ' +\n 'Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser.',\n );\n }\n\n // ── Step 1: Prepare ───────────────────────────────────────\n setState('preparing');\n setPhase('Loading document…');\n setProgress(0);\n setElapsed(0);\n setEstimatedRemaining(0);\n\n // Start elapsed timer\n startTimeRef.current = performance.now();\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n elapsedTimerRef.current = setInterval(() => {\n setElapsed(Math.floor((performance.now() - startTimeRef.current) / 1000));\n }, 1000);\n\n // Collect images from MediaProvider if provided and images not passed directly\n let images = config.images;\n if (!images && config.mediaProvider) {\n images = new Map<string, ArrayBuffer>();\n const entries = await config.mediaProvider.listMedia();\n for (const entry of entries) {\n const url = await config.mediaProvider.resolveUrl(entry.name);\n const res = await fetch(url);\n if (res.ok) {\n const data = await res.arrayBuffer();\n images.set(entry.name, data);\n }\n }\n }\n\n const docDuration = await frameCapture.init(\n doc,\n { images, audio: config.audio, width, height },\n config.captionMode,\n );\n\n if (cancelledRef.current) return;\n\n setDuration(docDuration);\n if (docDuration <= 0) {\n throw new Error('Document has zero duration — nothing to export');\n }\n\n // ── Step 2: Create encoder ────────────────────────────────\n setPhase('Starting encoder…');\n setProgress(5);\n\n const encoder = createEncoder({ width, height, fps, quality });\n encoderRef.current = encoder;\n setBackend('webcodecs');\n\n if (cancelledRef.current) return;\n\n // ── Step 3: Capture frames and encode ─────────────────────\n setState('capturing');\n const totalFrames = Math.ceil(docDuration * fps);\n\n const captureStartTime = performance.now();\n // Throttle UI updates to every ~10 frames to avoid excessive re-renders.\n // Each setState between awaits triggers a separate render cycle.\n const UI_UPDATE_INTERVAL = 10;\n\n for (let i = 0; i < totalFrames; i++) {\n if (cancelledRef.current) return;\n\n const time = i / fps;\n\n // Update UI periodically (not every frame)\n if (i % UI_UPDATE_INTERVAL === 0 || i === totalFrames - 1) {\n const captureProgress = Math.round((i / totalFrames) * 90);\n setProgress(5 + captureProgress);\n setPhase(`Capturing frame ${i + 1}/${totalFrames} (${time.toFixed(1)}s)`);\n\n if (i > 0) {\n const elapsedCapture = (performance.now() - captureStartTime) / 1000;\n const avgPerFrame = elapsedCapture / i;\n const remaining = Math.round(avgPerFrame * (totalFrames - i));\n setEstimatedRemaining(remaining);\n }\n }\n\n const bitmap = await frameCapture.captureFrame(time);\n\n if (cancelledRef.current) {\n bitmap.close();\n return;\n }\n\n // Encode immediately — WebCodecs is fast and async internally\n encoder.encodeFrame(bitmap, i);\n }\n\n if (cancelledRef.current) return;\n\n // ── Step 4: Finalize MP4 ──────────────────────────────────\n setState('encoding');\n setPhase('Finalizing video…');\n setProgress(95);\n\n const mp4Buffer = await encoder.finalize();\n encoderRef.current = null;\n\n if (cancelledRef.current) return;\n\n // ── Step 5: Create download URL ───────────────────────────\n const blob = new Blob([mp4Buffer], { type: 'video/mp4' });\n const url = URL.createObjectURL(blob);\n downloadUrlRef.current = url;\n\n setDownloadUrl(url);\n setFileSize(mp4Buffer.byteLength);\n setState('complete');\n setProgress(100);\n setPhase('Export complete');\n setEstimatedRemaining(0);\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n\n // Clean up\n frameCapture.destroy();\n } catch (err: unknown) {\n if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);\n if (cancelledRef.current) return;\n const message = err instanceof Error ? err.message : String(err);\n setState('error');\n setError(message);\n setPhase('Export failed');\n\n // Clean up on error\n if (encoderRef.current) {\n encoderRef.current.close();\n encoderRef.current = null;\n }\n frameCapture.destroy();\n }\n },\n [frameCapture],\n );\n\n return {\n state,\n progress,\n phase,\n duration,\n backend,\n downloadUrl,\n fileSize,\n error,\n elapsed,\n estimatedRemaining,\n startExport,\n cancel,\n reset,\n };\n}\n","/**\n * mp4Mux — Thin wrapper around mp4-muxer for WebCodecs encoding.\n *\n * Creates a Muxer instance configured for H.264 video, accumulates\n * encoded chunks, and produces a final MP4 ArrayBuffer.\n */\n\nimport { Muxer, ArrayBufferTarget } from 'mp4-muxer';\n\nexport interface Mp4MuxerOptions {\n width: number;\n height: number;\n fps: number;\n}\n\nexport interface Mp4MuxerHandle {\n /** Add an encoded video chunk to the muxer. */\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata): void;\n /** Finalize and return the MP4 as an ArrayBuffer. */\n finalize(): ArrayBuffer;\n}\n\n/**\n * Create an MP4 muxer configured for H.264 video.\n */\nexport function createMp4Muxer(options: Mp4MuxerOptions): Mp4MuxerHandle {\n const target = new ArrayBufferTarget();\n\n const muxer = new Muxer({\n target,\n video: {\n codec: 'avc',\n width: options.width,\n height: options.height,\n },\n fastStart: 'in-memory',\n });\n\n return {\n addVideoChunk(chunk: EncodedVideoChunk, meta?: EncodedVideoChunkMetadata) {\n muxer.addVideoChunk(chunk, meta);\n },\n\n finalize(): ArrayBuffer {\n muxer.finalize();\n return target.buffer;\n },\n };\n}\n","/**\n * Main-thread WebCodecs encoder.\n *\n * Encodes video frames to MP4 using the WebCodecs API and mp4-muxer,\n * running directly on the main thread. This is simpler and avoids\n * worker module-resolution issues with bundlers. Since frame capture\n * via html2canvas (~100-200ms per frame) is the bottleneck — not\n * encoding (~1ms per frame with hardware-accelerated WebCodecs) —\n * worker offloading provides minimal benefit.\n *\n * Requirements: Chrome 94+ / Edge 94+ (WebCodecs support).\n */\n\nimport { createMp4Muxer, type Mp4MuxerHandle } from './mp4Mux.js';\n\nexport interface EncoderConfig {\n width: number;\n height: number;\n fps: number;\n quality: 'draft' | 'normal' | 'high';\n}\n\nexport interface MainThreadEncoder {\n /** Encode a single frame. The bitmap is closed after encoding. */\n encodeFrame(bitmap: ImageBitmap, frameIndex: number): void;\n /** Flush pending frames and finalize the MP4. Returns the MP4 ArrayBuffer. */\n finalize(): Promise<ArrayBuffer>;\n /** Close the encoder without producing output (e.g., on cancel). */\n close(): void;\n}\n\n/**\n * Check whether the browser supports WebCodecs video encoding.\n */\nexport function supportsWebCodecs(): boolean {\n return typeof VideoEncoder !== 'undefined' && typeof VideoFrame !== 'undefined';\n}\n\nfunction bitrateForQuality(quality: string, width: number, height: number): number {\n const pixels = width * height;\n const baseBitrate = pixels * 4; // ~4 bits per pixel baseline\n switch (quality) {\n case 'draft':\n return Math.round(baseBitrate * 0.5);\n case 'high':\n return Math.round(baseBitrate * 2);\n default: // normal\n return baseBitrate;\n }\n}\n\n/**\n * Create a main-thread WebCodecs encoder.\n *\n * Throws if WebCodecs is not available.\n */\nexport function createEncoder(config: EncoderConfig): MainThreadEncoder {\n if (!supportsWebCodecs()) {\n throw new Error(\n 'WebCodecs is not available in this browser. ' +\n 'Video export requires Chrome 94+, Edge 94+, or another Chromium-based browser.',\n );\n }\n\n if (!config.fps || config.fps <= 0 || !config.width || !config.height) {\n throw new Error(\n `Invalid encoder config: fps=${config.fps}, width=${config.width}, height=${config.height}`,\n );\n }\n\n const muxer: Mp4MuxerHandle = createMp4Muxer({\n width: config.width,\n height: config.height,\n fps: config.fps,\n });\n\n let closed = false;\n const frameDuration = 1_000_000 / config.fps; // microseconds per frame\n\n const encoder = new VideoEncoder({\n output(chunk, meta) {\n if (closed) return;\n muxer.addVideoChunk(chunk, meta ?? undefined);\n },\n error(err) {\n console.error('WebCodecs encoder error:', err.message);\n },\n });\n\n encoder.configure({\n codec: 'avc1.640028', // H.264 High profile, level 4.0 (supports up to 1080p)\n width: config.width,\n height: config.height,\n bitrate: bitrateForQuality(config.quality, config.width, config.height),\n framerate: config.fps,\n });\n\n return {\n encodeFrame(bitmap: ImageBitmap, frameIndex: number) {\n if (closed) {\n bitmap.close();\n return;\n }\n const timestamp = Math.round(frameIndex * frameDuration);\n const frame = new VideoFrame(bitmap, { timestamp });\n const keyFrame = frameIndex % 30 === 0;\n encoder.encode(frame, { keyFrame });\n frame.close();\n bitmap.close();\n },\n\n async finalize(): Promise<ArrayBuffer> {\n if (closed) throw new Error('Encoder already closed');\n await encoder.flush();\n encoder.close();\n closed = true;\n return muxer.finalize();\n },\n\n close() {\n if (closed) return;\n closed = true;\n if (encoder.state !== 'closed') {\n encoder.close();\n }\n },\n };\n}\n","/**\n * useFrameCapture — Hidden div + html2canvas frame capture.\n *\n * Mounts a DocPlayer in renderMode inside a hidden div (same document),\n * then captures individual frames by seeking the player and rendering\n * the DOM to a canvas via html2canvas.\n *\n * Uses React directly — no script injection, no iframes, no eval.\n *\n * Returns an ImageBitmap for each frame (transferable to a Worker).\n */\n\nimport { createElement } from 'react';\nimport { createRoot, type Root } from 'react-dom/client';\nimport { useRef, useCallback, useMemo } from 'react';\nimport type { Doc, MediaProvider } from '@bendyline/squisq/schemas';\nimport type { RenderHtmlOptions } from '@bendyline/squisq-video';\nimport { DocPlayer, MediaContext } from '@bendyline/squisq-react';\nimport type { SquisqWindow, CaptionMode, CaptionStyle } from '@bendyline/squisq-react';\nimport html2canvas from 'html2canvas';\n\nexport interface FrameCaptureHandle {\n /** Initialize the hidden player. Returns the video duration in seconds. */\n init: (\n doc: Doc,\n renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,\n captionMode?: CaptionMode,\n ) => Promise<number>;\n /** Capture a single frame at the given time (seconds). Returns an ImageBitmap. */\n captureFrame: (time: number) => Promise<ImageBitmap>;\n /** Clean up resources. */\n destroy: () => void;\n}\n\n/** Extension → MIME type map (hoisted to avoid per-image allocation). */\nconst MIME_MAP: Record<string, string> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n gif: 'image/gif',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n bmp: 'image/bmp',\n avif: 'image/avif',\n};\n\n/** Convert an ArrayBuffer to a base64 data URI using chunked encoding (O(n)). */\nfunction arrayBufferToDataUrl(buffer: ArrayBuffer, mime: string): string {\n const bytes = new Uint8Array(buffer);\n const chunks: string[] = [];\n const CHUNK = 8192;\n for (let i = 0; i < bytes.length; i += CHUNK) {\n chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)));\n }\n return `data:${mime};base64,${btoa(chunks.join(''))}`;\n}\n\n/**\n * Create an inline MediaProvider from a map of paths to ArrayBuffers.\n */\nfunction createInlineProvider(images: Map<string, ArrayBuffer>): MediaProvider {\n const dataUrls = new Map<string, string>();\n const mimeTypes = new Map<string, string>();\n for (const [path, buffer] of images) {\n const ext = path.split('.').pop()?.toLowerCase() ?? '';\n const mime = MIME_MAP[ext] ?? 'application/octet-stream';\n dataUrls.set(path, arrayBufferToDataUrl(buffer, mime));\n mimeTypes.set(path, mime);\n }\n\n return {\n async resolveUrl(relativePath: string): Promise<string> {\n return dataUrls.get(relativePath) ?? relativePath;\n },\n async listMedia() {\n return [...dataUrls.keys()].map((name) => ({\n name,\n mimeType: mimeTypes.get(name) ?? 'application/octet-stream',\n size: 0,\n }));\n },\n async addMedia() {\n throw new Error('Read-only');\n },\n async removeMedia() {\n throw new Error('Read-only');\n },\n dispose() {},\n };\n}\n\n/**\n * Hook that manages a hidden div for frame capture.\n */\nexport function useFrameCapture(): FrameCaptureHandle {\n const containerRef = useRef<HTMLDivElement | null>(null);\n const rootRef = useRef<Root | null>(null);\n const dimensionsRef = useRef<{ width: number; height: number }>({ width: 1920, height: 1080 });\n\n const init = useCallback(\n async (\n doc: Doc,\n renderOptions: Omit<RenderHtmlOptions, 'playerScript'>,\n captionMode?: CaptionMode,\n ): Promise<number> => {\n // Clean up any existing container.\n // Defer unmount to avoid \"synchronously unmount a root while React\n // was already rendering\" when init() is called from a React handler.\n if (rootRef.current || containerRef.current) {\n const oldRoot = rootRef.current;\n const oldContainer = containerRef.current;\n rootRef.current = null;\n containerRef.current = null;\n await new Promise<void>((resolve) => {\n setTimeout(() => {\n if (oldRoot) oldRoot.unmount();\n if (oldContainer) oldContainer.remove();\n resolve();\n }, 0);\n });\n }\n\n const width = renderOptions.width ?? 1920;\n const height = renderOptions.height ?? 1080;\n dimensionsRef.current = { width, height };\n\n // Create a hidden container\n const container = document.createElement('div');\n container.style.cssText =\n `position:fixed;left:0;top:0;width:${width}px;height:${height}px;` +\n 'opacity:0;pointer-events:none;z-index:-1;overflow:hidden;';\n document.body.appendChild(container);\n containerRef.current = container;\n\n // Create render root\n const renderRoot = document.createElement('div');\n renderRoot.id = 'squisq-capture-root';\n renderRoot.style.cssText = `width:${width}px;height:${height}px;`;\n container.appendChild(renderRoot);\n\n // Build media provider from images\n const mediaProvider = renderOptions.images\n ? createInlineProvider(renderOptions.images)\n : null;\n\n // Mount DocPlayer in renderMode via React\n const root = createRoot(renderRoot);\n rootRef.current = root;\n\n // Derive caption props from captionMode\n const captionsEnabled = captionMode !== undefined && captionMode !== 'off';\n const captionStyle: CaptionStyle = captionMode === 'social' ? 'social' : 'standard';\n\n const playerElement = createElement(DocPlayer, {\n script: doc,\n basePath: '.',\n renderMode: true,\n showControls: false,\n autoPlay: false,\n forceViewport: { width, height, name: 'export' },\n captionsEnabled,\n captionStyle,\n });\n\n // Defer rendering to the next microtask to avoid \"synchronously unmount\n // a root while React was already rendering\" when init() is called during\n // a React render cycle (e.g., from startExport in VideoExportModal).\n await new Promise<void>((resolve) => setTimeout(resolve, 0));\n\n if (mediaProvider) {\n root.render(createElement(MediaContext.Provider, { value: mediaProvider }, playerElement));\n } else {\n root.render(playerElement);\n }\n\n // Wait for the render API to appear on window\n return new Promise<number>((resolve, reject) => {\n const timeout = setTimeout(() => {\n const w = window as SquisqWindow;\n const hasSeek = typeof w.seekTo === 'function';\n const hasDur = typeof w.getDuration === 'function';\n const rootEl = containerRef.current?.querySelector('#squisq-capture-root');\n const hasPlayer = rootEl ? rootEl.querySelector('.doc-player') !== null : false;\n reject(\n new Error(\n `Render API did not initialize within 15s. ` +\n `seekTo=${hasSeek}, getDuration=${hasDur}, player=${hasPlayer}, root=${!!rootEl}`,\n ),\n );\n }, 15000);\n\n const checkApi = () => {\n const w = window as SquisqWindow;\n if (typeof w.getDuration === 'function' && typeof w.seekTo === 'function') {\n clearTimeout(timeout);\n const duration = w.getDuration();\n resolve(duration);\n } else {\n requestAnimationFrame(checkApi);\n }\n };\n\n // Give React time to mount and run useEffects\n setTimeout(checkApi, 500);\n });\n },\n [],\n );\n\n const captureFrame = useCallback(async (time: number): Promise<ImageBitmap> => {\n const container = containerRef.current;\n const w = window as SquisqWindow;\n if (!container || typeof w.seekTo !== 'function') {\n throw new Error('Frame capture not initialized — call init() first');\n }\n\n const { width, height } = dimensionsRef.current;\n\n // Seek the player to the target time\n await w.seekTo(time);\n\n // Wait for the DOM to update after seek\n await new Promise<void>((resolve) =>\n requestAnimationFrame(() => requestAnimationFrame(() => resolve())),\n );\n\n const root = container.querySelector('#squisq-capture-root') as HTMLElement;\n if (!root) {\n throw new Error('Capture root element not found');\n }\n\n // Render the DOM to a canvas via html2canvas.\n // We're in the same document (no iframe), so COEP doesn't block cloning.\n const canvas = await html2canvas(root, {\n width,\n height,\n scale: 1,\n useCORS: true,\n allowTaint: true,\n backgroundColor: '#000000',\n logging: false,\n });\n\n // Convert to ImageBitmap (transferable to worker — zero-copy)\n const bitmap = await createImageBitmap(canvas);\n return bitmap;\n }, []);\n\n const destroy = useCallback(() => {\n if (rootRef.current) {\n rootRef.current.unmount();\n rootRef.current = null;\n }\n if (containerRef.current) {\n containerRef.current.remove();\n containerRef.current = null;\n }\n }, []);\n\n // Return a stable object to prevent useEffect cleanup loops\n // in consumers that depend on the handle reference.\n return useMemo(() => ({ init, captureFrame, destroy }), [init, captureFrame, destroy]);\n}\n","/**\n * VideoExportButton — Simple button that opens the VideoExportModal in a portal.\n *\n * Convenience wrapper for consumers who want a drop-in button.\n * The modal renders via `createPortal` into `document.body`.\n */\n\nimport { useState, useCallback } from 'react';\nimport { createPortal } from 'react-dom';\nimport type { Doc } from '@bendyline/squisq/schemas';\nimport type { MediaProvider } from '@bendyline/squisq/schemas';\nimport { VideoExportModal } from './VideoExportModal.js';\n\nexport interface VideoExportButtonProps {\n /** The document to export */\n doc: Doc;\n /** Player IIFE bundle source */\n playerScript: string;\n /** Optional media provider for resolving images/audio */\n mediaProvider?: MediaProvider;\n /** Pre-collected images map */\n images?: Map<string, ArrayBuffer>;\n /** Pre-collected audio map */\n audio?: Map<string, ArrayBuffer>;\n /** Button label (default: \"Export Video\") */\n label?: string;\n /** Additional inline styles for the button */\n style?: React.CSSProperties;\n /** Whether the button is disabled */\n disabled?: boolean;\n}\n\nexport function VideoExportButton({\n doc,\n playerScript,\n mediaProvider,\n images,\n audio,\n label = 'Export Video',\n style,\n disabled,\n}: VideoExportButtonProps) {\n const [showModal, setShowModal] = useState(false);\n\n const handleOpen = useCallback(() => setShowModal(true), []);\n const handleClose = useCallback(() => setShowModal(false), []);\n\n return (\n <>\n <button onClick={handleOpen} disabled={disabled} style={style}>\n {label}\n </button>\n\n {showModal &&\n createPortal(\n <VideoExportModal\n doc={doc}\n playerScript={playerScript}\n mediaProvider={mediaProvider}\n images={images}\n audio={audio}\n onClose={handleClose}\n />,\n document.body,\n )}\n </>\n );\n}\n"],"mappings":"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","names":["useState","useCallback","useRef","useCallback","useRef","useCallback","url","useState","useCallback","useState","useCallback","Fragment","jsx","jsxs","useState","useCallback"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bendyline/squisq-video-react",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "React components for browser-based video export of Squisq documents via WebCodecs",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bendyline",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/bendyline/squisq.git",
|
|
10
|
+
"directory": "packages/video-react"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/bendyline/squisq",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"squisq",
|
|
15
|
+
"video",
|
|
16
|
+
"mp4",
|
|
17
|
+
"react",
|
|
18
|
+
"webcodecs",
|
|
19
|
+
"export"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": {
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"src"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "tsup",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
44
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@bendyline/squisq": "1.1.0",
|
|
48
|
+
"@bendyline/squisq-video": "1.0.0",
|
|
49
|
+
"@bendyline/squisq-react": "1.0.2",
|
|
50
|
+
"html2canvas": "^1.4.1",
|
|
51
|
+
"mp4-muxer": "^5.1.3"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/react": "^18.0.0",
|
|
55
|
+
"@types/react-dom": "^18.0.0",
|
|
56
|
+
"react": "^18.0.0",
|
|
57
|
+
"react-dom": "^18.0.0",
|
|
58
|
+
"tsup": "^8.0.0",
|
|
59
|
+
"typescript": "^5.3.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
|
|
8
|
+
import { useState, useCallback } from 'react';
|
|
9
|
+
import { createPortal } from 'react-dom';
|
|
10
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
11
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
12
|
+
import { VideoExportModal } from './VideoExportModal.js';
|
|
13
|
+
|
|
14
|
+
export interface VideoExportButtonProps {
|
|
15
|
+
/** The document to export */
|
|
16
|
+
doc: Doc;
|
|
17
|
+
/** Player IIFE bundle source */
|
|
18
|
+
playerScript: string;
|
|
19
|
+
/** Optional media provider for resolving images/audio */
|
|
20
|
+
mediaProvider?: MediaProvider;
|
|
21
|
+
/** Pre-collected images map */
|
|
22
|
+
images?: Map<string, ArrayBuffer>;
|
|
23
|
+
/** Pre-collected audio map */
|
|
24
|
+
audio?: Map<string, ArrayBuffer>;
|
|
25
|
+
/** Button label (default: "Export Video") */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** Additional inline styles for the button */
|
|
28
|
+
style?: React.CSSProperties;
|
|
29
|
+
/** Whether the button is disabled */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function VideoExportButton({
|
|
34
|
+
doc,
|
|
35
|
+
playerScript,
|
|
36
|
+
mediaProvider,
|
|
37
|
+
images,
|
|
38
|
+
audio,
|
|
39
|
+
label = 'Export Video',
|
|
40
|
+
style,
|
|
41
|
+
disabled,
|
|
42
|
+
}: VideoExportButtonProps) {
|
|
43
|
+
const [showModal, setShowModal] = useState(false);
|
|
44
|
+
|
|
45
|
+
const handleOpen = useCallback(() => setShowModal(true), []);
|
|
46
|
+
const handleClose = useCallback(() => setShowModal(false), []);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<button onClick={handleOpen} disabled={disabled} style={style}>
|
|
51
|
+
{label}
|
|
52
|
+
</button>
|
|
53
|
+
|
|
54
|
+
{showModal &&
|
|
55
|
+
createPortal(
|
|
56
|
+
<VideoExportModal
|
|
57
|
+
doc={doc}
|
|
58
|
+
playerScript={playerScript}
|
|
59
|
+
mediaProvider={mediaProvider}
|
|
60
|
+
images={images}
|
|
61
|
+
audio={audio}
|
|
62
|
+
onClose={handleClose}
|
|
63
|
+
/>,
|
|
64
|
+
document.body,
|
|
65
|
+
)}
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
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
|
+
|
|
10
|
+
import { useState, useCallback } from 'react';
|
|
11
|
+
import type { Doc } from '@bendyline/squisq/schemas';
|
|
12
|
+
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
13
|
+
import type { VideoQuality, VideoOrientation } from '@bendyline/squisq-video';
|
|
14
|
+
import type { CaptionMode } from '@bendyline/squisq-react';
|
|
15
|
+
import { useVideoExport, type VideoExportConfig } from './hooks/useVideoExport.js';
|
|
16
|
+
|
|
17
|
+
// ── Types ──────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export interface VideoExportModalProps {
|
|
20
|
+
/** The document to export */
|
|
21
|
+
doc: Doc;
|
|
22
|
+
/** Player IIFE bundle source */
|
|
23
|
+
playerScript: string;
|
|
24
|
+
/** Optional media provider for resolving images/audio */
|
|
25
|
+
mediaProvider?: MediaProvider;
|
|
26
|
+
/** Pre-collected images map (alternative to mediaProvider) */
|
|
27
|
+
images?: Map<string, ArrayBuffer>;
|
|
28
|
+
/** Pre-collected audio map */
|
|
29
|
+
audio?: Map<string, ArrayBuffer>;
|
|
30
|
+
/** Called when the modal should close */
|
|
31
|
+
onClose: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Helpers ────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
function formatDuration(seconds: number): string {
|
|
37
|
+
if (seconds < 60) return `${seconds}s`;
|
|
38
|
+
const m = Math.floor(seconds / 60);
|
|
39
|
+
const s = seconds % 60;
|
|
40
|
+
return `${m}m ${s}s`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Styles ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
const overlayStyle: React.CSSProperties = {
|
|
46
|
+
position: 'fixed',
|
|
47
|
+
inset: 0,
|
|
48
|
+
background: 'rgba(0, 0, 0, 0.5)',
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
justifyContent: 'center',
|
|
52
|
+
zIndex: 10000,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const modalStyle: React.CSSProperties = {
|
|
56
|
+
background: '#FFFDF7',
|
|
57
|
+
border: '1px solid #c9b98a',
|
|
58
|
+
borderRadius: 0,
|
|
59
|
+
padding: '24px 28px',
|
|
60
|
+
minWidth: 380,
|
|
61
|
+
maxWidth: 480,
|
|
62
|
+
boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
|
|
63
|
+
fontFamily: 'system-ui, -apple-system, sans-serif',
|
|
64
|
+
color: '#4a3c1f',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const titleStyle: React.CSSProperties = {
|
|
68
|
+
margin: '0 0 16px 0',
|
|
69
|
+
fontSize: 18,
|
|
70
|
+
fontWeight: 600,
|
|
71
|
+
color: '#2d2310',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const labelStyle: React.CSSProperties = {
|
|
75
|
+
display: 'block',
|
|
76
|
+
fontSize: 13,
|
|
77
|
+
fontWeight: 500,
|
|
78
|
+
marginBottom: 4,
|
|
79
|
+
color: '#5a4a2a',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const selectStyle: React.CSSProperties = {
|
|
83
|
+
width: '100%',
|
|
84
|
+
padding: '6px 8px',
|
|
85
|
+
fontSize: 13,
|
|
86
|
+
fontFamily: 'inherit',
|
|
87
|
+
border: '1px solid #c9b98a',
|
|
88
|
+
borderRadius: 0,
|
|
89
|
+
background: '#fff',
|
|
90
|
+
color: '#4a3c1f',
|
|
91
|
+
marginBottom: 12,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const btnPrimary: React.CSSProperties = {
|
|
95
|
+
padding: '8px 20px',
|
|
96
|
+
fontSize: 14,
|
|
97
|
+
fontFamily: 'inherit',
|
|
98
|
+
fontWeight: 500,
|
|
99
|
+
cursor: 'pointer',
|
|
100
|
+
background: '#8B6914',
|
|
101
|
+
color: '#fff',
|
|
102
|
+
border: '1px solid #7a5c10',
|
|
103
|
+
borderRadius: 0,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const btnSecondary: React.CSSProperties = {
|
|
107
|
+
padding: '8px 20px',
|
|
108
|
+
fontSize: 14,
|
|
109
|
+
fontFamily: 'inherit',
|
|
110
|
+
fontWeight: 500,
|
|
111
|
+
cursor: 'pointer',
|
|
112
|
+
background: '#E8DFC6',
|
|
113
|
+
color: '#4a3c1f',
|
|
114
|
+
border: '1px solid #c9b98a',
|
|
115
|
+
borderRadius: 0,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const progressBarOuterStyle: React.CSSProperties = {
|
|
119
|
+
width: '100%',
|
|
120
|
+
height: 8,
|
|
121
|
+
background: '#E8DFC6',
|
|
122
|
+
borderRadius: 0,
|
|
123
|
+
overflow: 'hidden',
|
|
124
|
+
marginBottom: 8,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const footerStyle: React.CSSProperties = {
|
|
128
|
+
display: 'flex',
|
|
129
|
+
justifyContent: 'flex-end',
|
|
130
|
+
gap: 8,
|
|
131
|
+
marginTop: 20,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// ── Component ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
export function VideoExportModal({
|
|
137
|
+
doc,
|
|
138
|
+
playerScript,
|
|
139
|
+
mediaProvider,
|
|
140
|
+
images,
|
|
141
|
+
audio,
|
|
142
|
+
onClose,
|
|
143
|
+
}: VideoExportModalProps) {
|
|
144
|
+
const [quality, setQuality] = useState<VideoQuality>('normal');
|
|
145
|
+
const [fps, setFps] = useState(24);
|
|
146
|
+
const [orientation, setOrientation] = useState<VideoOrientation>('landscape');
|
|
147
|
+
const [captionMode, setCaptionMode] = useState<CaptionMode>('off');
|
|
148
|
+
|
|
149
|
+
const exportHook = useVideoExport();
|
|
150
|
+
const {
|
|
151
|
+
state,
|
|
152
|
+
progress,
|
|
153
|
+
backend,
|
|
154
|
+
downloadUrl,
|
|
155
|
+
fileSize,
|
|
156
|
+
error,
|
|
157
|
+
elapsed,
|
|
158
|
+
estimatedRemaining,
|
|
159
|
+
startExport,
|
|
160
|
+
cancel: cancelExport,
|
|
161
|
+
reset: resetExport,
|
|
162
|
+
} = exportHook;
|
|
163
|
+
|
|
164
|
+
const handleExport = useCallback(async () => {
|
|
165
|
+
const config: VideoExportConfig = {
|
|
166
|
+
quality,
|
|
167
|
+
fps,
|
|
168
|
+
orientation,
|
|
169
|
+
captionMode,
|
|
170
|
+
images,
|
|
171
|
+
audio,
|
|
172
|
+
mediaProvider,
|
|
173
|
+
playerScript,
|
|
174
|
+
};
|
|
175
|
+
await startExport(doc, config);
|
|
176
|
+
}, [
|
|
177
|
+
doc,
|
|
178
|
+
quality,
|
|
179
|
+
fps,
|
|
180
|
+
orientation,
|
|
181
|
+
captionMode,
|
|
182
|
+
images,
|
|
183
|
+
audio,
|
|
184
|
+
mediaProvider,
|
|
185
|
+
playerScript,
|
|
186
|
+
startExport,
|
|
187
|
+
]);
|
|
188
|
+
|
|
189
|
+
const handleDownload = useCallback(() => {
|
|
190
|
+
if (!downloadUrl) return;
|
|
191
|
+
const a = document.createElement('a');
|
|
192
|
+
a.href = downloadUrl;
|
|
193
|
+
const ts = new Date().toISOString().slice(0, 10);
|
|
194
|
+
a.download = `document-${ts}.mp4`;
|
|
195
|
+
document.body.appendChild(a);
|
|
196
|
+
a.click();
|
|
197
|
+
document.body.removeChild(a);
|
|
198
|
+
}, [downloadUrl]);
|
|
199
|
+
|
|
200
|
+
const handleClose = useCallback(() => {
|
|
201
|
+
if (state === 'capturing' || state === 'encoding' || state === 'preparing') {
|
|
202
|
+
cancelExport();
|
|
203
|
+
}
|
|
204
|
+
resetExport();
|
|
205
|
+
onClose();
|
|
206
|
+
}, [state, cancelExport, resetExport, onClose]);
|
|
207
|
+
|
|
208
|
+
const isExporting = state === 'preparing' || state === 'capturing' || state === 'encoding';
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div style={overlayStyle} onClick={handleClose}>
|
|
212
|
+
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
|
213
|
+
<h2 style={titleStyle}>Export Video</h2>
|
|
214
|
+
|
|
215
|
+
{/* ── Configure State ── */}
|
|
216
|
+
{state === 'idle' && (
|
|
217
|
+
<>
|
|
218
|
+
<div>
|
|
219
|
+
<label style={labelStyle}>Quality</label>
|
|
220
|
+
<select
|
|
221
|
+
style={selectStyle}
|
|
222
|
+
value={quality}
|
|
223
|
+
onChange={(e) => setQuality(e.target.value as VideoQuality)}
|
|
224
|
+
>
|
|
225
|
+
<option value="draft">Draft — fast, lower quality</option>
|
|
226
|
+
<option value="normal">Normal — balanced</option>
|
|
227
|
+
<option value="high">High — best quality, slower</option>
|
|
228
|
+
</select>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div>
|
|
232
|
+
<label style={labelStyle}>Frame Rate</label>
|
|
233
|
+
<select
|
|
234
|
+
style={selectStyle}
|
|
235
|
+
value={fps}
|
|
236
|
+
onChange={(e) => setFps(Number(e.target.value))}
|
|
237
|
+
>
|
|
238
|
+
<option value={15}>15 fps — fast export</option>
|
|
239
|
+
<option value={24}>24 fps — cinematic</option>
|
|
240
|
+
<option value={30}>30 fps — smooth</option>
|
|
241
|
+
</select>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<div>
|
|
245
|
+
<label style={labelStyle}>Orientation</label>
|
|
246
|
+
<select
|
|
247
|
+
style={selectStyle}
|
|
248
|
+
value={orientation}
|
|
249
|
+
onChange={(e) => setOrientation(e.target.value as VideoOrientation)}
|
|
250
|
+
>
|
|
251
|
+
<option value="landscape">Landscape (1920 × 1080)</option>
|
|
252
|
+
<option value="portrait">Portrait (1080 × 1920)</option>
|
|
253
|
+
</select>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div>
|
|
257
|
+
<label style={labelStyle}>Captions</label>
|
|
258
|
+
<select
|
|
259
|
+
style={selectStyle}
|
|
260
|
+
value={captionMode}
|
|
261
|
+
onChange={(e) => setCaptionMode(e.target.value as CaptionMode)}
|
|
262
|
+
>
|
|
263
|
+
<option value="off">None</option>
|
|
264
|
+
<option value="standard">Standard (top bar)</option>
|
|
265
|
+
<option value="social">Social media (large words)</option>
|
|
266
|
+
</select>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
<div style={footerStyle}>
|
|
270
|
+
<button style={btnSecondary} onClick={handleClose}>
|
|
271
|
+
Cancel
|
|
272
|
+
</button>
|
|
273
|
+
<button style={btnPrimary} onClick={handleExport}>
|
|
274
|
+
Export Video
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
</>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{/* ── Exporting State ── */}
|
|
281
|
+
{isExporting && (
|
|
282
|
+
<>
|
|
283
|
+
{backend && (
|
|
284
|
+
<p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 8px 0' }}>
|
|
285
|
+
Encoder: WebCodecs (H.264)
|
|
286
|
+
</p>
|
|
287
|
+
)}
|
|
288
|
+
|
|
289
|
+
<div style={progressBarOuterStyle}>
|
|
290
|
+
<div
|
|
291
|
+
style={{
|
|
292
|
+
width: `${progress}%`,
|
|
293
|
+
height: '100%',
|
|
294
|
+
background: '#8B6914',
|
|
295
|
+
transition: 'width 0.3s ease',
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<p style={{ fontSize: 13, margin: '0 0 4px 0' }}>{progress}% complete</p>
|
|
301
|
+
<p style={{ fontSize: 12, color: '#8a7a5a', margin: 0 }}>
|
|
302
|
+
{formatDuration(elapsed)} elapsed
|
|
303
|
+
{estimatedRemaining > 0 && ` · ~${formatDuration(estimatedRemaining)} remaining`}
|
|
304
|
+
</p>
|
|
305
|
+
|
|
306
|
+
<div style={footerStyle}>
|
|
307
|
+
<button style={btnSecondary} onClick={cancelExport}>
|
|
308
|
+
Cancel
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
</>
|
|
312
|
+
)}
|
|
313
|
+
|
|
314
|
+
{/* ── Complete State ── */}
|
|
315
|
+
{state === 'complete' && (
|
|
316
|
+
<>
|
|
317
|
+
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: '#2d6a10' }}>Export complete!</p>
|
|
318
|
+
<p style={{ fontSize: 13, color: '#5a4a2a', margin: '0 0 4px 0' }}>
|
|
319
|
+
File size: {(fileSize / (1024 * 1024)).toFixed(1)} MB
|
|
320
|
+
</p>
|
|
321
|
+
{backend && (
|
|
322
|
+
<p style={{ fontSize: 12, color: '#8a7a5a', margin: '0 0 12px 0' }}>
|
|
323
|
+
Encoded with WebCodecs (H.264)
|
|
324
|
+
</p>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
<div style={footerStyle}>
|
|
328
|
+
<button style={btnSecondary} onClick={handleClose}>
|
|
329
|
+
Close
|
|
330
|
+
</button>
|
|
331
|
+
<button style={btnPrimary} onClick={handleDownload}>
|
|
332
|
+
Download MP4
|
|
333
|
+
</button>
|
|
334
|
+
</div>
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{/* ── Error State ── */}
|
|
339
|
+
{state === 'error' && (
|
|
340
|
+
<>
|
|
341
|
+
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: '#a03020' }}>Export failed</p>
|
|
342
|
+
<p
|
|
343
|
+
style={{
|
|
344
|
+
fontSize: 13,
|
|
345
|
+
color: '#5a4a2a',
|
|
346
|
+
margin: '0 0 12px 0',
|
|
347
|
+
wordBreak: 'break-word',
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
350
|
+
{error}
|
|
351
|
+
</p>
|
|
352
|
+
|
|
353
|
+
<div style={footerStyle}>
|
|
354
|
+
<button style={btnSecondary} onClick={handleClose}>
|
|
355
|
+
Close
|
|
356
|
+
</button>
|
|
357
|
+
<button style={btnPrimary} onClick={handleExport}>
|
|
358
|
+
Retry
|
|
359
|
+
</button>
|
|
360
|
+
</div>
|
|
361
|
+
</>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|