streamflix-api / src /shared /modules /hls-conversion /hls-conversion.service.ts
Akshar2325
feat(hls-conversion): implement adaptive HLS conversion with multiple profiles and upload to MinIO
f8451c5
Raw
History Blame
13.9 kB
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<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}`);
}
}
}