/* The review player. Frame accuracy is the whole point, so two rules run through this file: 1. the frame number is the source of truth, never the raw currentTime float. Stepping seeks to (frame + 0.5) / fps — aiming at the middle of the target frame keeps browsers from rounding onto the neighbouring one. 2. fps comes from ffprobe (29.97 stays 29.97). requestVideoFrameCallback, where the browser has it, reports the frame actually on screen. */ const VideoPlayer = React.forwardRef(function VideoPlayer( { version, comments, activeId, tool, draft, onActivate, onAnnotate, onTimeChange }, ref ) { const videoRef = React.useRef(null); const overlayRef = React.useRef(null); const [playing, setPlaying] = React.useState(false); const [timeMs, setTimeMs] = React.useState(0); const [hover, setHover] = React.useState(null); const [stroke, setStroke] = React.useState(null); const fps = version.fps || 25; const durationMs = version.duration_ms || 0; const frame = frameAt(timeMs, fps); // The parent only needs the playhead for a label, so tell it a few times a // second instead of on every presented frame — otherwise the whole review // screen re-renders 30 times a second. const lastEmit = React.useRef(0); const emit = React.useCallback((ms) => { setTimeMs(ms); const now = performance.now(); if (onTimeChange && now - lastEmit.current > 250) { lastEmit.current = now; onTimeChange(ms, frameAt(ms, fps)); } }, [fps, onTimeChange]); // Follow the playhead. requestVideoFrameCallback fires once per presented // frame, which is both smoother and more truthful than timeupdate. React.useEffect(() => { const video = videoRef.current; if (!video) return; let handle = null; let cancelled = false; // `seeked` is always wired: a paused video that has never played does not // always present a frame, so the readout would otherwise lie after a jump. const onSeeked = () => emit(Math.round(video.currentTime * 1000)); video.addEventListener('seeked', onSeeked); video.addEventListener('loadedmetadata', onSeeked); if (video.requestVideoFrameCallback) { const tick = (_now, meta) => { if (cancelled) return; emit(Math.round(meta.mediaTime * 1000)); handle = video.requestVideoFrameCallback(tick); }; handle = video.requestVideoFrameCallback(tick); return () => { cancelled = true; video.removeEventListener('seeked', onSeeked); video.removeEventListener('loadedmetadata', onSeeked); if (handle && video.cancelVideoFrameCallback) video.cancelVideoFrameCallback(handle); }; } const onTime = () => emit(Math.round(video.currentTime * 1000)); video.addEventListener('timeupdate', onTime); return () => { video.removeEventListener('timeupdate', onTime); video.removeEventListener('seeked', onSeeked); video.removeEventListener('loadedmetadata', onSeeked); }; }, [emit]); const seekMs = React.useCallback((ms) => { const video = videoRef.current; if (!video) return; const clamped = Math.max(0, Math.min(durationMs || ms, ms)); video.currentTime = clamped / 1000; emit(clamped); }, [durationMs, emit]); const seekFrame = React.useCallback((target) => { const video = videoRef.current; if (!video) return; video.pause(); const total = Math.max(0, Math.floor((durationMs / 1000) * fps) - 1); const next = Math.max(0, Math.min(total, target)); // Aim mid-frame: exactly on the boundary browsers can land either side. video.currentTime = (next + 0.5) / fps; emit(Math.round((next / fps) * 1000)); }, [durationMs, fps, emit]); const toggle = React.useCallback(() => { const video = videoRef.current; if (!video) return; if (video.paused) video.play(); else video.pause(); }, []); // Read the element, never the React state: a note must be stamped with where // the video actually is, even if a repaint has not landed yet. const elementMs = React.useCallback( () => (videoRef.current ? Math.round(videoRef.current.currentTime * 1000) : 0), [] ); React.useImperativeHandle(ref, () => ({ pause: () => videoRef.current && videoRef.current.pause(), seekMs, currentMs: elementMs, currentFrame: () => frameAt(elementMs(), fps), }), [seekMs, elementMs, fps]); // Keyboard: the shortcuts an editor expects. Ignored while typing a note. React.useEffect(() => { const onKey = (e) => { const el = document.activeElement; if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return; if (e.key === ' ') { e.preventDefault(); toggle(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); e.shiftKey ? seekMs(timeMs - 1000) : seekFrame(frame - 1); } else if (e.key === 'ArrowRight') { e.preventDefault(); e.shiftKey ? seekMs(timeMs + 1000) : seekFrame(frame + 1); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [toggle, seekFrame, seekMs, frame, timeMs]); /* ---- annotation overlay ---- */ const rel = (e) => { const rect = overlayRef.current.getBoundingClientRect(); return { x: Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)), }; }; const onPointerDown = (e) => { if (tool === 'pin') { videoRef.current.pause(); onAnnotate && onAnnotate({ pin: rel(e) }); } else if (tool === 'draw') { videoRef.current.pause(); e.currentTarget.setPointerCapture(e.pointerId); const p = rel(e); setStroke({ points: [[p.x, p.y]], color: '#ef4444', width: 3 }); } }; const onPointerMove = (e) => { if (!stroke) return; const p = rel(e); setStroke((s) => ({ ...s, points: [...s.points, [p.x, p.y]] })); }; const onPointerUp = () => { if (!stroke) return; if (stroke.points.length > 1) onAnnotate && onAnnotate({ stroke }); setStroke(null); }; // What to draw on the picture: the selected note, plus anything pinned to the // frame we are sitting on, plus the stroke being drawn right now. const shown = React.useMemo(() => { const nearMs = 1000 / fps * 2; return comments.filter((c) => c.id === activeId || (!c.resolved && Math.abs(c.timestamp_ms - timeMs) <= nearMs) ); }, [comments, activeId, timeMs, fps]); const pathOf = (points) => points.map((p, i) => `${i ? 'L' : 'M'}${(p[0] * 100).toFixed(2)} ${(p[1] * 100).toFixed(2)}`).join(' '); /* ---- timeline ---- */ const meta = version.filmstrip_meta; const timelineRef = React.useRef(null); const onTimelineMove = (e) => { const rect = timelineRef.current.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); setHover({ ratio, x: ratio * rect.width, ms: ratio * durationMs }); }; let thumbStyle = null; if (hover && meta && version.filmstrip_url && meta.interval_ms) { const index = Math.max(0, Math.min(meta.count - 1, Math.floor(hover.ms / meta.interval_ms))); const col = index % meta.cols; const row = Math.floor(index / meta.cols); // Cap both dimensions: a 9:16 tile scaled on width alone would cover half // the picture. const scale = Math.min(160 / (meta.tile_w || 160), 132 / (meta.tile_h || 90)); thumbStyle = { left: hover.x, width: Math.round((meta.tile_w || 160) * scale), height: Math.round((meta.tile_h || 90) * scale), backgroundImage: `url(${version.filmstrip_url})`, backgroundSize: `${Math.round((meta.sheet_w || 0) * scale)}px ${Math.round((meta.sheet_h || 0) * scale)}px`, backgroundPosition: `-${Math.round(col * (meta.tile_w || 160) * scale)}px -${Math.round(row * (meta.tile_h || 90) * scale)}px`, }; } return (
setHover(null)} onClick={(e) => { const rect = timelineRef.current.getBoundingClientRect(); seekMs(((e.clientX - rect.left) / rect.width) * durationMs); }} >
{comments.filter((c) => !c.parent_id).map((c) => (
{ e.stopPropagation(); seekMs(c.timestamp_ms); onActivate && onActivate(c.id); }} /> ))} {thumbStyle &&
}
{timecode(timeMs, fps)} · f {frame} {fps.toFixed ? fps.toFixed(2) : fps} fps · {version.width}×{version.height}
); });