const AssetsDBApi = require('../db/api/assets'); const Asset_variantsDBApi = require('../db/api/asset_variants'); const { createEntityService } = require('../factories/service.factory'); 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 */ const VALID_MIME_PATTERNS = { image: { prefixes: ['image/'], description: 'image (jpeg, png, gif, webp, svg, etc.)', }, video: { prefixes: ['video/'], description: 'video (mp4, webm, mov, etc.)', }, audio: { prefixes: ['audio/'], description: 'audio (mp3, wav, ogg, etc.)', }, }; /** * Validate that mime_type matches asset_type * @param {string} assetType - Expected asset type (image, video, audio) * @param {string} mimeType - Actual MIME type of the file * @returns {{ valid: boolean, error?: string }} */ function validateAssetMimeType(assetType, mimeType) { // If no asset_type specified, skip validation if (!assetType) { return { valid: true }; } const patterns = VALID_MIME_PATTERNS[assetType]; // If asset_type is not one we validate (e.g., 'file'), skip validation if (!patterns) { return { valid: true }; } // If no mime_type provided, we can't validate but allow it // (browser may not always send mime type) if (!mimeType) { return { valid: true }; } const normalizedMime = String(mimeType).toLowerCase().trim(); // Check if mime_type matches any of the valid prefixes const isValid = patterns.prefixes.some((prefix) => normalizedMime.startsWith(prefix), ); if (!isValid) { return { valid: false, error: `Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"`, }; } return { valid: true }; } // Create base service from factory const BaseService = createEntityService(AssetsDBApi, { entityName: 'assets', }); /** * Assets Service with validation and video pre-processing */ class AssetsService extends BaseService { /** * Create asset with MIME type validation and video pre-processing */ static async create(data, currentUser) { // Validate asset_type and mime_type match const assetType = data.asset_type; const mimeType = data.mime_type; const validation = validateAssetMimeType(assetType, mimeType); if (!validation.valid) { throw new ValidationError(validation.error); } // Call parent create 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; } } /** * Update asset with MIME type validation */ static async update(data, id, currentUser) { // If updating asset_type or mime_type, validate they match if (data.asset_type || data.mime_type) { const assetType = data.asset_type; const mimeType = data.mime_type; // Only validate if both are provided in the update if (assetType && mimeType) { const validation = validateAssetMimeType(assetType, mimeType); if (!validation.valid) { throw new ValidationError(validation.error); } } } // Call parent update return super.update(data, id, currentUser); } } module.exports = AssetsService;