From fa094150ce401a44e16b5b7105d08ca3649489c6 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Fri, 29 May 2026 08:12:44 -0600 Subject: [PATCH 1/2] get frame duration from video during playback --- .../src/components/player/VideoJSPlayer.js | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/app/client/src/components/player/VideoJSPlayer.js b/app/client/src/components/player/VideoJSPlayer.js index 0306a2b0..2e306610 100644 --- a/app/client/src/components/player/VideoJSPlayer.js +++ b/app/client/src/components/player/VideoJSPlayer.js @@ -264,15 +264,39 @@ function SpacebarToggle() { /** * FrameStepKeys — listens for , and . keys to step one frame backward/forward. - * Assumes 30 fps since HTML5 video does not expose the actual frame rate. + * Detects the actual frame duration via requestVideoFrameCallback during playback; + * falls back to 30 fps until the real rate is known. * Must be rendered inside . */ -const FRAME_DURATION = 1 / 30 const FRAME_STEP_INTERVAL_MS = 150 function FrameStepKeys() { const media = Player.useMedia() const lastStepAt = useRef(0) + const frameDuration = useRef(1 / 30) + + // Measure real frame duration from the video stream as it plays + useEffect(() => { + if (!media || typeof media.requestVideoFrameCallback !== 'function') return + + let handle + let prevMediaTime = null + + const onFrame = (_, metadata) => { + if (prevMediaTime !== null) { + const delta = metadata.mediaTime - prevMediaTime + // Sanity-check: accept 8–240 fps + if (delta > 1 / 240 && delta < 1 / 8) { + frameDuration.current = delta + } + } + prevMediaTime = metadata.mediaTime + handle = media.requestVideoFrameCallback(onFrame) + } + + handle = media.requestVideoFrameCallback(onFrame) + return () => media.cancelVideoFrameCallback(handle) + }, [media]) useEffect(() => { if (!media) return @@ -289,7 +313,7 @@ function FrameStepKeys() { e.preventDefault() media.pause() media.currentTime = Math.min( - Math.max(media.currentTime + (e.key === '.' ? FRAME_DURATION : -FRAME_DURATION), 0), + Math.max(media.currentTime + (e.key === '.' ? frameDuration.current : -frameDuration.current), 0), media.duration || 0, ) // Force the browser to decode and paint the new frame even if play() From f4b10e81f97c92ddc2115957d76c57bdfb06408f Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Fri, 29 May 2026 08:32:47 -0600 Subject: [PATCH 2/2] use metadata.presentedFrames for actual accurate frame delta calculation --- .../src/components/player/VideoJSPlayer.js | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/app/client/src/components/player/VideoJSPlayer.js b/app/client/src/components/player/VideoJSPlayer.js index 2e306610..a9361e20 100644 --- a/app/client/src/components/player/VideoJSPlayer.js +++ b/app/client/src/components/player/VideoJSPlayer.js @@ -274,23 +274,38 @@ function FrameStepKeys() { const media = Player.useMedia() const lastStepAt = useRef(0) const frameDuration = useRef(1 / 30) + // Prevents the play/pause frame-render trick from poisoning rVFC measurements + const isStepping = useRef(false) - // Measure real frame duration from the video stream as it plays + // Measure real frame duration from the video stream as it plays naturally. + // Callbacks that fire during our own play/pause seek trick are ignored. useEffect(() => { if (!media || typeof media.requestVideoFrameCallback !== 'function') return let handle let prevMediaTime = null + let prevPresentedFrames = null const onFrame = (_, metadata) => { - if (prevMediaTime !== null) { - const delta = metadata.mediaTime - prevMediaTime - // Sanity-check: accept 8–240 fps - if (delta > 1 / 240 && delta < 1 / 8) { - frameDuration.current = delta + if (!isStepping.current && prevMediaTime !== null && prevPresentedFrames !== null) { + const timeDelta = metadata.mediaTime - prevMediaTime + // presentedFrames counts every composited frame, so dividing gives the + // true per-frame duration even when the callback fires every 2-4 frames + const frameDelta = metadata.presentedFrames - prevPresentedFrames + if (frameDelta > 0 && timeDelta > 0) { + const perFrame = timeDelta / frameDelta + if (perFrame > 1 / 240 && perFrame < 1 / 8) { + frameDuration.current = perFrame + } } } - prevMediaTime = metadata.mediaTime + if (!isStepping.current) { + prevMediaTime = metadata.mediaTime + prevPresentedFrames = metadata.presentedFrames + } else { + prevMediaTime = null + prevPresentedFrames = null + } handle = media.requestVideoFrameCallback(onFrame) } @@ -311,6 +326,7 @@ function FrameStepKeys() { lastStepAt.current = now e.preventDefault() + isStepping.current = true media.pause() media.currentTime = Math.min( Math.max(media.currentTime + (e.key === '.' ? frameDuration.current : -frameDuration.current), 0), @@ -318,7 +334,15 @@ function FrameStepKeys() { ) // Force the browser to decode and paint the new frame even if play() // has never been called (before first play the renderer stays frozen). - media.play().then(() => media.pause()).catch(() => {}) + media + .play() + .then(() => { + media.pause() + isStepping.current = false + }) + .catch(() => { + isStepping.current = false + }) } document.addEventListener('keydown', handleKeyDown)