39948-vm/backend/src/services/tour_pages.js
2026-05-28 07:19:36 +00:00

764 lines
22 KiB
JavaScript

/**
* 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<string|null>}
*/
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<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);
}
}
/**
* Generate reversed video variant for an asset
* @returns {Promise<string|null>} 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<Object|Array>} 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<Object>} 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;