Files
srt-streamer/electron/ffmpeg.js
T
admin 1f7dbc2a7d feat: initial release — SRT Streamer v1.0.0
Cross-platform Electron + React + FFmpeg desktop app for sending
multiple SRT streams simultaneously.

Features:
- Multiple simultaneous SRT output streams
- Video sources: desktop, window capture, cameras, capture cards
- Audio sources: microphones, system loopback, sound cards
- H.264 encoding with HW acceleration (NVENC/QSV/AMF/VideoToolbox)
- SRT modes: caller / listener / rendezvous
- Frame profile presets (4K, 1080p, 720p, 480p, 360p)
- Tolbek SRT receiver with configurable mode
- System tray: minimize-to-tray, exit confirmation dialog
- Portable build via electron-builder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 00:37:40 +03:00

371 lines
12 KiB
JavaScript

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()