import { useCallback, useEffect, useMemo, useState, type RefObject } from "react"; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); export const DEFAULT_FPS = 30; export function frameToTime(frame: number, fps: number): number { return Math.max(0, frame) / fps; } export function timeToFrame(time: number, fps: number): number { return Math.max(0, Math.floor(time * fps)); } export function formatTimestamp(seconds: number): string { const safeSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0; const mins = Math.floor(safeSeconds / 60); const secs = Math.floor(safeSeconds % 60); const millis = Math.floor((safeSeconds % 1) * 1000); return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${millis .toString() .padStart(3, "0")}`; } interface UseVideoFramesResult { currentTime: number; duration: number; currentFrame: number; totalFrames: number; seekToFrame: (frame: number) => void; seekToTimestamp: (seconds: number) => void; stepFrames: (delta: number) => void; } export function useVideoFrames( videoRef: RefObject, fps = DEFAULT_FPS ): UseVideoFramesResult { const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); useEffect(() => { let mounted = true; let boundVideo: HTMLVideoElement | null = null; let cleanup: (() => void) | null = null; let intervalId: number | null = null; let rafId: number | null = null; let lastEmitMs = 0; const EMIT_INTERVAL_MS = 50; // keep UI responsive without rerendering at 60fps const onTimeUpdate = () => { if (!mounted || !boundVideo) return; setCurrentTime(boundVideo.currentTime || 0); }; const onLoadedMetadata = () => { if (!mounted || !boundVideo) return; setDuration(boundVideo.duration || 0); setCurrentTime(boundVideo.currentTime || 0); }; const stopRaf = () => { if (rafId != null) { window.cancelAnimationFrame(rafId); rafId = null; } }; const startRaf = () => { stopRaf(); lastEmitMs = 0; const tick = (now: number) => { if (!mounted || !boundVideo) return; if (boundVideo.paused || boundVideo.ended) { stopRaf(); return; } if (now - lastEmitMs >= EMIT_INTERVAL_MS) { lastEmitMs = now; setCurrentTime(boundVideo.currentTime || 0); } rafId = window.requestAnimationFrame(tick); }; rafId = window.requestAnimationFrame(tick); }; const bind = (video: HTMLVideoElement) => { boundVideo = video; video.addEventListener("timeupdate", onTimeUpdate); video.addEventListener("loadedmetadata", onLoadedMetadata); video.addEventListener("seeking", onTimeUpdate); video.addEventListener("durationchange", onLoadedMetadata); video.addEventListener("play", startRaf); video.addEventListener("pause", stopRaf); video.addEventListener("ended", stopRaf); // If metadata is already available (e.g. the ref became non-null after initial render), // initialize state immediately so the UI doesn't get stuck at 0/0. if (video.readyState >= 1) { onLoadedMetadata(); } // If we bind while the video is already playing, start the loop immediately. if (!video.paused && !video.ended) { startRaf(); } return () => { stopRaf(); video.removeEventListener("timeupdate", onTimeUpdate); video.removeEventListener("loadedmetadata", onLoadedMetadata); video.removeEventListener("seeking", onTimeUpdate); video.removeEventListener("durationchange", onLoadedMetadata); video.removeEventListener("play", startRaf); video.removeEventListener("pause", stopRaf); video.removeEventListener("ended", stopRaf); }; }; const tryBind = () => { const video = videoRef.current; if (!video) return false; if (boundVideo === video) return true; cleanup?.(); cleanup = bind(video); return true; }; // On first render, the ref may still be null. Poll briefly until it becomes available. if (!tryBind()) { intervalId = window.setInterval(() => { if (tryBind() && intervalId != null) { window.clearInterval(intervalId); intervalId = null; } }, 50); } return () => { mounted = false; if (intervalId != null) window.clearInterval(intervalId); stopRaf(); cleanup?.(); cleanup = null; boundVideo = null; }; }, [videoRef]); const totalFrames = useMemo(() => { if (!duration) return 0; return Math.max(0, Math.floor(duration * fps)); }, [duration, fps]); const currentFrame = useMemo(() => timeToFrame(currentTime, fps), [currentTime, fps]); const seekToTimestamp = useCallback( (seconds: number) => { const video = videoRef.current; if (!video || !duration) return; const safeTime = clamp(seconds, 0, duration); video.currentTime = safeTime; setCurrentTime(safeTime); }, [duration, videoRef] ); const seekToFrame = useCallback( (frame: number) => { if (!totalFrames) return; const safeFrame = clamp(frame, 0, totalFrames); seekToTimestamp(frameToTime(safeFrame, fps)); }, [fps, seekToTimestamp, totalFrames] ); const stepFrames = useCallback( (delta: number) => { seekToFrame(currentFrame + delta); }, [currentFrame, seekToFrame] ); return { currentTime, duration, currentFrame, totalFrames, seekToFrame, seekToTimestamp, stepFrames, }; }