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
},
// 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

View File

@ -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<HTMLVideoElement | null>;
onComplete: () => void;
preloadedUrls?: Set<string>;
videoUrl?: string;
storageKey?: string; // Raw storage path for preload detection
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 {
startReverse: () => Promise<void>;
stopReverse: () => void;
@ -89,6 +95,15 @@ export function useReversePlayback(
const animationFrameRef = useRef<number | null>(null);
const didFinishRef = useRef(false);
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(), []);
@ -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<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:')) {
try {
const blobUrl = await getCachedBlobUrl(videoUrl);
@ -177,6 +227,10 @@ export function useReversePlayback(
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);
@ -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

View File

@ -215,7 +215,6 @@ export function useTransitionPlayback(
const preloadRef = useRef(preload);
const startReverseRef = useRef<(() => Promise<void>) | 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;