From 7e54cc88586cfd93671d6f5e60df8d5f3f982f63 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Thu, 23 Apr 2026 11:34:28 +0400 Subject: [PATCH] fixed smooth transitions issue for safari browser --- .../Constructor/CanvasBackground.tsx | 55 ++++++++++- .../Constructor/TransitionPreviewOverlay.tsx | 7 +- .../src/components/RuntimePresentation.tsx | 54 +++++++---- frontend/src/hooks/useBackgroundTransition.ts | 10 ++ frontend/src/hooks/useTransitionPlayback.ts | 91 +++++++++++++++---- frontend/src/pages/constructor.tsx | 8 +- 6 files changed, 181 insertions(+), 44 deletions(-) diff --git a/frontend/src/components/Constructor/CanvasBackground.tsx b/frontend/src/components/Constructor/CanvasBackground.tsx index 1a4217b..5f2a6ff 100644 --- a/frontend/src/components/Constructor/CanvasBackground.tsx +++ b/frontend/src/components/Constructor/CanvasBackground.tsx @@ -6,10 +6,16 @@ * Supports custom video playback settings (autoplay, loop, muted, start/end time). */ -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import NextImage from 'next/image'; import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayback'; import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay'; +import { scheduleAfterPaint } from '../../lib/browserUtils'; + +// Type for requestVideoFrameCallback (Safari 15.4+) +interface HTMLVideoElementWithRVFC extends HTMLVideoElement { + requestVideoFrameCallback: (callback: () => void) => number; +} interface CanvasBackgroundProps { backgroundImageUrl?: string; @@ -59,13 +65,58 @@ const CanvasBackground: React.FC = ({ const effectiveAutoplay = videoAutoplay && !shouldBlockAutoplay; const handleLoad = () => { - onBackgroundReady?.(); + // Wait for paint to ensure background is actually rendered before reporting ready. + // This prevents the transition overlay from being removed before the background is visible. + scheduleAfterPaint(() => { + onBackgroundReady?.(); + }); }; const handleError = () => { onBackgroundReady?.(); }; + // Track if we've already called onBackgroundReady to avoid double calls + const didReportReadyRef = useRef(false); + + // Reset ready flag when video URL changes + useEffect(() => { + didReportReadyRef.current = false; + }, [backgroundVideoUrl]); + + // Handle video first frame ready using requestVideoFrameCallback + // This ensures the video's first frame is actually painted before we report ready + useEffect(() => { + const video = videoRef.current; + if (!backgroundVideoUrl || !video || didReportReadyRef.current) return; + + const reportVideoReady = () => { + if (didReportReadyRef.current) return; + didReportReadyRef.current = true; + onBackgroundReady?.(); + }; + + // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+) + if ('requestVideoFrameCallback' in video) { + const rvfc = (video as HTMLVideoElementWithRVFC).requestVideoFrameCallback.bind(video); + rvfc(() => { + reportVideoReady(); + }); + } else { + // Fallback: use playing event + scheduleAfterPaint + const onPlaying = () => { + scheduleAfterPaint(() => { + reportVideoReady(); + }); + }; + + video.addEventListener('playing', onPlaying, { once: true }); + return () => { + video.removeEventListener('playing', onPlaying); + }; + } + }, [backgroundVideoUrl, onBackgroundReady]); + // When endTime is set, we disable native loop and handle it via the hook const useNativeLoop = videoEndTime == null ? videoLoop : false; diff --git a/frontend/src/components/Constructor/TransitionPreviewOverlay.tsx b/frontend/src/components/Constructor/TransitionPreviewOverlay.tsx index bc7feff..f91ae88 100644 --- a/frontend/src/components/Constructor/TransitionPreviewOverlay.tsx +++ b/frontend/src/components/Constructor/TransitionPreviewOverlay.tsx @@ -41,10 +41,11 @@ const TransitionPreviewOverlay: React.FC = ({ const containerOpacity = isBuffering ? 0 : (opacity ?? 1); return ( - // Outer: full viewport with black background (letterbox bars) - // Hidden while buffering to show old page content underneath + // Outer: full viewport, transparent background + // Transparent ensures if Safari clears video frame when paused, + // the new page background shows through instead of black flash
{/* Inner: respects letterbox dimensions when provided */} diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index c4cb969..39184b5 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -42,7 +42,7 @@ import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useBackgroundTransition } from '../hooks/useBackgroundTransition'; import { useBackgroundUrls } from '../hooks/useBackgroundUrls'; import { resolveAssetPlaybackUrl } from '../lib/assetUrl'; -import { isSafari } from '../lib/browserUtils'; +import { isSafari, scheduleAfterPaint } from '../lib/browserUtils'; import { logger } from '../lib/logger'; import { resolveNavigationTarget, @@ -321,36 +321,50 @@ export default function RuntimePresentation({ pageSwitch.switchToPage, ]); - // Handle background ready state for pages without images or with videos + // Handle background ready state for pages without any background useEffect(() => { - // If no background image, or if there's a video (video takes over), mark as ready - if ( - !selectedPage?.background_image_url || - selectedPage?.background_video_url - ) { + // Only mark ready immediately if there's no background media at all. + // For pages with image or video, CanvasBackground will call onBackgroundReady + // after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback). + if (!selectedPage?.background_image_url && !selectedPage?.background_video_url) { setIsBackgroundReady(true); } }, [selectedPage?.background_image_url, selectedPage?.background_video_url]); // Video transition overlay removal - instant (no fade) when background is ready - // Uses double RAF to ensure browser has painted the new background before removing overlay + // Uses scheduleAfterPaint to ensure browser has painted the new background before removing overlay + // Note: Double RAF doesn't work in Safari (nested RAFs can execute in same frame) + // CRITICAL: Must wait for BOTH isBackgroundReady AND pageSwitch.isNewBgReady + // - isBackgroundReady: RuntimePresentation state (image onLoad or immediate for video pages) + // - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility + // If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing useEffect(() => { - if (pendingTransitionComplete && isBackgroundReady) { + if (pendingTransitionComplete && isBackgroundReady && pageSwitch.isNewBgReady) { // Wait for paint cycle to complete before removing overlay - // Double RAF ensures the new background is fully rendered - requestAnimationFrame(() => { - requestAnimationFrame(() => { + // scheduleAfterPaint handles Safari's RAF quirks automatically + scheduleAfterPaint(() => { + // CRITICAL: Remove overlay from DOM FIRST, then clear video src + // If we clear src before removing overlay, Safari shows black frame + // because video.removeAttribute('src') immediately clears the frame + setTransitionPreview(null); + setPendingTransitionComplete(false); + + // Clear previous background now that transition is complete + // This resets isSwitching state for next navigation + pageSwitch.clearPreviousBackground(); + + // Clear video src AFTER overlay is removed from DOM + // Use another scheduleAfterPaint to ensure React has unmounted the overlay + scheduleAfterPaint(() => { const video = transitionVideoRef.current; if (video) { video.removeAttribute('src'); video.load(); } - setTransitionPreview(null); - setPendingTransitionComplete(false); }); }); } - }, [pendingTransitionComplete, isBackgroundReady]); + }, [pendingTransitionComplete, isBackgroundReady, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground]); // Safari Black Flash Prevention (video transitions only): // Update lastKnownBgUrl whenever we have a valid background image. @@ -692,7 +706,15 @@ export default function RuntimePresentation({ diff --git a/frontend/src/hooks/useBackgroundTransition.ts b/frontend/src/hooks/useBackgroundTransition.ts index 1583982..798704d 100644 --- a/frontend/src/hooks/useBackgroundTransition.ts +++ b/frontend/src/hooks/useBackgroundTransition.ts @@ -264,8 +264,18 @@ export function useBackgroundTransition({ * * The previous background stays visible during the entire fade animation, * providing a smooth crossfade effect. Only cleared after fade ends. + * + * IMPORTANT: Skip this for video transitions - the transition overlay handles + * the visual transition, and we'll clear the previous background manually + * after the overlay is removed. */ useEffect(() => { + // Read from ref to get current hasActiveTransition value + const hasActiveTransition = fadeInRef.current?.hasActiveTransition ?? false; + + // Skip clearing during video transitions - let RuntimePresentation handle it + if (hasActiveTransition) return; + if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) { // Fade is complete - clear the previous background overlay // This also resets isSwitching state so next navigation triggers fade-in diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index f393516..65403f6 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -24,7 +24,7 @@ import { extractStoragePath, } from '../lib/assetUrl'; import { downloadManager } from '../lib/offline/DownloadManager'; -import { isSafari, isFirefox } from '../lib/browserUtils'; +import { isSafari, isFirefox, scheduleAfterPaint } from '../lib/browserUtils'; export type ReverseMode = 'none' | 'separate'; @@ -87,24 +87,18 @@ const DEFAULT_TIMEOUTS = { /** * Get browser-specific finish offset for transition videos. - * Safari needs more buffer time to ensure the last frame stays visible - * until the new page background is ready to display. + * This is a backup timer - requestVideoFrameCallback is primary for modern browsers. * * @returns Finish offset in milliseconds before video end */ const getFinishBeforeEndMs = (): number => { if (isSafari()) { - // Safari: larger buffer to prevent black flash - // Safari's compositor timing can cause the video to clear - // before the new page background is fully composited - return 100; + return 350; } if (isFirefox()) { - // Firefox: slightly larger buffer for safety - return 60; + return 300; } - // Chrome and other browsers: standard timing - return 50; + return 300; }; function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean { @@ -169,6 +163,7 @@ export function useTransitionPlayback( customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs; const [phase, setPhase] = useState('idle'); + const [isVideoReady, setIsVideoReady] = useState(false); const didFinishRef = useRef(false); const didStartPlaybackRef = useRef(false); @@ -241,15 +236,10 @@ export function useTransitionPlayback( const video = videoRef.current; if (video) { + // Just pause - don't seek. We've already stopped at a safe frame + // (triggered by rvfc/timeupdate well before any black frames). + // Seeking is async and can cause a flash before the seek completes. video.pause(); - // Seek back slightly to ensure last frame is visible - if ( - video.duration && - Number.isFinite(video.duration) && - video.currentTime >= video.duration - 0.1 - ) { - video.currentTime = Math.max(0, video.duration - 0.05); - } } const currentTransition = transitionRef.current; @@ -558,6 +548,7 @@ export function useTransitionPlayback( sourceUrl, }); + setIsVideoReady(false); // Reset for new playback didStartPlaybackRef.current = false; if (startWatchdogTimerRef.current) { @@ -616,6 +607,45 @@ export function useTransitionPlayback( startWatchdogTimerRef.current = null; } + // Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+) + // This fires when a frame is actually sent to the compositor - no guesswork + if ('requestVideoFrameCallback' in video) { + const rvfc = video.requestVideoFrameCallback.bind(video); + + // First callback: frame is composited, safe to show overlay + rvfc((_now, _metadata) => { + if (!didFinishRef.current) { + setIsVideoReady(true); + } + + // Monitor video position to finish before end (prevents END flash) + // Note: rvfc fires AFTER frame is composited, so we need extra buffer + const monitorEnd = (_now2: number, metadata: VideoFrameCallbackMetadata) => { + if (didFinishRef.current) return; + + const duration = video.duration; + // Finish 300ms before end - gives margin for black/fade frames + // that some videos have in the last 100-200ms + if (Number.isFinite(duration) && metadata.mediaTime >= duration - 0.3) { + finishPlayback('rvfc-end'); + return; + } + + // Continue monitoring each frame + rvfc(monitorEnd); + }; + + rvfc(monitorEnd); + }); + } else { + // Fallback for older browsers without requestVideoFrameCallback + scheduleAfterPaint(() => { + if (!didFinishRef.current) { + setIsVideoReady(true); + } + }); + } + const mediaDurationSec = Number(video.duration); const durationSec = Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 @@ -632,6 +662,23 @@ export function useTransitionPlayback( finishPlayback('ended'); }; + // Backup handler for browsers without requestVideoFrameCallback + // Note: timeupdate fires infrequently (~250ms in Safari), so this is just a fallback + const onTimeUpdate = () => { + if (didFinishRef.current) return; + + const duration = video.duration; + if (!Number.isFinite(duration)) return; + + // Large buffer since timeupdate is infrequent + // Safari: 600ms, Others: 400ms + const safetyBuffer = isSafari() ? 0.6 : 0.4; + + if (video.currentTime >= duration - safetyBuffer) { + finishPlayback('timeupdate-end'); + } + }; + const onVideoError = async () => { if (didFinishRef.current) return; logIssue('video-error'); @@ -663,6 +710,7 @@ export function useTransitionPlayback( video.addEventListener('canplay', onCanPlay); video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); + video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('error', onVideoError); video.addEventListener('abort', onAbort); video.addEventListener('stalled', onStalled); @@ -680,6 +728,7 @@ export function useTransitionPlayback( video.removeEventListener('canplay', onCanPlay); video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); + video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('error', onVideoError); video.removeEventListener('abort', onAbort); video.removeEventListener('stalled', onStalled); @@ -701,6 +750,7 @@ export function useTransitionPlayback( useEffect(() => { if (!transition) { setPhase('idle'); + setIsVideoReady(false); activeSourceUrlRef.current = null; didFinishRef.current = false; didStartPlaybackRef.current = false; @@ -709,7 +759,8 @@ export function useTransitionPlayback( return { phase, - isBuffering: false, // No longer have buffering from frame-stepping + // Show buffering until video first frame is painted (prevents START black flash) + isBuffering: phase === 'preparing' || (phase === 'playing' && !isVideoReady), isReversing: false, // No longer support frame-stepping reverse cancel, forceComplete, diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index d751c1d..23e0bc8 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -393,10 +393,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { } }, [pendingTransitionComplete, isBackgroundReady, closeTransitionPreview]); - // Handle background ready state for pages without images or with videos + // Handle background ready state for pages without any background useEffect(() => { - // If no background image, or if there's a video (video takes over), mark as ready - if (!activePage?.background_image_url || activePage?.background_video_url) { + // Only mark ready immediately if there's no background media at all. + // For pages with image or video, CanvasBackground will call onBackgroundReady + // after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback). + if (!activePage?.background_image_url && !activePage?.background_video_url) { setIsBackgroundReady(true); } }, [activePage?.background_image_url, activePage?.background_video_url]);