tsmoothe transitions improvement

This commit is contained in:
Dmitri 2026-03-25 21:28:03 +04:00
parent 54aec6d861
commit fa41bd6ee1
8 changed files with 145 additions and 42 deletions

View File

@ -21,7 +21,7 @@ import BaseButton from './BaseButton';
import CardBox from './CardBox'; import CardBox from './CardBox';
import { OfflineToggle } from './Offline/OfflineToggle'; import { OfflineToggle } from './Offline/OfflineToggle';
import LayoutGuest from '../layouts/Guest'; import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config'; import { getPageTitle, baseURLApi } from '../config';
import { PRELOAD_CONFIG } from '../config/preload.config'; import { PRELOAD_CONFIG } from '../config/preload.config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator'; import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback'; import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
@ -31,7 +31,6 @@ import {
markPresignedUrlFailed, markPresignedUrlFailed,
isRelativeStoragePath, isRelativeStoragePath,
} from '../lib/assetUrl'; } from '../lib/assetUrl';
import { baseURLApi } from '../config';
import { buildElementStyle } from '../lib/elementStyles'; import { buildElementStyle } from '../lib/elementStyles';
import type { import type {
RuntimeProject, RuntimeProject,
@ -214,6 +213,9 @@ export default function RuntimePresentation({
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null); const transitionVideoRef = useRef<HTMLVideoElement>(null);
@ -289,7 +291,7 @@ export default function RuntimePresentation({
}); });
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern) // Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering } = useTransitionPlayback({ const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef, videoRef: transitionVideoRef,
transition: transitionPreview transition: transitionPreview
? { ? {
@ -300,17 +302,23 @@ export default function RuntimePresentation({
} }
: null, : null,
onComplete: (targetPageId) => { onComplete: (targetPageId) => {
const video = transitionVideoRef.current;
if (targetPageId) { if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
waitForPageImages(targetPage || null).then(() => { waitForPageImages(targetPage || null).then(() => {
// Mark background as not ready - new image will need to load
setIsBackgroundReady(false);
setSelectedPageId(targetPageId); setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]); setPageHistory((prev) => [...prev, targetPageId]);
requestAnimationFrame(() => { // Signal that transition is complete and waiting for background
requestAnimationFrame(() => { setPendingTransitionComplete(true);
setTransitionPreview(null);
});
});
}); });
} else {
// No target page - clean up and remove overlay
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
} }
}, },
features: { features: {
@ -359,6 +367,21 @@ export default function RuntimePresentation({
document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []); }, []);
// Remove transition overlay when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
const video = transitionVideoRef.current;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
});
});
}
}, [pendingTransitionComplete, isBackgroundReady]);
// Load presentation data // Load presentation data
useEffect(() => { useEffect(() => {
let isCancelled = false; let isCancelled = false;
@ -471,6 +494,17 @@ export default function RuntimePresentation({
} }
}, [selectedPage]); }, [selectedPage]);
// Handle background ready state for pages without images or with videos
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
) {
setIsBackgroundReady(true);
}
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
const navigateToPage = useCallback( const navigateToPage = useCallback(
async ( async (
targetPageId: string, targetPageId: string,
@ -488,8 +522,10 @@ export default function RuntimePresentation({
isReverse: isBack, isReverse: isBack,
}); });
} else { } else {
// Direct navigation - wait for images first // Direct navigation - wait for images first, then switch
await waitForPageImages(targetPage); await waitForPageImages(targetPage);
// Mark background as loading (Image onLoad will set it back to true)
setIsBackgroundReady(false);
setSelectedPageId(targetPageId); setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]); setPageHistory((prev) => [...prev, targetPageId]);
} }
@ -756,6 +792,24 @@ export default function RuntimePresentation({
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
> >
{/* Background image element - ensures proper loading for waitForPageImages() */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 pointer-events-none'>
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-cover'
priority
unoptimized
onLoad={() => setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
/>
</div>
)}
{/* Background video */} {/* Background video */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (
<video <video
@ -828,12 +882,15 @@ export default function RuntimePresentation({
</div> </div>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */} {/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{transitionPreview && ( {transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'> <div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video <video
ref={transitionVideoRef} ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear' className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{ opacity: isBuffering ? 0 : 1 }} style={{
opacity: transitionPhase === 'preparing' || isBuffering ? 0 : 1,
}}
muted muted
playsInline playsInline
preload='auto' preload='auto'

View File

@ -515,10 +515,16 @@ export function usePreloadOrchestrator(
const storagePaths: string[] = []; const storagePaths: string[] = [];
const currentPage = pages.find((p) => p.id === currentPageId); const currentPage = pages.find((p) => p.id === currentPageId);
if (currentPage?.background_image_url && isRelativeStoragePath(currentPage.background_image_url)) { if (
currentPage?.background_image_url &&
isRelativeStoragePath(currentPage.background_image_url)
) {
storagePaths.push(currentPage.background_image_url); storagePaths.push(currentPage.background_image_url);
} }
if (currentPage?.background_video_url && isRelativeStoragePath(currentPage.background_video_url)) { if (
currentPage?.background_video_url &&
isRelativeStoragePath(currentPage.background_video_url)
) {
storagePaths.push(currentPage.background_video_url); storagePaths.push(currentPage.background_video_url);
} }
@ -532,7 +538,10 @@ export function usePreloadOrchestrator(
const neighbors = neighborGraph.getNeighbors(currentPageId, 1); const neighbors = neighborGraph.getNeighbors(currentPageId, 1);
neighbors.forEach(({ pageId }) => { neighbors.forEach(({ pageId }) => {
const page = pages.find((p) => p.id === pageId); const page = pages.find((p) => p.id === pageId);
if (page?.background_image_url && isRelativeStoragePath(page.background_image_url)) { if (
page?.background_image_url &&
isRelativeStoragePath(page.background_image_url)
) {
storagePaths.push(page.background_image_url); storagePaths.push(page.background_image_url);
} }
}); });
@ -548,7 +557,9 @@ export function usePreloadOrchestrator(
addToQueue({ addToQueue({
id: `bg-img-${currentPageId}`, id: `bg-img-${currentPageId}`,
url: resolvedUrl, url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.currentPage + 200, priority: PRELOAD_CONFIG.priority.currentPage + 200,
assetType: 'image', assetType: 'image',
pageId: currentPageId, pageId: currentPageId,
@ -562,7 +573,9 @@ export function usePreloadOrchestrator(
addToQueue({ addToQueue({
id: `bg-vid-${currentPageId}`, id: `bg-vid-${currentPageId}`,
url: resolvedUrl, url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.currentPage + 150, priority: PRELOAD_CONFIG.priority.currentPage + 150,
assetType: 'video', assetType: 'video',
pageId: currentPageId, pageId: currentPageId,
@ -578,7 +591,9 @@ export function usePreloadOrchestrator(
addToQueue({ addToQueue({
id: generateJobId(), id: generateJobId(),
url: resolvedUrl, url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: asset.priority, priority: asset.priority,
assetType: asset.assetType, assetType: asset.assetType,
pageId: asset.pageId, pageId: asset.pageId,
@ -598,7 +613,9 @@ export function usePreloadOrchestrator(
addToQueue({ addToQueue({
id: `bg-img-${pageId}`, id: `bg-img-${pageId}`,
url: resolvedUrl, url: resolvedUrl,
storageKey: isRelativeStoragePath(storageKey) ? storageKey : undefined, storageKey: isRelativeStoragePath(storageKey)
? storageKey
: undefined,
priority: PRELOAD_CONFIG.priority.neighborBase, priority: PRELOAD_CONFIG.priority.neighborBase,
assetType: 'image', assetType: 'image',
pageId, pageId,
@ -620,9 +637,12 @@ export function usePreloadOrchestrator(
addAssetsToQueue(); addAssetsToQueue();
}) })
.catch((error) => { .catch((error) => {
logger.error('[PRELOAD] Failed to fetch presigned URLs, falling back to proxy', { logger.error(
error: error?.message, '[PRELOAD] Failed to fetch presigned URLs, falling back to proxy',
}); {
error: error?.message,
},
);
// Fallback: add to queue without presigned URLs (will use backend proxy) // Fallback: add to queue without presigned URLs (will use backend proxy)
addAssetsToQueue(); addAssetsToQueue();
}); });

View File

@ -113,7 +113,10 @@ function isPresignedUrl(url: string): boolean {
* Convert a presigned URL back to proxy URL * Convert a presigned URL back to proxy URL
* Extracts the storage key from the S3 path and builds a proxy URL * Extracts the storage key from the S3 path and builds a proxy URL
*/ */
function getProxyUrlFallback(presignedUrl: string, originalStorageKey?: string): string | null { function getProxyUrlFallback(
presignedUrl: string,
originalStorageKey?: string,
): string | null {
// If we have the original storage key, use it directly // If we have the original storage key, use it directly
if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) { if (originalStorageKey && isRelativeStoragePath(originalStorageKey)) {
const normalizedPath = originalStorageKey.replace(/^\/+/, ''); const normalizedPath = originalStorageKey.replace(/^\/+/, '');
@ -615,7 +618,11 @@ export function useTransitionPlayback(
// Check if this is a presigned URL failure (likely CORS) // Check if this is a presigned URL failure (likely CORS)
const currentUrl = currentPlayableUrlRef.current; const currentUrl = currentPlayableUrlRef.current;
if (currentUrl && isPresignedUrl(currentUrl) && !didTryFallbackRef.current) { if (
currentUrl &&
isPresignedUrl(currentUrl) &&
!didTryFallbackRef.current
) {
logger.info('Presigned URL failed, trying proxy fallback', { logger.info('Presigned URL failed, trying proxy fallback', {
url: currentUrl.slice(0, 80), url: currentUrl.slice(0, 80),
}); });
@ -628,7 +635,10 @@ export function useTransitionPlayback(
} }
// Get proxy fallback URL // Get proxy fallback URL
const fallbackUrl = getProxyUrlFallback(currentUrl, currentTransition.videoUrl); const fallbackUrl = getProxyUrlFallback(
currentUrl,
currentTransition.videoUrl,
);
if (fallbackUrl) { if (fallbackUrl) {
didTryFallbackRef.current = true; didTryFallbackRef.current = true;
video.pause(); video.pause();
@ -684,7 +694,17 @@ export function useTransitionPlayback(
clearTimers(); clearTimers();
stopReverseRef.current?.(); stopReverseRef.current?.();
}; };
}, [sourceUrl, videoRef, playbackStartMs, durationBufferMs, hardTimeoutMs, clearTimers, revokeBlobUrl, finishPlayback, handleError]); }, [
sourceUrl,
videoRef,
playbackStartMs,
durationBufferMs,
hardTimeoutMs,
clearTimers,
revokeBlobUrl,
finishPlayback,
handleError,
]);
useEffect(() => { useEffect(() => {
if (!transition) { if (!transition) {

View File

@ -7,6 +7,7 @@
import axios, { AxiosError } from 'axios'; import axios, { AxiosError } from 'axios';
import { baseURLApi } from '../config'; import { baseURLApi } from '../config';
import { logger } from './logger';
/** /**
* Check if a URL is a presigned S3 URL * Check if a URL is a presigned S3 URL
@ -27,12 +28,12 @@ export const setupPresignedUrlInterceptor = (): void => {
// Check if this is a presigned S3 URL failure (likely CORS) // Check if this is a presigned S3 URL failure (likely CORS)
if (isPresignedS3Url(url) && !presignedUrlsDisabled) { if (isPresignedS3Url(url) && !presignedUrlsDisabled) {
console.info('[assetUrl] Presigned URL request failed, disabling presigned URLs'); logger.info('Presigned URL request failed, disabling presigned URLs');
disablePresignedUrls(); disablePresignedUrls();
} }
return Promise.reject(error); return Promise.reject(error);
} },
); );
}; };
@ -44,7 +45,7 @@ export const disablePresignedUrls = (): void => {
if (!presignedUrlsDisabled) { if (!presignedUrlsDisabled) {
presignedUrlsDisabled = true; presignedUrlsDisabled = true;
presignedUrlCache.clear(); presignedUrlCache.clear();
console.info('[assetUrl] Presigned URLs disabled - all requests will use proxy'); logger.info('Presigned URLs disabled - all requests will use proxy');
} }
}; };
@ -127,7 +128,9 @@ const processBatch = async (): Promise<void> => {
* Queue a URL for batch presigning and return a promise that resolves when the batch completes. * Queue a URL for batch presigning and return a promise that resolves when the batch completes.
* Used by the preloader to efficiently fetch presigned URLs for multiple assets. * Used by the preloader to efficiently fetch presigned URLs for multiple assets.
*/ */
export const queuePresignedUrl = (storageKey: string): Promise<string | null> => { export const queuePresignedUrl = (
storageKey: string,
): Promise<string | null> => {
// Check cache first // Check cache first
const cached = presignedUrlCache.get(storageKey); const cached = presignedUrlCache.get(storageKey);
if (cached && cached.expiresAt > Date.now() + CACHE_BUFFER_MS) { if (cached && cached.expiresAt > Date.now() + CACHE_BUFFER_MS) {
@ -158,7 +161,9 @@ export const queuePresignedUrl = (storageKey: string): Promise<string | null> =>
* More efficient than calling queuePresignedUrl multiple times. * More efficient than calling queuePresignedUrl multiple times.
* Returns empty object if presigned URLs are disabled. * Returns empty object if presigned URLs are disabled.
*/ */
export const queuePresignedUrls = (storageKeys: string[]): Promise<Record<string, string>> => { export const queuePresignedUrls = (
storageKeys: string[],
): Promise<Record<string, string>> => {
// Skip if presigned URLs are disabled // Skip if presigned URLs are disabled
if (presignedUrlsDisabled) { if (presignedUrlsDisabled) {
return Promise.resolve({}); return Promise.resolve({});
@ -259,7 +264,7 @@ export const arePresignedUrlsDisabled = (): boolean => {
export const markPresignedUrlsVerified = (): void => { export const markPresignedUrlsVerified = (): void => {
if (!presignedUrlsDisabled && !presignedUrlsVerified) { if (!presignedUrlsDisabled && !presignedUrlsVerified) {
presignedUrlsVerified = true; presignedUrlsVerified = true;
console.info('[assetUrl] Presigned URLs verified - enabling direct S3 access'); logger.info('Presigned URLs verified - enabling direct S3 access');
} }
}; };

View File

@ -64,9 +64,7 @@ export const decodeImages = async (
* @example * @example
* const imageUrls = extractPageImageUrls(page); * const imageUrls = extractPageImageUrls(page);
*/ */
export const extractPageImageUrls = ( export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
page: PageWithImages | null,
): string[] => {
if (!page) return []; if (!page) return [];
const imageUrls: string[] = []; const imageUrls: string[] = [];

View File

@ -69,7 +69,10 @@ axios.interceptors.response.use(
// Detect presigned S3 URL failures (CORS not configured) // Detect presigned S3 URL failures (CORS not configured)
// Network errors (status 0) or CORS errors typically indicate S3 CORS issues // Network errors (status 0) or CORS errors typically indicate S3 CORS issues
if (isPresignedS3Url(requestUrl) && (!status || status === 0 || error.message?.includes('Network Error'))) { if (
isPresignedS3Url(requestUrl) &&
(!status || status === 0 || error.message?.includes('Network Error'))
) {
logger.info('[axios] Presigned URL failed, disabling presigned URLs', { logger.info('[axios] Presigned URL failed, disabling presigned URLs', {
url: requestUrl.slice(0, 80), url: requestUrl.slice(0, 80),
}); });

View File

@ -427,9 +427,13 @@ const mergeElementWithDefaults = (
const elementValue = elementRecord[prop]; const elementValue = elementRecord[prop];
const defaultValue = defaultsRecord[prop]; const defaultValue = defaultsRecord[prop];
const elementIsEmpty = const elementIsEmpty =
elementValue === '' || elementValue === undefined || elementValue === null; elementValue === '' ||
elementValue === undefined ||
elementValue === null;
const defaultHasValue = const defaultHasValue =
defaultValue !== undefined && defaultValue !== null && defaultValue !== ''; defaultValue !== undefined &&
defaultValue !== null &&
defaultValue !== '';
if (elementIsEmpty && defaultHasValue) { if (elementIsEmpty && defaultHasValue) {
mergedRecord[prop] = defaultValue; mergedRecord[prop] = defaultValue;
} }
@ -1087,10 +1091,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
(item: Record<string, unknown>) => { (item: Record<string, unknown>) => {
const extracted: Record<string, unknown> = {}; const extracted: Record<string, unknown> = {};
nestedUrlFields.forEach((urlField) => { nestedUrlFields.forEach((urlField) => {
if ( if (item[urlField] !== undefined && item[urlField] !== '') {
item[urlField] !== undefined &&
item[urlField] !== ''
) {
extracted[urlField] = item[urlField]; extracted[urlField] = item[urlField];
} }
}); });

View File

@ -232,8 +232,7 @@ const RuntimePageView = () => {
return; return;
} }
const isBack = const isBack = linkDirection === 'back' || isBackNavigation(targetPageId);
linkDirection === 'back' || isBackNavigation(targetPageId);
const transitionName = const transitionName =
transition?.name || transition?.slug || 'Transition'; transition?.name || transition?.slug || 'Transition';
const canUseReverseVideo = const canUseReverseVideo =