39948-vm/frontend/docs/runtime-presentation.md
2026-07-03 16:11:24 +02:00

60 KiB
Raw Blame History

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 stage environment 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_key and legacy cdn_url formats)
  • 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-40 ensures they appear above carousel controls (z-30) for proper click handling
  • Carousel uses portals to document.body with position: fixed for full-screen display
  • Carousel wrappers have pointer-events-none with buttons having pointer-events-auto
  • Background video at z-1 keeps 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:

  1. Listens to visualViewport.resize and visualViewport.scroll events
  2. Reads visualViewport.scale to detect current pinch-zoom level
  3. 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 colors
  • ControlIcon - built-in SVG or custom asset icon renderer
  • OfflineControl - 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:

  • RuntimeElement handles positioning, rotation, and interactive effects (hover/focus/active)
  • RuntimeElement delegates content rendering to UiElementRenderer
  • UiElementRenderer delegates to per-type components (NavigationElement, GalleryElement, etc.)
  • Both Constructor (CanvasElement) and Runtime (RuntimeElement) use UiElementRenderer for 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):

  1. When pendingTransitionComplete && isBackgroundReady, effect triggers cleanup
  2. Instant overlay removal (no fade animation):
    • video.removeAttribute('src'); video.load() - cleanup video
    • setTransitionPreview(null) - removes overlay immediately
    • setPendingTransitionComplete(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:

  1. Native Reverse - video.playbackRate = -1 (Chrome, Edge)
  2. 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-fetches src on every re-render, even with unoptimized
  • 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-image provides immediate visual (from browser cache)
  • Image element triggers onLoad callback for proper state tracking
  • waitForPageImages() uses img.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

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:

  1. pageLinks - Navigation connections for the neighbor graph
  2. 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:

  1. getReadyBlobUrl() - O(1) instant lookup for pre-decoded blob URLs
  2. previousBgImageUrl - Keeps old background visible during switch
  3. markBackgroundReady() - Called when new background loads
  4. clearPreviousBackground() - 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

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);
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
  • playsInline attribute 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

  1. Storage Key Mapping - Assets cached by canonical storage path (e.g., assets/vid.mp4), enabling cache hits regardless of presigned URL signature changes
  2. Instant Blob URL Lookup - getReadyBlobUrl(storageKey) provides O(1) lookup for pre-decoded blob URLs ready to display
  3. Image Pre-Decode During Playback - Decode images DURING transition video playback (not after) to eliminate black flash
  4. Double RAF Paint Sync - Two requestAnimationFrame calls ensure new page is painted before overlay removal
  5. Priority Preloading - Current page assets load first, then immediate neighbors (depth 1)
  6. S3 Direct Download - Assets downloaded directly from S3 presigned URLs, cached in browser Cache API
  7. Concurrent Downloads - 3 parallel downloads maximize throughput
  8. Network-Aware - Adaptive concurrency based on connection speed
  9. Hybrid Storage - Cache API for small files (<5MB), IndexedDB for large videos (≥5MB)
  10. Cached Video Seeking - Blob URLs from cache enable smooth reverse playback
  11. Neighbor Graph - BFS traversal finds reachable pages (1 hop to reduce requests)
  12. Instant Video Overlay - Container hidden while buffering, instant removal when bg ready (no fade)
  13. Smooth Crossfade - 700ms CSS animation with Material Design easing for non-video navigation
  14. usePageSwitch - Keeps previous background visible until new one is painted
  15. Post-Refresh Cache - Assets stored in Cache API under storage key survive page refresh

Troubleshooting

Page Not Loading

  1. Check runtime context detection
  2. Verify project slug matches
  3. Check X-Runtime-* headers sent
  4. Verify environment filtering

Transitions Not Playing

  1. Check transition video URL valid
  2. Verify supports_reverse for back navigation
  3. Check useTransitionPlayback errors in console
  4. Verify video preloaded via preloadOrchestrator
  5. Check isBuffering state in overlay rendering

Offline Mode Issues

  1. Check Service Worker registered
  2. Verify storage quota available
  3. Check IndexedDB for cached assets
  4. Test with DevTools offline mode

Black Flash on Navigation (Fixed)

Problem: Black flashes appeared during page transitions at three points:

  1. At transition START - Video not ready when overlay appears
  2. At transition END - Background not ready when overlay removed
  3. On direct navigation - No transition video to cover the switch

Root Causes:

  • isBuffering only tracked reverse playback, not initial video loading
  • CSS background-image vs img.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 useTransitionPlayback
  • RuntimePresentation.tsx:179-190 - useBackgroundTransition hook for fade-out effects
  • RuntimePresentation.tsx:444-483 - Background Image element with onLoad