From 8f1d3699a181fad6fd377b44f26e28666be56e34 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Tue, 7 Apr 2026 10:40:46 +0400 Subject: [PATCH] improved reverse playback --- frontend/src/config/preload.config.ts | 9 + frontend/src/hooks/useReversePlayback.ts | 189 +++++++++++++++----- frontend/src/hooks/useTransitionPlayback.ts | 25 ++- 3 files changed, 175 insertions(+), 48 deletions(-) diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts index 560ad30..c611f68 100644 --- a/frontend/src/config/preload.config.ts +++ b/frontend/src/config/preload.config.ts @@ -56,6 +56,15 @@ export const PRELOAD_CONFIG = { constructorMaxDepth: 1, // Same as maxDepth for constructor preview }, + // Reverse playback settings + reversePlayback: { + defaultFps: 25, // FPS for non-preloaded videos + preloadedFps: 45, // FPS for preloaded videos (blob URLs) + minFps: 15, // Minimum adaptive FPS + maxConsecutiveSlowFrames: 3, // Frames before reducing FPS + slowFrameThreshold: 1.3, // Multiplier of target frame time + }, + // Asset URL field names in element content_json (camelCase) assetFields: { // All asset URL fields for preloading extraction diff --git a/frontend/src/hooks/useReversePlayback.ts b/frontend/src/hooks/useReversePlayback.ts index 5da8249..4d7948c 100644 --- a/frontend/src/hooks/useReversePlayback.ts +++ b/frontend/src/hooks/useReversePlayback.ts @@ -7,15 +7,21 @@ import { type RefObject, } from 'react'; import { logger } from '../lib/logger'; +import { PRELOAD_CONFIG } from '../config/preload.config'; interface UseReversePlaybackOptions { videoRef: RefObject; onComplete: () => void; preloadedUrls?: Set; videoUrl?: string; + storageKey?: string; // Raw storage path for preload detection getCachedBlobUrl?: (url: string) => Promise; + getReadyBlobUrl?: (url: string) => string | null; // O(1) instant lookup } +// Note: requestVideoFrameCallback exists but only works during playback. +// For reverse frame-stepping (paused video), we use requestAnimationFrame + seeked events. + interface UseReversePlaybackResult { startReverse: () => Promise; stopReverse: () => void; @@ -89,6 +95,15 @@ export function useReversePlayback( const animationFrameRef = useRef(null); const didFinishRef = useRef(false); const cleanupFnsRef = useRef void>>([]); + const metricsRef = useRef<{ + consecutiveSlowFrames: number; + currentFps: number; + lastSeekDuration: number; + }>({ + consecutiveSlowFrames: 0, + currentFps: PRELOAD_CONFIG.reversePlayback.defaultFps, + lastSeekDuration: 0, + }); const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []); @@ -101,12 +116,8 @@ export function useReversePlayback( cleanupFnsRef.current.forEach((fn) => fn()); cleanupFnsRef.current = []; const video = options.videoRef.current; - if (video) { - if (video.playbackRate !== 1) { - video.playbackRate = 1; - } - // Ensure video visibility is restored - video.style.opacity = '1'; + if (video && video.playbackRate !== 1) { + video.playbackRate = 1; } }, [options.videoRef]); @@ -124,7 +135,14 @@ export function useReversePlayback( didFinishRef.current = false; setIsReversing(true); - const { onComplete, preloadedUrls, videoUrl, getCachedBlobUrl } = options; + const { + onComplete, + preloadedUrls, + videoUrl, + storageKey, + getCachedBlobUrl, + getReadyBlobUrl, + } = options; const actualDuration = video.duration; if (!Number.isFinite(actualDuration) || actualDuration <= 0) { @@ -161,12 +179,44 @@ export function useReversePlayback( logger.info('Native reverse failed, falling back to frame-stepping'); } - // Check if video is preloaded - const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl); - const fps = isPreloaded ? 30 : 15; - const stepSize = 1 / fps; + // Check if video is preloaded (use storageKey for accurate detection) + const isPreloaded = storageKey + ? preloadedUrls?.has(storageKey) + : videoUrl && preloadedUrls?.has(videoUrl); - // Try to get blob URL from cache for better seeking + // Reset adaptive FPS metrics + const config = PRELOAD_CONFIG.reversePlayback; + metricsRef.current = { + consecutiveSlowFrames: 0, + currentFps: isPreloaded ? config.preloadedFps : config.defaultFps, + lastSeekDuration: 0, + }; + + // Try O(1) instant lookup first (uses downloadManager.readyBlobUrls) + if (getReadyBlobUrl && videoUrl && !video.src.startsWith('blob:')) { + const readyBlobUrl = getReadyBlobUrl(videoUrl); + if (readyBlobUrl && !didFinishRef.current) { + logger.info('Using ready blob URL for reverse (O(1) lookup)', { + videoUrl: videoUrl.slice(-50), + }); + video.src = readyBlobUrl; + video.load(); + await new Promise((resolve) => { + const onLoaded = () => { + video.removeEventListener('loadeddata', onLoaded); + // Immediately seek to end to avoid showing first frame + if (video.duration && Number.isFinite(video.duration)) { + video.currentTime = video.duration - 0.01; + } + resolve(); + }; + video.addEventListener('loadeddata', onLoaded); + setTimeout(resolve, 1000); // Shorter timeout for ready URLs + }); + } + } + + // Fall back to async cache lookup if (getCachedBlobUrl && videoUrl && !video.src.startsWith('blob:')) { try { const blobUrl = await getCachedBlobUrl(videoUrl); @@ -177,6 +227,10 @@ export function useReversePlayback( await new Promise((resolve) => { const onLoaded = () => { video.removeEventListener('loadeddata', onLoaded); + // Immediately seek to end to avoid showing first frame + if (video.duration && Number.isFinite(video.duration)) { + video.currentTime = video.duration - 0.01; + } resolve(); }; video.addEventListener('loadeddata', onLoaded); @@ -206,48 +260,94 @@ export function useReversePlayback( } const beginFrameStepping = () => { - // Restore visibility now that we're at the correct frame - video.style.opacity = '1'; video.pause(); - let lastFrameTime = performance.now(); + let isWaitingForSeek = false; + let lastStepTime = performance.now(); let stepCount = 0; - const step = (currentFrameTime: number) => { - if (didFinishRef.current) { - animationFrameRef.current = null; - return; + const onSeekedFrame = () => { + if (didFinishRef.current) return; + + // Measure seek performance for adaptive FPS + const now = performance.now(); + const seekDuration = now - lastStepTime; + const targetFrameMs = 1000 / metricsRef.current.currentFps; + + if (seekDuration > targetFrameMs * config.slowFrameThreshold) { + metricsRef.current.consecutiveSlowFrames++; + if ( + metricsRef.current.consecutiveSlowFrames >= + config.maxConsecutiveSlowFrames + ) { + metricsRef.current.currentFps = Math.max( + config.minFps, + metricsRef.current.currentFps - 5, + ); + metricsRef.current.consecutiveSlowFrames = 0; + logger.info('Adaptive FPS reduced', { + newFps: metricsRef.current.currentFps, + seekDuration, + }); + } + } else { + metricsRef.current.consecutiveSlowFrames = 0; } - if (!video.paused) { - video.pause(); - } + isWaitingForSeek = false; + scheduleNextStep(); + }; - const elapsed = currentFrameTime - lastFrameTime; - if (elapsed < 1000 / fps) { + const scheduleNextStep = () => { + if (didFinishRef.current) return; + animationFrameRef.current = requestAnimationFrame(step); + }; + + // Throttled step - only executes when enough time has passed + const step = () => { + if (didFinishRef.current || isWaitingForSeek) return; + + // Check if enough time has passed for next frame + const now = performance.now(); + const elapsed = now - lastStepTime; + const targetFrameMs = 1000 / metricsRef.current.currentFps; + + if (elapsed < targetFrameMs) { + // Not enough time passed, wait for next animation frame animationFrameRef.current = requestAnimationFrame(step); return; } - lastFrameTime = currentFrameTime; - stepCount++; - const currentTime = video.currentTime; - const newTime = currentTime - stepSize; + stepCount++; + const currentStepSize = 1 / metricsRef.current.currentFps; + const newTime = video.currentTime - currentStepSize; if (stepCount % 30 === 0) { - logger.info('Frame-stepping progress', { stepCount, currentTime }); + logger.info('Frame-stepping progress', { + stepCount, + currentTime: video.currentTime, + fps: metricsRef.current.currentFps, + }); } if (newTime <= 0) { - animationFrameRef.current = null; + video.removeEventListener('seeked', onSeekedFrame); video.currentTime = 0; finishReverse('reverse-complete'); - } else { - video.currentTime = newTime; - animationFrameRef.current = requestAnimationFrame(step); + return; } + + isWaitingForSeek = true; + lastStepTime = now; + video.currentTime = newTime; }; - animationFrameRef.current = requestAnimationFrame(step); + video.addEventListener('seeked', onSeekedFrame); + cleanupFnsRef.current.push(() => + video.removeEventListener('seeked', onSeekedFrame), + ); + + // Start stepping + step(); }; const onSeeked = () => { @@ -297,7 +397,18 @@ export function useReversePlayback( ); video.pause(); } else { - video.currentTime = safeTarget; + // Skip initial seek if already near target position (within 0.2s) + // This prevents the forward-then-backward movement flash + if (Math.abs(video.currentTime - safeTarget) < 0.2) { + logger.info('Already near target, skipping initial seek', { + currentTime: video.currentTime, + safeTarget, + }); + video.removeEventListener('seeked', onSeeked); + beginFrameStepping(); + } else { + video.currentTime = safeTarget; + } } }; @@ -373,12 +484,10 @@ export function useReversePlayback( () => video.removeEventListener('ended', onEnded), ); - // Start playback to trigger buffering - // Hide video via direct DOM manipulation to avoid flash - // (React state update is async, so isBuffering won't hide it in time) + // Start playback to trigger buffering, but from near the end + // to avoid showing frame 0 (which would flash the target page) video.muted = true; - video.style.opacity = '0'; - video.currentTime = 0; + video.currentTime = actualDuration - 0.1; video.play().catch(() => undefined); // Fallback timeout diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 53a4e28..89cc169 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -215,7 +215,6 @@ export function useTransitionPlayback( const preloadRef = useRef(preload); const startReverseRef = useRef<(() => Promise) | null>(null); const stopReverseRef = useRef<(() => void) | null>(null); - const isReverseModeRef = useRef(false); const sourceUrl = useMemo(() => { if (!transition) return ''; @@ -255,13 +254,12 @@ export function useTransitionPlayback( const video = videoRef.current; if (video) { video.pause(); - // For forward playback, always seek to near-end to preserve last frame - // (browsers may auto-reset to 0 on 'ended' event before our handler runs) + // Seek back slightly to ensure last frame is visible + // Some browsers show black after 'ended' event when currentTime === duration if ( - !isReverseModeRef.current && video.duration && Number.isFinite(video.duration) && - video.duration > 0.1 + video.currentTime >= video.duration - 0.1 ) { video.currentTime = Math.max(0, video.duration - 0.05); } @@ -324,7 +322,9 @@ export function useTransitionPlayback( onComplete: handleReverseComplete, preloadedUrls: preload?.preloadedUrls, videoUrl: sourceUrl, + storageKey: transition?.storageKey, // Raw storage path for preload detection getCachedBlobUrl: preload?.getCachedBlobUrl, + getReadyBlobUrl: preload?.getReadyBlobUrl, // O(1) instant lookup }); useEffect(() => { @@ -385,7 +385,6 @@ export function useTransitionPlayback( setPhase('preparing'); const isReverseMode = currentTransition.reverseMode === 'reverse'; - isReverseModeRef.current = isReverseMode; const configuredDurationSec = Number(currentTransition.durationSec); const getMediaErrorDetails = () => { @@ -576,13 +575,16 @@ export function useTransitionPlayback( duration: video.duration, bufferedEnd: getBufferedEnd(video), }); + didStartPlaybackRef.current = true; // Prevent canplaythrough from double-starting setPhase('reversing'); void startReverseRef.current?.(); return; } video.src = playableSourceUrl; - video.currentTime = 0; + // For reverse mode, seek to a large value (browser clamps to duration) + // This prevents showing frame 0 while loading + video.currentTime = isReverseMode ? 999999 : 0; video.load(); lastLoadedSourceUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl; @@ -620,6 +622,9 @@ export function useTransitionPlayback( const onCanPlayThrough = () => { if (didFinishRef.current) return; + // Skip if reverse playback already started (avoid interference from seek events) + if (isReverseMode && didStartPlaybackRef.current) return; + logger.info('canplaythrough fired', { reverseMode: currentTransition.reverseMode, didStartPlayback: didStartPlaybackRef.current, @@ -687,7 +692,11 @@ export function useTransitionPlayback( } }; - const onEnded = () => finishPlayback('ended'); + const onEnded = () => { + // For reverse mode, ignore 'ended' event - wait for reverse playback to complete + if (isReverseMode) return; + finishPlayback('ended'); + }; const onVideoError = async () => { if (didFinishRef.current) return;