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 { OfflineToggle } from './Offline/OfflineToggle';
import LayoutGuest from '../layouts/Guest';
import { getPageTitle } from '../config';
import { getPageTitle, baseURLApi } from '../config';
import { PRELOAD_CONFIG } from '../config/preload.config';
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
import { useTransitionPlayback } from '../hooks/useTransitionPlayback';
@ -31,7 +31,6 @@ import {
markPresignedUrlFailed,
isRelativeStoragePath,
} from '../lib/assetUrl';
import { baseURLApi } from '../config';
import { buildElementStyle } from '../lib/elementStyles';
import type {
RuntimeProject,
@ -214,6 +213,9 @@ export default function RuntimePresentation({
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] =
useState(false);
const transitionVideoRef = useRef<HTMLVideoElement>(null);
@ -289,7 +291,7 @@ export default function RuntimePresentation({
});
// Integrate useTransitionPlayback hook for smooth transitions (matches Constructor pattern)
const { isBuffering } = useTransitionPlayback({
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
@ -300,17 +302,23 @@ export default function RuntimePresentation({
}
: null,
onComplete: (targetPageId) => {
const video = transitionVideoRef.current;
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
waitForPageImages(targetPage || null).then(() => {
// Mark background as not ready - new image will need to load
setIsBackgroundReady(false);
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTransitionPreview(null);
});
});
// Signal that transition is complete and waiting for background
setPendingTransitionComplete(true);
});
} else {
// No target page - clean up and remove overlay
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
},
features: {
@ -359,6 +367,21 @@ export default function RuntimePresentation({
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
useEffect(() => {
let isCancelled = false;
@ -471,6 +494,17 @@ export default function RuntimePresentation({
}
}, [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(
async (
targetPageId: string,
@ -488,8 +522,10 @@ export default function RuntimePresentation({
isReverse: isBack,
});
} else {
// Direct navigation - wait for images first
// Direct navigation - wait for images first, then switch
await waitForPageImages(targetPage);
// Mark background as loading (Image onLoad will set it back to true)
setIsBackgroundReady(false);
setSelectedPageId(targetPageId);
setPageHistory((prev) => [...prev, targetPageId]);
}
@ -756,6 +792,24 @@ export default function RuntimePresentation({
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 */}
{backgroundVideoUrl && (
<video
@ -828,12 +882,15 @@ export default function RuntimePresentation({
</div>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
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
playsInline
preload='auto'

View File

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

View File

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

View File

@ -7,6 +7,7 @@
import axios, { AxiosError } from 'axios';
import { baseURLApi } from '../config';
import { logger } from './logger';
/**
* 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)
if (isPresignedS3Url(url) && !presignedUrlsDisabled) {
console.info('[assetUrl] Presigned URL request failed, disabling presigned URLs');
logger.info('Presigned URL request failed, disabling presigned URLs');
disablePresignedUrls();
}
return Promise.reject(error);
}
},
);
};
@ -44,7 +45,7 @@ export const disablePresignedUrls = (): void => {
if (!presignedUrlsDisabled) {
presignedUrlsDisabled = true;
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.
* 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
const cached = presignedUrlCache.get(storageKey);
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.
* 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
if (presignedUrlsDisabled) {
return Promise.resolve({});
@ -259,7 +264,7 @@ export const arePresignedUrlsDisabled = (): boolean => {
export const markPresignedUrlsVerified = (): void => {
if (!presignedUrlsDisabled && !presignedUrlsVerified) {
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
* const imageUrls = extractPageImageUrls(page);
*/
export const extractPageImageUrls = (
page: PageWithImages | null,
): string[] => {
export const extractPageImageUrls = (page: PageWithImages | null): string[] => {
if (!page) return [];
const imageUrls: string[] = [];

View File

@ -69,7 +69,10 @@ axios.interceptors.response.use(
// Detect presigned S3 URL failures (CORS not configured)
// 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', {
url: requestUrl.slice(0, 80),
});

View File

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

View File

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