improved reverse playback

This commit is contained in:
Dmitri 2026-04-07 10:40:46 +04:00
parent f2315e91cb
commit 8f1d3699a1
3 changed files with 175 additions and 48 deletions

View File

@ -56,6 +56,15 @@ export const PRELOAD_CONFIG = {
constructorMaxDepth: 1, // Same as maxDepth for constructor preview 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) // Asset URL field names in element content_json (camelCase)
assetFields: { assetFields: {
// All asset URL fields for preloading extraction // All asset URL fields for preloading extraction

View File

@ -7,15 +7,21 @@ import {
type RefObject, type RefObject,
} from 'react'; } from 'react';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { PRELOAD_CONFIG } from '../config/preload.config';
interface UseReversePlaybackOptions { interface UseReversePlaybackOptions {
videoRef: RefObject<HTMLVideoElement | null>; videoRef: RefObject<HTMLVideoElement | null>;
onComplete: () => void; onComplete: () => void;
preloadedUrls?: Set<string>; preloadedUrls?: Set<string>;
videoUrl?: string; videoUrl?: string;
storageKey?: string; // Raw storage path for preload detection
getCachedBlobUrl?: (url: string) => Promise<string | null>; getCachedBlobUrl?: (url: string) => Promise<string | null>;
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 { interface UseReversePlaybackResult {
startReverse: () => Promise<void>; startReverse: () => Promise<void>;
stopReverse: () => void; stopReverse: () => void;
@ -89,6 +95,15 @@ export function useReversePlayback(
const animationFrameRef = useRef<number | null>(null); const animationFrameRef = useRef<number | null>(null);
const didFinishRef = useRef(false); const didFinishRef = useRef(false);
const cleanupFnsRef = useRef<Array<() => void>>([]); const cleanupFnsRef = useRef<Array<() => void>>([]);
const metricsRef = useRef<{
consecutiveSlowFrames: number;
currentFps: number;
lastSeekDuration: number;
}>({
consecutiveSlowFrames: 0,
currentFps: PRELOAD_CONFIG.reversePlayback.defaultFps,
lastSeekDuration: 0,
});
const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []); const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []);
@ -101,12 +116,8 @@ export function useReversePlayback(
cleanupFnsRef.current.forEach((fn) => fn()); cleanupFnsRef.current.forEach((fn) => fn());
cleanupFnsRef.current = []; cleanupFnsRef.current = [];
const video = options.videoRef.current; const video = options.videoRef.current;
if (video) { if (video && video.playbackRate !== 1) {
if (video.playbackRate !== 1) { video.playbackRate = 1;
video.playbackRate = 1;
}
// Ensure video visibility is restored
video.style.opacity = '1';
} }
}, [options.videoRef]); }, [options.videoRef]);
@ -124,7 +135,14 @@ export function useReversePlayback(
didFinishRef.current = false; didFinishRef.current = false;
setIsReversing(true); setIsReversing(true);
const { onComplete, preloadedUrls, videoUrl, getCachedBlobUrl } = options; const {
onComplete,
preloadedUrls,
videoUrl,
storageKey,
getCachedBlobUrl,
getReadyBlobUrl,
} = options;
const actualDuration = video.duration; const actualDuration = video.duration;
if (!Number.isFinite(actualDuration) || actualDuration <= 0) { if (!Number.isFinite(actualDuration) || actualDuration <= 0) {
@ -161,12 +179,44 @@ export function useReversePlayback(
logger.info('Native reverse failed, falling back to frame-stepping'); logger.info('Native reverse failed, falling back to frame-stepping');
} }
// Check if video is preloaded // Check if video is preloaded (use storageKey for accurate detection)
const isPreloaded = videoUrl && preloadedUrls?.has(videoUrl); const isPreloaded = storageKey
const fps = isPreloaded ? 30 : 15; ? preloadedUrls?.has(storageKey)
const stepSize = 1 / fps; : 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<void>((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:')) { if (getCachedBlobUrl && videoUrl && !video.src.startsWith('blob:')) {
try { try {
const blobUrl = await getCachedBlobUrl(videoUrl); const blobUrl = await getCachedBlobUrl(videoUrl);
@ -177,6 +227,10 @@ export function useReversePlayback(
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const onLoaded = () => { const onLoaded = () => {
video.removeEventListener('loadeddata', 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(); resolve();
}; };
video.addEventListener('loadeddata', onLoaded); video.addEventListener('loadeddata', onLoaded);
@ -206,48 +260,94 @@ export function useReversePlayback(
} }
const beginFrameStepping = () => { const beginFrameStepping = () => {
// Restore visibility now that we're at the correct frame
video.style.opacity = '1';
video.pause(); video.pause();
let lastFrameTime = performance.now(); let isWaitingForSeek = false;
let lastStepTime = performance.now();
let stepCount = 0; let stepCount = 0;
const step = (currentFrameTime: number) => { const onSeekedFrame = () => {
if (didFinishRef.current) { if (didFinishRef.current) return;
animationFrameRef.current = null;
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) { isWaitingForSeek = false;
video.pause(); scheduleNextStep();
} };
const elapsed = currentFrameTime - lastFrameTime; const scheduleNextStep = () => {
if (elapsed < 1000 / fps) { 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); animationFrameRef.current = requestAnimationFrame(step);
return; return;
} }
lastFrameTime = currentFrameTime;
stepCount++;
const currentTime = video.currentTime; stepCount++;
const newTime = currentTime - stepSize; const currentStepSize = 1 / metricsRef.current.currentFps;
const newTime = video.currentTime - currentStepSize;
if (stepCount % 30 === 0) { 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) { if (newTime <= 0) {
animationFrameRef.current = null; video.removeEventListener('seeked', onSeekedFrame);
video.currentTime = 0; video.currentTime = 0;
finishReverse('reverse-complete'); finishReverse('reverse-complete');
} else { return;
video.currentTime = newTime;
animationFrameRef.current = requestAnimationFrame(step);
} }
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 = () => { const onSeeked = () => {
@ -297,7 +397,18 @@ export function useReversePlayback(
); );
video.pause(); video.pause();
} else { } 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), () => video.removeEventListener('ended', onEnded),
); );
// Start playback to trigger buffering // Start playback to trigger buffering, but from near the end
// Hide video via direct DOM manipulation to avoid flash // to avoid showing frame 0 (which would flash the target page)
// (React state update is async, so isBuffering won't hide it in time)
video.muted = true; video.muted = true;
video.style.opacity = '0'; video.currentTime = actualDuration - 0.1;
video.currentTime = 0;
video.play().catch(() => undefined); video.play().catch(() => undefined);
// Fallback timeout // Fallback timeout

View File

@ -215,7 +215,6 @@ export function useTransitionPlayback(
const preloadRef = useRef(preload); const preloadRef = useRef(preload);
const startReverseRef = useRef<(() => Promise<void>) | null>(null); const startReverseRef = useRef<(() => Promise<void>) | null>(null);
const stopReverseRef = useRef<(() => void) | null>(null); const stopReverseRef = useRef<(() => void) | null>(null);
const isReverseModeRef = useRef(false);
const sourceUrl = useMemo(() => { const sourceUrl = useMemo(() => {
if (!transition) return ''; if (!transition) return '';
@ -255,13 +254,12 @@ export function useTransitionPlayback(
const video = videoRef.current; const video = videoRef.current;
if (video) { if (video) {
video.pause(); video.pause();
// For forward playback, always seek to near-end to preserve last frame // Seek back slightly to ensure last frame is visible
// (browsers may auto-reset to 0 on 'ended' event before our handler runs) // Some browsers show black after 'ended' event when currentTime === duration
if ( if (
!isReverseModeRef.current &&
video.duration && video.duration &&
Number.isFinite(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); video.currentTime = Math.max(0, video.duration - 0.05);
} }
@ -324,7 +322,9 @@ export function useTransitionPlayback(
onComplete: handleReverseComplete, onComplete: handleReverseComplete,
preloadedUrls: preload?.preloadedUrls, preloadedUrls: preload?.preloadedUrls,
videoUrl: sourceUrl, videoUrl: sourceUrl,
storageKey: transition?.storageKey, // Raw storage path for preload detection
getCachedBlobUrl: preload?.getCachedBlobUrl, getCachedBlobUrl: preload?.getCachedBlobUrl,
getReadyBlobUrl: preload?.getReadyBlobUrl, // O(1) instant lookup
}); });
useEffect(() => { useEffect(() => {
@ -385,7 +385,6 @@ export function useTransitionPlayback(
setPhase('preparing'); setPhase('preparing');
const isReverseMode = currentTransition.reverseMode === 'reverse'; const isReverseMode = currentTransition.reverseMode === 'reverse';
isReverseModeRef.current = isReverseMode;
const configuredDurationSec = Number(currentTransition.durationSec); const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => { const getMediaErrorDetails = () => {
@ -576,13 +575,16 @@ export function useTransitionPlayback(
duration: video.duration, duration: video.duration,
bufferedEnd: getBufferedEnd(video), bufferedEnd: getBufferedEnd(video),
}); });
didStartPlaybackRef.current = true; // Prevent canplaythrough from double-starting
setPhase('reversing'); setPhase('reversing');
void startReverseRef.current?.(); void startReverseRef.current?.();
return; return;
} }
video.src = playableSourceUrl; 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(); video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl; lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl;
@ -620,6 +622,9 @@ export function useTransitionPlayback(
const onCanPlayThrough = () => { const onCanPlayThrough = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
// Skip if reverse playback already started (avoid interference from seek events)
if (isReverseMode && didStartPlaybackRef.current) return;
logger.info('canplaythrough fired', { logger.info('canplaythrough fired', {
reverseMode: currentTransition.reverseMode, reverseMode: currentTransition.reverseMode,
didStartPlayback: didStartPlaybackRef.current, 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 () => { const onVideoError = async () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;