fixed assets coping issue while projects cloning
This commit is contained in:
parent
71f8b060a8
commit
56a319c125
@ -912,6 +912,136 @@ const finalizeUploadSession = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// File Copy Utility
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
* @param {string} filepath - File path or storage key
|
||||
* @returns {string} MIME type or default 'application/octet-stream'
|
||||
*/
|
||||
const getMimeTypeFromExtension = (filepath) => {
|
||||
const ext = path.extname(filepath).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.pdf': 'application/pdf',
|
||||
'.json': 'application/json',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy a file within storage using provider's native copy
|
||||
* S3: Uses CopyObjectCommand (server-side, no download/upload)
|
||||
* Local: Uses fs.copyFile
|
||||
* GCloud: Falls back to download/upload
|
||||
*
|
||||
* @param {string} sourceKey - Source storage key
|
||||
* @param {string} destKey - Destination storage key
|
||||
* @param {Object} [options] - Copy options
|
||||
* @param {string} [options.contentType] - MIME type (auto-detected if not provided)
|
||||
* @returns {Promise<{ url: string } | { key: string }>}
|
||||
*/
|
||||
const copyFile = async (sourceKey, destKey, options = {}) => {
|
||||
const provider = getFileStorageProvider();
|
||||
const contentType = options.contentType || getMimeTypeFromExtension(sourceKey);
|
||||
|
||||
if (provider === 's3') {
|
||||
const s3 = getS3Provider();
|
||||
const result = await s3.copy(sourceKey, destKey, { contentType });
|
||||
logger.debug({ sourceKey, destKey, provider: 's3' }, 'File copied (server-side)');
|
||||
return { url: result.url };
|
||||
}
|
||||
|
||||
if (provider === 'local') {
|
||||
const local = getLocalProvider();
|
||||
await local.copy(sourceKey, destKey);
|
||||
logger.debug({ sourceKey, destKey, provider: 'local' }, 'File copied');
|
||||
return { url: `/api/file/download?privateUrl=${encodeURIComponent(destKey)}` };
|
||||
}
|
||||
|
||||
// GCloud fallback: download + upload (no native copy implemented)
|
||||
if (provider === 'gcloud') {
|
||||
const buffer = await downloadToBuffer(sourceKey);
|
||||
logger.debug(
|
||||
{ sourceKey, destKey, provider: 'gcloud', size: buffer.length },
|
||||
'File copied (download/upload fallback)',
|
||||
);
|
||||
return uploadBuffer(destKey, buffer, { contentType });
|
||||
}
|
||||
|
||||
throw new Error(`Unknown storage provider: ${provider}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy multiple files in parallel with concurrency limit
|
||||
* @param {Array<{sourceKey: string, destKey: string, contentType?: string}>} copies - Array of copy operations
|
||||
* @param {Object} [options] - Options
|
||||
* @param {number} [options.concurrency=10] - Max concurrent copies
|
||||
* @param {boolean} [options.continueOnError=true] - Continue if individual copy fails
|
||||
* @returns {Promise<{succeeded: Array, failed: Array<{sourceKey: string, error: string}>}>}
|
||||
*/
|
||||
const copyFilesParallel = async (copies, options = {}) => {
|
||||
const { concurrency = 10, continueOnError = true } = options;
|
||||
const succeeded = [];
|
||||
const failed = [];
|
||||
|
||||
// Process in chunks for concurrency control
|
||||
for (let i = 0; i < copies.length; i += concurrency) {
|
||||
const chunk = copies.slice(i, i + concurrency);
|
||||
const results = await Promise.allSettled(
|
||||
chunk.map(({ sourceKey, destKey, contentType }) =>
|
||||
copyFile(sourceKey, destKey, { contentType }).then((result) => ({
|
||||
sourceKey,
|
||||
destKey,
|
||||
result,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const result = results[j];
|
||||
const copy = chunk[j];
|
||||
|
||||
if (result.status === 'fulfilled') {
|
||||
succeeded.push({ sourceKey: copy.sourceKey, destKey: copy.destKey });
|
||||
} else {
|
||||
const errorMsg = result.reason?.message || 'Unknown error';
|
||||
failed.push({
|
||||
sourceKey: copy.sourceKey,
|
||||
destKey: copy.destKey,
|
||||
error: errorMsg,
|
||||
});
|
||||
|
||||
if (!continueOnError) {
|
||||
throw new Error(`Copy failed for ${copy.sourceKey}: ${errorMsg}`);
|
||||
}
|
||||
|
||||
logger.warn({ sourceKey: copy.sourceKey, error: errorMsg }, 'File copy failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ succeeded: succeeded.length, failed: failed.length, total: copies.length },
|
||||
'Batch file copy completed',
|
||||
);
|
||||
|
||||
return { succeeded, failed };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Presigned URLs
|
||||
// ============================================================================
|
||||
@ -958,6 +1088,10 @@ module.exports = {
|
||||
// Buffer operations
|
||||
downloadToBuffer,
|
||||
uploadBuffer,
|
||||
// File copy utilities
|
||||
copyFile,
|
||||
copyFilesParallel,
|
||||
getMimeTypeFromExtension,
|
||||
// Session-based chunked uploads
|
||||
initUploadSession,
|
||||
getUploadSession,
|
||||
|
||||
@ -167,6 +167,35 @@ class LocalStorageProvider extends BaseStorageProvider {
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within local storage
|
||||
* Uses fs.promises.copyFile for efficient filesystem copying.
|
||||
*
|
||||
* @param {string} sourceKey - Source storage key/path
|
||||
* @param {string} destinationKey - Destination storage key/path
|
||||
* @param {Object} [_options] - Copy options (unused, for interface consistency)
|
||||
* @returns {Promise<{ key: string }>}
|
||||
*/
|
||||
async copy(sourceKey, destinationKey, _options = {}) {
|
||||
const sourcePath = this.buildPath(sourceKey);
|
||||
const destPath = this.buildPath(destinationKey);
|
||||
|
||||
// Check source exists before copying
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const error = new Error(`Source file not found: ${sourceKey}`);
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Ensure destination directory exists (using existing helper)
|
||||
ensureDirectoryExistence(destPath);
|
||||
|
||||
// Use async copyFile for non-blocking operation
|
||||
await fs.promises.copyFile(sourcePath, destPath);
|
||||
|
||||
return { key: destinationKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a signed URL for direct access (not supported for local storage)
|
||||
* For local storage, return the file path that can be served by express.static
|
||||
|
||||
@ -20,6 +20,7 @@ const {
|
||||
DeleteObjectsCommand,
|
||||
ListObjectsV2Command,
|
||||
HeadObjectCommand,
|
||||
CopyObjectCommand,
|
||||
} = require('@aws-sdk/client-s3');
|
||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||
@ -405,6 +406,52 @@ class S3StorageProvider extends BaseStorageProvider {
|
||||
return getSignedUrl(this.client, command, { expiresIn });
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within S3 (server-side copy - no download/upload)
|
||||
* Uses CopyObjectCommand for efficient server-side copying.
|
||||
* Inherits retry behavior from S3Client's adaptive retry mode.
|
||||
*
|
||||
* @param {string} sourceKey - Source storage key/path
|
||||
* @param {string} destinationKey - Destination storage key/path
|
||||
* @param {Object} [options] - Copy options
|
||||
* @param {AbortSignal} [options.signal] - AbortController signal for cancellation
|
||||
* @param {string} [options.contentType] - Override content type (uses MetadataDirective: REPLACE)
|
||||
* @returns {Promise<{ key: string, url: string }>}
|
||||
*/
|
||||
async copy(sourceKey, destinationKey, options = {}) {
|
||||
const fullSourceKey = this.buildKey(sourceKey);
|
||||
const fullDestinationKey = this.buildKey(destinationKey);
|
||||
const { signal, contentType } = options;
|
||||
|
||||
// CopySource requires URL-encoded path for special characters
|
||||
// Format: bucket/key (no leading slash)
|
||||
const encodedSourceKey = fullSourceKey
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
|
||||
const params = {
|
||||
Bucket: this.bucket,
|
||||
CopySource: `${this.bucket}/${encodedSourceKey}`,
|
||||
Key: fullDestinationKey,
|
||||
};
|
||||
|
||||
// Override content type if provided (requires MetadataDirective: REPLACE)
|
||||
if (contentType) {
|
||||
params.ContentType = contentType;
|
||||
params.MetadataDirective = 'REPLACE';
|
||||
}
|
||||
|
||||
// Use same pattern as other methods for AbortSignal support
|
||||
const sendOptions = signal ? { abortSignal: signal } : {};
|
||||
await this.client.send(new CopyObjectCommand(params), sendOptions);
|
||||
|
||||
return {
|
||||
key: fullDestinationKey,
|
||||
url: `https://${this.bucket}.s3.amazonaws.com/${fullDestinationKey}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying S3 client for advanced operations
|
||||
* @returns {S3Client}
|
||||
|
||||
@ -1,7 +1,73 @@
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const db = require('../db/models');
|
||||
const ProjectsDBApi = require('../db/api/projects');
|
||||
const { createEntityService } = require('../factories/service.factory');
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const FileService = require('./file');
|
||||
const { logger } = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Transform asset paths in ui_schema_json recursively
|
||||
* Handles nested objects and arrays.
|
||||
*
|
||||
* @param {Object|Array|string} uiSchema - The ui_schema_json object, array, or JSON string
|
||||
* @param {Map<string, string>} assetPathMap - Map of old storage paths to new paths
|
||||
* @returns {Object|Array} Transformed ui_schema_json with updated asset paths
|
||||
*/
|
||||
function transformUiSchemaAssetPaths(uiSchema, assetPathMap) {
|
||||
if (!uiSchema) return uiSchema;
|
||||
|
||||
// Parse JSON string if needed (may be double-stringified from DB)
|
||||
let data = uiSchema;
|
||||
while (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data);
|
||||
} catch {
|
||||
// Not valid JSON, return as-is
|
||||
return uiSchema;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof data !== 'object') return data;
|
||||
|
||||
// Clone to avoid mutating source
|
||||
const result = Array.isArray(data) ? [...data] : { ...data };
|
||||
|
||||
// Known fields that contain asset storage paths
|
||||
// Note: reverseVideoUrl is included - if not in assetPathMap, keeps original value
|
||||
const assetFields = new Set([
|
||||
'src',
|
||||
'mediaUrl',
|
||||
'imageUrl',
|
||||
'videoUrl',
|
||||
'audioUrl',
|
||||
'transitionVideoUrl',
|
||||
'reverseVideoUrl',
|
||||
'thumbnail',
|
||||
'storage_key',
|
||||
'iconUrl',
|
||||
'carouselPrevIconUrl',
|
||||
'carouselNextIconUrl',
|
||||
'galleryCarouselPrevIconUrl',
|
||||
'galleryCarouselNextIconUrl',
|
||||
'galleryCarouselBackIconUrl',
|
||||
]);
|
||||
|
||||
for (const key of Object.keys(result)) {
|
||||
const value = result[key];
|
||||
|
||||
if (assetFields.has(key) && typeof value === 'string' && value) {
|
||||
// Map to new storage path if available
|
||||
result[key] = assetPathMap.get(value) || value;
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
// Recurse into nested objects/arrays
|
||||
result[key] = transformUiSchemaAssetPaths(value, assetPathMap);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Generate base service from factory
|
||||
const BaseProjectsService = createEntityService(ProjectsDBApi, {
|
||||
@ -180,19 +246,83 @@ class ProjectsService extends BaseProjectsService {
|
||||
logo_url: sourceProject.logo_url,
|
||||
favicon_url: sourceProject.favicon_url,
|
||||
og_image_url: sourceProject.og_image_url,
|
||||
design_width: sourceProject.design_width,
|
||||
design_height: sourceProject.design_height,
|
||||
},
|
||||
{ currentUser, transaction },
|
||||
);
|
||||
|
||||
// Clone assets and variants
|
||||
// ============================================
|
||||
// Phase B: Collect all copy operations
|
||||
// ============================================
|
||||
const copyOperations = [];
|
||||
|
||||
for (const sourceAsset of sourceProject.assets_project || []) {
|
||||
if (sourceAsset.storage_key) {
|
||||
const ext = path.extname(sourceAsset.storage_key || '');
|
||||
const newStorageKey = `assets/${clonedProject.id}/${uuidv4()}${ext}`;
|
||||
|
||||
copyOperations.push({
|
||||
sourceKey: sourceAsset.storage_key,
|
||||
destKey: newStorageKey,
|
||||
contentType: sourceAsset.mime_type,
|
||||
});
|
||||
}
|
||||
|
||||
// Collect variant copy operations (skip 'reversed' - handled in Phase F with asset ID paths)
|
||||
const variants = sourceAsset.asset_variants_asset || [];
|
||||
for (const sourceVariant of variants) {
|
||||
if (sourceVariant.variant_type === 'reversed') continue; // Handled in Phase F
|
||||
if (sourceVariant.storage_key) {
|
||||
const variantExt = path.extname(sourceVariant.storage_key);
|
||||
const newVariantKey = `assets/${clonedProject.id}/${uuidv4()}${variantExt}`;
|
||||
|
||||
copyOperations.push({
|
||||
sourceKey: sourceVariant.storage_key,
|
||||
destKey: newVariantKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Phase C: Execute parallel copy
|
||||
// ============================================
|
||||
logger.info(
|
||||
{ sourceProjectId, copyCount: copyOperations.length },
|
||||
'Starting parallel file copy for project clone',
|
||||
);
|
||||
|
||||
const { succeeded, failed } = await FileService.copyFilesParallel(copyOperations, {
|
||||
concurrency: 10,
|
||||
continueOnError: true,
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Phase D: Build assetPathMap from results
|
||||
// ============================================
|
||||
const assetPathMap = new Map();
|
||||
for (const { sourceKey, destKey } of succeeded) {
|
||||
assetPathMap.set(sourceKey, destKey);
|
||||
}
|
||||
// Fallback: failed copies use original path (project still functional)
|
||||
for (const { sourceKey } of failed) {
|
||||
assetPathMap.set(sourceKey, sourceKey);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Phase E: Create asset/variant records, track ID mapping for reversed videos
|
||||
// ============================================
|
||||
const assetIdMap = new Map(); // oldAssetId → newAssetId
|
||||
|
||||
for (const sourceAsset of sourceProject.assets_project || []) {
|
||||
const clonedAsset = await db.assets.create(
|
||||
{
|
||||
name: sourceAsset.name,
|
||||
asset_type: sourceAsset.asset_type,
|
||||
type: sourceAsset.type || 'general',
|
||||
cdn_url: sourceAsset.cdn_url,
|
||||
storage_key: sourceAsset.storage_key,
|
||||
cdn_url: '', // Will be populated on first presigned URL request
|
||||
storage_key: assetPathMap.get(sourceAsset.storage_key) || sourceAsset.storage_key,
|
||||
mime_type: sourceAsset.mime_type,
|
||||
size_mb: sourceAsset.size_mb,
|
||||
width_px: sourceAsset.width_px,
|
||||
@ -207,11 +337,21 @@ class ProjectsService extends BaseProjectsService {
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// Track ID mapping for reversed video copying
|
||||
assetIdMap.set(sourceAsset.id, clonedAsset.id);
|
||||
|
||||
// Clone non-reversed variants (reversed handled in Phase F)
|
||||
for (const sourceVariant of sourceAsset.asset_variants_asset || []) {
|
||||
if (sourceVariant.variant_type === 'reversed') continue; // Handled in Phase F
|
||||
|
||||
const variantStorageKey =
|
||||
assetPathMap.get(sourceVariant.storage_key) || sourceVariant.storage_key;
|
||||
|
||||
await db.asset_variants.create(
|
||||
{
|
||||
variant_type: sourceVariant.variant_type,
|
||||
cdn_url: sourceVariant.cdn_url,
|
||||
cdn_url: '',
|
||||
storage_key: variantStorageKey,
|
||||
width_px: sourceVariant.width_px,
|
||||
height_px: sourceVariant.height_px,
|
||||
size_mb: sourceVariant.size_mb,
|
||||
@ -224,6 +364,46 @@ class ProjectsService extends BaseProjectsService {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Phase F: Copy reversed videos using asset ID mapping
|
||||
// Reversed videos use pattern: assets/{assetId}/reversed.mp4
|
||||
// ============================================
|
||||
const reversedCopyOps = [];
|
||||
for (const [oldAssetId, newAssetId] of assetIdMap) {
|
||||
const oldReversedKey = `assets/${oldAssetId}/reversed.mp4`;
|
||||
const newReversedKey = `assets/${newAssetId}/reversed.mp4`;
|
||||
reversedCopyOps.push({
|
||||
sourceKey: oldReversedKey,
|
||||
destKey: newReversedKey,
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
}
|
||||
|
||||
if (reversedCopyOps.length > 0) {
|
||||
logger.info(
|
||||
{ count: reversedCopyOps.length },
|
||||
'Copying reversed videos for cloned assets',
|
||||
);
|
||||
|
||||
const reversedResults = await FileService.copyFilesParallel(reversedCopyOps, {
|
||||
concurrency: 10,
|
||||
continueOnError: true, // Many assets won't have reversed videos - that's OK
|
||||
});
|
||||
|
||||
// Add successful reversed video copies to assetPathMap
|
||||
for (const { sourceKey, destKey } of reversedResults.succeeded) {
|
||||
assetPathMap.set(sourceKey, destKey);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
succeeded: reversedResults.succeeded.length,
|
||||
failed: reversedResults.failed.length,
|
||||
},
|
||||
'Reversed video copy completed',
|
||||
);
|
||||
}
|
||||
|
||||
// Clone tour pages (dev environment only - stage/production are populated via publishing)
|
||||
const sourcePages = await db.tour_pages.findAll({
|
||||
where: { projectId: sourceProjectId, environment: 'dev' },
|
||||
@ -240,6 +420,28 @@ class ProjectsService extends BaseProjectsService {
|
||||
delete pageData.deletedBy;
|
||||
delete pageData.importHash;
|
||||
|
||||
// Transform ui_schema_json asset paths (including reverseVideoUrl clearing)
|
||||
if (pageData.ui_schema_json) {
|
||||
pageData.ui_schema_json = transformUiSchemaAssetPaths(
|
||||
pageData.ui_schema_json,
|
||||
assetPathMap,
|
||||
);
|
||||
}
|
||||
|
||||
// Transform background URLs to new storage keys
|
||||
if (pageData.background_image_url) {
|
||||
pageData.background_image_url =
|
||||
assetPathMap.get(pageData.background_image_url) || pageData.background_image_url;
|
||||
}
|
||||
if (pageData.background_video_url) {
|
||||
pageData.background_video_url =
|
||||
assetPathMap.get(pageData.background_video_url) || pageData.background_video_url;
|
||||
}
|
||||
if (pageData.background_audio_url) {
|
||||
pageData.background_audio_url =
|
||||
assetPathMap.get(pageData.background_audio_url) || pageData.background_audio_url;
|
||||
}
|
||||
|
||||
await db.tour_pages.create(
|
||||
{
|
||||
...pageData,
|
||||
@ -268,6 +470,12 @@ class ProjectsService extends BaseProjectsService {
|
||||
delete trackData.deletedBy;
|
||||
delete trackData.importHash;
|
||||
|
||||
// Transform audio URL to new storage key
|
||||
if (trackData.storage_key) {
|
||||
trackData.storage_key =
|
||||
assetPathMap.get(trackData.storage_key) || trackData.storage_key;
|
||||
}
|
||||
|
||||
await db.project_audio_tracks.create(
|
||||
{
|
||||
...trackData,
|
||||
@ -281,6 +489,40 @@ class ProjectsService extends BaseProjectsService {
|
||||
);
|
||||
}
|
||||
|
||||
// Clone project element defaults
|
||||
// Note: ProjectsDBApi.create() auto-creates defaults from global templates.
|
||||
// We need to delete those and replace with source project's customized defaults.
|
||||
await db.project_element_defaults.destroy({
|
||||
where: { projectId: clonedProject.id },
|
||||
transaction,
|
||||
force: true, // Hard delete to avoid soft-delete conflicts
|
||||
});
|
||||
|
||||
const sourceElementDefaults = await db.project_element_defaults.findAll({
|
||||
where: { projectId: sourceProjectId },
|
||||
transaction,
|
||||
});
|
||||
|
||||
for (const sourceDefault of sourceElementDefaults) {
|
||||
const defaultData = sourceDefault.toJSON();
|
||||
delete defaultData.id;
|
||||
delete defaultData.createdAt;
|
||||
delete defaultData.updatedAt;
|
||||
delete defaultData.deletedAt;
|
||||
delete defaultData.deletedBy;
|
||||
delete defaultData.importHash;
|
||||
|
||||
await db.project_element_defaults.create(
|
||||
{
|
||||
...defaultData,
|
||||
projectId: clonedProject.id,
|
||||
createdById: currentUser.id,
|
||||
updatedById: currentUser.id,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
return clonedProject;
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -12,9 +12,16 @@ import { useBackgroundVideoPlayback } from '../../hooks/useBackgroundVideoPlayba
|
||||
import PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||
import { scheduleAfterPaint } from '../../lib/browserUtils';
|
||||
|
||||
// Type for requestVideoFrameCallback (Safari 15.4+)
|
||||
// Type for requestVideoFrameCallback (Safari 15.4+, Chrome 83+)
|
||||
// The callback receives (now: DOMHighResTimeStamp, metadata: VideoFrameCallbackMetadata)
|
||||
// but we ignore them since we only need to know the frame was painted
|
||||
interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
||||
requestVideoFrameCallback: (callback: () => void) => number;
|
||||
requestVideoFrameCallback: (
|
||||
callback: (
|
||||
now: DOMHighResTimeStamp,
|
||||
metadata: VideoFrameCallbackMetadata,
|
||||
) => void,
|
||||
) => number;
|
||||
}
|
||||
|
||||
interface CanvasBackgroundProps {
|
||||
@ -101,10 +108,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
||||
onBackgroundReady?.();
|
||||
};
|
||||
|
||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
|
||||
if ('requestVideoFrameCallback' in video) {
|
||||
const rvfc = (video as HTMLVideoElementWithRVFC).requestVideoFrameCallback.bind(video);
|
||||
rvfc(() => {
|
||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
|
||||
const videoWithRVFC = video as HTMLVideoElementWithRVFC;
|
||||
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
|
||||
videoWithRVFC.requestVideoFrameCallback(() => {
|
||||
reportVideoReady();
|
||||
});
|
||||
} else {
|
||||
|
||||
@ -326,7 +326,10 @@ export default function RuntimePresentation({
|
||||
// Only mark ready immediately if there's no background media at all.
|
||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
||||
if (!selectedPage?.background_image_url && !selectedPage?.background_video_url) {
|
||||
if (
|
||||
!selectedPage?.background_image_url &&
|
||||
!selectedPage?.background_video_url
|
||||
) {
|
||||
setIsBackgroundReady(true);
|
||||
}
|
||||
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
||||
@ -339,7 +342,11 @@ export default function RuntimePresentation({
|
||||
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
|
||||
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
|
||||
useEffect(() => {
|
||||
if (pendingTransitionComplete && isBackgroundReady && pageSwitch.isNewBgReady) {
|
||||
if (
|
||||
pendingTransitionComplete &&
|
||||
isBackgroundReady &&
|
||||
pageSwitch.isNewBgReady
|
||||
) {
|
||||
// Wait for paint cycle to complete before removing overlay
|
||||
// scheduleAfterPaint handles Safari's RAF quirks automatically
|
||||
scheduleAfterPaint(() => {
|
||||
@ -364,7 +371,12 @@ export default function RuntimePresentation({
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [pendingTransitionComplete, isBackgroundReady, pageSwitch.isNewBgReady, pageSwitch.clearPreviousBackground]);
|
||||
}, [
|
||||
pendingTransitionComplete,
|
||||
isBackgroundReady,
|
||||
pageSwitch.isNewBgReady,
|
||||
pageSwitch.clearPreviousBackground,
|
||||
]);
|
||||
|
||||
// Safari Black Flash Prevention (video transitions only):
|
||||
// Update lastKnownBgUrl whenever we have a valid background image.
|
||||
|
||||
@ -74,7 +74,8 @@ export function useBackgroundVideoPlayback({
|
||||
const trackingKey = videoStoragePath || videoUrl;
|
||||
|
||||
// Block autoplay if video already played this session (only when loop=false)
|
||||
const shouldBlockAutoplay = !loop && trackingKey ? playedVideos.has(trackingKey) : false;
|
||||
const shouldBlockAutoplay =
|
||||
!loop && trackingKey ? playedVideos.has(trackingKey) : false;
|
||||
// Store current values in refs for event handlers to access
|
||||
const startTimeRef = useRef(startTime);
|
||||
const endTimeRef = useRef(endTime);
|
||||
|
||||
@ -748,6 +748,8 @@ export function usePreloadOrchestrator(
|
||||
queuePresignedUrls(storagePaths)
|
||||
.then(async () => {
|
||||
logger.info('[PRELOAD] Presigned URLs fetched, adding to queue');
|
||||
// Note: Don't call markPresignedUrlsVerified() here - it's called after
|
||||
// first successful download to verify CORS is configured properly
|
||||
await addAssetsToQueue();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
|
||||
@ -620,13 +620,19 @@ export function useTransitionPlayback(
|
||||
|
||||
// Monitor video position to finish before end (prevents END flash)
|
||||
// Note: rvfc fires AFTER frame is composited, so we need extra buffer
|
||||
const monitorEnd = (_now2: number, metadata: VideoFrameCallbackMetadata) => {
|
||||
const monitorEnd = (
|
||||
_now2: number,
|
||||
metadata: VideoFrameCallbackMetadata,
|
||||
) => {
|
||||
if (didFinishRef.current) return;
|
||||
|
||||
const duration = video.duration;
|
||||
// Finish 300ms before end - gives margin for black/fade frames
|
||||
// that some videos have in the last 100-200ms
|
||||
if (Number.isFinite(duration) && metadata.mediaTime >= duration - 0.3) {
|
||||
if (
|
||||
Number.isFinite(duration) &&
|
||||
metadata.mediaTime >= duration - 0.3
|
||||
) {
|
||||
finishPlayback('rvfc-end');
|
||||
return;
|
||||
}
|
||||
@ -760,7 +766,8 @@ export function useTransitionPlayback(
|
||||
return {
|
||||
phase,
|
||||
// Show buffering until video first frame is painted (prevents START black flash)
|
||||
isBuffering: phase === 'preparing' || (phase === 'playing' && !isVideoReady),
|
||||
isBuffering:
|
||||
phase === 'preparing' || (phase === 'playing' && !isVideoReady),
|
||||
isReversing: false, // No longer support frame-stepping reverse
|
||||
cancel,
|
||||
forceComplete,
|
||||
|
||||
@ -120,12 +120,13 @@ class DownloadManagerClass {
|
||||
}
|
||||
|
||||
// Asset exists but is partial - full download requested
|
||||
// Continue to download the full asset
|
||||
logger.info('[DownloadManager] Upgrading partial to full download', {
|
||||
storageKey: storageKey.slice(-50),
|
||||
});
|
||||
// NOTE: Don't log here - will log after deduplication check below
|
||||
}
|
||||
|
||||
// Track if this is an upgrade request (for logging after dedup check)
|
||||
const isUpgradingPartial =
|
||||
assetInfo?.exists && assetInfo.isPartial && !isPartialDownload;
|
||||
|
||||
// Check if already in queue (use storageKey for deduplication)
|
||||
if (
|
||||
this.queue.some((j) => j.storageKey === storageKey) ||
|
||||
@ -133,7 +134,14 @@ class DownloadManagerClass {
|
||||
(j) => j.storageKey === storageKey,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
return; // Already queued - no log needed
|
||||
}
|
||||
|
||||
// Now we know this job will actually be added - safe to log
|
||||
if (isUpgradingPartial) {
|
||||
logger.info('[DownloadManager] Upgrading partial to full download', {
|
||||
storageKey: storageKey.slice(-50),
|
||||
});
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@ -398,7 +398,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
// Only mark ready immediately if there's no background media at all.
|
||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
||||
if (!activePage?.background_image_url && !activePage?.background_video_url) {
|
||||
if (
|
||||
!activePage?.background_image_url &&
|
||||
!activePage?.background_video_url
|
||||
) {
|
||||
setIsBackgroundReady(true);
|
||||
}
|
||||
}, [activePage?.background_image_url, activePage?.background_video_url]);
|
||||
@ -1534,7 +1537,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
||||
videoMuted={backgroundVideoMuted}
|
||||
videoStartTime={backgroundVideoStartTime}
|
||||
videoEndTime={backgroundVideoEndTime}
|
||||
videoStoragePath={backgroundVideoUrl || activePage?.background_video_url}
|
||||
videoStoragePath={
|
||||
backgroundVideoUrl || activePage?.background_video_url
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user