# Asset Upload & Variants Complete documentation for the Tour Builder Platform's asset management system including chunked uploads, storage providers, and variant tracking. For persisted media metadata, the backend is the source of truth for video FPS. Frontend upload probing is still used for immediate UX, but the asset create/update path now probes the stored file with bundled `ffprobe-static` and saves actual `frame_rate` metadata on the asset record. ## Overview The platform implements a **robust asset management system** with: - **Chunked uploads** - Large files uploaded in 5MB chunks with resumability - **Multi-provider storage** - S3, Google Cloud Storage, or local filesystem - **Asset variants** - Metadata tracking for different file formats/sizes - **Media probing** - Automatic extraction of duration, dimensions ## Architecture ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Upload Flow │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ Frontend │ │ Backend │ │ Storage │ │ │ │ Uploader │───▶│ Service │───▶│ Provider │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ │ │ │ │ │ Chunks (5MB) │ Session Mgmt │ S3/GCloud/Local │ │ ▼ ▼ ▼ │ │ UploadService.js file.js service AWS SDK / GCloud SDK │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ Database Structure │ │ │ │ assets ─────────────────────────────────────────┐ │ │ │ │ │ │ ▼ ▼ │ │ asset_variants projects │ │ (variant_type, cdn_url, dimensions) (owner) │ └─────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ Download Flow (S3 with Presigned URLs) │ │ │ │ ┌─────────────┐ 1. Get presigned URLs ┌─────────────┐ │ │ │ Frontend │────────────────────────▶│ Backend │ │ │ │ Preloader │◀────────────────────────│ /presign │ │ │ └─────────────┘ 2. Return signed URLs └─────────────┘ │ │ │ │ │ │ 3. Direct download (fast) │ │ ▼ │ │ ┌─────────────┐ │ │ │ S3 │ No backend proxy - assets served directly from S3 │ │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────────┘ ``` ## Chunked Upload System ### Overview Large files are uploaded in chunks to handle network interruptions and support resumable uploads. **Key Parameters:** | Parameter | Value | Description | |-----------|-------|-------------| | Chunk Size | 5 MB | `5 * 1024 * 1024` bytes | | Max Retries | 3 | Per-chunk retry limit | | Retry Backoff | 500ms × retry | Exponential backoff | | Session TTL | 24 hours | Expired sessions auto-cleaned | ### Upload Session Flow ``` ┌─────────────────────────────────────────────────────────────────────────┐ │ Phase 1: Initialize Session │ ├─────────────────────────────────────────────────────────────────────────┤ │ POST /file/upload-sessions/init │ │ Body: { folder, filename, totalChunks, size, contentType } │ │ Response: { sessionId, uploadedChunks: [], totalChunks } │ │ │ │ - Creates UUID session │ │ - Stores metadata (local: JSON file, S3: S3 object) │ │ - Triggers cleanup of expired sessions │ └─────────────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────────────┐ │ Phase 2: Upload Chunks │ ├─────────────────────────────────────────────────────────────────────────┤ │ PUT /file/upload-sessions/{sessionId}/chunks/{chunkIndex} │ │ Headers: Content-Type: application/octet-stream │ │ Body: │ │ Response: { sessionId, chunkIndex, uploadedChunks, totalChunks } │ │ │ │ For each chunk (0 to totalChunks-1): │ │ - Slice file: start = chunkIndex * 5MB, end = min(size, start+5MB) │ │ - Send raw binary data │ │ - Retry up to 3 times with exponential backoff │ │ - Track progress via onProgress callback │ └─────────────────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────────────────┐ │ Phase 3: Finalize Upload │ ├─────────────────────────────────────────────────────────────────────────┤ │ POST /file/upload-sessions/{sessionId}/finalize │ │ Response: { message, privateUrl, url } │ │ │ │ - Verify all chunks exist │ │ - Assemble chunks into final file │ │ - Validate assembled size matches declared size │ │ - Upload to final storage location │ │ - Cleanup session directory/objects │ │ - Return public and private URLs │ └─────────────────────────────────────────────────────────────────────────┘ ``` ### Session Status Check (Resumability) ```http GET /file/upload-sessions/{sessionId} ``` **Response:** ```json { "sessionId": "uuid", "totalChunks": 10, "uploadedChunks": [0, 1, 2, 3], "status": "active" } ``` Use this to resume interrupted uploads by checking which chunks are already uploaded. ### Session Metadata Structure ```javascript { id: "uuid", userId: "user-uuid", // Owner (enforced on all operations) folder: "assets/project-id", filename: "uuid.mp4", totalChunks: 10, size: 52428800, // 50MB contentType: "video/mp4", uploadedChunks: [0, 1, 2], // Completed chunk indices status: "active", createdAt: "2025-01-01T00:00:00Z", updatedAt: "2025-01-01T00:05:00Z" } ``` ### Session Storage Locations Session management is handled by `UploadSessionManager` class (`backend/src/services/file/UploadSessionManager.ts`). **Local Storage:** ``` {uploadDir}/upload_sessions/{sessionId}/ ├── meta.json # Session metadata └── chunks/ ├── 0.part # Chunk 0 ├── 1.part # Chunk 1 └── ... ``` **S3 Storage:** ``` {prefix}/_upload_sessions/{sessionId}/ ├── meta.json # Session metadata └── chunks/ ├── 0.part # Chunk 0 ├── 1.part # Chunk 1 └── ... ``` ### Chunk Assembly During finalization, chunks are assembled in a temp directory: ```javascript // Temp assembly location const tempDir = path.join(config.uploadDir, '_temp_assembly'); const assembledPath = path.join(tempDir, `${sessionId}.bin`); // For each chunk, append to assembled file for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { await streamAppendFile(assembledPath, chunkPath); } // Validate size if (assembledStats.size !== session.size) { throw new Error('Assembled file size mismatch'); } ``` ## Storage Providers ### Modular File Service Architecture The file service uses a **Strategy Pattern** with modular providers: ``` backend/src/services/ ├── file.js # Unified interface (provider initialization, routing) └── file/ ├── index.js # Module exports ├── BaseStorageProvider.ts # Abstract base class ├── S3StorageProvider.ts # AWS S3 implementation ├── LocalStorageProvider.ts # Local filesystem implementation └── UploadSessionManager.ts # Chunked upload session management ``` ### External Storage Circuit Breaker The unified file service protects S3/GCloud operations used by media processing with an in-process circuit breaker. Local filesystem storage is not breaker limited, which keeps local development and simple VM filesystem access direct. The values are validated in `backend/src/utils/env-validation.ts` and exposed through `config.resilience.fileStorage.breaker`; service code does not read these environment variables directly. Protected operations: - `downloadToBuffer` - `downloadToTempFile` - `uploadBuffer` - `deleteFile` Configuration overrides: | Variable | Default | Description | |----------|---------|-------------| | `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | `5` | Consecutive failures before the breaker opens | | `FILE_STORAGE_BREAKER_COOLDOWN_MS` | `30000` | Cooldown before a half-open probe is allowed | | `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | `2` | Half-open successes required to close the breaker | When the breaker is open, protected operations fail fast with a `503`-status error instead of piling more requests onto the external storage provider. ### Provider Selection Logic **File:** `backend/src/services/file.ts` ```javascript const getFileStorageProvider = () => { // 1. Explicit override from validated backend config const provider = config.fileStorage.provider; if (provider === 's3' || provider === 'gcloud' || provider === 'local') { return provider; } // 2. Auto-detect S3 from credentials const hasS3 = Boolean( config.s3.bucket && config.s3.region && config.s3.accessKeyId && config.s3.secretAccessKey ); if (hasS3) return 's3'; // 3. Auto-detect GCloud from credentials const hasGCloud = Boolean( config.gcloud.projectId && config.gcloud.clientEmail && config.gcloud.privateKey && config.gcloud.bucket && config.gcloud.hash ); if (hasGCloud) return 'gcloud'; // 4. Default to local filesystem return 'local'; }; ``` `FILE_STORAGE_PROVIDER` is trimmed/lowercased and validated in `backend/src/utils/env-validation.ts`; runtime service code reads `config.fileStorage.provider` only. ### S3 Configuration **Environment Variables:** ```bash FILE_STORAGE_PROVIDER=s3 AWS_S3_BUCKET=your-bucket-name AWS_S3_REGION=us-east-1 # Default: us-east-1 AWS_ACCESS_KEY_ID=AKIA... AWS_SECRET_ACCESS_KEY=secret... AWS_S3_PREFIX=optional-prefix # Default: afeefb9d49f5b7977577876b99532ac7 ``` **Implementation:** ```javascript const initS3 = () => { const client = new S3Client({ region: config.s3.region, credentials: { accessKeyId: config.s3.accessKeyId, secretAccessKey: config.s3.secretAccessKey, }, }); return { client, bucket: config.s3.bucket, region: config.s3.region, prefix: config.s3.prefix, }; }; ``` **URL Format:** ``` https://{bucket}.s3.{region}.amazonaws.com/{prefix}/{folder}/{filename} ``` **SDK:** AWS SDK v3 (`@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner`). The TypeScript provider uses official AWS SDK command/config/exception types and shared project storage contracts from `backend/src/types/file.ts`. ### Google Cloud Storage Configuration **Environment Variables:** ```bash FILE_STORAGE_PROVIDER=gcloud GC_PROJECT_ID=your-project-id GC_CLIENT_EMAIL=service-account@project.iam.gserviceaccount.com GC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n..." ``` **Config (config.ts):** Currently hardcoded values: ```javascript gcloud: { bucket: "fldemo-files", hash: "afeefb9d49f5b7977577876b99532ac7" } ``` **Note:** Unlike S3, GCloud bucket and hash are hardcoded in `config.ts`. To use different values, modify the config file directly. **URL Format:** ``` https://storage.googleapis.com/{bucket}/{hash}/{folder}/{filename} ``` **SDK:** `@google-cloud/storage` ### Local Storage Configuration **Default behavior** when no cloud credentials are configured. **Upload Directory:** `os.tmpdir()` (OS temp directory) **URL Format:** ``` /api/file/download?privateUrl={encodedPath} ``` Files are served via the backend download endpoint. Note: The download endpoint does **not** require authentication. ## Asset Database Models ### Assets Model **File:** `backend/src/db/models/assets.js` | Field | Type | Required | Description | |-------|------|----------|-------------| | id | UUID | Yes | Primary key | | name | TEXT(255) | No | Display name | | asset_type | ENUM | Yes | `image`, `video`, `audio`, `file` | | type | ENUM | Yes | `icon`, `background_image`, `audio`, `video`, `transition`, `logo`, `favicon`, `document`, `general` (default) | | cdn_url | TEXT | No | Public CDN/storage URL | | storage_key | TEXT | No | Private storage path | | mime_type | TEXT | No | MIME type (validated format) | | size_mb | DECIMAL | No | File size in MB | | width_px | INTEGER | No | Image/video width | | height_px | INTEGER | No | Image/video height | | duration_sec | DECIMAL | No | Video/audio duration | | frame_rate | DECIMAL | No | Video FPS from backend `ffprobe` | | checksum | TEXT | No | File checksum | | is_public | BOOLEAN | Yes | Public visibility (default: false) | | projectId | UUID | Yes | FK to projects (CASCADE) | **Indexes:** `projectId`, `asset_type`, `type`, `is_public`, `deletedAt` **Note on Environment Scoping:** Assets are scoped to projects, NOT environments. Unlike `tour_pages` which have `dev`, `stage`, and `production` versions, assets are shared across all environments within a project. When pages are published (dev → stage → production), the same asset URLs are used - only the page content (`ui_schema_json`) is copied between environments. ### Asset Variants Model **File:** `backend/src/db/models/asset_variants.js` | Field | Type | Required | Description | |-------|------|----------|-------------| | id | UUID | Yes | Primary key | | variant_type | ENUM | No | `thumbnail`, `preview`, `webp`, `mp4_low`, `mp4_high`, `original` | | cdn_url | TEXT(2048) | No | Variant CDN URL (validated URL format) | | width_px | INTEGER | No | Variant width | | height_px | INTEGER | No | Variant height | | size_mb | DECIMAL | No | Variant file size | | assetId | UUID | Yes | FK to assets (CASCADE) | **Variant Types:** | Type | Description | Use Case | |------|-------------|----------| | `thumbnail` | Small preview image | List views, galleries | | `preview` | Medium quality preview | Quick previews | | `webp` | WebP format | Web optimization | | `mp4_low` | Low bitrate video | Mobile, slow connections | | `mp4_high` | High bitrate video | Desktop, fast connections | | `original` | Unmodified original | Full quality access | | `reversed` | FFmpeg-reversed video | Back navigation transitions | **Note:** Most variants are **metadata records only** - the system tracks variant information but does not auto-generate them. The exception is `reversed` variants, which are auto-generated by the server when a page with transition videos is saved. **Critical:** The `assetId` field must be properly set when creating variant records. Without it, the variant becomes an orphaned record that cannot be found when looking up an asset's variants via the `asset_variants_asset` association. This is handled in `Asset_variantsDBApi.getFieldMapping()` which must include `assetId` in the field mapping. ### Reversed Video Variant Reversed videos are a special variant type generated server-side for back navigation transitions. They use a different storage path pattern than other assets. **Storage Patterns:** | Asset Type | Storage Path Pattern | Example | |------------|---------------------|---------| | Primary assets | `assets/{projectId}/{uuid}.ext` | `assets/abc-123/def-456.mp4` | | Reversed videos | `assets/{assetId}/reversed.mp4` | `assets/xyz-789/reversed.mp4` | **Key Difference:** Reversed videos use the **asset ID** (not project ID) in their path. This is because they are tied to a specific asset (the transition video), not just the project. **Generation Flow:** 1. User adds transition video to navigation element 2. User saves the tour page 3. Server detects `transitionVideoUrl` in navigation elements 4. Server generates reversed video using FFmpeg 5. Reversed video uploaded to `assets/{assetId}/reversed.mp4` 6. `reverseVideoUrl` populated in `ui_schema_json` **Note:** Not all assets have reversed videos - only transition videos used in navigation elements. ## Frontend Upload Implementation ### useAssetUploader Hook **File:** `frontend/src/components/Assets/useAssetUploader.ts` ```typescript interface UseAssetUploaderOptions { selectedProjectId: string; onUploadComplete: () => void; } interface UseAssetUploaderReturn { uploadingSections: string[]; uploadQueues: Record; runBatchUpload: (section: AssetSection, files: File[]) => Promise; } ``` **Key Features:** - **Batch upload** with queue management - **2 concurrent uploads** maximum (`maxConcurrent = 2`) - **Per-file progress** tracking - **Abortable** via AbortController - **Media probing** for video/audio duration and dimensions **Status States:** | Status | Description | |--------|-------------| | `queued` | File in queue, waiting to upload | | `uploading` | Actively uploading chunks | | `saving` | Finalizing upload and creating asset record | | `success` | Upload and save completed | | `error` | Upload failed (error message available) | ### UploadService Class **File:** `frontend/src/components/Uploaders/UploadService.js` **Single File Upload:** ```javascript const result = await FileUploader.upload(path, file, schema); // Returns: { id, name, sizeInBytes, privateUrl, publicUrl, new: true } ``` **Chunked Upload:** ```javascript const result = await FileUploader.uploadChunked( 'assets/project-id', // path file, // File object {}, // schema (validation) { chunkSize: 5 * 1024 * 1024, // 5MB maxRetries: 3, signal: abortController.signal, onProgress: (percent, { chunkIndex, totalChunks }) => {}, onStatus: (status, details) => {}, } ); ``` ### Media Duration Probing **File:** `frontend/src/lib/mediaDuration.ts` After upload, video/audio files are probed for metadata: ```typescript interface MediaDurationResult { duration: number; width?: number; // Video only height?: number; // Video only } // Probe video const result = await probeMediaDuration(file, 'video', 10000); // Returns: { duration: 120.5, width: 1920, height: 1080 } // Probe audio const result = await probeMediaDuration(file, 'audio', 10000); // Returns: { duration: 180.0 } ``` **Implementation:** - Creates HTML5 `