From 4088f2299d7856497a9247854d822fecd0ba0b51 Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 00:46:26 +0900 Subject: [PATCH] Add dependency bootstrap and simulation tests --- README.md | 42 +++- config.ini | 14 ++ screen-rtsp.ps1 | 539 ++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 580 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a6af7b0..297bb37 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 로그인 시 자동 실행 작업 등록 및 제거 - `config.ini` 기반 기능별 설정과 명령줄 override - 오디오 캡처/AAC 트랙을 `1` 또는 `0`으로 활성화·비활성화 +- FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, SHA-256 검증, 자동 설치 +- 모니터·오디오·NIC·프로세스·설정·안전장치용 내장 시뮬레이션 30건 ## RTSP URL @@ -55,7 +57,7 @@ rtsp://<현재_NIC_IP>:8554/monitor2 - `logs/monitor2-ffmpeg.log`: monitor2 FFmpeg 로그 - `logs/monitor1-audio.log`, `logs/monitor2-audio.log`: WASAPI loopback worker 오류 로그 -실행 파일과 생성 파일은 저장소에 포함하지 않습니다. 배포할 때 `ffmpeg.exe`와 `mediamtx.exe`를 `screen-rtsp.ps1` 옆에 둡니다. +실행 파일과 생성 파일은 저장소에 포함하지 않습니다. `Dependencies.AutoInstall=1`이면 실행할 때 없는 `ffmpeg.exe`와 `mediamtx.exe`를 스크립트 옆에 자동 설치합니다. ## 설정 파일 @@ -93,6 +95,11 @@ SignalThreshold=0.0005 | `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 | | `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 | | `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 | +| `Dependencies` | `AutoInstall` | `1` | 실행 파일 부재 시 자동 설치 여부 | +| `Dependencies` | `FFmpegUrl` | Gyan essentials ZIP | FFmpeg Windows ZIP 다운로드 주소 | +| `Dependencies` | `FFmpegSha256Url` | Gyan SHA-256 | FFmpeg 게시 해시 주소 | +| `Dependencies` | `MediaMTXUrl` | v1.21.1 공식 ZIP | MediaMTX Windows amd64 ZIP 주소 | +| `Dependencies` | `MediaMTXSha256` | v1.21.1 공식 해시 | MediaMTX ZIP 고정 SHA-256 | GPU, 모니터, 오디오, NIC, FFmpeg 지원, RTSP 포트를 확인하는 PowerShell 명령은 `config.ini` 위쪽에 모두 주석 형태로 포함되어 있습니다. @@ -102,9 +109,40 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. .\screen-rtsp.ps1 -ShowConfiguration ``` +## 의존 프로그램 자동 설치 + +`ffmpeg.exe` 또는 `mediamtx.exe`가 없고 `Dependencies.AutoInstall=1`이면 다음 순서로 자동 설치합니다. + +1. HTTPS 주소만 허용하고 ZIP을 Windows 임시 폴더에 다운로드합니다. +2. FFmpeg는 Gyan이 게시한 SHA-256을 받아 검증하고, MediaMTX는 공식 v1.21.1 Release의 고정 SHA-256을 검증합니다. +3. 압축 안의 실행 파일만 `screen-rtsp.ps1` 옆으로 복사합니다. +4. `ddagrab`, H.264 하드웨어 인코더, AAC 지원과 실행 버전을 확인합니다. +5. 검증용 임시 폴더를 삭제합니다. + +송출을 시작하지 않고 설치와 기능 확인만 먼저 수행할 수 있습니다. + +```powershell +.\screen-rtsp.ps1 -InstallDependenciesOnly +``` + +자동 다운로드를 금지하려면 `AutoInstall=0`으로 바꿉니다. 이 경우 실행 파일이 없으면 오류를 표시하고 종료합니다. 자동 설치에는 인터넷 HTTPS 연결과 스크립트 폴더 쓰기 권한이 필요합니다. + +- FFmpeg Windows build: +- MediaMTX v1.21.1: + +## 내장 시뮬레이션 + +실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 30건을 검사합니다. + +```powershell +.\screen-rtsp.ps1 -RunSimulationTests +``` + +모든 항목이 통과하면 종료 코드 `0`, 하나라도 실패하면 종료 코드 `1`을 반환합니다. + ## 처리 흐름 -1. FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다. +1. FFmpeg와 MediaMTX가 없으면 설정에 따라 다운로드·검증·설치한 뒤, FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다. 2. 설치된 GPU를 `NVIDIA > AMD > Intel` 순서로 찾고 각 인코더를 실제 1프레임으로 초기화합니다. 3. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력 장치를 읽습니다. 4. Core Audio peak가 감지된 출력 장치를 우선하고, 신호가 없으면 Windows 기본 출력 장치를 사용합니다. diff --git a/config.ini b/config.ini index 5e181aa..51a1c0c 100644 --- a/config.ini +++ b/config.ini @@ -15,6 +15,8 @@ # 실행 # 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 -InstallDependenciesOnly +# powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -RunSimulationTests # # 별도 설정 파일 사용 # powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -ConfigPath .\config.ini @@ -84,3 +86,15 @@ HealthTarget= [RTSP] Port=8554 + +[Dependencies] +# ffmpeg.exe 또는 mediamtx.exe가 없을 때 자동 다운로드: 1=사용, 0=오류 후 종료 +AutoInstall=1 + +# Gyan FFmpeg Windows essentials ZIP과 게시된 SHA-256입니다. +FFmpegUrl=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip +FFmpegSha256Url=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256 + +# MediaMTX v1.21.1 공식 Windows amd64 ZIP과 GitHub Release SHA-256입니다. +MediaMTXUrl=https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_windows_amd64.zip +MediaMTXSha256=faa97974861eb75a68b5aa326c78e7e7a6f670b5ef191bace78e715130381f23 diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index 72c31aa..569d072 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -38,12 +38,22 @@ param( [ValidateRange(1, 65535)] [int]$RtspPort = 8554, + [ValidateSet(0, 1)] + [int]$AutoInstallDependencies = 1, + + [string]$FFmpegDownloadUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", + [string]$FFmpegSha256Url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256", + [string]$MediaMTXDownloadUrl = "https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_windows_amd64.zip", + [string]$MediaMTXSha256 = "faa97974861eb75a68b5aa326c78e7e7a6f670b5ef191bace78e715130381f23", + [string]$ConfigPath = "", [switch]$InstallStartup, [switch]$UninstallStartup, [switch]$StartupStatus, [switch]$ShowConfiguration, + [switch]$InstallDependenciesOnly, + [switch]$RunSimulationTests, [Parameter(DontShow = $true)] [switch]$AudioWorker, @@ -669,6 +679,13 @@ function Assert-IniSettings { Audio = @("Enabled", "Bitrate", "SignalThreshold") Network = @("NicDescription", "HealthTarget") RTSP = @("Port") + Dependencies = @( + "AutoInstall", + "FFmpegUrl", + "FFmpegSha256Url", + "MediaMTXUrl", + "MediaMTXSha256" + ) } foreach ($sectionName in $Configuration.Keys) { if (-not $allowed.ContainsKey($sectionName)) { @@ -759,6 +776,27 @@ if ($ConfigWasLoaded) { if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) { $RtspPort = [int]$setting.Value } + + $setting = Get-IniSetting $ini "Dependencies" "AutoInstall" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) { + $AutoInstallDependencies = Convert-IniSwitch $setting.Value "Dependencies.AutoInstall" + } + $setting = Get-IniSetting $ini "Dependencies" "FFmpegUrl" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegDownloadUrl")) { + $FFmpegDownloadUrl = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Dependencies" "FFmpegSha256Url" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegSha256Url")) { + $FFmpegSha256Url = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Dependencies" "MediaMTXUrl" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXDownloadUrl")) { + $MediaMTXDownloadUrl = [string]$setting.Value + } + $setting = Get-IniSetting $ini "Dependencies" "MediaMTXSha256" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXSha256")) { + $MediaMTXSha256 = [string]$setting.Value + } } if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { @@ -832,6 +870,11 @@ if ($ShowConfiguration) { NicDescription = $NicDescription HealthTarget = $HealthTarget RtspPort = $RtspPort + AutoInstallDependencies = $AutoInstallDependencies + FFmpegDownloadUrl = $FFmpegDownloadUrl + FFmpegSha256Url = $FFmpegSha256Url + MediaMTXDownloadUrl = $MediaMTXDownloadUrl + MediaMTXSha256 = $MediaMTXSha256 } | Format-List exit 0 } @@ -877,10 +920,150 @@ function Write-Log { Add-Content -LiteralPath $WatchdogLog -Value $line -Encoding UTF8 } +function Assert-HttpsDependencyUrl { + param([string]$Url, [string]$Name) + $uri = $null + if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$uri) -or + $uri.Scheme -ne "https") { + throw "$Name 다운로드 URL은 유효한 HTTPS 주소여야 합니다: $Url" + } +} + +function New-DependencyTempDirectory { + $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) + $path = Join-Path $tempRoot ("screen-rtsp-dependencies-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $path -Force | Out-Null + return $path +} + +function Remove-DependencyTempDirectory { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return } + + $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\' + $resolvedPath = [System.IO.Path]::GetFullPath($Path) + $leaf = Split-Path -Leaf $resolvedPath + if (-not $resolvedPath.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or + $leaf -notlike "screen-rtsp-dependencies-*") { + throw "안전하지 않은 임시 폴더 삭제 요청을 거부했습니다: $resolvedPath" + } + if (Test-Path -LiteralPath $resolvedPath) { + Remove-Item -LiteralPath $resolvedPath -Recurse -Force + } +} + +function Save-DependencyFile { + param([string]$Url, [string]$Destination, [string]$Name) + Assert-HttpsDependencyUrl -Url $Url -Name $Name + Write-Log "$Name 다운로드 시작: $Url" + $previousProgressPreference = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + try { + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination -TimeoutSec 900 + } + finally { + $ProgressPreference = $previousProgressPreference + } + if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or + (Get-Item -LiteralPath $Destination).Length -le 0) { + throw "$Name 다운로드 결과가 비어 있습니다." + } +} + +function Get-RemoteSha256 { + param([string]$Url, [string]$Name) + Assert-HttpsDependencyUrl -Url $Url -Name "$Name SHA-256" + $previousProgressPreference = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + try { + $response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 60 + } + finally { + $ProgressPreference = $previousProgressPreference + } + $match = [regex]::Match([string]$response.Content, '(?i)\b[0-9a-f]{64}\b') + if (-not $match.Success) { + throw "$Name SHA-256 응답에서 해시를 찾을 수 없습니다: $Url" + } + return $match.Value.ToLowerInvariant() +} + +function Install-ZipDependency { + param( + [string]$Name, + [string]$DownloadUrl, + [string]$ExpectedSha256, + [string]$ExpectedSha256Url, + [string]$ExecutableName, + [string]$DestinationPath + ) + + $tempDirectory = New-DependencyTempDirectory + try { + $archivePath = Join-Path $tempDirectory "$Name.zip" + $extractPath = Join-Path $tempDirectory "extracted" + Save-DependencyFile -Url $DownloadUrl -Destination $archivePath -Name $Name + + if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256Url)) { + $ExpectedSha256 = Get-RemoteSha256 -Url $ExpectedSha256Url -Name $Name + } + if ($ExpectedSha256 -notmatch '(?i)^[0-9a-f]{64}$') { + throw "$Name SHA-256 값이 올바르지 않습니다." + } + + $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { + throw "$Name SHA-256 검증 실패: expected=$ExpectedSha256 actual=$actualSha256" + } + Write-Log "$Name SHA-256 검증 성공" + + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + $candidate = Get-ChildItem -LiteralPath $extractPath -Recurse -File | + Where-Object Name -eq $ExecutableName | + Select-Object -First 1 + if (-not $candidate) { + throw "$Name 압축 파일에서 $ExecutableName 파일을 찾을 수 없습니다." + } + + Copy-Item -LiteralPath $candidate.FullName -Destination $DestinationPath -Force + Unblock-File -LiteralPath $DestinationPath -ErrorAction SilentlyContinue + if (-not (Test-Path -LiteralPath $DestinationPath -PathType Leaf)) { + throw "$Name 설치에 실패했습니다: $DestinationPath" + } + Write-Log "$Name 설치 완료: $DestinationPath" + } + finally { + Remove-DependencyTempDirectory -Path $tempDirectory + } +} + function Assert-RequiredFiles { + $missing = @() + if (-not (Test-Path -LiteralPath $FFmpegPath -PathType Leaf)) { $missing += "ffmpeg.exe" } + if (-not (Test-Path -LiteralPath $MediaMTXPath -PathType Leaf)) { $missing += "mediamtx.exe" } + if ($missing.Count -eq 0) { return } + + if ($AutoInstallDependencies -ne 1) { + throw "필수 파일이 없고 자동 설치가 꺼져 있습니다: $($missing -join ', ')" + } + + Write-Log "필수 프로그램 자동 설치 시작: $($missing -join ', ')" + if ($missing -contains "ffmpeg.exe") { + Install-ZipDependency -Name "FFmpeg" -DownloadUrl $FFmpegDownloadUrl ` + -ExpectedSha256 "" -ExpectedSha256Url $FFmpegSha256Url ` + -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath + } + if ($missing -contains "mediamtx.exe") { + Install-ZipDependency -Name "MediaMTX" -DownloadUrl $MediaMTXDownloadUrl ` + -ExpectedSha256 $MediaMTXSha256 -ExpectedSha256Url "" ` + -ExecutableName "mediamtx.exe" -DestinationPath $MediaMTXPath + } + foreach ($path in @($FFmpegPath, $MediaMTXPath)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { - throw "필수 파일을 찾을 수 없습니다: $path" + throw "자동 설치 후에도 필수 파일을 찾을 수 없습니다: $path" } } } @@ -937,6 +1120,20 @@ function Test-FFmpegCapabilities { return $encoders } +if ($InstallDependenciesOnly) { + Assert-RequiredFiles + $null = Test-FFmpegCapabilities + $ffmpegVersion = (& $FFmpegPath -hide_banner -version 2>&1 | Select-Object -First 1) + $mediaMTXVersion = (& $MediaMTXPath --version 2>&1 | Select-Object -First 1) + [PSCustomObject]@{ + FFmpegPath = $FFmpegPath + FFmpegVersion = $ffmpegVersion + MediaMTXPath = $MediaMTXPath + MediaMTXVersion = $mediaMTXVersion + } | Format-List + exit 0 +} + function Invoke-ProcessWithTimeout { param( [string]$FilePath, @@ -1543,6 +1740,332 @@ function Test-StreamsRunning { return $true } +function Get-StreamingReadinessReason { + param($Screens, $Network, $Endpoint) + + $capturableScreenCount = @($Screens | Where-Object { $_.OutputIndex -ge 0 }).Count + if ($Screens.Count -lt $ChannelCount) { + return "활성 모니터 $ChannelCount 개 미만" + } + if ($capturableScreenCount -lt $ChannelCount) { + return "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" + } + + $networkReason = Test-NetworkHealth $Network + if ($networkReason) { return $networkReason } + if ($AudioEnabled -eq 1 -and -not $Endpoint) { + return "활성 오디오 출력 장치 없음" + } + return $null +} + +function Invoke-SimulationTests { + $results = New-Object 'System.Collections.Generic.List[object]' + + function Invoke-SimulationCase { + param([string]$Name, [scriptblock]$Body) + try { + $passed = [bool](& $Body) + $detail = if ($passed) { "PASS" } else { "조건 결과가 False입니다." } + } + catch { + $passed = $false + $detail = $_.Exception.Message + } + $results.Add([PSCustomObject]@{ + Number = $results.Count + 1 + Name = $Name + Passed = $passed + Detail = $detail + }) + } + + function New-SimulationIni { + param([string]$Content) + $path = Join-Path ([System.IO.Path]::GetTempPath()) ` + ("screen-rtsp-simulation-" + [Guid]::NewGuid().ToString("N") + ".ini") + [System.IO.File]::WriteAllText($path, $Content, (New-Object System.Text.UTF8Encoding($false))) + return $path + } + + $originalChannelCount = $script:ChannelCount + $originalSelectedAudioEndpointId = $script:SelectedAudioEndpointId + $originalHealthTarget = $script:HealthTarget + $originalAudioEnabled = $script:AudioEnabled + $originalStreamProcesses = $script:StreamProcesses + $originalAudioProcesses = $script:AudioProcesses + try { + $script:ChannelCount = 2 + $script:HealthTarget = "" + + Invoke-SimulationCase "INI 주석(#, ;, //)과 따옴표 값 파싱" { + $path = New-SimulationIni "# one`r`n; two`r`n// three`r`n[Audio]`r`nEnabled=0`r`nBitrate=`"128k`"`r`nSignalThreshold=0.1" + try { + $parsed = Read-IniConfiguration $path + return ($parsed.Audio.Enabled -eq "0" -and $parsed.Audio.Bitrate -eq "128k") + } + finally { [System.IO.File]::Delete($path) } + } + + Invoke-SimulationCase "알 수 없는 INI 설정 거부" { + $path = New-SimulationIni "[Audio]`r`nUnknownOption=1" + try { + $parsed = Read-IniConfiguration $path + try { Assert-IniSettings $parsed; return $false } catch { return $true } + } + finally { [System.IO.File]::Delete($path) } + } + + Invoke-SimulationCase "기능 스위치 1/0 허용" { + return ((Convert-IniSwitch "1" "test") -eq 1 -and + (Convert-IniSwitch "0" "test") -eq 0) + } + + Invoke-SimulationCase "기능 스위치 2 거부" { + try { $null = Convert-IniSwitch "2" "test"; return $false } catch { return $true } + } + + $screenPrimary = [PSCustomObject]@{ + DeviceName="DISPLAY1"; Primary=$true; X=0; Y=0; Width=2560; Height=1440 + Orientation=0; OutputIndex=0; DxgiRotation=1 + } + $screenSecond = [PSCustomObject]@{ + DeviceName="DISPLAY2"; Primary=$false; X=2560; Y=0; Width=1080; Height=1920 + Orientation=3; OutputIndex=1; DxgiRotation=4 + } + $screenThird = [PSCustomObject]@{ + DeviceName="DISPLAY3"; Primary=$false; X=-1920; Y=0; Width=1920; Height=1080 + Orientation=0; OutputIndex=2; DxgiRotation=1 + } + + Invoke-SimulationCase "모니터 1대이면 2채널 시작 보류" { + return (@(Select-StreamingScreens @($screenPrimary)).Count -eq 0) + } + + Invoke-SimulationCase "모니터 2대이면 Primary가 monitor1" { + $selected = @(Select-StreamingScreens @($screenSecond,$screenPrimary)) + return ($selected.Count -eq 2 -and $selected[0].DeviceName -eq "DISPLAY1") + } + + Invoke-SimulationCase "모니터 3대여도 설정된 2대만 선택" { + $selected = @(Select-StreamingScreens @($screenPrimary,$screenSecond,$screenThird)) + return ($selected.Count -eq 2 -and $selected[0].Primary -and + $selected[1].DeviceName -eq "DISPLAY3") + } + + Invoke-SimulationCase "D3D11에 매핑되지 않은 모니터가 있으면 시작 보류" { + $unmapped = $screenSecond.PSObject.Copy() + $unmapped.OutputIndex = -1 + $network = [PSCustomObject]@{ + Adapter=[PSCustomObject]@{Status="Up"} + IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} + } + $script:AudioEnabled = 0 + return ((Get-StreamingReadinessReason @($screenPrimary,$unmapped) $network $null) -like + "기본 D3D11 adapter*") + } + + Invoke-SimulationCase "해상도 변경 signature 감지" { + $changed = $screenPrimary.PSObject.Copy() + $changed.Width = 1920 + return ((Get-ScreenSignature @($screenPrimary)) -ne (Get-ScreenSignature @($changed))) + } + + Invoke-SimulationCase "위치/Primary/회전 변경 signature 감지" { + $changed = $screenSecond.PSObject.Copy() + $changed.X = -1080 + $changed.Primary = $true + $changed.Orientation = 1 + return ((Get-ScreenSignature @($screenSecond)) -ne (Get-ScreenSignature @($changed))) + } + + Invoke-SimulationCase "세로 모니터 회전 필터 선택" { + return ((Get-RotationFilter 1) -eq "transpose=clock" -and + (Get-RotationFilter 2) -eq "hflip,vflip" -and + (Get-RotationFilter 3) -eq "transpose=cclock" -and + $null -eq (Get-RotationFilter 0)) + } + + $defaultAudio = [PSCustomObject]@{ + Id="default"; Name="Default"; IsDefault=$true; Peak=0.0 + SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" + } + $audibleAudio = [PSCustomObject]@{ + Id="audible"; Name="HDMI"; IsDefault=$false; Peak=0.2 + SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" + } + + Invoke-SimulationCase "실제 peak가 있는 오디오 장치 우선" { + $script:SelectedAudioEndpointId = $null + return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") + } + + Invoke-SimulationCase "모든 장치 무음이면 기본 출력 선택" { + $script:SelectedAudioEndpointId = $null + $audibleAudio.Peak = 0.0 + return ((Select-AudioEndpoint @($audibleAudio,$defaultAudio)).Id -eq "default") + } + + Invoke-SimulationCase "현재 출력 장치에 신호가 있으면 선택 유지" { + $audibleAudio.Peak = 0.1 + $defaultAudio.Peak = 0.3 + $script:SelectedAudioEndpointId = "audible" + return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") + } + + Invoke-SimulationCase "오디오 mix format 변경 signature 감지" { + $changedAudio = $defaultAudio.PSObject.Copy() + $changedAudio.SampleRate = 44100 + return ((Get-AudioSignature $defaultAudio) -ne (Get-AudioSignature $changedAudio)) + } + + Invoke-SimulationCase "NIC 부재 signature" { + $network = [PSCustomObject]@{ Adapter=$null; IPv4=$null } + return ((Get-NetworkSignature $network) -eq "NIC_MISSING") + } + + Invoke-SimulationCase "NIC IPv4 변경 signature 감지" { + $adapter = [PSCustomObject]@{ + InterfaceGuid="guid"; ifIndex=8; Status="Up"; LinkSpeed="1 Gbps" + } + $network1 = [PSCustomObject]@{ + Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.34"} + } + $network2 = [PSCustomObject]@{ + Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.35"} + } + return ((Get-NetworkSignature $network1) -ne (Get-NetworkSignature $network2)) + } + + Invoke-SimulationCase "NIC가 없으면 송출 시작 보류" { + $script:AudioEnabled = 0 + $network = [PSCustomObject]@{Adapter=$null; IPv4=$null} + return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq + "활성 네트워크 어댑터 없음") + } + + Invoke-SimulationCase "NIC Link Down이면 송출 시작 보류" { + $script:AudioEnabled = 0 + $network = [PSCustomObject]@{ + Adapter=[PSCustomObject]@{Status="Disconnected"}; + IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} + } + return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq + "네트워크 Link Down") + } + + Invoke-SimulationCase "NIC IPv4가 없으면 송출 시작 보류" { + $script:AudioEnabled = 0 + $network = [PSCustomObject]@{Adapter=[PSCustomObject]@{Status="Up"}; IPv4=$null} + return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq + "네트워크 IPv4 없음") + } + + Invoke-SimulationCase "오디오 사용 중 endpoint가 없으면 시작 보류" { + $script:AudioEnabled = 1 + $network = [PSCustomObject]@{ + Adapter=[PSCustomObject]@{Status="Up"}; + IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} + } + return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq + "활성 오디오 출력 장치 없음") + } + + Invoke-SimulationCase "모니터/NIC/오디오가 정상이면 시작 가능" { + $script:AudioEnabled = 1 + $network = [PSCustomObject]@{ + Adapter=[PSCustomObject]@{Status="Up"}; + IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} + } + return ($null -eq (Get-StreamingReadinessReason ` + @($screenPrimary,$screenSecond) $network $defaultAudio)) + } + + Invoke-SimulationCase "GPU별 H.264 인코더 매핑" { + return ((Get-EncoderDefinition "NVIDIA").Name -eq "h264_nvenc" -and + (Get-EncoderDefinition "AMD").Name -eq "h264_amf" -and + (Get-EncoderDefinition "Intel").Name -eq "h264_qsv") + } + + Invoke-SimulationCase "자동 실행은 config.ini 경로만 전달" { + $arguments = Get-StartupArgumentList + return ($arguments -match '-ConfigPath' -and $arguments -notmatch '-VideoBitrate') + } + + Invoke-SimulationCase "2채널 FFmpeg/오디오 worker가 모두 생존" { + $alive = [PSCustomObject]@{HasExited=$false} + $script:AudioEnabled = 1 + $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} + $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} + return (Test-StreamsRunning) + } + + Invoke-SimulationCase "FFmpeg 하나가 종료되면 전체 재시작 필요" { + $alive = [PSCustomObject]@{HasExited=$false} + $dead = [PSCustomObject]@{HasExited=$true} + $script:AudioEnabled = 1 + $script:StreamProcesses = @{monitor1=$alive; monitor2=$dead} + $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} + return (-not (Test-StreamsRunning)) + } + + Invoke-SimulationCase "오디오 OFF이면 worker 없이 2채널 정상" { + $alive = [PSCustomObject]@{HasExited=$false} + $script:AudioEnabled = 0 + $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} + $script:AudioProcesses = @{} + return (Test-StreamsRunning) + } + + Invoke-SimulationCase "비 HTTPS 의존성 URL 거부" { + try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false } + catch { return $true } + } + + Invoke-SimulationCase "자동 설치 OFF에서 파일 부재 오류" { + $savedFFmpegPath = $script:FFmpegPath + $savedMediaMTXPath = $script:MediaMTXPath + $savedAutoInstall = $script:AutoInstallDependencies + try { + $script:FFmpegPath = Join-Path $BaseDir "definitely-missing-ffmpeg.exe" + $script:MediaMTXPath = Join-Path $BaseDir "definitely-missing-mediamtx.exe" + $script:AutoInstallDependencies = 0 + try { Assert-RequiredFiles; return $false } catch { return $true } + } + finally { + $script:FFmpegPath = $savedFFmpegPath + $script:MediaMTXPath = $savedMediaMTXPath + $script:AutoInstallDependencies = $savedAutoInstall + } + } + + Invoke-SimulationCase "안전 범위 밖 임시 폴더 삭제 거부" { + try { Remove-DependencyTempDirectory ([System.IO.Path]::GetTempPath()); return $false } + catch { return $true } + } + } + finally { + $script:ChannelCount = $originalChannelCount + $script:SelectedAudioEndpointId = $originalSelectedAudioEndpointId + $script:HealthTarget = $originalHealthTarget + $script:AudioEnabled = $originalAudioEnabled + $script:StreamProcesses = $originalStreamProcesses + $script:AudioProcesses = $originalAudioProcesses + } + + return $results.ToArray() +} + +if ($RunSimulationTests) { + $simulationResults = @(Invoke-SimulationTests) + $simulationResults | Format-Table Number,Passed,Name,Detail -AutoSize + $passedCount = @($simulationResults | Where-Object Passed).Count + $failedCount = $simulationResults.Count - $passedCount + Write-Host "Simulation summary: total=$($simulationResults.Count), passed=$passedCount, failed=$failedCount" + if ($failedCount -gt 0) { exit 1 } + exit 0 +} + $mutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog") $hasMutex = $false @@ -1618,18 +2141,8 @@ try { $healthySince = $null } - $reason = $null - $capturableScreenCount = @($screens | Where-Object { $_.OutputIndex -ge 0 }).Count - if ($screens.Count -lt $ChannelCount) { - $reason = "활성 모니터 $ChannelCount 개 미만" - } - elseif ($capturableScreenCount -lt $ChannelCount) { - $reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" - } - if (-not $reason) { $reason = Test-NetworkHealth $network } - if (-not $reason -and $AudioEnabled -eq 1 -and -not $endpoint) { - $reason = "활성 오디오 출력 장치 없음" - } + $reason = Get-StreamingReadinessReason -Screens $screens -Network $network ` + -Endpoint $endpoint if ($reason) { if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" }