/** * Tour Pages Service * * Extends the factory service with reversed video generation for back navigation transitions. * Reversed videos are always generated for forward navigation elements to support back navigation. */ 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'); const projectRegenInProgress = new Set(); const singleReverseGenerationInProgress = new Set(); const reverseGenerationPromiseByStorageKey = 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; } /** * Process reversed videos and update ui_schema_json with reversed URLs. * Returns data with updated ui_schema_json. * * Generates reversed videos for all navigation elements with transitions. * * @param {Object} data - Page data with ui_schema_json * @param {Object} currentUser - Current user for permissions * @param {Object} options - Processing options * @param {boolean} options._skipHistoryModeCheck - Skip initial regeneration 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; logger.info( { projectId }, 'Processing reversed videos for navigation elements', ); let wasModified = false; for (const element of uiSchema.elements) { const isBack = TourPagesService.isBackElement(element); const isForward = TourPagesService.isForwardElementWithTarget(element); // Always generate reversed videos for navigation elements with transitions const needsReversed = isBack || isForward; logger.debug( { elementType: element.type, navType: element.navType, isBack, isForward, needsReversed, hasTransitionVideo: Boolean(element.transitionVideoUrl), targetPageSlug: element.targetPageSlug, targetPageId: element.targetPageId, }, '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 { // Fast path: check if reversed variant already exists const reversedUrl = await TourPagesService.getExistingReversedVariant(storageKey); if (reversedUrl && reversedUrl !== element.reverseVideoUrl) { element.reverseVideoUrl = reversedUrl; wasModified = true; logger.info( { elementType: element.type, isBack, isForward, storageKey, }, 'Added existing reversed video URL to element', ); continue; } // Enqueue async generation - doesn't block save request if (projectId) { TourPagesService.enqueueSingleReverseGeneration({ projectId, pageId: data.id, storageKey, currentUser, }); } } catch (err) { logger.error({ err, storageKey }, 'Failed to process reversed variant'); // Continue without reversed video. Background generation can still populate later. } } // Always check all project pages for missing reversed videos on any page save. // Uses async queue to avoid blocking the save response. // Elements that already have reverseVideoUrl are skipped (no duplicate work). if (projectId && !options._skipHistoryModeCheck) { TourPagesService.enqueueProjectReversedVideosRegeneration( projectId, currentUser, data.id, ); } 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 if already generated. * Does not trigger generation. * * @param {string} storageKey - Original video storage key * @returns {Promise} */ static async getExistingReversedVariant(storageKey) { const asset = await AssetsDBApi.findBy({ storage_key: storageKey }); if (!asset) { logger.warn({ storageKey }, 'Asset not found for transition'); return null; } const variants = asset.asset_variants_asset || []; const reversedVariant = variants.find((v) => v.variant_type === 'reversed'); return reversedVariant?.storage_key || null; } /** * Enqueue one reversed video generation task without blocking request cycle. * Updates all matching project elements after generation succeeds. * * @param {Object} task * @param {string} task.projectId * @param {string} task.storageKey * @param {Object} task.currentUser * @param {string} [task.pageId] */ static enqueueSingleReverseGeneration({ projectId, storageKey, currentUser, pageId, }) { if (!projectId || !storageKey) return; const taskKey = `${projectId}:${storageKey}`; if (singleReverseGenerationInProgress.has(taskKey)) { return; } singleReverseGenerationInProgress.add(taskKey); setImmediate(async () => { const log = logger.child({ projectId, storageKey, pageId, operation: 'singleReverseGeneration', }); try { log.info('Starting background reversed variant generation'); const reversedUrl = await TourPagesService.getOrGenerateReversedVariant( storageKey, currentUser, ); if (!reversedUrl) { log.warn( 'Background reversed variant generation finished without result', ); return; } await TourPagesService.applyReversedUrlToProjectElements( projectId, storageKey, reversedUrl, currentUser, ); } catch (err) { log.error({ err }, 'Background reversed generation failed'); } finally { singleReverseGenerationInProgress.delete(taskKey); } }); } /** * Enqueue project-wide reverse regeneration (history mode) without blocking save request. * * @param {string} projectId * @param {Object} currentUser * @param {string} [excludePageId] */ static enqueueProjectReversedVideosRegeneration( projectId, currentUser, excludePageId, ) { if (!projectId) return; if (projectRegenInProgress.has(projectId)) return; projectRegenInProgress.add(projectId); setImmediate(async () => { try { await TourPagesService.regenerateProjectReversedVideos( projectId, currentUser, excludePageId, ); } catch (err) { logger.error( { err, projectId }, 'Background project regeneration failed', ); } finally { projectRegenInProgress.delete(projectId); } }); } /** * Write generated reversed URL into all matching navigation elements in dev pages. * * @param {string} projectId * @param {string} storageKey * @param {string} reversedUrl * @param {Object} currentUser */ static async applyReversedUrlToProjectElements( projectId, storageKey, reversedUrl, currentUser, ) { if (!projectId || !storageKey || !reversedUrl) return; const log = logger.child({ projectId, storageKey, operation: 'applyReversedUrlToProjectElements', }); const { rows: pages } = await Tour_pagesDBApi.findAll({ projectId, environment: 'dev', }); let pagesUpdated = 0; 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; let pageModified = false; for (const element of uiSchema.elements) { const shouldAttachReverse = TourPagesService.isBackElement(element) || TourPagesService.isForwardElementWithTarget(element); if (!shouldAttachReverse) continue; if (element.transitionVideoUrl !== storageKey) continue; if (element.reverseVideoUrl) continue; element.reverseVideoUrl = reversedUrl; pageModified = true; } if (!pageModified) continue; await Tour_pagesDBApi.partialUpdate( page.id, { ui_schema_json: JSON.stringify(uiSchema) }, { currentUser }, ); pagesUpdated++; } if (pagesUpdated > 0) { log.info({ pagesUpdated }, 'Applied reversed URL to project elements'); } } /** * 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) { const existingVariant = await TourPagesService.getExistingReversedVariant(storageKey); if (existingVariant) { return existingVariant; } if (reverseGenerationPromiseByStorageKey.has(storageKey)) { return reverseGenerationPromiseByStorageKey.get(storageKey); } const generationPromise = (async () => { // 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; } // Generate reversed video return TourPagesService.generateReversedVariant(asset, currentUser); })(); reverseGenerationPromiseByStorageKey.set(storageKey, generationPromise); try { return await generationPromise; } finally { reverseGenerationPromiseByStorageKey.delete(storageKey); } } /** * 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) { // Process both forward elements AND back elements with their own transition const isForward = TourPagesService.isForwardElementWithTarget(element); const isBackWithTransition = TourPagesService.isBackElement(element) && element.transitionVideoUrl; log.debug( { pageId: page.id, elementType: element.type, navType: element.navType, isForward, isBackWithTransition, hasTransitionVideo: Boolean(element.transitionVideoUrl), hasReverseVideo: Boolean(element.reverseVideoUrl), targetPageSlug: element.targetPageSlug, targetPageId: element.targetPageId, }, 'Checking element in regeneration', ); // Skip if neither forward nor back-with-transition if (!isForward && !isBackWithTransition) { 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; } } /** * Populate reverseVideoUrl for navigation elements from existing variants. * This eagerly looks up reversed variants that were generated asynchronously, * ensuring the frontend has the reversed URL even if the page wasn't re-saved * after variant generation completed. * * @param {Object|Array} pages - Single page or array of pages * @returns {Promise} Pages with populated reverseVideoUrl fields */ static async populateReverseVideoUrls(pages) { if (!pages) return pages; const isArray = Array.isArray(pages); const pageList = isArray ? pages : [pages]; // Collect all storage keys that need lookup const storageKeysToLookup = new Set(); for (const page of pageList) { if (!page?.ui_schema_json) 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; for (const element of uiSchema.elements) { if (!element.transitionVideoUrl) continue; if (element.reverseVideoUrl) continue; // Already has reversed URL // Only auto_reverse mode needs lookup; separate_video has manual URL if (element.transitionReverseMode === 'separate_video') continue; storageKeysToLookup.add(element.transitionVideoUrl); } } if (storageKeysToLookup.size === 0) { return pages; } // Batch lookup all reversed variants const reversedUrlByStorageKey = new Map(); for (const storageKey of storageKeysToLookup) { try { const reversedUrl = await TourPagesService.getExistingReversedVariant(storageKey); if (reversedUrl) { reversedUrlByStorageKey.set(storageKey, reversedUrl); } } catch { // Ignore lookup errors - element will just have no reverseVideoUrl } } if (reversedUrlByStorageKey.size === 0) { return pages; } // Apply reversed URLs to pages const modifiedPages = pageList.map((page) => { if (!page?.ui_schema_json) return page; 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)) return page; let modified = false; for (const element of uiSchema.elements) { if (!element.transitionVideoUrl) continue; if (element.reverseVideoUrl) continue; if (element.transitionReverseMode === 'separate_video') continue; const reversedUrl = reversedUrlByStorageKey.get( element.transitionVideoUrl, ); if (reversedUrl) { element.reverseVideoUrl = reversedUrl; modified = true; } } if (!modified) return page; // Return modified page with updated ui_schema_json const plainPage = page.get ? page.get({ plain: true }) : { ...page }; return { ...plainPage, ui_schema_json: JSON.stringify(uiSchema), }; }); return isArray ? modifiedPages : modifiedPages[0]; } /** * Check reverse video generation status for given storage keys. * Returns which keys have reversed variants ready. * * @param {string[]} storageKeys - Array of original video storage keys to check * @returns {Promise} Status object with ready keys and their reversed URLs */ static async checkReverseVideoStatus(storageKeys) { if ( !storageKeys || !Array.isArray(storageKeys) || storageKeys.length === 0 ) { return { ready: {}, pending: [], allReady: true }; } const ready = {}; const pending = []; for (const storageKey of storageKeys) { if (!storageKey) continue; try { const reversedUrl = await TourPagesService.getExistingReversedVariant(storageKey); if (reversedUrl) { ready[storageKey] = reversedUrl; } else { pending.push(storageKey); } } catch { pending.push(storageKey); } } return { ready, pending, allReady: pending.length === 0, }; } } module.exports = TourPagesService;