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 } }