Revert to version 3c8cf08
This commit is contained in:
parent
72f25886ec
commit
85d628fc54
@ -9,6 +9,8 @@ RUN yarn build
|
|||||||
|
|
||||||
|
|
||||||
FROM node:20.15.1-alpine
|
FROM node:20.15.1-alpine
|
||||||
|
# Install FFmpeg for video processing (reversed video generation)
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY backend/package.json backend/yarn.lock ./
|
COPY backend/package.json backend/yarn.lock ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN yarn install --pure-lockfile
|
||||||
|
|||||||
@ -11,16 +11,12 @@ WORKDIR /app/backend
|
|||||||
COPY backend/package.json backend/yarn.lock ./
|
COPY backend/package.json backend/yarn.lock ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN yarn install --pure-lockfile
|
||||||
|
|
||||||
FROM node:20.15.1-alpine AS app-shell-deps
|
|
||||||
RUN apk add --no-cache git
|
|
||||||
WORKDIR /app/app-shell
|
|
||||||
COPY app-shell/package.json app-shell/yarn.lock ./
|
|
||||||
RUN yarn install --pure-lockfile
|
|
||||||
|
|
||||||
# Nginx setup and application build
|
# Nginx setup and application build
|
||||||
FROM node:20.15.1-alpine AS build
|
FROM node:20.15.1-alpine AS build
|
||||||
RUN apk add --no-cache git nginx curl
|
RUN apk add --no-cache git nginx curl
|
||||||
RUN apk add --no-cache lsof procps
|
RUN apk add --no-cache lsof procps
|
||||||
|
# Install FFmpeg for video processing (reversed video generation)
|
||||||
|
RUN apk add --no-cache ffmpeg
|
||||||
RUN yarn global add concurrently
|
RUN yarn global add concurrently
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
@ -43,11 +39,9 @@ ENV PATH /root/.yarn/bin:/root/.config/yarn/global/node_modules/.bin:$PATH
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=frontend-deps /app/frontend /app/frontend
|
COPY --from=frontend-deps /app/frontend /app/frontend
|
||||||
COPY --from=backend-deps /app/backend /app/backend
|
COPY --from=backend-deps /app/backend /app/backend
|
||||||
COPY --from=app-shell-deps /app/app-shell /app/app-shell
|
|
||||||
|
|
||||||
COPY frontend /app/frontend
|
COPY frontend /app/frontend
|
||||||
COPY backend /app/backend
|
COPY backend /app/backend
|
||||||
COPY app-shell /app/app-shell
|
|
||||||
COPY docker /app/docker
|
COPY docker /app/docker
|
||||||
|
|
||||||
# Copy all files from root to /app
|
# Copy all files from root to /app
|
||||||
@ -68,8 +62,6 @@ EXPOSE 8080
|
|||||||
ENV NODE_ENV=dev_stage
|
ENV NODE_ENV=dev_stage
|
||||||
ENV FRONT_PORT=3001
|
ENV FRONT_PORT=3001
|
||||||
ENV BACKEND_PORT=3000
|
ENV BACKEND_PORT=3000
|
||||||
ENV APP_SHELL_PORT=4000
|
|
||||||
|
|
||||||
|
|
||||||
CMD ["sh", "-c", "\
|
CMD ["sh", "-c", "\
|
||||||
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \
|
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \
|
||||||
@ -80,6 +72,5 @@ CMD ["sh", "-c", "\
|
|||||||
while ! nc -z localhost ${BACKEND_PORT}; do \
|
while ! nc -z localhost ${BACKEND_PORT}; do \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done && \
|
done && \
|
||||||
echo 'Backend is up. Starting app_shell for Git check...' && \
|
echo 'Backend and frontend are up.' && \
|
||||||
yarn --cwd /app/app-shell start && \
|
|
||||||
wait $NGINX_PID"]
|
wait $NGINX_PID"]
|
||||||
@ -1,6 +1,8 @@
|
|||||||
FROM node:20.15.1-alpine
|
FROM node:20.15.1-alpine
|
||||||
|
|
||||||
RUN apk update && apk add bash
|
# Install bash and FFmpeg for video processing (reversed video generation)
|
||||||
|
RUN apk update && apk add --no-cache bash ffmpeg
|
||||||
|
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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: {
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
const AssetsDBApi = require('../db/api/assets');
|
const AssetsDBApi = require('../db/api/assets');
|
||||||
|
const Asset_variantsDBApi = require('../db/api/asset_variants');
|
||||||
const { createEntityService } = require('../factories/service.factory');
|
const { createEntityService } = require('../factories/service.factory');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
const { downloadToBuffer, uploadBuffer } = require('./file');
|
||||||
|
const videoProcessing = require('./videoProcessing');
|
||||||
|
const { logger } = require('../utils/logger');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Valid MIME type patterns for each asset type
|
* Valid MIME type patterns for each asset type
|
||||||
@ -68,11 +72,11 @@ const BaseService = createEntityService(AssetsDBApi, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assets Service with validation
|
* Assets Service with validation and video pre-processing
|
||||||
*/
|
*/
|
||||||
class AssetsService extends BaseService {
|
class AssetsService extends BaseService {
|
||||||
/**
|
/**
|
||||||
* Create asset with MIME type validation
|
* Create asset with MIME type validation and video pre-processing
|
||||||
*/
|
*/
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
// Validate asset_type and mime_type match
|
// Validate asset_type and mime_type match
|
||||||
@ -85,7 +89,99 @@ class AssetsService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Call parent create
|
// Call parent create
|
||||||
return super.create(data, currentUser);
|
const asset = await super.create(data, currentUser);
|
||||||
|
|
||||||
|
// Pre-generate reversed video for video assets (async, doesn't block response)
|
||||||
|
if (assetType === 'video' && data.storage_key) {
|
||||||
|
AssetsService.preGenerateReversedVideo(asset, currentUser).catch((err) => {
|
||||||
|
logger.error(
|
||||||
|
{ err, assetId: asset.id },
|
||||||
|
'Failed to pre-generate reversed video (non-blocking)',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-generate reversed video variant for a video asset.
|
||||||
|
* Runs asynchronously after asset creation - doesn't block the upload response.
|
||||||
|
* This ensures reversed videos are ready for instant use in transitions.
|
||||||
|
*
|
||||||
|
* @param {Object} asset - Created asset record
|
||||||
|
* @param {Object} currentUser - Current user context
|
||||||
|
*/
|
||||||
|
static async preGenerateReversedVideo(asset, currentUser) {
|
||||||
|
const log = logger.child({
|
||||||
|
assetId: asset.id,
|
||||||
|
operation: 'preGenerateReversed',
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('Starting pre-generation of reversed video');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if FFmpeg is available
|
||||||
|
const ffmpegAvailable = await videoProcessing.isFFmpegAvailable();
|
||||||
|
if (!ffmpegAvailable) {
|
||||||
|
log.warn('FFmpeg not available, skipping pre-generation');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if reversed variant already exists (shouldn't happen on create, but safety check)
|
||||||
|
const existingAsset = await AssetsDBApi.findBy({ id: asset.id });
|
||||||
|
const variants = existingAsset?.asset_variants_asset || [];
|
||||||
|
const existingReversed = variants.find((v) => v.variant_type === 'reversed');
|
||||||
|
|
||||||
|
if (existingReversed) {
|
||||||
|
log.debug('Reversed variant already exists');
|
||||||
|
return existingReversed.storage_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download original video to buffer
|
||||||
|
const storageKey = asset.storage_key;
|
||||||
|
log.info({ storageKey }, 'Downloading original video');
|
||||||
|
|
||||||
|
const originalBuffer = await downloadToBuffer(storageKey);
|
||||||
|
|
||||||
|
// Generate reversed video
|
||||||
|
log.info('Generating reversed video with FFmpeg');
|
||||||
|
const reversedBuffer = await videoProcessing.reverseVideo(
|
||||||
|
originalBuffer,
|
||||||
|
asset.original_file_name || 'video.mp4',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload reversed video to storage
|
||||||
|
const reversedKey = `assets/${asset.id}/reversed.mp4`;
|
||||||
|
log.info({ reversedKey }, 'Uploading reversed video');
|
||||||
|
|
||||||
|
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, sizeMb: (reversedBuffer.length / (1024 * 1024)).toFixed(2) },
|
||||||
|
'Pre-generated reversed video successfully',
|
||||||
|
);
|
||||||
|
|
||||||
|
return reversedKey;
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, 'Failed to pre-generate reversed video');
|
||||||
|
// Don't throw - this is a background operation
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,6 +1,542 @@
|
|||||||
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) {
|
||||||
|
// Process both forward elements AND back elements with their own transition
|
||||||
|
const isForward = TourPagesService.isForwardElementWithTarget(element);
|
||||||
|
const isBackWithTransition =
|
||||||
|
TourPagesService.isBackElement(element) &&
|
||||||
|
element.transitionVideoUrl;
|
||||||
|
|
||||||
|
log.debug({
|
||||||
|
pageId: page.id,
|
||||||
|
elementType: element.type,
|
||||||
|
navType: element.navType,
|
||||||
|
isForward,
|
||||||
|
isBackWithTransition,
|
||||||
|
hasTransitionVideo: Boolean(element.transitionVideoUrl),
|
||||||
|
hasReverseVideo: Boolean(element.reverseVideoUrl),
|
||||||
|
targetPageSlug: element.targetPageSlug,
|
||||||
|
targetPageId: element.targetPageId,
|
||||||
|
}, 'Checking element in regeneration');
|
||||||
|
|
||||||
|
// Skip if neither forward nor back-with-transition
|
||||||
|
if (!isForward && !isBackWithTransition) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!element.transitionVideoUrl) continue;
|
||||||
|
if (element.reverseVideoUrl) continue; // Already has reversed
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reversedUrl =
|
||||||
|
await TourPagesService.getOrGenerateReversedVariant(
|
||||||
|
element.transitionVideoUrl,
|
||||||
|
currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reversedUrl) {
|
||||||
|
element.reverseVideoUrl = reversedUrl;
|
||||||
|
pageModified = true;
|
||||||
|
elementsProcessed++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.error(
|
||||||
|
{ err, pageId: page.id, elementType: element.type },
|
||||||
|
'Failed to generate reversed video for element',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageModified) {
|
||||||
|
// Use partialUpdate to only update ui_schema_json field
|
||||||
|
// This avoids the regular update's getFieldMapping which sets other fields to null
|
||||||
|
await Tour_pagesDBApi.partialUpdate(
|
||||||
|
page.id,
|
||||||
|
{ ui_schema_json: JSON.stringify(uiSchema) },
|
||||||
|
{ currentUser },
|
||||||
|
);
|
||||||
|
pagesUpdated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
{ pagesUpdated, elementsProcessed },
|
||||||
|
'Completed project-wide reversed video regeneration',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, 'Failed to regenerate project reversed videos');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TourPagesService;
|
||||||
|
|||||||
89
backend/src/services/videoProcessing.js
Normal file
89
backend/src/services/videoProcessing.js
Normal 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,
|
||||||
|
};
|
||||||
@ -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"
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
|
|
||||||
|
|
||||||
version: "3.9"
|
version: "3.9"
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
@ -14,12 +12,8 @@ services:
|
|||||||
options:
|
options:
|
||||||
max-size: "10m"
|
max-size: "10m"
|
||||||
max-file: "3"
|
max-file: "3"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "10m"
|
|
||||||
max-file: "3"
|
|
||||||
image: postgres
|
image: postgres
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/db:/var/lib/postgresql/data
|
- ./data/db:/var/lib/postgresql/data
|
||||||
@ -36,10 +30,10 @@ services:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: backend
|
image: backend
|
||||||
|
build: ../backend
|
||||||
volumes:
|
volumes:
|
||||||
- ./wait-for-it.sh:/usr/src/app/wait-for-it.sh
|
- ./wait-for-it.sh:/usr/src/app/wait-for-it.sh
|
||||||
- ./start-backend.sh:/usr/src/app/start-backend.sh
|
- ./start-backend.sh:/usr/src/app/start-backend.sh
|
||||||
build: ../backend
|
|
||||||
environment:
|
environment:
|
||||||
- DB_HOST=db
|
- DB_HOST=db
|
||||||
ports:
|
ports:
|
||||||
@ -51,8 +45,4 @@ services:
|
|||||||
max-file: "3"
|
max-file: "3"
|
||||||
depends_on:
|
depends_on:
|
||||||
- "db"
|
- "db"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
command: ["bash", "./wait-for-it.sh", "db:5432", "--timeout=0", "--strict", "--", "bash", "./start-backend.sh"]
|
command: ["bash", "./wait-for-it.sh", "db:5432", "--timeout=0", "--strict", "--", "bash", "./start-backend.sh"]
|
||||||
|
|
||||||
|
|||||||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
/// <reference path="./.next/types/routes.d.ts" />
|
/// <reference path="./build/types/routes.d.ts" />
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@ -15,8 +15,10 @@ interface CanvasBackgroundProps {
|
|||||||
backgroundVideoUrl?: string;
|
backgroundVideoUrl?: string;
|
||||||
backgroundAudioUrl?: string;
|
backgroundAudioUrl?: string;
|
||||||
previousBgImageUrl?: string;
|
previousBgImageUrl?: string;
|
||||||
|
previousBgVideoUrl?: string;
|
||||||
isSwitching?: boolean;
|
isSwitching?: boolean;
|
||||||
isNewBgReady?: boolean;
|
isNewBgReady?: boolean;
|
||||||
|
isFadingIn?: boolean;
|
||||||
onBackgroundReady?: () => void;
|
onBackgroundReady?: () => void;
|
||||||
// Video playback settings
|
// Video playback settings
|
||||||
videoAutoplay?: boolean;
|
videoAutoplay?: boolean;
|
||||||
@ -31,8 +33,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
backgroundVideoUrl,
|
backgroundVideoUrl,
|
||||||
backgroundAudioUrl,
|
backgroundAudioUrl,
|
||||||
previousBgImageUrl,
|
previousBgImageUrl,
|
||||||
|
previousBgVideoUrl,
|
||||||
isSwitching = false,
|
isSwitching = false,
|
||||||
isNewBgReady = false,
|
isNewBgReady = false,
|
||||||
|
isFadingIn = false,
|
||||||
onBackgroundReady,
|
onBackgroundReady,
|
||||||
videoAutoplay = true,
|
videoAutoplay = true,
|
||||||
videoLoop = true,
|
videoLoop = true,
|
||||||
@ -94,10 +98,12 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Previous background overlay - shows during page switch until new bg is ready */}
|
{/* Previous background overlays - show during loading AND crossfade.
|
||||||
{previousBgImageUrl && isSwitching && !isNewBgReady && (
|
Uses CSS animation for fade-out effect during crossfade.
|
||||||
|
z-0 keeps them BELOW new backgrounds (z-1). */}
|
||||||
|
{previousBgImageUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
|
||||||
<div
|
<div
|
||||||
className='pointer-events-none absolute inset-0 z-10'
|
className={`pointer-events-none absolute inset-0 z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `url("${previousBgImageUrl}")`,
|
backgroundImage: `url("${previousBgImageUrl}")`,
|
||||||
backgroundSize: 'contain',
|
backgroundSize: 'contain',
|
||||||
@ -106,6 +112,16 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{previousBgVideoUrl && (isFadingIn || (isSwitching && !isNewBgReady)) && (
|
||||||
|
<video
|
||||||
|
className={`absolute inset-0 z-0 h-full w-full object-contain pointer-events-none ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
||||||
|
src={previousBgVideoUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
{/* Background video - z-1 keeps it below backdrop blur layer (z-5) */}
|
||||||
{backgroundVideoUrl && (
|
{backgroundVideoUrl && (
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
* Full-screen overlay for transition video preview.
|
* Full-screen overlay for transition video preview.
|
||||||
* Designed to work with useTransitionPlayback hook which manages
|
* Designed to work with useTransitionPlayback hook which manages
|
||||||
* video src and playback externally via the videoRef.
|
* video src and playback externally via the videoRef.
|
||||||
|
*
|
||||||
|
* Supports letterbox mode to constrain transitions within canvas bounds,
|
||||||
|
* matching the behavior of background images and UI elements.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@ -15,27 +18,54 @@ interface TransitionPreviewOverlayProps {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
/** Whether the video is currently buffering (used to hide video during load) */
|
/** Whether the video is currently buffering (used to hide video during load) */
|
||||||
isBuffering?: boolean;
|
isBuffering?: boolean;
|
||||||
|
/** Letterbox styles from useCanvasScale - positions overlay within canvas bounds */
|
||||||
|
letterboxStyles?: React.CSSProperties;
|
||||||
|
/** Video object-fit mode (default: 'contain' to match backgrounds) */
|
||||||
|
videoFit?: 'contain' | 'cover';
|
||||||
|
/** Additional opacity value for fade-out effects (0-1) */
|
||||||
|
opacity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
const TransitionPreviewOverlay: React.FC<TransitionPreviewOverlayProps> = ({
|
||||||
videoRef,
|
videoRef,
|
||||||
isActive,
|
isActive,
|
||||||
isBuffering = false,
|
isBuffering = false,
|
||||||
|
letterboxStyles,
|
||||||
|
videoFit = 'contain',
|
||||||
|
opacity,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isActive) return null;
|
if (!isActive) return null;
|
||||||
|
|
||||||
|
// Video opacity: 0 while buffering, 1 otherwise
|
||||||
|
const videoOpacity = isBuffering ? 0 : 1;
|
||||||
|
// Container opacity: controlled by parent for fade-out effects
|
||||||
|
const containerOpacity = opacity ?? 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
// Outer: full viewport with black background (letterbox bars)
|
||||||
|
// No fade transition - transition video itself is the effect
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 z-50 overflow-hidden pointer-events-none bg-black'
|
||||||
|
style={{ opacity: containerOpacity }}
|
||||||
|
>
|
||||||
|
{/* Inner: respects letterbox dimensions when provided */}
|
||||||
|
<div
|
||||||
|
className='overflow-hidden'
|
||||||
|
style={letterboxStyles || { position: 'absolute', inset: 0 }}
|
||||||
|
>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
className={`absolute inset-0 h-full w-full transition-opacity duration-300 ease-linear ${
|
||||||
style={{ opacity: isBuffering ? 0 : 1 }}
|
videoFit === 'cover' ? 'object-cover' : 'object-contain'
|
||||||
|
}`}
|
||||||
|
style={{ opacity: videoOpacity }}
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
preload='auto'
|
preload='auto'
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import BaseButton from './BaseButton';
|
|||||||
import CardBox from './CardBox';
|
import CardBox from './CardBox';
|
||||||
import { OfflineToggle } from './Offline/OfflineToggle';
|
import { OfflineToggle } from './Offline/OfflineToggle';
|
||||||
import RuntimeElement from './RuntimeElement';
|
import RuntimeElement from './RuntimeElement';
|
||||||
|
import TransitionPreviewOverlay from './Constructor/TransitionPreviewOverlay';
|
||||||
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
import GalleryCarouselOverlay from './UiElements/GalleryCarouselOverlay';
|
||||||
import { BackdropPortalProvider } from './BackdropPortal';
|
import { BackdropPortalProvider } from './BackdropPortal';
|
||||||
import { RotatePrompt } from './RotatePrompt';
|
import { RotatePrompt } from './RotatePrompt';
|
||||||
@ -100,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);
|
||||||
@ -185,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) => {
|
||||||
@ -226,12 +229,12 @@ export default function RuntimePresentation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use shared background transition hook for fade-out and fade-in effects
|
// Use shared background transition hook for crossfade effects
|
||||||
const {
|
const {
|
||||||
isOverlayFadingOut,
|
isOverlayFadingOut,
|
||||||
resetFadeOut,
|
resetFadeOut,
|
||||||
isFadingIn,
|
isFadingIn,
|
||||||
elementsOpacity,
|
onFadeInAnimationEnd,
|
||||||
resetFadeIn,
|
resetFadeIn,
|
||||||
} = useBackgroundTransition({
|
} = useBackgroundTransition({
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
@ -331,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;
|
||||||
@ -346,13 +350,16 @@ 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 - use shared hook for smooth transition
|
// Direct navigation with crossfade effect:
|
||||||
// Reset fade-in state to start fresh
|
// useBackgroundTransition detects switching and applies animation classes
|
||||||
resetFadeIn();
|
// - New page gets animate-crossfade-in (0 → 1)
|
||||||
// Previous background stays visible until new one is ready
|
// - Previous background gets animate-crossfade-out (1 → 0)
|
||||||
setIsBackgroundReady(false);
|
setIsBackgroundReady(false);
|
||||||
// Mark this page as initialized to prevent redundant effect calls
|
// Mark this page as initialized to prevent redundant effect calls
|
||||||
lastInitializedPageIdRef.current = targetPageId;
|
lastInitializedPageIdRef.current = targetPageId;
|
||||||
@ -398,6 +405,7 @@ export default function RuntimePresentation({
|
|||||||
navTarget.pageId,
|
navTarget.pageId,
|
||||||
navTarget.transitionVideoUrl,
|
navTarget.transitionVideoUrl,
|
||||||
navTarget.isBack,
|
navTarget.isBack,
|
||||||
|
navTarget.reverseVideoUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -534,15 +542,46 @@ export default function RuntimePresentation({
|
|||||||
style={{
|
style={{
|
||||||
...cssVars,
|
...cssVars,
|
||||||
...letterboxStyles,
|
...letterboxStyles,
|
||||||
backgroundImage: backgroundImageUrl
|
}}
|
||||||
? `url("${backgroundImageUrl}")`
|
>
|
||||||
: undefined,
|
<BackdropPortalProvider>
|
||||||
|
{/* Previous background overlays - show during loading AND crossfade.
|
||||||
|
Uses CSS animation for fade-out effect.
|
||||||
|
Cleared by useBackgroundTransition after fade completes. */}
|
||||||
|
{pageSwitch.previousBgImageUrl &&
|
||||||
|
(isFadingIn ||
|
||||||
|
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
||||||
backgroundSize: 'contain',
|
backgroundSize: 'contain',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
}}
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{pageSwitch.previousBgVideoUrl &&
|
||||||
|
(isFadingIn ||
|
||||||
|
(pageSwitch.isSwitching && !pageSwitch.isNewBgReady)) && (
|
||||||
|
<video
|
||||||
|
className={`absolute inset-0 h-full w-full object-contain pointer-events-none z-0 ${isFadingIn ? 'animate-crossfade-out' : ''}`}
|
||||||
|
src={pageSwitch.previousBgVideoUrl}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New page content wrapper - fades in for non-transition navigation.
|
||||||
|
z-1 ensures it's above previous backgrounds (z-0) during fade.
|
||||||
|
onAnimationEnd resets isFadingIn when CSS animation completes. */}
|
||||||
|
<div
|
||||||
|
data-testid='page-content-wrapper'
|
||||||
|
className={`absolute inset-0 z-1 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||||
|
onAnimationEnd={onFadeInAnimationEnd}
|
||||||
>
|
>
|
||||||
<BackdropPortalProvider>
|
|
||||||
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
|
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
|
||||||
CSS backgroundImage provides instant display.
|
CSS backgroundImage provides instant display.
|
||||||
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
|
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
|
||||||
@ -587,21 +626,6 @@ export default function RuntimePresentation({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
|
|
||||||
{pageSwitch.previousBgImageUrl &&
|
|
||||||
pageSwitch.isSwitching &&
|
|
||||||
!pageSwitch.isNewBgReady && (
|
|
||||||
<div
|
|
||||||
className='absolute inset-0 pointer-events-none z-10'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
|
|
||||||
backgroundSize: 'contain',
|
|
||||||
backgroundPosition: 'center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
|
||||||
{backgroundVideoUrl && (
|
{backgroundVideoUrl && (
|
||||||
<video
|
<video
|
||||||
@ -617,15 +641,7 @@ export default function RuntimePresentation({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
|
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
|
||||||
<div
|
<div className='absolute inset-0 z-40'>
|
||||||
className='absolute inset-0 z-40'
|
|
||||||
style={{
|
|
||||||
opacity: elementsOpacity,
|
|
||||||
transition: isFadingIn
|
|
||||||
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
|
|
||||||
: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pageElements.map((element: CanvasElement) => (
|
{pageElements.map((element: CanvasElement) => (
|
||||||
<RuntimeElement
|
<RuntimeElement
|
||||||
key={element.id}
|
key={element.id}
|
||||||
@ -638,6 +654,8 @@ export default function RuntimePresentation({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* End new page content wrapper */}
|
||||||
|
|
||||||
{/* Controls: Offline toggle and Fullscreen button */}
|
{/* Controls: Offline toggle and Fullscreen button */}
|
||||||
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
|
||||||
@ -661,24 +679,13 @@ export default function RuntimePresentation({
|
|||||||
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
|
||||||
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
|
||||||
{transitionPreview && (
|
{transitionPreview && (
|
||||||
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
|
<TransitionPreviewOverlay
|
||||||
<video
|
videoRef={transitionVideoRef}
|
||||||
ref={transitionVideoRef}
|
isActive={true}
|
||||||
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
|
isBuffering={transitionPhase === 'preparing' || isBuffering}
|
||||||
style={{
|
letterboxStyles={letterboxStyles}
|
||||||
opacity:
|
opacity={isOverlayFadingOut ? 0 : 1}
|
||||||
transitionPhase === 'preparing' ||
|
|
||||||
isBuffering ||
|
|
||||||
isOverlayFadingOut
|
|
||||||
? 0
|
|
||||||
: 1,
|
|
||||||
}}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload='auto'
|
|
||||||
disablePictureInPicture
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gallery Carousel Overlay */}
|
{/* Gallery Carousel Overlay */}
|
||||||
|
|||||||
@ -43,16 +43,16 @@ export const CANVAS_CONFIG = {
|
|||||||
|
|
||||||
// Page transition effects
|
// Page transition effects
|
||||||
pageTransition: {
|
pageTransition: {
|
||||||
/**
|
|
||||||
* Fade-in duration for non-transition navigation (ms).
|
|
||||||
* Applied when switching pages without a transition video.
|
|
||||||
*/
|
|
||||||
fadeInDurationMs: 500,
|
|
||||||
/**
|
/**
|
||||||
* Fade-out duration for transition video overlay (ms).
|
* Fade-out duration for transition video overlay (ms).
|
||||||
* Applied after transition video finishes playing.
|
* Applied after transition video finishes playing.
|
||||||
*/
|
*/
|
||||||
fadeOutDurationMs: 300,
|
fadeOutDurationMs: 300,
|
||||||
|
/**
|
||||||
|
* Crossfade animation duration for page backgrounds (ms).
|
||||||
|
* Used for smooth transitions between pages.
|
||||||
|
*/
|
||||||
|
crossfadeDurationMs: 300,
|
||||||
/**
|
/**
|
||||||
* CSS easing function for fade animations.
|
* CSS easing function for fade animations.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -34,68 +34,214 @@
|
|||||||
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
@apply bg-transparent border border-blue-600 text-blue-600 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Element appear animation keyframes */
|
/* Page crossfade animation keyframes - Safari optimized */
|
||||||
@keyframes element-fade-in {
|
@-webkit-keyframes page-crossfade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-crossfade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes page-crossfade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes page-crossfade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Crossfade animation classes - GPU accelerated for Safari */
|
||||||
|
.animate-crossfade-in {
|
||||||
|
-webkit-animation: page-crossfade-in 300ms ease-out forwards;
|
||||||
|
animation: page-crossfade-in 300ms ease-out forwards;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-crossfade-out {
|
||||||
|
-webkit-animation: page-crossfade-out 300ms ease-out forwards;
|
||||||
|
animation: page-crossfade-out 300ms ease-out forwards;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Element appear animation keyframes - Safari optimized */
|
||||||
|
@-webkit-keyframes element-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes element-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes element-slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, 20px, 0);
|
||||||
|
transform: translate3d(0, 20px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes element-slide-up {
|
@keyframes element-slide-up {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translate3d(0, 20px, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes element-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(0, -20px, 0);
|
||||||
|
transform: translate3d(0, -20px, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes element-slide-down {
|
@keyframes element-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-20px);
|
transform: translate3d(0, -20px, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes element-slide-left {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(20px, 0, 0);
|
||||||
|
transform: translate3d(20px, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes element-slide-left {
|
@keyframes element-slide-left {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(20px);
|
transform: translate3d(20px, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes element-slide-right {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: translate3d(-20px, 0, 0);
|
||||||
|
transform: translate3d(-20px, 0, 0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: translate3d(0, 0, 0);
|
||||||
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes element-slide-right {
|
@keyframes element-slide-right {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translate3d(-20px, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translate3d(0, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes element-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
-webkit-transform: scale3d(0.8, 0.8, 1);
|
||||||
|
transform: scale3d(0.8, 0.8, 1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
-webkit-transform: scale3d(1, 1, 1);
|
||||||
|
transform: scale3d(1, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes element-scale-in {
|
@keyframes element-scale-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.8);
|
transform: scale3d(0.8, 0.8, 1);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale3d(1, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,19 +14,20 @@
|
|||||||
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
|
* 2. Simple mode (constructor): Direct navigation clearing + optional fade-in
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import {
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import { CANVAS_CONFIG } from '../config/canvas.config';
|
import { CANVAS_CONFIG } from '../config/canvas.config';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fade-out duration from config
|
* Fade-out duration from config (for transition video overlay)
|
||||||
*/
|
*/
|
||||||
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
|
const FADE_OUT_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeOutDurationMs;
|
||||||
|
|
||||||
/**
|
|
||||||
* Fade-in duration from config
|
|
||||||
*/
|
|
||||||
const FADE_IN_DURATION_MS = CANVAS_CONFIG.pageTransition.fadeInDurationMs;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fade-out configuration (optional - for RuntimePresentation)
|
* Fade-out configuration (optional - for RuntimePresentation)
|
||||||
*/
|
*/
|
||||||
@ -47,8 +48,6 @@ export interface FadeOutConfig {
|
|||||||
export interface FadeInConfig {
|
export interface FadeInConfig {
|
||||||
/** Whether a transition video is currently active (disables fade-in) */
|
/** Whether a transition video is currently active (disables fade-in) */
|
||||||
hasActiveTransition: boolean;
|
hasActiveTransition: boolean;
|
||||||
/** Optional duration override (uses config default if not provided) */
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseBackgroundTransitionOptions {
|
export interface UseBackgroundTransitionOptions {
|
||||||
@ -58,6 +57,7 @@ export interface UseBackgroundTransitionOptions {
|
|||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
isNewBgReady: boolean;
|
isNewBgReady: boolean;
|
||||||
previousBgImageUrl: string;
|
previousBgImageUrl: string;
|
||||||
|
previousBgVideoUrl: string;
|
||||||
};
|
};
|
||||||
/** Optional fade-out configuration (for RuntimePresentation) */
|
/** Optional fade-out configuration (for RuntimePresentation) */
|
||||||
fadeOut?: FadeOutConfig;
|
fadeOut?: FadeOutConfig;
|
||||||
@ -70,11 +70,11 @@ export interface UseBackgroundTransitionResult {
|
|||||||
isOverlayFadingOut: boolean;
|
isOverlayFadingOut: boolean;
|
||||||
/** Reset the fade-out state (call before starting a new transition) */
|
/** Reset the fade-out state (call before starting a new transition) */
|
||||||
resetFadeOut: () => void;
|
resetFadeOut: () => void;
|
||||||
/** Whether page content is currently fading in */
|
/** Whether page content is currently fading (crossfade in progress) */
|
||||||
isFadingIn: boolean;
|
isFadingIn: boolean;
|
||||||
/** Opacity value for elements container (0 during fade, 1 when complete) */
|
/** Handler to call when fade-in animation ends (pass to onAnimationEnd) */
|
||||||
elementsOpacity: number;
|
onFadeInAnimationEnd: () => void;
|
||||||
/** Reset fade-in state before starting new navigation */
|
/** Reset fade-in state (for cleanup or cancellation) */
|
||||||
resetFadeIn: () => void;
|
resetFadeIn: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,7 +83,7 @@ export interface UseBackgroundTransitionResult {
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
* // Full mode with fade-out and fade-in (RuntimePresentation)
|
||||||
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
|
* const { isOverlayFadingOut, resetFadeOut, isFadingIn, onFadeInAnimationEnd } = useBackgroundTransition({
|
||||||
* pageSwitch,
|
* pageSwitch,
|
||||||
* fadeOut: {
|
* fadeOut: {
|
||||||
* pendingTransitionComplete,
|
* pendingTransitionComplete,
|
||||||
@ -99,6 +99,12 @@ export interface UseBackgroundTransitionResult {
|
|||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
|
* // In JSX:
|
||||||
|
* <div
|
||||||
|
* className={isFadingIn ? 'animate-crossfade-in' : ''}
|
||||||
|
* onAnimationEnd={onFadeInAnimationEnd}
|
||||||
|
* >
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Simple mode - direct navigation only (constructor)
|
* // Simple mode - direct navigation only (constructor)
|
||||||
* useBackgroundTransition({ pageSwitch });
|
* useBackgroundTransition({ pageSwitch });
|
||||||
@ -109,15 +115,10 @@ export function useBackgroundTransition({
|
|||||||
fadeIn,
|
fadeIn,
|
||||||
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
}: UseBackgroundTransitionOptions): UseBackgroundTransitionResult {
|
||||||
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
const [isOverlayFadingOut, setIsOverlayFadingOut] = useState(false);
|
||||||
|
|
||||||
// Fade-in state
|
|
||||||
const [isFadingIn, setIsFadingIn] = useState(false);
|
const [isFadingIn, setIsFadingIn] = useState(false);
|
||||||
const [elementsOpacity, setElementsOpacity] = useState(1);
|
|
||||||
|
|
||||||
// Track previous isSwitching state to detect transition start
|
// Track previous isSwitching state to detect transition start
|
||||||
const wasSwitchingRef = useRef(false);
|
const wasSwitchingRef = useRef(false);
|
||||||
// Track timer for cleanup
|
|
||||||
const fadeInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset fade-out state before starting a new transition.
|
* Reset fade-out state before starting a new transition.
|
||||||
@ -128,16 +129,18 @@ export function useBackgroundTransition({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset fade-in state before starting new navigation.
|
* Reset fade-in state (for cleanup or cancellation).
|
||||||
* Clears any in-progress fade animation.
|
|
||||||
*/
|
*/
|
||||||
const resetFadeIn = useCallback(() => {
|
const resetFadeIn = useCallback(() => {
|
||||||
if (fadeInTimerRef.current) {
|
|
||||||
clearTimeout(fadeInTimerRef.current);
|
|
||||||
fadeInTimerRef.current = null;
|
|
||||||
}
|
|
||||||
setIsFadingIn(false);
|
setIsFadingIn(false);
|
||||||
setElementsOpacity(1);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for onAnimationEnd event.
|
||||||
|
* Called when CSS animation completes - CSS is the source of truth for duration.
|
||||||
|
*/
|
||||||
|
const onFadeInAnimationEnd = useCallback(() => {
|
||||||
|
setIsFadingIn(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,92 +190,55 @@ export function useBackgroundTransition({
|
|||||||
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
|
}, [fadeOut, isOverlayFadingOut, pageSwitch]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Clear previous background overlay when new background is ready (direct navigation).
|
* Effect: Clear previous background overlay after fade completes (direct navigation).
|
||||||
*
|
*
|
||||||
* This handles the case when navigating without a transition video.
|
* The previous background stays visible during the entire fade animation,
|
||||||
* The previous background stays visible until the new one is ready to paint.
|
* providing a smooth crossfade effect. Only cleared after fade ends.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (pageSwitch.isSwitching && pageSwitch.isNewBgReady && !isFadingIn) {
|
||||||
pageSwitch.isSwitching &&
|
// Fade is complete - clear the previous background overlay
|
||||||
pageSwitch.isNewBgReady &&
|
// This also resets isSwitching state so next navigation triggers fade-in
|
||||||
pageSwitch.previousBgImageUrl
|
|
||||||
) {
|
|
||||||
// New background is ready - clear the previous background overlay
|
|
||||||
pageSwitch.clearPreviousBackground();
|
pageSwitch.clearPreviousBackground();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
pageSwitch.isSwitching,
|
pageSwitch.isSwitching,
|
||||||
pageSwitch.isNewBgReady,
|
pageSwitch.isNewBgReady,
|
||||||
pageSwitch.previousBgImageUrl,
|
|
||||||
pageSwitch.clearPreviousBackground,
|
pageSwitch.clearPreviousBackground,
|
||||||
|
isFadingIn,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Fade-in page content on non-transition navigation.
|
* Layout effect: Set up crossfade BEFORE browser paints when switching starts.
|
||||||
|
* useLayoutEffect runs synchronously after DOM mutations but before paint,
|
||||||
|
* preventing any flash of new content at full opacity.
|
||||||
*
|
*
|
||||||
* Trigger: pageSwitch.isSwitching becomes true AND no active transition
|
* IMPORTANT: Skip this for transitions - transition video IS the effect.
|
||||||
*
|
|
||||||
* Sequence:
|
|
||||||
* 1. Navigation starts (isSwitching: false → true)
|
|
||||||
* 2. No transition video active
|
|
||||||
* 3. Set elementsOpacity = 0
|
|
||||||
* 4. Use double RAF to ensure paint before animation
|
|
||||||
* 5. Set elementsOpacity = 1 (CSS animates)
|
|
||||||
* 6. After duration, set isFadingIn = false
|
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!fadeIn) {
|
if (!fadeIn) {
|
||||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { hasActiveTransition, durationMs = FADE_IN_DURATION_MS } = fadeIn;
|
const { hasActiveTransition } = fadeIn;
|
||||||
const justStartedSwitching =
|
const justStartedSwitching =
|
||||||
pageSwitch.isSwitching && !wasSwitchingRef.current;
|
pageSwitch.isSwitching && !wasSwitchingRef.current;
|
||||||
|
|
||||||
wasSwitchingRef.current = pageSwitch.isSwitching;
|
wasSwitchingRef.current = pageSwitch.isSwitching;
|
||||||
|
|
||||||
// Start fade-in when:
|
// Only start crossfade for NON-transition navigation
|
||||||
// - Just started switching (transition from false to true)
|
// Transitions use video overlay - no fade needed
|
||||||
// - No active transition video
|
|
||||||
if (justStartedSwitching && !hasActiveTransition) {
|
if (justStartedSwitching && !hasActiveTransition) {
|
||||||
setIsFadingIn(true);
|
setIsFadingIn(true);
|
||||||
setElementsOpacity(0);
|
|
||||||
|
|
||||||
// Double RAF ensures opacity:0 is painted before transition starts
|
|
||||||
// (Same pattern as usePageSwitch.ts:396-397)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setElementsOpacity(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear any existing timer
|
|
||||||
if (fadeInTimerRef.current) {
|
|
||||||
clearTimeout(fadeInTimerRef.current);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark fade as complete after duration
|
|
||||||
fadeInTimerRef.current = setTimeout(() => {
|
|
||||||
setIsFadingIn(false);
|
|
||||||
fadeInTimerRef.current = null;
|
|
||||||
}, durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
return () => {
|
|
||||||
if (fadeInTimerRef.current) {
|
|
||||||
clearTimeout(fadeInTimerRef.current);
|
|
||||||
fadeInTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [pageSwitch.isSwitching, fadeIn]);
|
}, [pageSwitch.isSwitching, fadeIn]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOverlayFadingOut,
|
isOverlayFadingOut,
|
||||||
resetFadeOut,
|
resetFadeOut,
|
||||||
isFadingIn,
|
isFadingIn,
|
||||||
elementsOpacity,
|
onFadeInAnimationEnd,
|
||||||
resetFadeIn,
|
resetFadeIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import {
|
|||||||
buildProxyUrl,
|
buildProxyUrl,
|
||||||
} from '../lib/assetUrl';
|
} from '../lib/assetUrl';
|
||||||
import { logger } from '../lib/logger';
|
import { logger } from '../lib/logger';
|
||||||
|
import { scheduleAfterPaint } from '../lib/browserUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal page interface for page switching
|
* Minimal page interface for page switching
|
||||||
@ -63,6 +64,8 @@ export interface UsePageSwitchResult {
|
|||||||
currentBgAudioUrl: string;
|
currentBgAudioUrl: string;
|
||||||
/** Previous background image URL (for overlay) */
|
/** Previous background image URL (for overlay) */
|
||||||
previousBgImageUrl: string;
|
previousBgImageUrl: string;
|
||||||
|
/** Previous background video URL (for overlay during fade) */
|
||||||
|
previousBgVideoUrl: string;
|
||||||
/** Whether we're in the middle of a page switch */
|
/** Whether we're in the middle of a page switch */
|
||||||
isSwitching: boolean;
|
isSwitching: boolean;
|
||||||
/** Whether the new background is ready to display */
|
/** Whether the new background is ready to display */
|
||||||
@ -198,9 +201,14 @@ export function usePageSwitch(
|
|||||||
const previousBgImageUrlRef = useRef('');
|
const previousBgImageUrlRef = useRef('');
|
||||||
previousBgImageUrlRef.current = previousBgImageUrl;
|
previousBgImageUrlRef.current = previousBgImageUrl;
|
||||||
|
|
||||||
|
const [previousBgVideoUrl, setPreviousBgVideoUrl] = useState('');
|
||||||
|
const previousBgVideoUrlRef = useRef('');
|
||||||
|
previousBgVideoUrlRef.current = previousBgVideoUrl;
|
||||||
|
|
||||||
// Transition state
|
// Transition state
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
const [isSwitching, setIsSwitching] = useState(false);
|
||||||
const [isNewBgReady, setIsNewBgReady] = useState(true);
|
// Initialize as false to trigger fade-in animation on initial page load
|
||||||
|
const [isNewBgReady, setIsNewBgReady] = useState(false);
|
||||||
|
|
||||||
// Track blob URLs we created so we can revoke them
|
// Track blob URLs we created so we can revoke them
|
||||||
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
const createdBlobUrlsRef = useRef<Set<string>>(new Set());
|
||||||
@ -362,16 +370,20 @@ export function usePageSwitch(
|
|||||||
setCurrentBgVideoUrl('');
|
setCurrentBgVideoUrl('');
|
||||||
setCurrentBgAudioUrl('');
|
setCurrentBgAudioUrl('');
|
||||||
setPreviousBgImageUrl('');
|
setPreviousBgImageUrl('');
|
||||||
|
setPreviousBgVideoUrl('');
|
||||||
setIsSwitching(false);
|
setIsSwitching(false);
|
||||||
setIsNewBgReady(true);
|
setIsNewBgReady(true);
|
||||||
onSwitched?.();
|
onSwitched?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current image as previous for overlay (use ref to avoid dependency)
|
// Save current backgrounds as previous for overlay (use refs to avoid dependencies)
|
||||||
if (currentBgImageUrlRef.current) {
|
if (currentBgImageUrlRef.current) {
|
||||||
setPreviousBgImageUrl(currentBgImageUrlRef.current);
|
setPreviousBgImageUrl(currentBgImageUrlRef.current);
|
||||||
}
|
}
|
||||||
|
if (currentBgVideoUrlRef.current) {
|
||||||
|
setPreviousBgVideoUrl(currentBgVideoUrlRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
setIsSwitching(true);
|
setIsSwitching(true);
|
||||||
setIsNewBgReady(false);
|
setIsNewBgReady(false);
|
||||||
@ -391,13 +403,11 @@ export function usePageSwitch(
|
|||||||
// Notify caller that backgrounds are set
|
// Notify caller that backgrounds are set
|
||||||
onSwitched?.();
|
onSwitched?.();
|
||||||
|
|
||||||
// For blob URLs, mark ready immediately (local data)
|
// For blob URLs, mark ready after paint (Safari-compatible)
|
||||||
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
if (imageUrl.startsWith('blob:') || !imageUrl) {
|
||||||
requestAnimationFrame(() => {
|
scheduleAfterPaint(() => {
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setIsNewBgReady(true);
|
setIsNewBgReady(true);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
|
// For remote images, wait for Image onLoad (caller should use markBackgroundReady)
|
||||||
},
|
},
|
||||||
@ -405,7 +415,8 @@ export function usePageSwitch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Directly set backgrounds without transition overlay
|
* Directly set backgrounds without transition overlay.
|
||||||
|
* Used for initial page load with fade-in animation.
|
||||||
*/
|
*/
|
||||||
const setBackgroundsDirectly = useCallback(
|
const setBackgroundsDirectly = useCallback(
|
||||||
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
(imageUrl: string, videoUrl: string, audioUrl: string) => {
|
||||||
@ -420,7 +431,13 @@ export function usePageSwitch(
|
|||||||
setCurrentBgAudioUrl(audioUrl);
|
setCurrentBgAudioUrl(audioUrl);
|
||||||
setPreviousBgImageUrl('');
|
setPreviousBgImageUrl('');
|
||||||
setIsSwitching(false);
|
setIsSwitching(false);
|
||||||
|
|
||||||
|
// Trigger fade-in animation: set not-ready then ready after paint
|
||||||
|
// This ensures the CSS animation triggers on initial page load
|
||||||
|
setIsNewBgReady(false);
|
||||||
|
scheduleAfterPaint(() => {
|
||||||
setIsNewBgReady(true);
|
setIsNewBgReady(true);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[revokeBlobUrl],
|
[revokeBlobUrl],
|
||||||
);
|
);
|
||||||
@ -433,16 +450,21 @@ export function usePageSwitch(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the previous background overlay
|
* Clear the previous background overlay (both image and video)
|
||||||
*/
|
*/
|
||||||
const clearPreviousBackground = useCallback(() => {
|
const clearPreviousBackground = useCallback(() => {
|
||||||
const prevUrl = previousBgImageUrlRef.current;
|
const prevImageUrl = previousBgImageUrlRef.current;
|
||||||
|
const prevVideoUrl = previousBgVideoUrlRef.current;
|
||||||
setPreviousBgImageUrl('');
|
setPreviousBgImageUrl('');
|
||||||
|
setPreviousBgVideoUrl('');
|
||||||
setIsSwitching(false);
|
setIsSwitching(false);
|
||||||
|
|
||||||
// Revoke the previous blob URL after clearing
|
// Revoke the previous blob URLs after clearing
|
||||||
if (prevUrl) {
|
if (prevImageUrl) {
|
||||||
revokeBlobUrl(prevUrl);
|
revokeBlobUrl(prevImageUrl);
|
||||||
|
}
|
||||||
|
if (prevVideoUrl) {
|
||||||
|
revokeBlobUrl(prevVideoUrl);
|
||||||
}
|
}
|
||||||
}, [revokeBlobUrl]);
|
}, [revokeBlobUrl]);
|
||||||
|
|
||||||
@ -451,6 +473,7 @@ export function usePageSwitch(
|
|||||||
currentBgVideoUrl,
|
currentBgVideoUrl,
|
||||||
currentBgAudioUrl,
|
currentBgAudioUrl,
|
||||||
previousBgImageUrl,
|
previousBgImageUrl,
|
||||||
|
previousBgVideoUrl,
|
||||||
isSwitching,
|
isSwitching,
|
||||||
isNewBgReady,
|
isNewBgReady,
|
||||||
switchToPage,
|
switchToPage,
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
@ -169,6 +154,7 @@ export function useTransitionPlayback(
|
|||||||
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
const lastLoadedBlobUrlRef = useRef<string | null>(null);
|
||||||
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
const lastLoadedSourceUrlRef = useRef<string | null>(null);
|
||||||
const didTryFallbackRef = useRef(false);
|
const didTryFallbackRef = useRef(false);
|
||||||
|
const didTryDecodeRetryRef = useRef(false);
|
||||||
const currentPlayableUrlRef = useRef<string | null>(null);
|
const currentPlayableUrlRef = useRef<string | null>(null);
|
||||||
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
const startWatchdogTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||||
null,
|
null,
|
||||||
@ -183,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(() => {
|
||||||
@ -225,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) &&
|
||||||
@ -278,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;
|
||||||
@ -337,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;
|
||||||
}
|
}
|
||||||
@ -351,10 +322,10 @@ export function useTransitionPlayback(
|
|||||||
didFinishRef.current = false;
|
didFinishRef.current = false;
|
||||||
didStartPlaybackRef.current = false;
|
didStartPlaybackRef.current = false;
|
||||||
didTryFallbackRef.current = false;
|
didTryFallbackRef.current = false;
|
||||||
|
didTryDecodeRetryRef.current = false;
|
||||||
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 = () => {
|
||||||
@ -374,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 },
|
||||||
});
|
});
|
||||||
@ -388,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'),
|
||||||
@ -400,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;
|
||||||
@ -438,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
|
||||||
@ -456,7 +424,6 @@ export function useTransitionPlayback(
|
|||||||
|
|
||||||
const needsBlobUrl = shouldLoadViaBlob(
|
const needsBlobUrl = shouldLoadViaBlob(
|
||||||
sourceUrl,
|
sourceUrl,
|
||||||
currentTransition.reverseMode,
|
|
||||||
featuresRef.current?.useBlobUrl,
|
featuresRef.current?.useBlobUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -481,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;
|
||||||
@ -494,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 =
|
||||||
@ -512,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),
|
||||||
@ -522,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',
|
||||||
});
|
});
|
||||||
@ -538,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;
|
||||||
@ -549,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;
|
||||||
@ -567,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -582,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
|
|
||||||
// For reverse mode, wait for canplaythrough to trigger startReverse()
|
|
||||||
if (!isReverseMode) {
|
|
||||||
attemptPlay();
|
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) {
|
|
||||||
didStartPlaybackRef.current = true;
|
|
||||||
setPhase('reversing');
|
|
||||||
void startReverseRef.current?.();
|
|
||||||
} else {
|
|
||||||
attemptPlay();
|
attemptPlay();
|
||||||
}
|
|
||||||
}, playbackStartMs);
|
}, playbackStartMs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logIssue('source-prepare-failed', error);
|
logIssue('source-prepare-failed', error);
|
||||||
@ -632,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');
|
||||||
|
|
||||||
@ -696,7 +587,6 @@ 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
|
||||||
@ -707,12 +597,9 @@ export function useTransitionPlayback(
|
|||||||
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');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -720,7 +607,15 @@ export function useTransitionPlayback(
|
|||||||
if (didFinishRef.current) return;
|
if (didFinishRef.current) return;
|
||||||
logIssue('video-error');
|
logIssue('video-error');
|
||||||
|
|
||||||
// Check if this is a presigned URL failure (likely CORS)
|
const errorCode = video.error?.code;
|
||||||
|
if (errorCode === 3 && !didTryDecodeRetryRef.current) {
|
||||||
|
logger.info('Safari video decode error, attempting reload');
|
||||||
|
didTryDecodeRetryRef.current = true;
|
||||||
|
video.load();
|
||||||
|
attemptPlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const currentUrl = currentPlayableUrlRef.current;
|
const currentUrl = currentPlayableUrlRef.current;
|
||||||
if (
|
if (
|
||||||
currentUrl &&
|
currentUrl &&
|
||||||
@ -731,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);
|
||||||
@ -768,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);
|
||||||
@ -786,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);
|
||||||
@ -794,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,
|
||||||
@ -819,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
89
frontend/src/lib/browserUtils.ts
Normal file
89
frontend/src/lib/browserUtils.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Browser Utilities
|
||||||
|
*
|
||||||
|
* Centralized browser detection and cross-browser timing utilities.
|
||||||
|
* Follows patterns from useNetworkAware.ts for vendor-prefixed API detection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect Safari browser (macOS and iOS).
|
||||||
|
* Uses feature detection pattern from useNetworkAware.ts.
|
||||||
|
*/
|
||||||
|
export const isSafari = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
// Safari but not Chrome/Chromium-based
|
||||||
|
return /^((?!chrome|android).)*safari/i.test(ua);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect iOS Safari specifically.
|
||||||
|
*/
|
||||||
|
export const isIOSSafari = (): boolean => {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
const ua = navigator.userAgent;
|
||||||
|
return /iPad|iPhone|iPod/.test(ua) && !('MSStream' in window);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a callback to run after the next browser paint.
|
||||||
|
* Safari-compatible timing that ensures state updates occur on the correct paint cycle.
|
||||||
|
*
|
||||||
|
* Safari's RAF scheduler can fire multiple nested RAFs in the same frame,
|
||||||
|
* defeating the double-RAF pattern commonly used in React.
|
||||||
|
* This uses setTimeout to create a macrotask boundary, ensuring the callback
|
||||||
|
* runs after the current paint cycle completes.
|
||||||
|
*
|
||||||
|
* @param callback - Function to run after paint
|
||||||
|
*/
|
||||||
|
export const scheduleAfterPaint = (callback: () => void): void => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// setTimeout(0) creates a macrotask boundary
|
||||||
|
// RAF ensures we're synced with the next paint cycle
|
||||||
|
// This pattern works reliably across all browsers including Safari
|
||||||
|
setTimeout(() => {
|
||||||
|
requestAnimationFrame(callback);
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a callback with explicit frame delay.
|
||||||
|
* Useful when you need to ensure a specific number of frames have passed.
|
||||||
|
*
|
||||||
|
* @param callback - Function to run after delay
|
||||||
|
* @param frames - Number of frames to wait (default: 1)
|
||||||
|
* @returns Cleanup function to cancel the scheduled callback
|
||||||
|
*/
|
||||||
|
export const scheduleAfterFrames = (
|
||||||
|
callback: () => void,
|
||||||
|
frames = 1,
|
||||||
|
): (() => void) => {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
callback();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
frameCount++;
|
||||||
|
if (frameCount >= frames) {
|
||||||
|
callback();
|
||||||
|
} else {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -358,10 +358,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
// Destructure stable callback reference to avoid infinite loops in useEffect deps
|
||||||
const pageSwitchToPage = pageSwitch.switchToPage;
|
const pageSwitchToPage = pageSwitch.switchToPage;
|
||||||
|
|
||||||
// Use shared background transition hook for direct navigation clearing and fade-in
|
// Use shared background transition hook for direct navigation clearing and crossfade
|
||||||
// (No fade-out needed in constructor - transitions complete immediately)
|
// Crossfade starts automatically when new background is ready
|
||||||
// NOTE: Must be defined before switchToPage callback which uses resetFadeIn
|
const { isFadingIn } = useBackgroundTransition({
|
||||||
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
|
|
||||||
pageSwitch,
|
pageSwitch,
|
||||||
fadeIn: {
|
fadeIn: {
|
||||||
hasActiveTransition: Boolean(transitionPreview),
|
hasActiveTransition: Boolean(transitionPreview),
|
||||||
@ -374,9 +373,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
// isBack parameter indicates this is a back navigation (pops history instead of pushing)
|
||||||
const switchToPage = useCallback(
|
const switchToPage = useCallback(
|
||||||
async (page: TourPage | null, isBack = false) => {
|
async (page: TourPage | null, isBack = false) => {
|
||||||
// Reset fade-in state to start fresh
|
|
||||||
resetFadeIn();
|
|
||||||
|
|
||||||
// Mark this page as initialized to prevent redundant effect calls
|
// Mark this page as initialized to prevent redundant effect calls
|
||||||
if (page) {
|
if (page) {
|
||||||
lastInitializedPageIdRef.current = page.id;
|
lastInitializedPageIdRef.current = page.id;
|
||||||
@ -386,6 +382,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
updateBackgroundFromPage(page);
|
updateBackgroundFromPage(page);
|
||||||
|
|
||||||
// Use hook to resolve and set blob URLs for display
|
// Use hook to resolve and set blob URLs for display
|
||||||
|
// Fade starts automatically when new background is ready (crossfade effect)
|
||||||
await pageSwitchToPage(
|
await pageSwitchToPage(
|
||||||
page
|
page
|
||||||
? {
|
? {
|
||||||
@ -403,12 +400,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection],
|
||||||
pageSwitchToPage,
|
|
||||||
updateBackgroundFromPage,
|
|
||||||
applyPageSelection,
|
|
||||||
resetFadeIn,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
|
||||||
@ -1213,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(
|
||||||
@ -1466,8 +1455,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
backgroundVideoUrl={backgroundVideoSrc}
|
backgroundVideoUrl={backgroundVideoSrc}
|
||||||
backgroundAudioUrl={backgroundAudioSrc}
|
backgroundAudioUrl={backgroundAudioSrc}
|
||||||
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
previousBgImageUrl={pageSwitch.previousBgImageUrl}
|
||||||
|
previousBgVideoUrl={pageSwitch.previousBgVideoUrl}
|
||||||
isSwitching={pageSwitch.isSwitching}
|
isSwitching={pageSwitch.isSwitching}
|
||||||
isNewBgReady={pageSwitch.isNewBgReady}
|
isNewBgReady={pageSwitch.isNewBgReady}
|
||||||
|
isFadingIn={isFadingIn}
|
||||||
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
onBackgroundReady={() => pageSwitch.markBackgroundReady()}
|
||||||
videoAutoplay={backgroundVideoAutoplay}
|
videoAutoplay={backgroundVideoAutoplay}
|
||||||
videoLoop={backgroundVideoLoop}
|
videoLoop={backgroundVideoLoop}
|
||||||
@ -1478,13 +1469,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
|
|
||||||
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
{/* Elements container - z-10 ensures they appear above backdrop layer */}
|
||||||
<div
|
<div
|
||||||
className='absolute inset-0 z-10'
|
className={`absolute inset-0 z-10 ${isFadingIn ? 'animate-crossfade-in' : ''}`}
|
||||||
style={{
|
|
||||||
opacity: elementsOpacity,
|
|
||||||
transition: isFadingIn
|
|
||||||
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
|
|
||||||
: 'none',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
@ -1590,6 +1575,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
videoRef={transitionVideoRef}
|
videoRef={transitionVideoRef}
|
||||||
isActive={Boolean(transitionPreview)}
|
isActive={Boolean(transitionPreview)}
|
||||||
isBuffering={isReverseBuffering}
|
isBuffering={isReverseBuffering}
|
||||||
|
letterboxStyles={letterboxStyles}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Gallery Carousel Overlay */}
|
{/* Gallery Carousel Overlay */}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -42,8 +42,8 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-out': 'fade-out 250ms ease-in-out',
|
'fade-out': 'fade-out 300ms ease-out',
|
||||||
'fade-in': 'fade-in 250ms ease-in-out'
|
'fade-in': 'fade-in 300ms ease-out'
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
dark: {
|
dark: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user