From 089c2421c5f3c9ab8405ddea3c996ec79c319b2f Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 00:30:23 +0900 Subject: [PATCH] Add INI configuration and optional audio capture --- .gitignore | 1 + README.md | 68 +++++++++ config.ini | 86 ++++++++++++ screen-rtsp.ps1 | 356 +++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 464 insertions(+), 47 deletions(-) create mode 100644 config.ini diff --git a/.gitignore b/.gitignore index 42c252c..28d5796 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /mediamtx.yml /logs/ /*.log +/config.local.ini # Local certificates and secrets. /*.crt diff --git a/README.md b/README.md index 014e2b9..a6af7b0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용 - 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지 - 로그인 시 자동 실행 작업 등록 및 제거 +- `config.ini` 기반 기능별 설정과 명령줄 override +- 오디오 캡처/AAC 트랙을 `1` 또는 `0`으로 활성화·비활성화 ## RTSP URL @@ -44,6 +46,7 @@ rtsp://<현재_NIC_IP>:8554/monitor2 ## 구성 - `screen-rtsp.ps1`: 캡처, 오디오 loopback, GPU 선택, MediaMTX/FFmpeg watchdog +- `config.ini`: 영상, 오디오, 네트워크, RTSP 설정과 진단 명령 예시 - `ffmpeg.exe`: `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg - `mediamtx.exe`: RTSP 서버. 현재 검증 기준 버전은 v1.21.1 - `mediamtx.yml`: 없으면 스크립트가 기본 설정을 자동 생성 @@ -54,6 +57,51 @@ rtsp://<현재_NIC_IP>:8554/monitor2 실행 파일과 생성 파일은 저장소에 포함하지 않습니다. 배포할 때 `ffmpeg.exe`와 `mediamtx.exe`를 `screen-rtsp.ps1` 옆에 둡니다. +## 설정 파일 + +기본 설정은 `screen-rtsp.ps1`과 같은 폴더의 `config.ini`에서 읽습니다. + +```ini +[Video] +FrameRate=30 +Bitrate=8000k +EncoderPriority=NVIDIA,AMD,Intel + +[Audio] +Enabled=1 +Bitrate=192k +SignalThreshold=0.0005 +``` + +`Audio.Enabled=1`이면 현재 재생 장치의 시스템 소리를 WASAPI loopback으로 캡처해 AAC 트랙을 추가합니다. `0`이면 오디오 장치 감시, loopback worker, AAC 트랙을 모두 사용하지 않고 영상만 송출합니다. + +줄 앞의 공백을 제외한 첫 문자가 `#`, `;`, `//`이면 설명이나 참고 명령으로 인식해 건너뜁니다. 값 오른쪽의 inline 주석은 지원하지 않으므로 설명은 별도 줄에 작성합니다. 명령줄에서 직접 지정한 값은 INI 값보다 우선합니다. + +알 수 없는 섹션, 설정 이름, 범위를 벗어난 값, `0/1` 이외의 기능 스위치는 시작 전에 오류로 표시합니다. + +| 섹션 | 설정 | 기본값 | 설명 | +|---|---|---:|---| +| `General` | `ChannelCount` | `2` | 송출 채널과 필요한 모니터 수 | +| `General` | `StableTimeSec` | `3` | 정상 상태 안정화 시간 | +| `General` | `PollIntervalSec` | `1` | 상태 확인 주기 | +| `Video` | `FrameRate` | `30` | 화면 프레임률 | +| `Video` | `Bitrate` | `8000k` | 채널별 영상 비트레이트 | +| `Video` | `EncoderPriority` | `NVIDIA,AMD,Intel` | GPU 선택 순서 | +| `Audio` | `Enabled` | `1` | 시스템 재생음과 AAC 트랙 사용 여부 | +| `Audio` | `Bitrate` | `192k` | AAC 비트레이트 | +| `Audio` | `SignalThreshold` | `0.0005` | 실제 재생 장치 판별 peak 기준 | +| `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 | +| `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 | +| `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 | + +GPU, 모니터, 오디오, NIC, FFmpeg 지원, RTSP 포트를 확인하는 PowerShell 명령은 `config.ini` 위쪽에 모두 주석 형태로 포함되어 있습니다. + +INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. + +```powershell +.\screen-rtsp.ps1 -ShowConfiguration +``` + ## 처리 흐름 1. FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다. @@ -80,6 +128,14 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallStartup ``` +등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -StartupStatus +Stop-ScheduledTask -TaskName "Screen RTSP Watchdog" +Start-ScheduledTask -TaskName "Screen RTSP Watchdog" +``` + 자동 실행 제거: ```powershell @@ -88,6 +144,18 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -Unins ## 실행 옵션 +다른 설정 파일을 사용하는 예시: + +```powershell +.\screen-rtsp.ps1 -ConfigPath C:\RTSP\config.ini +``` + +명령줄 값은 `config.ini`보다 우선합니다. 다음 실행에서만 오디오를 끄는 예시: + +```powershell +.\screen-rtsp.ps1 -AudioEnabled 0 +``` + GPU 우선순위를 바꾸는 예시: ```powershell diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..5e181aa --- /dev/null +++ b/config.ini @@ -0,0 +1,86 @@ +# ============================================================ +# RTSP Watchdog 설정 +# ============================================================ +# +# 주석/설명 규칙 +# - 줄의 첫 문자가 #, ;, // 중 하나이면 설명으로 처리하고 건너뜁니다. +# - 앞쪽 공백 뒤에 #, ;, //가 있어도 설명으로 처리합니다. +# - 값 오른쪽에는 주석을 붙이지 마십시오. 설명은 별도 줄에 작성합니다. +# - 경로와 장치명은 따옴표 없이 적어도 됩니다. +# - 기능 스위치는 1=사용, 0=사용 안 함입니다. +# - 명령줄에서 지정한 값은 이 파일보다 우선합니다. +; 세미콜론으로 시작하는 이 줄도 설명으로 처리됩니다. +// 슬래시 두 개로 시작하는 이 줄도 설명으로 처리됩니다. +# +# 실행 +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -ShowConfiguration +# +# 별도 설정 파일 사용 +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -ConfigPath .\config.ini +# +# 자동 실행 등록 / 상태 확인 / 즉시 실행 / 제거 +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallStartup +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -StartupStatus +# Start-ScheduledTask -TaskName "Screen RTSP Watchdog" +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -UninstallStartup +# +# GPU와 FFmpeg 지원 확인 +# Get-CimInstance Win32_VideoController | Select-Object Name,DriverVersion,Status +# .\ffmpeg.exe -hide_banner -filters | Select-String ddagrab +# .\ffmpeg.exe -hide_banner -encoders | Select-String 'h264_(nvenc|amf|qsv)' +# +# 모니터 확인 +# Add-Type -AssemblyName System.Windows.Forms +# [System.Windows.Forms.Screen]::AllScreens | Select-Object DeviceName,Primary,Bounds +# +# 오디오 장치 확인 +# Get-PnpDevice -Class AudioEndpoint | Select-Object Status,FriendlyName,InstanceId +# Get-CimInstance Win32_SoundDevice | Select-Object Name,Status,PNPDeviceID +# +# 네트워크 확인 +# Get-NetAdapter | Select-Object Name,InterfaceDescription,ifIndex,Status,LinkSpeed +# Get-NetIPAddress -AddressFamily IPv4 | Select-Object InterfaceAlias,InterfaceIndex,IPAddress +# Get-NetRoute -AddressFamily IPv4 -DestinationPrefix 0.0.0.0/0 +# ping.exe -S 192.168.50.34 192.168.50.1 +# +# RTSP 포트와 수신 시험 +# Get-NetTCPConnection -LocalPort 8554 -State Listen +# .\ffmpeg.exe -hide_banner -rtsp_transport tcp -i rtsp://127.0.0.1:8554/monitor1 -t 5 -f null NUL + +[General] +# 생성할 monitor 채널 수. 이 수만큼 캡처 가능한 활성 모니터가 필요합니다. +ChannelCount=2 + +# 모든 조건이 정상인 상태로 유지되어야 하는 시간입니다. +StableTimeSec=3 + +# 모니터, NIC, GPU, 오디오 상태 확인 주기입니다. +PollIntervalSec=1 + +[Video] +FrameRate=30 +Bitrate=8000k + +# 앞에 있는 GPU부터 실제 1프레임 인코더 초기화를 시험합니다. +EncoderPriority=NVIDIA,AMD,Intel + +[Audio] +# 시스템 재생음 녹음 및 RTSP AAC 트랙: 1=사용, 0=사용 안 함 +Enabled=1 +Bitrate=192k + +# 실제 소리가 출력되는 장치를 판별할 peak 최소값입니다. 범위는 0.0~1.0입니다. +SignalThreshold=0.0005 + +[Network] +# 비워 두면 IPv4 기본 경로의 활성 NIC를 자동 선택합니다. +# 특정 장치를 강제하려면 Get-NetAdapter의 InterfaceDescription을 그대로 적습니다. +NicDescription= + +# 비워 두면 Source IP ping 검사를 하지 않습니다. +# 예: 192.168.50.1 +HealthTarget= + +[RTSP] +Port=8554 diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index 1c0d13f..72c31aa 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -20,6 +20,12 @@ param( [ValidatePattern("^[0-9]+[kKmM]$")] [string]$AudioBitrate = "192k", + [ValidateSet(0, 1)] + [int]$AudioEnabled = 1, + + [ValidateRange(0.0, 1.0)] + [double]$AudioSignalThreshold = 0.0005, + [string]$NicDescription = "", [string]$HealthTarget = "", @@ -32,8 +38,12 @@ param( [ValidateRange(1, 65535)] [int]$RtspPort = 8554, + [string]$ConfigPath = "", + [switch]$InstallStartup, [switch]$UninstallStartup, + [switch]$StartupStatus, + [switch]$ShowConfiguration, [Parameter(DontShow = $true)] [switch]$AudioWorker, @@ -46,13 +56,9 @@ param( ) $ErrorActionPreference = "Stop" - -if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { - $EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() }) - $invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") } - if ($invalidEncoderVendor) { - throw "잘못된 EncoderPriorityCsv 값: $($invalidEncoderVendor -join ', ')" - } +$ExplicitParameters = @{} +foreach ($parameterName in $PSBoundParameters.Keys) { + $ExplicitParameters[$parameterName] = $true } # ============================================================ @@ -485,6 +491,10 @@ namespace ScreenRtsp.Audio ThrowIfFailed(client.Start(), "IAudioClient.Start"); byte[] buffer = new byte[1024 * 1024]; + int silenceFrames = Math.Max(1, (int)(format.SamplesPerSec / 100)); + int silenceBytes = checked(silenceFrames * format.BlockAlign); + System.Diagnostics.Stopwatch packetClock = + System.Diagnostics.Stopwatch.StartNew(); while (pipe.IsConnected) { uint packetFrames; @@ -493,7 +503,17 @@ namespace ScreenRtsp.Audio if (packetFrames == 0) { - Thread.Sleep(3); + // Some endpoints stop producing loopback packets while + // completely silent or power-saving. Keep FFmpeg and + // RTSP clocks moving with one 10 ms silent PCM block. + if (packetClock.ElapsedMilliseconds >= 10) + { + if (buffer.Length < silenceBytes) buffer = new byte[silenceBytes]; + Array.Clear(buffer, 0, silenceBytes); + pipe.Write(buffer, 0, silenceBytes); + packetClock.Restart(); + } + Thread.Sleep(2); continue; } @@ -517,6 +537,7 @@ namespace ScreenRtsp.Audio try { pipe.Write(buffer, 0, bytes); + packetClock.Restart(); } finally { @@ -577,6 +598,180 @@ if ([string]::IsNullOrWhiteSpace($BaseDir)) { $BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path } +function Read-IniConfiguration { + param([string]$Path) + + $result = @{} + $sectionName = "General" + $result[$sectionName] = @{} + $lineNumber = 0 + + foreach ($rawLine in (Get-Content -LiteralPath $Path)) { + $lineNumber++ + $line = $rawLine.Trim() + + if ([string]::IsNullOrWhiteSpace($line) -or + $line.StartsWith("#") -or + $line.StartsWith(";") -or + $line.StartsWith("//")) { + continue + } + + if ($line -match '^\[([^\]]+)\]$') { + $sectionName = $Matches[1].Trim() + if (-not $result.ContainsKey($sectionName)) { + $result[$sectionName] = @{} + } + continue + } + + if ($line -notmatch '^([^=]+)=(.*)$') { + throw "config.ini $lineNumber 번째 줄을 해석할 수 없습니다: $rawLine" + } + + $key = $Matches[1].Trim() + $value = $Matches[2].Trim() + if ([string]::IsNullOrWhiteSpace($key)) { + throw "config.ini $lineNumber 번째 줄의 설정 이름이 비어 있습니다." + } + $hasDoubleQuotes = $value.StartsWith('"') -and $value.EndsWith('"') + $hasSingleQuotes = $value.StartsWith("'") -and $value.EndsWith("'") + if ($value.Length -ge 2 -and ($hasDoubleQuotes -or $hasSingleQuotes)) { + $value = $value.Substring(1, $value.Length - 2) + } + $result[$sectionName][$key] = $value + } + + return $result +} + +function Get-IniSetting { + param($Configuration, [string]$Section, [string]$Key) + if ($Configuration.ContainsKey($Section) -and $Configuration[$Section].ContainsKey($Key)) { + return [PSCustomObject]@{ Found = $true; Value = $Configuration[$Section][$Key] } + } + return [PSCustomObject]@{ Found = $false; Value = $null } +} + +function Convert-IniSwitch { + param([string]$Value, [string]$Name) + if ($Value -notin @("0", "1")) { + throw "$Name 값은 1 또는 0이어야 합니다: $Value" + } + return [int]$Value +} + +function Assert-IniSettings { + param($Configuration) + $allowed = @{ + General = @("ChannelCount", "StableTimeSec", "PollIntervalSec") + Video = @("FrameRate", "Bitrate", "EncoderPriority") + Audio = @("Enabled", "Bitrate", "SignalThreshold") + Network = @("NicDescription", "HealthTarget") + RTSP = @("Port") + } + foreach ($sectionName in $Configuration.Keys) { + if (-not $allowed.ContainsKey($sectionName)) { + throw "config.ini에 알 수 없는 섹션이 있습니다: [$sectionName]" + } + foreach ($key in $Configuration[$sectionName].Keys) { + if ($allowed[$sectionName] -notcontains $key) { + throw "config.ini에 알 수 없는 설정이 있습니다: [$sectionName] $key" + } + } + } +} + +if ([string]::IsNullOrWhiteSpace($ConfigPath)) { + $ConfigPath = Join-Path $BaseDir "config.ini" +} +elseif (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { + $ConfigPath = Join-Path $BaseDir $ConfigPath +} +$ConfigPath = [System.IO.Path]::GetFullPath($ConfigPath) + +$ConfigWasLoaded = $false +$ini = @{} +if (Test-Path -LiteralPath $ConfigPath -PathType Leaf) { + $ini = Read-IniConfiguration -Path $ConfigPath + Assert-IniSettings $ini + $ConfigWasLoaded = $true +} +elseif ($ExplicitParameters.ContainsKey("ConfigPath")) { + throw "지정한 설정 파일을 찾을 수 없습니다: $ConfigPath" +} + +if ($ConfigWasLoaded) { + $setting = Get-IniSetting $ini "General" "ChannelCount" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("ChannelCount")) { + $ChannelCount = [int]$setting.Value + } + $setting = Get-IniSetting $ini "General" "StableTimeSec" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("StableTimeSec")) { + $StableTimeSec = [int]$setting.Value + } + $setting = Get-IniSetting $ini "General" "PollIntervalSec" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("PollIntervalSec")) { + $PollIntervalSec = [int]$setting.Value + } + + $setting = Get-IniSetting $ini "Video" "FrameRate" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FrameRate")) { + $FrameRate = [int]$setting.Value + } + $setting = Get-IniSetting $ini "Video" "Bitrate" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("VideoBitrate")) { + $VideoBitrate = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Video" "EncoderPriority" + if ($setting.Found -and + -not $ExplicitParameters.ContainsKey("EncoderPriority") -and + -not $ExplicitParameters.ContainsKey("EncoderPriorityCsv")) { + $EncoderPriority = @($setting.Value -split ',' | ForEach-Object { $_.Trim() }) + } + + $setting = Get-IniSetting $ini "Audio" "Enabled" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioEnabled")) { + $AudioEnabled = Convert-IniSwitch $setting.Value "Audio.Enabled" + } + $setting = Get-IniSetting $ini "Audio" "Bitrate" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioBitrate")) { + $AudioBitrate = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Audio" "SignalThreshold" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioSignalThreshold")) { + $AudioSignalThreshold = [double]::Parse( + $setting.Value, + [System.Globalization.CultureInfo]::InvariantCulture + ) + } + + $setting = Get-IniSetting $ini "Network" "NicDescription" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("NicDescription")) { + $NicDescription = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Network" "HealthTarget" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("HealthTarget")) { + $HealthTarget = [string]$setting.Value + } + + $setting = Get-IniSetting $ini "RTSP" "Port" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) { + $RtspPort = [int]$setting.Value + } +} + +if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { + $EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() }) +} +$invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") } +if ($EncoderPriority.Count -eq 0) { + throw "EncoderPriority에는 하나 이상의 GPU 제조사가 필요합니다." +} +if ($invalidEncoderVendor) { + throw "잘못된 EncoderPriority 값: $($invalidEncoderVendor -join ', ')" +} + $FFmpegPath = Join-Path $BaseDir "ffmpeg.exe" $MediaMTXPath = Join-Path $BaseDir "mediamtx.exe" $MediaMTXConfig = Join-Path $BaseDir "mediamtx.yml" @@ -585,7 +780,6 @@ $WatchdogLog = Join-Path $LogDir "watchdog.log" $TaskName = "Screen RTSP Watchdog" $PowerShellPath = Join-Path $PSHOME "powershell.exe" $RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort" -$AudioSignalThreshold = 0.0005 function Quote-ProcessArgument { param([string]$Value) @@ -602,21 +796,8 @@ function Join-ProcessArguments { function Get-StartupArgumentList { $values = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, - "-ChannelCount", $ChannelCount, - "-FrameRate", $FrameRate, - "-VideoBitrate", $VideoBitrate, - "-AudioBitrate", $AudioBitrate, - "-StableTimeSec", $StableTimeSec, - "-PollIntervalSec", $PollIntervalSec, - "-RtspPort", $RtspPort, - "-EncoderPriorityCsv", ($EncoderPriority -join ",") + "-ConfigPath", $ConfigPath ) - if (-not [string]::IsNullOrWhiteSpace($NicDescription)) { - $values += @("-NicDescription", $NicDescription) - } - if (-not [string]::IsNullOrWhiteSpace($HealthTarget)) { - $values += @("-HealthTarget", $HealthTarget) - } return Join-ProcessArguments $values } @@ -632,6 +813,27 @@ function Install-StartupTask { Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force | Out-Null Write-Host "자동 실행 작업을 등록했습니다: $TaskName" + Write-Host "설정 파일: $ConfigPath" +} + +if ($ShowConfiguration) { + [PSCustomObject]@{ + ConfigPath = $ConfigPath + ConfigLoaded = $ConfigWasLoaded + ChannelCount = $ChannelCount + StableTimeSec = $StableTimeSec + PollIntervalSec = $PollIntervalSec + FrameRate = $FrameRate + VideoBitrate = $VideoBitrate + EncoderPriority = ($EncoderPriority -join " > ") + AudioEnabled = $AudioEnabled + AudioBitrate = $AudioBitrate + AudioSignalThreshold = $AudioSignalThreshold + NicDescription = $NicDescription + HealthTarget = $HealthTarget + RtspPort = $RtspPort + } | Format-List + exit 0 } if ($InstallStartup) { @@ -645,6 +847,25 @@ if ($UninstallStartup) { exit 0 } +if ($StartupStatus) { + $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue + if (-not $task) { + Write-Host "자동 실행 작업이 등록되어 있지 않습니다: $TaskName" + exit 1 + } + $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName + [PSCustomObject]@{ + TaskName = $TaskName + State = $task.State + LastRunTime = $taskInfo.LastRunTime + LastTaskResult = $taskInfo.LastTaskResult + NextRunTime = $taskInfo.NextRunTime + Execute = $task.Actions.Execute + Arguments = $task.Actions.Arguments + } | Format-List + exit 0 +} + if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } @@ -710,6 +931,9 @@ function Test-FFmpegCapabilities { if ($encoders -notmatch '(?m)\bh264_(nvenc|amf|qsv)\b') { throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다." } + if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') { + throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다." + } return $encoders } @@ -824,7 +1048,9 @@ function Select-HardwareEncoder { } Add-Type -AssemblyName System.Windows.Forms -Initialize-AudioInterop +if ($AudioEnabled -eq 1) { + Initialize-AudioInterop +} $DisplayInteropSource = @' using System; @@ -1227,11 +1453,6 @@ function Start-Stream { $Endpoint, $Encoder ) - $pipeName = "screen-rtsp-{0}-{1}-{2}" -f $PID,$Channel,([Guid]::NewGuid().ToString("N")) - $audioProcess = Start-AudioCaptureWorker -Channel $Channel -Endpoint $Endpoint -PipeName $pipeName - $script:AudioProcesses["monitor$Channel"] = $audioProcess - Wait-AudioPipe -Process $audioProcess -PipeName $pipeName - $ddaInput = "ddagrab=output_idx=$($Screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1" $filters = @("hwdownload", "format=bgra") $rotationFilter = Get-RotationFilter $Screen.Orientation @@ -1240,27 +1461,48 @@ function Start-Stream { $videoFilter = $filters -join "," $rtspUrl = "$RTSPPublishBase/monitor$Channel" $logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log" - $pipePath = "\\.\pipe\$pipeName" $arguments = @( "-hide_banner", "-loglevel", "warning", "-nostdin", - "-f", "lavfi", "-i", $ddaInput, - "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat, - "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, - "-map", "0:v:0", "-map", "1:a:0", - "-vf", $videoFilter, - "-c:v", $Encoder.Name - ) + $Encoder.Arguments + @( + "-f", "lavfi", "-i", $ddaInput + ) + + if ($AudioEnabled -eq 1) { + $pipeName = "screen-rtsp-{0}-{1}-{2}" -f $PID,$Channel,([Guid]::NewGuid().ToString("N")) + $audioProcess = Start-AudioCaptureWorker -Channel $Channel -Endpoint $Endpoint -PipeName $pipeName + $script:AudioProcesses["monitor$Channel"] = $audioProcess + Wait-AudioPipe -Process $audioProcess -PipeName $pipeName + $pipePath = "\\.\pipe\$pipeName" + $arguments += @( + "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat, + "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, + "-map", "0:v:0", "-map", "1:a:0" + ) + } + else { + $arguments += @("-map", "0:v:0") + } + + $arguments += @("-vf", $videoFilter, "-c:v", $Encoder.Name) + $arguments += $Encoder.Arguments + $arguments += @( "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, - "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12", + "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12" + ) + if ($AudioEnabled -eq 1) { + $arguments += @( "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2", - "-af", "aresample=async=1000:first_pts=0", + "-af", "aresample=async=1000:first_pts=0" + ) + } + $arguments += @( "-f", "rtsp", "-rtsp_transport", "tcp", $rtspUrl ) + $audioDescription = if ($AudioEnabled -eq 1) { $Endpoint.Name } else { "사용 안 함" } Write-Log ("monitor{0} 시작: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}, 오디오={6}" -f ` $Channel,$Screen.OutputIndex,$Screen.Width,$Screen.Height,$Screen.Orientation, - $Encoder.Name,$Endpoint.Name) + $Encoder.Name,$audioDescription) return Start-Process -FilePath $FFmpegPath -ArgumentList (Join-ProcessArguments $arguments) ` -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru @@ -1271,7 +1513,12 @@ function Start-Streams { $selected = @(Select-StreamingScreens $Screens) if ($selected.Count -lt $ChannelCount) { return } Write-Log "RTSP $ChannelCount 채널 시작" - Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" + if ($AudioEnabled -eq 1) { + Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" + } + else { + Write-Log "오디오 캡처: 사용 안 함" + } for ($index = 0; $index -lt $ChannelCount; $index++) { $channel = $index + 1 $process = Start-Stream -Channel $channel -Screen $selected[$index] ` @@ -1285,7 +1532,9 @@ function Start-Streams { } function Test-StreamsRunning { - if ($StreamProcesses.Count -ne $ChannelCount -or $AudioProcesses.Count -ne $ChannelCount) { + $expectedAudioProcessCount = if ($AudioEnabled -eq 1) { $ChannelCount } else { 0 } + if ($StreamProcesses.Count -ne $ChannelCount -or + $AudioProcesses.Count -ne $expectedAudioProcessCount) { return $false } foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { @@ -1308,7 +1557,10 @@ try { $gpuSignature = Get-GpuSignature Write-Log "Screen RTSP Watchdog 시작" + if ($ConfigWasLoaded) { Write-Log "설정 파일: $ConfigPath" } + else { Write-Log "설정 파일 없음 - 내장 기본값 사용: $ConfigPath" } Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')" + Write-Log "오디오 캡처: $(if ($AudioEnabled -eq 1) { '사용' } else { '사용 안 함' })" if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } @@ -1329,8 +1581,16 @@ try { } $screens = @(Get-ActiveScreens) - $endpoints = @(Get-AudioEndpoints) - $endpoint = Select-AudioEndpoint $endpoints + if ($AudioEnabled -eq 1) { + $endpoints = @(Get-AudioEndpoints) + $endpoint = Select-AudioEndpoint $endpoints + $audioSignature = Get-AudioSignature $endpoint + } + else { + $endpoints = @() + $endpoint = $null + $audioSignature = "AUDIO_DISABLED" + } $network = Get-NetworkState if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) { @@ -1346,7 +1606,7 @@ try { } $systemSignature = (Get-ScreenSignature $screens) + "||" + - (Get-NetworkSignature $network) + "||" + (Get-AudioSignature $endpoint) + + (Get-NetworkSignature $network) + "||" + $audioSignature + "||" + $encoder.Name if ($systemSignature -ne $lastSystemSignature) { @@ -1367,7 +1627,9 @@ try { $reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" } if (-not $reason) { $reason = Test-NetworkHealth $network } - if (-not $reason -and -not $endpoint) { $reason = "활성 오디오 출력 장치 없음" } + if (-not $reason -and $AudioEnabled -eq 1 -and -not $endpoint) { + $reason = "활성 오디오 출력 장치 없음" + } if ($reason) { if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" } @@ -1384,7 +1646,7 @@ try { if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and -not (Test-StreamsRunning)) { - Write-Log "FFmpeg 또는 오디오 loopback worker 비정상 종료 감지" + Write-Log "FFmpeg 또는 보조 프로세스 비정상 종료 감지" Stop-Streams $healthySince = $null }