2026-04-14 13:17:31 +04:00

218 lines
6.3 KiB
JavaScript

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;