Add dependency bootstrap and simulation tests
This commit is contained in:
1 parent
089c2421c5
commit
4088f2299d
3 files changed
+580
-15
No files matched your search
@@ -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: <https://www.gyan.dev/ffmpeg/builds/>
|
||||
- MediaMTX v1.21.1: <https://github.com/bluenviron/mediamtx/releases/tag/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 기본 출력 장치를 사용합니다.
|
||||
|
||||
+14
@@ -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
|
||||
+526
-13
@@ -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" }
|
||||
|
||||
Reference in new issue
Block a user