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
|
// Presigned URLs
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -958,6 +1088,10 @@ module.exports = {
|
|||||||
// Buffer operations
|
// Buffer operations
|
||||||
downloadToBuffer,
|
downloadToBuffer,
|
||||||
uploadBuffer,
|
uploadBuffer,
|
||||||
|
// File copy utilities
|
||||||
|
copyFile,
|
||||||
|
copyFilesParallel,
|
||||||
|
getMimeTypeFromExtension,
|
||||||
// Session-based chunked uploads
|
// Session-based chunked uploads
|
||||||
initUploadSession,
|
initUploadSession,
|
||||||
getUploadSession,
|
getUploadSession,
|
||||||
|
|||||||
@ -167,6 +167,35 @@ class LocalStorageProvider extends BaseStorageProvider {
|
|||||||
return files;
|
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)
|
* 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
|
* For local storage, return the file path that can be served by express.static
|
||||||
|
|||||||
@ -20,6 +20,7 @@ const {
|
|||||||
DeleteObjectsCommand,
|
DeleteObjectsCommand,
|
||||||
ListObjectsV2Command,
|
ListObjectsV2Command,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
|
CopyObjectCommand,
|
||||||
} = require('@aws-sdk/client-s3');
|
} = require('@aws-sdk/client-s3');
|
||||||
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
|
||||||
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
const { NodeHttpHandler } = require('@smithy/node-http-handler');
|
||||||
@ -405,6 +406,52 @@ class S3StorageProvider extends BaseStorageProvider {
|
|||||||
return getSignedUrl(this.client, command, { expiresIn });
|
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
|
* Get the underlying S3 client for advanced operations
|
||||||
* @returns {S3Client}
|
* @returns {S3Client}
|
||||||
|
|||||||
@ -1,7 +1,73 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const ProjectsDBApi = require('../db/api/projects');
|
const ProjectsDBApi = require('../db/api/projects');
|
||||||
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 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
|
// Generate base service from factory
|
||||||
const BaseProjectsService = createEntityService(ProjectsDBApi, {
|
const BaseProjectsService = createEntityService(ProjectsDBApi, {
|
||||||
@ -180,19 +246,83 @@ class ProjectsService extends BaseProjectsService {
|
|||||||
logo_url: sourceProject.logo_url,
|
logo_url: sourceProject.logo_url,
|
||||||
favicon_url: sourceProject.favicon_url,
|
favicon_url: sourceProject.favicon_url,
|
||||||
og_image_url: sourceProject.og_image_url,
|
og_image_url: sourceProject.og_image_url,
|
||||||
|
design_width: sourceProject.design_width,
|
||||||
|
design_height: sourceProject.design_height,
|
||||||
},
|
},
|
||||||
{ currentUser, transaction },
|
{ 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 || []) {
|
for (const sourceAsset of sourceProject.assets_project || []) {
|
||||||
const clonedAsset = await db.assets.create(
|
const clonedAsset = await db.assets.create(
|
||||||
{
|
{
|
||||||
name: sourceAsset.name,
|
name: sourceAsset.name,
|
||||||
asset_type: sourceAsset.asset_type,
|
asset_type: sourceAsset.asset_type,
|
||||||
type: sourceAsset.type || 'general',
|
type: sourceAsset.type || 'general',
|
||||||
cdn_url: sourceAsset.cdn_url,
|
cdn_url: '', // Will be populated on first presigned URL request
|
||||||
storage_key: sourceAsset.storage_key,
|
storage_key: assetPathMap.get(sourceAsset.storage_key) || sourceAsset.storage_key,
|
||||||
mime_type: sourceAsset.mime_type,
|
mime_type: sourceAsset.mime_type,
|
||||||
size_mb: sourceAsset.size_mb,
|
size_mb: sourceAsset.size_mb,
|
||||||
width_px: sourceAsset.width_px,
|
width_px: sourceAsset.width_px,
|
||||||
@ -207,11 +337,21 @@ class ProjectsService extends BaseProjectsService {
|
|||||||
{ transaction },
|
{ 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 || []) {
|
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(
|
await db.asset_variants.create(
|
||||||
{
|
{
|
||||||
variant_type: sourceVariant.variant_type,
|
variant_type: sourceVariant.variant_type,
|
||||||
cdn_url: sourceVariant.cdn_url,
|
cdn_url: '',
|
||||||
|
storage_key: variantStorageKey,
|
||||||
width_px: sourceVariant.width_px,
|
width_px: sourceVariant.width_px,
|
||||||
height_px: sourceVariant.height_px,
|
height_px: sourceVariant.height_px,
|
||||||
size_mb: sourceVariant.size_mb,
|
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)
|
// Clone tour pages (dev environment only - stage/production are populated via publishing)
|
||||||
const sourcePages = await db.tour_pages.findAll({
|
const sourcePages = await db.tour_pages.findAll({
|
||||||
where: { projectId: sourceProjectId, environment: 'dev' },
|
where: { projectId: sourceProjectId, environment: 'dev' },
|
||||||
@ -240,6 +420,28 @@ class ProjectsService extends BaseProjectsService {
|
|||||||
delete pageData.deletedBy;
|
delete pageData.deletedBy;
|
||||||
delete pageData.importHash;
|
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(
|
await db.tour_pages.create(
|
||||||
{
|
{
|
||||||
...pageData,
|
...pageData,
|
||||||
@ -268,6 +470,12 @@ class ProjectsService extends BaseProjectsService {
|
|||||||
delete trackData.deletedBy;
|
delete trackData.deletedBy;
|
||||||
delete trackData.importHash;
|
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(
|
await db.project_audio_tracks.create(
|
||||||
{
|
{
|
||||||
...trackData,
|
...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();
|
await transaction.commit();
|
||||||
return clonedProject;
|
return clonedProject;
|
||||||
} catch (error) {
|
} 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 PreviousBackgroundOverlay from '../PreviousBackgroundOverlay';
|
||||||
import { scheduleAfterPaint } from '../../lib/browserUtils';
|
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 {
|
interface HTMLVideoElementWithRVFC extends HTMLVideoElement {
|
||||||
requestVideoFrameCallback: (callback: () => void) => number;
|
requestVideoFrameCallback: (
|
||||||
|
callback: (
|
||||||
|
now: DOMHighResTimeStamp,
|
||||||
|
metadata: VideoFrameCallbackMetadata,
|
||||||
|
) => void,
|
||||||
|
) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasBackgroundProps {
|
interface CanvasBackgroundProps {
|
||||||
@ -101,10 +108,10 @@ const CanvasBackground: React.FC<CanvasBackgroundProps> = ({
|
|||||||
onBackgroundReady?.();
|
onBackgroundReady?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+)
|
// Use requestVideoFrameCallback for precise frame-level timing (Safari 15.4+, Chrome 83+)
|
||||||
if ('requestVideoFrameCallback' in video) {
|
const videoWithRVFC = video as HTMLVideoElementWithRVFC;
|
||||||
const rvfc = (video as HTMLVideoElementWithRVFC).requestVideoFrameCallback.bind(video);
|
if (typeof videoWithRVFC.requestVideoFrameCallback === 'function') {
|
||||||
rvfc(() => {
|
videoWithRVFC.requestVideoFrameCallback(() => {
|
||||||
reportVideoReady();
|
reportVideoReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -326,7 +326,10 @@ export default function RuntimePresentation({
|
|||||||
// Only mark ready immediately if there's no background media at all.
|
// Only mark ready immediately if there's no background media at all.
|
||||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
// 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);
|
setIsBackgroundReady(true);
|
||||||
}
|
}
|
||||||
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
}, [selectedPage?.background_image_url, selectedPage?.background_video_url]);
|
||||||
@ -339,7 +342,11 @@ export default function RuntimePresentation({
|
|||||||
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
|
// - pageSwitch.isNewBgReady: Controls PreviousBackgroundOverlay visibility
|
||||||
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
|
// If we only check isBackgroundReady, PreviousBackgroundOverlay may still be showing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pendingTransitionComplete && isBackgroundReady && pageSwitch.isNewBgReady) {
|
if (
|
||||||
|
pendingTransitionComplete &&
|
||||||
|
isBackgroundReady &&
|
||||||
|
pageSwitch.isNewBgReady
|
||||||
|
) {
|
||||||
// Wait for paint cycle to complete before removing overlay
|
// Wait for paint cycle to complete before removing overlay
|
||||||
// scheduleAfterPaint handles Safari's RAF quirks automatically
|
// scheduleAfterPaint handles Safari's RAF quirks automatically
|
||||||
scheduleAfterPaint(() => {
|
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):
|
// Safari Black Flash Prevention (video transitions only):
|
||||||
// Update lastKnownBgUrl whenever we have a valid background image.
|
// Update lastKnownBgUrl whenever we have a valid background image.
|
||||||
|
|||||||
@ -74,7 +74,8 @@ export function useBackgroundVideoPlayback({
|
|||||||
const trackingKey = videoStoragePath || videoUrl;
|
const trackingKey = videoStoragePath || videoUrl;
|
||||||
|
|
||||||
// Block autoplay if video already played this session (only when loop=false)
|
// 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
|
// Store current values in refs for event handlers to access
|
||||||
const startTimeRef = useRef(startTime);
|
const startTimeRef = useRef(startTime);
|
||||||
const endTimeRef = useRef(endTime);
|
const endTimeRef = useRef(endTime);
|
||||||
|
|||||||
@ -748,6 +748,8 @@ export function usePreloadOrchestrator(
|
|||||||
queuePresignedUrls(storagePaths)
|
queuePresignedUrls(storagePaths)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
logger.info('[PRELOAD] Presigned URLs fetched, adding to queue');
|
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();
|
await addAssetsToQueue();
|
||||||
})
|
})
|
||||||
.catch(async (error) => {
|
.catch(async (error) => {
|
||||||
|
|||||||
@ -620,13 +620,19 @@ export function useTransitionPlayback(
|
|||||||
|
|
||||||
// Monitor video position to finish before end (prevents END flash)
|
// Monitor video position to finish before end (prevents END flash)
|
||||||
// Note: rvfc fires AFTER frame is composited, so we need extra buffer
|
// 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;
|
if (didFinishRef.current) return;
|
||||||
|
|
||||||
const duration = video.duration;
|
const duration = video.duration;
|
||||||
// Finish 300ms before end - gives margin for black/fade frames
|
// Finish 300ms before end - gives margin for black/fade frames
|
||||||
// that some videos have in the last 100-200ms
|
// 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');
|
finishPlayback('rvfc-end');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -760,7 +766,8 @@ export function useTransitionPlayback(
|
|||||||
return {
|
return {
|
||||||
phase,
|
phase,
|
||||||
// Show buffering until video first frame is painted (prevents START black flash)
|
// 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
|
isReversing: false, // No longer support frame-stepping reverse
|
||||||
cancel,
|
cancel,
|
||||||
forceComplete,
|
forceComplete,
|
||||||
|
|||||||
@ -120,12 +120,13 @@ class DownloadManagerClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Asset exists but is partial - full download requested
|
// Asset exists but is partial - full download requested
|
||||||
// Continue to download the full asset
|
// NOTE: Don't log here - will log after deduplication check below
|
||||||
logger.info('[DownloadManager] Upgrading partial to full download', {
|
|
||||||
storageKey: storageKey.slice(-50),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Check if already in queue (use storageKey for deduplication)
|
||||||
if (
|
if (
|
||||||
this.queue.some((j) => j.storageKey === storageKey) ||
|
this.queue.some((j) => j.storageKey === storageKey) ||
|
||||||
@ -133,7 +134,14 @@ class DownloadManagerClass {
|
|||||||
(j) => j.storageKey === storageKey,
|
(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) => {
|
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.
|
// Only mark ready immediately if there's no background media at all.
|
||||||
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
// For pages with image or video, CanvasBackground will call onBackgroundReady
|
||||||
// after the media's first frame is painted (using scheduleAfterPaint or requestVideoFrameCallback).
|
// 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);
|
setIsBackgroundReady(true);
|
||||||
}
|
}
|
||||||
}, [activePage?.background_image_url, activePage?.background_video_url]);
|
}, [activePage?.background_image_url, activePage?.background_video_url]);
|
||||||
@ -1534,7 +1537,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
videoMuted={backgroundVideoMuted}
|
videoMuted={backgroundVideoMuted}
|
||||||
videoStartTime={backgroundVideoStartTime}
|
videoStartTime={backgroundVideoStartTime}
|
||||||
videoEndTime={backgroundVideoEndTime}
|
videoEndTime={backgroundVideoEndTime}
|
||||||
videoStoragePath={backgroundVideoUrl || activePage?.background_video_url}
|
videoStoragePath={
|
||||||
|
backgroundVideoUrl || activePage?.background_video_url
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user