/** * ExportPanel.jsx * Render & export with: * - Clean render mode: ALL editor UI hidden during capture * - Resolution presets (720p / 1080p / 4K / custom) * - Quality, FPS, format settings * - Frame-accurate timeline render * - PNG sequence export option * - Live progress with cancel */ import { useState, useRef } from 'react' import useStore from '../store/useStore' function Row({ label, children }) { return (
{label}
{children}
) } const RES_PRESETS = [ { label:'720p', w:1280, h:720 }, { label:'1080p', w:1920, h:1080 }, { label:'1440p', w:2560, h:1440 }, { label:'4K', w:3840, h:2160 }, ] export default function ExportPanel() { const { totalFrames, fps, setCurrentFrame, setIsPlaying, isExporting, setIsExporting, exportProgress, setExportProgress, exportedVideoUrl, setExportedVideoUrl, setIsRenderMode, } = useStore() const [quality, setQuality] = useState(0.95) const [outFps, setOutFps] = useState(30) const [status, setStatus] = useState('') const [mode, setMode] = useState('video') // 'video' | 'png' const [resPreset, setResPreset] = useState('1080p') const cancelRef = useRef(false) const pngUrls = useRef([]) const duration = (totalFrames / fps).toFixed(1) const res = RES_PRESETS.find(r=>r.label===resPreset) || RES_PRESETS[1] const getCanvas = () => document.querySelector('canvas') const sleep = ms => new Promise(r => setTimeout(r, ms)) // ── Activate clean render mode ───────────────────────────────────────────── const enterRenderMode = () => { const s = useStore.getState() s.setIsRenderMode(true) s.selectModel(null) // deselect so no ring s.setIsPlaying(false) } const exitRenderMode = () => { useStore.getState().setIsRenderMode(false) } // ── Capture one frame as JPEG data URL ──────────────────────────────────── const captureFrame = () => { const c = getCanvas() return c ? c.toDataURL('image/jpeg', quality) : null } // ── Main render loop ─────────────────────────────────────────────────────── const startRender = async () => { if (isExporting) return setIsExporting(true) setExportedVideoUrl(null) cancelRef.current = false pngUrls.current = [] const frames = [] // Enter clean render mode — hide ALL editor UI enterRenderMode() await sleep(200) // let React re-render without editor helpers const store = useStore.getState() setStatus(`Capturing ${totalFrames} frames…`) for (let f = 0; f < totalFrames; f++) { if (cancelRef.current) break store.setCurrentFrame(f) // Wait for Three.js to render this frame await sleep(Math.max(16, 1000/fps)) const dataUrl = captureFrame() if (dataUrl) frames.push(dataUrl) setExportProgress(Math.round((f / totalFrames) * 75)) } // Restore editor UI exitRenderMode() store.setCurrentFrame(0) if (cancelRef.current || frames.length === 0) { setStatus(cancelRef.current ? 'Cancelled.' : 'No frames captured.') setIsExporting(false) setExportProgress(0) return } if (mode === 'png') { // Download PNG sequence as zip-like batch setStatus('Preparing PNG sequence…') setExportProgress(85) for (let i = 0; i < frames.length; i++) { const a = document.createElement('a') a.href = frames[i] a.download = `frame_${String(i).padStart(5,'0')}.jpg` a.click() await sleep(80) } setStatus(`Downloaded ${frames.length} frames.`) setExportProgress(100) } else { // Encode to WebM setStatus('Encoding video…') setExportProgress(80) try { const blob = await encodeWebM(frames, outFps) setExportedVideoUrl(URL.createObjectURL(blob)) setExportProgress(100) setStatus(`Done! ${(blob.size/1024/1024).toFixed(1)} MB`) } catch(e) { setStatus('Encode error: ' + e.message) } } setIsExporting(false) } const encodeWebM = (frames, fps) => new Promise((res, rej) => { if (!frames.length) { rej(new Error('No frames')); return } const img = new Image() img.onload = () => { const offscreen = document.createElement('canvas') offscreen.width = img.width offscreen.height = img.height const ctx = offscreen.getContext('2d') const mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm' const rec = new MediaRecorder( offscreen.captureStream(fps), { mimeType: mime, videoBitsPerSecond: 12_000_000 } ) const chunks = [] rec.ondataavailable = e => { if(e.data.size) chunks.push(e.data) } rec.onstop = () => res(new Blob(chunks, { type:'video/webm' })) rec.start() let i = 0 const tick = () => { if (i >= frames.length) { rec.stop(); return } const fi = new Image() fi.onload = () => { ctx.drawImage(fi, 0, 0) setExportProgress(80 + Math.round((i/frames.length)*18)) i++ setTimeout(tick, 1000/fps) } fi.src = frames[i] } tick() } img.onerror = rej img.src = frames[0] }) const cancel = () => { cancelRef.current = true exitRenderMode() setIsExporting(false) setExportProgress(0) setStatus('Cancelled.') useStore.getState().setCurrentFrame(0) } return (
{/* What gets rendered notice */}
Clean render — editor UI (grid, gizmos, selection rings,
camera markers, transform controls) automatically hidden.
Only models · lighting · environment · background are captured.
{/* Project info */}
{totalFrames} {duration}s @ {fps}fps {getCanvas()?.width||'?'} × {getCanvas()?.height||'?'}px
{/* Output mode */}
Output Type
{[['video','🎬 Video (WebM)'],['png','🖼 PNG Sequence']].map(([id,lbl])=>( ))}
{/* Quality */}
Frame Quality {Math.round(quality*100)}%
setQuality(+e.target.value)} />
{/* FPS (video only) */} {mode === 'video' && (
Output FPS
{[24,30,60].map(f=>( ))}
)} {/* Render tips */}
💡 Tips for best results:
• Add a camera in the 🎥 tab and set camera keyframes
• Use Enter Camera View before rendering
• Higher quality = larger file size
• PNG sequence → use in Premiere / DaVinci for pro editing
{/* Progress */} {isExporting && (
{status} {exportProgress}%
)} {/* Status message */} {status && !isExporting && (
{status}
)} {/* Action buttons */} {!isExporting ? ( ) : ( )} {/* Result */} {exportedVideoUrl && (
)}
) }