improved reverse playback
This commit is contained in:
parent
f2315e91cb
commit
8f1d3699a1
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user