import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; export class FrigateStreamer extends EventEmitter { private ffmpegProcess: ChildProcess | null = null; private isStreaming: boolean = false; private restartAttempts: number = 0; private readonly MAX_RESTART_ATTEMPTS = 5; private restartTimeout: NodeJS.Timeout | null = null; async start(rtspOutputUrl: string): Promise { if (this.isStreaming) { console.log('Already streaming to Frigate'); return; } try { // Get camera devices const videoDevices = await this.getVideoDevices(); if (videoDevices.length === 0) { throw new Error('No video devices found'); } const videoDevice = videoDevices[0]; // Use first available camera console.log(`Using video device: ${videoDevice}`); // Build FFmpeg command const ffmpegArgs = this.buildFfmpegArgs(videoDevice, rtspOutputUrl); // Start FFmpeg process this.ffmpegProcess = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'], }); this.ffmpegProcess.stdout?.on('data', (data) => { console.log(`FFmpeg stdout: ${data}`); }); this.ffmpegProcess.stderr?.on('data', (data) => { // FFmpeg logs to stderr by default const message = data.toString(); if (message.includes('error') || message.includes('Error')) { console.error(`FFmpeg error: ${message}`); this.emit('error', message); } }); this.ffmpegProcess.on('close', (code) => { console.log(`FFmpeg process exited with code ${code}`); this.isStreaming = false; if (code !== 0 && this.restartAttempts < this.MAX_RESTART_ATTEMPTS) { this.restartAttempts++; console.log(`Restarting FFmpeg (attempt ${this.restartAttempts}/${this.MAX_RESTART_ATTEMPTS})`); this.restartTimeout = setTimeout(() => { this.start(rtspOutputUrl); }, 5000); } else if (this.restartAttempts >= this.MAX_RESTART_ATTEMPTS) { this.emit('maxRestartsReached'); } }); this.ffmpegProcess.on('error', (error) => { console.error('FFmpeg process error:', error); this.emit('error', error); }); this.isStreaming = true; this.restartAttempts = 0; this.emit('started'); console.log(`Streaming to ${rtspOutputUrl}`); } catch (error) { console.error('Failed to start Frigate stream:', error); throw error; } } private async getVideoDevices(): Promise { return new Promise((resolve) => { if (process.platform === 'linux') { // On Linux, check for /dev/video* devices const { exec } = require('child_process'); exec('ls /dev/video* 2>/dev/null', (error: Error | null, stdout: string) => { if (error) { resolve([]); } else { const devices = stdout.trim().split('\n').filter(Boolean); resolve(devices); } }); } else if (process.platform === 'win32') { // On Windows, use DirectShow device listing // For simplicity, return default device name resolve(['video=Integrated Camera']); } else if (process.platform === 'darwin') { // On macOS, use AVFoundation resolve(['0']); // Device index } else { resolve([]); } }); } private buildFfmpegArgs(videoDevice: string, rtspOutputUrl: string): string[] { const baseArgs: string[] = []; if (process.platform === 'linux') { baseArgs.push( '-f', 'v4l2', '-input_format', 'mjpeg', '-framerate', '15', '-video_size', '640x480', '-i', videoDevice ); } else if (process.platform === 'win32') { baseArgs.push( '-f', 'dshow', '-framerate', '15', '-video_size', '640x480', '-i', videoDevice ); } else if (process.platform === 'darwin') { baseArgs.push( '-f', 'avfoundation', '-framerate', '15', '-video_size', '640x480', '-i', videoDevice ); } // Output settings for RTSP baseArgs.push( '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', '-profile:v', 'baseline', '-level', '3.1', '-pix_fmt', 'yuv420p', '-g', '30', // Keyframe interval '-b:v', '1M', '-bufsize', '1M', '-f', 'rtsp', '-rtsp_transport', 'tcp', rtspOutputUrl ); return baseArgs; } async stop(): Promise { if (this.restartTimeout) { clearTimeout(this.restartTimeout); this.restartTimeout = null; } if (this.ffmpegProcess) { this.ffmpegProcess.kill('SIGTERM'); // Force kill if still running after 5 seconds setTimeout(() => { if (this.ffmpegProcess && !this.ffmpegProcess.killed) { this.ffmpegProcess.kill('SIGKILL'); } }, 5000); this.ffmpegProcess = null; } this.isStreaming = false; this.restartAttempts = 0; this.emit('stopped'); } isActive(): boolean { return this.isStreaming; } }