UNPKG

@tiahui/anitorrent-cli

Version:

CLI tool for video management with PeerTube and Storj S3

705 lines (573 loc) 22.2 kB
const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs').promises; const { Logger } = require('../utils/logger'); class VideoService { constructor(options = {}) { this.logger = new Logger(options); } async checkFFmpegInstalled() { return new Promise((resolve) => { const ffmpeg = spawn('ffmpeg', ['-version']); ffmpeg.on('error', () => { resolve(false); }); ffmpeg.on('close', (code) => { resolve(code === 0); }); }); } async getVideoInfo(videoPath) { const ffmpegInstalled = await this.checkFFmpegInstalled(); if (!ffmpegInstalled) { throw new Error('FFmpeg is not installed or not available in PATH'); } const exists = await this.fileExists(videoPath); if (!exists) { throw new Error(`Video file not found: ${videoPath}`); } return new Promise((resolve, reject) => { const args = [ '-i', videoPath, '-f', 'null', '-' ]; const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', () => { try { const durationMatch = stderr.match(/Duration: (\d{2}:\d{2}:\d{2}\.\d{2})/); const resolutionMatch = stderr.match(/(\d{3,4}x\d{3,4})/); const bitrateMatch = stderr.match(/bitrate: (\d+) kb\/s/); const fpsMatch = stderr.match(/(\d+(?:\.\d+)?) fps/); const info = { duration: durationMatch ? durationMatch[1] : 'Unknown', resolution: resolutionMatch ? resolutionMatch[1] : 'Unknown', bitrate: bitrateMatch ? `${bitrateMatch[1]} kb/s` : 'Unknown', fps: fpsMatch ? `${fpsMatch[1]} fps` : 'Unknown' }; resolve(info); } catch (error) { reject(new Error(`Failed to parse video info: ${error.message}`)); } }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg process error: ${error.message}`)); }); }); } async fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } generateOutputFileName(inputPath, suffix = '_with_intro') { const ext = path.extname(inputPath); const nameWithoutExt = path.basename(inputPath, ext); const dir = path.dirname(inputPath); return path.join(dir, `${nameWithoutExt}${suffix}${ext}`); } async getFileSize(filePath) { try { const stats = await fs.stat(filePath); return this.formatFileSize(stats.size); } catch (error) { return 'Unknown'; } } formatFileSize(bytes) { const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; if (bytes === 0) return '0 Bytes'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } async getDetailedVideoInfo(videoPath) { return new Promise((resolve, reject) => { const args = [ '-i', videoPath, '-hide_banner' ]; const ffprobe = spawn('ffprobe', args); let stderr = ''; ffprobe.stderr.on('data', (data) => { stderr += data.toString(); }); ffprobe.on('close', () => { try { const streamMatch = stderr.match(/Stream #\d+:\d+.*?: Video: (.+)/); if (!streamMatch) { throw new Error('No video stream found'); } const videoStream = streamMatch[1]; const codecMatch = videoStream.match(/^(\w+)/); const resolutionMatch = videoStream.match(/(\d{3,4}x\d{3,4})/); const fpsMatch = videoStream.match(/(\d+(?:\.\d+)?)\s*fps/); const pixelFormatMatch = videoStream.match(/(\w+),\s*\d{3,4}x\d{3,4}/); const info = { codec: codecMatch ? codecMatch[1] : 'unknown', resolution: resolutionMatch ? resolutionMatch[1] : 'unknown', fps: fpsMatch ? parseFloat(fpsMatch[1]) : 30, pixelFormat: pixelFormatMatch ? pixelFormatMatch[1] : 'yuv420p' }; resolve(info); } catch (error) { reject(new Error(`Failed to parse detailed video info: ${error.message}`)); } }); ffprobe.on('error', (error) => { reject(new Error(`FFprobe process error: ${error.message}`)); }); }); } async mergeVideos(introPath, inputVideoPath, outputPath) { const AudioService = require('./audio-service'); const SubtitleService = require('./subtitle-service'); const audioService = new AudioService(); const subtitleService = new SubtitleService(); const tempDir = path.join(path.dirname(outputPath), `temp_merge_${Date.now()}`); try { this.logger.verbose('Starting advanced video merge with track preservation'); await fs.mkdir(tempDir, { recursive: true }); const inputVideoInfo = await this.getDetailedVideoInfo(inputVideoPath); const introDuration = await this.getVideoDuration(introPath); this.logger.verbose(`Input video specs: ${inputVideoInfo.fps}fps, ${inputVideoInfo.resolution}, ${inputVideoInfo.codec}`); this.logger.verbose(`Intro duration: ${introDuration}s`); const tempIntroPath = path.join(tempDir, 'intro_converted.mp4'); const tempVideoOnlyPath = path.join(tempDir, 'video_only.mp4'); const tempMergedVideoPath = path.join(tempDir, 'merged_video.mp4'); this.logger.verbose('Step 1: Converting intro to match input specifications'); await this.convertIntroToMatchInput(introPath, tempIntroPath, inputVideoInfo); this.logger.verbose('Step 2: Extracting video stream only from input'); await this.extractVideoStreamOnly(inputVideoPath, tempVideoOnlyPath); this.logger.verbose('Step 3: Concatenating video streams'); await this.concatenateVideosOnly(tempIntroPath, tempVideoOnlyPath, tempMergedVideoPath); this.logger.verbose('Step 4: Extracting audio tracks from input'); const audioTracks = await audioService.listAudioTracks(inputVideoPath); const extractedAudio = []; for (let i = 0; i < audioTracks.length; i++) { const audioPath = path.join(tempDir, `audio_${i}.aac`); await this.extractAudioTrack(inputVideoPath, audioPath, i); extractedAudio.push({ path: audioPath, track: audioTracks[i], index: i }); } this.logger.verbose('Step 5: Extracting subtitle tracks from input'); const subtitleTracks = await subtitleService.listSubtitleTracks(inputVideoPath); const extractedSubtitles = []; for (let i = 0; i < subtitleTracks.length; i++) { const subtitlePath = path.join(tempDir, `subtitle_${i}.ass`); const result = await subtitleService.extractSubtitles(inputVideoPath, path.basename(subtitlePath), i, tempDir); if (result.success) { extractedSubtitles.push({ path: result.outputPath, track: subtitleTracks[i], index: i }); } } this.logger.verbose('Step 6: Merging all streams with proper timing'); await this.mergeAllStreams( tempMergedVideoPath, extractedAudio, extractedSubtitles, introDuration, outputPath ); this.logger.verbose('Step 7: Cleaning up temporary files'); await this.cleanupTempDirectory(tempDir); this.logger.verbose('Video merge with track preservation completed successfully'); return { success: true, outputPath }; } catch (error) { this.logger.verbose(`Merge operation failed: ${error.message}`); await this.cleanupTempDirectory(tempDir); throw error; } } async convertIntroToMatchInput(introPath, outputPath, targetSpecs) { return new Promise((resolve, reject) => { const args = [ '-i', introPath, '-r', targetSpecs.fps.toString(), '-s', targetSpecs.resolution, '-c:v', this.getOptimalVideoCodec(targetSpecs.codec), '-c:a', 'aac', '-b:a', '128k', '-pix_fmt', targetSpecs.pixelFormat, '-vsync', 'cfr', '-preset', 'medium', '-crf', '18', '-movflags', '+faststart', '-y', outputPath ]; this.logger.verbose(`Converting intro with args: ${args.join(' ')}`); const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { this.logger.verbose('Intro conversion completed'); resolve(); } else { reject(new Error(`Intro conversion failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg conversion process error: ${error.message}`)); }); }); } async concatenateVideos(introPath, inputVideoPath, outputPath) { const listFilePath = path.join(path.dirname(outputPath), `concat_list_${Date.now()}.txt`); try { const listContent = `file '${introPath.replace(/\\/g, '/').replace(/'/g, "'\"'\"'")}' file '${inputVideoPath.replace(/\\/g, '/').replace(/'/g, "'\"'\"'")}'`; await fs.writeFile(listFilePath, listContent, 'utf8'); return new Promise((resolve, reject) => { const args = [ '-f', 'concat', '-safe', '0', '-i', listFilePath, '-c', 'copy', '-avoid_negative_ts', 'make_zero', '-fflags', '+genpts', '-y', outputPath ]; this.logger.verbose(`Concatenating videos with args: ${args.join(' ')}`); const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { this.cleanupTempFile(listFilePath); if (code === 0) { this.logger.verbose('Video concatenation completed'); resolve(); } else { reject(new Error(`Concatenation failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { this.cleanupTempFile(listFilePath); reject(new Error(`FFmpeg concatenation process error: ${error.message}`)); }); }); } catch (error) { await this.cleanupTempFile(listFilePath); throw error; } } getOptimalVideoCodec(inputCodec) { const codecMap = { 'h264': 'libx264', 'h265': 'libx265', 'hevc': 'libx265', 'vp9': 'libvpx-vp9', 'vp8': 'libvpx', 'av1': 'libaom-av1' }; return codecMap[inputCodec.toLowerCase()] || 'libx264'; } async getVideoDuration(videoPath) { return new Promise((resolve, reject) => { const args = [ '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', videoPath ]; const ffprobe = spawn('ffprobe', args); let stdout = ''; ffprobe.stdout.on('data', (data) => { stdout += data.toString(); }); ffprobe.on('close', (code) => { if (code === 0) { const duration = parseFloat(stdout.trim()); resolve(duration); } else { reject(new Error(`Failed to get video duration with code ${code}`)); } }); ffprobe.on('error', (error) => { reject(new Error(`FFprobe process error: ${error.message}`)); }); }); } async extractVideoStreamOnly(inputPath, outputPath) { return new Promise((resolve, reject) => { const args = [ '-i', inputPath, '-c:v', 'copy', '-an', '-sn', '-y', outputPath ]; const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { this.logger.verbose('Video stream extracted successfully'); resolve(); } else { reject(new Error(`Video extraction failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg video extraction error: ${error.message}`)); }); }); } async concatenateVideosOnly(introPath, inputVideoPath, outputPath) { const listFilePath = path.join(path.dirname(outputPath), `video_concat_list_${Date.now()}.txt`); try { const listContent = `file '${introPath.replace(/\\/g, '/').replace(/'/g, "'\"'\"'")}' file '${inputVideoPath.replace(/\\/g, '/').replace(/'/g, "'\"'\"'")}'`; await fs.writeFile(listFilePath, listContent, 'utf8'); return new Promise((resolve, reject) => { const args = [ '-f', 'concat', '-safe', '0', '-i', listFilePath, '-c', 'copy', '-avoid_negative_ts', 'make_zero', '-fflags', '+genpts', '-y', outputPath ]; const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { this.cleanupTempFile(listFilePath); if (code === 0) { this.logger.verbose('Video concatenation completed'); resolve(); } else { reject(new Error(`Video concatenation failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { this.cleanupTempFile(listFilePath); reject(new Error(`FFmpeg video concatenation error: ${error.message}`)); }); }); } catch (error) { await this.cleanupTempFile(listFilePath); throw error; } } async extractAudioTrack(inputPath, outputPath, trackIndex) { return new Promise((resolve, reject) => { const args = [ '-i', inputPath, '-map', `0:a:${trackIndex}`, '-c:a', 'aac', '-b:a', '192k', '-y', outputPath ]; const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', (code) => { if (code === 0) { this.logger.verbose(`Audio track ${trackIndex} extracted successfully`); resolve(); } else { reject(new Error(`Audio extraction failed for track ${trackIndex} with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg audio extraction error: ${error.message}`)); }); }); } async mergeAllStreams(videoPath, audioTracks, subtitleTracks, introDuration, outputPath) { return new Promise((resolve, reject) => { const args = ['-i', videoPath]; for (const audio of audioTracks) { args.push('-itsoffset', introDuration.toString(), '-i', audio.path); } args.push('-map', '0:v:0'); for (let i = 0; i < audioTracks.length; i++) { args.push('-map', `${i + 1}:a:0`); } args.push('-c:v', 'copy'); for (let i = 0; i < audioTracks.length; i++) { args.push('-c:a:' + i, 'aac'); args.push('-b:a:' + i, '192k'); } for (let i = 0; i < audioTracks.length; i++) { const track = audioTracks[i].track; if (track.language && track.language !== 'unknown') { args.push(`-metadata:s:a:${i}`, `language=${track.language}`); } if (track.title) { args.push(`-metadata:s:a:${i}`, `title=${track.title}`); } } args.push('-avoid_negative_ts', 'make_zero'); args.push('-y', outputPath); this.logger.verbose(`Merging video and audio streams with command: ffmpeg ${args.join(' ')}`); const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', async (code) => { if (code === 0) { this.logger.verbose('Video and audio streams merged successfully'); if (subtitleTracks.length > 0) { await this.addSubtitlesWithOffset(outputPath, subtitleTracks, introDuration); } resolve(); } else { reject(new Error(`Stream merging failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { reject(new Error(`FFmpeg stream merging error: ${error.message}`)); }); }); } async addSubtitlesWithOffset(videoPath, subtitleTracks, introDuration) { if (subtitleTracks.length === 0) { this.logger.verbose('No subtitles to add, skipping subtitle processing'); return; } const tempVideoPath = videoPath + '.temp.mp4'; const tempDir = path.dirname(subtitleTracks[0].path); try { this.logger.verbose('Processing subtitles with offset'); const offsetSubtitles = []; for (let i = 0; i < subtitleTracks.length; i++) { const subtitle = subtitleTracks[i]; const offsetSubPath = path.join(tempDir, `subtitle_offset_${i}.ass`); await this.offsetSubtitleFile(subtitle.path, offsetSubPath, introDuration); offsetSubtitles.push({ ...subtitle, path: offsetSubPath }); } return new Promise((resolve, reject) => { const args = ['-i', videoPath]; for (const subtitle of offsetSubtitles) { args.push('-i', subtitle.path); } args.push('-map', '0'); for (let i = 0; i < offsetSubtitles.length; i++) { args.push('-map', `${i + 1}:s:0`); } args.push('-c:v', 'copy'); args.push('-c:a', 'copy'); for (let i = 0; i < offsetSubtitles.length; i++) { args.push('-c:s:' + i, 'mov_text'); } for (let i = 0; i < offsetSubtitles.length; i++) { const track = offsetSubtitles[i].track; if (track.language && track.language !== 'unknown') { args.push(`-metadata:s:s:${i}`, `language=${track.language}`); } if (track.title) { args.push(`-metadata:s:s:${i}`, `title=${track.title}`); } if (track.forced) { args.push(`-disposition:s:${i}`, 'forced'); } if (track.default) { args.push(`-disposition:s:${i}`, 'default'); } } args.push('-avoid_negative_ts', 'make_zero'); args.push('-y', tempVideoPath); this.logger.verbose(`Adding subtitles: ffmpeg ${args.join(' ')}`); const ffmpeg = spawn('ffmpeg', args); let stderr = ''; ffmpeg.stderr.on('data', (data) => { stderr += data.toString(); }); ffmpeg.on('close', async (code) => { if (code === 0) { try { await fs.rename(tempVideoPath, videoPath); this.logger.verbose('Subtitles added successfully'); resolve(); } catch (error) { reject(new Error(`Failed to replace video file: ${error.message}`)); } } else { await this.cleanupTempFile(tempVideoPath); reject(new Error(`Adding subtitles failed with code ${code}: ${stderr}`)); } }); ffmpeg.on('error', (error) => { this.cleanupTempFile(tempVideoPath); reject(new Error(`FFmpeg subtitle adding error: ${error.message}`)); }); }); } catch (error) { throw new Error(`Subtitle offset processing failed: ${error.message}`); } } async offsetSubtitleFile(inputPath, outputPath, offsetSeconds) { try { const content = await fs.readFile(inputPath, 'utf8'); const timeRegex = /(\d{1,2}):(\d{2}):(\d{2})\.(\d{2})/g; const offsetContent = content.replace(timeRegex, (match, hours, minutes, seconds, centiseconds) => { const totalMs = (parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds)) * 1000 + parseInt(centiseconds) * 10; const offsetMs = totalMs + (offsetSeconds * 1000); const newHours = Math.floor(offsetMs / 3600000); const newMinutes = Math.floor((offsetMs % 3600000) / 60000); const newSeconds = Math.floor((offsetMs % 60000) / 1000); const newCentiseconds = Math.floor((offsetMs % 1000) / 10); return `${newHours.toString().padStart(1, '0')}:${newMinutes.toString().padStart(2, '0')}:${newSeconds.toString().padStart(2, '0')}.${newCentiseconds.toString().padStart(2, '0')}`; }); await fs.writeFile(outputPath, offsetContent, 'utf8'); this.logger.verbose(`Subtitle offset applied: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`); } catch (error) { throw new Error(`Failed to offset subtitle file ${inputPath}: ${error.message}`); } } async cleanupTempDirectory(dirPath) { try { const files = await fs.readdir(dirPath); for (const file of files) { await fs.unlink(path.join(dirPath, file)); } await fs.rmdir(dirPath); this.logger.verbose(`Cleaned up temp directory: ${dirPath}`); } catch (error) { this.logger.verbose(`Failed to cleanup temp directory ${dirPath}: ${error.message}`); } } async cleanupTempFile(filePath) { try { await fs.unlink(filePath); this.logger.verbose(`Cleaned up temp file: ${filePath}`); } catch (error) { this.logger.verbose(`Failed to cleanup temp file ${filePath}: ${error.message}`); } } } module.exports = VideoService;