Add INI configuration and optional audio capture

This commit is contained in:
seo-amugae committed 2026-10-08 00:30:23 +09:00
1 parent 5b15b06570
commit 089c2421c5
4 files changed
+458 -41

No files matched your search

+1
View File
@@ -6,6 +6,7 @@
/mediamtx.yml /mediamtx.yml
/logs/ /logs/
/*.log /*.log
/config.local.ini
# Local certificates and secrets. # Local certificates and secrets.
/*.crt /*.crt
+68
View File
@@ -31,6 +31,8 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서
- 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용 - 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용
- 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지 - 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지
- 로그인 시 자동 실행 작업 등록 및 제거 - 로그인 시 자동 실행 작업 등록 및 제거
- `config.ini` 기반 기능별 설정과 명령줄 override
- 오디오 캡처/AAC 트랙을 `1` 또는 `0`으로 활성화·비활성화
## RTSP URL ## RTSP URL
@@ -44,6 +46,7 @@ rtsp://<현재_NIC_IP>:8554/monitor2
## 구성 ## 구성
- `screen-rtsp.ps1`: 캡처, 오디오 loopback, GPU 선택, MediaMTX/FFmpeg watchdog - `screen-rtsp.ps1`: 캡처, 오디오 loopback, GPU 선택, MediaMTX/FFmpeg watchdog
- `config.ini`: 영상, 오디오, 네트워크, RTSP 설정과 진단 명령 예시
- `ffmpeg.exe`: `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg - `ffmpeg.exe`: `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg
- `mediamtx.exe`: RTSP 서버. 현재 검증 기준 버전은 v1.21.1 - `mediamtx.exe`: RTSP 서버. 현재 검증 기준 버전은 v1.21.1
- `mediamtx.yml`: 없으면 스크립트가 기본 설정을 자동 생성 - `mediamtx.yml`: 없으면 스크립트가 기본 설정을 자동 생성
@@ -54,6 +57,51 @@ rtsp://<현재_NIC_IP>:8554/monitor2
실행 파일과 생성 파일은 저장소에 포함하지 않습니다. 배포할 때 `ffmpeg.exe`와 `mediamtx.exe`를 `screen-rtsp.ps1` 옆에 둡니다. 실행 파일과 생성 파일은 저장소에 포함하지 않습니다. 배포할 때 `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 하드웨어 인코더 포함 여부를 확인합니다. 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 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 ```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 우선순위를 바꾸는 예시: GPU 우선순위를 바꾸는 예시:
```powershell ```powershell
+86
View File
@@ -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
+303 -41
View File
@@ -20,6 +20,12 @@ param(
[ValidatePattern("^[0-9]+[kKmM]$")] [ValidatePattern("^[0-9]+[kKmM]$")]
[string]$AudioBitrate = "192k", [string]$AudioBitrate = "192k",
[ValidateSet(0, 1)]
[int]$AudioEnabled = 1,
[ValidateRange(0.0, 1.0)]
[double]$AudioSignalThreshold = 0.0005,
[string]$NicDescription = "", [string]$NicDescription = "",
[string]$HealthTarget = "", [string]$HealthTarget = "",
@@ -32,8 +38,12 @@ param(
[ValidateRange(1, 65535)] [ValidateRange(1, 65535)]
[int]$RtspPort = 8554, [int]$RtspPort = 8554,
[string]$ConfigPath = "",
[switch]$InstallStartup, [switch]$InstallStartup,
[switch]$UninstallStartup, [switch]$UninstallStartup,
[switch]$StartupStatus,
[switch]$ShowConfiguration,
[Parameter(DontShow = $true)] [Parameter(DontShow = $true)]
[switch]$AudioWorker, [switch]$AudioWorker,
@@ -46,13 +56,9 @@ param(
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$ExplicitParameters = @{}
if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { foreach ($parameterName in $PSBoundParameters.Keys) {
$EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() }) $ExplicitParameters[$parameterName] = $true
$invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") }
if ($invalidEncoderVendor) {
throw "잘못된 EncoderPriorityCsv 값: $($invalidEncoderVendor -join ', ')"
}
} }
# ============================================================ # ============================================================
@@ -485,6 +491,10 @@ namespace ScreenRtsp.Audio
ThrowIfFailed(client.Start(), "IAudioClient.Start"); ThrowIfFailed(client.Start(), "IAudioClient.Start");
byte[] buffer = new byte[1024 * 1024]; 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) while (pipe.IsConnected)
{ {
uint packetFrames; uint packetFrames;
@@ -493,7 +503,17 @@ namespace ScreenRtsp.Audio
if (packetFrames == 0) 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; continue;
} }
@@ -517,6 +537,7 @@ namespace ScreenRtsp.Audio
try try
{ {
pipe.Write(buffer, 0, bytes); pipe.Write(buffer, 0, bytes);
packetClock.Restart();
} }
finally finally
{ {
@@ -577,6 +598,180 @@ if ([string]::IsNullOrWhiteSpace($BaseDir)) {
$BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path $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" $FFmpegPath = Join-Path $BaseDir "ffmpeg.exe"
$MediaMTXPath = Join-Path $BaseDir "mediamtx.exe" $MediaMTXPath = Join-Path $BaseDir "mediamtx.exe"
$MediaMTXConfig = Join-Path $BaseDir "mediamtx.yml" $MediaMTXConfig = Join-Path $BaseDir "mediamtx.yml"
@@ -585,7 +780,6 @@ $WatchdogLog = Join-Path $LogDir "watchdog.log"
$TaskName = "Screen RTSP Watchdog" $TaskName = "Screen RTSP Watchdog"
$PowerShellPath = Join-Path $PSHOME "powershell.exe" $PowerShellPath = Join-Path $PSHOME "powershell.exe"
$RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort" $RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort"
$AudioSignalThreshold = 0.0005
function Quote-ProcessArgument { function Quote-ProcessArgument {
param([string]$Value) param([string]$Value)
@@ -602,21 +796,8 @@ function Join-ProcessArguments {
function Get-StartupArgumentList { function Get-StartupArgumentList {
$values = @( $values = @(
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath,
"-ChannelCount", $ChannelCount, "-ConfigPath", $ConfigPath
"-FrameRate", $FrameRate,
"-VideoBitrate", $VideoBitrate,
"-AudioBitrate", $AudioBitrate,
"-StableTimeSec", $StableTimeSec,
"-PollIntervalSec", $PollIntervalSec,
"-RtspPort", $RtspPort,
"-EncoderPriorityCsv", ($EncoderPriority -join ",")
) )
if (-not [string]::IsNullOrWhiteSpace($NicDescription)) {
$values += @("-NicDescription", $NicDescription)
}
if (-not [string]::IsNullOrWhiteSpace($HealthTarget)) {
$values += @("-HealthTarget", $HealthTarget)
}
return Join-ProcessArguments $values return Join-ProcessArguments $values
} }
@@ -632,6 +813,27 @@ function Install-StartupTask {
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null -Principal $principal -Settings $settings -Force | Out-Null
Write-Host "자동 실행 작업을 등록했습니다: $TaskName" 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) { if ($InstallStartup) {
@@ -645,6 +847,25 @@ if ($UninstallStartup) {
exit 0 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)) { if (-not (Test-Path -LiteralPath $LogDir)) {
New-Item -ItemType Directory -Path $LogDir -Force | Out-Null 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') { if ($encoders -notmatch '(?m)\bh264_(nvenc|amf|qsv)\b') {
throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다." throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다."
} }
if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') {
throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다."
}
return $encoders return $encoders
} }
@@ -824,7 +1048,9 @@ function Select-HardwareEncoder {
} }
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Windows.Forms
if ($AudioEnabled -eq 1) {
Initialize-AudioInterop Initialize-AudioInterop
}
$DisplayInteropSource = @' $DisplayInteropSource = @'
using System; using System;
@@ -1227,11 +1453,6 @@ function Start-Stream {
$Endpoint, $Endpoint,
$Encoder $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" $ddaInput = "ddagrab=output_idx=$($Screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1"
$filters = @("hwdownload", "format=bgra") $filters = @("hwdownload", "format=bgra")
$rotationFilter = Get-RotationFilter $Screen.Orientation $rotationFilter = Get-RotationFilter $Screen.Orientation
@@ -1240,27 +1461,48 @@ function Start-Stream {
$videoFilter = $filters -join "," $videoFilter = $filters -join ","
$rtspUrl = "$RTSPPublishBase/monitor$Channel" $rtspUrl = "$RTSPPublishBase/monitor$Channel"
$logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log" $logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log"
$pipePath = "\\.\pipe\$pipeName"
$arguments = @( $arguments = @(
"-hide_banner", "-loglevel", "warning", "-nostdin", "-hide_banner", "-loglevel", "warning", "-nostdin",
"-f", "lavfi", "-i", $ddaInput, "-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, "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat,
"-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath,
"-map", "0:v:0", "-map", "1:a:0", "-map", "0:v:0", "-map", "1:a:0"
"-vf", $videoFilter, )
"-c:v", $Encoder.Name }
) + $Encoder.Arguments + @( else {
$arguments += @("-map", "0:v:0")
}
$arguments += @("-vf", $videoFilter, "-c:v", $Encoder.Name)
$arguments += $Encoder.Arguments
$arguments += @(
"-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, "-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", "-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 "-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 ` Write-Log ("monitor{0} 시작: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}, 오디오={6}" -f `
$Channel,$Screen.OutputIndex,$Screen.Width,$Screen.Height,$Screen.Orientation, $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) ` return Start-Process -FilePath $FFmpegPath -ArgumentList (Join-ProcessArguments $arguments) `
-WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru
@@ -1271,7 +1513,12 @@ function Start-Streams {
$selected = @(Select-StreamingScreens $Screens) $selected = @(Select-StreamingScreens $Screens)
if ($selected.Count -lt $ChannelCount) { return } if ($selected.Count -lt $ChannelCount) { return }
Write-Log "RTSP $ChannelCount 채널 시작" Write-Log "RTSP $ChannelCount 채널 시작"
if ($AudioEnabled -eq 1) {
Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))"
}
else {
Write-Log "오디오 캡처: 사용 안 함"
}
for ($index = 0; $index -lt $ChannelCount; $index++) { for ($index = 0; $index -lt $ChannelCount; $index++) {
$channel = $index + 1 $channel = $index + 1
$process = Start-Stream -Channel $channel -Screen $selected[$index] ` $process = Start-Stream -Channel $channel -Screen $selected[$index] `
@@ -1285,7 +1532,9 @@ function Start-Streams {
} }
function Test-StreamsRunning { 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 return $false
} }
foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) {
@@ -1308,7 +1557,10 @@ try {
$gpuSignature = Get-GpuSignature $gpuSignature = Get-GpuSignature
Write-Log "Screen RTSP Watchdog 시작" Write-Log "Screen RTSP Watchdog 시작"
if ($ConfigWasLoaded) { Write-Log "설정 파일: $ConfigPath" }
else { Write-Log "설정 파일 없음 - 내장 기본값 사용: $ConfigPath" }
Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')" Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')"
Write-Log "오디오 캡처: $(if ($AudioEnabled -eq 1) { '사용' } else { '사용 안 함' })"
if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" }
else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" }
if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" }
@@ -1329,8 +1581,16 @@ try {
} }
$screens = @(Get-ActiveScreens) $screens = @(Get-ActiveScreens)
if ($AudioEnabled -eq 1) {
$endpoints = @(Get-AudioEndpoints) $endpoints = @(Get-AudioEndpoints)
$endpoint = Select-AudioEndpoint $endpoints $endpoint = Select-AudioEndpoint $endpoints
$audioSignature = Get-AudioSignature $endpoint
}
else {
$endpoints = @()
$endpoint = $null
$audioSignature = "AUDIO_DISABLED"
}
$network = Get-NetworkState $network = Get-NetworkState
if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) { if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) {
@@ -1346,7 +1606,7 @@ try {
} }
$systemSignature = (Get-ScreenSignature $screens) + "||" + $systemSignature = (Get-ScreenSignature $screens) + "||" +
(Get-NetworkSignature $network) + "||" + (Get-AudioSignature $endpoint) + (Get-NetworkSignature $network) + "||" + $audioSignature +
"||" + $encoder.Name "||" + $encoder.Name
if ($systemSignature -ne $lastSystemSignature) { if ($systemSignature -ne $lastSystemSignature) {
@@ -1367,7 +1627,9 @@ try {
$reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" $reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만"
} }
if (-not $reason) { $reason = Test-NetworkHealth $network } 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) {
if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" } if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" }
@@ -1384,7 +1646,7 @@ try {
if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and
-not (Test-StreamsRunning)) { -not (Test-StreamsRunning)) {
Write-Log "FFmpeg 또는 오디오 loopback worker 비정상 종료 감지" Write-Log "FFmpeg 또는 보조 프로세스 비정상 종료 감지"
Stop-Streams Stop-Streams
$healthySince = $null $healthySince = $null
} }