feat: v1.2.0 — auto-reconnect, recording, monitor bounds, WASAPI loopback
- Auto-reconnect: stream retries with exponential backoff (2s→30s max) on unexpected exit; stops only when user clicks Stop - Recording: global RecordingBar with folder picker, segment duration (min), per-stream checkbox, Select All; uses FFmpeg tee muxer for simultaneous SRT + segmented .ts file output - Monitor capture: PowerShell System.Windows.Forms.Screen gives exact pixel bounds (x/y/width/height); per-monitor deviceName is now unique (desktop_monitor_N); gdigrab uses -offset_x/-offset_y/-video_size - Window capture: windowTitle now explicitly propagated in handleVideoSelect - System audio: added WASAPI Loopback option (no VB-Audio required), existing virtual-audio-capturer kept as fallback - Reconnecting event forwarded to renderer; status shows "Reconnecting…" with attempt count in logs - IPC: pick-folder (dialog.showOpenDialog) and open-folder (shell.openPath) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+78
-54
@@ -26,8 +26,6 @@ function runCommand(cmd, args, timeoutMs = 10000) {
|
||||
proc.stdout.on('data', d => outBufs.push(d))
|
||||
proc.stderr.on('data', d => errBufs.push(d))
|
||||
const finish = () => {
|
||||
// FFmpeg outputs UTF-8; on Russian Windows system tools may use CP1251,
|
||||
// but FFmpeg is always UTF-8 — safe to decode as utf8 here.
|
||||
const stdout = Buffer.concat(outBufs).toString('utf8')
|
||||
const stderr = Buffer.concat(errBufs).toString('utf8')
|
||||
resolve({ stdout, stderr })
|
||||
@@ -38,12 +36,9 @@ function runCommand(cmd, args, timeoutMs = 10000) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Windows — enumerate application windows via PowerShell ───────────────────
|
||||
// KEY: force UTF-8 output BEFORE any text is written, receive as Buffer,
|
||||
// then decode as UTF-8 — this fixes Cyrillic/Unicode window titles on Russian Windows.
|
||||
// ─── Windows — enumerate open windows via PowerShell (UTF-8 safe) ─────────────
|
||||
function getWindowsWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
// Force UTF-8 console output so Node receives proper Unicode
|
||||
const ps1 = [
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
@@ -56,14 +51,12 @@ function getWindowsWindowsList() {
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
|
||||
// encoding: 'buffer' — receive raw bytes, we decode manually as UTF-8
|
||||
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
|
||||
(err, stdoutBuf) => {
|
||||
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
|
||||
|
||||
// Decode the buffer as UTF-8 (PowerShell wrote UTF-8 BOM-less)
|
||||
// Strip UTF-8 BOM if present (EF BB BF)
|
||||
let buf = stdoutBuf
|
||||
// Strip UTF-8 BOM if present
|
||||
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
|
||||
const stdout = buf.toString('utf8')
|
||||
|
||||
@@ -83,7 +76,57 @@ function getWindowsWindowsList() {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Windows — enumerate dShow devices + monitors ─────────────────────────────
|
||||
// ─── Windows — enumerate monitors with exact pixel bounds ─────────────────────
|
||||
// Uses System.Windows.Forms.Screen (always available on Windows)
|
||||
function getWindowsMonitors() {
|
||||
return new Promise((resolve) => {
|
||||
const ps1 = [
|
||||
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'$OutputEncoding = [System.Text.Encoding]::UTF8;',
|
||||
'Add-Type -AssemblyName System.Windows.Forms;',
|
||||
'[System.Windows.Forms.Screen]::AllScreens',
|
||||
'| Sort-Object { $_.Primary } -Descending', // primary first
|
||||
'| ForEach-Object {',
|
||||
' $b = $_.Bounds;',
|
||||
' Write-Output ("{0}|{1}|{2}|{3}|{4}|{5}" -f $_.DeviceName, $b.X, $b.Y, $b.Width, $b.Height, $_.Primary)',
|
||||
'}'
|
||||
].join(' ')
|
||||
|
||||
execFile(
|
||||
'powershell.exe',
|
||||
['-NonInteractive', '-NoProfile', '-WindowStyle', 'Hidden', '-Command', ps1],
|
||||
{ timeout: 8000, windowsHide: true, encoding: 'buffer' },
|
||||
(err, stdoutBuf) => {
|
||||
if (err || !stdoutBuf || !stdoutBuf.length) { resolve([]); return }
|
||||
|
||||
let buf = stdoutBuf
|
||||
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) buf = buf.slice(3)
|
||||
const stdout = buf.toString('utf8')
|
||||
|
||||
const monitors = []
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
const parts = line.trim().replace(/\r$/, '').split('|')
|
||||
if (parts.length < 5) continue
|
||||
const [deviceName, x, y, w, h, primary] = parts
|
||||
monitors.push({
|
||||
label: (deviceName || '').trim().replace(/\\\\.\\/, '').trim(),
|
||||
bounds: {
|
||||
x: parseInt(x) || 0,
|
||||
y: parseInt(y) || 0,
|
||||
width: parseInt(w) || 1920,
|
||||
height: parseInt(h) || 1080,
|
||||
},
|
||||
primary: primary?.trim().toLowerCase() === 'true'
|
||||
})
|
||||
}
|
||||
|
||||
resolve(monitors)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Windows — dShow devices + monitors + windows ─────────────────────────────
|
||||
async function getWindowsDevices() {
|
||||
const ffmpeg = getFfmpegPath()
|
||||
const { stderr } = await runCommand(ffmpeg, ['-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'])
|
||||
@@ -98,31 +141,39 @@ async function getWindowsDevices() {
|
||||
if (audioMatch) { audio.push({ name: audioMatch[1], deviceName: audioMatch[1], type: 'device' }) }
|
||||
}
|
||||
|
||||
// System audio loopback (works with VB-Audio, Stereo Mix, or virtual-audio-capturer)
|
||||
// System audio options
|
||||
audio.push({
|
||||
name: 'Системный звук (Loopback / Stereo Mix)',
|
||||
name: 'Системный звук (WASAPI Loopback)',
|
||||
deviceName: '__wasapi_loopback__',
|
||||
type: 'system',
|
||||
description: 'Нативный захват Windows — без стороннего ПО'
|
||||
})
|
||||
audio.push({
|
||||
name: 'Системный звук (VB-Audio / Stereo Mix)',
|
||||
deviceName: 'virtual-audio-capturer',
|
||||
type: 'system',
|
||||
description: 'Захват всего выходного звука'
|
||||
description: 'Требует VB-Audio Cable или Stereo Mix'
|
||||
})
|
||||
|
||||
// Screen sources: full desktop
|
||||
// Screen sources
|
||||
const screenDevices = [
|
||||
{ name: 'Рабочий стол (весь экран)', type: 'desktop', deviceName: 'desktop' }
|
||||
]
|
||||
|
||||
// Per-monitor entries
|
||||
// Per-monitor entries with real pixel bounds
|
||||
const monitors = await getWindowsMonitors()
|
||||
monitors.forEach((m, i) => {
|
||||
const label = m.primary ? `${m.label} (основной)` : m.label
|
||||
screenDevices.push({
|
||||
name: `Монитор ${i + 1}${m.label ? ': ' + m.label : ''}`,
|
||||
name: `Монитор ${i + 1}${label ? ': ' + label : ''} — ${m.bounds.width}×${m.bounds.height}`,
|
||||
type: 'desktop',
|
||||
deviceName: 'desktop',
|
||||
monitorIndex: i
|
||||
deviceName: `desktop_monitor_${i}`, // unique key per monitor
|
||||
monitorIndex: i,
|
||||
monitorBounds: m.bounds // { x, y, width, height }
|
||||
})
|
||||
})
|
||||
|
||||
// Enumerate open windows — included directly in video sources
|
||||
// Open application windows
|
||||
const wins = await getWindowsWindowsList()
|
||||
const windowDevices = wins.map(w => ({
|
||||
name: w.title,
|
||||
@@ -138,22 +189,6 @@ async function getWindowsDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getWindowsMonitors() {
|
||||
return new Promise((resolve) => {
|
||||
const t = setTimeout(() => resolve([]), 5000)
|
||||
exec('wmic desktopmonitor get Name /format:list', (err, stdout) => {
|
||||
clearTimeout(t)
|
||||
if (err || !stdout) { resolve([]); return }
|
||||
const monitors = []
|
||||
for (const block of stdout.split(/\r?\n\r?\n/)) {
|
||||
const m = block.match(/Name=(.+)/)
|
||||
if (m && m[1].trim()) monitors.push({ label: m[1].trim() })
|
||||
}
|
||||
resolve(monitors)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── macOS ────────────────────────────────────────────────────────────────────
|
||||
async function getMacDevices() {
|
||||
const ffmpeg = getFfmpegPath()
|
||||
@@ -176,13 +211,10 @@ async function getMacDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS window list via osascript
|
||||
const wins = await getMacWindowsList()
|
||||
const windowDevices = wins.map(w => ({
|
||||
name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title
|
||||
}))
|
||||
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
|
||||
|
||||
return { video: [...video, ...windowDevices], audio }
|
||||
return { video, audio }
|
||||
}
|
||||
|
||||
function getMacWindowsList() {
|
||||
@@ -190,9 +222,8 @@ function getMacWindowsList() {
|
||||
const script = 'tell application "System Events" to get the name of every window of every process whose visible is true'
|
||||
execFile('osascript', ['-e', script], { timeout: 5000 }, (err, stdout) => {
|
||||
if (err || !stdout) { resolve([]); return }
|
||||
const wins = stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
|
||||
.map(title => ({ title, type: 'window' }))
|
||||
resolve(wins)
|
||||
resolve(stdout.trim().split(',').map(s => s.trim()).filter(Boolean)
|
||||
.map(title => ({ title, type: 'window' })))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -201,7 +232,6 @@ function getMacWindowsList() {
|
||||
async function getLinuxDevices() {
|
||||
const video = [], audio = []
|
||||
|
||||
// V4L2 cameras
|
||||
try {
|
||||
const { stdout: devList } = await runCommand('ls', ['/dev/video0', '/dev/video1', '/dev/video2', '/dev/video3', '/dev/video4'])
|
||||
for (const dev of devList.trim().split('\n').filter(Boolean)) {
|
||||
@@ -212,12 +242,9 @@ async function getLinuxDevices() {
|
||||
const display = process.env.DISPLAY || ':0'
|
||||
video.unshift({ name: `Рабочий стол (${display})`, type: 'desktop', devicePath: display })
|
||||
|
||||
// Linux windows
|
||||
const wins = await getLinuxWindowsList()
|
||||
const windowDevices = wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title }))
|
||||
video.push(...windowDevices)
|
||||
video.push(...wins.map(w => ({ name: w.title, deviceName: w.title, type: 'window', windowTitle: w.title })))
|
||||
|
||||
// PulseAudio / ALSA
|
||||
try {
|
||||
const { stdout: paList } = await runCommand('pactl', ['list', 'short', 'sources'])
|
||||
for (const line of paList.split('\n').filter(Boolean)) {
|
||||
@@ -243,14 +270,12 @@ function getLinuxWindowsList() {
|
||||
return new Promise((resolve) => {
|
||||
execFile('wmctrl', ['-l'], { timeout: 3000 }, (err, stdout) => {
|
||||
if (err || !stdout) { resolve([]); return }
|
||||
const wins = stdout.trim().split('\n')
|
||||
resolve(stdout.trim().split('\n')
|
||||
.map(line => {
|
||||
const parts = line.split(/\s+/)
|
||||
const title = parts.slice(3).join(' ').trim()
|
||||
return { title, type: 'window' }
|
||||
return { title: parts.slice(3).join(' ').trim(), type: 'window' }
|
||||
})
|
||||
.filter(w => w.title)
|
||||
resolve(wins)
|
||||
.filter(w => w.title))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -273,7 +298,6 @@ async function getDevices() {
|
||||
return result
|
||||
}
|
||||
|
||||
// Still exposed separately for the refresh button
|
||||
async function getWindows() {
|
||||
if (process.platform === 'win32') return getWindowsWindowsList()
|
||||
if (process.platform === 'darwin') return getMacWindowsList()
|
||||
|
||||
+145
-112
@@ -1,74 +1,79 @@
|
||||
'use strict'
|
||||
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.streams = new Map() // id -> { process, config }
|
||||
this._manualStops = new Set() // IDs stopped by user (no reconnect)
|
||||
this._reconnectTimers = new Map() // id -> setTimeout handle
|
||||
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
|
||||
}
|
||||
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
|
||||
// ─── Build FFmpeg args ────────────────────────────────────────────────────────
|
||||
_buildStreamArgs(config) {
|
||||
const args = []
|
||||
const platform = process.platform
|
||||
|
||||
// === VIDEO INPUT ===
|
||||
const { videoSource, audioSource } = config
|
||||
|
||||
// ── VIDEO INPUT ──────────────────────────────────────────────────────────────
|
||||
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('-draw_mouse', videoSource.hideCursor ? '0' : '1')
|
||||
args.push('-framerate', String(config.framerate || 30))
|
||||
|
||||
// Per-monitor capture: use stored bounds if available
|
||||
if (videoSource.monitorBounds) {
|
||||
const { x, y, width, height } = videoSource.monitorBounds
|
||||
args.push('-offset_x', String(x))
|
||||
args.push('-offset_y', String(y))
|
||||
args.push('-video_size', `${width}x${height}`)
|
||||
}
|
||||
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`)
|
||||
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}`)
|
||||
// Per-monitor: use bounds if available
|
||||
if (videoSource.monitorBounds) {
|
||||
const { x, y, width, height } = videoSource.monitorBounds
|
||||
args.push('-s', `${width}x${height}`)
|
||||
args.push('-i', `${display}+${x},${y}`)
|
||||
} else {
|
||||
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}`)
|
||||
args.push('-i', `title=${videoSource.windowTitle || videoSource.deviceName}`)
|
||||
} 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`)
|
||||
@@ -78,7 +83,6 @@ class FFmpegManager extends EventEmitter {
|
||||
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))
|
||||
@@ -95,18 +99,17 @@ class FFmpegManager extends EventEmitter {
|
||||
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
|
||||
} else {
|
||||
// No video — black frame
|
||||
args.push('-f', 'lavfi', '-i', 'color=black:640x480:rate=25')
|
||||
}
|
||||
|
||||
// === AUDIO INPUT ===
|
||||
// ── 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}`)
|
||||
if (audioSource.deviceName === '__wasapi_loopback__') {
|
||||
// Native WASAPI loopback — captures all system audio, no extra software needed
|
||||
args.push('-f', 'wasapi', '-loopback', '1', '-i', '')
|
||||
} else {
|
||||
args.push('-f', 'dshow')
|
||||
args.push('-i', `audio=${audioSource.deviceName}`)
|
||||
@@ -116,7 +119,6 @@ class FFmpegManager extends EventEmitter {
|
||||
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 {
|
||||
@@ -126,11 +128,10 @@ class FFmpegManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// === VIDEO ENCODING ===
|
||||
// ── 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') {
|
||||
@@ -140,40 +141,49 @@ class FFmpegManager extends EventEmitter {
|
||||
} 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)
|
||||
args.push('-b:v', videoBitrate, '-maxrate', videoBitrate, '-bufsize', videoBitrate)
|
||||
|
||||
if (config.resolution) {
|
||||
args.push('-vf', `scale=${config.resolution.replace('x', ':')}`)
|
||||
}
|
||||
args.push('-g', String((config.framerate || 30) * 2))
|
||||
|
||||
args.push('-g', String((config.framerate || 30) * 2)) // keyframe interval
|
||||
|
||||
// === AUDIO ENCODING ===
|
||||
// ── AUDIO ENCODING ────────────────────────────────────────────────────────────
|
||||
if (audioSource && audioSource.type !== 'none') {
|
||||
args.push('-c:a', 'aac')
|
||||
args.push('-b:a', config.audioBitrate || '128k')
|
||||
args.push('-ar', '44100')
|
||||
args.push('-c:a', 'aac', '-b:a', config.audioBitrate || '128k', '-ar', '44100')
|
||||
} else {
|
||||
args.push('-an')
|
||||
}
|
||||
|
||||
// === SRT OUTPUT ===
|
||||
// ── OUTPUT (SRT / tee with recording) ─────────────────────────────────────────
|
||||
const { serverAddress, port, srtMode, latency } = config
|
||||
const address = serverAddress.replace(/^(srt:\/\/|rtmp:\/\/|http:\/\/|https:\/\/)/, '')
|
||||
const lat = latency || 200
|
||||
const lat = latency || 200
|
||||
const mode = srtMode || 'caller'
|
||||
const srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316`
|
||||
|
||||
let srtUrl = `srt://${address}:${port}?mode=${mode}&latency=${lat * 1000}&pkt_size=1316`
|
||||
if (config.record && config.recordFolder) {
|
||||
// Tee muxer: stream to SRT AND write segmented file simultaneously
|
||||
const segSecs = Math.max(1, config.recordSegmentMinutes || 60) * 60
|
||||
const safeName = (config.name || config.id).replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
|
||||
args.push('-f', 'mpegts')
|
||||
args.push(srtUrl)
|
||||
// Build file path — convert Windows backslashes, escape drive colon for tee muxer
|
||||
let folder = (config.recordFolder + '').replace(/\\/g, '/')
|
||||
if (process.platform === 'win32') {
|
||||
folder = folder.replace(/^([A-Za-z]):/, '$1\\:')
|
||||
}
|
||||
const filePath = `${folder}/${safeName}_%Y%m%d_%H%M%S.ts`
|
||||
const fileOpts = `f=segment:segment_time=${segSecs}:strftime=1:reset_timestamps=1`
|
||||
const teeOut = `[f=mpegts]${srtUrl}|[${fileOpts}]${filePath}`
|
||||
|
||||
args.push('-f', 'tee', teeOut)
|
||||
} else {
|
||||
args.push('-f', 'mpegts', srtUrl)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -181,34 +191,51 @@ class FFmpegManager extends EventEmitter {
|
||||
_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
|
||||
return 'h264_nvenc' // will fallback to software 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'
|
||||
return {
|
||||
nvenc: 'h264_nvenc',
|
||||
qsv: 'h264_qsv',
|
||||
amf: 'h264_amf',
|
||||
videotoolbox: 'h264_videotoolbox',
|
||||
vaapi: 'h264_vaapi',
|
||||
software: 'libx264',
|
||||
}[hwAccel] || 'libx264'
|
||||
}
|
||||
|
||||
// ─── Public: start stream ────────────────────────────────────────────────────
|
||||
async startStream(config) {
|
||||
const { id } = config
|
||||
if (this.streams.has(id)) {
|
||||
this.stopStream(id)
|
||||
|
||||
// Cancel any pending reconnect timer
|
||||
if (this._reconnectTimers.has(id)) {
|
||||
clearTimeout(this._reconnectTimers.get(id))
|
||||
this._reconnectTimers.delete(id)
|
||||
}
|
||||
|
||||
const args = this._buildStreamArgs(config)
|
||||
console.log(`[FFmpeg] Starting stream ${id}:`, this.ffmpegPath, args.join(' '))
|
||||
// Kill existing process for this id if any
|
||||
if (this.streams.has(id)) {
|
||||
const existing = this.streams.get(id)
|
||||
try { existing.process.kill() } catch {}
|
||||
this.streams.delete(id)
|
||||
}
|
||||
|
||||
// Mark as NOT a manual stop (so reconnect will fire on unexpected exits)
|
||||
this._manualStops.delete(id)
|
||||
|
||||
return this._spawn(config, 0, true)
|
||||
}
|
||||
|
||||
// ─── Internal: spawn ffmpeg process ─────────────────────────────────────────
|
||||
_spawn(config, attempt, initial) {
|
||||
const { id } = config
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(this.ffmpegPath, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const args = this._buildStreamArgs(config)
|
||||
console.log(`[FFmpeg] Starting stream ${id} (attempt ${attempt}):`, this.ffmpegPath, args.join(' '))
|
||||
|
||||
const proc = spawn(this.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
||||
|
||||
let started = false
|
||||
let errorBuffer = ''
|
||||
@@ -220,38 +247,57 @@ class FFmpegManager extends EventEmitter {
|
||||
|
||||
if (!started && (text.includes('Output #0') || text.includes('frame='))) {
|
||||
started = true
|
||||
resolve({ pid: proc.pid })
|
||||
if (initial) resolve({ pid: proc.pid })
|
||||
}
|
||||
})
|
||||
|
||||
proc.on('error', (err) => {
|
||||
console.error(`[FFmpeg] Process error for ${id}:`, err)
|
||||
if (!started) reject(err)
|
||||
console.error(`[FFmpeg] Spawn error ${id}:`, err)
|
||||
if (initial && !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}`)
|
||||
console.log(`[FFmpeg] Stream ${id} exited: code=${code} signal=${signal} attempt=${attempt}`)
|
||||
const wasManual = this._manualStops.has(id)
|
||||
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) {
|
||||
// ── Initial start failed — try hwAccel fallback first ─────────────────
|
||||
if (initial && !started && code !== 0 && config.hwAccel !== 'software') {
|
||||
console.log(`[FFmpeg] HW encode failed for ${id}, retrying with software…`)
|
||||
this._spawn({ ...config, hwAccel: 'software' }, 0, true)
|
||||
.then(resolve).catch(reject)
|
||||
return
|
||||
}
|
||||
if (initial && !started) {
|
||||
reject(new Error(`FFmpeg exited with code ${code}\n${errorBuffer}`))
|
||||
return
|
||||
}
|
||||
|
||||
// ── Auto-reconnect on unexpected exit ─────────────────────────────────
|
||||
if (!wasManual) {
|
||||
const delayMs = Math.min(2000 * Math.pow(1.5, attempt), 30000)
|
||||
const nextAttempt = attempt + 1
|
||||
console.log(`[FFmpeg] Scheduling reconnect for ${id} in ${delayMs}ms (attempt ${nextAttempt})`)
|
||||
this.emit('reconnecting', { id, attempt: nextAttempt, delayMs })
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this._reconnectTimers.delete(id)
|
||||
if (!this._manualStops.has(id)) {
|
||||
this._spawn(config, nextAttempt, false)
|
||||
}
|
||||
}, delayMs)
|
||||
this._reconnectTimers.set(id, timer)
|
||||
}
|
||||
})
|
||||
|
||||
// Start timeout
|
||||
// Startup timeout — resolve even if FFmpeg hasn't printed "Output #0" yet
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true
|
||||
resolve({ pid: proc.pid })
|
||||
if (initial) resolve({ pid: proc.pid })
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
@@ -259,23 +305,27 @@ class FFmpegManager extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Public: stop stream ─────────────────────────────────────────────────────
|
||||
stopStream(id) {
|
||||
// Cancel any pending reconnect
|
||||
if (this._reconnectTimers.has(id)) {
|
||||
clearTimeout(this._reconnectTimers.get(id))
|
||||
this._reconnectTimers.delete(id)
|
||||
}
|
||||
|
||||
// Mark as manual stop so reconnect won't fire
|
||||
this._manualStops.add(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')
|
||||
}
|
||||
stream.process.kill(process.platform === 'win32' ? 'SIGTERM' : 'SIGINT')
|
||||
this.streams.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
stopAllStreams() {
|
||||
for (const [id] of this.streams) {
|
||||
this.stopStream(id)
|
||||
}
|
||||
for (const [id] of this.streams) this.stopStream(id)
|
||||
this.stopTolbek()
|
||||
}
|
||||
|
||||
@@ -285,20 +335,16 @@ class FFmpegManager extends EventEmitter {
|
||||
|
||||
getActiveStreams() {
|
||||
const result = []
|
||||
for (const [id, { config }] of this.streams) {
|
||||
result.push({ id, config })
|
||||
}
|
||||
for (const [id, { config }] of this.streams) result.push({ id, config })
|
||||
return result
|
||||
}
|
||||
|
||||
// === TOLBEK (SRT RECEIVER) ===
|
||||
// ─── Tolbek SRT Receiver ─────────────────────────────────────────────────────
|
||||
async startTolbek(config) {
|
||||
if (this.tolbekProcess) {
|
||||
this.stopTolbek()
|
||||
}
|
||||
if (this.tolbekProcess) this.stopTolbek()
|
||||
|
||||
const { port, latency, mode, serverAddress } = config
|
||||
const lat = (latency || 200) * 1000
|
||||
const lat = (latency || 200) * 1000
|
||||
const srtMode = mode || 'listener'
|
||||
|
||||
let srtUrl
|
||||
@@ -308,51 +354,38 @@ class FFmpegManager extends EventEmitter {
|
||||
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,
|
||||
'-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']
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
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)
|
||||
setTimeout(() => { if (!started) { started = true; resolve({ pid: proc.pid }) } }, 3000)
|
||||
|
||||
this.tolbekProcess = proc
|
||||
})
|
||||
|
||||
+22
-7
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog } = require('electron')
|
||||
const { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage, dialog, shell } = require('electron')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const ffmpegManager = require('./ffmpeg')
|
||||
@@ -124,7 +124,7 @@ function loadConfig() {
|
||||
} catch (e) {
|
||||
console.error('Failed to load config:', e)
|
||||
}
|
||||
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 } }
|
||||
return { streams: [], tolbek: { enabled: false, port: 5000, latency: 200 }, recording: { folder: '', segmentMinutes: 60 } }
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
@@ -323,6 +323,19 @@ ipcMain.handle('get-active-streams', () => {
|
||||
return ffmpegManager.getActiveStreams()
|
||||
})
|
||||
|
||||
ipcMain.handle('pick-folder', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow || undefined, {
|
||||
title: 'Выберите папку для записи',
|
||||
properties: ['openDirectory', 'createDirectory']
|
||||
})
|
||||
if (result.canceled || !result.filePaths.length) return null
|
||||
return result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('open-folder', async (_, folderPath) => {
|
||||
try { await shell.openPath(folderPath) } catch {}
|
||||
})
|
||||
|
||||
ipcMain.handle('minimize-window', () => {
|
||||
mainWindow && mainWindow.minimize()
|
||||
})
|
||||
@@ -335,21 +348,23 @@ ipcMain.on('update-tray-count', (_, count) => {
|
||||
updateTrayMenu(count)
|
||||
})
|
||||
|
||||
// Forward FFmpeg log events to renderer
|
||||
// Forward FFmpeg events to renderer
|
||||
ffmpegManager.on('log', (data) => {
|
||||
mainWindow && mainWindow.webContents.send('stream-log', data)
|
||||
})
|
||||
|
||||
ffmpegManager.on('error', (data) => {
|
||||
mainWindow && mainWindow.webContents.send('stream-error', data)
|
||||
const active = ffmpegManager.getActiveCount()
|
||||
updateTrayMenu(active)
|
||||
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||
})
|
||||
|
||||
ffmpegManager.on('ended', (data) => {
|
||||
mainWindow && mainWindow.webContents.send('stream-ended', data)
|
||||
const active = ffmpegManager.getActiveCount()
|
||||
updateTrayMenu(active)
|
||||
updateTrayMenu(ffmpegManager.getActiveCount())
|
||||
})
|
||||
|
||||
ffmpegManager.on('reconnecting', (data) => {
|
||||
mainWindow && mainWindow.webContents.send('stream-reconnecting', data)
|
||||
})
|
||||
|
||||
app.whenReady().then(() => {
|
||||
|
||||
+16
-11
@@ -2,7 +2,7 @@ const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// Config
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// Devices
|
||||
@@ -10,25 +10,30 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getWindows: () => ipcRenderer.invoke('get-windows'),
|
||||
|
||||
// Streams
|
||||
startStream: (config) => ipcRenderer.invoke('start-stream', config),
|
||||
stopStream: (id) => ipcRenderer.invoke('stop-stream', id),
|
||||
getActiveStreams: () => ipcRenderer.invoke('get-active-streams'),
|
||||
startStream: (config) => ipcRenderer.invoke('start-stream', config),
|
||||
stopStream: (id) => ipcRenderer.invoke('stop-stream', id),
|
||||
getActiveStreams: () => ipcRenderer.invoke('get-active-streams'),
|
||||
|
||||
// Tolbek receiver
|
||||
startTolbek: (config) => ipcRenderer.invoke('start-tolbek', config),
|
||||
stopTolbek: () => ipcRenderer.invoke('stop-tolbek'),
|
||||
stopTolbek: () => ipcRenderer.invoke('stop-tolbek'),
|
||||
|
||||
// File system helpers
|
||||
pickFolder: () => ipcRenderer.invoke('pick-folder'),
|
||||
openFolder: (p) => ipcRenderer.invoke('open-folder', p),
|
||||
|
||||
// Window controls
|
||||
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
|
||||
hideWindow: () => ipcRenderer.invoke('hide-window'),
|
||||
hideWindow: () => ipcRenderer.invoke('hide-window'),
|
||||
updateTrayCount: (count) => ipcRenderer.send('update-tray-count', count),
|
||||
|
||||
// Event listeners
|
||||
onStreamStatus: (cb) => ipcRenderer.on('stream-status', (_, data) => cb(data)),
|
||||
onStreamLog: (cb) => ipcRenderer.on('stream-log', (_, data) => cb(data)),
|
||||
onStreamError: (cb) => ipcRenderer.on('stream-error', (_, data) => cb(data)),
|
||||
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, data) => cb(data)),
|
||||
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
|
||||
onStreamStatus: (cb) => ipcRenderer.on('stream-status', (_, d) => cb(d)),
|
||||
onStreamLog: (cb) => ipcRenderer.on('stream-log', (_, d) => cb(d)),
|
||||
onStreamError: (cb) => ipcRenderer.on('stream-error', (_, d) => cb(d)),
|
||||
onStreamEnded: (cb) => ipcRenderer.on('stream-ended', (_, d) => cb(d)),
|
||||
onStreamReconnecting: (cb) => ipcRenderer.on('stream-reconnecting', (_, d) => cb(d)),
|
||||
onAllStreamsStopped: (cb) => ipcRenderer.on('all-streams-stopped', () => cb()),
|
||||
|
||||
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user