39948-vm/documentation/page-transitions.md
2026-07-03 16:11:24 +02:00

39 KiB

Page Transitions Feature

Documentation for the Tour Builder Platform's page transition system using video-based animations stored directly on navigation elements.

Overview

The platform implements video-based page transitions that are configured directly on navigation elements in tour_pages.ui_schema_json. The system supports forward and server-side pre-generated reversed playback with intelligent preloading.

┌─────────────────────────────────────────────────────────────────┐
│                       Transition Architecture                     │
│                                                                   │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                    Data Layer                                │ │
│  │  tour_pages.ui_schema_json → elements[].transitionVideoUrl  │ │
│  │  asset_variants.variant_type = 'reversed'                   │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                              │                                    │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                 Server Processing Layer                      │ │
│  │  TourPagesService → videoProcessing.ts (FFmpeg reversal)    │ │
│  └─────────────────────────────────────────────────────────────┘ │
│                              │                                    │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                   Execution Layer                            │ │
│  │  Runtime Navigation │ Transition Overlay │ useTransitionPlayback│
│  └─────────────────────────────────────────────────────────────┘ │
│                              │                                    │
│  ┌─────────────────────────────────────────────────────────────┐ │
│  │                   Preloading Layer                           │ │
│  │  usePreloadOrchestrator │ usePageSwitch │ useNeighborGraph  │ │
│  │  getReadyBlobUrl │ S3 Presigned URLs │ Cache API            │ │
│  └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘

Reverse Video Architecture

Server-Side Pre-Generated Reversed Variants

The platform uses server-side pre-computation of reversed videos stored as separate asset variants. This approach was chosen over client-side frame-stepping for:

  • Instant playback - No client-side processing needed
  • Device independence - Works equally well on all devices
  • Professional quality - FFmpeg ensures perfect audio/video synchronization
  • Reliability - Pre-generated videos eliminate runtime failures

Reverse Generation Flow

Page Save Event (create/update)
    ↓
TourPagesService.update() / create()
    ↓
processReversedVideosAndUpdateSchema()
    └─ For each navigation element with transitionVideoUrl:
        ↓
    getOrGenerateReversedVariant()
        ├─ Check if reversed variant exists in asset_variants
        └─ If not:
            ↓
        generateReversedVariant()
            ├─ Download original video (downloadToBuffer)
            ├─ Reverse with FFmpeg (videoProcessing.reverseVideo)
            ├─ Upload reversed variant (uploadBuffer)
            └─ Create asset_variants record with type='reversed'
        ↓
    Update ui_schema_json with reverseVideoUrl
    ↓
Save page with updated URLs

Back Navigation Behavior

Back navigation always uses history mode - when the user clicks a back button, they return to the previous page using the original forward transition played in reverse.

How it works:

  1. User navigates forward from Page A to Page B (forward transition plays)
  2. User clicks back button on Page B
  3. System finds the forward navigation element that brought the user from Page A
  4. The reversed video from that forward element plays
  5. User returns to Page A

Benefits:

  • Simpler UX - back button works like browser back
  • No configuration needed - back navigation is automatic
  • Consistent behavior - same transition forward and back

Data Model

Transition Configuration in Elements

Transition settings are stored directly on navigation elements:

// In tour_pages.ui_schema_json.elements
{
  id: "nav-button-1",
  type: "navigation_next",
  label: "Next Page",

  // Navigation target
  targetPageSlug: "gallery-page",

  // Transition configuration
  transitionVideoUrl: "assets/project-xyz/transitions/zoom-in.mp4",
  transitionDurationSec: 2.5,
  transitionReverseMode: "auto_reverse",  // or "separate_video"
  reverseVideoUrl: undefined,              // Auto-populated by server on save

  // Position & styling
  xPercent: 90,
  yPercent: 85,
  iconUrl: "assets/icons/arrow.png"
}

Key Transition Fields

Field Type Description
transitionVideoUrl string URL to transition video asset
transitionDurationSec number Duration in seconds (auto-detected from video metadata)
transitionReverseMode 'auto_reverse' | 'separate_video' How back navigation transitions should play
reverseVideoUrl string Auto-generated reversed video URL (or manually uploaded for separate_video mode)

Reverse Mode Options:

Mode Behavior
auto_reverse Server generates reversed video on page save
separate_video Uses manually uploaded reverseVideoUrl for back navigation
(omitted) No transition on back navigation

Database Schema

asset_variants table (stores reversed videos):

-- Variant types enum includes 'reversed'
ALTER TYPE enum_asset_variants_variant_type ADD VALUE 'reversed';

-- Columns for reversed variants
assetId: UUID          -- Parent asset reference
variant_type: 'reversed'
cdn_url: TEXT          -- Public URL (S3/GCloud/local)
storage_key: TEXT      -- Private storage path
size_mb: DECIMAL       -- File size

Storage path pattern: assets/${assetId}/reversed.mp4

Benefits of Inline Storage

  1. Simpler data model - No separate transitions table to manage
  2. No ID remapping - Videos are referenced by URL, not foreign key
  3. Per-element transitions - Different navigation buttons can have different transitions
  4. Easy publishing - Transitions copy with the page, no extra steps needed
  5. Automatic reversal - Server generates reversed videos on page save

Server Requirements

FFmpeg Runtime

FFmpeg is required for reversed video generation. Without it, back navigation transitions won't have pre-generated reversed videos.

The project uses bundled FFmpeg binaries through the backend npm packages ffmpeg-static and ffprobe-static. Manual OS-level installation is not required for the standard setup.

Verify bundled runtime from the backend context:

cd backend
node -e "console.log(require('ffmpeg-static')); console.log(require('ffprobe-static').path)"

VM Memory Risk

Reverse generation can be memory-heavy. On the standard VM, a June 2026 incident was caused by the kernel OOM-killing an ffmpeg child process that used about 3.3 GiB RSS on a 3.8 GiB RAM VM. Because the child process belonged to the PM2 systemd unit, PM2 stopped the frontend, backend, executor, and telemetry processes, causing Apache to return 503 Service Unavailable.

See deployment-vm.md for the VM recovery runbook.

Implemented resource controls:

  1. videoProcessing.reverseVideo() uses an in-process single-worker queue, so only one FFmpeg reversal runs at a time. Additional requests wait for the previous job to finish.
  2. FFmpeg reversal uses -threads 1 to reduce CPU and memory pressure.
  3. FFmpeg reversal has a hard timeout (FFMPEG_REVERSE_TIMEOUT_MS, default 600000, exposed as config.resilience.ffmpeg.reverseTimeoutMs) and kills the child process when the timeout is reached.
  4. FFmpeg reversal is protected by an in-process circuit breaker (FFMPEG_BREAKER_FAILURE_THRESHOLD, FFMPEG_BREAKER_COOLDOWN_MS, FFMPEG_BREAKER_SUCCESS_THRESHOLD, exposed under config.resilience.ffmpeg.breaker) so repeated failures stop new reversal jobs during the cooldown window.
  5. FFprobe metadata extraction has a timeout (FFPROBE_TIMEOUT_MS, default 30000, exposed as config.resilience.ffmpeg.ffprobeTimeoutMs), and reverse-video logs include input/output byte sizes plus probed media metadata.
  6. External file storage calls used by reversal download/upload paths are protected by the shared file-storage circuit breaker for S3/GCloud providers.
  7. TourPagesService still deduplicates generation by transition video storageKey, so repeated requests for the same source video share the same generation promise.
  8. Before enqueueing auto-reverse generation, TourPagesService validates the source asset in one place. It rejects transition videos larger than 16 GiB unless a reversed variant already exists, and it also rejects videos whose stored width_px, height_px, duration_sec, and frame_rate imply too much decoded frame data for the VM. Asset frame_rate is now probed on the backend with bundled ffprobe-static during asset create/update. For older assets without persisted frame_rate, the validation path probes the stored file on demand and only falls back to a conservative 30 FPS estimate if probing fails.
  9. The page save returns a validation error so the constructor can show an explicit user-facing notification instead of silently starting a risky background job.

Additional hardening still recommended:

  1. Reject or downscale very large transition videos before reversal.
  2. Consider running media processing in a separate worker with memory limits.

Backend Implementation

Video Processing Service

File: backend/src/services/videoProcessing.ts

FFmpeg-based video reversal:

import { isFFmpegAvailable, reverseVideo } from './videoProcessing.ts';

// Core function: reverseVideo(inputBuffer, filename) → reversedBuffer
// Processing pipeline:
//   1. Create temporary directory for FFmpeg operations
//   2. Write input buffer to temp file
//   3. Probe input media metadata with a timeout
//   4. Execute FFmpeg behind the single-worker queue and circuit breaker:
//      - -vf reverse (video reversal)
//      - -af areverse (audio reversal)
//      - -c:v libx264 (H.264 encoding)
//      - -preset fast (performance preset)
//      - -crf 23 (compression quality)
//      - -c:a aac (audio encoding)
//      - -threads 1 (resource cap)
//      - kill FFmpeg if FFMPEG_REVERSE_TIMEOUT_MS is exceeded
//   5. Probe output media metadata and log input/output sizes
//   6. Read output buffer
//   7. Clean up temporary files

Tour Pages Service Integration

File: backend/src/services/tour_pages.ts

Key functions:

  • processReversedVideosAndUpdateSchema() - Main orchestrator for reverse generation
  • getOrGenerateReversedVariant() - Check/generate reversed variant
  • generateReversedVariant() - Download → Reverse → Upload → Record
  • regenerateProjectReversedVideos() - Project-wide generation for missing reversed videos

Generation Pattern:

  • Reversed videos are always generated for all navigation elements with transitions
  • Generated on-demand when page is saved (create/update)
  • Checked before generation to avoid duplication
  • Different transition videos are processed sequentially through the global FFmpeg queue; the backend does not run multiple FFmpeg reversals in parallel
  • Background processing keeps save requests fast

File Service Integration

File: backend/src/services/file.ts

Key functions used for reverse generation:

  • downloadToBuffer(privateUrl) - Downloads file from storage provider to Buffer
  • uploadBuffer(privateUrl, buffer, options) - Uploads buffer to storage

Frontend Types

File: frontend/src/types/constructor.ts

interface CanvasElement {
  id: string;
  type: CanvasElementType;  // 'navigation_next' | 'navigation_prev' | ...
  label: string;

  // Navigation
  targetPageSlug?: string;
  /** @deprecated Use targetPageSlug instead */
  targetPageId?: string;

  // Transition
  transitionVideoUrl?: string;
  transitionDurationSec?: number;
  transitionReverseMode?: 'auto_reverse' | 'separate_video';
  reverseVideoUrl?: string;  // Auto-generated or manually uploaded

  // ... other fields
}

File: frontend/src/types/presentation.ts

// Shared presentation types for RuntimePresentation and constructor.tsx

// Canvas element with navigation properties (for click handling)
interface NavigableElement {
  id: string;
  type: string;
  targetPageSlug?: string;
  targetPageId?: string;
  transitionVideoUrl?: string;
  reverseVideoUrl?: string;
  navType?: 'forward' | 'back';
  navDisabled?: boolean;
}

// Navigation target resolved from element click
interface NavigationTarget {
  page: RuntimePage;
  pageId: string;
  transitionVideoUrl?: string;
  reverseVideoUrl?: string;
  isBack: boolean;
}

// Transition phase (exported for navigation helpers)
type TransitionPhase = 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed';

File: frontend/src/hooks/useTransitionPlayback.ts

// ReverseMode for transition playback (runtime)
type ReverseMode = 'none' | 'separate';  // 'reverse' removed - now uses pre-generated videos

interface TransitionConfig {
  videoUrl: string;
  storageKey?: string;        // Raw storage path for cache lookup
  reverseMode: ReverseMode;
  reverseVideoUrl?: string;   // Pre-generated reversed video URL
  reverseStorageKey?: string; // Storage key for reversed video
  durationSec?: number;
  targetPageId?: string;
  displayName?: string;
  isBack?: boolean;           // Track navigation direction
}

// Internal playback phases
type PlaybackPhase = 'idle' | 'preparing' | 'playing' | 'finishing' | 'completed';

Mapping between constructor and runtime modes:

Constructor (transitionReverseMode) Runtime (ReverseMode)
'auto_reverse' 'separate' (uses pre-generated reverseVideoUrl)
'separate_video' 'separate'
(omitted) 'none'

Runtime Execution

Navigation Flow

File: frontend/src/components/RuntimePresentation.tsx

┌──────────────────────────────────────────────────────────────┐
│  1. User clicks navigation element                           │
│     └── handleElementClick(element)                          │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│  2. Resolve target page from slug                            │
│     └── pages.find(p => p.slug === element.targetPageSlug)   │
└──────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌──────────────────────────────────────────────────────────────┐
│  3. Check for transition video                               │
│     └── element.transitionVideoUrl exists?                   │
└──────────────────────────────────────────────────────────────┘
                              │
          ┌───────────────────┴───────────────────┐
          │ Yes                                   │ No
          ▼                                       ▼
┌──────────────────────┐              ┌──────────────────────┐
│  4a. Set transition  │              │  4b. Direct navigate │
│  state with correct  │              │  to target page      │
│  video URL           │              │                      │
│                      │              │  setSelectedPageId() │
│  isBack?             │              └──────────────────────┘
│  ├─ true: use        │
│  │  reverseVideoUrl  │
│  └─ false: use       │
│     transitionVideoUrl│
└──────────┬───────────┘
           │
           ▼
┌──────────────────────────────────────────────────────────────┐
│  5. Render full-screen video overlay                         │
│                                                              │
│  Forward: video.play() with transitionVideoUrl              │
│  Back: video.play() with reverseVideoUrl (pre-generated)    │
└──────────────────────────────────────────────────────────────┘
           │
           ▼
┌──────────────────────────────────────────────────────────────┐
│  6. Video ends (or fallback timeout fires)                   │
│     └── finishOverlayTransition()                            │
│     └── applyPageSelection(targetPageId)                     │
│     └── Clear overlay state                                  │
└──────────────────────────────────────────────────────────────┘

Source URL Selection

File: frontend/src/hooks/useTransitionPlayback.ts

const sourceUrl = useMemo(() => {
  if (!transition) return '';
  // Use reversed video if back navigation with separate reversed video
  if (transition.isBack && transition.reverseVideoUrl) {
    return transition.reverseVideoUrl;
  }
  return transition.videoUrl;
}, [transition]);

Key Characteristics:

  • No frame-stepping computation needed
  • Simple conditional: if back navigation AND reversed URL exists → use reversed video
  • Direct playback from downloaded buffer

Transition Preview Hook

File: frontend/src/hooks/useTransitionPreview.ts

// Validation before preview
if (direction === 'back' && !element.reverseVideoUrl) {
  onError?.('Reversed video not available. Save the page to generate it.');
  return;
}

// Preview state structure
{
  videoUrl: element.transitionVideoUrl,
  reverseMode: direction === 'back' ? 'separate' : 'none',
  reverseVideoUrl: element.reverseVideoUrl,    // Pre-reversed video URL
  reverseStorageKey: element.reverseVideoUrl,  // Storage path for caching
  isBack: direction === 'back'                 // Track navigation direction
}

Overlay Rendering

File: frontend/src/components/Constructor/TransitionPreviewOverlay.tsx

The TransitionPreviewOverlay component renders transition videos within the letterboxed canvas bounds:

<TransitionPreviewOverlay
  videoRef={transitionVideoRef}
  isActive={Boolean(transitionPreview)}
  isBuffering={transitionPhase === 'preparing' || isBuffering}
  letterboxStyles={letterboxStyles}  // From useCanvasScale hook
  opacity={1}  // Always 1 - no fade-out for video transitions
/>

Component Props:

Prop Type Description
videoRef RefObject<HTMLVideoElement> Reference managed by useTransitionPlayback
isActive boolean Whether overlay is visible
isBuffering boolean Hides entire container while buffering (prevents black flash)
letterboxStyles CSSProperties Position/size from useCanvasScale
videoFit 'contain' | 'cover' Object-fit mode (default: 'contain')
opacity number Container opacity (default: 1)

Video Transition Flow (No Fades):

1. Click → isBuffering=true → container opacity=0 (old page visible)
2. Video ready → isBuffering=false → container opacity=1 (video first frame)
3. Video plays → last frame = new page background
4. onComplete → setTransitionPreview(null) → instant overlay removal

Key Design Decision:

  • Video transitions do NOT use fade effects
  • Video itself IS the transition (first frame = old page, last frame = new page)
  • Overlay removed instantly when new background is ready
  • This prevents any visual discontinuity

Navigation Helpers

File: frontend/src/lib/navigationHelpers.ts

Shared utilities for page navigation:

import {
  resolveNavigationTarget,
  isBackNavigation,
  getNavigationDirection,
  isTransitionBlocking,
  hasPlayableTransition,
  isNavigationType,
} from '../lib/navigationHelpers';
Function Purpose
resolveNavigationTarget(element, pages, context) Resolve target page from element. Back navigation always uses history mode
isBackNavigation(element) Check if element navigates backwards
getNavigationDirection(element) Get navigation direction as 'back' or 'forward'
isTransitionBlocking(transitionPhase) Check if transition is blocking navigation
hasPlayableTransition(element, direction) Check if element has playable transition
isNavigationType(elementType) Check if element type is a navigation type
resolveHistoryBackTarget(pages, currentSlug, previousPageId) Resolve back target from navigation history
findIncomingNavigationElement(pages, fromPageId, toPageId) Find forward element that links pages

Playable Transition Check:

const hasPlayableTransition = (
  element: {
    transitionVideoUrl?: string;
    transitionReverseMode?: string;
    reverseVideoUrl?: string;
  },
  direction: 'back' | 'forward' = 'forward',
): boolean => {
  if (!element.transitionVideoUrl) return false;

  // For back navigation, need reverse video (auto-generated or separate)
  if (direction === 'back' && !element.reverseVideoUrl) {
    return false;
  }

  return true;
};

Constructor Configuration

Setting Up Transitions

File: frontend/src/pages/constructor.tsx

When editing a navigation element:

  1. Select element type (navigation_next or navigation_prev)
  2. Choose target page by slug
  3. Select transition video from project assets
  4. Duration is auto-detected from video metadata
  5. Choose reverse mode for back navigation
  6. Save the page to trigger reverse video generation
// Saving navigation element with transition
const elementData = {
  type: selectedElementType,
  targetPageSlug: targetPage?.slug,
  transitionVideoUrl: selectedTransitionVideo?.cdn_url,
  transitionDurationSec: transitionDuration,
  transitionReverseMode: reverseMode,  // 'auto_reverse' | 'separate_video' | undefined
  reverseVideoUrl: reverseMode === 'separate_video' ? reverseVideo?.cdn_url : undefined,
  // Note: reverseVideoUrl is auto-populated for 'auto_reverse' mode on save
};

Reverse Mode UI Options:

UI Option transitionReverseMode reverseVideoUrl
"Auto Reverse" 'auto_reverse' Auto-generated on save
"Separate Video" 'separate_video' User-uploaded video
"No Reverse" (not set) (not set)

When to Enable Reverse

Video Type transitionReverseMode Reason
Zoom in/out 'auto_reverse' Symmetrical animation
Slide left/right 'auto_reverse' Direction can reverse
Fade in/out 'auto_reverse' Works both directions
Text animation 'separate_video' Use dedicated reverse video
One-way motion (omit) Only makes sense forward
Complex animation 'separate_video' Pre-rendered reverse looks better

Preloading Integration

Transition Video Preloading

File: frontend/src/lib/extractPageLinks.ts

Transition videos (including reversed) are extracted from navigation elements:

import { extractPageLinksAndElements } from '../lib/extractPageLinks';

// Extract navigation links (includes transition and reverse videos)
const { pageLinks, preloadElements } = extractPageLinksAndElements(filteredPages);

// pageLinks contains transition information:
// { from_pageId, to_pageId, transition: { video_url, reverse_video_url } }

Priority Calculation

Transition videos have the highest priority (+150):

Transition Priority = neighborBase + assetTypeBonus

neighborBase: 1000 (current page) or 500 (neighbor)
assetTypeBonus: 150 (transition - highest priority)

Examples:
- Current page transition: 1000 + 150 = 1150
- Neighbor (depth 1) transition: 500 + 150 = 650

Asset Type Priorities:

Type Priority Reason
Transition +150 Needed immediately on click
Image +100 Required for page display
Audio +50 Background audio
Video +30 Can stream progressively

Sophisticated Source Resolution Pipeline

File: frontend/src/hooks/useTransitionPlayback.ts

// Source resolution order:
1. Try storage key ready blob URL (pre-downloaded)
2. Try storage key cached blob URL (Cache API)
3. Reuse cached blob URL from previous playback
4. Try ready blob URL by resolved CDN URL
5. Try cached blob URL by resolved CDN URL
6. Fetch video as blob with presigned URL or proxy fallback

Storage Key Usage:

  • storageKey and reverseStorageKey used for cache lookup
  • Allows offline access via IndexedDB
  • Distinguishes between original and reversed video caches

Non-Transition Navigation (CSS Transitions)

When navigating without a video transition, the system uses CSS-based transitions with settings resolved through a cascade.

Settings Cascade Resolution

Transition settings (type, duration, easing, overlay color) are resolved through a three-level cascade:

┌─────────────────────────────────────────────────────────────────────────┐
│                    Transition Settings Cascade                           │
│                                                                          │
│  1. Element Level (highest priority)                                    │
│     └── ui_schema_json.elements[].transitionSettings                    │
│                              ↓ fallback                                  │
│  2. Project Level (per environment)                                     │
│     └── project_transition_settings WHERE projectId AND environment     │
│                              ↓ fallback                                  │
│  3. Global Level (platform-wide defaults)                               │
│     └── global_transition_defaults (single record)                      │
│                              ↓ fallback                                  │
│  4. Hardcoded Fallback                                                  │
│     └── type: 'fade', durationMs: 700, easing: 'ease-in-out'           │
└─────────────────────────────────────────────────────────────────────────┘

Hook: useTransitionSettings resolves final settings:

const transitionSettings = useTransitionSettings({
  globalDefaults,      // From global_transition_defaults table
  projectSettings,     // From project_transition_settings (environment-specific)
  elementSettings,     // From currentElementTransitionSettings state
});

// Returns: { type, durationMs, easing, overlayColor }

Extracting element settings: Use extractElementTransitionSettings() to convert element fields:

import { extractElementTransitionSettings } from '../types/transition';

// Extract from clicked navigation element
const elementSettings = extractElementTransitionSettings(clickedElement);
// Returns: { transitionType?, transitionDurationMs?, transitionEasing?, transitionOverlayColor? }

The function only includes fields with actual values (not empty strings), allowing cascade fallthrough when element uses "Use Project Default".

Environment-Aware Project Settings:

Project transition settings are stored per-environment and copied during publishing:

  • Constructor (dev): Edits project_transition_settings WHERE environment='dev'
  • Save to Stage: Copies dev settings → stage
  • Publish: Copies stage settings → production

See project-transition-settings.md for full documentation.

CSS Transition Implementation

CSS Variables (main.css) - Single Source of Truth:

:root {
  --crossfade-duration: 700ms;
  /* Smooth easing: slow start, gentle acceleration, soft landing */
  --crossfade-easing: cubic-bezier(0.4, 0, 0.2, 1);
}

CSS Animations (main.css):

@keyframes page-crossfade-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes page-crossfade-out {
  from { opacity: 1; }
  to { opacity: 0; }
}

.animate-crossfade-in {
  animation: page-crossfade-in var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
}

.animate-crossfade-out {
  animation: page-crossfade-out var(--crossfade-duration, 700ms) var(--crossfade-easing, cubic-bezier(0.4, 0, 0.2, 1)) forwards;
}

Easing Curve Characteristics:

  • cubic-bezier(0.4, 0, 0.2, 1) - Material Design standard easing
  • Slow start - prevents abrupt appearance
  • Gentle acceleration through middle
  • Soft landing at end

CSS Transition vs Video Transition

Navigation Type Effect Duration Control Settings Source
With transition video Video overlay plays, instant removal Video duration Element-level only
Without transition video CSS fade animation Cascade resolution Element → Project → Global

File References

File Purpose
backend/src/services/videoProcessing.ts FFmpeg video reversal service
backend/src/services/tour_pages.ts Reverse video generation orchestration
backend/src/services/file.ts Download/upload buffer operations
backend/src/services/project_transition_settings.ts Project transition settings service
backend/src/db/api/asset_variants.ts Reversed variant database operations
backend/src/db/api/project_transition_settings.ts Project transition settings API
backend/src/db/models/asset_variants.js Asset variants model (includes 'reversed' type)
backend/src/db/models/project_transition_settings.js Project transition settings model
backend/src/db/migrations/20260413091125-add-reversed-variant-type.js Migration for reversed variant support
backend/src/db/migrations/20260501000002-create-project-transition-settings.js Project transition settings table
frontend/src/css/main.css CSS animation keyframes and classes
frontend/src/pages/constructor.tsx Transition video selection UI
frontend/src/components/RuntimePresentation.tsx Transition overlay playback and navigation
frontend/src/components/TourFlowManager.tsx Project transition settings UI
frontend/src/components/Constructor/TransitionPreviewOverlay.tsx Canvas-aware transition video overlay
frontend/src/lib/extractPageLinks.ts Extract transition videos from navigation elements
frontend/src/lib/navigationHelpers.ts Navigation target resolution, reverse detection
frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts Redux store for transition settings
frontend/src/hooks/usePreloadOrchestrator.ts Preloading with ready blob URLs
frontend/src/hooks/usePageSwitch.ts Page navigation using preloaded transitions
frontend/src/hooks/useTransitionPlayback.ts Transition video playback coordination
frontend/src/hooks/useTransitionPreview.ts Transition preview with reverse validation
frontend/src/hooks/useTransitionSettings.ts Cascade resolution for transition settings
frontend/src/hooks/useBackgroundTransition.ts Background fade-out coordination
frontend/src/hooks/useNeighborGraph.ts Navigation graph for preload prioritization
frontend/src/types/constructor.ts Element type definitions
frontend/src/types/transition.ts Transition settings types
frontend/src/types/presentation.ts Runtime navigation types
frontend/src/config/preload.config.ts Preload priority weights

Error Handling

Video Load Failures

videoRef.current.onerror = (e) => {
  console.error('Transition video failed to load:', e);
  // Fallback: complete navigation without video
  finishOverlayTransition();
};

Missing Reversed Video

// In useTransitionPreview
if (direction === 'back' && !element.reverseVideoUrl) {
  onError?.('Reversed video not available. Save the page to generate it.');
  return;
}

FFmpeg Unavailable

// In videoProcessing.ts
if (!isFFmpegAvailable()) {
  logger.warn('FFmpeg not available, skipping reverse video generation');
  return null;
}

Environment Handling

Transitions are environment-aware:

// Pages are filtered by environment before extraction
const filteredPages = pages.filter(p => p.environment === environment);

// Transitions only work between pages in the same environment
const { pageLinks } = extractPageLinksAndElements(filteredPages);
Environment Context Access
dev Constructor editing Authenticated
stage Preview/review Authenticated
production Public runtime Public

Publishing: When pages are published (dev → stage → production), transitions and reversed videos are copied as part of ui_schema_json. Since transitions reference video URLs (not IDs), no remapping is needed.

Performance Considerations

1. Server-Side Generation Benefits

Aspect Client-Side (Old) Server-Side (New)
Computation During playback At page save time
Device Performance Device-dependent Instant playback
Audio Sync Manual filtering FFmpeg perfect sync
Memory Usage High during playback None (pre-generated)

2. Video Format

Use optimized video formats:

  • MP4 with H.264 for broad compatibility
  • WebM with VP9 for better compression
  • Keep transitions short (0.5-3 seconds)

3. Caching Strategy

Layer Purpose
Cache API (< 5MB) Fast asset storage
IndexedDB (≥ 5MB) Large assets, offline data
Blob URLs Pre-decoded for instant display

Troubleshooting

Transition Doesn't Play

  1. Check transitionVideoUrl is valid
  2. Verify video is in supported format
  3. Check browser console for CORS errors
  4. Ensure video is preloaded (check Network tab)

Reverse Video Not Available

  1. Save the page - reverse videos are generated on page save
  2. Check server logs for FFmpeg errors
  3. Verify bundled FFmpeg paths resolve from the backend process
  4. Check asset_variants table for variant_type='reversed' records
  5. On the VM, check kernel logs for OOM-killed ffmpeg processes

Back Navigation Has No Transition

  1. Verify transitionReverseMode is set ('auto_reverse' or 'separate_video')
  2. For 'auto_reverse': save the page to trigger generation
  3. For 'separate_video': ensure reverseVideoUrl is set
  4. Check hasPlayableTransition(element, 'back') returns true

Navigation Gets Stuck

  1. Check fallback timeout is firing
  2. Verify onEnded event triggers
  3. Look for JavaScript errors
  4. Check transition state clears properly

Element Settings Not Applied (Wrong Duration/Easing)

Symptom: Element-level transition settings (duration, easing, overlay color) are ignored; transition uses project or global defaults instead.

Cause: React's async state batching. When clicking a navigation button:

  1. setCurrentElementTransitionSettings() schedules state update
  2. switchToPage() starts transition immediately
  3. useTransitionSettings resolves with OLD state (before React processes step 1)

Solution: Use flushSync from react-dom to force synchronous state updates:

import { flushSync } from 'react-dom';
import { extractElementTransitionSettings } from '../types/transition';

// In handleElementClick:
const elementSettings = extractElementTransitionSettings(clickedElement);

// Force synchronous update BEFORE navigation
flushSync(() => {
  setCurrentElementTransitionSettings(elementSettings);
});

// Now transition uses correct element settings
switchToPage(targetPageId, config);

Files requiring this fix:

  • frontend/src/pages/constructor.tsx
  • frontend/src/components/RuntimePresentation.tsx

Debug tip: Add console.log inside useTransitionSettings to verify element settings are received:

console.log('[useTransitionSettings] element:', elementSettings);

If element shows as null when it shouldn't, the flushSync fix is missing.