193 lines
6.8 KiB
TypeScript
Executable File
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);
|
|
});
|
|
});
|
|
}
|
|
}
|