diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..1851f86
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,16 @@
+#!/bin/sh
+set -eu
+
+repo_root="$(git rev-parse --show-toplevel)"
+script_path="$repo_root/screen-rtsp.ps1"
+config_path="$repo_root/config.ini"
+
+if ! command -v powershell.exe >/dev/null 2>&1; then
+ echo "ScreenRTSP: powershell.exe를 찾을 수 없어 config.ini 자동 기록 정보를 제거할 수 없습니다." >&2
+ exit 1
+fi
+
+powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$script_path" \
+ -ConfigPath "$config_path" -ResetManagedInfo >/dev/null
+git add -- "$config_path"
+
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000..b08ab74
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,11 @@
+#!/bin/sh
+set -eu
+
+managed_keys='CheckedAt|EnvironmentFingerprint|GPU|NvidiaNvencApi|NvidiaNvencRequiredApi|NvidiaNvencSupport|AmdAmfSupport|IntelQsvSupport|SelectedHardwareEncoder|FFmpegVersion|FFmpegBuild|FFmpegExecutableSha256|FFmpegDownloadSource|FFmpegArchiveSha256Verification|MediaMTXVersion|MediaMTXExecutableSha256|MediaMTXDownloadSource|MediaMTXArchiveSha256Verification|CompatibilityTest'
+
+if git show HEAD:config.ini 2>/dev/null | grep -E "^# (${managed_keys})=.+" >/dev/null 2>&1; then
+ echo "ScreenRTSP: HEAD의 config.ini에 실제 장치/설치 정보가 남아 있어 push를 중단합니다." >&2
+ echo "다시 커밋하면 pre-commit hook이 자동 기록 영역을 비웁니다." >&2
+ exit 1
+fi
+
diff --git a/README.md b/README.md
index e4e6cea..ac968c1 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +16,10 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서
- 전체 화면과 마우스 커서 포함
- 모니터 연결, 해제, 좌표, 해상도, 회전, Primary 변경 감지
- NVIDIA NVENC, AMD AMF, Intel QSV 자동 감지
-- GPU 존재 여부와 FFmpeg 1프레임 초기화 시험을 함께 사용한 인코더 선택
+- GPU/드라이버를 매번 다시 읽고 각 인코더를 4초간 실제 인코딩한 뒤 H.264 IDR 주기를 검사해 선택
+- NVENC 요구 API와 드라이버 제공 API가 다르면 게시 해시가 있는 이전 호환 FFmpeg 후보를 시험
+- AMF를 포함한 모든 후보에 명시적 주기 IDR을 강제하고, 중간 접속 수신기의 첫 영상 수신을 검증
+- 환경이 바뀌면 단일 FFmpeg 2채널 2560×1440/30fps/채널당 8Mbps/AAC/11초 RTSP 최종 시험 수행
- 기본 우선순위 `NVIDIA > AMD > Intel`, 실행 인자로 변경 가능
- Windows Core Audio WASAPI loopback 기반 시스템 재생음 캡처
- 실제 오디오 peak가 있는 출력 장치 우선 선택
@@ -39,8 +42,9 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서
- 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구
- `config.ini` 기반 기능별 설정과 명령줄 override
- 전체 오디오와 마이크 혼합을 각각 `1` 또는 `0`으로 활성화·비활성화
-- FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, SHA-256 검증, 자동 설치
-- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 42건
+- FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, 게시 SHA-256 검증, 후보 선검증 및 백업 가능한 안전 교체
+- `config.ini` 최상단에 비개인 환경·호환성 정보를 원자적으로 자동 기록
+- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 49건
## RTSP URL
@@ -78,6 +82,9 @@ WASAPI 출력 loopback worker → named pipe ─┐ ├─ FFmpeg 1개
- `logs/watchdog.log`: watchdog 상태 로그
- `logs/ffmpeg.log`: 모든 monitor 출력을 담당하는 단일 FFmpeg 로그
- `logs/audio-loopback.log`: 단일 WASAPI loopback worker 오류 로그
+- `logs/compatibility-state.json`: 동일 환경의 무거운 2채널 최종 시험 성공 상태
+- `logs/dependency-evidence.json`: 다운로드 출처와 ZIP SHA-256 검증 이력
+- `logs/backups/`: 안전 교체 전 기존 실행 파일 백업
실행 파일과 생성 파일은 저장소에 포함하지 않습니다. `Dependencies.AutoInstall=1`이면 실행할 때 없는 `ffmpeg.exe`와 `mediamtx.exe`를 스크립트 옆에 자동 설치합니다.
@@ -85,6 +92,19 @@ WASAPI 출력 loopback worker → named pipe ─┐ ├─ FFmpeg 1개
기본 설정은 `screen-rtsp.ps1`과 같은 폴더의 `config.ini`에서 읽습니다.
+파일 최상단의 다음 표식 사이는 프로그램이 관리하는 설치 환경 정보입니다.
+
+```ini
+# >>> SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT >>>
+# 이 부분은 실제 컴퓨터에서 실행한 후 자동 기록되며 사용자가 직접 수정하지 않습니다.
+# ...
+# <<< SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT <<<
+```
+
+GPU 이름과 드라이버, NVENC API, AMF/QSV 시험 결과, 선택 인코더, FFmpeg/MediaMTX 버전·실행 파일 SHA-256·검증 출처·확인 시각이 기록됩니다. 모든 줄이 주석이므로 설정 파서가 읽지 않으며, 송출 판단은 이 기록이 아니라 실행 시 다시 수행한 실제 검사만 사용합니다. 사용자 이름, 컴퓨터 이름, IP, PnP 장치 ID, 토큰이나 인증 정보는 기록하지 않습니다.
+
+갱신은 같은 폴더의 임시 파일을 완성한 뒤 원자적으로 교체하므로 실패해도 기존 `config.ini`를 유지합니다. 표식 밖의 설정과 사용자 주석은 변경하지 않습니다.
+
```ini
[Video]
FrameRate=30
@@ -122,6 +142,8 @@ SignalThreshold=0.0005
| `Dependencies` | `AutoInstall` | `1` | 실행 파일 부재 시 자동 설치 여부 |
| `Dependencies` | `FFmpegUrl` | Gyan essentials ZIP | FFmpeg Windows ZIP 다운로드 주소 |
| `Dependencies` | `FFmpegSha256Url` | Gyan SHA-256 | FFmpeg 게시 해시 주소 |
+| `Dependencies` | `FFmpegFallbackUrl` | BtbN 2026-05-31 n8.1 호환 빌드 | NVENC API 불일치 때만 시험할 Windows x64 후보 |
+| `Dependencies` | `FFmpegFallbackSha256Url` | 해당 Release `checksums.sha256` | 파일명과 일치하는 게시 해시 주소 |
| `Dependencies` | `MediaMTXUrl` | v1.21.1 공식 ZIP | MediaMTX Windows amd64 ZIP 주소 |
| `Dependencies` | `MediaMTXSha256` | v1.21.1 공식 해시 | MediaMTX ZIP 고정 SHA-256 |
@@ -137,11 +159,14 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다.
`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. 검증용 임시 폴더를 삭제합니다.
+1. 기존 실행 파일이 정상이면 유지하며 불필요하게 다운로드하지 않습니다.
+2. 필요할 때만 HTTPS 주소의 ZIP을 Windows 임시 폴더에 받습니다.
+3. FFmpeg는 배포자가 게시한 SHA-256을 파일명까지 대조하고, MediaMTX는 공식 v1.21.1 Release SHA-256을 검증합니다.
+4. 임시 폴더 안의 후보에서 `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, AAC와 DirectShow 지원을 확인합니다.
+5. 현재 설치 GPU별로 4초/120프레임 이상 인코딩하고, 디코딩 키프레임과 Annex-B NAL type 5가 GOP 30 기준으로 주기 생성되는지 검사합니다.
+6. NVIDIA 우선 환경에서 최신 빌드가 `Required NVENC API > Found API`로 실패하면 2026-05-31 BtbN n8.1 호환 후보를 동일한 절차와 2채널 RTSP 시험까지 통과시킨 뒤에만 채택합니다.
+7. 교체 직전 기존 실행 파일을 `logs/backups`에 보존하고 같은 볼륨에서 안전 교체합니다. 다운로드·해시·기능·인코딩 검증 중 하나라도 실패하면 기존 파일을 유지합니다.
+8. MediaMTX v1.21.1이 정상 실행되면 교체하지 않습니다.
송출을 시작하지 않고 설치와 기능 확인만 먼저 수행할 수 있습니다.
@@ -149,14 +174,25 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다.
.\screen-rtsp.ps1 -InstallDependenciesOnly
```
+환경 변경 여부와 관계없이 2채널 최종 시험을 다시 수행하려면 다음처럼 실행합니다. 이미 Watchdog이 두 GPU 세션을 사용 중이면 시험 결과가 왜곡될 수 있으므로 먼저 자동 실행 작업을 완전히 중지한 상태에서 사용합니다.
+
+```powershell
+.\screen-rtsp.ps1 -InstallDependenciesOnly -ForceCompatibilityTest
+```
+
자동 다운로드를 금지하려면 `AutoInstall=0`으로 바꿉니다. 이 경우 실행 파일이 없으면 오류를 표시하고 종료합니다. 자동 설치에는 인터넷 HTTPS 연결과 스크립트 폴더 쓰기 권한이 필요합니다.
+예를 들어 NVIDIA Quadro P6000 드라이버 582.70이 제공하는 NVENC API가 13.0이고 현재 FFmpeg 빌드가 13.1을 요구하면 로그에 `Required: 13.1 Found: 13.0`을 보존합니다. 이 경우 NVIDIA가 우선순위상 첫 GPU일 때만 13.1 헤더 도입 전의 BtbN 월별 보관 빌드를 내려받아 임시 위치에서 SHA-256, 기능, 4초 IDR, 2채널 RTSP 시험을 모두 수행합니다. 이전 빌드도 실패하면 설치하지 않고, 현재 FFmpeg에서 IDR 및 최종 수신 시험을 통과한 AMD AMF 또는 Intel QSV를 선택합니다.
+
- FFmpeg Windows build:
+- FFmpeg 공식 Windows 빌드 안내(Gyan/BtbN):
+- NVENC API 13.1 최소 드라이버 안내:
+- NVENC API 13.0 호환 후보(BtbN monthly release):
- MediaMTX v1.21.1:
## 내장 시뮬레이션
-실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 42건을 검사합니다.
+실제 송출 프로세스나 예약 작업을 만들지 않고 주요 판단 로직 49건을 검사합니다. 자동 기록 블록의 파서 격리, 사용자 설정 보존, 중복 방지, 개인정보 키 배제와 GPU별 IDR 옵션도 포함됩니다.
```powershell
.\screen-rtsp.ps1 -RunSimulationTests
@@ -166,14 +202,17 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다.
## 처리 흐름
-1. FFmpeg와 MediaMTX가 없으면 설정에 따라 다운로드·검증·설치한 뒤, FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다.
-2. 설치된 GPU를 `NVIDIA > AMD > Intel` 순서로 찾고 각 인코더를 실제 1프레임으로 초기화합니다.
-3. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력·입력 endpoint를 읽습니다.
-4. Core Audio peak가 감지된 출력 장치와 마이크를 우선하고, 신호가 없으면 Windows 기본 Multimedia 장치를 사용합니다.
-5. 모든 조건이 3초 동안 유지되면 WASAPI loopback worker 하나와 FFmpeg 하나를 시작합니다.
-6. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다.
-7. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다.
-8. 모니터, NIC, GPU, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 새 구성을 읽어 재시작합니다.
+1. FFmpeg와 MediaMTX가 없으면 HTTPS/SHA-256/후보 실행 검증 후 설치합니다.
+2. GPU와 드라이버를 다시 읽고 각 하드웨어 인코더로 4초 이상 실제 H.264를 생성해 주기 IDR을 검사합니다.
+3. NVIDIA NVENC API 불일치가 확인되면 호환 이전 빌드를 임시 폴더에서 먼저 검증합니다. 실패하면 기존 파일을 유지하고 IDR 검증을 통과한 AMF/QSV 후보로 넘어갑니다.
+4. 최초 실행이나 GPU·드라이버·FFmpeg·MediaMTX 변경 시 두 개의 1440p 시험 영상을 하나의 FFmpeg에서 RTSP로 publish하고 새 수신기 두 개로 11초간 실제 디코딩, IDR, FPS, 비트레이트와 AAC 트랙을 확인합니다.
+5. 검증 결과와 비개인 환경 정보를 `config.ini` 자동 기록 영역 및 watchdog 로그에 남깁니다.
+6. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력·입력 endpoint를 읽습니다.
+7. Core Audio peak가 감지된 출력 장치와 마이크를 우선하고, 신호가 없으면 Windows 기본 Multimedia 장치를 사용합니다.
+8. 모든 조건이 3초 동안 유지되면 WASAPI loopback worker 하나와 FFmpeg 하나를 시작합니다.
+9. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다.
+10. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다.
+11. 모니터, NIC, GPU, 드라이버, 실행 파일, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 검증과 구성을 새로 읽어 재시작합니다.
## 설치 및 실행
@@ -292,3 +331,12 @@ GPU 우선순위를 바꾸는 예시:
## 저장소 정책
FFmpeg와 MediaMTX 바이너리, 로그, 자동 생성 설정, 인증서와 키는 Git에 올리지 않습니다. 비밀번호나 토큰도 스크립트와 README에 기록하지 않습니다.
+
+실제 실행 후 `config.ini` 최상단에는 해당 PC의 GPU와 실행 파일 정보가 채워집니다. 저장소에는 표식과 안내 문구 및 빈 필드만 남겨야 하므로 `.githooks/pre-commit`이 커밋 직전에 자동 기록 영역만 비우고 다시 stage합니다. `pre-push`는 HEAD에 실제 값이 남아 있으면 push를 중단합니다. 스크립트를 Git 작업 폴더에서 한 번 실행하면 `core.hooksPath=.githooks`를 자동 설정하며, 수동 활성화와 수동 초기화 명령은 다음과 같습니다.
+
+```powershell
+.\screen-rtsp.ps1 -InstallGitHooks
+.\screen-rtsp.ps1 -ResetManagedInfo
+```
+
+이 초기화는 표식 사이만 교체하며 나머지 설정값과 사용자 주석을 보존합니다. `--no-verify`로 hook을 우회하지 마십시오.
diff --git a/config.ini b/config.ini
index f6ecd4d..be84f11 100644
--- a/config.ini
+++ b/config.ini
@@ -1,4 +1,28 @@
-# ============================================================
+# >>> SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT >>>
+# 이 부분은 실제 컴퓨터에서 실행한 후 자동 기록되며 사용자가 직접 수정하지 않습니다.
+# 설정값으로 해석되지 않으며 송출 동작을 결정하는 입력으로 사용되지 않습니다.
+# CheckedAt=
+# EnvironmentFingerprint=
+# GPU=
+# NvidiaNvencApi=
+# NvidiaNvencRequiredApi=
+# NvidiaNvencSupport=
+# AmdAmfSupport=
+# IntelQsvSupport=
+# SelectedHardwareEncoder=
+# FFmpegVersion=
+# FFmpegBuild=
+# FFmpegExecutableSha256=
+# FFmpegDownloadSource=
+# FFmpegArchiveSha256Verification=
+# MediaMTXVersion=
+# MediaMTXExecutableSha256=
+# MediaMTXDownloadSource=
+# MediaMTXArchiveSha256Verification=
+# CompatibilityTest=
+# <<< SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT <<<
+
+# ============================================================
# RTSP Watchdog 설정
# ============================================================
#
@@ -69,7 +93,7 @@ PollIntervalSec=1
FrameRate=30
Bitrate=8000k
-# 앞에 있는 GPU부터 실제 1프레임 인코더 초기화를 시험합니다.
+# 앞에 있는 GPU부터 3초 이상 실제 인코딩, 주기 IDR 및 2채널 RTSP 수신을 시험합니다.
EncoderPriority=NVIDIA,AMD,Intel
[Audio]
@@ -104,6 +128,11 @@ AutoInstall=1
FFmpegUrl=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
FFmpegSha256Url=https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256
+# 최신 빌드의 NVENC API가 현재 NVIDIA 드라이버보다 높을 때 시험할 2026-05-31 BtbN 호환 후보입니다.
+# FFmpeg 공식 다운로드 페이지가 안내하는 Windows 빌드이며 게시된 checksums.sha256을 검증합니다.
+FFmpegFallbackUrl=https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-n8.1.1-9-g58d4114d36-win64-gpl-8.1.zip
+FFmpegFallbackSha256Url=https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/checksums.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 cab351d..0bc7b79 100644
--- a/screen-rtsp.ps1
+++ b/screen-rtsp.ps1
@@ -46,6 +46,8 @@ param(
[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]$FFmpegFallbackDownloadUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-n8.1.1-9-g58d4114d36-win64-gpl-8.1.zip",
+ [string]$FFmpegFallbackSha256Url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/checksums.sha256",
[string]$MediaMTXDownloadUrl = "https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_windows_amd64.zip",
[string]$MediaMTXSha256 = "faa97974861eb75a68b5aa326c78e7e7a6f670b5ef191bace78e715130381f23",
@@ -57,6 +59,9 @@ param(
[switch]$ShowConfiguration,
[switch]$InstallDependenciesOnly,
[switch]$RunSimulationTests,
+ [switch]$ForceCompatibilityTest,
+ [switch]$ResetManagedInfo,
+ [switch]$InstallGitHooks,
[switch]$Foreground,
[Parameter(DontShow = $true)]
@@ -701,6 +706,8 @@ function Assert-IniSettings {
"AutoInstall",
"FFmpegUrl",
"FFmpegSha256Url",
+ "FFmpegFallbackUrl",
+ "FFmpegFallbackSha256Url",
"MediaMTXUrl",
"MediaMTXSha256"
)
@@ -811,6 +818,14 @@ if ($ConfigWasLoaded) {
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegSha256Url")) {
$FFmpegSha256Url = [string]$setting.Value
}
+ $setting = Get-IniSetting $ini "Dependencies" "FFmpegFallbackUrl"
+ if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegFallbackDownloadUrl")) {
+ $FFmpegFallbackDownloadUrl = [string]$setting.Value
+ }
+ $setting = Get-IniSetting $ini "Dependencies" "FFmpegFallbackSha256Url"
+ if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegFallbackSha256Url")) {
+ $FFmpegFallbackSha256Url = [string]$setting.Value
+ }
$setting = Get-IniSetting $ini "Dependencies" "MediaMTXUrl"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXDownloadUrl")) {
$MediaMTXDownloadUrl = [string]$setting.Value
@@ -837,9 +852,118 @@ $MediaMTXPath = Join-Path $BaseDir "mediamtx.exe"
$MediaMTXConfig = Join-Path $BaseDir "mediamtx.yml"
$LogDir = Join-Path $BaseDir "logs"
$WatchdogLog = Join-Path $LogDir "watchdog.log"
+$CompatibilityStatePath = Join-Path $LogDir "compatibility-state.json"
+$DependencyEvidencePath = Join-Path $LogDir "dependency-evidence.json"
$TaskName = "Screen RTSP Watchdog"
$PowerShellPath = Join-Path $PSHOME "powershell.exe"
$RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort"
+$DependencyInstallEvidence = @{}
+$ManagedInfoStart = "# >>> SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT >>>"
+$ManagedInfoEnd = "# <<< SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT <<<"
+$ManagedInfoKeys = @(
+ "CheckedAt",
+ "EnvironmentFingerprint",
+ "GPU",
+ "NvidiaNvencApi",
+ "NvidiaNvencRequiredApi",
+ "NvidiaNvencSupport",
+ "AmdAmfSupport",
+ "IntelQsvSupport",
+ "SelectedHardwareEncoder",
+ "FFmpegVersion",
+ "FFmpegBuild",
+ "FFmpegExecutableSha256",
+ "FFmpegDownloadSource",
+ "FFmpegArchiveSha256Verification",
+ "MediaMTXVersion",
+ "MediaMTXExecutableSha256",
+ "MediaMTXDownloadSource",
+ "MediaMTXArchiveSha256Verification",
+ "CompatibilityTest"
+)
+
+function ConvertTo-ManagedInfoValue {
+ param($Value)
+ if ($null -eq $Value) { return "" }
+ return (([string]$Value) -replace '[\r\n]+', ' ' -replace '[\x00-\x1f]', '').Trim()
+}
+
+function New-ManagedInfoBlock {
+ param([hashtable]$Values = @{})
+ $lines = New-Object System.Collections.Generic.List[string]
+ $lines.Add($ManagedInfoStart)
+ $lines.Add("# 이 부분은 실제 컴퓨터에서 실행한 후 자동 기록되며 사용자가 직접 수정하지 않습니다.")
+ $lines.Add("# 설정값으로 해석되지 않으며 송출 동작을 결정하는 입력으로 사용되지 않습니다.")
+ foreach ($key in $ManagedInfoKeys) {
+ $value = if ($Values.ContainsKey($key)) {
+ ConvertTo-ManagedInfoValue $Values[$key]
+ }
+ else { "" }
+ $lines.Add("# $key=$value")
+ }
+ $lines.Add($ManagedInfoEnd)
+ return ($lines -join "`r`n")
+}
+
+function Set-ManagedInfoBlock {
+ param(
+ [hashtable]$Values = @{},
+ [string]$Path = $ConfigPath
+ )
+ $block = New-ManagedInfoBlock -Values $Values
+ $existing = if (Test-Path -LiteralPath $Path -PathType Leaf) {
+ [System.IO.File]::ReadAllText($Path)
+ }
+ else { "" }
+
+ $pattern = '(?ms)^' + [regex]::Escape($ManagedInfoStart) +
+ '.*?^' + [regex]::Escape($ManagedInfoEnd) + '\s*(?:\r?\n)?'
+ $matches = [regex]::Matches($existing, $pattern)
+ if ($matches.Count -gt 1) {
+ throw "config.ini 자동 기록 영역이 둘 이상 존재합니다. 파일을 먼저 정리하십시오."
+ }
+ if ($matches.Count -eq 1) {
+ $updated = [regex]::Replace($existing, $pattern, $block + "`r`n`r`n", 1)
+ }
+ else {
+ $updated = $block + "`r`n`r`n" + $existing.TrimStart("`r", "`n")
+ }
+ if ($updated -eq $existing) { return }
+
+ $directory = Split-Path -Parent ([System.IO.Path]::GetFullPath($Path))
+ if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
+ New-Item -ItemType Directory -Path $directory -Force | Out-Null
+ }
+ $tempPath = Join-Path $directory (".{0}.{1}.tmp" -f (Split-Path -Leaf $Path),[Guid]::NewGuid().ToString("N"))
+ $backupPath = Join-Path $directory (".{0}.{1}.previous" -f (Split-Path -Leaf $Path),[Guid]::NewGuid().ToString("N"))
+ try {
+ # Windows PowerShell 5.1의 Get-Content가 한국어를 정확히 읽도록 UTF-8 BOM을 유지합니다.
+ [System.IO.File]::WriteAllText($tempPath, $updated, (New-Object System.Text.UTF8Encoding($true)))
+ if (Test-Path -LiteralPath $Path -PathType Leaf) {
+ [System.IO.File]::Replace($tempPath, $Path, $backupPath, $true)
+ }
+ else {
+ Move-Item -LiteralPath $tempPath -Destination $Path
+ }
+ }
+ finally {
+ if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
+ if (Test-Path -LiteralPath $backupPath) { Remove-Item -LiteralPath $backupPath -Force }
+ }
+}
+
+function Install-RepositoryGitHooks {
+ $gitDirectory = Join-Path $BaseDir ".git"
+ $hooksDirectory = Join-Path $BaseDir ".githooks"
+ if (-not (Test-Path -LiteralPath $gitDirectory) -or
+ -not (Test-Path -LiteralPath $hooksDirectory -PathType Container) -or
+ -not (Get-Command git.exe -ErrorAction SilentlyContinue)) {
+ return $false
+ }
+ & git.exe -C $BaseDir config core.hooksPath .githooks
+ if ($LASTEXITCODE -ne 0) { throw "Git hooksPath 설정에 실패했습니다." }
+ return $true
+}
function Quote-ProcessArgument {
param([string]$Value)
@@ -883,9 +1007,12 @@ function Get-HiddenWorkerArgumentList {
"-AutoInstallDependencies", [string]$AutoInstallDependencies,
"-FFmpegDownloadUrl", $FFmpegDownloadUrl,
"-FFmpegSha256Url", $FFmpegSha256Url,
+ "-FFmpegFallbackDownloadUrl", $FFmpegFallbackDownloadUrl,
+ "-FFmpegFallbackSha256Url", $FFmpegFallbackSha256Url,
"-MediaMTXDownloadUrl", $MediaMTXDownloadUrl,
"-MediaMTXSha256", $MediaMTXSha256
)
+ if ($ForceCompatibilityTest) { $values += "-ForceCompatibilityTest" }
return Join-ProcessArguments $values
}
@@ -927,6 +1054,26 @@ function Install-StartupTask {
Write-Host "설정 파일: $ConfigPath"
}
+if ($ResetManagedInfo) {
+ Set-ManagedInfoBlock -Values @{}
+ Write-Host "config.ini 자동 기록 영역을 저장소용 빈 템플릿으로 초기화했습니다: $ConfigPath"
+ exit 0
+}
+
+if ($InstallGitHooks) {
+ if (Install-RepositoryGitHooks) {
+ Write-Host "Git 커밋 전 자동 기록 정보 제거 hook을 활성화했습니다."
+ }
+ else {
+ throw "현재 폴더에서 Git 저장소 또는 .githooks 폴더를 찾을 수 없습니다."
+ }
+ exit 0
+}
+
+# 개발 저장소에서 실행할 때만 저장소에 포함된 hook을 자동 활성화합니다.
+# 배포 폴더에는 .git이 없으므로 아무 변경도 하지 않습니다.
+$null = Install-RepositoryGitHooks
+
if ($ShowConfiguration) {
[PSCustomObject]@{
ConfigPath = $ConfigPath
@@ -947,6 +1094,8 @@ if ($ShowConfiguration) {
AutoInstallDependencies = $AutoInstallDependencies
FFmpegDownloadUrl = $FFmpegDownloadUrl
FFmpegSha256Url = $FFmpegSha256Url
+ FFmpegFallbackDownloadUrl = $FFmpegFallbackDownloadUrl
+ FFmpegFallbackSha256Url = $FFmpegFallbackSha256Url
MediaMTXDownloadUrl = $MediaMTXDownloadUrl
MediaMTXSha256 = $MediaMTXSha256
} | Format-List
@@ -1003,6 +1152,34 @@ function Write-Log {
Add-Content -LiteralPath $WatchdogLog -Value $line -Encoding UTF8
}
+function Import-DependencyEvidence {
+ if (-not (Test-Path -LiteralPath $DependencyEvidencePath -PathType Leaf)) { return }
+ try {
+ $saved = Get-Content -LiteralPath $DependencyEvidencePath -Raw | ConvertFrom-Json
+ foreach ($name in @("FFmpeg", "MediaMTX")) {
+ if ($saved.$name) { $script:DependencyInstallEvidence[$name] = $saved.$name }
+ }
+ }
+ catch { Write-Log "의존성 검증 이력 읽기 실패: $($_.Exception.Message)" }
+}
+
+function Save-DependencyEvidence {
+ $tempPath = "$DependencyEvidencePath.$([Guid]::NewGuid().ToString('N')).tmp"
+ try {
+ [System.IO.File]::WriteAllText(
+ $tempPath,
+ ($script:DependencyInstallEvidence | ConvertTo-Json -Depth 6),
+ (New-Object System.Text.UTF8Encoding($false))
+ )
+ Move-Item -LiteralPath $tempPath -Destination $DependencyEvidencePath -Force
+ }
+ finally {
+ if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
+ }
+}
+
+Import-DependencyEvidence
+
function Assert-HttpsDependencyUrl {
param([string]$Url, [string]$Name)
$uri = $null
@@ -1056,7 +1233,7 @@ function Save-DependencyFile {
}
function Get-RemoteSha256 {
- param([string]$Url, [string]$Name)
+ param([string]$Url, [string]$Name, [string]$TargetFileName = "")
Assert-HttpsDependencyUrl -Url $Url -Name "$Name SHA-256"
$previousProgressPreference = $ProgressPreference
$ProgressPreference = "SilentlyContinue"
@@ -1066,7 +1243,23 @@ function Get-RemoteSha256 {
finally {
$ProgressPreference = $previousProgressPreference
}
- $match = [regex]::Match([string]$response.Content, '(?i)\b[0-9a-f]{64}\b')
+ $content = if ($response.Content -is [byte[]]) {
+ [System.Text.Encoding]::UTF8.GetString($response.Content)
+ }
+ else { [string]$response.Content }
+ $match = $null
+ if (-not [string]::IsNullOrWhiteSpace($TargetFileName)) {
+ foreach ($line in ($content -split '[\r\n]+')) {
+ if ($line -match [regex]::Escape($TargetFileName) -and
+ $line -match '(?i)\b([0-9a-f]{64})\b') {
+ $match = [regex]::Match($line, '(?i)\b[0-9a-f]{64}\b')
+ break
+ }
+ }
+ }
+ if (-not $match) {
+ $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b')
+ }
if (-not $match.Success) {
throw "$Name SHA-256 응답에서 해시를 찾을 수 없습니다: $Url"
}
@@ -1080,7 +1273,8 @@ function Install-ZipDependency {
[string]$ExpectedSha256,
[string]$ExpectedSha256Url,
[string]$ExecutableName,
- [string]$DestinationPath
+ [string]$DestinationPath,
+ [scriptblock]$Validator
)
$tempDirectory = New-DependencyTempDirectory
@@ -1090,7 +1284,9 @@ function Install-ZipDependency {
Save-DependencyFile -Url $DownloadUrl -Destination $archivePath -Name $Name
if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256Url)) {
- $ExpectedSha256 = Get-RemoteSha256 -Url $ExpectedSha256Url -Name $Name
+ $archiveFileName = [System.IO.Path]::GetFileName(([Uri]$DownloadUrl).AbsolutePath)
+ $ExpectedSha256 = Get-RemoteSha256 -Url $ExpectedSha256Url -Name $Name `
+ -TargetFileName $archiveFileName
}
if ($ExpectedSha256 -notmatch '(?i)^[0-9a-f]{64}$') {
throw "$Name SHA-256 값이 올바르지 않습니다."
@@ -1110,12 +1306,53 @@ function Install-ZipDependency {
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"
+ if ($Validator) {
+ $validation = & $Validator $candidate.FullName
+ if ($validation -is [bool]) {
+ if (-not $validation) { throw "$Name 후보 실행 파일 검증에 실패했습니다." }
+ }
+ elseif (-not $validation.Passed) {
+ throw "$Name 후보 실행 파일 검증에 실패했습니다: $($validation.Reason)"
+ }
}
- Write-Log "$Name 설치 완료: $DestinationPath"
+
+ $destinationDirectory = Split-Path -Parent $DestinationPath
+ $stagingPath = Join-Path $destinationDirectory `
+ (".{0}.{1}.staging" -f (Split-Path -Leaf $DestinationPath),[Guid]::NewGuid().ToString("N"))
+ $backupDirectory = Join-Path $LogDir "backups"
+ if (-not (Test-Path -LiteralPath $backupDirectory -PathType Container)) {
+ New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
+ }
+ $backupPath = Join-Path $backupDirectory `
+ ("{0}-{1}.bak" -f (Split-Path -Leaf $DestinationPath),(Get-Date -Format "yyyyMMdd-HHmmss"))
+ Copy-Item -LiteralPath $candidate.FullName -Destination $stagingPath
+ try {
+ Unblock-File -LiteralPath $stagingPath -ErrorAction SilentlyContinue
+ if (Test-Path -LiteralPath $DestinationPath -PathType Leaf) {
+ [System.IO.File]::Replace($stagingPath, $DestinationPath, $backupPath, $true)
+ Write-Log "$Name 기존 실행 파일 백업: $backupPath"
+ }
+ else {
+ Move-Item -LiteralPath $stagingPath -Destination $DestinationPath
+ }
+ }
+ finally {
+ if (Test-Path -LiteralPath $stagingPath) {
+ Remove-Item -LiteralPath $stagingPath -Force
+ }
+ }
+ if (-not (Test-Path -LiteralPath $DestinationPath -PathType Leaf)) {
+ throw "$Name 안전 교체 후 실행 파일을 찾을 수 없습니다: $DestinationPath"
+ }
+ $script:DependencyInstallEvidence[$Name] = [PSCustomObject]@{
+ Source = $DownloadUrl
+ ArchiveSha256 = $actualSha256
+ Sha256Verified = $true
+ InstalledAt = (Get-Date).ToString("o")
+ }
+ Save-DependencyEvidence
+ Write-Log "$Name 설치 완료: $DestinationPath (source=$DownloadUrl, sha256=$actualSha256)"
+ return $script:DependencyInstallEvidence[$Name]
}
finally {
Remove-DependencyTempDirectory -Path $tempDirectory
@@ -1124,8 +1361,28 @@ function Install-ZipDependency {
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 (-not (Test-Path -LiteralPath $FFmpegPath -PathType Leaf)) {
+ $missing += "ffmpeg.exe"
+ }
+ else {
+ try { $null = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath }
+ catch {
+ Write-Log "기존 FFmpeg 기능 검증 실패: $($_.Exception.Message)"
+ $missing += "ffmpeg.exe"
+ }
+ }
+ if (-not (Test-Path -LiteralPath $MediaMTXPath -PathType Leaf)) {
+ $missing += "mediamtx.exe"
+ }
+ else {
+ $mediaCheck = Invoke-ProcessWithTimeout -FilePath $MediaMTXPath `
+ -ArgumentList @("--version") -TimeoutMilliseconds 10000
+ if ($mediaCheck.ExitCode -ne 0 -or
+ $mediaCheck.Output -notmatch '(?i)MediaMTX|v\d+\.\d+') {
+ Write-Log "기존 MediaMTX 실행 검증 실패: $(Get-CompactFailureReason $mediaCheck.Output)"
+ $missing += "mediamtx.exe"
+ }
+ }
if ($missing.Count -eq 0) { return }
if ($AutoInstallDependencies -ne 1) {
@@ -1136,12 +1393,29 @@ function Assert-RequiredFiles {
if ($missing -contains "ffmpeg.exe") {
Install-ZipDependency -Name "FFmpeg" -DownloadUrl $FFmpegDownloadUrl `
-ExpectedSha256 "" -ExpectedSha256Url $FFmpegSha256Url `
- -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath
+ -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath `
+ -Validator {
+ param($candidatePath)
+ try {
+ $null = Test-FFmpegCapabilities -FFmpegExecutable $candidatePath
+ [PSCustomObject]@{Passed=$true;Reason=""}
+ }
+ catch { [PSCustomObject]@{Passed=$false;Reason=$_.Exception.Message} }
+ }
}
if ($missing -contains "mediamtx.exe") {
Install-ZipDependency -Name "MediaMTX" -DownloadUrl $MediaMTXDownloadUrl `
-ExpectedSha256 $MediaMTXSha256 -ExpectedSha256Url "" `
- -ExecutableName "mediamtx.exe" -DestinationPath $MediaMTXPath
+ -ExecutableName "mediamtx.exe" -DestinationPath $MediaMTXPath `
+ -Validator {
+ param($candidatePath)
+ $result = Invoke-ProcessWithTimeout -FilePath $candidatePath `
+ -ArgumentList @("--version") -TimeoutMilliseconds 10000
+ [PSCustomObject]@{
+ Passed = ($result.ExitCode -eq 0 -and $result.Output -match '(?i)MediaMTX|v\d+\.\d+')
+ Reason = $result.Output.Trim()
+ }
+ }
}
foreach ($path in @($FFmpegPath, $MediaMTXPath)) {
@@ -1189,38 +1463,42 @@ paths:
}
function Test-FFmpegCapabilities {
- $filters = (& $FFmpegPath -hide_banner -filters 2>&1 | Out-String)
+ param([string]$FFmpegExecutable = $FFmpegPath)
+
+ $filtersResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
+ -ArgumentList @("-hide_banner", "-filters") -TimeoutMilliseconds 15000
+ $encodersResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
+ -ArgumentList @("-hide_banner", "-encoders") -TimeoutMilliseconds 15000
+ $devicesResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
+ -ArgumentList @("-hide_banner", "-devices") -TimeoutMilliseconds 15000
+ if ($filtersResult.ExitCode -ne 0 -or $encodersResult.ExitCode -ne 0) {
+ throw "FFmpeg 기능 목록을 읽지 못했습니다."
+ }
+ $filters = $filtersResult.Output
+ $encoders = $encodersResult.Output
+ $devices = $devicesResult.Output
if ($filters -notmatch '(?m)\bddagrab\b') {
throw "현재 FFmpeg에 ddagrab 필터가 없습니다."
}
- $encoders = (& $FFmpegPath -hide_banner -encoders 2>&1 | Out-String)
- 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 인코더가 없습니다."
+ foreach ($requiredEncoder in @("h264_nvenc", "h264_amf", "h264_qsv", "aac")) {
+ if ($encoders -notmatch ("(?m)\b" + [regex]::Escape($requiredEncoder) + "\b")) {
+ throw "현재 FFmpeg에 필수 인코더가 없습니다: $requiredEncoder"
+ }
}
if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) {
- $devices = (& $FFmpegPath -hide_banner -devices 2>&1 | Out-String)
if ($devices -notmatch '(?m)\bdshow\b') {
throw "마이크 캡처가 켜져 있지만 현재 FFmpeg에 DirectShow(dshow) 입력이 없습니다."
}
}
- 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
+ return [PSCustomObject]@{
+ DdaGrab = $true
+ Nvenc = $true
+ Amf = $true
+ Qsv = $true
+ Aac = $true
+ Dshow = ($devices -match '(?m)\bdshow\b')
+ EncoderList = $encoders
+ }
}
function Invoke-ProcessWithTimeout {
@@ -1240,11 +1518,16 @@ function Invoke-ProcessWithTimeout {
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
[void]$process.Start()
+ $stdoutTask = $process.StandardOutput.ReadToEndAsync()
+ $stderrTask = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit($TimeoutMilliseconds)) {
try { $process.Kill() } catch { }
- return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out" }
+ try { $process.WaitForExit(5000) | Out-Null } catch { }
+ $partialOutput = ""
+ try { $partialOutput = $stdoutTask.Result + $stderrTask.Result } catch { }
+ return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out`r`n$partialOutput" }
}
- $output = $process.StandardOutput.ReadToEnd() + $process.StandardError.ReadToEnd()
+ $output = $stdoutTask.Result + $stderrTask.Result
return [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = $output }
}
@@ -1276,61 +1559,780 @@ function Get-GpuSignature {
}) -join "|")
}
+function Get-RuntimeEnvironmentSignature {
+ $binaryParts = foreach ($path in @($FFmpegPath, $MediaMTXPath)) {
+ if (Test-Path -LiteralPath $path -PathType Leaf) {
+ $item = Get-Item -LiteralPath $path
+ "$($item.Name):$($item.Length):$($item.LastWriteTimeUtc.Ticks)"
+ }
+ else { "MISSING:$path" }
+ }
+ return (Get-GpuSignature) + "||" + ($binaryParts -join "|")
+}
+
function Get-EncoderDefinition {
param([string]$Vendor)
switch ($Vendor) {
"NVIDIA" {
return [PSCustomObject]@{
Vendor = "NVIDIA"; Name = "h264_nvenc"
- Arguments = @("-preset","p1","-tune","ull","-rc","cbr","-zerolatency","1")
+ Arguments = @(
+ "-preset","p1","-tune","ull","-rc","cbr","-zerolatency","1",
+ "-forced-idr","1","-strict_gop","1"
+ )
}
}
"AMD" {
return [PSCustomObject]@{
Vendor = "AMD"; Name = "h264_amf"
- Arguments = @("-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1")
+ Arguments = @(
+ "-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1",
+ "-forced_idr","1","-header_spacing",[string]$FrameRate
+ )
}
}
"Intel" {
return [PSCustomObject]@{
Vendor = "Intel"; Name = "h264_qsv"
- Arguments = @("-preset","veryfast","-look_ahead","0","-low_delay_brc","1")
+ Arguments = @(
+ "-preset","veryfast","-look_ahead","0","-low_delay_brc","1",
+ "-forced_idr","1","-idr_interval","1","-repeat_pps","1"
+ )
}
}
}
}
+function Get-H264IdrMetrics {
+ param(
+ [string]$FFmpegExecutable,
+ [string]$BitstreamPath,
+ [int]$ExpectedMinimumIdrFrames = 3
+ )
+ $bytes = [System.IO.File]::ReadAllBytes($BitstreamPath)
+ $nalIdrCount = 0
+ for ($index = 0; $index -lt ($bytes.Length - 4); $index++) {
+ $nalStart = -1
+ if ($bytes[$index] -eq 0 -and $bytes[$index + 1] -eq 0 -and
+ $bytes[$index + 2] -eq 1) {
+ $nalStart = $index + 3
+ }
+ elseif ($bytes[$index] -eq 0 -and $bytes[$index + 1] -eq 0 -and
+ $bytes[$index + 2] -eq 0 -and $bytes[$index + 3] -eq 1) {
+ $nalStart = $index + 4
+ }
+ if ($nalStart -ge 0 -and (($bytes[$nalStart] -band 0x1f) -eq 5)) {
+ $nalIdrCount++
+ }
+ }
+
+ $decode = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
+ "-hide_banner", "-loglevel", "info", "-nostdin",
+ "-i", $BitstreamPath, "-vf", "showinfo", "-f", "null", "NUL"
+ ) -TimeoutMilliseconds 30000
+ $idrFrameCount = [regex]::Matches(
+ $decode.Output,
+ '(?im)Parsed_showinfo[^\r\n]*iskey:\s*1[^\r\n]*type:\s*I'
+ ).Count
+ $frameMatches = [regex]::Matches($decode.Output, '(?im)Parsed_showinfo[^\r\n]*\bn:\s*(\d+)')
+ $decodedFrames = 0
+ foreach ($match in $frameMatches) {
+ $decodedFrames = [Math]::Max($decodedFrames, ([int]$match.Groups[1].Value + 1))
+ }
+ return [PSCustomObject]@{
+ Passed = ($decode.ExitCode -eq 0 -and
+ $nalIdrCount -ge $ExpectedMinimumIdrFrames -and
+ $idrFrameCount -ge $ExpectedMinimumIdrFrames)
+ NalIdrCount = $nalIdrCount
+ IdrFrameCount = $idrFrameCount
+ DecodedFrames = $decodedFrames
+ DecodeExitCode = $decode.ExitCode
+ }
+}
+
+function Get-CompactFailureReason {
+ param([string]$Output, [int]$MaximumLength = 600)
+ $importantLines = @($Output -split '[\r\n]+' | Where-Object {
+ $_ -match '(?i)error|failed|failure|does not support|not supported|minimum required|required:|cannot|could not|timed out|invalid'
+ })
+ $source = if ($importantLines.Count -gt 0) { $importantLines -join " " } else { $Output }
+ $reason = (($source -replace '[\r\n]+', ' ') -replace '\s+', ' ').Trim()
+ if ($reason.Length -gt $MaximumLength) {
+ return $reason.Substring($reason.Length - $MaximumLength, $MaximumLength)
+ }
+ return $reason
+}
+
function Test-HardwareEncoder {
- param($Definition)
+ param(
+ $Definition,
+ [string]$FFmpegExecutable = $FFmpegPath,
+ [int]$TestSeconds = 4
+ )
+ $testDirectory = New-DependencyTempDirectory
+ $bitstreamPath = Join-Path $testDirectory ("{0}.h264" -f $Definition.Name)
+ $testFrameRate = [Math]::Max(1, [int]$FrameRate)
+ $frameCount = $testFrameRate * [Math]::Max(3, $TestSeconds)
+ $expectedIdrFrames = [Math]::Max(3, [int][Math]::Floor(($frameCount - 1) / $testFrameRate) + 1)
$arguments = @(
- "-hide_banner", "-loglevel", "error", "-nostdin",
- "-f", "lavfi", "-i", "color=c=black:s=128x128:r=30:d=0.1",
- "-frames:v", "1", "-vf", "format=nv12",
+ "-hide_banner", "-loglevel", "verbose", "-nostdin",
+ "-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=$testFrameRate",
+ "-frames:v", [string]$frameCount, "-vf", "format=nv12",
"-c:v", $Definition.Name
- ) + $Definition.Arguments + @("-b:v", "1000k", "-f", "null", "NUL")
- $result = Invoke-ProcessWithTimeout -FilePath $FFmpegPath -ArgumentList $arguments `
- -TimeoutMilliseconds 8000
- return ($result.ExitCode -eq 0)
+ ) + $Definition.Arguments + @(
+ "-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "4000k",
+ "-g", [string]$testFrameRate, "-bf", "0",
+ "-force_key_frames", "expr:gte(t,n_forced*1)",
+ "-f", "h264", "-y", $bitstreamPath
+ )
+ try {
+ $result = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
+ -ArgumentList $arguments -TimeoutMilliseconds 45000
+ $requiredApi = ""
+ $foundApi = ""
+ if ($result.Output -match '(?i)Required:\s*([0-9.]+)\s+Found:\s*([0-9.]+)') {
+ $requiredApi = $Matches[1]
+ $foundApi = $Matches[2]
+ }
+ elseif ($result.Output -match '(?i)Loaded Nvenc version\s+([0-9.]+)') {
+ $foundApi = $Matches[1]
+ }
+
+ $metrics = $null
+ if ($result.ExitCode -eq 0 -and
+ (Test-Path -LiteralPath $bitstreamPath -PathType Leaf) -and
+ (Get-Item -LiteralPath $bitstreamPath).Length -gt 0) {
+ $metrics = Get-H264IdrMetrics -FFmpegExecutable $FFmpegExecutable `
+ -BitstreamPath $bitstreamPath -ExpectedMinimumIdrFrames $expectedIdrFrames
+ }
+ $passed = ($result.ExitCode -eq 0 -and $metrics -and $metrics.Passed)
+ $failureReason = ""
+ if (-not $passed) {
+ if ($result.ExitCode -ne 0) {
+ $failureReason = Get-CompactFailureReason $result.Output
+ }
+ elseif (-not $metrics) {
+ $failureReason = "인코더가 H.264 비트스트림을 생성하지 않았습니다."
+ }
+ else {
+ $failureReason = "주기 IDR 검증 실패: expected>=$expectedIdrFrames, " +
+ "idrFrames=$($metrics.IdrFrameCount), nalType5=$($metrics.NalIdrCount)"
+ }
+ }
+ return [PSCustomObject]@{
+ Vendor = $Definition.Vendor
+ Encoder = $Definition.Name
+ Passed = $passed
+ ExitCode = $result.ExitCode
+ TestSeconds = [Math]::Max(3, $TestSeconds)
+ Frames = if ($metrics) { $metrics.DecodedFrames } else { 0 }
+ IdrFrames = if ($metrics) { $metrics.IdrFrameCount } else { 0 }
+ NalIdrCount = if ($metrics) { $metrics.NalIdrCount } else { 0 }
+ RequiredApi = $requiredApi
+ FoundApi = $foundApi
+ ApiMismatch = (-not [string]::IsNullOrWhiteSpace($requiredApi) -and
+ -not [string]::IsNullOrWhiteSpace($foundApi) -and
+ ([version]$requiredApi -gt [version]$foundApi))
+ FailureReason = $failureReason
+ }
+ }
+ finally {
+ Remove-DependencyTempDirectory -Path $testDirectory
+ }
+}
+
+function Test-AvailableHardwareEncoders {
+ param([string]$FFmpegExecutable = $FFmpegPath)
+ $inventory = @(Get-GpuInventory)
+ $results = New-Object System.Collections.Generic.List[object]
+ foreach ($vendor in @("NVIDIA", "AMD", "Intel")) {
+ $gpu = @($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" })
+ $definition = Get-EncoderDefinition $vendor
+ if ($gpu.Count -eq 0) {
+ $results.Add([PSCustomObject]@{
+ Vendor=$vendor; Encoder=$definition.Name; Passed=$false; ExitCode=$null
+ TestSeconds=0; Frames=0; IdrFrames=0; NalIdrCount=0
+ RequiredApi=""; FoundApi=""; ApiMismatch=$false
+ FailureReason="GPU가 감지되지 않았습니다."
+ })
+ continue
+ }
+ Write-Log "하드웨어 인코더 4초/IDR 시험: $vendor / $($definition.Name)"
+ $test = Test-HardwareEncoder -Definition $definition `
+ -FFmpegExecutable $FFmpegExecutable -TestSeconds 4
+ $results.Add($test)
+ if ($test.Passed) {
+ Write-Log ("인코더 시험 성공: {0}, frames={1}, IDR frames={2}, NAL type5={3}" -f `
+ $test.Encoder,$test.Frames,$test.IdrFrames,$test.NalIdrCount)
+ }
+ else {
+ Write-Log "인코더 시험 실패: $($test.Encoder) - $($test.FailureReason)"
+ }
+ }
+ return $results.ToArray()
}
function Select-HardwareEncoder {
- param([string]$EncoderList)
- $inventory = @(Get-GpuInventory)
+ param($TestResults)
foreach ($vendor in $EncoderPriority) {
- if (-not ($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" })) {
- continue
- }
- $definition = Get-EncoderDefinition $vendor
- if ($EncoderList -notmatch [regex]::Escape($definition.Name)) { continue }
- Write-Log "하드웨어 인코더 시험: $vendor / $($definition.Name)"
- if (Test-HardwareEncoder $definition) {
- $gpuNames = (($inventory | Where-Object Vendor -eq $vendor).Name -join ", ")
- Write-Log "하드웨어 인코더 선택: $($definition.Name) ($gpuNames)"
+ $test = $TestResults | Where-Object { $_.Vendor -eq $vendor } | Select-Object -First 1
+ if ($test -and $test.Passed) {
+ $definition = Get-EncoderDefinition $vendor
+ $gpuNames = ((Get-GpuInventory | Where-Object Vendor -eq $vendor).Name -join ", ")
+ Write-Log "하드웨어 인코더 선택 후보: $($definition.Name) ($gpuNames)"
return $definition
}
- Write-Log "하드웨어 인코더 사용 불가: $($definition.Name)"
}
- throw "감지된 GPU에서 사용할 수 있는 H.264 하드웨어 인코더가 없습니다."
+ $failureSummary = ($TestResults | ForEach-Object {
+ "$($_.Encoder): $($_.FailureReason)"
+ }) -join " | "
+ throw "감지된 GPU에서 IDR 검증을 통과한 H.264 하드웨어 인코더가 없습니다. $failureSummary"
+}
+
+function Get-FreeTcpPort {
+ $listener = New-Object System.Net.Sockets.TcpListener(
+ [System.Net.IPAddress]::Loopback,
+ 0
+ )
+ $listener.Start()
+ try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
+ finally { $listener.Stop() }
+}
+
+function Wait-TcpPort {
+ param([int]$Port, [int]$TimeoutMilliseconds = 10000)
+ $stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
+ while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) {
+ $client = New-Object System.Net.Sockets.TcpClient
+ try {
+ $async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
+ if ($async.AsyncWaitHandle.WaitOne(250) -and $client.Connected) {
+ $client.EndConnect($async)
+ return $true
+ }
+ }
+ catch { }
+ finally { $client.Dispose() }
+ Start-Sleep -Milliseconds 100
+ }
+ return $false
+}
+
+function Stop-TestProcess {
+ param($Process)
+ if ($Process -and -not $Process.HasExited) {
+ try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch { }
+ try { $Process.WaitForExit(5000) | Out-Null } catch { }
+ }
+}
+
+function Get-MediaDurationSeconds {
+ param([string]$Output)
+ if ($Output -match '(?i)Duration:\s*(\d+):(\d+):([0-9.]+)') {
+ return ([int]$Matches[1] * 3600) + ([int]$Matches[2] * 60) +
+ [double]::Parse($Matches[3], [System.Globalization.CultureInfo]::InvariantCulture)
+ }
+ return 0.0
+}
+
+function Test-DualRtspCompatibility {
+ param(
+ $Definition,
+ [string]$FFmpegExecutable = $FFmpegPath,
+ [string]$MediaMTXExecutable = $MediaMTXPath,
+ [int]$ReceiveSeconds = 11
+ )
+ $testDirectory = New-DependencyTempDirectory
+ $port = Get-FreeTcpPort
+ $serverProcess = $null
+ $publisherProcess = $null
+ $receiverProcesses = @()
+ try {
+ $serverConfig = Join-Path $testDirectory "mediamtx-compat.yml"
+ $serverOut = Join-Path $testDirectory "mediamtx.out.log"
+ $serverErr = Join-Path $testDirectory "mediamtx.err.log"
+ $serverText = @"
+logLevel: warn
+rtsp: yes
+rtspAddress: 127.0.0.1:$port
+rtspTransports: [tcp]
+rtmp: no
+hls: no
+webrtc: no
+srt: no
+moq: no
+paths:
+ all_others:
+"@
+ [System.IO.File]::WriteAllText(
+ $serverConfig,
+ $serverText,
+ (New-Object System.Text.UTF8Encoding($false))
+ )
+ $serverProcess = Start-Process -FilePath $MediaMTXExecutable `
+ -ArgumentList (Quote-ProcessArgument $serverConfig) `
+ -WorkingDirectory $testDirectory -WindowStyle Hidden `
+ -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -PassThru
+ if (-not (Wait-TcpPort -Port $port -TimeoutMilliseconds 10000)) {
+ throw "임시 MediaMTX가 포트 $port 에서 시작되지 않았습니다."
+ }
+
+ $publishSeconds = $ReceiveSeconds + 8
+ $publisherArguments = @(
+ "-hide_banner", "-loglevel", "warning", "-nostdin",
+ "-re", "-f", "lavfi", "-i", "testsrc2=size=2560x1440:rate=30",
+ "-re", "-f", "lavfi", "-i", "testsrc2=size=2560x1440:rate=30",
+ "-re", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
+ "-filter_complex", "[2:a]asplit=2[a0][a1]"
+ )
+ for ($channelIndex = 0; $channelIndex -lt 2; $channelIndex++) {
+ $publisherArguments += @(
+ "-map", "$channelIndex`:v:0", "-map", "[a$channelIndex]",
+ "-c:v", $Definition.Name
+ )
+ $publisherArguments += @($Definition.Arguments)
+ $publisherArguments += @(
+ "-b:v", "8000k", "-maxrate", "8000k", "-bufsize", "8000k",
+ "-g", "30", "-bf", "0", "-pix_fmt", "nv12",
+ "-force_key_frames", "expr:gte(t,n_forced*1)",
+ "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
+ "-t", [string]$publishSeconds,
+ "-f", "rtsp", "-rtsp_transport", "tcp",
+ "rtsp://127.0.0.1:$port/compat-monitor$($channelIndex + 1)"
+ )
+ }
+ $publisherLog = Join-Path $testDirectory "publisher.log"
+ $publisherOut = Join-Path $testDirectory "publisher.out.log"
+ $publisherProcess = Start-Process -FilePath $FFmpegExecutable `
+ -ArgumentList (Join-ProcessArguments $publisherArguments) `
+ -WorkingDirectory $testDirectory -WindowStyle Hidden `
+ -RedirectStandardOutput $publisherOut -RedirectStandardError $publisherLog -PassThru
+
+ Start-Sleep -Seconds 3
+ if ($publisherProcess.HasExited) {
+ $publisherError = if (Test-Path $publisherLog) {
+ Get-Content -LiteralPath $publisherLog -Raw
+ }
+ else { "" }
+ throw "2채널 시험 publisher가 조기 종료했습니다: $(Get-CompactFailureReason $publisherError)"
+ }
+
+ $receiverStart = Get-Date
+ for ($channel = 1; $channel -le 2; $channel++) {
+ $recordingPath = Join-Path $testDirectory "monitor$channel.mkv"
+ $receiverOut = Join-Path $testDirectory "receiver$channel.out.log"
+ $receiverErr = Join-Path $testDirectory "receiver$channel.err.log"
+ $receiverArguments = @(
+ "-hide_banner", "-loglevel", "warning", "-nostdin",
+ "-fflags", "+genpts+discardcorrupt", "-rtsp_transport", "tcp",
+ "-i", "rtsp://127.0.0.1:$port/compat-monitor$channel",
+ "-t", [string]$ReceiveSeconds,
+ "-map", "0:v:0", "-map", "0:a:0", "-c", "copy", "-y", $recordingPath
+ )
+ $process = Start-Process -FilePath $FFmpegExecutable `
+ -ArgumentList (Join-ProcessArguments $receiverArguments) `
+ -WorkingDirectory $testDirectory -WindowStyle Hidden `
+ -RedirectStandardOutput $receiverOut -RedirectStandardError $receiverErr -PassThru
+ $receiverProcesses += [PSCustomObject]@{
+ Channel=$channel; Process=$process; RecordingPath=$recordingPath
+ ErrorPath=$receiverErr; FirstDataSeconds=$null
+ }
+ }
+
+ $joinDeadline = (Get-Date).AddSeconds(5)
+ while ((Get-Date) -lt $joinDeadline) {
+ foreach ($receiver in $receiverProcesses) {
+ if ($null -eq $receiver.FirstDataSeconds -and
+ (Test-Path -LiteralPath $receiver.RecordingPath -PathType Leaf) -and
+ (Get-Item -LiteralPath $receiver.RecordingPath).Length -gt 0) {
+ $receiver.FirstDataSeconds = [Math]::Round(((Get-Date) - $receiverStart).TotalSeconds, 2)
+ }
+ }
+ if (@($receiverProcesses | Where-Object { $null -eq $_.FirstDataSeconds }).Count -eq 0) {
+ break
+ }
+ Start-Sleep -Milliseconds 100
+ }
+
+ foreach ($receiver in $receiverProcesses) {
+ if (-not $receiver.Process.WaitForExit(($ReceiveSeconds + 10) * 1000)) {
+ Stop-TestProcess $receiver.Process
+ throw "monitor$($receiver.Channel) 수신기가 제한 시간 안에 종료되지 않았습니다."
+ }
+ }
+
+ $channelResults = New-Object System.Collections.Generic.List[object]
+ foreach ($receiver in $receiverProcesses) {
+ $channel = $receiver.Channel
+ if ($null -eq $receiver.FirstDataSeconds) {
+ throw "monitor$channel 새 수신기가 5초 안에 첫 데이터를 받지 못했습니다."
+ }
+ if (-not (Test-Path -LiteralPath $receiver.RecordingPath -PathType Leaf) -or
+ (Get-Item -LiteralPath $receiver.RecordingPath).Length -le 0) {
+ $receiverError = if (Test-Path $receiver.ErrorPath) {
+ Get-Content -LiteralPath $receiver.ErrorPath -Raw
+ }
+ else { "" }
+ throw "monitor$channel RTSP 수신 실패: $(Get-CompactFailureReason $receiverError)"
+ }
+
+ $verify = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
+ "-hide_banner", "-loglevel", "info", "-nostdin",
+ "-i", $receiver.RecordingPath,
+ "-map", "0:v:0", "-vf", "showinfo", "-map", "0:a:0",
+ "-f", "null", "NUL"
+ ) -TimeoutMilliseconds 60000
+ $duration = Get-MediaDurationSeconds $verify.Output
+ $rawPath = Join-Path $testDirectory "monitor$channel.h264"
+ $extract = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
+ "-hide_banner", "-loglevel", "error", "-nostdin",
+ "-i", $receiver.RecordingPath, "-map", "0:v:0", "-c:v", "copy",
+ "-bsf:v", "h264_mp4toannexb", "-f", "h264", "-y", $rawPath
+ ) -TimeoutMilliseconds 30000
+ if ($extract.ExitCode -ne 0) {
+ throw "monitor$channel H.264 비트스트림 추출 실패: $(Get-CompactFailureReason $extract.Output)"
+ }
+ $expectedIdr = [Math]::Max(3, [int][Math]::Floor($duration) - 2)
+ $metrics = Get-H264IdrMetrics -FFmpegExecutable $FFmpegExecutable `
+ -BitstreamPath $rawPath -ExpectedMinimumIdrFrames $expectedIdr
+ $ptsTimes = New-Object System.Collections.Generic.List[double]
+ foreach ($ptsMatch in [regex]::Matches(
+ $verify.Output,
+ '(?im)Parsed_showinfo[^\r\n]*\bpts_time:\s*(-?[0-9.]+)'
+ )) {
+ $ptsTimes.Add([double]::Parse(
+ $ptsMatch.Groups[1].Value,
+ [System.Globalization.CultureInfo]::InvariantCulture
+ ))
+ }
+ $videoDuration = if ($ptsTimes.Count -gt 1) {
+ ($ptsTimes[$ptsTimes.Count - 1] - $ptsTimes[0]) + (1.0 / 30.0)
+ }
+ else { 0.0 }
+ $fps = if ($videoDuration -gt 0) {
+ $metrics.DecodedFrames / $videoDuration
+ }
+ else { 0 }
+ $bitrate = if ($duration -gt 0) {
+ ((Get-Item -LiteralPath $receiver.RecordingPath).Length * 8.0 / $duration)
+ }
+ else { 0 }
+ $passed = ($verify.ExitCode -eq 0 -and $duration -ge 10.0 -and
+ $metrics.Passed -and $fps -ge 28.5 -and $fps -le 31.5 -and
+ $bitrate -ge 4000000 -and $bitrate -le 12000000 -and
+ $receiver.FirstDataSeconds -le 5.0)
+ $channelResults.Add([PSCustomObject]@{
+ Channel=$channel; Passed=$passed; Duration=[Math]::Round($duration,2)
+ VideoDuration=[Math]::Round($videoDuration,2)
+ Frames=$metrics.DecodedFrames; Fps=[Math]::Round($fps,2)
+ IdrFrames=$metrics.IdrFrameCount; NalIdrCount=$metrics.NalIdrCount
+ Bitrate=[Math]::Round($bitrate); AudioTrack=($verify.ExitCode -eq 0)
+ FirstDataSeconds=$receiver.FirstDataSeconds
+ })
+ }
+ $passedAll = @($channelResults | Where-Object { -not $_.Passed }).Count -eq 0
+ return [PSCustomObject]@{
+ Passed=$passedAll
+ Encoder=$Definition.Name
+ Resolution="2560x1440"
+ FrameRate=30
+ TargetBitrate="8000k"
+ Audio="AAC 48kHz stereo 192k"
+ Channels=$channelResults.ToArray()
+ FailureReason=if ($passedAll) { "" } else { "하나 이상의 채널이 품질 기준을 충족하지 못했습니다." }
+ }
+ }
+ catch {
+ return [PSCustomObject]@{
+ Passed=$false; Encoder=$Definition.Name; Resolution="2560x1440"
+ FrameRate=30; TargetBitrate="8000k"; Audio="AAC 48kHz stereo 192k"
+ Channels=@(); FailureReason=$_.Exception.Message
+ }
+ }
+ finally {
+ foreach ($receiver in $receiverProcesses) { Stop-TestProcess $receiver.Process }
+ Stop-TestProcess $publisherProcess
+ Stop-TestProcess $serverProcess
+ Remove-DependencyTempDirectory -Path $testDirectory
+ }
+}
+
+function Get-EnvironmentFingerprint {
+ param([string]$FFmpegExecutable = $FFmpegPath, [string]$MediaMTXExecutable = $MediaMTXPath)
+ $ffmpegHash = if (Test-Path -LiteralPath $FFmpegExecutable -PathType Leaf) {
+ (Get-FileHash -LiteralPath $FFmpegExecutable -Algorithm SHA256).Hash
+ }
+ else { "" }
+ $mediaHash = if (Test-Path -LiteralPath $MediaMTXExecutable -PathType Leaf) {
+ (Get-FileHash -LiteralPath $MediaMTXExecutable -Algorithm SHA256).Hash
+ }
+ else { "" }
+ $scriptHash = if (Test-Path -LiteralPath $PSCommandPath -PathType Leaf) {
+ (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash
+ }
+ else { "" }
+ $source = (Get-GpuSignature) + "|" + $ffmpegHash + "|" + $mediaHash + "|" + $scriptHash + "|" +
+ ($EncoderPriority -join ",") + "|$FrameRate|$VideoBitrate"
+ $sha = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $bytes = [System.Text.Encoding]::UTF8.GetBytes($source)
+ return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant()
+ }
+ finally { $sha.Dispose() }
+}
+
+function Get-CompatibilityState {
+ if (-not (Test-Path -LiteralPath $CompatibilityStatePath -PathType Leaf)) { return $null }
+ try { return (Get-Content -LiteralPath $CompatibilityStatePath -Raw | ConvertFrom-Json) }
+ catch {
+ Write-Log "호환성 상태 파일을 읽지 못해 전체 검사를 다시 수행합니다: $($_.Exception.Message)"
+ return $null
+ }
+}
+
+function Save-CompatibilityState {
+ param([string]$Fingerprint, [string]$Encoder, $Result)
+ $state = [ordered]@{
+ Fingerprint=$Fingerprint
+ Encoder=$Encoder
+ Passed=[bool]$Result.Passed
+ CheckedAt=(Get-Date).ToString("o")
+ }
+ $tempPath = "$CompatibilityStatePath.$([Guid]::NewGuid().ToString('N')).tmp"
+ try {
+ [System.IO.File]::WriteAllText(
+ $tempPath,
+ ($state | ConvertTo-Json -Depth 5),
+ (New-Object System.Text.UTF8Encoding($false))
+ )
+ Move-Item -LiteralPath $tempPath -Destination $CompatibilityStatePath -Force
+ }
+ finally {
+ if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
+ }
+}
+
+function Confirm-HardwareEncoderForEnvironment {
+ param(
+ $EncoderTests,
+ [string]$FFmpegExecutable = $FFmpegPath,
+ [switch]$Force
+ )
+ $fingerprint = Get-EnvironmentFingerprint -FFmpegExecutable $FFmpegExecutable
+ $state = Get-CompatibilityState
+ foreach ($vendor in $EncoderPriority) {
+ $test = $EncoderTests | Where-Object { $_.Vendor -eq $vendor } | Select-Object -First 1
+ if (-not $test -or -not $test.Passed) { continue }
+ $definition = Get-EncoderDefinition $vendor
+ $useCachedResult = (-not $Force -and $state -and $state.Passed -and
+ $state.Fingerprint -eq $fingerprint -and $state.Encoder -eq $definition.Name)
+ if ($useCachedResult) {
+ Write-Log "2채널 2560x1440 RTSP 최종 검증: 환경 변경 없음, 이전 성공 결과 재사용"
+ return [PSCustomObject]@{
+ Encoder=$definition; Fingerprint=$fingerprint
+ FinalTest=[PSCustomObject]@{Passed=$true;Cached=$true;Channels=@()}
+ }
+ }
+
+ Write-Log "2채널 최종 호환성 시험 시작: $($definition.Name), 2560x1440, 30fps, 채널당 8Mbps, 11초 수신"
+ $finalTest = Test-DualRtspCompatibility -Definition $definition `
+ -FFmpegExecutable $FFmpegExecutable -MediaMTXExecutable $MediaMTXPath
+ if ($finalTest.Passed) {
+ foreach ($channelResult in $finalTest.Channels) {
+ Write-Log (("최종 시험 monitor{0}: duration={1}s fps={2} bitrate={3}bps " +
+ "IDR={4} audio={5} firstData={6}s") -f $channelResult.Channel,
+ $channelResult.Duration,$channelResult.Fps,$channelResult.Bitrate,
+ $channelResult.IdrFrames,$channelResult.AudioTrack,$channelResult.FirstDataSeconds)
+ }
+ Save-CompatibilityState -Fingerprint $fingerprint -Encoder $definition.Name -Result $finalTest
+ Write-Log "2채널 최종 호환성 시험 성공: $($definition.Name)"
+ return [PSCustomObject]@{
+ Encoder=$definition; Fingerprint=$fingerprint; FinalTest=$finalTest
+ }
+ }
+ $test.Passed = $false
+ $test.FailureReason = "2채널 RTSP 최종 검증 실패: $($finalTest.FailureReason)"
+ Write-Log "2채널 최종 호환성 시험 실패: $($definition.Name) - $($finalTest.FailureReason)"
+ }
+ $null = Select-HardwareEncoder -TestResults $EncoderTests
+}
+
+function Resolve-FFmpegCompatibility {
+ $capabilities = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath
+ $tests = @(Test-AvailableHardwareEncoders -FFmpegExecutable $FFmpegPath)
+ $inventory = @(Get-GpuInventory)
+ $firstAvailableVendor = $null
+ foreach ($vendor in $EncoderPriority) {
+ if (@($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" }).Count -gt 0) {
+ $firstAvailableVendor = $vendor
+ break
+ }
+ }
+ $nvidiaTest = $tests | Where-Object Vendor -eq "NVIDIA" | Select-Object -First 1
+ $fallbackAlreadyInstalled = ($script:DependencyInstallEvidence.ContainsKey("FFmpeg") -and
+ $script:DependencyInstallEvidence["FFmpeg"].Source -eq $FFmpegFallbackDownloadUrl)
+ $shouldTryFallback = ($AutoInstallDependencies -eq 1 -and -not $fallbackAlreadyInstalled -and
+ $firstAvailableVendor -eq "NVIDIA" -and $nvidiaTest -and $nvidiaTest.ApiMismatch)
+
+ if ($shouldTryFallback) {
+ Write-Log "현재 FFmpeg와 NVIDIA NVENC API 불일치 또는 사용 가능 인코더 부재 - 검증된 이전 빌드 시험"
+ $script:FallbackValidationTests = $null
+ $script:FallbackFinalTest = $null
+ try {
+ Install-ZipDependency -Name "FFmpeg" `
+ -DownloadUrl $FFmpegFallbackDownloadUrl `
+ -ExpectedSha256 "" -ExpectedSha256Url $FFmpegFallbackSha256Url `
+ -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath `
+ -Validator {
+ param($candidatePath)
+ try {
+ $null = Test-FFmpegCapabilities -FFmpegExecutable $candidatePath
+ $candidateTests = @(Test-AvailableHardwareEncoders -FFmpegExecutable $candidatePath)
+ $candidateNvenc = $candidateTests | Where-Object Vendor -eq "NVIDIA" |
+ Select-Object -First 1
+ if (-not $candidateNvenc -or -not $candidateNvenc.Passed) {
+ $reason = if ($candidateNvenc) { $candidateNvenc.FailureReason } `
+ else { "NVIDIA 시험 결과가 없습니다." }
+ return [PSCustomObject]@{Passed=$false;Reason=$reason}
+ }
+ $definition = Get-EncoderDefinition "NVIDIA"
+ $final = Test-DualRtspCompatibility -Definition $definition `
+ -FFmpegExecutable $candidatePath -MediaMTXExecutable $MediaMTXPath
+ if (-not $final.Passed) {
+ return [PSCustomObject]@{Passed=$false;Reason=$final.FailureReason}
+ }
+ $script:FallbackValidationTests = $candidateTests
+ $script:FallbackFinalTest = $final
+ return [PSCustomObject]@{Passed=$true;Reason=""}
+ }
+ catch { return [PSCustomObject]@{Passed=$false;Reason=$_.Exception.Message} }
+ } | Out-Null
+ $capabilities = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath
+ $tests = @($script:FallbackValidationTests)
+ if ($script:FallbackFinalTest -and $script:FallbackFinalTest.Passed) {
+ $installedFingerprint = Get-EnvironmentFingerprint
+ Save-CompatibilityState -Fingerprint $installedFingerprint `
+ -Encoder "h264_nvenc" -Result $script:FallbackFinalTest
+ }
+ Write-Log "NVENC API 호환 이전 FFmpeg 빌드로 안전 교체 완료"
+ }
+ catch {
+ Write-Log "이전 FFmpeg 호환 후보를 채택하지 않음 - 기존 파일 유지: $($_.Exception.Message)"
+ }
+ }
+ return [PSCustomObject]@{Capabilities=$capabilities;Tests=$tests}
+}
+
+function Get-ExecutableVersionInformation {
+ param([string]$Path, [ValidateSet("FFmpeg", "MediaMTX")][string]$Kind)
+ $arguments = if ($Kind -eq "FFmpeg") { @("-hide_banner", "-version") } else { @("--version") }
+ $result = Invoke-ProcessWithTimeout -FilePath $Path -ArgumentList $arguments `
+ -TimeoutMilliseconds 15000
+ $lines = @($result.Output -split '[\r\n]+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
+ $buildText = ""
+ if ($Kind -eq "FFmpeg" -and $lines.Count -gt 1) {
+ $buildText = $lines[1].Trim()
+ if ($lines.Count -gt 2 -and $lines[2] -match '^configuration:') {
+ $buildText += "; " + $lines[2].Trim()
+ }
+ }
+ return [PSCustomObject]@{
+ Version = if ($lines.Count -gt 0) { $lines[0].Trim() } else { "감지 실패" }
+ Build = $buildText
+ Sha256 = if (Test-Path -LiteralPath $Path -PathType Leaf) {
+ (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
+ }
+ else { "" }
+ }
+}
+
+function Get-EncoderSupportText {
+ param($Test)
+ if (-not $Test) { return "감지 실패" }
+ if ($Test.FailureReason -eq "GPU가 감지되지 않았습니다.") { return "GPU 미감지" }
+ if ($Test.Passed) {
+ return "지원(IDR 통과: frames=$($Test.Frames), idr=$($Test.IdrFrames))"
+ }
+ return "실패: $($Test.FailureReason)"
+}
+
+function Update-InstallationEnvironmentInfo {
+ param($EncoderTests, $Encoder, [string]$Fingerprint, $FinalTest)
+ try {
+ $ffmpegInfo = Get-ExecutableVersionInformation -Path $FFmpegPath -Kind FFmpeg
+ $mediaInfo = Get-ExecutableVersionInformation -Path $MediaMTXPath -Kind MediaMTX
+ $inventory = @(Get-GpuInventory)
+ $gpuText = ($inventory | Sort-Object Vendor,Name | ForEach-Object {
+ "$($_.Name) (driver $($_.DriverVersion), status $($_.Status))"
+ }) -join " | "
+ $nvenc = $EncoderTests | Where-Object Vendor -eq "NVIDIA" | Select-Object -First 1
+ $amf = $EncoderTests | Where-Object Vendor -eq "AMD" | Select-Object -First 1
+ $qsv = $EncoderTests | Where-Object Vendor -eq "Intel" | Select-Object -First 1
+ $ffmpegEvidence = if ($script:DependencyInstallEvidence.ContainsKey("FFmpeg")) {
+ $script:DependencyInstallEvidence["FFmpeg"]
+ }
+ else { $null }
+ $mediaEvidence = if ($script:DependencyInstallEvidence.ContainsKey("MediaMTX")) {
+ $script:DependencyInstallEvidence["MediaMTX"]
+ }
+ else { $null }
+ if (-not $FinalTest) {
+ $FinalTest = [PSCustomObject]@{Passed=$false;Cached=$false;FailureReason="검사 완료 전 실패"}
+ }
+ $compatibilityText = if ($FinalTest.Cached) {
+ "통과(동일 환경의 2채널 시험 결과 재사용)"
+ }
+ elseif ($FinalTest.Passed) {
+ "통과(2560x1440/30fps/8Mbps/AAC/11초/2채널)"
+ }
+ else { "실패: $($FinalTest.FailureReason)" }
+ $values = @{
+ CheckedAt=(Get-Date).ToString("yyyy-MM-ddTHH:mm:ssK")
+ EnvironmentFingerprint=$Fingerprint
+ GPU=$gpuText
+ NvidiaNvencApi=if ($nvenc) { $nvenc.FoundApi } else { "감지 실패" }
+ NvidiaNvencRequiredApi=if ($nvenc) { $nvenc.RequiredApi } else { "감지 실패" }
+ NvidiaNvencSupport=(Get-EncoderSupportText $nvenc)
+ AmdAmfSupport=(Get-EncoderSupportText $amf)
+ IntelQsvSupport=(Get-EncoderSupportText $qsv)
+ SelectedHardwareEncoder=if ($Encoder) { $Encoder.Name } else { "" }
+ FFmpegVersion=$ffmpegInfo.Version
+ FFmpegBuild=$ffmpegInfo.Build
+ FFmpegExecutableSha256=$ffmpegInfo.Sha256
+ FFmpegDownloadSource=if ($ffmpegEvidence) { $ffmpegEvidence.Source } else { "기존 파일(출처 기록 없음)" }
+ FFmpegArchiveSha256Verification=if ($ffmpegEvidence -and $ffmpegEvidence.Sha256Verified) {
+ "성공: $($ffmpegEvidence.ArchiveSha256)"
+ } else { "감지 실패" }
+ MediaMTXVersion=$mediaInfo.Version
+ MediaMTXExecutableSha256=$mediaInfo.Sha256
+ MediaMTXDownloadSource=if ($mediaEvidence) { $mediaEvidence.Source } else { "기존 파일(출처 기록 없음)" }
+ MediaMTXArchiveSha256Verification=if ($mediaEvidence -and $mediaEvidence.Sha256Verified) {
+ "성공: $($mediaEvidence.ArchiveSha256)"
+ } else { "감지 실패" }
+ CompatibilityTest=$compatibilityText
+ }
+ Set-ManagedInfoBlock -Values $values
+
+ Write-Log "GPU/드라이버: $gpuText"
+ Write-Log "FFmpeg: $($ffmpegInfo.Version); build=$($ffmpegInfo.Build); sha256=$($ffmpegInfo.Sha256)"
+ Write-Log "MediaMTX: $($mediaInfo.Version); sha256=$($mediaInfo.Sha256)"
+ foreach ($test in $EncoderTests) {
+ Write-Log (("인코더 호환 결과: {0}, pass={1}, apiRequired={2}, apiFound={3}, " +
+ "frames={4}, idr={5}, reason={6}") -f $test.Encoder,$test.Passed,
+ $test.RequiredApi,$test.FoundApi,$test.Frames,$test.IdrFrames,$test.FailureReason)
+ }
+ $selectedEncoderName = if ($Encoder) { $Encoder.Name } else { "선택 실패" }
+ Write-Log "최종 선택 인코더: $selectedEncoderName; 최종 호환성=$compatibilityText"
+ if ($ffmpegEvidence) {
+ Write-Log "FFmpeg 다운로드 검증: source=$($ffmpegEvidence.Source), sha256=$($ffmpegEvidence.ArchiveSha256)"
+ }
+ if ($mediaEvidence) {
+ Write-Log "MediaMTX 다운로드 검증: source=$($mediaEvidence.Source), sha256=$($mediaEvidence.ArchiveSha256)"
+ }
+ }
+ catch {
+ Write-Log "config.ini 자동 기록 영역 갱신 실패(기존 파일 유지): $($_.Exception.Message)"
+ }
}
Add-Type -AssemblyName System.Windows.Forms
@@ -1863,7 +2865,8 @@ function New-FFmpegArguments {
$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",
+ "-force_key_frames", "expr:gte(t,n_forced*1)"
)
if ($AudioEnabled -eq 1) {
$arguments += @(
@@ -2057,6 +3060,71 @@ function Invoke-SimulationTests {
finally { [System.IO.File]::Delete($path) }
}
+ Invoke-SimulationCase "자동 기록 영역은 INI 설정으로 해석하지 않음" {
+ $content = (New-ManagedInfoBlock -Values @{GPU="Example GPU"}) +
+ "`r`n[General]`r`nChannelCount=2"
+ $path = New-SimulationIni $content
+ try {
+ $parsed = Read-IniConfiguration $path
+ return ($parsed.General.ChannelCount -eq "2" -and
+ -not $parsed.ContainsKey("ManagedEnvironment"))
+ }
+ finally { [System.IO.File]::Delete($path) }
+ }
+
+ Invoke-SimulationCase "자동 기록 갱신은 사용자 설정과 주석 보존" {
+ $path = New-SimulationIni "# 사용자 주석`r`n[General]`r`nChannelCount=7`r`n"
+ try {
+ Set-ManagedInfoBlock -Path $path -Values @{GPU="Test GPU"}
+ $updated = [System.IO.File]::ReadAllText($path)
+ return ($updated -match '# GPU=Test GPU' -and
+ $updated -match '# 사용자 주석' -and
+ $updated -match 'ChannelCount=7')
+ }
+ finally { [System.IO.File]::Delete($path) }
+ }
+
+ Invoke-SimulationCase "자동 기록 영역만 두 번 갱신해도 표식은 하나" {
+ $path = New-SimulationIni "[General]`r`nChannelCount=2`r`n"
+ try {
+ Set-ManagedInfoBlock -Path $path -Values @{GPU="First"}
+ Set-ManagedInfoBlock -Path $path -Values @{GPU="Second"}
+ $updated = [System.IO.File]::ReadAllText($path)
+ return ([regex]::Matches($updated, [regex]::Escape($ManagedInfoStart)).Count -eq 1 -and
+ $updated -match '# GPU=Second' -and $updated -notmatch '# GPU=First')
+ }
+ finally { [System.IO.File]::Delete($path) }
+ }
+
+ Invoke-SimulationCase "저장소용 초기화는 자동 기록 값만 비움" {
+ $path = New-SimulationIni ((New-ManagedInfoBlock -Values @{
+ GPU="Secret GPU"; CheckedAt="2026-01-01"
+ }) + "`r`n[General]`r`nChannelCount=3")
+ try {
+ Set-ManagedInfoBlock -Path $path -Values @{}
+ $updated = [System.IO.File]::ReadAllText($path)
+ return ($updated -match '(?m)^# GPU=\r?$' -and
+ $updated -match '(?m)^# CheckedAt=\r?$' -and
+ $updated -match 'ChannelCount=3' -and
+ $updated -notmatch 'Secret GPU')
+ }
+ finally { [System.IO.File]::Delete($path) }
+ }
+
+ Invoke-SimulationCase "자동 기록은 허용되지 않은 개인정보 키를 버림" {
+ $block = New-ManagedInfoBlock -Values @{
+ GPU="Safe GPU"; UserName="admin"; ComputerName="DESKTOP-SECRET"; IP="10.0.0.1"
+ }
+ return ($block -match '# GPU=Safe GPU' -and
+ $block -notmatch 'UserName|ComputerName|DESKTOP-SECRET|10\.0\.0\.1')
+ }
+
+ Invoke-SimulationCase "자동 기록 값의 줄바꿈 제거" {
+ $block = New-ManagedInfoBlock -Values @{GPU="GPU-A`r`n# Injected=1"}
+ return ($block -match '# GPU=GPU-A # Injected=1' -and
+ $block -notmatch '(?m)^# Injected=1$')
+ }
+
Invoke-SimulationCase "알 수 없는 INI 설정 거부" {
$path = New-SimulationIni "[Audio]`r`nUnknownOption=1"
try {
@@ -2286,6 +3354,15 @@ function Invoke-SimulationTests {
(Get-EncoderDefinition "Intel").Name -eq "h264_qsv")
}
+ Invoke-SimulationCase "GPU별 명시적 IDR 옵션 구성" {
+ $nvenc = (Get-EncoderDefinition "NVIDIA").Arguments
+ $amf = (Get-EncoderDefinition "AMD").Arguments
+ $qsv = (Get-EncoderDefinition "Intel").Arguments
+ return ($nvenc -contains "-forced-idr" -and $nvenc -contains "-strict_gop" -and
+ $amf -contains "-forced_idr" -and $amf -contains "-header_spacing" -and
+ $qsv -contains "-forced_idr" -and $qsv -contains "-idr_interval")
+ }
+
Invoke-SimulationCase "자동 실행은 숨김 worker와 config.ini만 전달" {
$arguments = Get-StartupArgumentList
return ($arguments -match '-WindowStyle\s+Hidden' -and
@@ -2346,6 +3423,7 @@ function Invoke-SimulationTests {
$filter = if ($filterIndex -ge 0) { $arguments[$filterIndex + 1] } else { "" }
return ($filter -match 'amix=inputs=2' -and $filter -match 'asplit=2' -and
$arguments -contains 'dshow' -and
+ $arguments -contains '-force_key_frames' -and
@($arguments | Where-Object { $_ -eq 'h264_nvenc' }).Count -eq 2 -and
@($arguments | Where-Object { $_ -eq "$RTSPPublishBase/monitor1" }).Count -eq 1 -and
@($arguments | Where-Object { $_ -eq "$RTSPPublishBase/monitor2" }).Count -eq 1)
@@ -2429,6 +3507,55 @@ function Invoke-SimulationTests {
return $results.ToArray()
}
+if ($InstallDependenciesOnly) {
+ $installMutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog")
+ $ownsInstallMutex = $false
+ try {
+ $ownsInstallMutex = $installMutex.WaitOne(0, $false)
+ if (-not $ownsInstallMutex) {
+ throw "실행 중인 Watchdog가 GPU 인코더를 사용하고 있습니다. 완전히 중지한 뒤 호환성 시험을 실행하십시오."
+ }
+ Assert-RequiredFiles
+ Ensure-MediaMTXConfig
+ $compatibility = $null
+ try {
+ $compatibility = Resolve-FFmpegCompatibility
+ $confirmation = Confirm-HardwareEncoderForEnvironment `
+ -EncoderTests $compatibility.Tests -Force:$ForceCompatibilityTest
+ }
+ catch {
+ if ($compatibility -and $compatibility.Tests) {
+ Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
+ -Encoder $null -Fingerprint (Get-EnvironmentFingerprint) `
+ -FinalTest ([PSCustomObject]@{
+ Passed=$false;Cached=$false;FailureReason=$_.Exception.Message
+ })
+ }
+ throw
+ }
+ $encoder = $confirmation.Encoder
+ Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
+ -Encoder $encoder -Fingerprint $confirmation.Fingerprint `
+ -FinalTest $confirmation.FinalTest
+ $ffmpegVersion = Get-ExecutableVersionInformation -Path $FFmpegPath -Kind FFmpeg
+ $mediaMTXVersion = Get-ExecutableVersionInformation -Path $MediaMTXPath -Kind MediaMTX
+ [PSCustomObject]@{
+ FFmpegPath = $FFmpegPath
+ FFmpegVersion = $ffmpegVersion.Version
+ MediaMTXPath = $MediaMTXPath
+ MediaMTXVersion = $mediaMTXVersion.Version
+ SelectedEncoder = $encoder.Name
+ CompatibilityPassed = $confirmation.FinalTest.Passed
+ CompatibilityCached = [bool]$confirmation.FinalTest.Cached
+ } | Format-List
+ exit 0
+ }
+ finally {
+ if ($ownsInstallMutex) { try { $installMutex.ReleaseMutex() } catch { } }
+ $installMutex.Dispose()
+ }
+}
+
if ($RunSimulationTests) {
$simulationResults = @(Invoke-SimulationTests)
$simulationResults | Format-Table Number,Passed,Name,Detail -AutoSize
@@ -2459,9 +3586,27 @@ try {
Assert-RequiredFiles
Ensure-MediaMTXConfig
- $encoderList = Test-FFmpegCapabilities
- $encoder = Select-HardwareEncoder -EncoderList $encoderList
- $gpuSignature = Get-GpuSignature
+ $compatibility = $null
+ try {
+ $compatibility = Resolve-FFmpegCompatibility
+ $confirmation = Confirm-HardwareEncoderForEnvironment `
+ -EncoderTests $compatibility.Tests -Force:$ForceCompatibilityTest
+ $encoder = $confirmation.Encoder
+ Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
+ -Encoder $encoder -Fingerprint $confirmation.Fingerprint `
+ -FinalTest $confirmation.FinalTest
+ }
+ catch {
+ if ($compatibility -and $compatibility.Tests) {
+ Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
+ -Encoder $null -Fingerprint (Get-EnvironmentFingerprint) `
+ -FinalTest ([PSCustomObject]@{
+ Passed=$false;Cached=$false;FailureReason=$_.Exception.Message
+ })
+ }
+ throw
+ }
+ $runtimeEnvironmentSignature = Get-RuntimeEnvironmentSignature
Write-Log "Screen RTSP Watchdog 시작"
if ($ConfigWasLoaded) { Write-Log "설정 파일: $ConfigPath" }
@@ -2534,13 +3679,19 @@ try {
$network = Get-NetworkState
if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) {
- $currentGpuSignature = Get-GpuSignature
+ $currentEnvironmentSignature = Get-RuntimeEnvironmentSignature
$lastGpuCheck = Get-Date
- if ($currentGpuSignature -ne $gpuSignature) {
- Write-Log "GPU 구성 변경 감지"
+ if ($currentEnvironmentSignature -ne $runtimeEnvironmentSignature) {
+ Write-Log "GPU, 드라이버 또는 실행 파일 변경 감지"
Stop-Streams
- $encoder = Select-HardwareEncoder -EncoderList $encoderList
- $gpuSignature = $currentGpuSignature
+ $compatibility = Resolve-FFmpegCompatibility
+ $confirmation = Confirm-HardwareEncoderForEnvironment `
+ -EncoderTests $compatibility.Tests
+ $encoder = $confirmation.Encoder
+ Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
+ -Encoder $encoder -Fingerprint $confirmation.Fingerprint `
+ -FinalTest $confirmation.FinalTest
+ $runtimeEnvironmentSignature = Get-RuntimeEnvironmentSignature
$healthySince = $null
}
}