Spaces:
Runtime error
Runtime error
Akshar2325
feat(hls-conversion): implement adaptive HLS conversion with multiple profiles and upload to MinIO
f8451c5 | import { Injectable, Logger } from '@nestjs/common'; | |
| import * as fs from 'fs/promises'; | |
| import { existsSync } from 'fs'; | |
| import * as path from 'path'; | |
| import * as os from 'os'; | |
| import ffmpeg from 'fluent-ffmpeg'; | |
| import ffmpegInstaller from '@ffmpeg-installer/ffmpeg'; | |
| import ffprobeInstaller from '@ffprobe-installer/ffprobe'; | |
| interface ConversionResult { | |
| m3u8Path: string; | |
| segmentPaths: string[]; | |
| m3u8Content: string; | |
| duration: number; | |
| segmentCount: number; | |
| } | |
| export interface AdaptiveProfile { | |
| name: string; // e.g., '480p' | |
| width?: number; | |
| height?: number; | |
| videoBitrate?: string; // e.g., '1500k' | |
| maxrate?: string; // e.g., '1800k' | |
| bufsize?: string; // e.g., '3000k' | |
| audioBitrate?: string; // e.g., '128k' | |
| preset?: string; // x264 preset | |
| videoCodec?: string; // e.g., 'libx264' or 'h264_nvenc' | |
| audioCodec?: string; // e.g., 'aac' | |
| isAudioOnly?: boolean; | |
| } | |
| export interface AdaptiveConversionResult { | |
| masterM3u8Path: string; | |
| renditions: Array<{ | |
| name: string; | |
| m3u8Path: string; | |
| segmentPaths: string[]; | |
| bandwidth: number; | |
| resolution?: string; // e.g., 1280x720 | |
| codecs: string; | |
| duration: number; | |
| segmentCount: number; | |
| }>; | |
| outputDir: string; | |
| } | |
| /** | |
| * HlsConversionService | |
| * Pure video-to-HLS conversion service | |
| * - Converts video files to HLS format using FFmpeg | |
| * - Returns file paths for storage | |
| * - Does NOT handle uploading or database operations | |
| * - Can be reused by any storage provider | |
| */ | |
| () | |
| export class HlsConversionService { | |
| private readonly logger = new Logger(HlsConversionService.name); | |
| // Use system temp directory - works on all OS and servers | |
| // Windows: C:\Users\...\AppData\Local\Temp\streamflix-hls | |
| // Linux: /tmp/streamflix-hls | |
| private readonly tempDir = path.join(os.tmpdir(), 'streamflix-hls'); | |
| constructor() { | |
| // Set FFmpeg and FFprobe paths | |
| ffmpeg.setFfmpegPath(ffmpegInstaller.path); | |
| ffmpeg.setFfprobePath(ffprobeInstaller.path); | |
| this.logger.log(`✅ FFmpeg path: ${ffmpegInstaller.path}`); | |
| this.logger.log(`✅ FFprobe path: ${ffprobeInstaller.path}`); | |
| // Ensure HLS directory exists | |
| this.ensureDirectories(); | |
| } | |
| /** | |
| * Ensure required directories exist | |
| */ | |
| private async ensureDirectories(): Promise<void> { | |
| try { | |
| await fs.mkdir(this.tempDir, { recursive: true }); | |
| this.logger.log(`📁 HLS directory ready: ${this.tempDir}`); | |
| } catch (error) { | |
| this.logger.error(`Failed to create HLS directory: ${error.message}`); | |
| } | |
| } | |
| /** | |
| * Convert video to HLS stream (single quality, simple conversion) | |
| * Returns paths to generated files for storage-agnostic handling | |
| */ | |
| async convertVideoToHLS( | |
| uploadId: string, | |
| videoPath: string, | |
| ): Promise<ConversionResult> { | |
| // Use a per-upload subdirectory to avoid spaces in paths for ffmpeg | |
| const workDir = path.join(this.tempDir, uploadId); | |
| // Ensure work directory exists | |
| await fs.mkdir(workDir, { recursive: true }); | |
| try { | |
| this.logger.log(`🎬 Converting video: ${uploadId}`); | |
| // Get video metadata | |
| const metadata = await this.getVideoMetadata(videoPath); | |
| this.logger.log(`Video metadata: ${JSON.stringify(metadata)}`); | |
| // Define output paths in temp folder | |
| const m3u8Path = path.join(workDir, 'index.m3u8'); | |
| const segmentPattern = path.join(workDir, 'segment_%03d.ts'); | |
| this.logger.log(`Converting video to HLS...`); | |
| this.logger.log(`Output playlist: ${m3u8Path}`); | |
| this.logger.log(`Segment pattern: ${segmentPattern}`); | |
| // Convert to HLS (single quality) | |
| await this.convertToHLS(videoPath, m3u8Path, segmentPattern); | |
| // Get all generated segment files | |
| const files = await fs.readdir(workDir); | |
| const segmentFiles = files.filter((f) => f.endsWith('.ts')); | |
| const segmentPaths = segmentFiles.map((f) => path.join(workDir, f)); | |
| // Read m3u8 content | |
| const m3u8Content = await fs.readFile(m3u8Path, 'utf-8'); | |
| this.logger.log(`✅ Video conversion completed: ${uploadId}`); | |
| this.logger.log(`📦 Generated ${segmentFiles.length} segments`); | |
| // Return result with file paths for caller to handle storage | |
| return { | |
| m3u8Path, | |
| segmentPaths, | |
| m3u8Content, | |
| duration: metadata.duration, | |
| segmentCount: segmentFiles.length, | |
| }; | |
| } catch (error) { | |
| this.logger.error(`❌ Video conversion failed: ${error.message}`); | |
| // Cleanup on error | |
| await this.cleanupFiles(uploadId); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Convert video to HLS format (simple single quality) | |
| */ | |
| private convertToHLS( | |
| inputPath: string, | |
| outputPath: string, | |
| segmentPattern: string, | |
| ): Promise<void> { | |
| return new Promise((resolve, reject) => { | |
| ffmpeg(inputPath) | |
| .outputOptions([ | |
| // Video encoding | |
| '-c:v', | |
| 'libx264', | |
| '-preset', | |
| 'medium', | |
| '-crf', | |
| '23', | |
| '-profile:v', | |
| 'high', | |
| '-level', | |
| '4.0', | |
| // Audio encoding | |
| '-c:a', | |
| 'aac', | |
| '-b:a', | |
| '128k', | |
| '-ac', | |
| '2', | |
| '-ar', | |
| '48000', | |
| // HLS settings | |
| '-hls_time', | |
| '6', | |
| '-hls_list_size', | |
| '0', | |
| '-hls_playlist_type', | |
| 'vod', | |
| '-f', | |
| 'hls', | |
| // Optimization | |
| '-sc_threshold', | |
| '0', | |
| '-g', | |
| '48', | |
| '-keyint_min', | |
| '48', | |
| ]) | |
| .output(outputPath) | |
| .addOption('-hls_segment_filename', segmentPattern) | |
| .on('progress', (progress) => { | |
| if (progress.percent) { | |
| this.logger.log( | |
| `Conversion progress: ${progress.percent.toFixed(1)}%`, | |
| ); | |
| } | |
| }) | |
| .on('end', () => { | |
| this.logger.log('✅ HLS conversion complete'); | |
| resolve(); | |
| }) | |
| .on('error', (err) => { | |
| this.logger.error(`❌ HLS conversion failed: ${err.message}`); | |
| reject(err); | |
| }) | |
| .run(); | |
| }); | |
| } | |
| /** | |
| * Adaptive HLS conversion: 480p, 720p, 1080p + audio-only and master playlist | |
| */ | |
| async convertVideoToAdaptiveHLS( | |
| uploadId: string, | |
| videoPath: string, | |
| profiles?: AdaptiveProfile[], | |
| ): Promise<AdaptiveConversionResult> { | |
| const workDir = path.join(this.tempDir, uploadId); | |
| await fs.mkdir(workDir, { recursive: true }); | |
| // Detect if input has audio track; skip audio-only profile if absent | |
| const inputMeta = await this.getVideoMetadata(videoPath); | |
| const hasAudio = Boolean(inputMeta?.audioCodec); | |
| const defaultProfiles: AdaptiveProfile[] = [ | |
| { | |
| name: '480p', | |
| width: 854, | |
| height: 480, | |
| videoBitrate: '1200k', | |
| maxrate: '1500k', | |
| bufsize: '3000k', | |
| audioBitrate: '128k', | |
| preset: 'veryfast', | |
| videoCodec: 'libx264', | |
| audioCodec: 'aac', | |
| }, | |
| { | |
| name: '720p', | |
| width: 1280, | |
| height: 720, | |
| videoBitrate: '3000k', | |
| maxrate: '3500k', | |
| bufsize: '7000k', | |
| audioBitrate: '128k', | |
| preset: 'veryfast', | |
| videoCodec: 'libx264', | |
| audioCodec: 'aac', | |
| }, | |
| { | |
| name: '1080p', | |
| width: 1920, | |
| height: 1080, | |
| videoBitrate: '5500k', | |
| maxrate: '6500k', | |
| bufsize: '12000k', | |
| audioBitrate: '128k', | |
| preset: 'veryfast', | |
| videoCodec: 'libx264', | |
| audioCodec: 'aac', | |
| }, | |
| { | |
| name: 'audio', | |
| isAudioOnly: true, | |
| audioBitrate: '128k', | |
| audioCodec: 'aac', | |
| }, | |
| ]; | |
| let useProfiles = profiles && profiles.length ? profiles : defaultProfiles; | |
| if (!hasAudio) { | |
| useProfiles = useProfiles.filter((p) => !p.isAudioOnly); | |
| this.logger.log( | |
| 'ℹ️ Input has no audio track. Skipping audio-only HLS rendition.', | |
| ); | |
| } | |
| const renditions: AdaptiveConversionResult['renditions'] = []; | |
| for (const p of useProfiles) { | |
| this.logger.log( | |
| `🎬 Converting ${p.isAudioOnly ? 'audio-only' : p.name} rendition...`, | |
| ); | |
| const m3u8Name = p.isAudioOnly | |
| ? 'index_audio.m3u8' | |
| : `index_${p.name}.m3u8`; | |
| const m3u8Path = path.join(workDir, m3u8Name); | |
| const segmentPattern = p.isAudioOnly | |
| ? path.join(workDir, 'segment_audio_%03d.ts') | |
| : path.join(workDir, `segment_${p.name}_%03d.ts`); | |
| const args: string[] = ['-i', videoPath]; | |
| if (p.isAudioOnly) { | |
| args.push( | |
| '-vn', | |
| '-c:a', | |
| p.audioCodec || 'aac', | |
| '-b:a', | |
| p.audioBitrate || '128k', | |
| ); | |
| } else { | |
| args.push( | |
| '-filter:v', | |
| `scale=${p.width}:${p.height}`, | |
| '-c:v', | |
| p.videoCodec || 'libx264', | |
| '-preset', | |
| p.preset || 'veryfast', | |
| '-b:v', | |
| p.videoBitrate || '3000k', | |
| '-maxrate', | |
| p.maxrate || '3500k', | |
| '-bufsize', | |
| p.bufsize || '7000k', | |
| '-c:a', | |
| p.audioCodec || 'aac', | |
| '-b:a', | |
| p.audioBitrate || '128k', | |
| ); | |
| } | |
| args.push( | |
| '-f', | |
| 'hls', | |
| '-hls_time', | |
| '6', | |
| '-hls_list_size', | |
| '0', | |
| '-hls_playlist_type', | |
| 'vod', | |
| '-hls_segment_filename', | |
| segmentPattern, | |
| m3u8Path, | |
| ); | |
| await this.runRawFfmpeg(args); | |
| const files = await fs.readdir(workDir); | |
| const prefix = p.isAudioOnly ? 'segment_audio_' : `segment_${p.name}_`; | |
| const segmentFiles = files.filter( | |
| (f) => f.startsWith(prefix) && f.endsWith('.ts'), | |
| ); | |
| const segmentPaths = segmentFiles | |
| .map((f) => path.join(workDir, f)) | |
| .sort(); | |
| const meta = await this.getVideoMetadata(videoPath); | |
| const bandwidth = p.isAudioOnly | |
| ? 128000 | |
| : this.estimateBandwidth( | |
| p.videoBitrate || '3000k', | |
| p.audioBitrate || '128k', | |
| ); | |
| const resolution = p.isAudioOnly ? undefined : `${p.width}x${p.height}`; | |
| const codecs = p.isAudioOnly ? 'mp4a.40.2' : 'avc1.64001f,mp4a.40.2'; | |
| renditions.push({ | |
| name: p.name, | |
| m3u8Path, | |
| segmentPaths, | |
| bandwidth, | |
| resolution, | |
| codecs, | |
| duration: meta.duration, | |
| segmentCount: segmentPaths.length, | |
| }); | |
| } | |
| const masterM3u8Path = path.join(workDir, 'master.m3u8'); | |
| const lines: string[] = ['#EXTM3U']; | |
| const audio = renditions.find((r) => r.name === 'audio'); | |
| if (audio) { | |
| lines.push( | |
| `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Audio",DEFAULT=YES,URI="${path.basename(audio.m3u8Path)}"`, | |
| ); | |
| } | |
| for (const r of renditions) { | |
| if (r.name === 'audio') continue; | |
| lines.push( | |
| `#EXT-X-STREAM-INF:BANDWIDTH=${r.bandwidth},RESOLUTION=${r.resolution},CODECS="${r.codecs}"${audio ? ',AUDIO="audio"' : ''}`, | |
| ); | |
| lines.push(path.basename(r.m3u8Path)); | |
| } | |
| await fs.writeFile(masterM3u8Path, lines.join('\n'), 'utf8'); | |
| return { masterM3u8Path, renditions, outputDir: workDir }; | |
| } | |
| private async runRawFfmpeg(args: string[]): Promise<void> { | |
| return new Promise((resolve, reject) => { | |
| const child = require('child_process').spawn(ffmpegInstaller.path, args); | |
| let lastProgress = ''; | |
| let lastLogTime = 0; | |
| // Parse stderr for progress, log sparingly (every 10s) | |
| child.stderr.on('data', (data: Buffer) => { | |
| const line = data.toString(); | |
| if (line.includes('time=')) { | |
| const now = Date.now(); | |
| if (now - lastLogTime > 10000) { | |
| // Log every 10 seconds | |
| const match = line.match(/time=(\S+)/); | |
| if (match) { | |
| lastProgress = match[1]; | |
| this.logger.log(`⏳ Progress: ${lastProgress}`); | |
| lastLogTime = now; | |
| } | |
| } | |
| } | |
| }); | |
| child.on('error', (err: any) => reject(err)); | |
| child.on('close', (code: number) => | |
| code === 0 | |
| ? resolve() | |
| : reject(new Error(`ffmpeg exited with code ${code}`)), | |
| ); | |
| }); | |
| } | |
| private estimateBandwidth( | |
| videoBitrate: string, | |
| audioBitrate: string, | |
| ): number { | |
| const toBits = (kb: string) => parseInt(kb.replace('k', ''), 10) * 1000; | |
| return toBits(videoBitrate) + toBits(audioBitrate); | |
| } | |
| /** | |
| * Get video metadata | |
| */ | |
| private getVideoMetadata(videoPath: string): Promise<any> { | |
| return new Promise((resolve, reject) => { | |
| ffmpeg.ffprobe(videoPath, (err, metadata) => { | |
| if (err) { | |
| reject(err); | |
| } else { | |
| resolve({ | |
| duration: metadata.format.duration, | |
| size: metadata.format.size, | |
| bitrate: metadata.format.bit_rate, | |
| videoCodec: metadata.streams.find((s) => s.codec_type === 'video') | |
| ?.codec_name, | |
| audioCodec: metadata.streams.find((s) => s.codec_type === 'audio') | |
| ?.codec_name, | |
| width: metadata.streams.find((s) => s.codec_type === 'video') | |
| ?.width, | |
| height: metadata.streams.find((s) => s.codec_type === 'video') | |
| ?.height, | |
| }); | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Cleanup temporary HLS files | |
| */ | |
| async cleanupFiles(uploadId: string): Promise<void> { | |
| try { | |
| const workDir = path.join(this.tempDir, uploadId); | |
| if (existsSync(workDir)) { | |
| await fs.rm(workDir, { recursive: true, force: true }); | |
| this.logger.log(`🧹 Cleaned up temp folder: ${workDir}`); | |
| } else { | |
| this.logger.log(`🧹 No temp folder to clean for ${uploadId}`); | |
| } | |
| } catch (error) { | |
| this.logger.warn(`⚠️ Cleanup failed: ${error.message}`); | |
| } | |
| } | |
| } | |