/** * CameraMode.jsx * Complete camera management panel: * - Add / rename / delete cameras * - Select active camera * - Enter / Exit camera view (viewport switches to render through camera) * - FOV, near/far clip controls * - Camera position/target inputs * - Camera keyframes on the timeline (per camera) * - Capture PNG from camera POV * - Record WebM sequence from camera * - Camera layer visible in Timeline */ import { useState, useRef, useCallback, useEffect } from 'react' import * as THREE from 'three' import useStore from '../store/useStore' const UID = () => `cam_${Date.now().toString(36)}` // Camera keyframe key per camera: __cam___ const camKey = id => `__cam_${id}__` function addCamKeyframe(camId, frame, position, target, fov) { const s = useStore.getState() const kf = JSON.parse(JSON.stringify(s.keyframes)) if (!kf[frame]) kf[frame] = {} kf[frame][camKey(camId)] = { position: Array.isArray(position) ? position : [position.x,position.y,position.z], target: Array.isArray(target) ? target : [target.x,target.y,target.z], fov, } useStore.setState({ keyframes: kf }) } function removeCamKeyframe(camId, frame) { const s = useStore.getState() const kf = JSON.parse(JSON.stringify(s.keyframes)) const k = camKey(camId) if (kf[frame]?.[k]) { delete kf[frame][k] if (!Object.keys(kf[frame]).length) delete kf[frame] useStore.setState({ keyframes: kf }) } } function getCamKeyframes(camId) { const kf = useStore.getState().keyframes const k = camKey(camId) return Object.entries(kf) .filter(([,v]) => v[k]) .map(([f,v]) => ({ frame:parseInt(f), data:v[k] })) .sort((a,b)=>a.frame-b.frame) } // ── Vec3 input row ───────────────────────────────────────────────────────────── function Vec3Input({ label, value, onChange, step=0.1 }) { const axes = ['X','Y','Z'] const colors = ['#ef4444','#22c55e','#3b82f6'] return (
{label}
{axes.map((ax,i) => (
{ax} { const v = [...(value||[0,0,0])] v[i] = parseFloat(e.target.value)||0; onChange(v) }} style={{ border:'none', background:'transparent', width:'100%', padding:'5px 4px', fontSize:11, fontFamily:'var(--font-mono)', color:'var(--text0)' }} />
))}
) } // ── Single camera card ───────────────────────────────────────────────────────── function CameraCard({ cam, isActive, isInView }) { const { setActiveCameraId, setInCameraView, updateCamera, removeCamera, cameras, currentFrame, inCameraView, activeCameraId, selectedCameraId, selectCamera, cameraTransformMode, setCameraTransformMode, } = useStore() const [expanded, setExpanded] = useState(isActive) const [renaming, setRenaming] = useState(false) const [nameVal, setNameVal] = useState(cam.name) const [camKeys, setCamKeys] = useState([]) const recording = useRef(false) const cancelRef = useRef(false) const [recProg, setRecProg] = useState(0) const [recActive,setRecAct] = useState(false) const refreshKeys = useCallback(() => setCamKeys(getCamKeyframes(cam.id)), [cam.id]) useEffect(() => { const unsub = useStore.subscribe(s=>s.keyframes, refreshKeys) refreshKeys() return unsub }, [refreshKeys]) const enterCamera = () => { setActiveCameraId(cam.id) setInCameraView(true) setExpanded(true) } const exitCamera = () => { setInCameraView(false) } const addKF = () => { const s = useStore.getState() // Get current viewport camera position (the r3f camera if we're in view, else cam data) const pos = cam.position || [5,3,5] const tgt = cam.target || [0,0,0] addCamKeyframe(cam.id, currentFrame, pos, tgt, cam.fov||50) refreshKeys() } const captureFrame = () => { const canvas = document.querySelector('canvas') if (!canvas) return const url = canvas.toDataURL('image/png') const a = document.createElement('a') a.href = url; a.download = `${cam.name}_frame${currentFrame}.png`; a.click() } const recordSeq = async () => { const canvas = document.querySelector('canvas') if (!canvas || recActive) return const s = useStore.getState() setRecAct(true); cancelRef.current = false const chunks = [] const rec = new MediaRecorder(canvas.captureStream(s.fps), { mimeType:'video/webm', videoBitsPerSecond:10_000_000 }) rec.ondataavailable = e => chunks.push(e.data) rec.start() for (let f=0; fsetTimeout(r,1000/s.fps+8)) setRecProg(Math.round(f/s.totalFrames*100)) } rec.stop() await new Promise(r=>{ rec.onstop=r }) if (!cancelRef.current) { const url = URL.createObjectURL(new Blob(chunks,{type:'video/webm'})) const a = document.createElement('a') a.href=url; a.download=`${cam.name}_${Date.now()}.webm`; a.click() } setRecAct(false); setRecProg(0); s.setCurrentFrame(0) } const hasKfNow = camKeys.some(k=>k.frame===currentFrame) const thisActive = activeCameraId===cam.id const inThisView = thisActive && inCameraView return (
{/* Header row */}
{/* Camera icon */}
🎥
{/* Name */} {renaming ? ( setNameVal(e.target.value)} onBlur={()=>{ updateCamera(cam.id,{name:nameVal}); setRenaming(false) }} onKeyDown={e=>{ if(e.key==='Enter'){ updateCamera(cam.id,{name:nameVal}); setRenaming(false) }}} autoFocus style={{ flex:1, fontSize:12, fontWeight:700, padding:'2px 6px', borderRadius:3 }} /> ) : ( { setNameVal(cam.name); setRenaming(true) }} style={{ flex:1, fontSize:12, fontWeight:700, color:'var(--text0)', cursor:'text', userSelect:'none' }} title="Double-click to rename" >{cam.name} )} {/* FOV badge */} {cam.fov||50}° {/* Expand */} {/* Delete */} {cameras.length > 1 && ( )}
{/* ── Transform mode selector ── */}
Transform: {[['⊹','translate','Move'],['↻','rotate','Rotate']].map(([icon,mode,label])=>( ))} {selectedCameraId===cam.id && ( )}
{/* Enter / Exit camera view button */}
{!inThisView ? ( ) : ( )}
{expanded && (
{/* Position / Target */} updateCamera(cam.id,{position:v})} /> updateCamera(cam.id,{target:v})} /> {/* FOV */}
Field of View {cam.fov||50}°
updateCamera(cam.id,{fov:+e.target.value})} />
{[24,35,50,70,90].map(f=>( ))}
{/* Near / Far */}
{[['Near Clip','near',0.01],['Far Clip','far',1000]].map(([lbl,key,def])=>(
{lbl}
updateCamera(cam.id,{[key]:parseFloat(e.target.value)||def})} style={{}} />
))}
{/* Keyframes */}
CAMERA KEYFRAMES
{hasKfNow && ( )}
{camKeys.length>0 && (
{camKeys.map(({frame,data})=>(
useStore.getState().setCurrentFrame(frame)} style={{ display:'flex', justifyContent:'space-between', alignItems:'center', padding:'4px 8px', borderRadius:'var(--radius-sm)', cursor:'pointer', background:frame===currentFrame?'rgba(245,158,11,0.1)':'var(--bg1)', border:`1px solid ${frame===currentFrame?'rgba(245,158,11,0.25)':'var(--border)'}`, }}> 🎥 Frame {frame} · FOV {Math.round(data.fov||50)}°
))}
)}
{/* Capture */}
RENDER OUTPUT
{!recActive ? ( ) : (
Recording… {recProg}%
)}
)}
) } // ── Main Panel ───────────────────────────────────────────────────────────────── export default function CameraMode() { const { cameras, activeCameraId, inCameraView, addCamera, setActiveCameraId, setInCameraView, selectedCameraId, selectCamera, cameraTransformMode, setCameraTransformMode } = useStore() const addNewCamera = () => { const id = UID() const n = cameras.length + 1 // Place new camera offset from current active const base = cameras.find(c=>c.id===activeCameraId)||cameras[0] const pos = base ? [base.position[0]+n*1.5, base.position[1], base.position[2]+n*0.5] : [5,3,5] addCamera({ id, name:`Camera ${n}`, position:pos, target:[0,0,0], fov:50, near:0.01, far:1000, }) setActiveCameraId(id) } return (
{/* Header + Add button */}
Cameras {cameras.length}
{inCameraView ? '🎥 In camera view' : 'Free orbit mode'}
{/* Status bar */} {inCameraView && (
🎥 Rendering through {cameras.find(c=>c.id===activeCameraId)?.name}
)} {/* Controls hint */} {inCameraView && (
WASD fly   Q/E up/down   Drag look around   Scroll speed
)} {/* Camera cards */} {cameras.map(cam => ( ))} {/* Tips */}
💡 Tips:
• Double-click camera name to rename
• Add keyframes to animate camera path
• Use FOV presets: 24mm/35mm/50mm feel
• Record Sequence captures from camera POV
) }