collect-system/apps/server/src/models/resource/processor/VideoProcessor.ts

193 lines
6.8 KiB
TypeScript
Executable File

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<Resource> {
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<string> {
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<number> {
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<string> {
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<string>((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<Partial<VideoMetadata>> {
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<VideoMetadata> = {
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);
});
});
}
}