fixed transitions

This commit is contained in:
Flatlogic Bot 2026-04-14 08:07:36 +00:00
parent 85d628fc54
commit 87250b8bfe
3 changed files with 261 additions and 81 deletions

View File

@ -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<string|null>} Reversed video storage key or null
* @returns {Promise<string|null>}
*/
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<string|null>} 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);
}
}
/**

View File

@ -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<string | null>(null);
const lastLoadedBlobUrlRef = useRef<string | null>(null);
const lastLoadedSourceUrlRef = useRef<string | null>(null);
const didTryFallbackRef = useRef(false);
const didTryDecodeRetryRef = useRef(false);
const currentPlayableUrlRef = useRef<string | null>(null);
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | 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');
};

View File

@ -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<string, unknown>): 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<Record<string, unknown> | 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;