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:
- User navigates forward from Page A to Page B (forward transition plays)
- User clicks back button on Page B
- System finds the forward navigation element that brought the user from Page A
- The reversed video from that forward element plays
- 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
- Simpler data model - No separate transitions table to manage
- No ID remapping - Videos are referenced by URL, not foreign key
- Per-element transitions - Different navigation buttons can have different transitions
- Easy publishing - Transitions copy with the page, no extra steps needed
- 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:
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.- FFmpeg reversal uses
-threads 1to reduce CPU and memory pressure. - FFmpeg reversal has a hard timeout (
FFMPEG_REVERSE_TIMEOUT_MS, default600000, exposed asconfig.resilience.ffmpeg.reverseTimeoutMs) and kills the child process when the timeout is reached. - FFmpeg reversal is protected by an in-process circuit breaker
(
FFMPEG_BREAKER_FAILURE_THRESHOLD,FFMPEG_BREAKER_COOLDOWN_MS,FFMPEG_BREAKER_SUCCESS_THRESHOLD, exposed underconfig.resilience.ffmpeg.breaker) so repeated failures stop new reversal jobs during the cooldown window. - FFprobe metadata extraction has a timeout (
FFPROBE_TIMEOUT_MS, default30000, exposed asconfig.resilience.ffmpeg.ffprobeTimeoutMs), and reverse-video logs include input/output byte sizes plus probed media metadata. - External file storage calls used by reversal download/upload paths are protected by the shared file-storage circuit breaker for S3/GCloud providers.
TourPagesServicestill deduplicates generation by transition videostorageKey, so repeated requests for the same source video share the same generation promise.- Before enqueueing auto-reverse generation,
TourPagesServicevalidates the source asset in one place. It rejects transition videos larger than16 GiBunless a reversed variant already exists, and it also rejects videos whose storedwidth_px,height_px,duration_sec, andframe_rateimply too much decoded frame data for the VM. Assetframe_rateis now probed on the backend with bundledffprobe-staticduring asset create/update. For older assets without persistedframe_rate, the validation path probes the stored file on demand and only falls back to a conservative30 FPSestimate if probing fails. - 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:
- Reject or downscale very large transition videos before reversal.
- 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 generationgetOrGenerateReversedVariant()- Check/generate reversed variantgenerateReversedVariant()- Download → Reverse → Upload → RecordregenerateProjectReversedVideos()- 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 BufferuploadBuffer(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:
- Select element type (
navigation_nextornavigation_prev) - Choose target page by slug
- Select transition video from project assets
- Duration is auto-detected from video metadata
- Choose reverse mode for back navigation
- 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:
storageKeyandreverseStorageKeyused 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
- Check
transitionVideoUrlis valid - Verify video is in supported format
- Check browser console for CORS errors
- Ensure video is preloaded (check Network tab)
Reverse Video Not Available
- Save the page - reverse videos are generated on page save
- Check server logs for FFmpeg errors
- Verify bundled FFmpeg paths resolve from the backend process
- Check
asset_variantstable forvariant_type='reversed'records - On the VM, check kernel logs for OOM-killed
ffmpegprocesses
Back Navigation Has No Transition
- Verify
transitionReverseModeis set ('auto_reverse'or'separate_video') - For
'auto_reverse': save the page to trigger generation - For
'separate_video': ensurereverseVideoUrlis set - Check
hasPlayableTransition(element, 'back')returns true
Navigation Gets Stuck
- Check fallback timeout is firing
- Verify
onEndedevent triggers - Look for JavaScript errors
- 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:
setCurrentElementTransitionSettings()schedules state updateswitchToPage()starts transition immediatelyuseTransitionSettingsresolves 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.tsxfrontend/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.