const { EventEmitter } = require('events') const { spawn } = require('child_process') const path = require('path') const fs = require('fs') const { app } = require('electron') class FFmpegManager extends EventEmitter { constructor() { super() this.streams = new Map() // id -> { process, config } this.tolbekProcess = null this.ffmpegPath = this._resolveFfmpegPath() } _resolveFfmpegPath() { // 1. Check bundled ffmpeg in app resources const resourcesPath = process.resourcesPath || path.join(__dirname, '..') const candidates = [ path.join(resourcesPath, 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), path.join(__dirname, '..', 'ffmpeg-bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'), ] for (const p of candidates) { if (fs.existsSync(p)) { console.log('[FFmpeg] Using bundled:', p) return p } } // 2. Fallback to system ffmpeg console.log('[FFmpeg] Using system ffmpeg') return process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' } // Build FFmpeg args for a stream config _buildStreamArgs(config) { const args = [] const platform = process.platform // === VIDEO INPUT === const { videoSource, audioSource } = config if (videoSource.type === 'desktop') { // Screen capture if (platform === 'win32') { args.push('-f', 'gdigrab') const hideCursor = videoSource.hideCursor ? '0' : '1' args.push('-draw_mouse', hideCursor) args.push('-framerate', String(config.framerate || 30)) args.push('-i', 'desktop') } else if (platform === 'darwin') { args.push('-f', 'avfoundation') args.push('-framerate', String(config.framerate || 30)) args.push('-capture_cursor', videoSource.hideCursor ? '0' : '1') args.push('-i', `${videoSource.screenIndex || '1'}:none`) } else { args.push('-f', 'x11grab') const display = process.env.DISPLAY || ':0' args.push('-framerate', String(config.framerate || 30)) if (videoSource.hideCursor) args.push('-draw_mouse', '0') args.push('-i', `${display}`) } } else if (videoSource.type === 'window') { // Window capture if (platform === 'win32') { args.push('-f', 'gdigrab') args.push('-draw_mouse', videoSource.hideCursor ? '0' : '1') args.push('-framerate', String(config.framerate || 30)) args.push('-i', `title=${videoSource.windowTitle}`) } else if (platform === 'darwin') { // macOS window capture via avfoundation index args.push('-f', 'avfoundation') args.push('-framerate', String(config.framerate || 30)) args.push('-i', `${videoSource.deviceIndex || '1'}:none`) } else { args.push('-f', 'x11grab') args.push('-framerate', String(config.framerate || 30)) args.push('-i', process.env.DISPLAY || ':0') } } else if (videoSource.type === 'device') { // Camera or capture card if (platform === 'win32') { args.push('-f', 'dshow') args.push('-framerate', String(config.framerate || 30)) if (config.resolution) args.push('-video_size', config.resolution) args.push('-i', `video=${videoSource.deviceName}`) } else if (platform === 'darwin') { args.push('-f', 'avfoundation') args.push('-framerate', String(config.framerate || 30)) if (config.resolution) args.push('-video_size', config.resolution) args.push('-i', `${videoSource.deviceIndex}:none`) } else { args.push('-f', 'v4l2') args.push('-framerate', String(config.framerate || 30)) if (config.resolution) args.push('-video_size', config.resolution) args.push('-i', videoSource.devicePath || '/dev/video0') } } else if (videoSource.type === 'none') { // No video - generate black frame args.push('-f', 'lavfi', '-i', 'color=black:640x480:rate=25') } // === AUDIO INPUT === if (audioSource && audioSource.type !== 'none') { if (platform === 'win32') { if (audioSource.type === 'system') { // System audio loopback via wasapi args.push('-f', 'dshow') args.push('-i', `audio=${audioSource.deviceName}`) } else { args.push('-f', 'dshow') args.push('-i', `audio=${audioSource.deviceName}`) } } else if (platform === 'darwin') { args.push('-f', 'avfoundation') args.push('-i', `:${audioSource.deviceIndex || '0'}`) } else { if (audioSource.type === 'system') { // PulseAudio monitor for system audio args.push('-f', 'pulse') args.push('-i', audioSource.deviceName || 'default.monitor') } else { args.push('-f', 'alsa') args.push('-i', audioSource.deviceName || 'default') } } } // === VIDEO ENCODING === const videoCodec = this._selectVideoCodec(config.hwAccel) args.push('-c:v', videoCodec) // Codec-specific options if (videoCodec === 'h264_nvenc') { args.push('-preset', 'p4', '-tune', 'ull', '-rc', 'cbr') } else if (videoCodec === 'h264_qsv') { args.push('-preset', 'faster') } else if (videoCodec === 'h264_amf') { args.push('-quality', 'speed', '-rc', 'cbr') } else if (videoCodec === 'h264_videotoolbox') { args.push('-realtime', '1') } else { // libx264 software args.push('-preset', 'ultrafast', '-tune', 'zerolatency') } const videoBitrate = config.videoBitrate || '2000k' args.push('-b:v', videoBitrate) args.push('-maxrate', videoBitrate) args.push('-bufsize', videoBitrate) if (config.resolution) { args.push('-vf', `scale=${config.resolution.replace('x', ':')}`) } args.push('-g', String((config.framerate || 30) * 2)) // keyframe interval // === AUDIO ENCODING === if (audioSource && audioSource.type !== 'none') { args.push('-c:a', 'aac') args.push('-b:a', config.audioBitrate || '128k') args.push('-ar', '44100') } else { args.push('-an') } // === SRT OUTPUT === const { serverAddress, port, srtMode, latency } = config const address = serverAddress.replace(/^(srt:\/\/|rtmp:\/\/|http:\/\/|https:\/\/)/, '') const lat = latency || 200 const mode = srtMode || 'caller' let srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316` args.push('-f', 'mpegts') args.push(srtUrl) return args } _selectVideoCodec(hwAccel) { if (!hwAccel || hwAccel === 'none') return 'libx264' if (hwAccel === 'auto') { // Will be detected at runtime; return in preference order if (process.platform === 'darwin') return 'h264_videotoolbox' return 'h264_nvenc' // Will fallback in startStream if unavailable } const codecMap = { 'nvenc': 'h264_nvenc', 'qsv': 'h264_qsv', 'amf': 'h264_amf', 'videotoolbox': 'h264_videotoolbox', 'vaapi': 'h264_vaapi', 'software': 'libx264' } return codecMap[hwAccel] || 'libx264' } async startStream(config) { const { id } = config if (this.streams.has(id)) { this.stopStream(id) } const args = this._buildStreamArgs(config) console.log(`[FFmpeg] Starting stream ${id}:`, this.ffmpegPath, args.join(' ')) return new Promise((resolve, reject) => { const proc = spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }) let started = false let errorBuffer = '' proc.stderr.on('data', (data) => { const text = data.toString() errorBuffer += text this.emit('log', { id, text }) if (!started && (text.includes('Output #0') || text.includes('frame='))) { started = true resolve({ pid: proc.pid }) } }) proc.on('error', (err) => { console.error(`[FFmpeg] Process error for ${id}:`, err) if (!started) reject(err) this.emit('error', { id, error: err.message }) this.streams.delete(id) }) proc.on('exit', (code, signal) => { console.log(`[FFmpeg] Stream ${id} exited: code=${code} signal=${signal}`) this.streams.delete(id) this.emit('ended', { id, code, signal }) // If hwAccel failed, retry with software encoding if (!started && code !== 0 && config.hwAccel !== 'software') { console.log(`[FFmpeg] HW encode failed for ${id}, retrying with software...`) this.startStream({ ...config, hwAccel: 'software' }) .then(resolve) .catch(reject) } else if (!started) { reject(new Error(`FFmpeg exited with code ${code}\n${errorBuffer}`)) } }) // Start timeout setTimeout(() => { if (!started) { started = true resolve({ pid: proc.pid }) } }, 5000) this.streams.set(id, { process: proc, config }) }) } stopStream(id) { const stream = this.streams.get(id) if (stream) { console.log(`[FFmpeg] Stopping stream ${id}`) if (process.platform === 'win32') { stream.process.kill('SIGTERM') } else { stream.process.kill('SIGINT') } this.streams.delete(id) } } stopAllStreams() { for (const [id] of this.streams) { this.stopStream(id) } this.stopTolbek() } getActiveCount() { return this.streams.size + (this.tolbekProcess ? 1 : 0) } getActiveStreams() { const result = [] for (const [id, { config }] of this.streams) { result.push({ id, config }) } return result } // === TOLBEK (SRT RECEIVER) === async startTolbek(config) { if (this.tolbekProcess) { this.stopTolbek() } const { port, latency, mode, serverAddress } = config const lat = (latency || 200) * 1000 const srtMode = mode || 'listener' let srtUrl if (srtMode === 'listener') { srtUrl = `srt://0.0.0.0:${port}?mode=listener&latency=${lat}` } else if (srtMode === 'caller') { const addr = (serverAddress || '').replace(/^srt:\/\//, '') srtUrl = `srt://${addr}:${port}?mode=caller&latency=${lat}` } else { // rendezvous const addr = (serverAddress || '0.0.0.0').replace(/^srt:\/\//, '') srtUrl = `srt://${addr}:${port}?mode=rendezvous&latency=${lat}` } const args = [ '-fflags', 'nobuffer', '-flags', 'low_delay', '-f', 'mpegts', '-i', srtUrl, '-f', 'null', '-' ] console.log('[FFmpeg] Starting Tolbek receiver:', args.join(' ')) return new Promise((resolve, reject) => { const proc = spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }) let started = false proc.stderr.on('data', (data) => { const text = data.toString() this.emit('log', { id: 'tolbek', text }) if (!started) { started = true resolve({ pid: proc.pid }) } }) proc.on('error', (err) => { if (!started) reject(err) this.emit('error', { id: 'tolbek', error: err.message }) this.tolbekProcess = null }) proc.on('exit', (code) => { this.tolbekProcess = null this.emit('ended', { id: 'tolbek', code }) }) setTimeout(() => { if (!started) { started = true; resolve({ pid: proc.pid }) } }, 3000) this.tolbekProcess = proc }) } stopTolbek() { if (this.tolbekProcess) { console.log('[FFmpeg] Stopping Tolbek receiver') this.tolbekProcess.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT') this.tolbekProcess = null } } } module.exports = new FFmpegManager()