diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index dfb7594..6887f7e 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -290,6 +290,94 @@ const resolveAssetPlaybackUrl = (value?: string) => { return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`; }; +/** + * Wait for all images on a page to be decoded before switching. + * This eliminates white flash by ensuring images are ready to paint. + */ +const waitForPageImages = async ( + page: TourPage | null, + timeoutMs = 2000, +): Promise => { + if (!page) return; + + const imageUrls: string[] = []; + + // Background image + if (page.background_image_url) { + const url = resolveAssetPlaybackUrl(page.background_image_url); + if (url) imageUrls.push(url); + } + + // Parse ui_schema_json for element images + try { + const uiSchema = + typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json) + : page.ui_schema_json; + + const pageElements = Array.isArray(uiSchema?.elements) + ? uiSchema.elements + : []; + + pageElements.forEach((el: Record) => { + const imageFields = ['iconUrl', 'imageUrl', 'mediaUrl', 'src']; + imageFields.forEach((field) => { + const value = el[field]; + if (typeof value === 'string' && value) { + const url = resolveAssetPlaybackUrl(value); + if (url && !imageUrls.includes(url)) imageUrls.push(url); + } + }); + + // Carousel slides + const slides = el.carouselSlides; + if (Array.isArray(slides)) { + slides.forEach((slide: Record) => { + if (typeof slide.imageUrl === 'string' && slide.imageUrl) { + const url = resolveAssetPlaybackUrl(slide.imageUrl); + if (url && !imageUrls.includes(url)) imageUrls.push(url); + } + }); + } + + // Cards + const cards = el.cards; + if (Array.isArray(cards)) { + cards.forEach((card: Record) => { + if (typeof card.imageUrl === 'string' && card.imageUrl) { + const url = resolveAssetPlaybackUrl(card.imageUrl); + if (url && !imageUrls.includes(url)) imageUrls.push(url); + } + }); + } + }); + } catch { + // Ignore parse errors + } + + if (imageUrls.length === 0) return; + + // Decode all images in parallel with timeout + const decodePromises = imageUrls.map( + (url) => + new Promise((resolve) => { + const img = new Image(); + img.src = url; + if (typeof img.decode === 'function') { + img.decode().then(() => resolve()).catch(() => resolve()); + } else { + img.onload = () => resolve(); + img.onerror = () => resolve(); + } + }), + ); + + await Promise.race([ + Promise.all(decodePromises), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +}; + const readMediaDuration = ( playbackUrl: string, mediaType: 'video' | 'audio', @@ -2137,10 +2225,14 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { if (!hasPlayableTransition) { setPendingNavigationPageId(''); setTransitionPreview(null); - setActivePageId(targetPageId); - setSelectedElementId(''); - setSelectedMenuItem('none'); - setErrorMessage(''); + // Wait for target page images to decode before switching (eliminates white flash) + const targetPage = pages.find((p) => p.id === targetPageId) || null; + waitForPageImages(targetPage).then(() => { + setActivePageId(targetPageId); + setSelectedElementId(''); + setSelectedMenuItem('none'); + setErrorMessage(''); + }); return; } @@ -2638,25 +2730,42 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { clearTimers(); cleanupReverseFrame(); setIsBufferingForReverse(false); + // Pause video but keep last frame visible video.pause(); - video.removeAttribute('src'); - video.load(); - // Don't cleanup blob URL here - might be reused for reverse - setTransitionPreview(null); - setPendingNavigationPageId((pendingPageId) => { - const nextPageId = String(pendingPageId || '').trim(); - if (nextPageId) { - setActivePageId(nextPageId); - setSelectedElementId(''); - setSelectedMenuItem('none'); - setErrorMessage(''); - } - return ''; - }); + logger.info('Transition preview finished', { reason, src: video.currentSrc || sourceUrl || '', }); + + setPendingNavigationPageId((pendingPageId) => { + const nextPageId = String(pendingPageId || '').trim(); + if (nextPageId) { + // Wait for target page images to decode before hiding transition + const targetPage = pages.find((p) => p.id === nextPageId) || null; + waitForPageImages(targetPage).then(() => { + // Switch page first (while transition still covers it) + setActivePageId(nextPageId); + setSelectedElementId(''); + setSelectedMenuItem('none'); + setErrorMessage(''); + // Wait for React to render, then hide transition + requestAnimationFrame(() => { + requestAnimationFrame(() => { + video.removeAttribute('src'); + video.load(); + setTransitionPreview(null); + }); + }); + }); + } else { + // No pending navigation - just cleanup + video.removeAttribute('src'); + video.load(); + setTransitionPreview(null); + } + return ''; + }); }; const configuredDurationSec = Number(transitionPreview.durationSec);