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) // 1. Request presigned URLs (max 50 per batch, 1-hour expiry)
POST /api/file/presign { urls: ["assets/img.jpg", ...] } POST /api/file/presign { urls: ["assets/img.jpg", ...] }
// 2. Download directly from S3 → Store in Cache API // 2. Download directly from S3 → Store in Cache API (dual key)
// 3. Create blob URL → Decode image → Store in readyBlobUrlsRef // 3. Create blob URL → Decode image → Store in readyBlobUrlsRef (dual key)
// 4. Instant lookup during navigation (O(1)) // 4. Instant lookup during navigation (O(1)) - use storage key for reliability
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl); 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:** **Preload Priority:**
| Type | Priority | Notes | | 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 fill
sizes='100vw' sizes='100vw'
className={`rounded-full object-cover bg-gray-100 dark:bg-dark-900 ${imageClassName}`} className={`rounded-full object-cover bg-gray-100 dark:bg-dark-900 ${imageClassName}`}
unoptimized
/> />
</div> </div>
) : ( ) : (

View File

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

View File

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

View File

@ -225,18 +225,47 @@ export function usePageSwitch(
/** /**
* Resolve a storage path to a displayable URL. * 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( const resolveToDisplayUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => { async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return ''; 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); const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return ''; if (!originalUrl) return '';
const cache = preloadCacheRef.current; // Try instant blob URL lookup by resolved URL
// 1. Try instant blob URL lookup (already decoded, ready to paint)
if (cache?.getReadyBlobUrl) { if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl); const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) { 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)) { if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
try { try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl); const blobUrl = await cache.getCachedBlobUrl(originalUrl);
@ -257,11 +286,11 @@ export function usePageSwitch(
return blobUrl; return blobUrl;
} }
} catch { } 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) const storageKey = isRelativeStoragePath(storagePath)
? storagePath ? storagePath
: undefined; : 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( const resolveMediaUrl = useCallback(
async (storagePath: string | undefined): Promise<string> => { async (storagePath: string | undefined): Promise<string> => {
if (!storagePath) return ''; 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); const originalUrl = resolveAssetPlaybackUrl(storagePath);
if (!originalUrl) return ''; if (!originalUrl) return '';
const cache = preloadCacheRef.current;
// 1. Try instant blob URL lookup first
if (cache?.getReadyBlobUrl) { if (cache?.getReadyBlobUrl) {
const readyUrl = cache.getReadyBlobUrl(originalUrl); const readyUrl = cache.getReadyBlobUrl(originalUrl);
if (readyUrl) { if (readyUrl) {
@ -290,7 +342,6 @@ export function usePageSwitch(
} }
} }
// 2. Fallback: try cached blob URL
if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) { if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
try { try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl); 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. * Create a blob URL from cache and decode if image.
* Stores the ready-to-display blob URL in readyBlobUrlsRef. * 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( const createReadyBlobUrl = useCallback(
async (url: string): Promise<void> => { async (url: string, storageKey?: string): Promise<void> => {
try { try {
// Get blob from Cache API // Get blob from Cache API
const blob = await StorageManager.getAsset(url); const blob = await StorageManager.getAsset(url);
@ -294,12 +295,19 @@ export function usePreloadOrchestrator(
await decodeImage(blobUrl); await decodeImage(blobUrl);
} }
// Store ready blob URL // Store ready blob URL keyed by download URL
readyBlobUrlsRef.current.set(url, blobUrl); readyBlobUrlsRef.current.set(url, blobUrl);
preloadedUrls.add(url); 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', { logger.info('[PRELOAD] Asset ready', {
url: url.slice(-50), url: url.slice(-50),
storageKey: storageKey?.slice(-50),
blobUrl: blobUrl.slice(0, 30), blobUrl: blobUrl.slice(0, 30),
}); });
} catch (error) { } catch (error) {
@ -345,7 +353,7 @@ export function usePreloadOrchestrator(
if (cached) { if (cached) {
logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) }); logger.info('[PRELOAD] Already cached', { url: item.url.slice(-50) });
// Create blob URL and decode - makes asset ready to display instantly // Create blob URL and decode - makes asset ready to display instantly
await createReadyBlobUrl(item.url); await createReadyBlobUrl(item.url, item.storageKey);
continue; continue;
} }
@ -362,18 +370,34 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Download complete', { logger.info('[PRELOAD] Download complete', {
url: item.url.slice(-50), url: item.url.slice(-50),
}); });
await createReadyBlobUrl(item.url); await createReadyBlobUrl(item.url, item.storageKey);
if (isPresignedUrl(item.url)) { if (isPresignedUrl(item.url)) {
markPresignedUrlsVerified(); 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) { if (item.storageKey) {
const proxyUrl = buildProxyUrl(item.storageKey);
const blobUrl = readyBlobUrlsRef.current.get(item.url); const blobUrl = readyBlobUrlsRef.current.get(item.url);
if (blobUrl) { if (blobUrl) {
// Map proxy URL for fallback compatibility
const proxyUrl = buildProxyUrl(item.storageKey);
readyBlobUrlsRef.current.set(proxyUrl, blobUrl); readyBlobUrlsRef.current.set(proxyUrl, blobUrl);
preloadedUrls.add(proxyUrl); 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) => { .catch(async (err) => {
@ -396,7 +420,7 @@ export function usePreloadOrchestrator(
logger.info('[PRELOAD] Proxy download complete', { logger.info('[PRELOAD] Proxy download complete', {
url: proxyUrl.slice(-60), url: proxyUrl.slice(-60),
}); });
await createReadyBlobUrl(proxyUrl); await createReadyBlobUrl(proxyUrl, item.storageKey);
} catch (retryErr) { } catch (retryErr) {
logger.error('[PRELOAD] Proxy download also failed', { logger.error('[PRELOAD] Proxy download also failed', {
url: proxyUrl.slice(-60), url: proxyUrl.slice(-60),

View File

@ -16,6 +16,7 @@ export type ReverseMode = 'none' | 'reverse' | 'separate';
export interface TransitionConfig { export interface TransitionConfig {
videoUrl: string; videoUrl: string;
storageKey?: string; // Raw storage path for cache lookup
reverseMode: ReverseMode; reverseMode: ReverseMode;
reverseVideoUrl?: string; reverseVideoUrl?: string;
durationSec?: number; durationSec?: number;
@ -42,6 +43,7 @@ export interface UseTransitionPlaybackOptions {
preload?: { preload?: {
preloadedUrls?: Set<string>; preloadedUrls?: Set<string>;
getCachedBlobUrl?: (url: string) => Promise<string | null>; getCachedBlobUrl?: (url: string) => Promise<string | null>;
getReadyBlobUrl?: (url: string) => string | null;
}; };
} }
@ -414,6 +416,38 @@ export function useTransitionPlayback(
}; };
const resolvePlayableSource = async (): Promise<string> => { 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 ( if (
lastLoadedBlobUrlRef.current && lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl lastLoadedSourceUrlRef.current === sourceUrl
@ -439,7 +473,18 @@ export function useTransitionPlayback(
return sourceUrl; 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) { if (getCachedBlobUrl) {
try { try {
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl); 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', { logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode, reverseMode: currentTransition.reverseMode,
}); });

View File

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