preloading fix

This commit is contained in:
Dmitri 2026-03-27 18:27:15 +04:00
parent b8f2274572
commit 41713d1274
9 changed files with 244 additions and 82 deletions

View File

@ -349,13 +349,15 @@ Assets are preloaded directly from S3 for better performance:
// 1. Request presigned URLs (max 50 per batch, 1-hour expiry)
POST /api/file/presign { urls: ["assets/img.jpg", ...] }
// 2. Download directly from S3 → Store in Cache API
// 3. Create blob URL → Decode image → Store in readyBlobUrlsRef
// 2. Download directly from S3 → Store in Cache API (dual key)
// 3. Create blob URL → Decode image → Store in readyBlobUrlsRef (dual key)
// 4. Instant lookup during navigation (O(1))
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl);
// 4. Instant lookup during navigation (O(1)) - use storage key for reliability
const blobUrl = preloadOrchestrator.getReadyBlobUrl(storageKey);
```
**Storage Key Mapping:** Assets are cached under both download URL and canonical storage key (e.g., `assets/project/video.mp4`). Lookups prioritize storage key because presigned URL signatures change on each resolution. This ensures cache hits even after URL regeneration or page refresh.
**Preload Priority:**
| Type | Priority | Notes |
|------|----------|-------|

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,7 @@ export default function ImageField({
fill
sizes='100vw'
className={`rounded-full object-cover bg-gray-100 dark:bg-dark-900 ${imageClassName}`}
unoptimized
/>
</div>
) : (

View File

@ -51,6 +51,7 @@ export default function RuntimePresentation({
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string;
storageKey: string;
isReverse: boolean;
} | null>(null);
const [error, setError] = useState('');
@ -120,6 +121,7 @@ export default function RuntimePresentation({
transition: transitionPreview
? {
videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
targetPageId: transitionPreview.targetPageId,
displayName: 'Transition',
@ -156,6 +158,7 @@ export default function RuntimePresentation({
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl,
},
});
@ -374,6 +377,7 @@ export default function RuntimePresentation({
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
storageKey: transitionVideoUrl, // Raw storage path for cache lookup
isReverse: isBack,
});
} else {
@ -682,28 +686,46 @@ export default function RuntimePresentation({
backgroundPosition: 'center',
}}
>
{/* Background image element - CSS backgroundImage provides instant display,
Image component enhances with optimized loading. bg-black prevents white flash. */}
{/* Background image element - CSS backgroundImage provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
{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);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-cover'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-cover'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}

View File

@ -38,6 +38,7 @@ export default function UserAvatar({
fill
sizes='100vw'
className='rounded-full object-cover bg-gray-100 dark:bg-slate-800'
unoptimized
/>
</div>
)}

View File

@ -225,18 +225,47 @@ export function usePageSwitch(
/**
* Resolve a storage path to a displayable URL.
* Priority: 1) ready blob URL (instant, already decoded), 2) cached blob URL, 3) presigned URL with fallback
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) presigned URL with fallback
*/
const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory storage path lookup first (instant, same session)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) {
logger.info('Using ready blob URL (storage key)', {
storagePath: storagePath.slice(-50),
});
return readyUrl;
}
}
// 2. Try persistent cache by storage path (survives page refresh)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
logger.info('Using cached blob URL (storage key)', {
storagePath: storagePath.slice(-50),
});
return blobUrl;
}
} catch {
// Fall through to URL resolution
}
}
// 3. Resolve to playback URL and try lookup (fallback for resolved URLs)
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
const cache = preloadCacheRef.current;
// 1. Try instant blob URL lookup (already decoded, ready to paint)
// Try instant blob URL lookup by resolved URL
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
@ -245,7 +274,7 @@ export function usePageSwitch(
}
}
// 2. Fallback: try cached blob URL (creates new blob, needs decode)
// Fallback: try cached blob URL by resolved URL
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);
@ -257,11 +286,11 @@ export function usePageSwitch(
return blobUrl;
}
} catch {
// Fall through to fallback
// Fall through
}
}
// 3. Load with presigned URL fallback (handles CORS failures)
// Load with presigned URL fallback (handles CORS failures)
const storageKey = isRelativeStoragePath(storagePath)
? storagePath
: undefined;
@ -271,18 +300,41 @@ export function usePageSwitch(
);
/**
* Resolve video/audio URL (no fallback needed, just blob check)
* Resolve video/audio URL.
* Priority: 1) ready blob URL by storage path, 2) cached blob URL by storage path,
* 3) ready blob URL by resolved URL, 4) cached blob URL, 5) resolved URL
*/
const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return '';
const cache = preloadCacheRef.current;
// 1. Try in-memory storage path lookup first (instant, same session)
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(storagePath);
if (readyUrl) {
return readyUrl;
}
}
// 2. Try persistent cache by storage path (survives page refresh)
if (cache?.getCachedBlobUrl) {
try {
const blobUrl = await cache.getCachedBlobUrl(storagePath);
if (blobUrl) {
createdBlobUrlsRef.current.add(blobUrl);
return blobUrl;
}
} catch {
// Fall through
}
}
// 3. Resolve URL and try lookup by resolved URL
const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return '';
const cache = preloadCacheRef.current;
// 1. Try instant blob URL lookup first
if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) {
@ -290,7 +342,6 @@ export function usePageSwitch(
}
}
// 2. Fallback: try cached blob URL
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl);

View File

@ -273,9 +273,10 @@ export function usePreloadOrchestrator(
/**
* Create a blob URL from cache and decode if image.
* Stores the ready-to-display blob URL in readyBlobUrlsRef.
* If storageKey is provided, also maps the storage key to the blob URL for canonical lookup.
*/
const createReadyBlobUrl = useCallback(
async (url: string): Promise<void> => {
async (url: string, storageKey?: string): Promise<void> => {
try {
// Get blob from Cache API
const blob = await StorageManager.getAsset(url);
@ -294,12 +295,19 @@ export function usePreloadOrchestrator(
await decodeImage(blobUrl);
}
// Store ready blob URL
// Store ready blob URL keyed by download URL
readyBlobUrlsRef.current.set(url, blobUrl);
preloadedUrls.add(url);
// Also map storage key for canonical lookup (most reliable)
if (storageKey) {
readyBlobUrlsRef.current.set(storageKey, blobUrl);
preloadedUrls.add(storageKey);
}
logger.info('[PRELOAD] Asset ready', {
url: url.slice(-50),
storageKey: storageKey?.slice(-50),
blobUrl: blobUrl.slice(0, 30),
});
} catch (error) {
@ -345,7 +353,7 @@ export function usePreloadOrchestrator(
if (cached) {
logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) });
// Create blob URL and decode - makes asset ready to display instantly
await createReadyBlobUrl(item.url);
await createReadyBlobUrl(item.url, item.storageKey);
continue;
}
@ -362,18 +370,34 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Download complete', {
url: item.url.slice(-50),
});
await createReadyBlobUrl(item.url);
await createReadyBlobUrl(item.url, item.storageKey);
if (isPresignedUrl(item.url)) {
markPresignedUrlsVerified();
}
// Also map proxy URL to the same blob URL for fallback lookup
// Map proxy URL and storage key to the same blob URL for fallback lookup
if (item.storageKey) {
const proxyUrl = buildProxyUrl(item.storageKey);
const blobUrl = readyBlobUrlsRef.current.get(item.url);
if (blobUrl) {
// Map proxy URL for fallback compatibility
const proxyUrl = buildProxyUrl(item.storageKey);
readyBlobUrlsRef.current.set(proxyUrl, blobUrl);
preloadedUrls.add(proxyUrl);
}
// Store in Cache API under storage key for post-refresh lookups
if (typeof caches !== 'undefined') {
try {
const cache = await caches.open(OFFLINE_CONFIG.cacheNames.assets);
const existingResponse = await cache.match(item.url);
if (existingResponse) {
await cache.put(item.storageKey, existingResponse.clone());
}
} catch (e) {
logger.warn('[PRELOAD] Failed to store under storage key', {
storageKey: item.storageKey,
});
}
}
}
})
.catch(async (err) => {
@ -396,7 +420,7 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Proxy download complete', {
url: proxyUrl.slice(-60),
});
await createReadyBlobUrl(proxyUrl);
await createReadyBlobUrl(proxyUrl, item.storageKey);
} catch (retryErr) {
logger.error('[PRELOAD] Proxy download also failed', {
url: proxyUrl.slice(-60),

View File

@ -16,6 +16,7 @@ export type ReverseMode = 'none' | 'reverse' | 'separate';
export interface TransitionConfig {
videoUrl: string;
storageKey?: string; // Raw storage path for cache lookup
reverseMode: ReverseMode;
reverseVideoUrl?: string;
durationSec?: number;
@ -42,6 +43,7 @@ export interface UseTransitionPlaybackOptions {
preload?: {
preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => string | null;
};
}
@ -414,6 +416,38 @@ export function useTransitionPlayback(
};
const resolvePlayableSource = async (): Promise<string> => {
// 1. Try storage key lookup first (most reliable for cache hits)
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl;
const storageKey = currentTransition.storageKey;
if (getReadyBlobUrl && storageKey) {
const readyUrl = getReadyBlobUrl(storageKey);
if (readyUrl) {
logger.info('Using ready blob URL from storage key', {
storageKey: storageKey.slice(-50),
});
return readyUrl;
}
}
// 2. Try cached blob URL by storage key (post-refresh scenario)
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && storageKey) {
try {
const cachedBlobUrl = await getCachedBlobUrl(storageKey);
if (cachedBlobUrl) {
logger.info('Using cached blob URL from storage key', {
storageKey: storageKey.slice(-50),
});
lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl;
return cachedBlobUrl;
}
} catch {
// Fall through
}
}
// 3. Reuse cached blob URL if same source (existing logic)
if (
lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl
@ -439,7 +473,18 @@ export function useTransitionPlayback(
return sourceUrl;
}
const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
// 4. Try ready blob URL by resolved URL
if (getReadyBlobUrl) {
const readyUrl = getReadyBlobUrl(sourceUrl);
if (readyUrl) {
logger.info('Using ready blob URL from resolved URL', {
url: sourceUrl.slice(-50),
});
return readyUrl;
}
}
// 5. Try cached blob URL by resolved URL
if (getCachedBlobUrl) {
try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
@ -458,6 +503,7 @@ export function useTransitionPlayback(
}
}
// 6. Fetch video as blob (network fallback)
logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode,
});

View File

@ -86,8 +86,10 @@ type DragElementState = {
type TransitionPreviewState = {
videoUrl: string;
storageKey: string; // Raw storage path for cache lookup
reverseMode: 'none' | 'reverse' | 'separate';
reverseVideoUrl?: string;
reverseStorageKey?: string; // Raw storage path for reverse video cache lookup
durationSec?: number;
title: string;
};
@ -679,6 +681,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
transition: transitionPreview
? {
videoUrl: resolveAssetPlaybackUrl(transitionPreview.videoUrl),
storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.reverseMode,
reverseVideoUrl: transitionPreview.reverseVideoUrl
? resolveAssetPlaybackUrl(transitionPreview.reverseVideoUrl)
@ -723,6 +726,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
preload: {
preloadedUrls: preloadOrchestrator.preloadedUrls,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
},
});
@ -2019,6 +2023,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
setTransitionPreview({
videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw storage path for cache lookup
reverseMode:
direction === 'forward'
? 'none'
@ -2026,6 +2031,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? 'separate'
: 'reverse',
reverseVideoUrl: element.reverseVideoUrl,
reverseStorageKey: element.reverseVideoUrl,
durationSec: element.transitionDurationSec,
title: `${element.navLabel || element.label} · ${direction}`,
});
@ -2187,12 +2193,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (element.iconUrl) {
return (
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip icon'
fill
sizes='100vw'
className='object-contain'
className='max-h-[220px] max-w-[220px] object-contain'
draggable={false}
/>
</div>
@ -2213,12 +2218,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
if (element.iconUrl) {
return (
<div className='relative h-auto w-auto max-h-[220px] max-w-[220px]'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description icon'
fill
sizes='100vw'
className='object-contain'
className='max-h-[220px] max-w-[220px] object-contain'
draggable={false}
/>
</div>
@ -2272,12 +2276,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
>
{card.imageUrl ? (
<div className='relative h-full w-full'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(card.imageUrl)}
alt={card.title || 'Gallery card'}
fill
sizes='100vw'
className='object-cover'
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
@ -2303,12 +2306,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<div className='h-20 overflow-hidden rounded bg-gray-100'>
{firstSlide?.imageUrl ? (
<div className='relative h-full w-full'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(firstSlide.imageUrl)}
alt={firstSlide.caption || 'Carousel slide'}
fill
sizes='100vw'
className='object-cover'
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
/>
</div>
@ -2326,12 +2328,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
<span className='flex items-center gap-1'>
{element.carouselPrevIconUrl ? (
<div className='relative h-3 w-3'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(element.carouselPrevIconUrl)}
alt='Previous icon'
fill
sizes='100vw'
className='object-contain'
className='w-3 h-3 object-contain'
draggable={false}
/>
</div>
@ -2342,12 +2343,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
Next
{element.carouselNextIconUrl ? (
<div className='relative h-3 w-3'>
<NextImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={resolveAssetPlaybackUrl(element.carouselNextIconUrl)}
alt='Next icon'
fill
sizes='100vw'
className='object-contain'
className='w-3 h-3 object-contain'
draggable={false}
/>
</div>
@ -2602,21 +2602,36 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
className='absolute inset-0 bg-black overflow-hidden'
style={canvasBackgroundStyle}
>
{/* Background image - CSS backgroundImage on canvas provides instant display,
NextImage enhances with optimized loading. bg-black prevents white flash. */}
{/* Background image - CSS backgroundImage on canvas provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image.
NextImage only used for non-blob URLs that benefit from optimization. */}
{backgroundImageSrc ? (
<div className='absolute inset-0 h-full w-full pointer-events-none select-none'>
<NextImage
key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageSrc}
alt='Background'
fill
sizes='100vw'
className='object-cover'
draggable={false}
onLoad={() => pageSwitch.markBackgroundReady()}
onError={() => pageSwitch.markBackgroundReady()}
/>
{backgroundImageSrc.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageSrc}
alt='Background'
className='absolute inset-0 w-full h-full object-cover'
draggable={false}
onLoad={() => pageSwitch.markBackgroundReady()}
onError={() => pageSwitch.markBackgroundReady()}
/>
) : (
<NextImage
key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageSrc}
alt='Background'
fill
sizes='100vw'
className='object-cover'
draggable={false}
unoptimized
onLoad={() => pageSwitch.markBackgroundReady()}
onError={() => pageSwitch.markBackgroundReady()}
/>
)}
</div>
) : null}