Parcourir la source

fix: frame-step responsiveness — eliminate re-render pile-up at end of video

Root cause: seeked event was firing on every rVFC tick (~60/sec) regardless
of whether a frame-step was in progress, causing React state updates to
stack up as pending work. At the end of the video the decoder takes longer
to seek, so the pile-up was more visible.

Fix:
- stepInFlightRef gate: blocks rVFC state updates while a frame-step is in
  flight (150ms window). rVFC loop keeps scheduling callbacks but skips
  setState during this window.
- stepFrame: sets video.currentTime, then IMMEDIATELY calls setCurrentTime /
  onTimeUpdate / redrawAnnotations — UI updates in the same frame as the
  keypress, before the decoder even starts seeking.
- The gate expires after 150ms, enough for a ~10-frame decode cycle on any
  device, then normal rVFC updates resume.

The user sees an instant visual response on every keypress regardless of
playhead position.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Claude Dev il y a 1 mois
Parent
commit
56f1d275bb
1 fichiers modifiés avec 37 ajouts et 4 suppressions
  1. 37 4
      src/components/video-player/VideoPlayer.tsx

+ 37 - 4
src/components/video-player/VideoPlayer.tsx

@@ -100,26 +100,41 @@ export function VideoPlayer({
   visibleAnnotationsRef.current = visibleAnnotations;
   drawModeRef.current = drawMode;
 
+  // ── Frame-step debounce ──────────────────────────────────────────────────
+  // Prevent rapid keypresses from piling up seeks before they complete.
+  // A step is considered "in flight" for 150ms; during this window the seeked
+  // handler is gated so we don't fire redundant setState on every rVFC tick.
+  const stepInFlightRef = useRef(false);
+  const stepTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
   // ── rVFC loop ───────────────────────────────────────────────────────────────
   useEffect(() => {
     const video = videoRef.current;
     if (!video) return;
+
     function onFrame(_now: number, metadata: { mediaTime: number }) {
+      // Skip state updates when a frame-step is in flight to avoid re-render pile-up
+      if (stepInFlightRef.current) {
+        videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
+        return;
+      }
       const t = metadata.mediaTime;
       setCurrentTime(t);
       onTimeUpdate(t);
       redrawAnnotationsRef.current(t);
       videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
     }
-    videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
 
-    // Immediate time update after any seek completes — keeps UI in sync with video
     function onSeeked() {
+      // Only update UI for non-frame-step seeks (e.g. click-to-seek on timeline)
+      if (stepInFlightRef.current) return;
       const t = videoRef.current?.currentTime ?? 0;
       setCurrentTime(t);
       onTimeUpdate(t);
       redrawAnnotationsRef.current(t);
     }
+
+    videoCallbackRef.current = (video as any).requestVideoFrameCallback(onFrame);
     video.addEventListener('seeked', onSeeked);
 
     return () => {
@@ -209,9 +224,27 @@ export function VideoPlayer({
   function stepFrame(dir: 1 | -1) {
     const video = videoRef.current;
     if (!video) return;
-    const frameTime = 1 / (fps || 30);
+    const frameTime = 1 / (fpsRef.current || 30);
+
+    // Gate: block rVFC state updates for 150ms so the video has time to seek
+    // before we start hammering React with setState from every rVFC tick
+    stepInFlightRef.current = true;
+    if (stepTimeoutRef.current !== null) clearTimeout(stepTimeoutRef.current);
+
     video.pause();
-    video.currentTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
+    const targetTime = Math.max(0, Math.min(duration, video.currentTime + dir * frameTime));
+    video.currentTime = targetTime;
+
+    // Update UI immediately — don't wait for rVFC or seeked
+    setCurrentTime(targetTime);
+    onTimeUpdate(targetTime);
+    redrawAnnotationsRef.current(targetTime);
+
+    // Release the gate after one frame decode cycle (~16ms) + decode overhead
+    stepTimeoutRef.current = setTimeout(() => {
+      stepInFlightRef.current = false;
+      stepTimeoutRef.current = null;
+    }, 150);
   }
 
   function toggleFullscreen() {