60 KiB
Runtime Presentation Page - E2E Documentation
Overview
The Runtime Presentation system is a full-screen, presentation-mode viewer for virtual tours. It enables end users to navigate through tour pages with transitions, rich media content, and offline support. Unlike the Constructor (edit mode), Runtime is optimized for public viewing with intelligent preloading and smooth transitions.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Access Routes │
│ /p/[projectSlug] │ /p/[projectSlug]/stage │ /runtime │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ RuntimePresentation Component │
│ Page Rendering │ Navigation │ Transitions │ Offline Support │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Hooks Layer │
│ usePageDataLoader │ useProjectAssets │ usePreloadOrchestrator │
│ usePageSwitch │ useTransitionPlayback │ useBackgroundTransition│
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Component Layer │
│ RuntimeElement │ UiElementRenderer │ GalleryCarouselOverlay │
│ RuntimeControls (Offline + Fullscreen + Sound) │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Helper Libraries │
│ extractPageLinks │ navigationHelpers │ assetUrl │ logger │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────────┐
│ Data Layer │
│ Projects API │ Tour Pages API │ S3 Direct Download │
└─────────────────────────────────────────────────────────────────┘
Access Modes
Production Mode
Route: /p/[projectSlug]
- Public access without authentication
- Shows only production environment pages
- Full offline support with OfflineToggle
Stage Mode
Route: /p/[projectSlug]/stage
- Testing environment for previewing changes before production
- Shows only
stageenvironment pages - Environment badge displayed
- Content published from Constructor via "Save to Stage" button
Admin Runtime Mode
Route: /runtime
- Internal admin testing
- Requires authentication
- Can test any project
Environment Model
Content flows through three environments:
┌─────────────────────────────────────────────────────────────────┐
│ Constructor (Edit) Stage (Preview) Production (Live) │
│ dev ──────► stage ──────► production │
│ "Save to Stage" "Publish" │
└─────────────────────────────────────────────────────────────────┘
| Environment | Purpose | Access |
|---|---|---|
dev |
Active editing in Constructor | Constructor only (not Runtime) |
stage |
Preview/testing before publish | /p/[slug]/stage route |
production |
Live public presentation | /p/[slug] route |
Note: The dev environment is only accessible in the Constructor. Runtime always shows either stage or production content.
Runtime Context Detection
Primary Method: Route-Based Environment
The platform uses route-based environment access, not subdomains. The environment is determined by the frontend route and passed to the backend via headers.
| Route | Environment | Component |
|---|---|---|
/p/[projectSlug] |
production | pages/p/[projectSlug]/index.tsx |
/p/[projectSlug]/stage |
stage | pages/p/[projectSlug]/stage.tsx |
Frontend sends environment via headers:
// RuntimePresentation.tsx
const apiConfig = {
headers: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment, // 'production' | 'stage'
},
};
Backend middleware (middleware/runtime-context.ts):
// Reads both hostname AND headers for flexibility
req.runtimeContext = {
mode: detectFromHostname(hostname), // Fallback for subdomain access
headerEnvironment: req.headers['x-runtime-environment'],
headerProjectSlug: req.headers['x-runtime-project-slug'],
};
Backend DB filtering (db/api/runtime-context.ts):
// Uses header-based environment when hostname detection returns 'admin'
// SECURITY: Only 'production' and 'stage' allowed from headers
// 'dev' is blocked to prevent unauthorized access to dev data
Project Loading
useProjectAssets Hook
The component uses the useProjectAssets hook to resolve project assets (favicon, og_image) to presigned URLs for meta tags:
// Resolve project assets (favicon, og_image, logo) to presigned URLs
const { faviconUrl, ogImageUrl } = useProjectAssets(project);
This hook:
- Extracts storage paths from URLs (handles both
storage_keyand legacycdn_urlformats) - Queues presigned URL requests for relative paths
- Resolves to playback URLs (presigned if available, otherwise proxy)
Global UI Controls
Runtime controls for offline mode, fullscreen, and global sound mute resolve
through global_ui_control_defaults, project_ui_control_settings, and the
current page's global_ui_controls_settings_json. Dimensions and positions use
canvas-relative percentages, so controls keep consistent proportions and spacing
across screens for projects with the same canvas ratio.
Custom icons use each control's defaultIconUrl and activeIconUrl; empty
values fall back to the built-in MDI icons.
Embedded runtime fullscreen uses the same wrapper fallback as Info Panel image
detail fullscreen: native document fullscreen first, same-origin iframe
fullscreen second, then parent tour-builder:request-fullscreen postMessage.
Exit from cross-origin wrapper fullscreen posts tour-builder:exit-fullscreen.
usePageDataLoader Hook
The component uses the shared usePageDataLoader hook for data loading:
const { project, pages, isLoading, error, initialPageId } = usePageDataLoader({
projectSlug,
environment,
apiHeaders: {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment,
},
});
Data Fetching Flow
1. usePageDataLoader fetches project
└── GET /projects?slug={projectSlug}
└── Headers: X-Runtime-Project-Slug, X-Runtime-Environment
2. usePageDataLoader fetches tour pages
└── GET /tour_pages?project={projectId}
└── Pages include ui_schema_json with navigation elements
3. STRICT environment filtering (both layers)
└── Backend: Filters by X-Runtime-Environment header
└── Frontend: .filter((p) => p.environment === environment)
4. Pages sorted by sort_order
5. initialPageId returned (first page by sort_order)
Note: Navigation targets, transitions, and page elements are all stored in tour_pages.ui_schema_json. No separate API calls needed.
Environment Isolation (Critical)
IMPORTANT: Strict environment filtering prevents data leaks.
Frontend Filter (RuntimePresentation.tsx):
// STRICT: Only exact environment match - no fallbacks!
const envFilteredPages = pageRows
.filter((p: any) => p.environment === environment)
.sort((a: any, b: any) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
Backend Filter (db/api/runtime-context.ts):
// Header-based filtering for route-based access
// Only 'production' and 'stage' allowed - 'dev' is BLOCKED
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
| Route | Shows | Never Shows |
|---|---|---|
/p/cardiff |
environment='production' only |
dev, stage |
/p/cardiff/stage |
environment='stage' only |
dev, production |
Public Runtime Headers
const headers = {
'X-Runtime-Project-Slug': projectSlug,
'X-Runtime-Environment': environment, // 'production' | 'stage'
};
Public Field Filtering
The runtime-public.ts middleware sanitizes responses for unauthenticated requests:
Projects: id, name, slug, description, logo_url, favicon_url, og_image_url
Tour Pages: id, projectId, environment, source_key, name, slug, sort_order, background_image_url, background_video_url, background_embed_url, background_audio_url, background_loop, requires_auth, ui_schema_json
Project Audio Tracks: id, projectId, environment, source_key, name, slug, url, loop, volume, sort_order, is_enabled
Note: Navigation links and transitions are stored in tour_pages.ui_schema_json, not as separate entities.
Transition Settings (Public Access)
The RuntimePresentation fetches transition settings from two endpoints:
// On mount - fetch global defaults (always public)
useEffect(() => {
dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]);
// When project loads - fetch project-specific settings
useEffect(() => {
if (project?.id) {
dispatch(fetchProjectTransitionSettings({
projectId: project.id,
environment,
apiHeaders: runtimeApiHeaders,
}));
}
}, [dispatch, project?.id, environment, runtimeApiHeaders]);
Authentication Model (URL-path based):
| Endpoint | Environment | Auth Required |
|---|---|---|
GET /global-transition-defaults |
n/a | No (always public) |
GET /project-transition-settings/project/:id/env/production |
production | No for public projects; JWT + staff permission or DB access grant for private projects |
GET /project-transition-settings/project/:id/env/stage |
stage | Yes |
Public presentations (/p/[slug]) work in incognito mode without authentication.
Private production presentations use the same runtime route, but require JWT and
the project visibility/access grant flow described in
private-production-presentations.md.
Page Rendering
Z-Index Layering Structure
The RuntimePresentation uses a specific z-index hierarchy to ensure proper layering of components:
| Layer | Z-Index | Component | Purpose |
|---|---|---|---|
| Background | z-1 |
Background image/video | Page background content |
| Backdrop blur | z-5 |
BackdropPortalProvider | Blur effects for elements |
| Carousel background | z-10 |
CarouselElement (portal) | Full-width carousel background |
| Previous background | z-10 |
Previous bg overlay | Shows during page transitions |
| Carousel controls | z-30 |
CarouselElement (portal) | Carousel prev/next buttons |
| Page elements | z-40 |
Elements container | Navigation buttons, UI elements |
| UI controls | settings-driven | RuntimeControls | Canvas-relative global controls |
| Transition overlay | z-50 |
Transition video | Page transition videos |
Key Design Decisions:
- Page elements at
z-40ensures they appear above carousel controls (z-30) for proper click handling - Carousel uses portals to
document.bodywithposition: fixedfor full-screen display - Carousel wrappers have
pointer-events-nonewith buttons havingpointer-events-auto - Background video at
z-1keeps it below backdrop blur effects
RuntimeControls Component
Location: src/components/Runtime/RuntimeControls.tsx
The RuntimeControls component renders the offline toggle, fullscreen button, and optional global sound button. Each button has independent canvas-relative positioning, dimensions, styling, order, visibility, disabled state, and state-specific icon/color settings. The sound button is presentation-level: if any page in the loaded runtime environment has background audio, unmuted background video, hover/click effects, media player sound, or gallery/info-panel video, the button is visible on every page in that presentation.
Key Features:
| Feature | Implementation |
|---|---|
| Canvas-relative positioning | xPercent/yPercent are resolved inside the visible canvas bounds |
| Canvas-relative sizing | buttonSizePercent, iconSizePercent, and borderRadiusPercent resolve from canvas width |
| Pinch-zoom resistance | Uses visualViewport API to counter-scale during pinch-zoom |
| Configurable z-index | Runtime uses control settings; constructor clamps controls below editor chrome |
| Global sound toggle | Uses presentationHasAudio, useVideoSoundControl, and backgroundAudioController to mute/unmute background audio, hover/click effects, audio/video player elements, and gallery/info-panel videos across the whole presentation |
| Optional controls | showOfflineButton, showFullscreenButton, and showSoundButton gate rendering without deleting settings |
iOS Pinch-Zoom Fix:
iOS browsers (all use WebKit engine) have inconsistent scaling behavior between rem units and other CSS values during pinch-zoom gestures. The RuntimeControls uses a useCounterZoom hook that:
- Listens to
visualViewport.resizeandvisualViewport.scrollevents - Reads
visualViewport.scaleto detect current pinch-zoom level - Applies an inverse
transform: scale(1/zoomLevel)to maintain constant visual size
// Counter-scale formula
const counterScale = 1 / visualViewport.scale;
// Applied as: transform: scale(counterScale), transformOrigin: 'top right'
Sub-components:
ControlButton- Styled button with hover states matching BaseButton colorsControlIcon- built-in SVG or custom asset icon rendererOfflineControl- Offline download toggle with status indicators
UI Schema Structure
Pages store elements in ui_schema_json:
interface UISchema {
elements: UISchemaElement[];
}
interface UISchemaElement {
id: string;
type: ElementType;
// Position (percentage-based)
xPercent: number;
yPercent: number;
rotation?: number;
// Dimensions
width?: string;
height?: string;
// Styling
opacity?: number;
padding?: string;
margin?: string;
fontSize?: string;
fontFamily?: string;
color?: string;
backgroundColor?: string;
// Content
iconUrl?: string;
imageUrl?: string;
mediaUrl?: string;
// Navigation (stored in ui_schema_json)
targetPageSlug?: string; // Slug-based navigation (consistent across environments)
navType?: 'forward' | 'back';
navLabel?: string;
navDisabled?: boolean; // Runtime ignores activation while preserving visual effects
transitionVideoUrl?: string;
// Type-specific
descriptionTitle?: string;
descriptionText?: string;
tooltipTitle?: string;
tooltipText?: string;
galleryCards?: GalleryCard[];
carouselSlides?: CarouselSlide[];
}
Element Rendering
Elements are rendered using shared components for WYSIWYG consistency with the constructor:
{/* Page elements - z-40 ensures they appear above carousel controls (z-30) */}
<div className="absolute inset-0 z-40">
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) => handleGalleryCardClick(element, cardIndex)}
/>
))}
</div>
// URL resolver uses preloaded blob URLs when available (instant display)
const resolveUrlWithBlob = useCallback(
(url: string | undefined): string => {
if (!url) return '';
// Try storage key first, then resolved URL
const blobUrl =
preloadOrchestrator?.getReadyBlobUrl(url) ||
preloadOrchestrator?.getReadyBlobUrl(resolveAssetPlaybackUrl(url));
if (blobUrl) return blobUrl;
return resolveAssetPlaybackUrl(url);
},
[preloadOrchestrator],
);
Component Architecture:
RuntimeElementhandles positioning, rotation, and interactive effects (hover/focus/active)RuntimeElementdelegates content rendering toUiElementRendererUiElementRendererdelegates to per-type components (NavigationElement, GalleryElement, etc.)- Both Constructor (
CanvasElement) and Runtime (RuntimeElement) useUiElementRendererfor WYSIWYG consistency
RuntimeElement Component
Wraps each UI element with positioning, interactive effects, and delegates content rendering to UiElementRenderer:
| Feature | Description |
|---|---|
| Percentage positioning | Uses xPercent, yPercent for responsive placement |
| Transform | Centers element and applies rotation |
| Interactive effects | Hover, focus, and active state styling via useElementEffects hook |
| Click handling | Propagates clicks to parent handler; disabled navigation/info panel elements keep visual hover/focus/active effects but ignore click and keyboard activation |
| Gallery card clicks | onGalleryCardClick prop opens GalleryCarouselOverlay |
| Content delegation | Renders content via UiElementRenderer for WYSIWYG consistency |
| URL resolution | Passes resolveUrl prop for blob URL support |
UiElementRenderer Component
Unified element rendering component - single source of truth used by both RuntimeElement (presentation) and CanvasElement (constructor) for WYSIWYG consistency.
Architecture:
UiElementRenderer (main entry point)
├── useElementWrapperStyle (shared styling hook)
└── Per-type components:
├── NavigationElement (navigation_next, navigation_prev)
├── GalleryElement (gallery) → can trigger GalleryCarouselOverlay
├── TooltipElement (tooltip)
├── DescriptionElement (description)
├── CarouselElement (carousel)
├── LogoElement (logo)
├── SpotElement (spot/hotspot)
├── VideoPlayerElement (video_player)
├── AudioPlayerElement (audio_player)
└── PopupElement (popup)
GalleryCarouselOverlay (fullscreen overlay)
├── Swipe navigation between images
├── Customizable prev/next/back buttons
└── Percentage-based button positioning
| Element Type | Component | Rendered Content |
|---|---|---|
navigation_* |
NavigationElement | Icon + label with nav styling |
spot |
SpotElement | Hotspot/clickable area with icon |
description |
DescriptionElement | Title + text block |
tooltip |
TooltipElement | Icon with popover |
video_player |
VideoPlayerElement | HTML5 video |
audio_player |
AudioPlayerElement | HTML5 audio |
gallery |
GalleryElement | Image grid from galleryCards |
carousel |
CarouselElement | Slideshow from carouselSlides |
logo |
LogoElement | Logo image element |
popup |
PopupElement | Modal/popup overlay |
Element Types
| Type | Rendering |
|---|---|
navigation_next |
Button with icon, navigates forward |
navigation_prev |
Button with icon, navigates backward |
spot |
Hotspot/clickable area with icon |
description |
Title + text block with styling |
tooltip |
Icon with hover popover |
video_player |
HTML5 video with controls |
audio_player |
HTML5 audio with controls |
gallery |
Grid of images from galleryCards |
carousel |
Slideshow from carouselSlides |
logo |
Logo image element |
popup |
Modal/popup overlay |
Page Navigation
Navigation Flow
┌─────────────────────────────────────────────────────────────────┐
│ 1. User clicks navigation element │
│ └── handleElementClick(element) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 2. Extract navigation data & resolve slug │
│ └── targetPageSlug → resolve to targetPageId │
│ └── navType, transitionVideoUrl │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 3. Determine if back navigation │
│ └── navType === 'back' OR type === 'navigation_prev' │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 4. Wait for target page images │
│ └── Decode images to prevent white flash │
└─────────────────────────────────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Has Transition? │ │ No Transition │
│ YES │ │ Direct page switch │
└──────────┬───────────┘ └──────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 5. Show transition overlay via useTransitionPlayback │
│ └── Full-screen video playback (forward or reverse) │
│ └── Images pre-decode DURING video playback │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ 6. Video ends → onComplete callback fires │
│ └── waitForPageImages() completes instantly (pre-decoded) │
│ └── Switch to target page │
│ └── Update page history │
│ └── Double requestAnimationFrame ensures page painted │
│ └── THEN remove overlay (setTransitionPreview(null)) │
└─────────────────────────────────────────────────────────────────┘
Element Click Handler
The click handler uses shared navigation helpers from lib/navigationHelpers.ts:
const handleElementClick = useCallback(
(element: any) => {
if (isNavigationType(element.type) && element.navDisabled) {
return;
}
// Block navigation while transition is actively playing or buffering
if (isTransitionBlocking(transitionPhase as TransitionPhase, isBuffering)) {
return;
}
// Use shared helper to resolve navigation target
const navTarget = resolveNavigationTarget(element, pages);
if (navTarget) {
navigateToPage(
navTarget.pageId,
navTarget.transitionVideoUrl,
navTarget.isBack,
);
}
},
[navigateToPage, pages, transitionPhase, isBuffering],
);
Navigation Helpers (lib/navigationHelpers.ts)
| Helper | Purpose |
|---|---|
resolveNavigationTarget(element, pages) |
Resolves element's targetPageSlug to page ID, returns {pageId, transitionVideoUrl, isBack} or null |
isTransitionBlocking(phase, isBuffering) |
Returns true if navigation should be blocked (transition playing) |
getNavigationDirection(element) |
Returns 'forward' or 'back' based on navType or element type |
Note: Navigation elements store targetPageSlug (not UUID) because slugs are consistent across environments (dev/stage/production). The slug is resolved to a page ID at navigation time.
Page History Management
Page history is now managed by the shared usePageNavigation hook (replacing manual useState):
// usePageNavigation hook provides unified history management
const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId,
trackHistory: true,
});
// applyPageSelection handles history with browser-like behavior:
// - Forward: appends to history (trimmed to MAX_HISTORY_LENGTH=50)
// - Back (isBack=true): pops from history if target matches previous page
applyPageSelection(targetPageId, isBack);
// getNavigationContext provides context for history-based navigation
const navContext = getNavigationContext();
// Returns: { currentPageSlug, previousPageId }
const navTarget = resolveNavigationTarget(element, pages, navContext);
Transition Execution
Transition State
// Transition state for useTransitionPlayback hook
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
videoUrl: string; // Resolved URL for playback
storageKey: string; // Raw storage path for cache lookup (enables presigned URL independence)
isReverse: boolean;
} | null>(null);
useTransitionPlayback Hook Integration
The useTransitionPlayback hook handles both forward and reverse playback with smooth transitions:
// State for coordinating transition completion with background readiness
const [isBackgroundReady, setIsBackgroundReady] = useState(true);
const [pendingTransitionComplete, setPendingTransitionComplete] = useState(false);
// Hook returns both isBuffering and phase for granular opacity control
const { isBuffering, phase: transitionPhase } = useTransitionPlayback({
videoRef: transitionVideoRef,
transition: transitionPreview
? {
videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey, // Raw path for instant cache lookup
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none',
targetPageId: transitionPreview.targetPageId,
displayName: 'Transition',
isBack: transitionPreview.isReverse, // Pass through for history management
}
: null,
// onComplete receives isBack flag for proper history management
onComplete: async (targetPageId, isBack) => {
if (targetPageId) {
const targetPage = pages.find((p) => p.id === targetPageId);
// Use shared hook to resolve blob URLs and switch page
await pageSwitch.switchToPage(targetPage, () => {
// usePageNavigation hook: pops history on back, appends on forward
applyPageSelection(targetPageId, isBack ?? false);
});
setIsBackgroundReady(false);
// Signal transition complete, wait for background
setPendingTransitionComplete(true);
} else {
// Cleanup when no target page
video?.removeAttribute('src');
video?.load();
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
},
features: {
useBlobUrl: true, // Enables seeking for reverse playback
preDecodeImages: false, // Overlay shows last frame while new bg loads behind
},
preload: {
preloadedUrls: preloadOrchestrator?.preloadedUrls || new Set(),
getCachedBlobUrl: preloadOrchestrator?.getCachedBlobUrl,
getReadyBlobUrl: preloadOrchestrator?.getReadyBlobUrl, // Instant O(1) lookup by storage key
},
});
useBackgroundTransition Hook Integration
The useBackgroundTransition hook handles crossfade effects for non-video navigation only:
// Use shared background transition hook for crossfade effects
// NOTE: fadeOut config is NOT used for video transitions.
// Video transitions end instantly (last frame = new page, then overlay removed).
// fadeIn is used for non-video navigation (crossfade 700ms).
const { isFadingIn, onFadeInAnimationEnd, resetFadeIn } =
useBackgroundTransition({
pageSwitch,
// No fadeOut - video transitions don't use fade
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
Video Overlay Removal (separate effect, no hook involvement):
// Video transition overlay removal - instant (no fade) when background is ready
useEffect(() => {
if (pendingTransitionComplete && isBackgroundReady) {
const video = transitionVideoRef.current;
if (video) {
video.removeAttribute('src');
video.load();
}
setTransitionPreview(null);
setPendingTransitionComplete(false);
}
}, [pendingTransitionComplete, isBackgroundReady]);
How it works (instant removal, no fade):
- When
pendingTransitionComplete && isBackgroundReady, effect triggers cleanup - Instant overlay removal (no fade animation):
video.removeAttribute('src'); video.load()- cleanup videosetTransitionPreview(null)- removes overlay immediatelysetPendingTransitionComplete(false)- reset state
Why no fade for video transitions:
- Video itself IS the transition effect
- First frame of video = old page background
- Last frame of video = new page background
- Fading would create visual discontinuity
Transition Overlay Rendering
{/* Container hidden while buffering to prevent black flash */}
{/* Video first frame should match old page background */}
{/* Overlay removed instantly when new background ready */}
{transitionPreview && (
<TransitionPreviewOverlay
videoRef={transitionVideoRef}
isActive={true}
isBuffering={transitionPhase === 'preparing' || isBuffering}
letterboxStyles={letterboxStyles}
opacity={1} // Always 1 - no fade-out for video transitions
/>
)}
TransitionPreviewOverlay behavior:
containerOpacity = isBuffering ? 0 : 1- hides entire container while loading- Old page visible through transparent overlay until video ready
- Video appears instantly when ready (first frame = old page)
- Overlay removed instantly when new background ready
Key Features
| Feature | Description |
|---|---|
| Container hidden while buffering | Prevents black flash, old page visible |
| Instant overlay appearance | Video first frame matches old page |
| Instant overlay removal | No fade-out, removed when bg ready |
| useBackgroundTransition | Shared hook handles crossfade (non-video nav) |
| isBackgroundReady state | Tracks when new page background is fully rendered |
| pendingTransitionComplete state | Signals video ended, waiting for background |
| Blob URL support | Enables seeking for reverse playback |
| Video cleanup | removeAttribute('src') + load() prevents memory leaks |
| Navigation blocking | isTransitionBlocking() prevents navigation during playback |
Reverse Playback Modes
The hook internally uses useReversePlayback with strategies:
- Native Reverse -
video.playbackRate = -1(Chrome, Edge) - Frame-Stepping - Manual frame-by-frame reverse (Safari, Firefox)
Background Media
Background Image
The background uses both CSS background-image (for immediate display) and an image element for proper loading detection. Blob URLs use native <img> to prevent re-fetch issues, while regular URLs use Next.js Image:
<div
className="relative w-screen h-screen overflow-hidden bg-black"
style={{
backgroundImage: backgroundImageUrl ? `url("${backgroundImageUrl}")` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
{/* Image element ensures proper loading for waitForPageImages() */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className="absolute inset-0 pointer-events-none">
{backgroundImageUrl.startsWith('blob:') ? (
// Native img for blob URLs - no re-fetch on re-render
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=""
className="absolute inset-0 w-full h-full object-cover"
onLoad={() => setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
/>
) : (
// Next.js Image for regular URLs - optimization benefits
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=""
fill
sizes="100vw"
className="object-cover"
priority
unoptimized
onLoad={() => setIsBackgroundReady(true)}
onError={() => setIsBackgroundReady(true)}
/>
)}
</div>
)}
</div>
Why conditional rendering?
- Next.js
<Image>re-fetchessrcon every re-render, even withunoptimized - Blob URLs are already in memory - no need for Next.js optimization
- Native
<img>is cached by browser and doesn't re-fetch - This prevents thousands of unnecessary blob URL requests during animations
Why both CSS and Image element?
- CSS
background-imageprovides immediate visual (from browser cache) - Image element triggers
onLoadcallback for proper state tracking waitForPageImages()usesimg.decode()which works with both image types- This prevents black flash when transitioning between pages
Background Video
Background videos support configurable playback settings including custom start/end times.
Video Playback Settings (from tour_pages):
| Field | Type | Default | Description |
|---|---|---|---|
background_video_autoplay |
BOOLEAN | true | Autoplay on load |
background_video_loop |
BOOLEAN | true | Loop continuously |
background_video_muted |
BOOLEAN | true | Mute audio (required for autoplay) |
background_video_start_time |
DECIMAL(10,1) | null | Start time in seconds |
background_video_end_time |
DECIMAL(10,1) | null | End/loop time in seconds |
DECIMAL Parsing (Critical):
Sequelize DECIMAL fields return strings from the database (e.g., "2.5" not 2.5). These must be parsed before use:
// Parse DECIMAL strings for video time settings
const videoStartTime =
selectedPage?.background_video_start_time != null
? parseFloat(String(selectedPage.background_video_start_time))
: null;
const videoEndTime =
selectedPage?.background_video_end_time != null
? parseFloat(String(selectedPage.background_video_end_time))
: null;
useBackgroundVideoPlayback Hook:
The useBackgroundVideoPlayback hook handles start/end time control via video events:
const { videoRef: bgVideoRef } = useBackgroundVideoPlayback({
videoUrl: backgroundVideoUrl,
autoplay: videoAutoplay,
loop: videoLoop,
muted: videoMuted,
startTime: videoStartTime, // Must be parsed from DECIMAL string
endTime: videoEndTime, // Must be parsed from DECIMAL string
});
Note: When endTime is set, native HTML5 loop is disabled. The hook handles looping via timeupdate event, seeking back to startTime.
Rendering:
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl} // Force remount on URL change
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={videoEndTime == null ? videoLoop : false} // JS handles loop when endTime set
muted={videoMuted}
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
)}
Background Ready State
For pages without images or with videos, background is immediately ready:
useEffect(() => {
if (!selectedPage?.background_image_url || selectedPage?.background_video_url) {
setIsBackgroundReady(true);
}
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
Preloading Integration
Extracting Page Links and Elements
Navigation and preload data is extracted from ui_schema_json using the shared utility:
import { extractPageLinksAndElements } from '../lib/extractPageLinks';
// Extract page links and preload elements from ui_schema_json
const { pageLinks, preloadElements } = useMemo(() => {
return extractPageLinksAndElements(pages);
}, [pages]);
This enables:
- pageLinks - Navigation connections for the neighbor graph
- preloadElements - Asset URLs (icons, media, transitions) for preloading
Info Panel target_page click destinations are extracted from nested infoPanelSections as page links. Runtime trigger clicks are ignored when infoPanelDisabled: true. When a page renders, every enabled Info Panel element with infoPanelOpenByDefault: true opens automatically; missing or false values preserve the legacy closed-by-default behavior. Runtime tracks open Info Panels by element ID, and binds image detail state, fullscreen gallery state, and media-section selected image state to the originating panel ID. Multiple open Info Panels share a single fullscreen backdrop: backdrop clicks close the whole open group, while each panel close button closes only that panel. Runtime clicks from header/title/text sections, span items, and image/card/video/360 items resolve targetPageSlug against the current environment's pages; external URL actions open a new browser tab. Cards/media sections support two rendering modes: mediaOpenMode: 'panel' opens the item in the side preview panel, and mediaOpenMode: 'fullscreen' opens the section's media items in the shared fullscreen GalleryCarouselOverlay; video items use videoUrl and render with native video controls in both modes. Image detail panels render through a body-level portal so their fullscreen state is not trapped below the canvas stacking context or runtime global controls. When a presentation is already in browser fullscreen, image detail fullscreen uses local expansion and can return to panel view without exiting presentation fullscreen. Embedded presentations first request native panel fullscreen, then same-origin iframe fullscreen, then post tour-builder:request-fullscreen to the parent page before falling back to local iframe-viewport fullscreen. Media items with useAsBackground replace the current screen background through the same image/video/360 background renderer used by page backgrounds. While the fullscreen gallery overlay is open, runtime top-right controls are not rendered, and 360 iframes in the overlay are not granted iframe fullscreen permission. 360 iframe URLs are normalized through buildChromeFreeEmbedUrl; Kuula embeds are rendered with fs=0 so provider fullscreen controls do not duplicate platform controls while source playback/autorotate parameters are preserved.
usePreloadOrchestrator
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks, // From extractPageLinksAndElements
elements: preloadElements, // From extractPageLinksAndElements
currentPageId: selectedPageId,
pageHistory,
enabled: !isLoading && !error,
// maxNeighborDepth defaults to 1 - only preload immediate neighbors
});
// Available methods
const {
isPreloading,
preloadedUrls,
queueLength,
getCachedBlobUrl, // Async: creates blob URL from cache (by storage key or resolved URL)
isUrlPreloaded,
getReadyBlobUrl, // Instant O(1) lookup by storage key: decoded blob URL ready to display
} = preloadOrchestrator;
// Storage key mapping enables reliable cache lookups
// regardless of which presigned URL was used for download
Storage Key Mapping (Key Feature)
The preload system maps assets by storage key (canonical path like assets/project-123/video.mp4) in addition to download URLs. This enables cache hits even when presigned URLs change.
// Setting transition preview with storage key
setTransitionPreview({
targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), // Resolved for playback
storageKey: transitionVideoUrl, // Raw path for cache lookup
isReverse: isBack,
});
// Lookup priority in useTransitionPlayback:
// 1. getReadyBlobUrl(storageKey) → instant O(1) Map lookup (same session)
// 2. getCachedBlobUrl(storageKey) → Cache API lookup (~5ms, post-refresh)
// 3. getReadyBlobUrl(resolvedUrl) → fallback
// 4. getCachedBlobUrl(resolvedUrl) → fallback
// 5. Network fetch → last resort
Why storage key mapping matters:
| Scenario | Download URL | Storage Key | Lookup Result |
|---|---|---|---|
| Same session | https://s3...?Sig=ABC |
assets/vid.mp4 |
✅ Instant via storage key |
| New presigned URL | https://s3...?Sig=XYZ |
assets/vid.mp4 |
✅ Instant via storage key |
| Page refresh | N/A (in-memory cleared) | assets/vid.mp4 |
✅ From Cache API by storage key |
### usePageSwitch Integration
The `usePageSwitch` hook provides smooth page transitions using blob URLs from the preload cache:
```typescript
const pageSwitch = usePageSwitch({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl, // Instant O(1)
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl, // Fallback
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
});
// Switch to a page with smooth background transition
await pageSwitch.switchToPage(targetPage);
// Canvas background uses resolved blob URLs
const backgroundImageSrc =
pageSwitch.currentBgImageUrl || resolveAssetPlaybackUrl(backgroundImageUrl);
White Flash Prevention:
getReadyBlobUrl()- O(1) instant lookup for pre-decoded blob URLspreviousBgImageUrl- Keeps old background visible during switchmarkBackgroundReady()- Called when new background loadsclearPreviousBackground()- Fades out overlay after paint confirmed
Preload Priority
Current page assets: priority = 1000 + assetType weight
Neighbor (distance 1): priority = 500 + assetType weight
Asset weights:
- Transition: +150 (highest - needed immediately on navigation)
- Image: +100 (backgrounds load during transition playback)
- Audio: +50
- Video: +30
Note: maxNeighborDepth defaults to 1 (immediate neighbors only). Depth 2 was causing too many requests.
Image Pre-Decode
const waitForPageImages = async (pageId: string) => {
const page = pages.find((p) => p.id === pageId);
const imageUrls: string[] = [];
// Collect background image
if (page.background_image_url) {
imageUrls.push(page.background_image_url);
}
// Collect element images
const schema = JSON.parse(page.ui_schema_json || '{}');
(schema.elements || []).forEach((el) => {
if (el.iconUrl) imageUrls.push(el.iconUrl);
if (el.imageUrl) imageUrls.push(el.imageUrl);
});
// Decode all images
await Promise.all(
imageUrls.map(async (url) => {
const img = new Image();
img.src = url;
try {
await Promise.race([
img.decode(),
new Promise((_, reject) => setTimeout(reject, 2000)),
]);
} catch {
// Ignore decode failures
}
})
);
};
Offline Support
OfflineToggle Component
<OfflineToggle
projectId={project.id}
projectSlug={projectSlug}
projectName={project.name}
showLabel={false}
size="small"
/>
Offline States
| State | UI | Action |
|---|---|---|
not_downloaded |
"Download for offline" | Click to start |
downloading |
"Downloading 45%" | Progress indicator |
downloaded |
"Available offline" | Checkmark icon |
error |
"Retry download" | Click to retry |
outdated |
"Update available" | Click to update |
useOfflineMode Hook
const {
isOfflineCapable,
isDownloaded,
isDownloading,
status,
progress,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
deleteOfflineData,
estimatedSize,
} = useOfflineMode({
projectId,
projectSlug,
projectName,
});
Fullscreen Mode
Toggle Fullscreen
const [isFullscreen, setIsFullscreen] = useState(false);
const toggleFullscreen = useCallback(async () => {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen();
setIsFullscreen(true);
} else {
await document.exitFullscreen();
setIsFullscreen(false);
}
}, []);
// Listen for ESC key exit
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(Boolean(document.fullscreenElement));
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
Fullscreen Button
<button
onClick={toggleFullscreen}
className="absolute top-4 right-16 z-40"
>
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</button>
Head Meta Tags
The component renders SEO and social sharing meta tags in the <Head> component:
<Head>
<title>{project?.name || 'Presentation'}</title>
{faviconUrl && <link key="favicon" rel="icon" href={faviconUrl} />}
{ogImageUrl && (
<>
<meta key="og:image" property="og:image" content={ogImageUrl} />
<meta key="twitter:image:src" property="twitter:image:src" content={ogImageUrl} />
</>
)}
{project?.name && (
<>
<meta key="og:title" property="og:title" content={project.name} />
<meta key="twitter:title" property="twitter:title" content={project.name} />
</>
)}
{project?.description && (
<>
<meta key="og:description" property="og:description" content={project.description} />
<meta key="twitter:description" property="twitter:description" content={project.description} />
</>
)}
</Head>
Meta tags rendered:
- favicon - Project favicon via presigned URL from
useProjectAssets - og:image / twitter:image:src - Open Graph image for social sharing
- og:title / twitter:title - Project name for social sharing
- og:description / twitter:description - Project description for social sharing
Gallery Carousel Overlay
When users click on gallery cards, a fullscreen carousel overlay opens for navigating through images.
State Management
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: any;
initialIndex: number;
} | null>(null);
Gallery Card Click Handler
const handleGalleryCardClick = useCallback(
(element: any, cardIndex: number) => {
if (element.galleryCards?.length > 0) {
setActiveGalleryCarousel({ element, initialIndex: cardIndex });
}
},
[],
);
GalleryCarouselOverlay Component
The overlay provides:
- Fullscreen image carousel with swipe navigation
- Customizable prev/next/back button icons and positions
- Percentage-based button positioning (like canvas elements)
- Draggable buttons in constructor edit mode
- No runtime chrome controls while open: download, fullscreen, and global mute buttons are hidden. Gallery videos still read the global muted state.
Info Panel image detail fullscreen is separate from GalleryCarouselOverlay.
It is portaled to document.body and uses z-index values above
RuntimeControls so detail fullscreen controls remain clickable while global
presentation controls stay behind the image view.
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl}
backLabel={activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
isEditMode={false}
/>
)}
Mobile/Touch Support
Touch-Friendly Elements
- Large touch targets for navigation buttons
playsInlineattribute on videos for iOS- Responsive sizing with
w-full h-full object-cover - Standard click handlers work for touch events
Video Autoplay Policies
<video
autoPlay
muted // Required for autoplay on mobile
playsInline // Required for inline playback on iOS
loop
/>
Comparison: Runtime vs Constructor
| Aspect | Runtime | Constructor |
|---|---|---|
| Purpose | View presentations | Edit content |
| Access | Public or authenticated | Authenticated only |
| Environment | stage or production |
dev only |
| Full-screen | Yes | No |
| Transitions | Play forward/reverse | Preview only |
| Offline Support | Full caching | Not supported |
| Preloading | Neighbor graph (depth 1) | Same (depth 1) |
| Element Interaction | Click-to-navigate | Drag-to-edit |
| Background Video | Looping auto-play | Static display |
| UI Schema Source | ui_schema_json |
ui_schema_json + live edits |
Key Files Reference
Hooks
| File | Purpose |
|---|---|
hooks/usePageDataLoader.ts |
Project and page data loading (shared with constructor) |
hooks/useProjectAssets.ts |
Resolves project assets (favicon, og_image, logo) to presigned URLs |
hooks/usePreloadOrchestrator.ts |
Asset preload with blob URL caching (S3 direct → Cache API → blob URLs) |
hooks/usePageSwitch.ts |
Smooth page transitions with preload integration |
hooks/useTransitionPlayback.ts |
Transition video playback (forward + reverse) |
hooks/useBackgroundTransition.ts |
Background fade-out animation coordination |
hooks/useBackgroundVideoPlayback.ts |
Background video time control (start/end time) |
hooks/useReversePlayback.ts |
Low-level reverse video playback |
hooks/useNeighborGraph.ts |
Navigation graph building from pageLinks |
hooks/useOfflineMode.ts |
Offline download management |
Components
| File | Purpose |
|---|---|
components/RuntimePresentation.tsx |
Main presentation component |
components/RuntimeElement.tsx |
Element wrapper with positioning and effects |
components/UiElements/UiElementRenderer.tsx |
Unified element rendering (WYSIWYG consistency) |
components/UiElements/GalleryCarouselOverlay.tsx |
Fullscreen gallery carousel overlay |
components/UiElements/shared/useElementWrapperStyle.ts |
Shared styling hook for consistent wrapper styling |
components/UiElements/elements/*.tsx |
Per-type element components (NavigationElement, GalleryElement, etc.) |
components/Offline/OfflineToggle.tsx |
Offline toggle button |
Pages
| File | Purpose |
|---|---|
pages/runtime.tsx |
Admin runtime viewer |
pages/p/[projectSlug]/index.tsx |
Production presentation |
pages/p/[projectSlug]/stage.tsx |
Stage presentation |
Libraries & Helpers
| File | Purpose |
|---|---|
lib/extractPageLinks.ts |
Extract pageLinks and preloadElements from ui_schema_json |
lib/navigationHelpers.ts |
Navigation utilities: resolveNavigationTarget, isTransitionBlocking |
lib/assetUrl.ts |
Asset URL resolution (resolveAssetPlaybackUrl) |
lib/logger.ts |
Structured logging |
Configuration & Types
| File | Purpose |
|---|---|
config/preload.config.ts |
Preload settings: priority weights, maxDepth, asset field names |
types/runtime.ts |
Runtime type definitions |
types/preload.ts |
Preload type definitions (PreloadPageLink, PreloadElement, etc.) |
types/presentation.ts |
TransitionPhase type |
Backend Files
| File | Purpose |
|---|---|
routes/runtime-context.ts |
Context detection endpoint |
middlewares/runtime-context.ts |
Context detection middleware |
middlewares/runtime-public.ts |
Public field filtering |
Configuration
Preload Configuration
// config/preload.config.ts
{
maxConcurrentDownloads: 3,
neighborGraph: {
maxDepth: 1, // Only preload immediate neighbors
},
priority: {
currentPage: 1000,
neighborBase: 500,
assetType: {
transition: 150, // Highest - needed immediately on navigation
image: 100, // Backgrounds load during transition playback
audio: 50,
video: 30,
},
},
assetFields: {
// Asset URL fields extracted from ui_schema_json
all: ['iconUrl', 'imageUrl', 'mediaUrl', 'transitionVideoUrl', ...],
nested: ['galleryCards', 'carouselSlides'],
},
}
Offline Configuration
// config/offline.config.ts
{
cacheNames: {
assets: 'tour-builder-assets-v1',
},
storage: {
indexedDbMinSize: 5 * 1024 * 1024, // 5MB
warningPercent: 80,
criticalPercent: 95,
},
}
Performance Optimizations
- Storage Key Mapping - Assets cached by canonical storage path (e.g.,
assets/vid.mp4), enabling cache hits regardless of presigned URL signature changes - Instant Blob URL Lookup -
getReadyBlobUrl(storageKey)provides O(1) lookup for pre-decoded blob URLs ready to display - Image Pre-Decode During Playback - Decode images DURING transition video playback (not after) to eliminate black flash
- Double RAF Paint Sync - Two
requestAnimationFramecalls ensure new page is painted before overlay removal - Priority Preloading - Current page assets load first, then immediate neighbors (depth 1)
- S3 Direct Download - Assets downloaded directly from S3 presigned URLs, cached in browser Cache API
- Concurrent Downloads - 3 parallel downloads maximize throughput
- Network-Aware - Adaptive concurrency based on connection speed
- Hybrid Storage - Cache API for small files (<5MB), IndexedDB for large videos (≥5MB)
- Cached Video Seeking - Blob URLs from cache enable smooth reverse playback
- Neighbor Graph - BFS traversal finds reachable pages (1 hop to reduce requests)
- Instant Video Overlay - Container hidden while buffering, instant removal when bg ready (no fade)
- Smooth Crossfade - 700ms CSS animation with Material Design easing for non-video navigation
- usePageSwitch - Keeps previous background visible until new one is painted
- Post-Refresh Cache - Assets stored in Cache API under storage key survive page refresh
Troubleshooting
Page Not Loading
- Check runtime context detection
- Verify project slug matches
- Check X-Runtime-* headers sent
- Verify environment filtering
Transitions Not Playing
- Check transition video URL valid
- Verify supports_reverse for back navigation
- Check useTransitionPlayback errors in console
- Verify video preloaded via preloadOrchestrator
- Check isBuffering state in overlay rendering
Offline Mode Issues
- Check Service Worker registered
- Verify storage quota available
- Check IndexedDB for cached assets
- Test with DevTools offline mode
Black Flash on Navigation (Fixed)
Problem: Black flashes appeared during page transitions at three points:
- At transition START - Video not ready when overlay appears
- At transition END - Background not ready when overlay removed
- On direct navigation - No transition video to cover the switch
Root Causes:
isBufferingonly tracked reverse playback, not initial video loading- CSS
background-imagevsimg.decode()mismatch - decode worked on Image elements but background used CSS - Overlay removed before new page fully painted
Solution - 5-Phase Implementation:
| Phase | Fix | Implementation |
|---|---|---|
| 1 | Use phase for overlay opacity |
opacity: transitionPhase === 'preparing' || isBuffering ? 0 : 1 |
| 2 | Add Image element for background | Next.js <Image> with onLoad={() => setIsBackgroundReady(true)} |
| 3 | Keep transition frame until ready | pendingTransitionComplete && isBackgroundReady guards overlay removal |
| 4 | Fix direct navigation | setIsBackgroundReady(false) before page change |
| 5 | Video cleanup | video.removeAttribute('src'); video.load() prevents memory leaks |
Timing Sequence:
Navigation WITH transition:
1. User clicks → setTransitionPreview() → overlay renders (opacity: 0)
2. Video loads → phase: 'preparing' → 'playing' → overlay fades in
3. Video ends → onComplete fires → waitForPageImages() → page switches
4. Image onLoad → isBackgroundReady: true
5. Effect triggers → RAF × 2 → overlay removed
Navigation WITHOUT transition:
1. User clicks → waitForPageImages() → setIsBackgroundReady(false)
2. Page switches → Image onLoad → isBackgroundReady: true
3. New page visible immediately (no overlay needed)
Key Files:
RuntimePresentation.tsx:132- Phase destructuring from useTransitionPlaybackRuntimePresentation.tsx:179-190- useBackgroundTransition hook for fade-out effectsRuntimePresentation.tsx:444-483- Background Image element with onLoad