942 lines
39 KiB
Markdown
942 lines
39 KiB
Markdown
# 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:
|
|
|
|
```typescript
|
|
// 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):
|
|
|
|
```sql
|
|
-- 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:**
|
|
|
|
```bash
|
|
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](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:
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```typescript
|
|
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:**
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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](./project-transition-settings.md) for full documentation.
|
|
|
|
### CSS Transition Implementation
|
|
|
|
**CSS Variables (main.css) - Single Source of Truth:**
|
|
```css
|
|
: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):**
|
|
```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
|
|
|
|
```typescript
|
|
videoRef.current.onerror = (e) => {
|
|
console.error('Transition video failed to load:', e);
|
|
// Fallback: complete navigation without video
|
|
finishOverlayTransition();
|
|
};
|
|
```
|
|
|
|
### Missing Reversed Video
|
|
|
|
```typescript
|
|
// In useTransitionPreview
|
|
if (direction === 'back' && !element.reverseVideoUrl) {
|
|
onError?.('Reversed video not available. Save the page to generate it.');
|
|
return;
|
|
}
|
|
```
|
|
|
|
### FFmpeg Unavailable
|
|
|
|
```typescript
|
|
// In videoProcessing.ts
|
|
if (!isFFmpegAvailable()) {
|
|
logger.warn('FFmpeg not available, skipping reverse video generation');
|
|
return null;
|
|
}
|
|
```
|
|
|
|
## Environment Handling
|
|
|
|
Transitions are environment-aware:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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:
|
|
```typescript
|
|
console.log('[useTransitionSettings] element:', elementSettings);
|
|
```
|
|
If element shows as `null` when it shouldn't, the flushSync fix is missing.
|