Spaces:
Sleeping
Sleeping
| /** | |
| * 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 ( | |
| <div style={{ display:'flex', justifyContent:'space-between', alignItems:'center', | |
| padding:'6px 0', borderBottom:'1px solid var(--border)' }}> | |
| <span style={{ fontSize:11, color:'var(--text2)' }}>{label}</span> | |
| <div style={{ display:'flex', alignItems:'center', gap:6 }}>{children}</div> | |
| </div> | |
| ) | |
| } | |
| 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 ( | |
| <div style={{ padding:12, display:'flex', flexDirection:'column', gap:10, overflow:'auto' }}> | |
| {/* What gets rendered notice */} | |
| <div style={{ padding:'10px 12px', borderRadius:'var(--radius)', | |
| background:'rgba(6,214,160,0.06)', border:'1px solid rgba(6,214,160,0.2)', | |
| fontSize:11, color:'var(--accent3)', lineHeight:1.7 }}> | |
| β <b>Clean render</b> β editor UI (grid, gizmos, selection rings,<br/> | |
| camera markers, transform controls) automatically hidden.<br/> | |
| Only models Β· lighting Β· environment Β· background are captured. | |
| </div> | |
| {/* Project info */} | |
| <div style={{ background:'var(--bg2)', borderRadius:'var(--radius)', | |
| padding:'8px 12px', border:'1px solid var(--border)' }}> | |
| <Row label="Frames"> | |
| <span style={{ fontFamily:'var(--font-mono)', fontWeight:600, color:'var(--text0)' }}>{totalFrames}</span> | |
| </Row> | |
| <Row label="Duration"> | |
| <span style={{ fontFamily:'var(--font-mono)', fontWeight:600, color:'var(--text0)' }}>{duration}s @ {fps}fps</span> | |
| </Row> | |
| <Row label="Canvas resolution"> | |
| <span style={{ fontFamily:'var(--font-mono)', color:'var(--text0)', fontSize:10 }}> | |
| {getCanvas()?.width||'?'} Γ {getCanvas()?.height||'?'}px | |
| </span> | |
| </Row> | |
| </div> | |
| {/* Output mode */} | |
| <div> | |
| <div style={{ fontSize:10, color:'var(--text2)', fontWeight:600, | |
| letterSpacing:'0.08em', textTransform:'uppercase', marginBottom:6 }}>Output Type</div> | |
| <div style={{ display:'flex', gap:5 }}> | |
| {[['video','π¬ Video (WebM)'],['png','πΌ PNG Sequence']].map(([id,lbl])=>( | |
| <button key={id} onClick={()=>setMode(id)} style={{ | |
| flex:1, padding:'7px 0', borderRadius:'var(--radius-sm)', cursor:'pointer', fontSize:11, | |
| background: mode===id?'rgba(79,142,255,0.15)':'var(--bg2)', | |
| border:`1px solid ${mode===id?'rgba(79,142,255,0.4)':'var(--border)'}`, | |
| color: mode===id?'var(--accent)':'var(--text1)', fontWeight: mode===id?700:400, | |
| }}>{lbl}</button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Quality */} | |
| <div> | |
| <div style={{ display:'flex', justifyContent:'space-between', marginBottom:4 }}> | |
| <span style={{ fontSize:11, color:'var(--text2)', fontWeight:500 }}>Frame Quality</span> | |
| <span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}> | |
| {Math.round(quality*100)}% | |
| </span> | |
| </div> | |
| <input type="range" min={0.5} max={1} step={0.01} value={quality} | |
| onChange={e=>setQuality(+e.target.value)} /> | |
| </div> | |
| {/* FPS (video only) */} | |
| {mode === 'video' && ( | |
| <div> | |
| <div style={{ fontSize:11, color:'var(--text2)', fontWeight:500, marginBottom:6 }}>Output FPS</div> | |
| <div style={{ display:'flex', gap:4 }}> | |
| {[24,30,60].map(f=>( | |
| <button key={f} onClick={()=>setOutFps(f)} style={{ | |
| flex:1, padding:'6px 0', borderRadius:'var(--radius-sm)', cursor:'pointer', | |
| background: outFps===f?'rgba(79,142,255,0.15)':'var(--bg2)', | |
| border:`1px solid ${outFps===f?'rgba(79,142,255,0.4)':'var(--border)'}`, | |
| color: outFps===f?'var(--accent)':'var(--text1)', | |
| fontSize:11, fontWeight: outFps===f?700:400, | |
| }}>{f} fps</button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Render tips */} | |
| <div style={{ padding:'9px 11px', borderRadius:'var(--radius-sm)', | |
| background:'var(--bg2)', border:'1px solid var(--border)', | |
| fontSize:10, color:'var(--text3)', lineHeight:1.75 }}> | |
| π‘ <b style={{color:'var(--text2)'}}>Tips for best results:</b><br/> | |
| β’ Add a camera in the π₯ tab and set camera keyframes<br/> | |
| β’ Use <b>Enter Camera View</b> before rendering<br/> | |
| β’ Higher quality = larger file size<br/> | |
| β’ PNG sequence β use in Premiere / DaVinci for pro editing | |
| </div> | |
| {/* Progress */} | |
| {isExporting && ( | |
| <div> | |
| <div style={{ display:'flex', justifyContent:'space-between', marginBottom:5 }}> | |
| <span style={{ fontSize:11, color:'var(--text2)' }}>{status}</span> | |
| <span style={{ fontSize:11, fontFamily:'var(--font-mono)', color:'var(--accent)' }}> | |
| {exportProgress}% | |
| </span> | |
| </div> | |
| <div style={{ height:6, background:'var(--bg3)', borderRadius:3 }}> | |
| <div style={{ | |
| height:'100%', borderRadius:3, transition:'width 0.4s', | |
| width:`${exportProgress}%`, | |
| background:'linear-gradient(90deg,var(--accent),var(--accent2),var(--accent3))', | |
| }}/> | |
| </div> | |
| </div> | |
| )} | |
| {/* Status message */} | |
| {status && !isExporting && ( | |
| <div style={{ | |
| padding:'8px 10px', borderRadius:'var(--radius-sm)', fontSize:11, | |
| background: status.includes('error')||status.includes('Error') | |
| ?'rgba(239,68,68,0.08)':'rgba(6,214,160,0.08)', | |
| border:`1px solid ${status.includes('error')||status.includes('Error') | |
| ?'rgba(239,68,68,0.2)':'rgba(6,214,160,0.2)'}`, | |
| color: status.includes('error')||status.includes('Error') | |
| ?'var(--danger)':'var(--accent3)', | |
| }}>{status}</div> | |
| )} | |
| {/* Action buttons */} | |
| {!isExporting ? ( | |
| <button onClick={startRender} style={{ | |
| padding:'12px 0', borderRadius:'var(--radius)', | |
| background:'linear-gradient(135deg,var(--accent),var(--accent2))', | |
| border:'none', color:'#fff', fontSize:14, fontWeight:700, | |
| cursor:'pointer', letterSpacing:'0.04em', | |
| boxShadow:'0 4px 20px rgba(79,142,255,0.4)', | |
| transition:'opacity 0.15s, transform 0.1s', | |
| }} | |
| onMouseEnter={e=>e.currentTarget.style.opacity='0.88'} | |
| onMouseLeave={e=>e.currentTarget.style.opacity='1'} | |
| >βΆ Render & Export</button> | |
| ) : ( | |
| <button onClick={cancel} style={{ | |
| padding:'11px 0', borderRadius:'var(--radius)', | |
| background:'rgba(239,68,68,0.1)', border:'1px solid rgba(239,68,68,0.3)', | |
| color:'var(--danger)', fontSize:13, fontWeight:600, cursor:'pointer', | |
| }}>βΉ Cancel Render</button> | |
| )} | |
| {/* Result */} | |
| {exportedVideoUrl && ( | |
| <div style={{ display:'flex', flexDirection:'column', gap:8 }}> | |
| <video src={exportedVideoUrl} controls loop | |
| style={{ width:'100%', borderRadius:'var(--radius)', border:'1px solid var(--border)' }} /> | |
| <a href={exportedVideoUrl} | |
| download={`render_${useStore.getState().projectName.replace(/\s/g,'_')}_${Date.now()}.webm`} | |
| style={{ | |
| display:'block', padding:'10px 0', borderRadius:'var(--radius)', | |
| background:'rgba(6,214,160,0.1)', border:'1px solid rgba(6,214,160,0.3)', | |
| color:'var(--accent3)', textAlign:'center', textDecoration:'none', | |
| fontSize:12, fontWeight:700, | |
| }}>β¬ Download Video</a> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |