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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user