fixed cross-browser issues, changed reverse playback approach

This commit is contained in:
Dmitri 2026-04-13 19:05:31 +04:00
parent f445066706
commit 540b7b6aa7
18 changed files with 1028 additions and 967 deletions

View File

@ -27,6 +27,7 @@
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"express": "4.18.2", "express": "4.18.2",
"express-validator": "^7.0.0", "express-validator": "^7.0.0",
"fluent-ffmpeg": "^2.1.3",
"formidable": "1.2.2", "formidable": "1.2.2",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"joi": "^17.13.0", "joi": "^17.13.0",

View File

@ -72,6 +72,7 @@ class Asset_variantsDBApi extends GenericDBApi {
id: data.id || undefined, id: data.id || undefined,
variant_type: data.variant_type || null, variant_type: data.variant_type || null,
cdn_url: data.cdn_url || null, cdn_url: data.cdn_url || null,
storage_key: data.storage_key || null,
width_px: data.width_px || null, width_px: data.width_px || null,
height_px: data.height_px || null, height_px: data.height_px || null,
size_mb: data.size_mb || null, size_mb: data.size_mb || null,

View File

@ -213,6 +213,41 @@ class GenericDBApi {
return record; 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 = {}) { static async deleteByIds(ids, options = {}) {
const currentUser = options.currentUser || { id: null }; const currentUser = options.currentUser || { id: null };
const transaction = options.transaction; const transaction = options.transaction;

View File

@ -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)
},
};

View File

@ -23,9 +23,16 @@ module.exports = function (sequelize, DataTypes) {
'mp4_high', 'mp4_high',
'original', 'original',
'reversed',
], ],
}, },
storage_key: {
type: DataTypes.TEXT,
allowNull: true,
},
cdn_url: { cdn_url: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
validate: { validate: {

View File

@ -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<Buffer>}
*/
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 // Chunked Upload Session Management
// ============================================================================ // ============================================================================
@ -592,10 +664,16 @@ const generatePresignedUrls = async (urls) => {
module.exports = { module.exports = {
// Provider detection // Provider detection
getFileStorageProvider, getFileStorageProvider,
getS3Provider,
getLocalProvider,
getGCloudBucket,
// Unified interface // Unified interface
uploadFile, uploadFile,
downloadFile, downloadFile,
deleteFile, deleteFile,
// Buffer operations
downloadToBuffer,
uploadBuffer,
// Session-based chunked uploads // Session-based chunked uploads
initUploadSession, initUploadSession,
getUploadSession, getUploadSession,

View File

@ -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', 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<boolean>}
*/
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<string|null>} 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<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) {
// 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;

View File

@ -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<Buffer>} 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<boolean>}
*/
async function isFFmpegAvailable() {
return new Promise((resolve) => {
ffmpeg.getAvailableFormats((err) => {
resolve(!err);
});
});
}
module.exports = {
reverseVideo,
isFFmpegAvailable,
};

View File

@ -77,7 +77,7 @@
"@smithy/util-utf8" "^2.0.0" "@smithy/util-utf8" "^2.0.0"
tslib "^2.6.2" 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" version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz" resolved "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz"
integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==
@ -93,7 +93,7 @@
dependencies: dependencies:
tslib "^2.6.2" 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" version "5.2.0"
resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz" resolved "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz"
integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== 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" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.9.0: acorn@^8.9.0:
version "8.15.0" version "8.16.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
agent-base@^7.1.0, agent-base@^7.1.2: integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
version "7.1.4"
resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz"
integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==
agent-base@6: agent-base@6:
version "6.0.2" version "6.0.2"
@ -1557,8 +1554,15 @@ agent-base@6:
dependencies: dependencies:
debug "4" 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: 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: dependencies:
fast-deep-equal "^3.1.1" fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0" fast-json-stable-stringify "^2.0.0"
@ -1722,6 +1726,11 @@ async-retry@^1.3.3:
dependencies: dependencies:
retry "0.13.1" 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: asynckit@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" 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" balanced-match "^1.0.0"
concat-map "0.0.1" concat-map "0.0.1"
brace-expansion@^2.0.1: 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==
dependencies:
balanced-match "^1.0.0"
brace-expansion@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"
integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==
@ -1960,22 +1962,7 @@ chalk@^4.0.0, chalk@^4.1.0:
ansi-styles "^4.1.0" ansi-styles "^4.1.0"
supports-color "^7.1.0" supports-color "^7.1.0"
chokidar@^3.5.2: 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==
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:
version "3.6.0" version "3.6.0"
resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz" resolved "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz"
integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
@ -2030,6 +2017,11 @@ combined-stream@^1.0.8:
dependencies: dependencies:
delayed-stream "~1.0.0" 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: commander@^10.0.0:
version "10.0.1" version "10.0.1"
resolved "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz" 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" resolved "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz"
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== 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: concat-map@0.0.1:
version "0.0.1" version "0.0.1"
resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" 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" ini "^1.3.4"
proto-list "~1.2.1" 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" version "0.5.4"
resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz" resolved "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz"
integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== 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" resolved "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz"
integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== 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: debug@^3.2.7:
version "3.2.7" version "3.2.7"
resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz"
@ -2185,34 +2186,13 @@ debug@^3.2.7:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4, debug@^4.3.4, debug@^4.3.5, debug@4: debug@^4.3.1, debug@^4.3.2:
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:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies: dependencies:
ms "^2.1.3" 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: decamelize@^4.0.0:
version "4.0.0" version "4.0.0"
resolved "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz" 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" resolved "https://registry.npmjs.org/denque/-/denque-1.5.1.tgz"
integrity sha512-XwE+iZ4D6ZUB7mfYRMb5wByE8L74HCn30FBN7sWnXksWc1LO1bPDl67pBR9o/kC4z/xSNAwkMYcGgqDV3BE3Hw== 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: depd@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== 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: destroy@1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" 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" resolved "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz"
integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A== 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: doctrine@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz" resolved "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz"
@ -2283,13 +2270,6 @@ doctrine@^2.1.0:
dependencies: dependencies:
esutils "^2.0.2" 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: dotenv@^16.4.0:
version "16.6.1" version "16.6.1"
resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz" 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" resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz"
integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== 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" version "1.0.11"
resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== 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" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== 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" version "8.57.1"
resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz"
integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==
@ -2715,7 +2695,7 @@ express-validator@^7.0.0:
lodash "^4.17.21" lodash "^4.17.21"
validator "~13.15.23" validator "~13.15.23"
"express@>=4.0.0 || >=5.0.0-beta", express@4.18.2: express@4.18.2:
version "4.18.2" version "4.18.2"
resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz" resolved "https://registry.npmjs.org/express/-/express-4.18.2.tgz"
integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==
@ -2789,7 +2769,7 @@ fast-xml-builder@^1.1.4:
dependencies: dependencies:
path-expression-matcher "^1.1.3" 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" version "5.5.8"
resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz"
integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ== integrity sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==
@ -2855,7 +2835,17 @@ flat@^5.0.2:
integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
flatted@^3.2.9: 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: follow-redirects@^1.15.11:
version "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" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
fresh@^0.5.2, fresh@0.5.2: fresh@0.5.2, fresh@^0.5.2:
version "0.5.2" version "0.5.2"
resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz"
integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== 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" resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== 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: function-bind@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" 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" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: 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==
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:
version "1.2.4" version "1.2.4"
resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz"
integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
@ -3095,6 +3068,18 @@ glob-parent@~5.1.2:
dependencies: dependencies:
is-glob "^4.0.1" 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: glob@^10.4.2:
version "10.5.0" version "10.5.0"
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
@ -3130,18 +3115,6 @@ glob@^8.1.0:
minimatch "^5.0.1" minimatch "^5.0.1"
once "^1.3.0" 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: globals@^13.19.0:
version "13.24.0" version "13.24.0"
resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" 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" agent-base "^7.1.2"
debug "4" 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: iconv-lite@0.4.24:
version "0.4.24" version "0.4.24"
resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
@ -3347,6 +3306,13 @@ iconv-lite@0.4.24:
dependencies: dependencies:
safer-buffer ">= 2.1.2 < 3" 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: ieee754@^1.2.1:
version "1.2.1" version "1.2.1"
resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz"
@ -3388,7 +3354,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" 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" version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -3685,16 +3651,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3:
dependencies: dependencies:
has-symbols "^1.0.2" has-symbols "^1.0.2"
is-symbol@^1.0.4: 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==
dependencies:
call-bound "^1.0.2"
has-symbols "^1.1.0"
safe-regex-test "^1.1.0"
is-symbol@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz"
integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== 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" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz"
integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== 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: merge-descriptors@1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz" resolved "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz"
integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== 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: methods@^1.1.2, methods@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" 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: dependencies:
mime-db "1.52.0" 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" version "1.6.0"
resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
@ -4084,14 +4041,7 @@ minimatch@^5.0.1, minimatch@^5.1.6:
dependencies: dependencies:
brace-expansion "^2.0.1" brace-expansion "^2.0.1"
minimatch@^9.0.1: 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==
dependencies:
brace-expansion "^2.0.2"
minimatch@^9.0.4:
version "9.0.9" version "9.0.9"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz"
integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==
@ -4141,16 +4091,11 @@ moment-timezone@^0.5.43:
dependencies: dependencies:
moment "^2.29.4" moment "^2.29.4"
moment@^2.29.4, moment@2.30.1: moment@2.30.1, moment@^2.29.4:
version "2.30.1" version "2.30.1"
resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz" resolved "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz"
integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== 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: ms@2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" 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" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 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: multer@^2.0.0:
version "2.1.1" version "2.1.1"
resolved "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz" 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-docker "^2.1.1"
is-wsl "^2.2.0" 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: optionator@^0.9.3:
version "0.9.4" version "0.9.4"
resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
@ -4456,7 +4401,7 @@ passport-microsoft@^2.0.0:
dependencies: dependencies:
passport-oauth2 "1.8.0" 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" version "1.8.0"
resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz"
integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==
@ -4467,7 +4412,7 @@ passport-oauth2@^1.1.2, passport-oauth2@1.8.0:
uid2 "0.0.x" uid2 "0.0.x"
utils-merge "1.x.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" version "1.0.0"
resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA== integrity sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==
@ -4567,7 +4512,7 @@ pg-types@2.2.0:
postgres-date "~1.0.4" postgres-date "~1.0.4"
postgres-interval "^1.1.0" postgres-interval "^1.1.0"
pg@^8.20.0, pg@>=8.0: pg@^8.20.0:
version "8.20.0" version "8.20.0"
resolved "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz" resolved "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz"
integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA== integrity sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==
@ -4934,7 +4879,7 @@ safe-array-concat@^1.1.3:
has-symbols "^1.1.0" has-symbols "^1.1.0"
isarray "^2.0.5" 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" version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 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" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
semver@^7.5.3: 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==
semver@^7.5.4:
version "7.7.4" version "7.7.4"
resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== 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" resolved "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz"
integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg== integrity sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==
sequelize@^6.37.0, "sequelize@>= 4": sequelize@^6.37.0:
version "6.37.8" version "6.37.8"
resolved "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz" resolved "https://registry.npmjs.org/sequelize/-/sequelize-6.37.8.tgz"
integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw== integrity sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==
@ -5258,13 +5198,6 @@ streamsearch@^1.1.0:
resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== 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": "string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3" version "4.2.3"
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" 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" define-properties "^1.2.1"
es-object-atoms "^1.0.0" 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": "strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" 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" resolved "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz"
integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== 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" version "1.0.0"
resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== 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" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== 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" version "1.0.1"
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 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" resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
uuid@^9.0.0: 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==
uuid@^9.0.1:
version "9.0.1" version "9.0.1"
resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz" resolved "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz"
integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==
@ -5799,18 +5734,7 @@ which-collection@^1.0.2:
is-weakmap "^2.0.2" is-weakmap "^2.0.2"
is-weakset "^2.0.3" is-weakset "^2.0.3"
which-typed-array@^1.1.14: 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==
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:
version "1.1.15" version "1.1.15"
resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz"
integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== 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" gopd "^1.2.0"
has-tostringtag "^1.0.2" 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: which@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"

View File

@ -101,7 +101,8 @@ export default function RuntimePresentation({
targetPageId: string; targetPageId: string;
videoUrl: string; videoUrl: string;
storageKey: string; storageKey: string;
isReverse: boolean; isBack: boolean;
reverseVideoUrl?: string;
} | null>(null); } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const [isBackgroundReady, setIsBackgroundReady] = useState(true); const [isBackgroundReady, setIsBackgroundReady] = useState(true);
@ -186,10 +187,11 @@ export default function RuntimePresentation({
? { ? {
videoUrl: transitionPreview.videoUrl, videoUrl: transitionPreview.videoUrl,
storageKey: transitionPreview.storageKey, storageKey: transitionPreview.storageKey,
reverseMode: transitionPreview.isReverse ? 'reverse' : 'none', reverseMode: transitionPreview.reverseVideoUrl ? 'separate' : 'none',
reverseVideoUrl: transitionPreview.reverseVideoUrl,
targetPageId: transitionPreview.targetPageId, targetPageId: transitionPreview.targetPageId,
displayName: 'Transition', displayName: 'Transition',
isBack: transitionPreview.isReverse, // Pass through for history management isBack: transitionPreview.isBack,
} }
: null, : null,
onComplete: async (targetPageId, isBack) => { onComplete: async (targetPageId, isBack) => {
@ -332,6 +334,7 @@ export default function RuntimePresentation({
targetPageId: string, targetPageId: string,
transitionVideoUrl?: string, transitionVideoUrl?: string,
isBack = false, isBack = false,
reverseVideoUrl?: string,
) => { ) => {
const targetPage = pages.find((p) => p.id === targetPageId); const targetPage = pages.find((p) => p.id === targetPageId);
if (!targetPage) return; if (!targetPage) return;
@ -347,7 +350,10 @@ export default function RuntimePresentation({
targetPageId, targetPageId,
videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl), videoUrl: resolveAssetPlaybackUrl(transitionVideoUrl),
storageKey: transitionVideoUrl, // Raw storage path for cache lookup storageKey: transitionVideoUrl, // Raw storage path for cache lookup
isReverse: isBack, isBack,
reverseVideoUrl: reverseVideoUrl
? resolveAssetPlaybackUrl(reverseVideoUrl)
: undefined,
}); });
} else { } else {
// Direct navigation with crossfade effect: // Direct navigation with crossfade effect:
@ -399,6 +405,7 @@ export default function RuntimePresentation({
navTarget.pageId, navTarget.pageId,
navTarget.transitionVideoUrl, navTarget.transitionVideoUrl,
navTarget.isBack, navTarget.isBack,
navTarget.reverseVideoUrl,
); );
} }
}, },

View File

@ -56,15 +56,6 @@ export const PRELOAD_CONFIG = {
constructorMaxDepth: 1, // Same as maxDepth for constructor preview 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) // Partial preload settings (online mode only)
// Download only first N bytes of videos/audio for faster Phase 1 completion // Download only first N bytes of videos/audio for faster Phase 1 completion
// Playback uses presigned URL directly (browser handles remaining buffering) // Playback uses presigned URL directly (browser handles remaining buffering)

View File

@ -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<HTMLVideoElement | null>;
onComplete: () => void;
preloadedUrls?: Set<string>;
videoUrl?: string;
storageKey?: string; // Raw storage path for preload detection
getCachedBlobUrl?: (url: string) => Promise<string | null>;
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<void>;
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<boolean> {
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<number | null>(null);
const didFinishRef = useRef(false);
const cleanupFnsRef = useRef<Array<() => 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<void>((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<void>((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,
};
}

View File

@ -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 { import {
useCallback, useCallback,
useEffect, useEffect,
@ -17,9 +25,8 @@ import {
extractStoragePath, extractStoragePath,
} from '../lib/assetUrl'; } from '../lib/assetUrl';
import { downloadManager } from '../lib/offline/DownloadManager'; import { downloadManager } from '../lib/offline/DownloadManager';
import { useReversePlayback } from './useReversePlayback';
export type ReverseMode = 'none' | 'reverse' | 'separate'; export type ReverseMode = 'none' | 'separate';
export interface TransitionConfig { export interface TransitionConfig {
videoUrl: string; videoUrl: string;
@ -61,7 +68,6 @@ export type PlaybackPhase =
| 'idle' | 'idle'
| 'preparing' | 'preparing'
| 'playing' | 'playing'
| 'reversing'
| 'finishing' | 'finishing'
| 'completed'; | 'completed';
@ -79,19 +85,8 @@ const DEFAULT_TIMEOUTS = {
hardTimeoutMs: 45000, hardTimeoutMs: 45000,
}; };
function getBufferedEnd(video: HTMLVideoElement): number { function shouldLoadViaBlob(url: string, useBlobUrlOption?: boolean): boolean {
return video.buffered.length > 0
? video.buffered.end(video.buffered.length - 1)
: 0;
}
function shouldLoadViaBlob(
url: string,
reverseMode: ReverseMode,
useBlobUrlOption?: boolean,
): boolean {
if (useBlobUrlOption === false) return false; if (useBlobUrlOption === false) return false;
if (reverseMode === 'reverse') return true;
if (useBlobUrlOption === true) return true; if (useBlobUrlOption === true) return true;
try { 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<void> { async function waitForImages(urls: string[], timeoutMs = 2000): Promise<void> {
if (urls.length === 0) return; if (urls.length === 0) return;
@ -155,13 +143,10 @@ export function useTransitionPlayback(
const playbackStartMs = const playbackStartMs =
customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs; customTimeouts?.playbackStartMs ?? DEFAULT_TIMEOUTS.playbackStartMs;
const durationBufferMs =
customTimeouts?.durationBufferMs ?? DEFAULT_TIMEOUTS.durationBufferMs;
const hardTimeoutMs = const hardTimeoutMs =
customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs; customTimeouts?.hardTimeoutMs ?? DEFAULT_TIMEOUTS.hardTimeoutMs;
const [phase, setPhase] = useState<PlaybackPhase>('idle'); const [phase, setPhase] = useState<PlaybackPhase>('idle');
const [isReverseBufferingLocal, setIsReverseBufferingLocal] = useState(false);
const didFinishRef = useRef(false); const didFinishRef = useRef(false);
const didStartPlaybackRef = useRef(false); const didStartPlaybackRef = useRef(false);
@ -184,14 +169,26 @@ export function useTransitionPlayback(
const transitionRef = useRef(transition); const transitionRef = useRef(transition);
const featuresRef = useRef(features); const featuresRef = useRef(features);
const preloadRef = useRef(preload); const preloadRef = useRef(preload);
const startReverseRef = useRef<(() => Promise<void>) | 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(() => { const sourceUrl = useMemo(() => {
if (!transition) return ''; if (!transition) return '';
return transition.reverseMode === 'separate' && transition.reverseVideoUrl // Use reversed video if this is back navigation with a separate reversed video
? transition.reverseVideoUrl if (transition.isBack && transition.reverseVideoUrl) {
: transition.videoUrl; 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]); }, [transition]);
const clearTimers = useCallback(() => { const clearTimers = useCallback(() => {
@ -226,7 +223,6 @@ export function useTransitionPlayback(
if (video) { if (video) {
video.pause(); video.pause();
// Seek back slightly to ensure last frame is visible // Seek back slightly to ensure last frame is visible
// Some browsers show black after 'ended' event when currentTime === duration
if ( if (
video.duration && video.duration &&
Number.isFinite(video.duration) && Number.isFinite(video.duration) &&
@ -279,43 +275,17 @@ export function useTransitionPlayback(
[finishPlayback], [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(() => { useEffect(() => {
onCompleteRef.current = onComplete; onCompleteRef.current = onComplete;
onErrorRef.current = onError; onErrorRef.current = onError;
transitionRef.current = transition; transitionRef.current = transition;
featuresRef.current = features; featuresRef.current = features;
preloadRef.current = preload; preloadRef.current = preload;
startReverseRef.current = startReverse;
stopReverseRef.current = stopReverse;
}); });
useEffect(() => {
setIsReverseBufferingLocal(isReverseBuffering);
}, [isReverseBuffering]);
const cancel = useCallback(() => { const cancel = useCallback(() => {
if (phase === 'idle') return; if (phase === 'idle') return;
clearTimers(); clearTimers();
stopReverseRef.current?.();
didFinishRef.current = true; didFinishRef.current = true;
setPhase('idle'); setPhase('idle');
const video = videoRef.current; const video = videoRef.current;
@ -338,12 +308,12 @@ export function useTransitionPlayback(
return; return;
} }
// Include reverseMode in the key so same video can play forward then reverse // Include isBack in the key so same video can play forward or as reversed
const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`; const sourceKey = `${sourceUrl}|${currentTransition.isBack ? 'back' : 'forward'}`;
if (activeSourceUrlRef.current === sourceKey) { if (activeSourceUrlRef.current === sourceKey) {
logger.info('Skipping duplicate effect for same source', { logger.info('Skipping duplicate effect for same source', {
sourceUrl, sourceUrl,
reverseMode: currentTransition.reverseMode, isBack: currentTransition.isBack,
}); });
return; return;
} }
@ -356,7 +326,6 @@ export function useTransitionPlayback(
currentPlayableUrlRef.current = null; currentPlayableUrlRef.current = null;
setPhase('preparing'); setPhase('preparing');
const isReverseMode = currentTransition.reverseMode === 'reverse';
const configuredDurationSec = Number(currentTransition.durationSec); const configuredDurationSec = Number(currentTransition.durationSec);
const getMediaErrorDetails = () => { const getMediaErrorDetails = () => {
@ -376,7 +345,7 @@ export function useTransitionPlayback(
networkState: video.networkState, networkState: video.networkState,
duration: video.duration, duration: video.duration,
configuredDurationSec, configuredDurationSec,
reverseMode: currentTransition.reverseMode, isBack: currentTransition.isBack,
mediaError: getMediaErrorDetails(), mediaError: getMediaErrorDetails(),
error: error instanceof Error ? error : { error }, error: error instanceof Error ? error : { error },
}); });
@ -390,9 +359,7 @@ export function useTransitionPlayback(
) { ) {
return; return;
} }
// Finish slightly BEFORE the video ends to ensure last frame is visible const finishBeforeEndMs = 50;
// and prevent browser-specific 'ended' event quirks (black frame)
const finishBeforeEndMs = 50; // 50ms before video naturally ends
const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs); const finishMs = Math.max(100, durationSec * 1000 - finishBeforeEndMs);
finishTimerRef.current = setTimeout( finishTimerRef.current = setTimeout(
() => finishPlayback('duration-timer'), () => finishPlayback('duration-timer'),
@ -402,34 +369,33 @@ export function useTransitionPlayback(
const attemptPlay = () => { const attemptPlay = () => {
video.play().catch((playError) => { video.play().catch((playError) => {
if (!isReverseMode) { logIssue('play-failed', playError);
logIssue('play-failed', playError);
}
}); });
}; };
const resolvePlayableSource = async (): Promise<string> => { const resolvePlayableSource = async (): Promise<string> => {
// 1. Try storage key lookup first (most reliable for cache hits)
const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl; const getReadyBlobUrl = preloadRef.current?.getReadyBlobUrl;
const storageKey = currentTransition.storageKey; const currentStorageKey = storageKey;
if (getReadyBlobUrl && storageKey) {
const readyUrl = getReadyBlobUrl(storageKey); // 1. Try storage key lookup first (most reliable for cache hits)
if (getReadyBlobUrl && currentStorageKey) {
const readyUrl = getReadyBlobUrl(currentStorageKey);
if (readyUrl) { if (readyUrl) {
logger.info('Using ready blob URL from storage key', { logger.info('Using ready blob URL from storage key', {
storageKey: storageKey.slice(-50), storageKey: currentStorageKey.slice(-50),
}); });
return readyUrl; 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; const getCachedBlobUrl = preloadRef.current?.getCachedBlobUrl;
if (getCachedBlobUrl && storageKey) { if (getCachedBlobUrl && currentStorageKey) {
try { try {
const cachedBlobUrl = await getCachedBlobUrl(storageKey); const cachedBlobUrl = await getCachedBlobUrl(currentStorageKey);
if (cachedBlobUrl) { if (cachedBlobUrl) {
logger.info('Using cached blob URL from storage key', { logger.info('Using cached blob URL from storage key', {
storageKey: storageKey.slice(-50), storageKey: currentStorageKey.slice(-50),
}); });
lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl; 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 ( if (
lastLoadedBlobUrlRef.current && lastLoadedBlobUrlRef.current &&
lastLoadedSourceUrlRef.current === sourceUrl lastLoadedSourceUrlRef.current === sourceUrl
@ -458,7 +424,6 @@ export function useTransitionPlayback(
const needsBlobUrl = shouldLoadViaBlob( const needsBlobUrl = shouldLoadViaBlob(
sourceUrl, sourceUrl,
currentTransition.reverseMode,
featuresRef.current?.useBlobUrl, featuresRef.current?.useBlobUrl,
); );
@ -483,7 +448,7 @@ export function useTransitionPlayback(
const cachedBlobUrl = await getCachedBlobUrl(sourceUrl); const cachedBlobUrl = await getCachedBlobUrl(sourceUrl);
if (cachedBlobUrl) { if (cachedBlobUrl) {
logger.info('Using preloaded blob URL from cache', { logger.info('Using preloaded blob URL from cache', {
reverseMode: currentTransition.reverseMode, isBack: currentTransition.isBack,
}); });
lastLoadedBlobUrlRef.current = cachedBlobUrl; lastLoadedBlobUrlRef.current = cachedBlobUrl;
lastLoadedSourceUrlRef.current = sourceUrl; lastLoadedSourceUrlRef.current = sourceUrl;
@ -496,17 +461,13 @@ export function useTransitionPlayback(
} }
} }
// 6. Fetch video as blob with presigned URL support // 6. Fetch video as blob
// Follows usePageSwitch.loadImageWithFallback pattern: logger.info('Fetching video as blob', {
// Try presigned URL first (SW can intercept for caching), fallback to proxy if it fails isBack: currentTransition.isBack,
logger.info('Fetching video as blob for seeking support', {
reverseMode: currentTransition.reverseMode,
}); });
// Re-resolve URL to get presigned URL if now available const freshUrl = currentStorageKey
// (may have been cached since transition started) ? resolveAssetPlaybackUrl(currentStorageKey)
const freshUrl = storageKey
? resolveAssetPlaybackUrl(storageKey)
: sourceUrl; : sourceUrl;
const token = const token =
@ -514,7 +475,6 @@ export function useTransitionPlayback(
? localStorage.getItem('token') || '' ? localStorage.getItem('token') || ''
: ''; : '';
// Helper: Fetch video and return blob URL, caching for next time
const fetchVideoAsBlob = async (url: string): Promise<string> => { const fetchVideoAsBlob = async (url: string): Promise<string> => {
logger.info('Fetching video from URL', { logger.info('Fetching video from URL', {
url: url.slice(0, 80), url: url.slice(0, 80),
@ -524,14 +484,13 @@ export function useTransitionPlayback(
const response = await axios.get(url, { const response = await axios.get(url, {
responseType: 'blob', responseType: 'blob',
headers: token ? { Authorization: `Bearer ${token}` } : undefined, headers: token ? { Authorization: `Bearer ${token}` } : undefined,
baseURL: '', // Prevent double /api/ prefix when using buildProxyUrl baseURL: '',
}); });
const blob = response.data as Blob; const blob = response.data as Blob;
// Cache for next time using existing DownloadManager pattern if (currentStorageKey) {
if (storageKey) { const normalizedKey = extractStoragePath(currentStorageKey);
const normalizedKey = extractStoragePath(storageKey);
const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, { const blobUrl = await downloadManager.cacheBlob(normalizedKey, blob, {
assetType: 'transition', assetType: 'transition',
}); });
@ -540,7 +499,6 @@ export function useTransitionPlayback(
return blobUrl; return blobUrl;
} }
// Fallback: create blob URL without caching
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
lastLoadedBlobUrlRef.current = blobUrl; lastLoadedBlobUrlRef.current = blobUrl;
lastLoadedSourceUrlRef.current = sourceUrl; lastLoadedSourceUrlRef.current = sourceUrl;
@ -551,16 +509,14 @@ export function useTransitionPlayback(
}; };
try { try {
// Try fetching with potentially presigned URL (SW can intercept if S3)
return await fetchVideoAsBlob(freshUrl); return await fetchVideoAsBlob(freshUrl);
} catch (error) { } catch (error) {
// If presigned URL failed and we have storage key, retry with proxy if (currentStorageKey && isPresignedUrl(freshUrl)) {
if (storageKey && isPresignedUrl(freshUrl)) {
logger.info('Presigned URL failed, retrying with proxy', { logger.info('Presigned URL failed, retrying with proxy', {
storageKey: storageKey.slice(-40), storageKey: currentStorageKey.slice(-40),
}); });
markPresignedUrlFailed(storageKey); markPresignedUrlFailed(currentStorageKey);
const proxyUrl = buildProxyUrl(storageKey); const proxyUrl = buildProxyUrl(currentStorageKey);
return await fetchVideoAsBlob(proxyUrl); return await fetchVideoAsBlob(proxyUrl);
} }
throw error; throw error;
@ -569,7 +525,7 @@ export function useTransitionPlayback(
const loadAndPlay = async () => { const loadAndPlay = async () => {
logger.info('loadAndPlay called', { logger.info('loadAndPlay called', {
reverseMode: currentTransition.reverseMode, isBack: currentTransition.isBack,
sourceUrl, sourceUrl,
}); });
@ -584,47 +540,18 @@ export function useTransitionPlayback(
if (didFinishRef.current) return; if (didFinishRef.current) return;
video.pause(); 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; video.src = playableSourceUrl;
// For reverse mode, seek to a large value (browser clamps to duration) video.currentTime = 0;
// This prevents showing frame 0 while loading
video.currentTime = isReverseMode ? 999999 : 0;
video.load(); video.load();
lastLoadedSourceUrlRef.current = playableSourceUrl; lastLoadedSourceUrlRef.current = playableSourceUrl;
currentPlayableUrlRef.current = playableSourceUrl; currentPlayableUrlRef.current = playableSourceUrl;
// Only attempt play for forward playback attemptPlay();
// For reverse mode, wait for canplaythrough to trigger startReverse()
if (!isReverseMode) {
attemptPlay();
}
startWatchdogTimerRef.current = setTimeout(() => { startWatchdogTimerRef.current = setTimeout(() => {
if (didStartPlaybackRef.current || didFinishRef.current) return; if (didStartPlaybackRef.current || didFinishRef.current) return;
logIssue('playback-start-slow'); logIssue('playback-start-slow');
if (isReverseMode) { attemptPlay();
didStartPlaybackRef.current = true;
setPhase('reversing');
void startReverseRef.current?.();
} else {
attemptPlay();
}
}, playbackStartMs); }, playbackStartMs);
} catch (error) { } catch (error) {
logIssue('source-prepare-failed', error); logIssue('source-prepare-failed', error);
@ -634,62 +561,24 @@ export function useTransitionPlayback(
const onLoadedMetadata = () => { const onLoadedMetadata = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
if (!isReverseMode) { video.currentTime = 0;
video.currentTime = 0; attemptPlay();
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?.();
}
}; };
const onCanPlay = () => { const onCanPlay = () => {
if (didFinishRef.current) return; if (didFinishRef.current) return;
if (isReverseMode) return; // Don't play for reverse mode
attemptPlay(); attemptPlay();
}; };
const onPlaying = () => { const onPlaying = () => {
logger.info('onPlaying fired', { logger.info('onPlaying fired', {
reverseMode: currentTransition.reverseMode, isBack: currentTransition.isBack,
didStartPlayback: didStartPlaybackRef.current, didStartPlayback: didStartPlaybackRef.current,
didFinish: didFinishRef.current, didFinish: didFinishRef.current,
}); });
if (didFinishRef.current) return; 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; didStartPlaybackRef.current = true;
setPhase('playing'); setPhase('playing');
@ -698,23 +587,19 @@ export function useTransitionPlayback(
startWatchdogTimerRef.current = null; startWatchdogTimerRef.current = null;
} }
if (!isReverseMode) { const mediaDurationSec = Number(video.duration);
const mediaDurationSec = Number(video.duration); const durationSec =
const durationSec = Number.isFinite(configuredDurationSec) && configuredDurationSec > 0
Number.isFinite(configuredDurationSec) && configuredDurationSec > 0 ? configuredDurationSec
? configuredDurationSec : Number.isFinite(mediaDurationSec) && mediaDurationSec > 0
: Number.isFinite(mediaDurationSec) && mediaDurationSec > 0 ? mediaDurationSec
? mediaDurationSec : NaN;
: NaN; if (Number.isFinite(durationSec) && durationSec > 0) {
if (Number.isFinite(durationSec) && durationSec > 0) { scheduleFinishByDuration(durationSec);
scheduleFinishByDuration(durationSec);
}
} }
}; };
const onEnded = () => { const onEnded = () => {
// For reverse mode, ignore 'ended' event - wait for reverse playback to complete
if (isReverseMode) return;
finishPlayback('ended'); finishPlayback('ended');
}; };
@ -722,7 +607,6 @@ export function useTransitionPlayback(
if (didFinishRef.current) return; if (didFinishRef.current) return;
logIssue('video-error'); logIssue('video-error');
// Safari video decode error recovery (MEDIA_ERR_DECODE = 3)
const errorCode = video.error?.code; const errorCode = video.error?.code;
if (errorCode === 3 && !didTryDecodeRetryRef.current) { if (errorCode === 3 && !didTryDecodeRetryRef.current) {
logger.info('Safari video decode error, attempting reload'); logger.info('Safari video decode error, attempting reload');
@ -732,7 +616,6 @@ export function useTransitionPlayback(
return; return;
} }
// Check if this is a presigned URL failure (likely CORS)
const currentUrl = currentPlayableUrlRef.current; const currentUrl = currentPlayableUrlRef.current;
if ( if (
currentUrl && currentUrl &&
@ -743,14 +626,11 @@ export function useTransitionPlayback(
url: currentUrl.slice(0, 80), 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; const originalVideoUrl = currentTransition.videoUrl;
if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) { if (originalVideoUrl && isRelativeStoragePath(originalVideoUrl)) {
markPresignedUrlFailed(originalVideoUrl); markPresignedUrlFailed(originalVideoUrl);
} }
// Get proxy fallback URL using storage key
const videoStorageKey = currentTransition.videoUrl; const videoStorageKey = currentTransition.videoUrl;
if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) { if (videoStorageKey && isRelativeStoragePath(videoStorageKey)) {
const fallbackUrl = buildProxyUrl(videoStorageKey); const fallbackUrl = buildProxyUrl(videoStorageKey);
@ -780,7 +660,6 @@ export function useTransitionPlayback(
}; };
video.addEventListener('loadedmetadata', onLoadedMetadata); video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('canplaythrough', onCanPlayThrough);
video.addEventListener('canplay', onCanPlay); video.addEventListener('canplay', onCanPlay);
video.addEventListener('playing', onPlaying); video.addEventListener('playing', onPlaying);
video.addEventListener('ended', onEnded); video.addEventListener('ended', onEnded);
@ -798,7 +677,6 @@ export function useTransitionPlayback(
return () => { return () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata); video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('canplaythrough', onCanPlayThrough);
video.removeEventListener('canplay', onCanPlay); video.removeEventListener('canplay', onCanPlay);
video.removeEventListener('playing', onPlaying); video.removeEventListener('playing', onPlaying);
video.removeEventListener('ended', onEnded); video.removeEventListener('ended', onEnded);
@ -806,11 +684,11 @@ export function useTransitionPlayback(
video.removeEventListener('abort', onAbort); video.removeEventListener('abort', onAbort);
video.removeEventListener('stalled', onStalled); video.removeEventListener('stalled', onStalled);
clearTimers(); clearTimers();
stopReverseRef.current?.();
}; };
}, [ }, [
sourceUrl, sourceUrl,
transition?.reverseMode, // Re-run effect when reverseMode changes (same video, different direction) storageKey,
transition?.isBack,
videoRef, videoRef,
playbackStartMs, playbackStartMs,
hardTimeoutMs, hardTimeoutMs,
@ -831,8 +709,8 @@ export function useTransitionPlayback(
return { return {
phase, phase,
isBuffering: isReverseBufferingLocal, isBuffering: false, // No longer have buffering from frame-stepping
isReversing, isReversing: false, // No longer support frame-stepping reverse
cancel, cancel,
forceComplete, forceComplete,
}; };

View File

@ -103,27 +103,24 @@ export function useTransitionPreview({
return; return;
} }
// Check for separate reverse video if needed // For back navigation, check if reversed video is available
if ( // (either manually uploaded or backend-generated)
direction === 'back' && if (direction === 'back' && !element.reverseVideoUrl) {
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
) {
onError?.( onError?.(
'Select back-transition asset or switch reverse mode to Auto Reverse.', 'Reversed video not available. Save the page to generate it.',
); );
return; 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 = { const previewState: TransitionPreviewState = {
videoUrl: element.transitionVideoUrl, videoUrl: element.transitionVideoUrl,
storageKey: element.transitionVideoUrl, // Raw storage path for cache lookup storageKey: element.transitionVideoUrl, // Raw storage path for cache lookup
reverseMode: reverseMode,
direction === 'forward'
? 'none'
: element.transitionReverseMode === 'separate_video'
? 'separate'
: 'reverse',
reverseVideoUrl: element.reverseVideoUrl, reverseVideoUrl: element.reverseVideoUrl,
reverseStorageKey: element.reverseVideoUrl, reverseStorageKey: element.reverseVideoUrl,
durationSec: element.transitionDurationSec, durationSec: element.transitionDurationSec,

View File

@ -175,7 +175,7 @@ export const getNavigationDirection = (
/** /**
* Check if transition is actively blocking navigation. * 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 transitionPhase - Current transition phase
* @param isBuffering - Whether video is buffering * @param isBuffering - Whether video is buffering
@ -185,14 +185,16 @@ export const isTransitionBlocking = (
transitionPhase: TransitionPhase, transitionPhase: TransitionPhase,
isBuffering: boolean, isBuffering: boolean,
): boolean => { ): boolean => {
const activePhases: TransitionPhase[] = ['preparing', 'playing', 'reversing']; const activePhases: TransitionPhase[] = ['preparing', 'playing'];
return activePhases.includes(transitionPhase) || isBuffering; return activePhases.includes(transitionPhase) || isBuffering;
}; };
/** /**
* Check if element has a playable transition. * Check if element has a playable transition.
* A transition is playable if it has a video URL, and for back navigation, * A transition is playable if:
* either supports reverse or has a separate reverse video. * - 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 element - Element with transition properties
* @param direction - Navigation direction * @param direction - Navigation direction
@ -201,7 +203,6 @@ export const isTransitionBlocking = (
export const hasPlayableTransition = ( export const hasPlayableTransition = (
element: { element: {
transitionVideoUrl?: string; transitionVideoUrl?: string;
transitionReverseMode?: string;
reverseVideoUrl?: string; reverseVideoUrl?: string;
}, },
direction: 'back' | 'forward' = 'forward', direction: 'back' | 'forward' = 'forward',
@ -210,12 +211,8 @@ export const hasPlayableTransition = (
return false; return false;
} }
// For back navigation with separate_video mode, need reverse video // For back navigation, require pre-reversed video (generated by backend)
if ( if (direction === 'back' && !element.reverseVideoUrl) {
direction === 'back' &&
element.transitionReverseMode === 'separate_video' &&
!element.reverseVideoUrl
) {
return false; return false;
} }

View File

@ -1205,11 +1205,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
? 'Create transition' ? 'Create transition'
: selectedElement?.label || 'Element editor'; : selectedElement?.label || 'Element editor';
if (backgroundImageSrc) { // Background image is rendered by CanvasBackground component (same as runtime)
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`; // No CSS background-image needed on canvas div
canvasBackgroundStyle.backgroundSize = 'cover';
canvasBackgroundStyle.backgroundPosition = 'center';
}
// Duration notes for UI display // Duration notes for UI display
const durationNotes = useMemo( const durationNotes = useMemo(

View File

@ -224,8 +224,12 @@ export interface CanvasElement extends BaseCanvasElement {
/** Target page slug for navigation - slugs are consistent across environments */ /** Target page slug for navigation - slugs are consistent across environments */
targetPageSlug?: string; targetPageSlug?: string;
transitionVideoUrl?: string; transitionVideoUrl?: string;
/** Storage key for the transition video (for cache lookup) */
transitionStorageKey?: string;
transitionReverseMode?: 'auto_reverse' | 'separate_video'; transitionReverseMode?: 'auto_reverse' | 'separate_video';
reverseVideoUrl?: string; 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 */ /** Back navigation mode: 'target_page' navigates to fixed slug, 'history' uses page history */
navBackMode?: 'target_page' | 'history'; navBackMode?: 'target_page' | 'history';
transitionDurationSec?: number; transitionDurationSec?: number;

View File

@ -15,9 +15,9 @@ export interface TransitionPreviewState {
videoUrl: string; videoUrl: string;
/** Raw storage path for cache lookup */ /** Raw storage path for cache lookup */
storageKey: string; storageKey: string;
/** Playback mode: none (forward), reverse (auto-reverse), separate (use reverseVideoUrl) */ /** Playback mode: none (forward), separate (use reverseVideoUrl for back navigation) */
reverseMode: 'none' | 'reverse' | 'separate'; reverseMode: 'none' | 'separate';
/** Resolved URL for separate reverse video */ /** Resolved URL for separate reverse video (pre-generated by backend) */
reverseVideoUrl?: string; reverseVideoUrl?: string;
/** Raw storage path for reverse video cache lookup */ /** Raw storage path for reverse video cache lookup */
reverseStorageKey?: string; reverseStorageKey?: string;
@ -95,7 +95,7 @@ export type TransitionPhase =
| 'idle' | 'idle'
| 'preparing' | 'preparing'
| 'playing' | 'playing'
| 'reversing' | 'finishing'
| 'completed'; | 'completed';
/** /**