import path, { dirname } from 'path'; import ffmpeg from 'fluent-ffmpeg'; import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types'; import { Resource, ResourceStatus, db } from '@nice/common'; import { getUploadFilePath } from '@server/utils/file'; import fs from 'fs/promises'; import sharp from 'sharp'; import { BaseProcessor } from './BaseProcessor'; export class VideoProcessor extends BaseProcessor { constructor() { super(); } async process(resource: Resource): Promise { console.log('process'); const { url } = resource; const filepath = getUploadFilePath(url); this.logger.log( `Processing video for resource ID: ${resource.id}, File ID: ${url}`, ); const originMeta = resource.meta as unknown as FileMetadata; if (!originMeta.filetype?.startsWith('video/')) { this.logger.log(`Skipping non-video resource: ${resource.id}`); return resource; } try { const streamDir = this.createOutputDir(filepath, 'stream'); const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([ this.generateM3U8Stream(filepath, streamDir), this.getVideoMetadata(filepath), this.generateVideoCover(filepath, dirname(filepath)), ]); const videoMeta: VideoMetadata = { ...videoMetadata, coverUrl: coverUrl, }; const updatedResource = await db.resource.update({ where: { id: resource.id }, data: { meta: { ...originMeta, ...videoMeta, }, }, }); this.logger.log( `Successfully processed video for resource ID: ${resource.id}`, ); return updatedResource; } catch (error: any) { this.logger.error( `Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`, ); throw new Error(`Failed to process video: ${error.message}`); } } private async generateVideoCover( filepath: string, outputDir: string, ): Promise { this.logger.log(`Generating video cover for: ${filepath}`); const jpgCoverPath = path.join(outputDir, 'cover.jpg'); const webpCoverPath = path.join(outputDir, 'cover.webp'); return new Promise((resolve, reject) => { ffmpeg(filepath) .on('end', async () => { try { // 使用 Sharp 将 JPG 转换为 WebP await sharp(jpgCoverPath) .webp({ quality: 80 }) // 设置 WebP 压缩质量 .toFile(webpCoverPath); // 删除临时 JPG 文件 await fs.unlink(jpgCoverPath); this.logger.log(`Video cover generated at: ${webpCoverPath}`); resolve(path.basename(webpCoverPath)); } catch (error: any) { this.logger.error( `Error converting cover to WebP: ${error.message}`, ); reject(error); } }) .on('error', (err) => { this.logger.error(`Error generating video cover: ${err.message}`); reject(err); }) .screenshots({ count: 1, folder: outputDir, filename: 'cover.jpg', size: '640x360', }); }); } private async getVideoDuration(filepath: string): Promise { this.logger.log(`Getting video duration for file: ${filepath}`); return new Promise((resolve, reject) => { ffmpeg.ffprobe(filepath, (err, metadata) => { if (err) { this.logger.error(`Error getting video duration: ${err.message}`); reject(err); return; } const duration = metadata.format.duration || 0; this.logger.log(`Video duration: ${duration} seconds`); resolve(duration); }); }); } private async generateM3U8Stream( filepath: string, outputDir: string, ): Promise { console.log('outputDir', outputDir); const m3u8Path = path.join(outputDir, 'index.m3u8'); this.logger.log( `Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`, ); return new Promise((resolve, reject) => { ffmpeg(filepath) .outputOptions([ // Improved video encoding settings '-c:v libx264', '-preset medium', // Balance between encoding speed and compression '-crf 23', // Constant Rate Factor for quality '-profile:v high', // Higher profile for better compression '-level:v 4.1', // Updated level for better compatibility // Parallel processing and performance '-threads 0', // Auto-detect optimal thread count '-x264-params keyint=48:min-keyint=48', // More precise GOP control // HLS specific optimizations '-hls_time 4', // Shorter segment duration for better adaptive streaming '-hls_list_size 0', // Keep all segments in playlist '-hls_flags independent_segments+delete_segments', // Allow segment cleanup // Additional encoding optimizations '-sc_threshold 0', // Disable scene change detection for more consistent segments '-max_muxing_queue_size 1024', // Increase muxing queue size // Output format '-f hls', ]) .output(m3u8Path) .on('start', (commandLine) => { this.logger.log(`Starting ffmpeg with command: ${commandLine}`); }) .on('end', () => { this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`); resolve(m3u8Path); }) .on('error', (err) => { const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`; this.logger.error(errorMessage); reject(new Error(errorMessage)); }) .run(); }); } private async getVideoMetadata( filepath: string, ): Promise> { this.logger.log(`Getting video metadata for file: ${filepath}`); return new Promise((resolve, reject) => { ffmpeg.ffprobe(filepath, (err, metadata) => { if (err) { this.logger.error(`Error getting video metadata: ${err.message}`); reject(err); return; } const videoStream = metadata.streams.find( (stream) => stream.codec_type === 'video', ); const audioStream = metadata.streams.find( (stream) => stream.codec_type === 'audio', ); const videoMetadata: Partial = { width: videoStream?.width || 0, height: videoStream?.height || 0, duration: metadata.format.duration || 0, videoCodec: videoStream?.codec_name || '', audioCodec: audioStream?.codec_name || '', }; this.logger.log( `Extracted video metadata: ${JSON.stringify(videoMetadata)}`, ); resolve(videoMetadata); }); }); } }