From 87250b8bfe25b668141ad5fe478e396c35412f2f Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 14 Apr 2026 08:07:36 +0000 Subject: [PATCH] fixed transitions --- backend/src/services/tour_pages.js | 264 +++++++++++++++++--- frontend/src/hooks/useTransitionPlayback.ts | 52 ++-- frontend/src/lib/navigationHelpers.ts | 26 +- 3 files changed, 261 insertions(+), 81 deletions(-) diff --git a/backend/src/services/tour_pages.js b/backend/src/services/tour_pages.js index 9a5f95a..2d4696e 100644 --- a/backend/src/services/tour_pages.js +++ b/backend/src/services/tour_pages.js @@ -19,6 +19,9 @@ const { logger } = require('../utils/logger'); // Cache for project history-mode status (cleared per request cycle) const projectHistoryModeCache = new Map(); +const projectRegenInProgress = new Set(); +const singleReverseGenerationInProgress = new Set(); +const reverseGenerationPromiseByStorageKey = new Map(); // Create base service from factory const BaseService = createEntityService(Tour_pagesDBApi, { @@ -276,11 +279,10 @@ class TourPagesService extends BaseService { const storageKey = element.transitionVideoUrl; try { - // Get or generate reversed video URL - const reversedUrl = await TourPagesService.getOrGenerateReversedVariant( - storageKey, - currentUser, - ); + // Fast path for request cycle: only reuse existing reversed variants. + // Missing variants are generated asynchronously to keep save requests fast. + const reversedUrl = + await TourPagesService.getExistingReversedVariant(storageKey); if (reversedUrl && reversedUrl !== element.reverseVideoUrl) { element.reverseVideoUrl = reversedUrl; @@ -292,43 +294,39 @@ class TourPagesService extends BaseService { isForward, storageKey, }, - 'Added reversed video URL to element', + 'Added existing reversed video URL to element', ); + continue; + } + + if (projectId) { + TourPagesService.enqueueSingleReverseGeneration({ + projectId, + pageId: data.id, + storageKey, + currentUser, + }); } } catch (err) { - logger.error( - { err, storageKey }, - 'Failed to get/generate reversed variant', - ); - // Continue without reversed video - button will be disabled in frontend + logger.error({ err, storageKey }, 'Failed to process reversed variant'); + // Continue without reversed video. Background generation can still populate later. } } // 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 - ) { + if (thisPageHasHistoryMode && projectId && !options._skipHistoryModeCheck) { logger.info( { projectId }, - 'History mode back button detected - regenerating forward elements', + 'History mode back button detected - scheduling forward regeneration', ); - try { - await TourPagesService.regenerateProjectReversedVideos( - projectId, - currentUser, - data.id, - ); - } catch (err) { - logger.error( - { err, projectId }, - 'Failed to regenerate project reversed videos', - ); - } + TourPagesService.enqueueProjectReversedVideosRegeneration( + projectId, + currentUser, + data.id, + ); } if (wasModified) { @@ -343,13 +341,13 @@ class TourPagesService extends BaseService { } /** - * Get existing reversed variant URL or generate one + * Get existing reversed variant URL if already generated. + * Does not trigger generation. + * * @param {string} storageKey - Original video storage key - * @param {Object} currentUser - Current user for permissions - * @returns {Promise} Reversed video storage key or null + * @returns {Promise} */ - static async getOrGenerateReversedVariant(storageKey, currentUser) { - // Find the asset by storage key + static async getExistingReversedVariant(storageKey) { const asset = await AssetsDBApi.findBy({ storage_key: storageKey }); if (!asset) { @@ -357,17 +355,203 @@ class TourPagesService extends BaseService { 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; + 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; } - // Generate reversed video - return TourPagesService.generateReversedVariant(asset, currentUser); + 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); + } } /** diff --git a/frontend/src/hooks/useTransitionPlayback.ts b/frontend/src/hooks/useTransitionPlayback.ts index b276380..eae5159 100644 --- a/frontend/src/hooks/useTransitionPlayback.ts +++ b/frontend/src/hooks/useTransitionPlayback.ts @@ -18,7 +18,6 @@ import axios from 'axios'; import { logger } from '../lib/logger'; import { markPresignedUrlFailed, - isRelativeStoragePath, resolveAssetPlaybackUrl, isPresignedUrl, buildProxyUrl, @@ -153,7 +152,6 @@ export function useTransitionPlayback( const activeSourceUrlRef = useRef(null); const lastLoadedBlobUrlRef = useRef(null); const lastLoadedSourceUrlRef = useRef(null); - const didTryFallbackRef = useRef(false); const didTryDecodeRetryRef = useRef(false); const currentPlayableUrlRef = useRef(null); const startWatchdogTimerRef = useRef | null>( @@ -175,9 +173,8 @@ export function useTransitionPlayback( // Otherwise, use the original videoUrl const sourceUrl = useMemo(() => { if (!transition) return ''; - // Use reversed video if this is back navigation with a separate reversed video - if (transition.isBack && transition.reverseVideoUrl) { - return transition.reverseVideoUrl; + if (transition.isBack) { + return transition.reverseVideoUrl || ''; } return transition.videoUrl; }, [transition]); @@ -185,8 +182,8 @@ export function useTransitionPlayback( // 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; + if (transition.isBack) { + return transition.reverseVideoUrl || undefined; } return transition.storageKey; }, [transition]); @@ -304,7 +301,16 @@ export function useTransitionPlayback( useEffect(() => { const video = videoRef.current; const currentTransition = transitionRef.current; - if (!currentTransition || !video || !sourceUrl) { + if (!currentTransition || !video) { + return; + } + + if (!sourceUrl) { + logger.info('No playable transition source, skipping playback', { + isBack: currentTransition.isBack, + targetPageId: currentTransition.targetPageId, + }); + void finishPlayback('missing-source'); return; } @@ -321,7 +327,6 @@ export function useTransitionPlayback( activeSourceUrlRef.current = sourceKey; didFinishRef.current = false; didStartPlaybackRef.current = false; - didTryFallbackRef.current = false; didTryDecodeRetryRef.current = false; currentPlayableUrlRef.current = null; setPhase('preparing'); @@ -616,35 +621,6 @@ export function useTransitionPlayback( return; } - const currentUrl = currentPlayableUrlRef.current; - if ( - currentUrl && - isPresignedUrl(currentUrl) && - !didTryFallbackRef.current - ) { - logger.info('Presigned URL failed, trying proxy fallback', { - url: currentUrl.slice(0, 80), - }); - - const originalVideoUrl = currentTransition.videoUrl; - if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) { - markPresignedUrlFailed(originalVideoUrl); - } - - const videoStorageKey = currentTransition.videoUrl; - if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) { - const fallbackUrl = buildProxyUrl(videoStorageKey); - didTryFallbackRef.current = true; - video.pause(); - video.src = fallbackUrl; - currentPlayableUrlRef.current = fallbackUrl; - video.currentTime = 0; - video.load(); - attemptPlay(); - return; - } - } - handleError('video-error'); }; diff --git a/frontend/src/lib/navigationHelpers.ts b/frontend/src/lib/navigationHelpers.ts index 401bf09..0250bd7 100644 --- a/frontend/src/lib/navigationHelpers.ts +++ b/frontend/src/lib/navigationHelpers.ts @@ -50,11 +50,31 @@ export const findIncomingNavigationElement = ( ); const elements = Array.isArray(uiSchema.elements) ? uiSchema.elements : []; - // Find navigation element pointing to target page - const found = elements.find( + const candidateElements = elements.filter( (el) => isNavigationType(String(el.type || '')) && - el.targetPageSlug === targetPageSlug, + el.targetPageSlug === targetPageSlug && + !isBackNavigation(el as unknown as NavigableElement), + ); + + const scoreCandidate = (candidate: Record): number => { + const hasTransition = Boolean(candidate.transitionVideoUrl); + const hasReverse = Boolean(candidate.reverseVideoUrl); + + if (hasTransition && hasReverse) return 3; + if (hasTransition) return 2; + if (hasReverse) return 1; + return 0; + }; + + const found = candidateElements.reduce | null>( + (best, candidate) => { + if (!best) return candidate; + const bestScore = scoreCandidate(best); + const candidateScore = scoreCandidate(candidate); + return candidateScore > bestScore ? candidate : best; + }, + null, ); if (!found) return null;