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 */ @Injectable() 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 { 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 { // 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 { 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 { 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 { 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 { 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 { 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}`); } } }