178 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
}
|