Files
imperial-command-center/electron/services/FrigateStreamer.ts

178 lines
5.1 KiB
TypeScript

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<void> {
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<string[]> {
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<void> {
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;
}
}