import React, { useRef, useEffect, useCallback } from 'react'; import { useStudioStore } from '../store/useStudioStore'; import { Keyframe } from '../utils/timeline'; import './Timeline.css'; const Timeline: React.FC = () => { const { objects, selectedId, tracks, playhead, setPlayhead, timelinePlaying, setTimelinePlaying, timelineDuration, setTimelineDuration, addKeyframe, removeKeyframe, } = useStudioStore(); const rafRef = useRef(0); const lastTimeRef = useRef(0); const playheadRef = useRef(playhead); // ← ref so tick closure stays fresh const rulerRef = useRef(null); // Keep ref in sync with store value useEffect(() => { playheadRef.current = playhead; }, [playhead]); // ── Playback loop ───────────────────────────────────────────── useEffect(() => { if (!timelinePlaying) { cancelAnimationFrame(rafRef.current); return; } const tick = (now: number) => { const delta = (now - lastTimeRef.current) / 1000; lastTimeRef.current = now; const next = playheadRef.current + delta; if (next >= timelineDuration) { setPlayhead(timelineDuration); setTimelinePlaying(false); return; } setPlayhead(next); rafRef.current = requestAnimationFrame(tick); }; lastTimeRef.current = performance.now(); rafRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafRef.current); }, [timelinePlaying, timelineDuration, setPlayhead, setTimelinePlaying]); // ── Add keyframe at playhead for selected object ────────────── const handleAddKeyframe = useCallback(() => { if (!selectedId) return; const obj = objects.find(o => o.id === selectedId); if (!obj) return; const kf: Keyframe = { id: Math.random().toString(36).slice(2), time: parseFloat(playheadRef.current.toFixed(3)), position: [...obj.position] as [number, number, number], rotation: [...obj.rotation] as [number, number, number], scale: [...obj.scale] as [number, number, number], easing: 'ease-in-out', }; addKeyframe(selectedId, kf); }, [selectedId, objects, addKeyframe]); // ── Keyboard shortcut: I = insert keyframe ──────────────────── useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'i' || e.key === 'I') handleAddKeyframe(); }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [handleAddKeyframe]); // ── Click ruler to seek ─────────────────────────────────────── const handleRulerClick = useCallback((e: React.MouseEvent) => { const rect = rulerRef.current?.getBoundingClientRect(); if (!rect) return; const t = ((e.clientX - rect.left) / rect.width) * timelineDuration; setPlayhead(Math.max(0, Math.min(timelineDuration, t))); }, [timelineDuration, setPlayhead]); const fmt = (t: number) => { const s = Math.floor(t); const f = Math.floor((t % 1) * 30); return `${String(s).padStart(2, '0')}:${String(f).padStart(2, '0')}`; }; const playheadPct = (playhead / timelineDuration) * 100; return (
{/* ── Controls bar ── */}
{fmt(playhead)} / {fmt(timelineDuration)}
setTimelineDuration(parseInt(e.target.value) || 5)} /> s
{/* ── Track area ── */}
{/* Object labels column */}
{objects.length === 0 && (
Load a model first
)} {objects.map(obj => { const kfCount = tracks.find(t => t.objectId === obj.id)?.keyframes.length ?? 0; return (
{obj.name} {kfCount > 0 && {kfCount}K}
); })}
{/* Ruler + tracks column */}
{/* Ruler */}
{Array.from({ length: timelineDuration + 1 }, (_, i) => (
{i}s
))}
{/* Keyframe tracks */}
{objects.map(obj => { const track = tracks.find(t => t.objectId === obj.id); const sorted = track ? [...track.keyframes].sort((a, b) => a.time - b.time) : []; return (
{/* Connection lines */} {sorted.map((kf, i) => { if (i === sorted.length - 1) return null; const x1 = (kf.time / timelineDuration) * 100; const x2 = (sorted[i+1].time / timelineDuration) * 100; return (
); })} {/* Keyframe diamonds */} {sorted.map(kf => (
{ e.stopPropagation(); removeKeyframe(obj.id, kf.id); }} >◆
))} {/* Playhead ghost line */}
); })}
); }; export default Timeline;