From 540b7b6aa71c17c3cb087cab01c739e36251eecd Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 13 Apr 2026 19:05:31 +0400 Subject: [PATCH] fixed cross-browser issues, changed reverse playback approach --- backend/package.json | 1 + backend/src/db/api/asset_variants.js | 1 + backend/src/db/api/base.api.js | 35 ++ ...0260413091125-add-reversed-variant-type.js | 33 ++ backend/src/db/models/asset_variants.js | 7 + backend/src/services/file.js | 78 +++ backend/src/services/tour_pages.js | 537 +++++++++++++++++- backend/src/services/videoProcessing.js | 89 +++ backend/yarn.lock | 337 +++++------ .../src/components/RuntimePresentation.tsx | 15 +- frontend/src/config/preload.config.ts | 9 - frontend/src/hooks/useReversePlayback.ts | 516 ----------------- frontend/src/hooks/useTransitionPlayback.ts | 276 +++------ frontend/src/hooks/useTransitionPreview.ts | 23 +- frontend/src/lib/navigationHelpers.ts | 19 +- frontend/src/pages/constructor.tsx | 7 +- frontend/src/types/constructor.ts | 4 + frontend/src/types/presentation.ts | 8 +- 18 files changed, 1028 insertions(+), 967 deletions(-) create mode 100644 backend/src/db/migrations/20260413091125-add-reversed-variant-type.js create mode 100644 backend/src/services/videoProcessing.js delete mode 100644 frontend/src/hooks/useReversePlayback.ts diff --git a/backend/package.json b/backend/package.json index 00b4988..ef0915c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "dotenv": "^16.4.0", "express": "4.18.2", "express-validator": "^7.0.0", + "fluent-ffmpeg": "^2.1.3", "formidable": "1.2.2", "helmet": "^8.0.0", "joi": "^17.13.0", diff --git a/backend/src/db/api/asset_variants.js b/backend/src/db/api/asset_variants.js index 5cbffdc..9159220 100644 --- a/backend/src/db/api/asset_variants.js +++ b/backend/src/db/api/asset_variants.js @@ -72,6 +72,7 @@ class Asset_variantsDBApi extends GenericDBApi { id: data.id || undefined, variant_type: data.variant_type || null, cdn_url: data.cdn_url || null, + storage_key: data.storage_key || null, width_px: data.width_px || null, height_px: data.height_px || null, size_mb: data.size_mb || null, diff --git a/backend/src/db/api/base.api.js b/backend/src/db/api/base.api.js index 5e29331..9602f28 100644 --- a/backend/src/db/api/base.api.js +++ b/backend/src/db/api/base.api.js @@ -213,6 +213,41 @@ class GenericDBApi { return record; } + /** + * Partial update - only updates fields explicitly passed in data. + * Unlike update(), this doesn't go through getFieldMapping which + * converts missing fields to null. + * + * Use this when you need to update specific fields without affecting others. + * + * @param {string} id - Record ID + * @param {Object} data - Fields to update (only these will be modified) + * @param {Object} options - Options with currentUser and transaction + */ + static async partialUpdate(id, data, options = {}) { + const currentUser = options.currentUser || { id: null }; + const transaction = options.transaction; + + const record = await this.MODEL.findByPk(id, { transaction }); + + if (!record) { + throw { status: 404, message: `${this.TABLE_NAME} not found` }; + } + + const updatePayload = { updatedById: currentUser.id }; + + // Only include fields that are explicitly in the data object + for (const [key, value] of Object.entries(data)) { + if (value !== undefined) { + updatePayload[key] = value; + } + } + + await record.update(updatePayload, { transaction }); + + return record; + } + static async deleteByIds(ids, options = {}) { const currentUser = options.currentUser || { id: null }; const transaction = options.transaction; diff --git a/backend/src/db/migrations/20260413091125-add-reversed-variant-type.js b/backend/src/db/migrations/20260413091125-add-reversed-variant-type.js new file mode 100644 index 0000000..067d5a8 --- /dev/null +++ b/backend/src/db/migrations/20260413091125-add-reversed-variant-type.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Migration: Add 'reversed' variant type to asset_variants + * + * This enables storing pre-reversed videos for back navigation transitions. + * Also adds storage_key column to track the S3/local storage path. + */ + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Add 'reversed' to the enum_asset_variants_variant_type enum + await queryInterface.sequelize.query(` + ALTER TYPE "enum_asset_variants_variant_type" + ADD VALUE IF NOT EXISTS 'reversed'; + `); + + // Add storage_key column if it doesn't exist + const tableInfo = await queryInterface.describeTable('asset_variants'); + if (!tableInfo.storage_key) { + await queryInterface.addColumn('asset_variants', 'storage_key', { + type: Sequelize.TEXT, + allowNull: true, + }); + } + }, + + async down() { + // PostgreSQL doesn't support removing enum values + // storage_key column is safe to leave (no data loss) + }, +}; diff --git a/backend/src/db/models/asset_variants.js b/backend/src/db/models/asset_variants.js index f7354f0..b665bf6 100644 --- a/backend/src/db/models/asset_variants.js +++ b/backend/src/db/models/asset_variants.js @@ -23,9 +23,16 @@ module.exports = function (sequelize, DataTypes) { 'mp4_high', 'original', + + 'reversed', ], }, + storage_key: { + type: DataTypes.TEXT, + allowNull: true, + }, + cdn_url: { type: DataTypes.TEXT, validate: { diff --git a/backend/src/services/file.js b/backend/src/services/file.js index 374c620..c50d19b 100644 --- a/backend/src/services/file.js +++ b/backend/src/services/file.js @@ -371,6 +371,78 @@ const deleteFile = async (privateUrl, options = {}) => { } }; +// ============================================================================ +// Download to Buffer (for processing) +// ============================================================================ + +/** + * Download a file to buffer (for processing) + * @param {string} privateUrl - Storage key/path + * @returns {Promise} + */ +const downloadToBuffer = async (privateUrl) => { + const provider = getFileStorageProvider(); + + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.download(privateUrl); + + // Convert stream to buffer + if (typeof result.body.transformToByteArray === 'function') { + const bytes = await result.body.transformToByteArray(); + return Buffer.from(bytes); + } + // Handle readable stream + const chunks = []; + for await (const chunk of result.body) { + chunks.push(chunk); + } + return Buffer.concat(chunks); + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const file = bucket.file(`${hash}/${privateUrl}`); + const [data] = await file.download(); + return data; + } else { + // Local provider - read directly from filesystem + return fs.readFileSync(path.join(config.uploadDir, privateUrl)); + } +}; + +/** + * Upload buffer to storage + * @param {string} privateUrl - Storage key/path + * @param {Buffer} buffer - File buffer to upload + * @param {Object} options - Upload options + * @param {string} [options.contentType] - MIME type + * @returns {Promise<{ url: string }>} + */ +const uploadBuffer = async (privateUrl, buffer, options = {}) => { + const provider = getFileStorageProvider(); + const { contentType = 'application/octet-stream' } = options; + + if (provider === 's3') { + const s3 = getS3Provider(); + const result = await s3.upload(privateUrl, buffer, { contentType }); + return { url: result.url }; + } else if (provider === 'gcloud') { + const { bucket, hash } = getGCloudBucket(); + const filePath = `${hash}/${privateUrl}`; + const blob = bucket.file(filePath); + await new Promise((resolve, reject) => { + const blobStream = blob.createWriteStream({ resumable: false }); + blobStream.on('error', reject); + blobStream.on('finish', resolve); + blobStream.end(buffer); + }); + return { url: `https://storage.googleapis.com/${bucket.name}/${blob.name}` }; + } else { + const local = getLocalProvider(); + await local.upload(privateUrl, buffer); + return { url: `/api/file/download?privateUrl=${encodeURIComponent(privateUrl)}` }; + } +}; + // ============================================================================ // Chunked Upload Session Management // ============================================================================ @@ -592,10 +664,16 @@ const generatePresignedUrls = async (urls) => { module.exports = { // Provider detection getFileStorageProvider, + getS3Provider, + getLocalProvider, + getGCloudBucket, // Unified interface uploadFile, downloadFile, deleteFile, + // Buffer operations + downloadToBuffer, + uploadBuffer, // Session-based chunked uploads initUploadSession, getUploadSession, diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index f35914e..fccffe7 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -1,6 +1,537 @@ -const Tour_pagesDBApi = require('../db/api/tour_pages'); -const { createEntityService } = require('../factories/service.factory'); +/** + * Tour Pages Service + * + * Extends the factory service with reversed video generation for back navigation transitions. + * + * Supports two back navigation modes: + * 1. target_page: Back button has its own transitionVideoUrl - reversed video generated for it + * 2. history: Back button uses forward element from previous page - reversed video generated + * for forward elements when project has any history-mode back buttons + */ -module.exports = createEntityService(Tour_pagesDBApi, { +const Tour_pagesDBApi = require('../db/api/tour_pages'); +const AssetsDBApi = require('../db/api/assets'); +const Asset_variantsDBApi = require('../db/api/asset_variants'); +const { createEntityService } = require('../factories/service.factory'); +const { downloadToBuffer, uploadBuffer } = require('./file'); +const videoProcessing = require('./videoProcessing'); +const { logger } = require('../utils/logger'); + +// Cache for project history-mode status (cleared per request cycle) +const projectHistoryModeCache = new Map(); + +// Create base service from factory +const BaseService = createEntityService(Tour_pagesDBApi, { entityName: 'tour_pages', }); + +/** + * Tour Pages Service with reversed video generation + */ +class TourPagesService extends BaseService { + /** + * Create tour page - generate reversed videos if needed + */ + static async create(data, currentUser) { + // Process reversed videos and get updated ui_schema_json + const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( + data, + currentUser, + ); + + return super.create(updatedData, currentUser); + } + + /** + * Update tour page - generate reversed videos if needed + */ + static async update(data, id, currentUser) { + // Fetch existing page to get projectId (not included in update request body) + const existingPage = await Tour_pagesDBApi.findBy({ id }); + const projectId = existingPage?.projectId || data.projectId || data.project_id; + + // Process reversed videos and get updated ui_schema_json + const updatedData = await TourPagesService.processReversedVideosAndUpdateSchema( + { ...data, projectId, id }, + currentUser, + ); + + return super.update(updatedData, id, currentUser); + } + + /** + * Check if element is a back navigation button + * @private + */ + static isBackElement(element) { + return ( + element.type === 'navigation_prev' || + (element.type?.startsWith?.('navigation') && element.navType === 'back') + ); + } + + /** + * Check if element is a forward navigation button with a target and transition + * @private + */ + static isForwardElementWithTarget(element) { + const isForward = + element.type === 'navigation_next' || + (element.type?.startsWith?.('navigation') && + element.navType !== 'back' && + element.type !== 'navigation_prev'); + // Check for target (slug or legacy ID) and transition video + const hasTarget = element.targetPageSlug || element.targetPageId; + return isForward && hasTarget && element.transitionVideoUrl; + } + + /** + * Check if any page in the project has a history-mode back button. + * Uses per-request caching to avoid repeated queries. + * + * @param {string} projectId - Project ID + * @returns {Promise} + */ + static async projectHasHistoryModeBackButton(projectId) { + if (!projectId) return false; + + // Check cache first + if (projectHistoryModeCache.has(projectId)) { + return projectHistoryModeCache.get(projectId); + } + + const { rows: pages } = await Tour_pagesDBApi.findAll( + { projectId }, + { attributes: ['id', 'ui_schema_json'] }, + ); + + logger.info( + { projectId, pageCount: pages.length }, + 'Checking project for history-mode back buttons', + ); + + for (const page of pages) { + const uiSchema = + typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json || '{}') + : page.ui_schema_json || {}; + + if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) continue; + + for (const element of uiSchema.elements) { + const isBack = TourPagesService.isBackElement(element); + + logger.debug( + { + pageId: page.id, + elementType: element.type, + navType: element.navType, + navBackMode: element.navBackMode, + isBack, + }, + 'Checking element for history mode', + ); + + if (isBack && element.navBackMode === 'history') { + logger.info( + { pageId: page.id, elementType: element.type }, + 'Found history-mode back button', + ); + projectHistoryModeCache.set(projectId, true); + return true; + } + } + } + + logger.info({ projectId }, 'No history-mode back buttons found in project'); + projectHistoryModeCache.set(projectId, false); + return false; + } + + /** + * Clear project cache (call at end of request or after project changes) + * @param {string} projectId - Project ID to clear, or undefined to clear all + */ + static clearProjectCache(projectId) { + if (projectId) { + projectHistoryModeCache.delete(projectId); + } else { + projectHistoryModeCache.clear(); + } + } + + /** + * Process reversed videos and update ui_schema_json with reversed URLs. + * Returns data with updated ui_schema_json. + * + * Handles two cases: + * 1. Back navigation elements with transitionVideoUrl - always generate reversed + * 2. Forward navigation elements when project uses history-mode back navigation + * + * @param {Object} data - Page data with ui_schema_json + * @param {Object} currentUser - Current user for permissions + * @param {Object} options - Processing options + * @param {boolean} options._forceForwardReversed - Force processing forward elements + * @param {boolean} options._skipHistoryModeCheck - Skip initial history mode check + */ + static async processReversedVideosAndUpdateSchema( + data, + currentUser, + options = {}, + ) { + let uiSchema = data.ui_schema_json; + const wasString = typeof uiSchema === 'string'; + + // Parse if string + if (wasString) { + try { + uiSchema = JSON.parse(uiSchema); + } catch { + return data; // Return original data if parsing fails + } + } + + if (!uiSchema?.elements || !Array.isArray(uiSchema.elements)) { + logger.debug({ hasElements: false }, 'No elements in ui_schema_json'); + return data; + } + + // Get project ID + const projectId = data.projectId || data.project_id || data.project; + + // Check if this page being saved has a history-mode back button + // This is used to trigger regeneration of other pages' forward elements + let thisPageHasHistoryMode = false; + + for (const element of uiSchema.elements) { + if ( + TourPagesService.isBackElement(element) && + element.navBackMode === 'history' + ) { + thisPageHasHistoryMode = true; + break; + } + } + + // Check if project already has history-mode back buttons (from other pages) + // Skip this check if we're in regeneration mode to avoid recursion + let projectHasHistoryMode = options._forceForwardReversed || false; + + if (!projectHasHistoryMode && projectId && !options._skipHistoryModeCheck) { + // Invalidate cache since we're about to save changes + TourPagesService.clearProjectCache(projectId); + projectHasHistoryMode = + await TourPagesService.projectHasHistoryModeBackButton(projectId); + } + + // Combined check: process forward elements if project uses history mode OR this page enables it + const shouldProcessForward = + projectHasHistoryMode || thisPageHasHistoryMode; + + logger.info( + { + thisPageHasHistoryMode, + projectHasHistoryMode, + shouldProcessForward, + projectId, + }, + 'History mode detection result', + ); + + let wasModified = false; + + for (const element of uiSchema.elements) { + const isBack = TourPagesService.isBackElement(element); + const isForward = TourPagesService.isForwardElementWithTarget(element); + + // Determine if this element needs reversed video + const needsReversed = isBack || (shouldProcessForward && isForward); + + logger.debug( + { + elementType: element.type, + navType: element.navType, + isBack, + isForward, + needsReversed, + hasTransitionVideo: Boolean(element.transitionVideoUrl), + targetPageSlug: element.targetPageSlug, + targetPageId: element.targetPageId, + shouldProcessForward, + }, + 'Evaluating element for reversed video', + ); + + if (!needsReversed) continue; + if (!element.transitionVideoUrl) continue; + + // Skip if already has a manually set reverseVideoUrl (separate_video mode) + if ( + element.transitionReverseMode === 'separate_video' && + element.reverseVideoUrl + ) { + continue; + } + + const storageKey = element.transitionVideoUrl; + + try { + // Get or generate reversed video URL + const reversedUrl = await TourPagesService.getOrGenerateReversedVariant( + storageKey, + currentUser, + ); + + if (reversedUrl && reversedUrl !== element.reverseVideoUrl) { + element.reverseVideoUrl = reversedUrl; + wasModified = true; + logger.info( + { + elementType: element.type, + isBack, + isForward, + storageKey, + }, + 'Added reversed video URL to element', + ); + } + } catch (err) { + logger.error( + { err, storageKey }, + 'Failed to get/generate reversed variant', + ); + // Continue without reversed video - button will be disabled in frontend + } + } + + // If this page has a history-mode back button, trigger regeneration of + // other pages' forward elements that are missing reversed videos. + // This runs every save but only processes elements without reverseVideoUrl. + if ( + thisPageHasHistoryMode && + projectId && + !options._skipHistoryModeCheck + ) { + logger.info( + { projectId }, + 'History mode back button detected - regenerating forward elements', + ); + + try { + await TourPagesService.regenerateProjectReversedVideos( + projectId, + currentUser, + data.id, + ); + } catch (err) { + logger.error( + { err, projectId }, + 'Failed to regenerate project reversed videos', + ); + } + } + + if (wasModified) { + return { + ...data, + // Return same type as input: string if input was string, object otherwise + ui_schema_json: wasString ? JSON.stringify(uiSchema) : uiSchema, + }; + } + + return data; + } + + /** + * Get existing reversed variant URL or generate one + * @param {string} storageKey - Original video storage key + * @param {Object} currentUser - Current user for permissions + * @returns {Promise} Reversed video storage key or null + */ + static async getOrGenerateReversedVariant(storageKey, currentUser) { + // Find the asset by storage key + const asset = await AssetsDBApi.findBy({ storage_key: storageKey }); + + if (!asset) { + logger.warn({ storageKey }, 'Asset not found for transition'); + return null; + } + + // Check if reversed variant already exists + const variants = asset.asset_variants_asset || []; + const reversedVariant = variants.find((v) => v.variant_type === 'reversed'); + + if (reversedVariant) { + logger.debug({ assetId: asset.id }, 'Using existing reversed variant'); + return reversedVariant.storage_key; + } + + // Generate reversed video + return TourPagesService.generateReversedVariant(asset, currentUser); + } + + /** + * Generate reversed video variant for an asset + * @returns {Promise} Reversed video storage key or null + */ + static async generateReversedVariant(asset, currentUser) { + const log = logger.child({ assetId: asset.id }); + log.info('Generating reversed video variant'); + + try { + // Check if FFmpeg is available + const ffmpegAvailable = await videoProcessing.isFFmpegAvailable(); + if (!ffmpegAvailable) { + log.error('FFmpeg is not available on this server'); + return null; + } + + // Download original video to buffer + const originalBuffer = await downloadToBuffer(asset.storage_key); + + // Generate reversed video + const reversedBuffer = await videoProcessing.reverseVideo( + originalBuffer, + asset.original_file_name || 'video.mp4', + ); + + // Upload reversed video to storage + const reversedKey = `assets/${asset.id}/reversed.mp4`; + + const result = await uploadBuffer(reversedKey, reversedBuffer, { + contentType: 'video/mp4', + }); + + // Create variant record + await Asset_variantsDBApi.create( + { + assetId: asset.id, + variant_type: 'reversed', + cdn_url: result.url, + storage_key: reversedKey, + size_mb: reversedBuffer.length / (1024 * 1024), + }, + { currentUser }, + ); + + log.info( + { reversedKey, size: reversedBuffer.length }, + 'Reversed variant created', + ); + + return reversedKey; + } catch (err) { + log.error({ err }, 'Failed to generate reversed variant'); + return null; + } + } + + /** + * Regenerate reversed videos for all forward navigation elements in project. + * Called when history-mode back navigation is first enabled in a project. + * + * @param {string} projectId - Project ID + * @param {Object} currentUser - Current user for permissions + * @param {string} excludePageId - Optional page ID to exclude (the page being saved) + */ + static async regenerateProjectReversedVideos( + projectId, + currentUser, + excludePageId, + ) { + const log = logger.child({ projectId, operation: 'regenerateReversed' }); + log.info('Starting project-wide reversed video regeneration'); + + try { + // Only process dev environment pages (constructor works with dev) + const { rows: pages } = await Tour_pagesDBApi.findAll({ + projectId, + environment: 'dev', + }); + + let pagesUpdated = 0; + let elementsProcessed = 0; + + for (const page of pages) { + // Skip the page that triggered this regeneration (it's already being processed) + if (excludePageId && page.id === excludePageId) { + continue; + } + + const uiSchema = + typeof page.ui_schema_json === 'string' + ? JSON.parse(page.ui_schema_json || '{}') + : page.ui_schema_json || {}; + + if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) { + continue; + } + + let pageModified = false; + + for (const element of uiSchema.elements) { + // Only process forward elements with transitions that don't have reversed yet + const isForward = TourPagesService.isForwardElementWithTarget(element); + + log.debug({ + pageId: page.id, + elementType: element.type, + navType: element.navType, + isForward, + hasTransitionVideo: Boolean(element.transitionVideoUrl), + hasReverseVideo: Boolean(element.reverseVideoUrl), + targetPageSlug: element.targetPageSlug, + targetPageId: element.targetPageId, + }, 'Checking element in regeneration'); + + if (!isForward) { + continue; + } + + if (!element.transitionVideoUrl) continue; + if (element.reverseVideoUrl) continue; // Already has reversed + + try { + const reversedUrl = + await TourPagesService.getOrGenerateReversedVariant( + element.transitionVideoUrl, + currentUser, + ); + + if (reversedUrl) { + element.reverseVideoUrl = reversedUrl; + pageModified = true; + elementsProcessed++; + } + } catch (err) { + log.error( + { err, pageId: page.id, elementType: element.type }, + 'Failed to generate reversed video for element', + ); + } + } + + if (pageModified) { + // Use partialUpdate to only update ui_schema_json field + // This avoids the regular update's getFieldMapping which sets other fields to null + await Tour_pagesDBApi.partialUpdate( + page.id, + { ui_schema_json: JSON.stringify(uiSchema) }, + { currentUser }, + ); + pagesUpdated++; + } + } + + log.info( + { pagesUpdated, elementsProcessed }, + 'Completed project-wide reversed video regeneration', + ); + } catch (err) { + log.error({ err }, 'Failed to regenerate project reversed videos'); + throw err; + } + } +} + +module.exports = TourPagesService; diff --git a/backend/src/services/videoProcessing.js b/backend/src/services/videoProcessing.js new file mode 100644 index 0000000..d57b390 --- /dev/null +++ b/backend/src/services/videoProcessing.js @@ -0,0 +1,89 @@ +/** + * Video Processing Service + * + * Provides video manipulation operations using FFmpeg. + * Used for generating reversed videos for back navigation transitions. + */ + +const ffmpeg = require('fluent-ffmpeg'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); +const { logger } = require('../utils/logger'); + +/** + * Reverse a video using FFmpeg + * @param {Buffer} inputBuffer - Input video buffer + * @param {string} filename - Original filename (for extension) + * @returns {Promise} Reversed video buffer + */ +async function reverseVideo(inputBuffer, filename) { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'video-reverse-')); + const ext = path.extname(filename) || '.mp4'; + const inputPath = path.join(tempDir, `input${ext}`); + const outputPath = path.join(tempDir, `reversed${ext}`); + + try { + // Write input buffer to temp file + await fs.writeFile(inputPath, inputBuffer); + + logger.info({ inputPath, outputPath }, 'Starting video reversal'); + + // Reverse video with FFmpeg + await new Promise((resolve, reject) => { + ffmpeg(inputPath) + .outputOptions([ + '-vf', 'reverse', + '-af', 'areverse', + '-c:v', 'libx264', + '-preset', 'fast', + '-crf', '23', + '-c:a', 'aac', + '-movflags', '+faststart', + ]) + .output(outputPath) + .on('start', (cmd) => logger.debug({ cmd }, 'FFmpeg command')) + .on('progress', (progress) => { + if (progress.percent) { + logger.debug({ percent: progress.percent }, 'FFmpeg progress'); + } + }) + .on('end', () => { + logger.info({ outputPath }, 'Video reversal complete'); + resolve(); + }) + .on('error', (err, stdout, stderr) => { + logger.error({ err, stdout, stderr }, 'FFmpeg error'); + reject(err); + }) + .run(); + }); + + // Read output as buffer + return await fs.readFile(outputPath); + } finally { + // Cleanup temp files + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch (cleanupErr) { + logger.warn({ err: cleanupErr, tempDir }, 'Failed to cleanup temp dir'); + } + } +} + +/** + * Check if FFmpeg is available + * @returns {Promise} + */ +async function isFFmpegAvailable() { + return new Promise((resolve) => { + ffmpeg.getAvailableFormats((err) => { + resolve(!err); + }); + }); +} + +module.exports = { + reverseVideo, + isFFmpegAvailable, +}; diff --git a/backend/yarn.lock b/backend/yarn.lock index 3da3deb..09eb297 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -77,7 +77,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-crypto/sha256-js@^5.2.0", "@aws-crypto/sha256-js@5.2.0": +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz" integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== @@ -93,7 +93,7 @@ dependencies: tslib "^2.6.2" -"@aws-crypto/util@^5.2.0", "@aws-crypto/util@5.2.0": +"@aws-crypto/util@5.2.0", "@aws-crypto/util@^5.2.0": version "5.2.0" resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz" integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== @@ -1542,13 +1542,10 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0: - version "8.15.0" - -agent-base@^7.1.0, agent-base@^7.1.2: - version "7.1.4" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" - integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== +acorn@^8.9.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== agent-base@6: version "6.0.2" @@ -1557,8 +1554,15 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== + ajv@^6.12.4: - version "6.12.6" + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -1722,6 +1726,11 @@ async-retry@^1.3.3: dependencies: retry "0.13.1" +async@^0.2.9: + version "0.2.10" + resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1" + integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" @@ -1837,14 +1846,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: - version "2.0.2" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" - integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== - dependencies: - balanced-match "^1.0.0" - -brace-expansion@^2.0.2: +brace-expansion@^2.0.1, brace-expansion@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== @@ -1960,22 +1962,7 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" -chokidar@^3.5.2: - version "3.6.0" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" - integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chokidar@^3.5.3: +chokidar@^3.5.2, chokidar@^3.5.3: version "3.6.0" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -2030,6 +2017,11 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@6.2.0: + version "6.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz" + integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== + commander@^10.0.0: version "10.0.1" resolved "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz" @@ -2040,11 +2032,6 @@ commander@^6.1.0: resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz" integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== -commander@6.2.0: - version "6.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz" - integrity sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q== - concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" @@ -2068,7 +2055,7 @@ config-chain@^1.1.13: ini "^1.3.4" proto-list "~1.2.1" -content-disposition@^0.5.3, content-disposition@0.5.4: +content-disposition@0.5.4, content-disposition@^0.5.3: version "0.5.4" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== @@ -2178,6 +2165,20 @@ dateformat@^4.6.3: resolved "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4, debug@^4, debug@^4.3.4, debug@^4.3.5: + version "4.3.5" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + debug@^3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" @@ -2185,34 +2186,13 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4, debug@^4.3.4, debug@^4.3.5, debug@4: - version "4.3.5" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz" - integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== - dependencies: - ms "2.1.2" - -debug@^4.3.1: +debug@^4.3.1, debug@^4.3.2: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" -debug@^4.3.2: - version "4.4.3" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - decamelize@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" @@ -2256,16 +2236,16 @@ denque@^1.4.1: resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz" integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== -depd@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - depd@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== +depd@^1.1.0: + version "1.1.2" + resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz" + integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== + destroy@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" @@ -2276,6 +2256,13 @@ diff@^5.2.0: resolved "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz" integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== +doctrine@3.0.0, doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + doctrine@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz" @@ -2283,13 +2270,6 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -doctrine@^3.0.0, doctrine@3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - dotenv@^16.4.0: version "16.6.1" resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz" @@ -2324,7 +2304,7 @@ eastasianwidth@^0.2.0: resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -ecdsa-sig-formatter@^1.0.11, ecdsa-sig-formatter@1.0.11: +ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== @@ -2615,7 +2595,7 @@ eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.57.0: +eslint@^8.57.0: version "8.57.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -2715,7 +2695,7 @@ express-validator@^7.0.0: lodash "^4.17.21" validator "~13.15.23" -"express@>=4.0.0 || >=5.0.0-beta", express@4.18.2: +express@4.18.2: version "4.18.2" resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== @@ -2789,7 +2769,7 @@ fast-xml-builder@^1.1.4: dependencies: path-expression-matcher "^1.1.3" -fast-xml-parser@^5.3.4, fast-xml-parser@5.5.8: +fast-xml-parser@5.5.8, fast-xml-parser@^5.3.4: version "5.5.8" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz" integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== @@ -2855,7 +2835,17 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.3" + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + +fluent-ffmpeg@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz#d6846be257777844249a4adeb320f25326d239f3" + integrity sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q== + dependencies: + async "^0.2.9" + which "^1.1.1" follow-redirects@^1.15.11: version "1.15.11" @@ -2917,7 +2907,7 @@ forwarded@0.2.0: resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== -fresh@^0.5.2, fresh@0.5.2: +fresh@0.5.2, fresh@^0.5.2: version "0.5.2" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== @@ -2937,6 +2927,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -3006,29 +3001,7 @@ get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-intrinsic@^1.2.1, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-intrinsic@^1.2.3: +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== @@ -3095,6 +3068,18 @@ glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^10.4.2: version "10.5.0" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" @@ -3130,18 +3115,6 @@ glob@^8.1.0: minimatch "^5.0.1" once "^1.3.0" -glob@7.1.6: - version "7.1.6" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - globals@^13.19.0: version "13.24.0" resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" @@ -3326,20 +3299,6 @@ https-proxy-agent@^7.0.0, https-proxy-agent@^7.0.1: agent-base "^7.1.2" debug "4" -iconv-lite@^0.6.2: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -iconv-lite@^0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" @@ -3347,6 +3306,13 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.6.2, iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -3388,7 +3354,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@^2.0.4, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -3685,16 +3651,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" -is-symbol@^1.0.4: - version "1.1.1" - resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" - integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== - dependencies: - call-bound "^1.0.2" - has-symbols "^1.1.0" - safe-regex-test "^1.1.0" - -is-symbol@^1.1.1: +is-symbol@^1.0.4, is-symbol@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== @@ -4026,16 +3983,16 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -merge-descriptors@^1.0.1: - version "1.0.3" - resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" - integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== - merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== +merge-descriptors@^1.0.1: + version "1.0.3" + resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz" + integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== + methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" @@ -4053,7 +4010,7 @@ mime-types@^2.1.12, mime-types@^2.1.35, mime-types@~2.1.24, mime-types@~2.1.34: dependencies: mime-db "1.52.0" -mime@^1.3.4, mime@1.6.0: +mime@1.6.0, mime@^1.3.4: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -4084,14 +4041,7 @@ minimatch@^5.0.1, minimatch@^5.1.6: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.1: - version "9.0.9" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" - integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== - dependencies: - brace-expansion "^2.0.2" - -minimatch@^9.0.4: +minimatch@^9.0.1, minimatch@^9.0.4: version "9.0.9" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== @@ -4141,16 +4091,11 @@ moment-timezone@^0.5.43: dependencies: moment "^2.29.4" -moment@^2.29.4, moment@2.30.1: +moment@2.30.1, moment@^2.29.4: version "2.30.1" resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== -ms@^2.1.1, ms@^2.1.3, ms@2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -4161,6 +4106,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + multer@^2.0.0: version "2.1.1" resolved "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz" @@ -4377,11 +4327,6 @@ open@^8.0.0: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-types@>=7: - version "12.1.3" - resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz" - integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== - optionator@^0.9.3: version "0.9.4" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" @@ -4456,7 +4401,7 @@ passport-microsoft@^2.0.0: dependencies: passport-oauth2 "1.8.0" -passport-oauth2@^1.1.2, passport-oauth2@1.8.0: +passport-oauth2@1.8.0, passport-oauth2@^1.1.2: version "1.8.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz" integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== @@ -4467,7 +4412,7 @@ passport-oauth2@^1.1.2, passport-oauth2@1.8.0: uid2 "0.0.x" utils-merge "1.x.x" -passport-strategy@^1.0.0, passport-strategy@1.x.x: +passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== @@ -4567,7 +4512,7 @@ pg-types@2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@^8.20.0, pg@>=8.0: +pg@^8.20.0: version "8.20.0" resolved "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz" integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA== @@ -4934,7 +4879,7 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4990,12 +4935,7 @@ semver@^6.3.1: resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.3: - version "7.7.4" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" - integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== - -semver@^7.5.4: +semver@^7.5.3, semver@^7.5.4: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== @@ -5047,7 +4987,7 @@ sequelize-pool@^7.1.0: resolved "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz" integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== -sequelize@^6.37.0, "sequelize@>= 4": +sequelize@^6.37.0: version "6.37.8" resolved "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz" integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw== @@ -5258,13 +5198,6 @@ streamsearch@^1.1.0: resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -5343,6 +5276,13 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -5688,7 +5628,7 @@ universalify@^2.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz" integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== @@ -5705,7 +5645,7 @@ util-deprecate@^1.0.1: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@^1.0.1, utils-merge@1.0.1, utils-merge@1.x.x: +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== @@ -5715,12 +5655,7 @@ uuid@^8.0.0, uuid@^8.3.0, uuid@^8.3.2: resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - -uuid@^9.0.1: +uuid@^9.0.0, uuid@^9.0.1: version "9.0.1" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== @@ -5799,18 +5734,7 @@ which-collection@^1.0.2: is-weakmap "^2.0.2" is-weakset "^2.0.3" -which-typed-array@^1.1.14: - version "1.1.15" - resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.2" - -which-typed-array@^1.1.15: +which-typed-array@^1.1.14, which-typed-array@^1.1.15: version "1.1.15" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== @@ -5834,6 +5758,13 @@ which-typed-array@^1.1.16, which-typed-array@^1.1.19: gopd "^1.2.0" has-tostringtag "^1.0.2" +which@^1.1.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + which@^2.0.1: version "2.0.2" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index e0c84a0..a613bbc 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -101,7 +101,8 @@ export default function RuntimePresentation({ targetPageId: string; videoUrl: string; storageKey: string; - isReverse: boolean; + isBack: boolean; + reverseVideoUrl?: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [isBackgroundReady, setIsBackgroundReady] = useState(true); @@ -186,10 +187,11 @@ export default function RuntimePresentation({ ? { videoUrl: transitionPreview.videoUrl, storageKey: transitionPreview.storageKey, - reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', + reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none', + reverseVideoUrl: transitionPreview.reverseVideoUrl, targetPageId: transitionPreview.targetPageId, displayName: 'Transition', - isBack: transitionPreview.isReverse, // Pass through for history management + isBack: transitionPreview.isBack, } : null, onComplete: async (targetPageId, isBack) => { @@ -332,6 +334,7 @@ export default function RuntimePresentation({ targetPageId: string, transitionVideoUrl?: string, isBack = false, + reverseVideoUrl?: string, ) => { const targetPage = pages.find((p) => p.id === targetPageId); if (!targetPage) return; @@ -347,7 +350,10 @@ export default function RuntimePresentation({ targetPageId, videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), storageKey: transitionVideoUrl, // Raw storage path for cache lookup - isReverse: isBack, + isBack, + reverseVideoUrl: reverseVideoUrl + ? resolveAssetPlaybackUrl(reverseVideoUrl) + : undefined, }); } else { // Direct navigation with crossfade effect: @@ -399,6 +405,7 @@ export default function RuntimePresentation({ navTarget.pageId, navTarget.transitionVideoUrl, navTarget.isBack, + navTarget.reverseVideoUrl, ); } }, diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts index 958452a..96f5d56 100644 --- a/frontend/src/config/preload.config.ts +++ b/frontend/src/config/preload.config.ts @@ -56,15 +56,6 @@ export const PRELOAD_CONFIG = { constructorMaxDepth: 1, // Same as maxDepth for constructor preview }, - // Reverse playback settings - reversePlayback: { - defaultFps: 25, // FPS for non-preloaded videos - preloadedFps: 45, // FPS for preloaded videos (blob URLs) - minFps: 15, // Minimum adaptive FPS - maxConsecutiveSlowFrames: 3, // Frames before reducing FPS - slowFrameThreshold: 1.3, // Multiplier of target frame time - }, - // Partial preload settings (online mode only) // Download only first N bytes of videos/audio for faster Phase 1 completion // Playback uses presigned URL directly (browser handles remaining buffering) diff --git a/frontend/src/hooks/useReversePlayback.ts b/frontend/src/hooks/useReversePlayback.ts deleted file mode 100644 index 4d7948c..0000000 --- a/frontend/src/hooks/useReversePlayback.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type RefObject, -} from 'react'; -import { logger } from '../lib/logger'; -import { PRELOAD_CONFIG } from '../config/preload.config'; - -interface UseReversePlaybackOptions { - videoRef: RefObject; - onComplete: () => void; - preloadedUrls?: Set; - videoUrl?: string; - storageKey?: string; // Raw storage path for preload detection - getCachedBlobUrl?: (url: string) => Promise; - getReadyBlobUrl?: (url: string) => string | null; // O(1) instant lookup -} - -// Note: requestVideoFrameCallback exists but only works during playback. -// For reverse frame-stepping (paused video), we use requestAnimationFrame + seeked events. - -interface UseReversePlaybackResult { - startReverse: () => Promise; - stopReverse: () => void; - isReversing: boolean; - isBuffering: boolean; - canUseNativeReverse: boolean; -} - -function checkNativeReverseSupport(): boolean { - if (typeof window === 'undefined') return false; - try { - const video = document.createElement('video'); - video.playbackRate = -1; - return video.playbackRate === -1; - } catch { - return false; - } -} - -async function startNativeReverse( - video: HTMLVideoElement, - duration: number, - onComplete: () => void, - didFinishRef: { current: boolean }, -): Promise { - return new Promise((resolve) => { - video.currentTime = duration; - video.playbackRate = -1; - - const cleanup = () => { - video.removeEventListener('timeupdate', onTimeUpdate); - video.playbackRate = 1; - }; - - const onTimeUpdate = () => { - if (didFinishRef.current) { - cleanup(); - resolve(true); - return; - } - if (video.currentTime <= 0.05) { - cleanup(); - video.pause(); - video.currentTime = 0; - onComplete(); - resolve(true); - } - }; - - video.addEventListener('timeupdate', onTimeUpdate); - - video.play().catch((err) => { - logger.error('Native reverse play failed:', err); - cleanup(); - resolve(false); - }); - }); -} - -function getBufferedEnd(video: HTMLVideoElement): number { - return video.buffered.length > 0 - ? video.buffered.end(video.buffered.length - 1) - : 0; -} - -export function useReversePlayback( - options: UseReversePlaybackOptions, -): UseReversePlaybackResult { - const [isReversing, setIsReversing] = useState(false); - const [isBuffering, setIsBuffering] = useState(false); - const animationFrameRef = useRef(null); - const didFinishRef = useRef(false); - const cleanupFnsRef = useRef void>>([]); - const metricsRef = useRef<{ - consecutiveSlowFrames: number; - currentFps: number; - lastSeekDuration: number; - }>({ - consecutiveSlowFrames: 0, - currentFps: PRELOAD_CONFIG.reversePlayback.defaultFps, - lastSeekDuration: 0, - }); - - const canUseNativeReverse = useMemo(() => checkNativeReverseSupport(), []); - - const cleanup = useCallback(() => { - didFinishRef.current = true; - if (animationFrameRef.current !== null) { - cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - cleanupFnsRef.current.forEach((fn) => fn()); - cleanupFnsRef.current = []; - const video = options.videoRef.current; - if (video && video.playbackRate !== 1) { - video.playbackRate = 1; - } - }, [options.videoRef]); - - const stopReverse = useCallback(() => { - cleanup(); - setIsReversing(false); - setIsBuffering(false); - }, [cleanup]); - - const startReverse = useCallback(async () => { - const video = options.videoRef.current; - if (!video) return; - - stopReverse(); - didFinishRef.current = false; - setIsReversing(true); - - const { - onComplete, - preloadedUrls, - videoUrl, - storageKey, - getCachedBlobUrl, - getReadyBlobUrl, - } = options; - - const actualDuration = video.duration; - if (!Number.isFinite(actualDuration) || actualDuration <= 0) { - logger.error('Invalid video duration for reverse playback', { - videoDuration: video.duration, - readyState: video.readyState, - videoUrl, - }); - setIsReversing(false); - onComplete(); - return; - } - - const finishReverse = (reason: string) => { - if (didFinishRef.current) return; - didFinishRef.current = true; - cleanup(); - setIsReversing(false); - setIsBuffering(false); - logger.info('Reverse playback finished', { reason }); - onComplete(); - }; - - // Strategy 1: Native playbackRate = -1 - if (canUseNativeReverse) { - logger.info('Using native playbackRate = -1'); - const success = await startNativeReverse( - video, - actualDuration, - () => finishReverse('native-complete'), - didFinishRef, - ); - if (success) return; - logger.info('Native reverse failed, falling back to frame-stepping'); - } - - // Check if video is preloaded (use storageKey for accurate detection) - const isPreloaded = storageKey - ? preloadedUrls?.has(storageKey) - : videoUrl && preloadedUrls?.has(videoUrl); - - // Reset adaptive FPS metrics - const config = PRELOAD_CONFIG.reversePlayback; - metricsRef.current = { - consecutiveSlowFrames: 0, - currentFps: isPreloaded ? config.preloadedFps : config.defaultFps, - lastSeekDuration: 0, - }; - - // Try O(1) instant lookup first (uses downloadManager.readyBlobUrls) - if (getReadyBlobUrl && videoUrl && !video.src.startsWith('blob:')) { - const readyBlobUrl = getReadyBlobUrl(videoUrl); - if (readyBlobUrl && !didFinishRef.current) { - logger.info('Using ready blob URL for reverse (O(1) lookup)', { - videoUrl: videoUrl.slice(-50), - }); - video.src = readyBlobUrl; - video.load(); - await new Promise((resolve) => { - const onLoaded = () => { - video.removeEventListener('loadeddata', onLoaded); - // Immediately seek to end to avoid showing first frame - if (video.duration && Number.isFinite(video.duration)) { - video.currentTime = video.duration - 0.01; - } - resolve(); - }; - video.addEventListener('loadeddata', onLoaded); - setTimeout(resolve, 1000); // Shorter timeout for ready URLs - }); - } - } - - // Fall back to async cache lookup - if (getCachedBlobUrl && videoUrl && !video.src.startsWith('blob:')) { - try { - const blobUrl = await getCachedBlobUrl(videoUrl); - if (blobUrl && !didFinishRef.current) { - logger.info('Using cached blob URL for reverse'); - video.src = blobUrl; - video.load(); - await new Promise((resolve) => { - const onLoaded = () => { - video.removeEventListener('loadeddata', onLoaded); - // Immediately seek to end to avoid showing first frame - if (video.duration && Number.isFinite(video.duration)) { - video.currentTime = video.duration - 0.01; - } - resolve(); - }; - video.addEventListener('loadeddata', onLoaded); - setTimeout(resolve, 2000); - }); - } - } catch (err) { - logger.warn('Failed to get cached blob URL', { err }); - } - } - - const startFrameStepping = (targetTime: number) => { - const maxBuffered = getBufferedEnd(video); - const safeTarget = Math.min(targetTime, maxBuffered); - - logger.info('Preparing reverse seek', { - requestedTarget: targetTime, - maxBuffered, - safeTarget, - readyState: video.readyState, - }); - - if (safeTarget < 0.1) { - logger.info('No buffered range available'); - finishReverse('no-buffered'); - return; - } - - const beginFrameStepping = () => { - video.pause(); - let isWaitingForSeek = false; - let lastStepTime = performance.now(); - let stepCount = 0; - - const onSeekedFrame = () => { - if (didFinishRef.current) return; - - // Measure seek performance for adaptive FPS - const now = performance.now(); - const seekDuration = now - lastStepTime; - const targetFrameMs = 1000 / metricsRef.current.currentFps; - - if (seekDuration > targetFrameMs * config.slowFrameThreshold) { - metricsRef.current.consecutiveSlowFrames++; - if ( - metricsRef.current.consecutiveSlowFrames >= - config.maxConsecutiveSlowFrames - ) { - metricsRef.current.currentFps = Math.max( - config.minFps, - metricsRef.current.currentFps - 5, - ); - metricsRef.current.consecutiveSlowFrames = 0; - logger.info('Adaptive FPS reduced', { - newFps: metricsRef.current.currentFps, - seekDuration, - }); - } - } else { - metricsRef.current.consecutiveSlowFrames = 0; - } - - isWaitingForSeek = false; - scheduleNextStep(); - }; - - const scheduleNextStep = () => { - if (didFinishRef.current) return; - animationFrameRef.current = requestAnimationFrame(step); - }; - - // Throttled step - only executes when enough time has passed - const step = () => { - if (didFinishRef.current || isWaitingForSeek) return; - - // Check if enough time has passed for next frame - const now = performance.now(); - const elapsed = now - lastStepTime; - const targetFrameMs = 1000 / metricsRef.current.currentFps; - - if (elapsed < targetFrameMs) { - // Not enough time passed, wait for next animation frame - animationFrameRef.current = requestAnimationFrame(step); - return; - } - - stepCount++; - const currentStepSize = 1 / metricsRef.current.currentFps; - const newTime = video.currentTime - currentStepSize; - - if (stepCount % 30 === 0) { - logger.info('Frame-stepping progress', { - stepCount, - currentTime: video.currentTime, - fps: metricsRef.current.currentFps, - }); - } - - if (newTime <= 0) { - video.removeEventListener('seeked', onSeekedFrame); - video.currentTime = 0; - finishReverse('reverse-complete'); - return; - } - - isWaitingForSeek = true; - lastStepTime = now; - video.currentTime = newTime; - }; - - video.addEventListener('seeked', onSeekedFrame); - cleanupFnsRef.current.push(() => - video.removeEventListener('seeked', onSeekedFrame), - ); - - // Start stepping - step(); - }; - - const onSeeked = () => { - video.removeEventListener('seeked', onSeeked); - if (didFinishRef.current) return; - - if (video.currentTime < 0.1) { - logger.info('Seek failed, retrying'); - const onRetry = () => { - video.removeEventListener('seeked', onRetry); - if (didFinishRef.current) return; - if (video.currentTime >= 0.1) { - beginFrameStepping(); - } else { - finishReverse('seek-failed'); - } - }; - video.addEventListener('seeked', onRetry); - cleanupFnsRef.current.push(() => - video.removeEventListener('seeked', onRetry), - ); - video.currentTime = safeTarget; - return; - } - - beginFrameStepping(); - }; - - video.addEventListener('seeked', onSeeked); - cleanupFnsRef.current.push(() => - video.removeEventListener('seeked', onSeeked), - ); - - if (!video.paused) { - const onPause = () => { - video.removeEventListener('pause', onPause); - if (didFinishRef.current) return; - requestAnimationFrame(() => { - if (!didFinishRef.current) { - video.currentTime = safeTarget; - } - }); - }; - video.addEventListener('pause', onPause); - cleanupFnsRef.current.push(() => - video.removeEventListener('pause', onPause), - ); - video.pause(); - } else { - // Skip initial seek if already near target position (within 0.2s) - // This prevents the forward-then-backward movement flash - if (Math.abs(video.currentTime - safeTarget) < 0.2) { - logger.info('Already near target, skipping initial seek', { - currentTime: video.currentTime, - safeTarget, - }); - video.removeEventListener('seeked', onSeeked); - beginFrameStepping(); - } else { - video.currentTime = safeTarget; - } - } - }; - - const bufferedEnd = getBufferedEnd(video); - - logger.info('Starting reverse playback', { - duration: actualDuration, - bufferedEnd, - isPreloaded, - readyState: video.readyState, - }); - - // If already fully buffered, start immediately - if (bufferedEnd >= actualDuration - 0.1) { - logger.info('Video fully buffered, starting immediately'); - startFrameStepping(actualDuration); - return; - } - - // Need to buffer more - setIsBuffering(true); - let progressCheckCount = 0; - const maxProgressChecks = 160; // ~8 seconds at 20 checks/sec - - const onProgress = () => { - if (didFinishRef.current) return; - progressCheckCount++; - const currentBuffered = getBufferedEnd(video); - - if (currentBuffered >= actualDuration - 0.1) { - setIsBuffering(false); - startFrameStepping(actualDuration); - return; - } - - if (progressCheckCount >= maxProgressChecks) { - setIsBuffering(false); - if (currentBuffered >= 0.5) { - startFrameStepping(currentBuffered); - } else { - finishReverse('buffer-timeout'); - } - } - }; - - const onCanPlayThrough = () => { - if (didFinishRef.current) return; - const currentBuffered = getBufferedEnd(video); - if (currentBuffered >= 0.5) { - setIsBuffering(false); - const targetTime = Math.min(currentBuffered, actualDuration); - startFrameStepping(targetTime); - } - }; - - const onEnded = () => { - if (didFinishRef.current) return; - setIsBuffering(false); - const currentBuffered = getBufferedEnd(video); - if (currentBuffered >= 0.5) { - startFrameStepping(currentBuffered); - } else { - startFrameStepping(actualDuration); - } - }; - - video.addEventListener('progress', onProgress); - video.addEventListener('canplaythrough', onCanPlayThrough); - video.addEventListener('ended', onEnded); - cleanupFnsRef.current.push( - () => video.removeEventListener('progress', onProgress), - () => video.removeEventListener('canplaythrough', onCanPlayThrough), - () => video.removeEventListener('ended', onEnded), - ); - - // Start playback to trigger buffering, but from near the end - // to avoid showing frame 0 (which would flash the target page) - video.muted = true; - video.currentTime = actualDuration - 0.1; - video.play().catch(() => undefined); - - // Fallback timeout - const timeoutId = setTimeout(() => { - if (didFinishRef.current) return; - setIsBuffering(false); - const currentBuffered = getBufferedEnd(video); - if (currentBuffered >= 0.5) { - startFrameStepping(currentBuffered); - } else { - finishReverse('buffer-timeout'); - } - }, 8000); - cleanupFnsRef.current.push(() => clearTimeout(timeoutId)); - }, [canUseNativeReverse, options, stopReverse, cleanup]); - - useEffect(() => () => stopReverse(), [stopReverse]); - - return { - startReverse, - stopReverse, - isReversing, - isBuffering, - canUseNativeReverse, - }; -} diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index 2754f56..b276380 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -1,3 +1,11 @@ +/** + * useTransitionPlayback Hook + * + * Handles video transition playback between pages. + * For back navigation, uses pre-reversed video generated by the backend. + * No frame-stepping - all transitions play forward. + */ + import { useCallback, useEffect, @@ -17,9 +25,8 @@ import { extractStoragePath, } from '../lib/assetUrl'; import { downloadManager } from '../lib/offline/DownloadManager'; -import { useReversePlayback } from './useReversePlayback'; -export type ReverseMode = 'none' | 'reverse' | 'separate'; +export type ReverseMode = 'none' | 'separate'; export interface TransitionConfig { videoUrl: string; @@ -61,7 +68,6 @@ export type PlaybackPhase = | 'idle' | 'preparing' | 'playing' - | 'reversing' | 'finishing' | 'completed'; @@ -79,19 +85,8 @@ const DEFAULT_TIMEOUTS = { hardTimeoutMs: 45000, }; -function getBufferedEnd(video: HTMLVideoElement): number { - return video.buffered.length > 0 - ? video.buffered.end(video.buffered.length - 1) - : 0; -} - -function shouldLoadViaBlob( - url: string, - reverseMode: ReverseMode, - useBlobUrlOption?: boolean, -): boolean { +function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean { if (useBlobUrlOption === false) return false; - if (reverseMode === 'reverse') return true; if (useBlobUrlOption === true) return true; try { @@ -107,13 +102,6 @@ function shouldLoadViaBlob( } } -function buildBlobRequestUrl(url: string): string { - if (url.startsWith('/api/')) { - return url.replace(/^\/api(?=\/)/, ''); - } - return url; -} - async function waitForImages(urls: string[], timeoutMs = 2000): Promise { if (urls.length === 0) return; @@ -155,13 +143,10 @@ export function useTransitionPlayback( const playbackStartMs = customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs; - const durationBufferMs = - customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs; const hardTimeoutMs = customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs; const [phase, setPhase] = useState('idle'); - const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false); const didFinishRef = useRef(false); const didStartPlaybackRef = useRef(false); @@ -184,14 +169,26 @@ export function useTransitionPlayback( const transitionRef = useRef(transition); const featuresRef = useRef(features); const preloadRef = useRef(preload); - const startReverseRef = useRef<(() => Promise) | null>(null); - const stopReverseRef = useRef<(() => void) | null>(null); + // Determine which video URL to use: + // For back navigation with a reversed video, use reverseVideoUrl + // Otherwise, use the original videoUrl const sourceUrl = useMemo(() => { if (!transition) return ''; - return transition.reverseMode === 'separate' && transition.reverseVideoUrl - ? transition.reverseVideoUrl - : transition.videoUrl; + // Use reversed video if this is back navigation with a separate reversed video + if (transition.isBack && transition.reverseVideoUrl) { + return transition.reverseVideoUrl; + } + return transition.videoUrl; + }, [transition]); + + // Storage key for cache lookup - use reversed video key for back navigation + const storageKey = useMemo(() => { + if (!transition) return undefined; + if (transition.isBack && transition.reverseVideoUrl) { + return transition.reverseVideoUrl; + } + return transition.storageKey; }, [transition]); const clearTimers = useCallback(() => { @@ -226,7 +223,6 @@ export function useTransitionPlayback( if (video) { video.pause(); // Seek back slightly to ensure last frame is visible - // Some browsers show black after 'ended' event when currentTime === duration if ( video.duration && Number.isFinite(video.duration) && @@ -279,43 +275,17 @@ export function useTransitionPlayback( [finishPlayback], ); - const handleReverseComplete = useCallback(() => { - finishPlayback('reverse-complete'); - }, [finishPlayback]); - - const { - startReverse, - stopReverse, - isReversing, - isBuffering: isReverseBuffering, - } = useReversePlayback({ - videoRef, - onComplete: handleReverseComplete, - preloadedUrls: preload?.preloadedUrls, - videoUrl: sourceUrl, - storageKey: transition?.storageKey, // Raw storage path for preload detection - getCachedBlobUrl: preload?.getCachedBlobUrl, - getReadyBlobUrl: preload?.getReadyBlobUrl, // O(1) instant lookup - }); - useEffect(() => { onCompleteRef.current = onComplete; onErrorRef.current = onError; transitionRef.current = transition; featuresRef.current = features; preloadRef.current = preload; - startReverseRef.current = startReverse; - stopReverseRef.current = stopReverse; }); - useEffect(() => { - setIsReverseBufferingLocal(isReverseBuffering); - }, [isReverseBuffering]); - const cancel = useCallback(() => { if (phase === 'idle') return; clearTimers(); - stopReverseRef.current?.(); didFinishRef.current = true; setPhase('idle'); const video = videoRef.current; @@ -338,12 +308,12 @@ export function useTransitionPlayback( return; } - // Include reverseMode in the key so same video can play forward then reverse - const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`; + // Include isBack in the key so same video can play forward or as reversed + const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`; if (activeSourceUrlRef.current === sourceKey) { logger.info('Skipping duplicate effect for same source', { sourceUrl, - reverseMode: currentTransition.reverseMode, + isBack: currentTransition.isBack, }); return; } @@ -356,7 +326,6 @@ export function useTransitionPlayback( currentPlayableUrlRef.current = null; setPhase('preparing'); - const isReverseMode = currentTransition.reverseMode === 'reverse'; const configuredDurationSec = Number(currentTransition.durationSec); const getMediaErrorDetails = () => { @@ -376,7 +345,7 @@ export function useTransitionPlayback( networkState: video.networkState, duration: video.duration, configuredDurationSec, - reverseMode: currentTransition.reverseMode, + isBack: currentTransition.isBack, mediaError: getMediaErrorDetails(), error: error instanceof Error ? error : { error }, }); @@ -390,9 +359,7 @@ export function useTransitionPlayback( ) { return; } - // Finish slightly BEFORE the video ends to ensure last frame is visible - // and prevent browser-specific 'ended' event quirks (black frame) - const finishBeforeEndMs = 50; // 50ms before video naturally ends + const finishBeforeEndMs = 50; const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); finishTimerRef.current = setTimeout( () => finishPlayback('duration-timer'), @@ -402,34 +369,33 @@ export function useTransitionPlayback( const attemptPlay = () => { video.play().catch((playError) => { - if (!isReverseMode) { - logIssue('play-failed', playError); - } + logIssue('play-failed', playError); }); }; const resolvePlayableSource = async (): Promise => { - // 1. Try storage key lookup first (most reliable for cache hits) const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl; - const storageKey = currentTransition.storageKey; - if (getReadyBlobUrl && storageKey) { - const readyUrl = getReadyBlobUrl(storageKey); + const currentStorageKey = storageKey; + + // 1. Try storage key lookup first (most reliable for cache hits) + if (getReadyBlobUrl && currentStorageKey) { + const readyUrl = getReadyBlobUrl(currentStorageKey); if (readyUrl) { logger.info('Using ready blob URL from storage key', { - storageKey: storageKey.slice(-50), + storageKey: currentStorageKey.slice(-50), }); return readyUrl; } } - // 2. Try cached blob URL by storage key (post-refresh scenario) + // 2. Try cached blob URL by storage key const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl; - if (getCachedBlobUrl && storageKey) { + if (getCachedBlobUrl && currentStorageKey) { try { - const cachedBlobUrl = await getCachedBlobUrl(storageKey); + const cachedBlobUrl = await getCachedBlobUrl(currentStorageKey); if (cachedBlobUrl) { logger.info('Using cached blob URL from storage key', { - storageKey: storageKey.slice(-50), + storageKey: currentStorageKey.slice(-50), }); lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedSourceUrlRef.current = sourceUrl; @@ -440,7 +406,7 @@ export function useTransitionPlayback( } } - // 3. Reuse cached blob URL if same source (existing logic) + // 3. Reuse cached blob URL if same source if ( lastLoadedBlobUrlRef.current && lastLoadedSourceUrlRef.current === sourceUrl @@ -458,7 +424,6 @@ export function useTransitionPlayback( const needsBlobUrl = shouldLoadViaBlob( sourceUrl, - currentTransition.reverseMode, featuresRef.current?.useBlobUrl, ); @@ -483,7 +448,7 @@ export function useTransitionPlayback( const cachedBlobUrl = await getCachedBlobUrl(sourceUrl); if (cachedBlobUrl) { logger.info('Using preloaded blob URL from cache', { - reverseMode: currentTransition.reverseMode, + isBack: currentTransition.isBack, }); lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedSourceUrlRef.current = sourceUrl; @@ -496,17 +461,13 @@ export function useTransitionPlayback( } } - // 6. Fetch video as blob with presigned URL support - // Follows usePageSwitch.loadImageWithFallback pattern: - // Try presigned URL first (SW can intercept for caching), fallback to proxy if it fails - logger.info('Fetching video as blob for seeking support', { - reverseMode: currentTransition.reverseMode, + // 6. Fetch video as blob + logger.info('Fetching video as blob', { + isBack: currentTransition.isBack, }); - // Re-resolve URL to get presigned URL if now available - // (may have been cached since transition started) - const freshUrl = storageKey - ? resolveAssetPlaybackUrl(storageKey) + const freshUrl = currentStorageKey + ? resolveAssetPlaybackUrl(currentStorageKey) : sourceUrl; const token = @@ -514,7 +475,6 @@ export function useTransitionPlayback( ? localStorage.getItem('token') || '' : ''; - // Helper: Fetch video and return blob URL, caching for next time const fetchVideoAsBlob = async (url: string): Promise => { logger.info('Fetching video from URL', { url: url.slice(0, 80), @@ -524,14 +484,13 @@ export function useTransitionPlayback( const response = await axios.get(url, { responseType: 'blob', headers: token ? { Authorization: `Bearer ${token}` } : undefined, - baseURL: '', // Prevent double /api/ prefix when using buildProxyUrl + baseURL: '', }); const blob = response.data as Blob; - // Cache for next time using existing DownloadManager pattern - if (storageKey) { - const normalizedKey = extractStoragePath(storageKey); + if (currentStorageKey) { + const normalizedKey = extractStoragePath(currentStorageKey); const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, { assetType: 'transition', }); @@ -540,7 +499,6 @@ export function useTransitionPlayback( return blobUrl; } - // Fallback: create blob URL without caching const blobUrl = URL.createObjectURL(blob); lastLoadedBlobUrlRef.current = blobUrl; lastLoadedSourceUrlRef.current = sourceUrl; @@ -551,16 +509,14 @@ export function useTransitionPlayback( }; try { - // Try fetching with potentially presigned URL (SW can intercept if S3) return await fetchVideoAsBlob(freshUrl); } catch (error) { - // If presigned URL failed and we have storage key, retry with proxy - if (storageKey && isPresignedUrl(freshUrl)) { + if (currentStorageKey && isPresignedUrl(freshUrl)) { logger.info('Presigned URL failed, retrying with proxy', { - storageKey: storageKey.slice(-40), + storageKey: currentStorageKey.slice(-40), }); - markPresignedUrlFailed(storageKey); - const proxyUrl = buildProxyUrl(storageKey); + markPresignedUrlFailed(currentStorageKey); + const proxyUrl = buildProxyUrl(currentStorageKey); return await fetchVideoAsBlob(proxyUrl); } throw error; @@ -569,7 +525,7 @@ export function useTransitionPlayback( const loadAndPlay = async () => { logger.info('loadAndPlay called', { - reverseMode: currentTransition.reverseMode, + isBack: currentTransition.isBack, sourceUrl, }); @@ -584,47 +540,18 @@ export function useTransitionPlayback( if (didFinishRef.current) return; video.pause(); - stopReverseRef.current?.(); - - const isSameSource = - lastLoadedSourceUrlRef.current === playableSourceUrl; - - if (isReverseMode && isSameSource && video.readyState >= 2) { - logger.info('Reusing buffered video for reverse', { - readyState: video.readyState, - duration: video.duration, - bufferedEnd: getBufferedEnd(video), - }); - didStartPlaybackRef.current = true; // Prevent canplaythrough from double-starting - setPhase('reversing'); - void startReverseRef.current?.(); - return; - } - video.src = playableSourceUrl; - // For reverse mode, seek to a large value (browser clamps to duration) - // This prevents showing frame 0 while loading - video.currentTime = isReverseMode ? 999999 : 0; + video.currentTime = 0; video.load(); lastLoadedSourceUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl; - // Only attempt play for forward playback - // For reverse mode, wait for canplaythrough to trigger startReverse() - if (!isReverseMode) { - attemptPlay(); - } + attemptPlay(); startWatchdogTimerRef.current = setTimeout(() => { if (didStartPlaybackRef.current || didFinishRef.current) return; logIssue('playback-start-slow'); - if (isReverseMode) { - didStartPlaybackRef.current = true; - setPhase('reversing'); - void startReverseRef.current?.(); - } else { - attemptPlay(); - } + attemptPlay(); }, playbackStartMs); } catch (error) { logIssue('source-prepare-failed', error); @@ -634,62 +561,24 @@ export function useTransitionPlayback( const onLoadedMetadata = () => { if (didFinishRef.current) return; - if (!isReverseMode) { - video.currentTime = 0; - attemptPlay(); - } - }; - - const onCanPlayThrough = () => { - if (didFinishRef.current) return; - // Skip if reverse playback already started (avoid interference from seek events) - if (isReverseMode && didStartPlaybackRef.current) return; - - logger.info('canplaythrough fired', { - reverseMode: currentTransition.reverseMode, - didStartPlayback: didStartPlaybackRef.current, - }); - - if (isReverseMode && !didStartPlaybackRef.current) { - didStartPlaybackRef.current = true; - if (startWatchdogTimerRef.current) { - clearTimeout(startWatchdogTimerRef.current); - startWatchdogTimerRef.current = null; - } - video.pause(); - setPhase('reversing'); - void startReverseRef.current?.(); - } + video.currentTime = 0; + attemptPlay(); }; const onCanPlay = () => { if (didFinishRef.current) return; - if (isReverseMode) return; // Don't play for reverse mode attemptPlay(); }; const onPlaying = () => { logger.info('onPlaying fired', { - reverseMode: currentTransition.reverseMode, + isBack: currentTransition.isBack, didStartPlayback: didStartPlaybackRef.current, didFinish: didFinishRef.current, }); if (didFinishRef.current) return; - if (isReverseMode && !didStartPlaybackRef.current) { - logger.info('Triggering reverse from onPlaying'); - didStartPlaybackRef.current = true; - if (startWatchdogTimerRef.current) { - clearTimeout(startWatchdogTimerRef.current); - startWatchdogTimerRef.current = null; - } - video.pause(); - setPhase('reversing'); - void startReverseRef.current?.(); - return; - } - didStartPlaybackRef.current = true; setPhase('playing'); @@ -698,23 +587,19 @@ export function useTransitionPlayback( startWatchdogTimerRef.current = null; } - if (!isReverseMode) { - const mediaDurationSec = Number(video.duration); - const durationSec = - Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 - ? configuredDurationSec - : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 - ? mediaDurationSec - : NaN; - if (Number.isFinite(durationSec) && durationSec > 0) { - scheduleFinishByDuration(durationSec); - } + const mediaDurationSec = Number(video.duration); + const durationSec = + Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 + ? configuredDurationSec + : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 + ? mediaDurationSec + : NaN; + if (Number.isFinite(durationSec) && durationSec > 0) { + scheduleFinishByDuration(durationSec); } }; const onEnded = () => { - // For reverse mode, ignore 'ended' event - wait for reverse playback to complete - if (isReverseMode) return; finishPlayback('ended'); }; @@ -722,7 +607,6 @@ export function useTransitionPlayback( if (didFinishRef.current) return; logIssue('video-error'); - // Safari video decode error recovery (MEDIA_ERR_DECODE = 3) const errorCode = video.error?.code; if (errorCode === 3 && !didTryDecodeRetryRef.current) { logger.info('Safari video decode error, attempting reload'); @@ -732,7 +616,6 @@ export function useTransitionPlayback( return; } - // Check if this is a presigned URL failure (likely CORS) const currentUrl = currentPlayableUrlRef.current; if ( currentUrl && @@ -743,14 +626,11 @@ export function useTransitionPlayback( url: currentUrl.slice(0, 80), }); - // Mark presigned URL as failed so future resolves use proxy - // Extract storage key from the original transition videoUrl const originalVideoUrl = currentTransition.videoUrl; if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) { markPresignedUrlFailed(originalVideoUrl); } - // Get proxy fallback URL using storage key const videoStorageKey = currentTransition.videoUrl; if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) { const fallbackUrl = buildProxyUrl(videoStorageKey); @@ -780,7 +660,6 @@ export function useTransitionPlayback( }; video.addEventListener('loadedmetadata', onLoadedMetadata); - video.addEventListener('canplaythrough', onCanPlayThrough); video.addEventListener('canplay', onCanPlay); video.addEventListener('playing', onPlaying); video.addEventListener('ended', onEnded); @@ -798,7 +677,6 @@ export function useTransitionPlayback( return () => { video.removeEventListener('loadedmetadata', onLoadedMetadata); - video.removeEventListener('canplaythrough', onCanPlayThrough); video.removeEventListener('canplay', onCanPlay); video.removeEventListener('playing', onPlaying); video.removeEventListener('ended', onEnded); @@ -806,11 +684,11 @@ export function useTransitionPlayback( video.removeEventListener('abort', onAbort); video.removeEventListener('stalled', onStalled); clearTimers(); - stopReverseRef.current?.(); }; }, [ sourceUrl, - transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction) + storageKey, + transition?.isBack, videoRef, playbackStartMs, hardTimeoutMs, @@ -831,8 +709,8 @@ export function useTransitionPlayback( return { phase, - isBuffering: isReverseBufferingLocal, - isReversing, + isBuffering: false, // No longer have buffering from frame-stepping + isReversing: false, // No longer support frame-stepping reverse cancel, forceComplete, }; diff --git a/frontend/src/hooks/useTransitionPreview.ts b/frontend/src/hooks/useTransitionPreview.ts index b34e793..df5e7e5 100644 --- a/frontend/src/hooks/useTransitionPreview.ts +++ b/frontend/src/hooks/useTransitionPreview.ts @@ -103,27 +103,24 @@ export function useTransitionPreview({ return; } - // Check for separate reverse video if needed - if ( - direction === 'back' && - element.transitionReverseMode === 'separate_video' && - !element.reverseVideoUrl - ) { + // For back navigation, check if reversed video is available + // (either manually uploaded or backend-generated) + if (direction === 'back' && !element.reverseVideoUrl) { onError?.( - 'Select back-transition asset or switch reverse mode to Auto Reverse.', + 'Reversed video not available. Save the page to generate it.', ); return; } + // Determine reverse mode: + // - 'none' for forward navigation + // - 'separate' for back navigation (uses pre-reversed video) + const reverseMode = direction === 'back' ? 'separate' : 'none'; + const previewState: TransitionPreviewState = { videoUrl: element.transitionVideoUrl, storageKey: element.transitionVideoUrl, // Raw storage path for cache lookup - reverseMode: - direction === 'forward' - ? 'none' - : element.transitionReverseMode === 'separate_video' - ? 'separate' - : 'reverse', + reverseMode, reverseVideoUrl: element.reverseVideoUrl, reverseStorageKey: element.reverseVideoUrl, durationSec: element.transitionDurationSec, diff --git a/frontend/src/lib/navigationHelpers.ts b/frontend/src/lib/navigationHelpers.ts index b1e962c..401bf09 100644 --- a/frontend/src/lib/navigationHelpers.ts +++ b/frontend/src/lib/navigationHelpers.ts @@ -175,7 +175,7 @@ export const getNavigationDirection = ( /** * Check if transition is actively blocking navigation. - * Navigation should be blocked during preparing, playing, or reversing phases. + * Navigation should be blocked during preparing or playing phases. * * @param transitionPhase - Current transition phase * @param isBuffering - Whether video is buffering @@ -185,14 +185,16 @@ export const isTransitionBlocking = ( transitionPhase: TransitionPhase, isBuffering: boolean, ): boolean => { - const activePhases: TransitionPhase[] = ['preparing', 'playing', 'reversing']; + const activePhases: TransitionPhase[] = ['preparing', 'playing']; return activePhases.includes(transitionPhase) || isBuffering; }; /** * Check if element has a playable transition. - * A transition is playable if it has a video URL, and for back navigation, - * either supports reverse or has a separate reverse video. + * A transition is playable if: + * - Forward navigation: has a video URL + * - Back navigation: has a video URL AND a reversed video URL + * (reversed video is pre-generated by backend) * * @param element - Element with transition properties * @param direction - Navigation direction @@ -201,7 +203,6 @@ export const isTransitionBlocking = ( export const hasPlayableTransition = ( element: { transitionVideoUrl?: string; - transitionReverseMode?: string; reverseVideoUrl?: string; }, direction: 'back' | 'forward' = 'forward', @@ -210,12 +211,8 @@ export const hasPlayableTransition = ( return false; } - // For back navigation with separate_video mode, need reverse video - if ( - direction === 'back' && - element.transitionReverseMode === 'separate_video' && - !element.reverseVideoUrl - ) { + // For back navigation, require pre-reversed video (generated by backend) + if (direction === 'back' && !element.reverseVideoUrl) { return false; } diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 50cad67..4b18a21 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -1205,11 +1205,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ? 'Create transition' : selectedElement?.label || 'Element editor'; - if (backgroundImageSrc) { - canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`; - canvasBackgroundStyle.backgroundSize = 'cover'; - canvasBackgroundStyle.backgroundPosition = 'center'; - } + // Background image is rendered by CanvasBackground component (same as runtime) + // No CSS background-image needed on canvas div // Duration notes for UI display const durationNotes = useMemo( diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index e25f347..f83becb 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -224,8 +224,12 @@ export interface CanvasElement extends BaseCanvasElement { /** Target page slug for navigation - slugs are consistent across environments */ targetPageSlug?: string; transitionVideoUrl?: string; + /** Storage key for the transition video (for cache lookup) */ + transitionStorageKey?: string; transitionReverseMode?: 'auto_reverse' | 'separate_video'; reverseVideoUrl?: string; + /** Storage key for the reversed transition video (pre-generated by backend) */ + transitionReversedStorageKey?: string; /** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */ navBackMode?: 'target_page' | 'history'; transitionDurationSec?: number; diff --git a/frontend/src/types/presentation.ts b/frontend/src/types/presentation.ts index 4b3a169..89bae1b 100644 --- a/frontend/src/types/presentation.ts +++ b/frontend/src/types/presentation.ts @@ -15,9 +15,9 @@ export interface TransitionPreviewState { videoUrl: string; /** Raw storage path for cache lookup */ storageKey: string; - /** Playback mode: none (forward), reverse (auto-reverse), separate (use reverseVideoUrl) */ - reverseMode: 'none' | 'reverse' | 'separate'; - /** Resolved URL for separate reverse video */ + /** Playback mode: none (forward), separate (use reverseVideoUrl for back navigation) */ + reverseMode: 'none' | 'separate'; + /** Resolved URL for separate reverse video (pre-generated by backend) */ reverseVideoUrl?: string; /** Raw storage path for reverse video cache lookup */ reverseStorageKey?: string; @@ -95,7 +95,7 @@ export type TransitionPhase = | 'idle' | 'preparing' | 'playing' - | 'reversing' + | 'finishing' | 'completed'; /**