#requires -Version 5.1 [CmdletBinding()] param( [ValidateSet("NVIDIA", "AMD", "Intel")] [string[]]$EncoderPriority = @("NVIDIA", "AMD", "Intel"), [Parameter(DontShow = $true)] [string]$EncoderPriorityCsv = "", [ValidateRange(1, 16)] [int]$ChannelCount = 2, [ValidateRange(1, 240)] [int]$FrameRate = 30, [ValidatePattern("^[0-9]+[kKmM]$")] [string]$VideoBitrate = "8000k", [ValidatePattern("^[0-9]+[kKmM]$")] [string]$AudioBitrate = "192k", [ValidateSet(0, 1)] [int]$AudioEnabled = 1, [ValidateSet(0, 1)] [int]$MicrophoneEnabled = 1, [ValidateRange(0.0, 1.0)] [double]$AudioSignalThreshold = 0.0005, [string]$NicDescription = "", [string]$HealthTarget = "", [ValidateRange(1, 60)] [int]$StableTimeSec = 3, [ValidateRange(1, 60)] [int]$PollIntervalSec = 1, [ValidateRange(1, 65535)] [int]$RtspPort = 8554, [ValidateRange(1, 3600)] [int]$RtspHealthCheckIntervalSec = 10, [ValidateRange(1, 60)] [int]$RtspHealthTimeoutSec = 5, [ValidateRange(1, 10)] [int]$RtspHealthFailureThreshold = 2, [ValidateRange(0, 600)] [int]$RtspHealthStartupGraceSec = 45, [ValidateSet(0, 1)] [int]$AutoInstallDependencies = 1, [string]$FFmpegDownloadUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", [string]$FFmpegSha256Url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256", [string]$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", [string]$ConfigPath = "", [switch]$InstallStartup, [switch]$UninstallStartup, [switch]$StartupStatus, [switch]$ShowConfiguration, [switch]$InstallDependenciesOnly, [switch]$RunSimulationTests, [switch]$ForceCompatibilityTest, [switch]$ResetManagedInfo, [switch]$InstallGitHooks, [switch]$Foreground, [Parameter(DontShow = $true)] [switch]$WatchdogWorker, [Parameter(DontShow = $true)] [switch]$AudioWorker, [Parameter(DontShow = $true)] [string]$AudioEndpointId, [Parameter(DontShow = $true)] [string]$AudioPipeName ) $ErrorActionPreference = "Stop" $ExplicitParameters = @{} foreach ($parameterName in $PSBoundParameters.Keys) { $ExplicitParameters[$parameterName] = $true } # ============================================================ # Windows multi-monitor RTSP watchdog # - Desktop Duplication API (ddagrab) # - NVIDIA NVENC / AMD AMF / Intel QSV automatic selection # - Core Audio WASAPI loopback + automatic microphone capture # - One FFmpeg process publishes every monitor channel # - MediaMTX publisher watchdog # ============================================================ $AudioInteropSource = @' using System; using System.Collections.Generic; using System.IO; using System.IO.Pipes; using System.Runtime.InteropServices; using System.Threading; namespace ScreenRtsp.Audio { public sealed class EndpointInfo { public string Id { get; set; } public string Name { get; set; } public bool IsDefault { get; set; } public float Peak { get; set; } public int SampleRate { get; set; } public int Channels { get; set; } public int BitsPerSample { get; set; } public int BlockAlign { get; set; } public string SampleFormat { get; set; } } internal enum EDataFlow { Render = 0, Capture = 1, All = 2 } internal enum ERole { Console = 0, Multimedia = 1, Communications = 2 } internal enum AudioClientShareMode { Shared = 0, Exclusive = 1 } [Flags] internal enum ClsCtx : uint { InprocServer = 0x1, InprocHandler = 0x2, LocalServer = 0x4, All = InprocServer | InprocHandler | LocalServer } [Flags] internal enum AudioClientStreamFlags : uint { Loopback = 0x00020000 } [Flags] internal enum AudioClientBufferFlags : uint { DataDiscontinuity = 0x1, Silent = 0x2, TimestampError = 0x4 } [StructLayout(LayoutKind.Sequential)] internal struct PropertyKey { public Guid FormatId; public int PropertyId; } [StructLayout(LayoutKind.Explicit)] internal struct PropVariant { [FieldOffset(0)] public ushort VariantType; [FieldOffset(8)] public IntPtr PointerValue; } [StructLayout(LayoutKind.Sequential, Pack = 2)] internal struct WaveFormatEx { public ushort FormatTag; public ushort Channels; public uint SamplesPerSec; public uint AvgBytesPerSec; public ushort BlockAlign; public ushort BitsPerSample; public ushort ExtraSize; } [ComImport] [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDeviceEnumerator { [PreserveSig] int EnumAudioEndpoints(EDataFlow dataFlow, uint stateMask, out IMMDeviceCollection devices); [PreserveSig] int GetDefaultAudioEndpoint(EDataFlow dataFlow, ERole role, out IMMDevice endpoint); [PreserveSig] int GetDevice([MarshalAs(UnmanagedType.LPWStr)] string id, out IMMDevice device); [PreserveSig] int RegisterEndpointNotificationCallback(IntPtr client); [PreserveSig] int UnregisterEndpointNotificationCallback(IntPtr client); } [ComImport] [Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDeviceCollection { [PreserveSig] int GetCount(out uint count); [PreserveSig] int Item(uint index, out IMMDevice device); } [ComImport] [Guid("D666063F-1587-4E43-81F1-B948E807363F")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IMMDevice { [PreserveSig] int Activate(ref Guid iid, ClsCtx context, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object instance); [PreserveSig] int OpenPropertyStore(uint access, out IPropertyStore properties); [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int GetState(out uint state); } [ComImport] [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IPropertyStore { [PreserveSig] int GetCount(out uint count); [PreserveSig] int GetAt(uint index, out PropertyKey key); [PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value); [PreserveSig] int SetValue(ref PropertyKey key, ref PropVariant value); [PreserveSig] int Commit(); } [ComImport] [Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioClient { [PreserveSig] int Initialize(AudioClientShareMode shareMode, AudioClientStreamFlags streamFlags, long bufferDuration, long periodicity, IntPtr format, ref Guid sessionGuid); [PreserveSig] int GetBufferSize(out uint bufferFrames); [PreserveSig] int GetStreamLatency(out long latency); [PreserveSig] int GetCurrentPadding(out uint paddingFrames); [PreserveSig] int IsFormatSupported(AudioClientShareMode shareMode, IntPtr format, out IntPtr closestMatch); [PreserveSig] int GetMixFormat(out IntPtr format); [PreserveSig] int GetDevicePeriod(out long defaultPeriod, out long minimumPeriod); [PreserveSig] int Start(); [PreserveSig] int Stop(); [PreserveSig] int Reset(); [PreserveSig] int SetEventHandle(IntPtr eventHandle); [PreserveSig] int GetService(ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object service); } [ComImport] [Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioCaptureClient { [PreserveSig] int GetBuffer(out IntPtr data, out uint frames, out AudioClientBufferFlags flags, out ulong devicePosition, out ulong qpcPosition); [PreserveSig] int ReleaseBuffer(uint frames); [PreserveSig] int GetNextPacketSize(out uint frames); } [ComImport] [Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064")] [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] internal interface IAudioMeterInformation { [PreserveSig] int GetPeakValue(out float peak); [PreserveSig] int GetMeteringChannelCount(out int channelCount); [PreserveSig] int GetChannelsPeakValues(int channelCount, [Out] float[] peaks); [PreserveSig] int QueryHardwareSupport(out int hardwareSupportMask); } [ComImport] [Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] internal class MMDeviceEnumeratorComObject { } public static class CoreAudio { private const uint DeviceStateActive = 0x1; private const ushort WaveFormatPcm = 0x0001; private const ushort WaveFormatIeeeFloat = 0x0003; private const ushort WaveFormatExtensible = 0xFFFE; private static readonly Guid AudioClientGuid = new Guid("1CB9AD4C-DBFA-4C32-B178-C2F568A703B2"); private static readonly Guid AudioCaptureClientGuid = new Guid("C8ADBD64-E71E-48A0-A4DE-185C395CD317"); private static readonly Guid AudioMeterGuid = new Guid("C02216F6-8C67-4B5B-9D00-D008E73E0064"); private static readonly Guid PcmSubFormat = new Guid("00000001-0000-0010-8000-00AA00389B71"); private static readonly Guid FloatSubFormat = new Guid("00000003-0000-0010-8000-00AA00389B71"); [DllImport("ole32.dll")] private static extern int PropVariantClear(ref PropVariant value); private static void ThrowIfFailed(int hr, string operation) { if (hr < 0) throw new COMException(operation + " failed", hr); } private static string GetId(IMMDevice device) { string id; ThrowIfFailed(device.GetId(out id), "IMMDevice.GetId"); return id; } private static string GetFriendlyName(IMMDevice device) { IPropertyStore store = null; PropVariant value = new PropVariant(); bool hasValue = false; try { ThrowIfFailed(device.OpenPropertyStore(0, out store), "IMMDevice.OpenPropertyStore"); PropertyKey key = new PropertyKey { FormatId = new Guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), PropertyId = 14 }; ThrowIfFailed(store.GetValue(ref key, out value), "IPropertyStore.GetValue"); hasValue = true; return value.PointerValue == IntPtr.Zero ? "Unknown audio endpoint" : Marshal.PtrToStringUni(value.PointerValue); } finally { if (hasValue) PropVariantClear(ref value); if (store != null) Marshal.ReleaseComObject(store); } } private static EndpointInfo ReadFormat(IMMDevice device) { object instance = null; IAudioClient client = null; IntPtr formatPtr = IntPtr.Zero; try { Guid iid = AudioClientGuid; ThrowIfFailed(device.Activate(ref iid, ClsCtx.All, IntPtr.Zero, out instance), "IMMDevice.Activate(IAudioClient)"); client = (IAudioClient)instance; ThrowIfFailed(client.GetMixFormat(out formatPtr), "IAudioClient.GetMixFormat"); WaveFormatEx format = (WaveFormatEx)Marshal.PtrToStructure(formatPtr, typeof(WaveFormatEx)); Guid subFormat = Guid.Empty; if (format.FormatTag == WaveFormatExtensible && format.ExtraSize >= 22) subFormat = (Guid)Marshal.PtrToStructure(IntPtr.Add(formatPtr, 24), typeof(Guid)); string sampleFormat; if (format.FormatTag == WaveFormatIeeeFloat || subFormat == FloatSubFormat) sampleFormat = format.BitsPerSample == 64 ? "f64le" : "f32le"; else if (format.FormatTag == WaveFormatPcm || subFormat == PcmSubFormat) sampleFormat = format.BitsPerSample <= 16 ? "s16le" : (format.BitsPerSample <= 24 ? "s24le" : "s32le"); else throw new NotSupportedException("Unsupported Windows mix format tag: " + format.FormatTag); return new EndpointInfo { SampleRate = (int)format.SamplesPerSec, Channels = format.Channels, BitsPerSample = format.BitsPerSample, BlockAlign = format.BlockAlign, SampleFormat = sampleFormat }; } finally { if (formatPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(formatPtr); if (client != null) Marshal.ReleaseComObject(client); } } private static float ReadPeak(IMMDevice device) { object instance = null; IAudioMeterInformation meter = null; try { Guid iid = AudioMeterGuid; ThrowIfFailed(device.Activate(ref iid, ClsCtx.All, IntPtr.Zero, out instance), "IMMDevice.Activate(IAudioMeterInformation)"); meter = (IAudioMeterInformation)instance; float peak; ThrowIfFailed(meter.GetPeakValue(out peak), "IAudioMeterInformation.GetPeakValue"); return peak; } catch { return 0.0f; } finally { if (meter != null) Marshal.ReleaseComObject(meter); } } private static EndpointInfo[] GetEndpoints(EDataFlow flow) { IMMDeviceEnumerator enumerator = null; IMMDeviceCollection collection = null; IMMDevice defaultDevice = null; string defaultId = null; List result = new List(); try { enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); if (enumerator.GetDefaultAudioEndpoint(flow, ERole.Multimedia, out defaultDevice) >= 0 && defaultDevice != null) defaultId = GetId(defaultDevice); ThrowIfFailed(enumerator.EnumAudioEndpoints(flow, DeviceStateActive, out collection), "IMMDeviceEnumerator.EnumAudioEndpoints"); uint count; ThrowIfFailed(collection.GetCount(out count), "IMMDeviceCollection.GetCount"); for (uint i = 0; i < count; i++) { IMMDevice device = null; try { ThrowIfFailed(collection.Item(i, out device), "IMMDeviceCollection.Item"); EndpointInfo info = ReadFormat(device); info.Id = GetId(device); info.Name = GetFriendlyName(device); info.IsDefault = String.Equals(info.Id, defaultId, StringComparison.OrdinalIgnoreCase); info.Peak = ReadPeak(device); result.Add(info); } catch { // A disappearing hot-plug endpoint is ignored and observed next poll. } finally { if (device != null) Marshal.ReleaseComObject(device); } } return result.ToArray(); } finally { if (defaultDevice != null) Marshal.ReleaseComObject(defaultDevice); if (collection != null) Marshal.ReleaseComObject(collection); if (enumerator != null) Marshal.ReleaseComObject(enumerator); } } public static EndpointInfo[] GetRenderEndpoints() { return GetEndpoints(EDataFlow.Render); } public static EndpointInfo[] GetCaptureEndpoints() { return GetEndpoints(EDataFlow.Capture); } public static void CaptureLoopback(string endpointId, string pipeName) { IMMDeviceEnumerator enumerator = null; IMMDevice device = null; IAudioClient client = null; IAudioCaptureClient capture = null; object clientObject = null; object captureObject = null; IntPtr formatPtr = IntPtr.Zero; NamedPipeServerStream pipe = null; try { enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); ThrowIfFailed(enumerator.GetDevice(endpointId, out device), "IMMDeviceEnumerator.GetDevice"); Guid clientIid = AudioClientGuid; ThrowIfFailed(device.Activate(ref clientIid, ClsCtx.All, IntPtr.Zero, out clientObject), "IMMDevice.Activate(IAudioClient)"); client = (IAudioClient)clientObject; ThrowIfFailed(client.GetMixFormat(out formatPtr), "IAudioClient.GetMixFormat"); WaveFormatEx format = (WaveFormatEx)Marshal.PtrToStructure(formatPtr, typeof(WaveFormatEx)); Guid session = Guid.Empty; ThrowIfFailed(client.Initialize(AudioClientShareMode.Shared, AudioClientStreamFlags.Loopback, 10000000, 0, formatPtr, ref session), "IAudioClient.Initialize(loopback)"); Guid captureIid = AudioCaptureClientGuid; ThrowIfFailed(client.GetService(ref captureIid, out captureObject), "IAudioClient.GetService(IAudioCaptureClient)"); capture = (IAudioCaptureClient)captureObject; pipe = new NamedPipeServerStream(pipeName, PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.None, 1024 * 1024, 1024 * 1024); pipe.WaitForConnection(); ThrowIfFailed(client.Start(), "IAudioClient.Start"); byte[] buffer = new byte[1024 * 1024]; int silenceFrames = Math.Max(1, (int)(format.SamplesPerSec / 100)); int silenceBytes = checked(silenceFrames * format.BlockAlign); System.Diagnostics.Stopwatch packetClock = System.Diagnostics.Stopwatch.StartNew(); while (pipe.IsConnected) { uint packetFrames; ThrowIfFailed(capture.GetNextPacketSize(out packetFrames), "IAudioCaptureClient.GetNextPacketSize"); if (packetFrames == 0) { // Some endpoints stop producing loopback packets while // completely silent or power-saving. Keep FFmpeg and // RTSP clocks moving with one 10 ms silent PCM block. if (packetClock.ElapsedMilliseconds >= 10) { if (buffer.Length < silenceBytes) buffer = new byte[silenceBytes]; Array.Clear(buffer, 0, silenceBytes); pipe.Write(buffer, 0, silenceBytes); packetClock.Restart(); } Thread.Sleep(2); continue; } while (packetFrames > 0) { IntPtr data; uint frames; AudioClientBufferFlags flags; ulong devicePosition; ulong qpcPosition; ThrowIfFailed(capture.GetBuffer(out data, out frames, out flags, out devicePosition, out qpcPosition), "IAudioCaptureClient.GetBuffer"); int bytes = checked((int)frames * format.BlockAlign); if (buffer.Length < bytes) buffer = new byte[bytes]; if ((flags & AudioClientBufferFlags.Silent) != 0 || data == IntPtr.Zero) Array.Clear(buffer, 0, bytes); else Marshal.Copy(data, buffer, 0, bytes); try { pipe.Write(buffer, 0, bytes); packetClock.Restart(); } finally { capture.ReleaseBuffer(frames); } ThrowIfFailed(capture.GetNextPacketSize(out packetFrames), "IAudioCaptureClient.GetNextPacketSize"); } } } catch (IOException) { // The consumer closed its pipe. } finally { if (client != null) { try { client.Stop(); } catch { } } if (pipe != null) pipe.Dispose(); if (formatPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(formatPtr); if (capture != null) Marshal.ReleaseComObject(capture); if (client != null) Marshal.ReleaseComObject(client); if (device != null) Marshal.ReleaseComObject(device); if (enumerator != null) Marshal.ReleaseComObject(enumerator); } } } } '@ function Initialize-AudioInterop { if (-not ("ScreenRtsp.Audio.CoreAudio" -as [type])) { Add-Type -TypeDefinition $AudioInteropSource -Language CSharp } } if ($AudioWorker) { try { if ([string]::IsNullOrWhiteSpace($AudioEndpointId) -or [string]::IsNullOrWhiteSpace($AudioPipeName)) { throw "Audio worker arguments are missing." } Initialize-AudioInterop [ScreenRtsp.Audio.CoreAudio]::CaptureLoopback($AudioEndpointId, $AudioPipeName) exit 0 } catch { [Console]::Error.WriteLine($_.Exception.ToString()) exit 1 } } $BaseDir = $PSScriptRoot if ([string]::IsNullOrWhiteSpace($BaseDir)) { $BaseDir = Split-Path -Parent $MyInvocation.MyCommand.Path } function Read-IniConfiguration { param([string]$Path) $result = @{} $sectionName = "General" $result[$sectionName] = @{} $lineNumber = 0 foreach ($rawLine in (Get-Content -LiteralPath $Path)) { $lineNumber++ $line = $rawLine.Trim() if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("#") -or $line.StartsWith(";") -or $line.StartsWith("//")) { continue } if ($line -match '^\[([^\]]+)\]$') { $sectionName = $Matches[1].Trim() if (-not $result.ContainsKey($sectionName)) { $result[$sectionName] = @{} } continue } if ($line -notmatch '^([^=]+)=(.*)$') { throw "config.ini $lineNumber 번째 줄을 해석할 수 없습니다: $rawLine" } $key = $Matches[1].Trim() $value = $Matches[2].Trim() if ([string]::IsNullOrWhiteSpace($key)) { throw "config.ini $lineNumber 번째 줄의 설정 이름이 비어 있습니다." } $hasDoubleQuotes = $value.StartsWith('"') -and $value.EndsWith('"') $hasSingleQuotes = $value.StartsWith("'") -and $value.EndsWith("'") if ($value.Length -ge 2 -and ($hasDoubleQuotes -or $hasSingleQuotes)) { $value = $value.Substring(1, $value.Length - 2) } $result[$sectionName][$key] = $value } return $result } function Get-IniSetting { param($Configuration, [string]$Section, [string]$Key) if ($Configuration.ContainsKey($Section) -and $Configuration[$Section].ContainsKey($Key)) { return [PSCustomObject]@{ Found = $true; Value = $Configuration[$Section][$Key] } } return [PSCustomObject]@{ Found = $false; Value = $null } } function Convert-IniSwitch { param([string]$Value, [string]$Name) if ($Value -notin @("0", "1")) { throw "$Name 값은 1 또는 0이어야 합니다: $Value" } return [int]$Value } function Assert-IniSettings { param($Configuration) $allowed = @{ General = @("ChannelCount", "StableTimeSec", "PollIntervalSec") Video = @("FrameRate", "Bitrate", "EncoderPriority") Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold") Network = @("NicDescription", "HealthTarget") RTSP = @( "Port", "HealthCheckIntervalSec", "HealthTimeoutSec", "HealthFailureThreshold", "StartupGraceSec" ) Dependencies = @( "AutoInstall", "FFmpegUrl", "FFmpegSha256Url", "FFmpegFallbackUrl", "FFmpegFallbackSha256Url", "MediaMTXUrl", "MediaMTXSha256" ) } foreach ($sectionName in $Configuration.Keys) { if (-not $allowed.ContainsKey($sectionName)) { throw "config.ini에 알 수 없는 섹션이 있습니다: [$sectionName]" } foreach ($key in $Configuration[$sectionName].Keys) { if ($allowed[$sectionName] -notcontains $key) { throw "config.ini에 알 수 없는 설정이 있습니다: [$sectionName] $key" } } } } if ([string]::IsNullOrWhiteSpace($ConfigPath)) { $ConfigPath = Join-Path $BaseDir "config.ini" } elseif (-not [System.IO.Path]::IsPathRooted($ConfigPath)) { $ConfigPath = Join-Path $BaseDir $ConfigPath } $ConfigPath = [System.IO.Path]::GetFullPath($ConfigPath) $ConfigWasLoaded = $false $ini = @{} if (Test-Path -LiteralPath $ConfigPath -PathType Leaf) { $ini = Read-IniConfiguration -Path $ConfigPath Assert-IniSettings $ini $ConfigWasLoaded = $true } elseif ($ExplicitParameters.ContainsKey("ConfigPath")) { throw "지정한 설정 파일을 찾을 수 없습니다: $ConfigPath" } if ($ConfigWasLoaded) { $setting = Get-IniSetting $ini "General" "ChannelCount" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("ChannelCount")) { $ChannelCount = [int]$setting.Value } $setting = Get-IniSetting $ini "General" "StableTimeSec" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("StableTimeSec")) { $StableTimeSec = [int]$setting.Value } $setting = Get-IniSetting $ini "General" "PollIntervalSec" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("PollIntervalSec")) { $PollIntervalSec = [int]$setting.Value } $setting = Get-IniSetting $ini "Video" "FrameRate" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FrameRate")) { $FrameRate = [int]$setting.Value } $setting = Get-IniSetting $ini "Video" "Bitrate" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("VideoBitrate")) { $VideoBitrate = [string]$setting.Value } $setting = Get-IniSetting $ini "Video" "EncoderPriority" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("EncoderPriority") -and -not $ExplicitParameters.ContainsKey("EncoderPriorityCsv")) { $EncoderPriority = @($setting.Value -split ',' | ForEach-Object { $_.Trim() }) } $setting = Get-IniSetting $ini "Audio" "Enabled" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioEnabled")) { $AudioEnabled = Convert-IniSwitch $setting.Value "Audio.Enabled" } $setting = Get-IniSetting $ini "Audio" "MicrophoneEnabled" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MicrophoneEnabled")) { $MicrophoneEnabled = Convert-IniSwitch $setting.Value "Audio.MicrophoneEnabled" } $setting = Get-IniSetting $ini "Audio" "Bitrate" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioBitrate")) { $AudioBitrate = [string]$setting.Value } $setting = Get-IniSetting $ini "Audio" "SignalThreshold" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AudioSignalThreshold")) { $AudioSignalThreshold = [double]::Parse( $setting.Value, [System.Globalization.CultureInfo]::InvariantCulture ) } $setting = Get-IniSetting $ini "Network" "NicDescription" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("NicDescription")) { $NicDescription = [string]$setting.Value } $setting = Get-IniSetting $ini "Network" "HealthTarget" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("HealthTarget")) { $HealthTarget = [string]$setting.Value } $setting = Get-IniSetting $ini "RTSP" "Port" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) { $RtspPort = [int]$setting.Value } $setting = Get-IniSetting $ini "RTSP" "HealthCheckIntervalSec" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthCheckIntervalSec")) { $RtspHealthCheckIntervalSec = [int]$setting.Value } $setting = Get-IniSetting $ini "RTSP" "HealthTimeoutSec" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthTimeoutSec")) { $RtspHealthTimeoutSec = [int]$setting.Value } $setting = Get-IniSetting $ini "RTSP" "HealthFailureThreshold" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthFailureThreshold")) { $RtspHealthFailureThreshold = [int]$setting.Value } $setting = Get-IniSetting $ini "RTSP" "StartupGraceSec" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthStartupGraceSec")) { $RtspHealthStartupGraceSec = [int]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "AutoInstall" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) { $AutoInstallDependencies = Convert-IniSwitch $setting.Value "Dependencies.AutoInstall" } $setting = Get-IniSetting $ini "Dependencies" "FFmpegUrl" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegDownloadUrl")) { $FFmpegDownloadUrl = [string]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "FFmpegSha256Url" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegSha256Url")) { $FFmpegSha256Url = [string]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "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 } $setting = Get-IniSetting $ini "Dependencies" "MediaMTXSha256" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXSha256")) { $MediaMTXSha256 = [string]$setting.Value } } function Assert-IntegerRange { param( [string]$Name, [int]$Value, [int]$Minimum, [int]$Maximum ) if ($Value -lt $Minimum -or $Value -gt $Maximum) { throw "$Name 값은 $Minimum 이상 $Maximum 이하여야 합니다: $Value" } } # Parameter validation runs before config.ini is loaded. Validate the final # effective values again so an invalid INI value cannot bypass ValidateRange. Assert-IntegerRange "General.ChannelCount" $ChannelCount 1 16 Assert-IntegerRange "General.StableTimeSec" $StableTimeSec 1 60 Assert-IntegerRange "General.PollIntervalSec" $PollIntervalSec 1 60 Assert-IntegerRange "Video.FrameRate" $FrameRate 1 240 Assert-IntegerRange "RTSP.Port" $RtspPort 1 65535 Assert-IntegerRange "RTSP.HealthCheckIntervalSec" $RtspHealthCheckIntervalSec 1 3600 Assert-IntegerRange "RTSP.HealthTimeoutSec" $RtspHealthTimeoutSec 1 60 Assert-IntegerRange "RTSP.HealthFailureThreshold" $RtspHealthFailureThreshold 1 10 Assert-IntegerRange "RTSP.StartupGraceSec" $RtspHealthStartupGraceSec 0 600 if ($VideoBitrate -notmatch '^[0-9]+[kKmM]$') { throw "Video.Bitrate 형식이 잘못되었습니다: $VideoBitrate" } if ($AudioBitrate -notmatch '^[0-9]+[kKmM]$') { throw "Audio.Bitrate 형식이 잘못되었습니다: $AudioBitrate" } if ($AudioSignalThreshold -lt 0.0 -or $AudioSignalThreshold -gt 1.0) { throw "Audio.SignalThreshold 값은 0.0 이상 1.0 이하여야 합니다: $AudioSignalThreshold" } if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { $EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() }) } $invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") } if ($EncoderPriority.Count -eq 0) { throw "EncoderPriority에는 하나 이상의 GPU 제조사가 필요합니다." } if ($invalidEncoderVendor) { throw "잘못된 EncoderPriority 값: $($invalidEncoderVendor -join ', ')" } $FFmpegPath = Join-Path $BaseDir "ffmpeg.exe" $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) if ($null -eq $Value) { return '""' } if ($Value -notmatch '[\s"]') { return $Value } return '"' + ($Value -replace '(\\*)"', '$1$1\"' -replace '(\\+)$', '$1$1') + '"' } function Join-ProcessArguments { param([string[]]$Values) return (($Values | ForEach-Object { Quote-ProcessArgument $_ }) -join " ") } function Get-StartupArgumentList { $values = @( "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-ConfigPath", $ConfigPath, "-WatchdogWorker" ) return Join-ProcessArguments $values } function Get-HiddenWorkerArgumentList { $values = @( "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-ConfigPath", $ConfigPath, "-WatchdogWorker", "-EncoderPriorityCsv", ($EncoderPriority -join ","), "-ChannelCount", [string]$ChannelCount, "-FrameRate", [string]$FrameRate, "-VideoBitrate", $VideoBitrate, "-AudioBitrate", $AudioBitrate, "-AudioEnabled", [string]$AudioEnabled, "-MicrophoneEnabled", [string]$MicrophoneEnabled, "-AudioSignalThreshold", $AudioSignalThreshold.ToString( [System.Globalization.CultureInfo]::InvariantCulture ), "-NicDescription", $NicDescription, "-HealthTarget", $HealthTarget, "-StableTimeSec", [string]$StableTimeSec, "-PollIntervalSec", [string]$PollIntervalSec, "-RtspPort", [string]$RtspPort, "-RtspHealthCheckIntervalSec", [string]$RtspHealthCheckIntervalSec, "-RtspHealthTimeoutSec", [string]$RtspHealthTimeoutSec, "-RtspHealthFailureThreshold", [string]$RtspHealthFailureThreshold, "-RtspHealthStartupGraceSec", [string]$RtspHealthStartupGraceSec, "-AutoInstallDependencies", [string]$AutoInstallDependencies, "-FFmpegDownloadUrl", $FFmpegDownloadUrl, "-FFmpegSha256Url", $FFmpegSha256Url, "-FFmpegFallbackDownloadUrl", $FFmpegFallbackDownloadUrl, "-FFmpegFallbackSha256Url", $FFmpegFallbackSha256Url, "-MediaMTXDownloadUrl", $MediaMTXDownloadUrl, "-MediaMTXSha256", $MediaMTXSha256 ) if ($ForceCompatibilityTest) { $values += "-ForceCompatibilityTest" } return Join-ProcessArguments $values } function Get-StartupTriggers { $logonTrigger = New-ScheduledTaskTrigger -AtLogOn ` -User "$env:USERDOMAIN\$env:USERNAME" $recoveryTrigger = New-ScheduledTaskTrigger -Once -At ((Get-Date).AddMinutes(1)) ` -RepetitionInterval (New-TimeSpan -Minutes 1) return @($logonTrigger, $recoveryTrigger) } function Get-StartupSettings { return New-ScheduledTaskSettingsSet -Hidden -StartWhenAvailable ` -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew } function Start-HiddenWatchdog { $process = Start-Process -FilePath $PowerShellPath ` -ArgumentList (Get-HiddenWorkerArgumentList) ` -WorkingDirectory $BaseDir -WindowStyle Hidden -PassThru Write-Host "숨김 백그라운드 Watchdog를 시작했습니다. PID=$($process.Id)" Write-Host "로그: $WatchdogLog" } function Install-StartupTask { $action = New-ScheduledTaskAction -Execute $PowerShellPath -Argument (Get-StartupArgumentList) ` -WorkingDirectory $BaseDir $triggers = @(Get-StartupTriggers) $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" ` -LogonType Interactive -RunLevel Highest $settings = Get-StartupSettings Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers ` -Principal $principal -Settings $settings -Force | Out-Null Write-Host "자동 실행 작업을 등록했습니다: $TaskName" Write-Host "숨김 실행: 사용" Write-Host "복구: 로그인 시 시작 + 1분 간격 무기한 확인 + 실패 시 재시작" 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 ConfigLoaded = $ConfigWasLoaded ChannelCount = $ChannelCount StableTimeSec = $StableTimeSec PollIntervalSec = $PollIntervalSec FrameRate = $FrameRate VideoBitrate = $VideoBitrate EncoderPriority = ($EncoderPriority -join " > ") AudioEnabled = $AudioEnabled MicrophoneEnabled = $MicrophoneEnabled AudioBitrate = $AudioBitrate AudioSignalThreshold = $AudioSignalThreshold NicDescription = $NicDescription HealthTarget = $HealthTarget RtspPort = $RtspPort RtspHealthCheckIntervalSec = $RtspHealthCheckIntervalSec RtspHealthTimeoutSec = $RtspHealthTimeoutSec RtspHealthFailureThreshold = $RtspHealthFailureThreshold RtspHealthStartupGraceSec = $RtspHealthStartupGraceSec AutoInstallDependencies = $AutoInstallDependencies FFmpegDownloadUrl = $FFmpegDownloadUrl FFmpegSha256Url = $FFmpegSha256Url FFmpegFallbackDownloadUrl = $FFmpegFallbackDownloadUrl FFmpegFallbackSha256Url = $FFmpegFallbackSha256Url MediaMTXDownloadUrl = $MediaMTXDownloadUrl MediaMTXSha256 = $MediaMTXSha256 } | Format-List exit 0 } if ($InstallStartup) { Install-StartupTask exit 0 } if ($UninstallStartup) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue Write-Host "자동 실행 작업을 제거했습니다: $TaskName" exit 0 } if ($StartupStatus) { $task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue if (-not $task) { Write-Host "자동 실행 작업이 등록되어 있지 않습니다: $TaskName" exit 1 } $taskInfo = Get-ScheduledTaskInfo -TaskName $TaskName [PSCustomObject]@{ TaskName = $TaskName State = $task.State LastRunTime = $taskInfo.LastRunTime LastTaskResult = $taskInfo.LastTaskResult NextRunTime = $taskInfo.NextRunTime Execute = $task.Actions.Execute Arguments = $task.Actions.Arguments Hidden = $task.Settings.Hidden StartWhenAvailable = $task.Settings.StartWhenAvailable RestartCount = $task.Settings.RestartCount RestartInterval = $task.Settings.RestartInterval MultipleInstances = $task.Settings.MultipleInstances TriggerCount = @($task.Triggers).Count RecoveryInterval = (@($task.Triggers | Where-Object { $_.Repetition -and $_.Repetition.Interval } | ForEach-Object { $_.Repetition.Interval }) -join ", ") } | Format-List exit 0 } if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir -Force | Out-Null } function Write-Log { param([string]$Message) $line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message Write-Host $line 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 if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") { throw "$Name 다운로드 URL은 유효한 HTTPS 주소여야 합니다: $Url" } } function New-DependencyTempDirectory { $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $path = Join-Path $tempRoot ("screen-rtsp-dependencies-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $path -Force | Out-Null return $path } function Remove-DependencyTempDirectory { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\' $resolvedPath = [System.IO.Path]::GetFullPath($Path) $leaf = Split-Path -Leaf $resolvedPath if (-not $resolvedPath.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or $leaf -notlike "screen-rtsp-dependencies-*") { throw "안전하지 않은 임시 폴더 삭제 요청을 거부했습니다: $resolvedPath" } if (Test-Path -LiteralPath $resolvedPath) { Remove-Item -LiteralPath $resolvedPath -Recurse -Force } } function Save-DependencyFile { param([string]$Url, [string]$Destination, [string]$Name) Assert-HttpsDependencyUrl -Url $Url -Name $Name Write-Log "$Name 다운로드 시작: $Url" $previousProgressPreference = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination -TimeoutSec 900 } finally { $ProgressPreference = $previousProgressPreference } if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or (Get-Item -LiteralPath $Destination).Length -le 0) { throw "$Name 다운로드 결과가 비어 있습니다." } } function Get-RemoteSha256 { param([string]$Url, [string]$Name, [string]$TargetFileName = "") Assert-HttpsDependencyUrl -Url $Url -Name "$Name SHA-256" $previousProgressPreference = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { $response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 60 } finally { $ProgressPreference = $previousProgressPreference } $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" } return $match.Value.ToLowerInvariant() } function Install-ZipDependency { param( [string]$Name, [string]$DownloadUrl, [string]$ExpectedSha256, [string]$ExpectedSha256Url, [string]$ExecutableName, [string]$DestinationPath, [scriptblock]$Validator ) $tempDirectory = New-DependencyTempDirectory try { $archivePath = Join-Path $tempDirectory "$Name.zip" $extractPath = Join-Path $tempDirectory "extracted" Save-DependencyFile -Url $DownloadUrl -Destination $archivePath -Name $Name if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256Url)) { $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 값이 올바르지 않습니다." } $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "$Name SHA-256 검증 실패: expected=$ExpectedSha256 actual=$actualSha256" } Write-Log "$Name SHA-256 검증 성공" Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force $candidate = Get-ChildItem -LiteralPath $extractPath -Recurse -File | Where-Object Name -eq $ExecutableName | Select-Object -First 1 if (-not $candidate) { throw "$Name 압축 파일에서 $ExecutableName 파일을 찾을 수 없습니다." } if ($Validator) { $validation = & $Validator $candidate.FullName if ($validation -is [bool]) { if (-not $validation) { throw "$Name 후보 실행 파일 검증에 실패했습니다." } } elseif (-not $validation.Passed) { throw "$Name 후보 실행 파일 검증에 실패했습니다: $($validation.Reason)" } } $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 } } function Assert-RequiredFiles { $missing = @() 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) { throw "필수 파일이 없고 자동 설치가 꺼져 있습니다: $($missing -join ', ')" } Write-Log "필수 프로그램 자동 설치 시작: $($missing -join ', ')" if ($missing -contains "ffmpeg.exe") { Install-ZipDependency -Name "FFmpeg" -DownloadUrl $FFmpegDownloadUrl ` -ExpectedSha256 "" -ExpectedSha256Url $FFmpegSha256Url ` -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath ` -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 ` -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)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "자동 설치 후에도 필수 파일을 찾을 수 없습니다: $path" } } } function Ensure-MediaMTXConfig { $config = @" # generated by Screen RTSP Watchdog rtsp: yes rtspAddress: :$RtspPort rtspTransports: [tcp] rtmp: no hls: no webrtc: no srt: no moq: no paths: all_others: "@ if (Test-Path -LiteralPath $MediaMTXConfig -PathType Leaf) { $existingConfig = Get-Content -LiteralPath $MediaMTXConfig -Raw if ($existingConfig -notmatch '^# generated by Screen RTSP Watchdog') { $configuredPort = 8554 if ($existingConfig -match '(?m)^\s*rtspAddress:\s*(?:[^:\s]+)?:(\d+)\s*$') { $configuredPort = [int]$Matches[1] } if ($configuredPort -ne $RtspPort) { throw "mediamtx.yml RTSP 포트($configuredPort)와 -RtspPort($RtspPort)가 다릅니다." } return } if ($existingConfig.Trim() -eq $config.Trim()) { return } } [System.IO.File]::WriteAllText( $MediaMTXConfig, $config, (New-Object System.Text.UTF8Encoding($false)) ) Write-Log "MediaMTX 기본 설정 생성/갱신: $MediaMTXConfig" } function Test-FFmpegCapabilities { 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 필터가 없습니다." } 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) { if ($devices -notmatch '(?m)\bdshow\b') { throw "마이크 캡처가 켜져 있지만 현재 FFmpeg에 DirectShow(dshow) 입력이 없습니다." } } return [PSCustomObject]@{ DdaGrab = $true Nvenc = $true Amf = $true Qsv = $true Aac = $true Dshow = ($devices -match '(?m)\bdshow\b') EncoderList = $encoders } } function Invoke-ProcessWithTimeout { param( [string]$FilePath, [string[]]$ArgumentList, [int]$TimeoutMilliseconds = 10000 ) $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $FilePath $startInfo.Arguments = Join-ProcessArguments $ArgumentList $startInfo.WorkingDirectory = $BaseDir $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $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 { } 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 = $stdoutTask.Result + $stderrTask.Result return [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = $output } } function Get-GpuInventory { return @( Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object { $vendor = $null $identity = "$($_.Name) $($_.AdapterCompatibility) $($_.PNPDeviceID)" if ($identity -match '(?i)NVIDIA|VEN_10DE') { $vendor = "NVIDIA" } elseif ($identity -match '(?i)\bAMD\b|\bATI\b|Advanced Micro Devices|VEN_(1002|1022)') { $vendor = "AMD" } elseif ($identity -match '(?i)Intel|VEN_8086') { $vendor = "Intel" } if ($vendor) { [PSCustomObject]@{ Vendor = $vendor Name = $_.Name DriverVersion = $_.DriverVersion PnpDeviceId = $_.PNPDeviceID Status = $_.Status } } } ) } function Get-GpuSignature { return ((Get-GpuInventory | Sort-Object Vendor,Name | ForEach-Object { "{0}:{1}:{2}:{3}" -f $_.Vendor,$_.Name,$_.DriverVersion,$_.Status }) -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", "-forced-idr","1","-strict_gop","1" ) } } "AMD" { return [PSCustomObject]@{ Vendor = "AMD"; Name = "h264_amf" 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", "-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, [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", "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", "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($TestResults) foreach ($vendor in $EncoderPriority) { $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 } } $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 if ($AudioEnabled -eq 1) { Initialize-AudioInterop } $DisplayInteropSource = @' using System; using System.Runtime.InteropServices; namespace ScreenRtsp.Display { [StructLayout(LayoutKind.Sequential)] public struct PointL { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] internal struct Rect { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] internal struct DxgiOutputDesc { [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName; public Rect DesktopCoordinates; [MarshalAs(UnmanagedType.Bool)] public bool AttachedToDesktop; public int Rotation; public IntPtr Monitor; } public sealed class DdaOutputInfo { public int OutputIndex { get; set; } public string DeviceName { get; set; } public int X { get; set; } public int Y { get; set; } public int Width { get; set; } public int Height { get; set; } public int DxgiRotation { get; set; } } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] public struct DevMode { [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string DeviceName; public short SpecVersion; public short DriverVersion; public short Size; public short DriverExtra; public int Fields; public PointL Position; public int DisplayOrientation; public int DisplayFixedOutput; public short Color; public short Duplex; public short YResolution; public short TTOption; public short Collate; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string FormName; public short LogPixels; public int BitsPerPel; public int PelsWidth; public int PelsHeight; public int DisplayFlags; public int DisplayFrequency; public int ICMMethod; public int ICMIntent; public int MediaType; public int DitherType; public int Reserved1; public int Reserved2; public int PanningWidth; public int PanningHeight; } public static class NativeMethods { [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern bool EnumDisplaySettings(string deviceName, int modeNumber, ref DevMode mode); public static int GetOrientation(string deviceName) { DevMode mode = new DevMode(); mode.DeviceName = new string('\0', 32); mode.FormName = new string('\0', 32); mode.Size = (short)Marshal.SizeOf(typeof(DevMode)); return EnumDisplaySettings(deviceName, -1, ref mode) ? mode.DisplayOrientation : 0; } } public static class DxgiOutputs { [DllImport("dxgi.dll")] private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr factory); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int EnumAdaptersDelegate(IntPtr self, uint adapterIndex, out IntPtr adapter); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int EnumOutputsDelegate(IntPtr self, uint outputIndex, out IntPtr output); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int GetDescDelegate(IntPtr self, out DxgiOutputDesc description); private static T GetVtableDelegate(IntPtr instance, int methodIndex) where T : class { IntPtr vtable = Marshal.ReadIntPtr(instance); IntPtr method = Marshal.ReadIntPtr(vtable, methodIndex * IntPtr.Size); return Marshal.GetDelegateForFunctionPointer(method, typeof(T)) as T; } public static DdaOutputInfo[] GetDefaultAdapterOutputs() { Guid factoryIid = new Guid("770AAE78-F26F-4DBA-A829-253C83D1B387"); IntPtr factory = IntPtr.Zero; IntPtr adapter = IntPtr.Zero; var result = new System.Collections.Generic.List(); try { int hr = CreateDXGIFactory1(ref factoryIid, out factory); if (hr < 0) Marshal.ThrowExceptionForHR(hr); EnumAdaptersDelegate enumAdapters = GetVtableDelegate(factory, 7); hr = enumAdapters(factory, 0, out adapter); if (hr < 0) Marshal.ThrowExceptionForHR(hr); EnumOutputsDelegate enumOutputs = GetVtableDelegate(adapter, 7); for (uint index = 0; ; index++) { IntPtr output = IntPtr.Zero; try { hr = enumOutputs(adapter, index, out output); if (hr < 0) break; GetDescDelegate getDesc = GetVtableDelegate(output, 7); DxgiOutputDesc desc; hr = getDesc(output, out desc); if (hr < 0) Marshal.ThrowExceptionForHR(hr); if (!desc.AttachedToDesktop) continue; result.Add(new DdaOutputInfo { OutputIndex = (int)index, DeviceName = (desc.DeviceName ?? String.Empty).TrimEnd('\0'), X = desc.DesktopCoordinates.Left, Y = desc.DesktopCoordinates.Top, Width = desc.DesktopCoordinates.Right - desc.DesktopCoordinates.Left, Height = desc.DesktopCoordinates.Bottom - desc.DesktopCoordinates.Top, DxgiRotation = desc.Rotation }); } finally { if (output != IntPtr.Zero) Marshal.Release(output); } } return result.ToArray(); } finally { if (adapter != IntPtr.Zero) Marshal.Release(adapter); if (factory != IntPtr.Zero) Marshal.Release(factory); } } } } '@ Add-Type -TypeDefinition $DisplayInteropSource -Language CSharp function Get-ActiveScreens { $screens = @([System.Windows.Forms.Screen]::AllScreens) $ddaOutputs = @([ScreenRtsp.Display.DxgiOutputs]::GetDefaultAdapterOutputs()) $result = @() foreach ($screen in $screens) { $ddaOutput = $ddaOutputs | Where-Object DeviceName -eq $screen.DeviceName | Select-Object -First 1 $result += [PSCustomObject]@{ DeviceName = $screen.DeviceName Primary = $screen.Primary X = $screen.Bounds.X Y = $screen.Bounds.Y Width = $screen.Bounds.Width Height = $screen.Bounds.Height Orientation = [ScreenRtsp.Display.NativeMethods]::GetOrientation($screen.DeviceName) OutputIndex = if ($ddaOutput) { $ddaOutput.OutputIndex } else { -1 } DxgiRotation = if ($ddaOutput) { $ddaOutput.DxgiRotation } else { -1 } } } return $result } function Get-ScreenSignature { param($Screens) return (($Screens | Sort-Object DeviceName | ForEach-Object { "{0}:{1}:{2}:{3}:{4}:{5}:{6}:{7}:{8}" -f $_.DeviceName,$_.Primary,$_.X,$_.Y, $_.Width,$_.Height,$_.Orientation,$_.OutputIndex,$_.DxgiRotation }) -join "|") } function Select-StreamingScreens { param($Screens) $capturable = @($Screens | Where-Object { $_.OutputIndex -ge 0 }) if ($capturable.Count -lt $ChannelCount) { return @() } return @($capturable | Sort-Object @{ Expression = "Primary"; Descending = $true },X,Y,DeviceName | Select-Object -First $ChannelCount) } function Get-AudioEndpoints { return @([ScreenRtsp.Audio.CoreAudio]::GetRenderEndpoints()) } function Get-MicrophoneEndpoints { return @([ScreenRtsp.Audio.CoreAudio]::GetCaptureEndpoints()) } $script:SelectedAudioEndpointId = $null $script:SelectedMicrophoneEndpointId = $null function Find-PreferredAudioEndpoint { param($Endpoints, [string]$CurrentEndpointId) $available = @($Endpoints) if ($available.Count -eq 0) { return $null } $current = $null if ($CurrentEndpointId) { $current = $available | Where-Object Id -eq $CurrentEndpointId | Select-Object -First 1 } $signaling = @($available | Where-Object { $_.Peak -ge $AudioSignalThreshold } | Sort-Object Peak -Descending) if ($signaling.Count -gt 0) { if ($current -and $current.Peak -ge $AudioSignalThreshold) { return $current } return $signaling[0] } $default = $available | Where-Object IsDefault | Select-Object -First 1 if ($default) { return $default } if ($current) { return $current } return $available[0] } function Select-AudioEndpoint { param($Endpoints) $selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints ` -CurrentEndpointId $script:SelectedAudioEndpointId $script:SelectedAudioEndpointId = if ($selected) { $selected.Id } else { $null } return $selected } function Select-MicrophoneEndpoint { param($Endpoints) $selected = Find-PreferredAudioEndpoint -Endpoints $Endpoints ` -CurrentEndpointId $script:SelectedMicrophoneEndpointId $script:SelectedMicrophoneEndpointId = if ($selected) { $selected.Id } else { $null } return $selected } function ConvertTo-AudioDeviceMatchKey { param([string]$Name) if ([string]::IsNullOrWhiteSpace($Name)) { return "" } $normalized = $Name.Normalize([System.Text.NormalizationForm]::FormKC).ToUpperInvariant() return [System.Text.RegularExpressions.Regex]::Replace($normalized, '[\W_]', '') } function Get-DirectShowAudioDeviceNames { $result = Invoke-ProcessWithTimeout -FilePath $FFmpegPath -ArgumentList @( "-hide_banner", "-list_devices", "true", "-f", "dshow", "-i", "dummy" ) -TimeoutMilliseconds 10000 if ($result.ExitCode -eq -1) { throw "DirectShow 오디오 장치 목록 확인 시간이 초과되었습니다." } return @([regex]::Matches($result.Output, '(?m)"(?[^"\r\n]+)"\s+\(audio\)') | ForEach-Object { $_.Groups['name'].Value } | Select-Object -Unique) } function Resolve-DirectShowMicrophoneName { param($Endpoint, [string[]]$DeviceNames) if (-not $Endpoint) { return $null } $exact = @($DeviceNames | Where-Object { $_ -ceq $Endpoint.Name }) if ($exact.Count -gt 0) { return $exact[0] } $endpointKey = ConvertTo-AudioDeviceMatchKey $Endpoint.Name $normalized = @($DeviceNames | Where-Object { (ConvertTo-AudioDeviceMatchKey $_) -eq $endpointKey }) if ($normalized.Count -eq 1) { return $normalized[0] } return $null } function Get-AudioEndpointSignature { param($Endpoint) if (-not $Endpoint) { return "MISSING" } return "{0}:{1}:{2}:{3}:{4}:{5}" -f $Endpoint.Id,$Endpoint.IsDefault, $Endpoint.SampleRate,$Endpoint.Channels,$Endpoint.BitsPerSample,$Endpoint.SampleFormat } function Get-AudioSignature { param($PlaybackEndpoint, $MicrophoneEndpoint) $playback = Get-AudioEndpointSignature $PlaybackEndpoint $microphone = if ($MicrophoneEnabled -eq 1) { Get-AudioEndpointSignature $MicrophoneEndpoint } else { "DISABLED" } return "PLAYBACK=$playback|MICROPHONE=$microphone" } function Get-NetworkState { $adapter = $null if (-not [string]::IsNullOrWhiteSpace($NicDescription)) { $adapter = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object InterfaceDescription -eq $NicDescription | Select-Object -First 1 } else { $routes = @(Get-NetRoute -AddressFamily IPv4 -DestinationPrefix "0.0.0.0/0" ` -ErrorAction SilentlyContinue | Sort-Object RouteMetric,InterfaceMetric) foreach ($route in $routes) { $candidate = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue if ($candidate -and $candidate.Status -eq "Up") { $adapter = $candidate; break } } if (-not $adapter) { $adapter = Get-NetAdapter -ErrorAction SilentlyContinue | Where-Object Status -eq "Up" | Sort-Object ifIndex | Select-Object -First 1 } } $ip = $null if ($adapter) { $ip = Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -AddressFamily IPv4 ` -AddressState Preferred -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notlike "169.254.*" -and $_.IPAddress -ne "0.0.0.0" } | Sort-Object PrefixLength -Descending | Select-Object -First 1 } return [PSCustomObject]@{ Adapter = $adapter; IPv4 = $ip } } function Test-NetworkHealth { param($Network) if (-not $Network.Adapter) { return "활성 네트워크 어댑터 없음" } if ($Network.Adapter.Status -ne "Up") { return "네트워크 Link Down" } if (-not $Network.IPv4) { return "네트워크 IPv4 없음" } if (-not [string]::IsNullOrWhiteSpace($HealthTarget)) { & ping.exe -S $Network.IPv4.IPAddress $HealthTarget -n 1 -w 800 *> $null if ($LASTEXITCODE -ne 0) { return "지정 NIC 통신 실패: $HealthTarget" } } return $null } function Get-NetworkSignature { param($Network) if (-not $Network.Adapter) { return "NIC_MISSING" } $ip = if ($Network.IPv4) { $Network.IPv4.IPAddress } else { "" } return "{0}:{1}:{2}:{3}:{4}" -f $Network.Adapter.InterfaceGuid, $Network.Adapter.ifIndex,$Network.Adapter.Status,$Network.Adapter.LinkSpeed,$ip } $script:MediaMTXProcess = $null $script:OwnsMediaMTX = $false $script:StreamProcesses = @{} $script:AudioProcesses = @{} $script:LastRtspHealthCheck = [DateTime]::MinValue $script:RtspHealthFailures = 0 $script:StreamStartedAt = [DateTime]::MinValue function Test-ProcessRunning { param($Process) if (-not $Process) { return $false } try { return (-not $Process.HasExited) } catch { return $false } } function Start-MediaMTX { if ($script:OwnsMediaMTX -and (Test-ProcessRunning $script:MediaMTXProcess)) { return $true } $listener = Get-NetTCPConnection -LocalPort $RtspPort -State Listen ` -ErrorAction SilentlyContinue | Select-Object -First 1 if ($listener) { $existing = Get-Process -Id $listener.OwningProcess -ErrorAction SilentlyContinue if ($existing -and $existing.ProcessName -eq "mediamtx") { if (-not $script:MediaMTXProcess -or $script:MediaMTXProcess.Id -ne $existing.Id) { Write-Log "기존 MediaMTX 사용 (PID=$($existing.Id))" } $script:MediaMTXProcess = $existing $script:OwnsMediaMTX = $false return $true } Write-Log "ERROR: RTSP 포트 $RtspPort 를 다른 프로세스가 사용 중 (PID=$($listener.OwningProcess))" $script:MediaMTXProcess = $null return $false } if (Test-ProcessRunning $script:MediaMTXProcess) { return $true } Write-Log "MediaMTX 시작" $script:MediaMTXProcess = Start-Process -FilePath $MediaMTXPath ` -ArgumentList (Quote-ProcessArgument $MediaMTXConfig) -WorkingDirectory $BaseDir ` -WindowStyle Hidden -PassThru $script:OwnsMediaMTX = $true Start-Sleep -Milliseconds 500 return (Test-ProcessRunning $script:MediaMTXProcess) } function Stop-Streams { if ($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) { Write-Log "RTSP 전체 채널 종료" } foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { try { if (Test-ProcessRunning $process) { Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue } } catch { } } $script:StreamProcesses = @{} $script:AudioProcesses = @{} $script:LastRtspHealthCheck = [DateTime]::MinValue $script:RtspHealthFailures = 0 $script:StreamStartedAt = [DateTime]::MinValue } function Get-StreamProcessDiagnosticLines { $lines = New-Object System.Collections.Generic.List[string] foreach ($group in @( [PSCustomObject]@{Name="FFmpeg"; Processes=$script:StreamProcesses}, [PSCustomObject]@{Name="Audio"; Processes=$script:AudioProcesses} )) { foreach ($entry in $group.Processes.GetEnumerator()) { $process = $entry.Value try { $process.Refresh() if ($process.HasExited) { $lines.Add("$($group.Name)/$($entry.Key): PID=$($process.Id), 종료, ExitCode=$($process.ExitCode)") } else { $lines.Add("$($group.Name)/$($entry.Key): PID=$($process.Id), 실행 중") } } catch { $lines.Add("$($group.Name)/$($entry.Key): 상태 확인 실패 - $($_.Exception.Message)") } } } return @($lines) } function Write-StreamFailureDiagnostics { param([string]$Reason) Write-Log "AV 파이프라인 진단: $Reason" foreach ($line in @(Get-StreamProcessDiagnosticLines)) { Write-Log "프로세스: $line" } foreach ($path in @( (Join-Path $LogDir "ffmpeg.log"), (Join-Path $LogDir "audio-loopback.log") )) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } $tail = @(Get-Content -LiteralPath $path -Tail 8 -ErrorAction SilentlyContinue) foreach ($line in $tail) { if (-not [string]::IsNullOrWhiteSpace($line)) { Write-Log "$([System.IO.Path]::GetFileName($path)): $line" } } } } function Stop-OrphanedPipelineProcesses { $scriptPath = [System.IO.Path]::GetFullPath($PSCommandPath) $candidates = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.Name -in @("ffmpeg.exe", "powershell.exe") }) foreach ($candidate in $candidates) { $owned = $false if ($candidate.Name -eq "ffmpeg.exe" -and -not [string]::IsNullOrWhiteSpace($candidate.ExecutablePath)) { $owned = [string]::Equals( [System.IO.Path]::GetFullPath($candidate.ExecutablePath), [System.IO.Path]::GetFullPath($FFmpegPath), [System.StringComparison]::OrdinalIgnoreCase ) } elseif ($candidate.Name -eq "powershell.exe" -and $candidate.CommandLine) { $owned = ($candidate.CommandLine.IndexOf( $scriptPath, [System.StringComparison]::OrdinalIgnoreCase ) -ge 0 -and $candidate.CommandLine -match '(?i)-AudioWorker(?:\s|$)') } if (-not $owned -or $candidate.ProcessId -eq $PID) { continue } Write-Log "이전 Watchdog의 잔여 프로세스 정리: $($candidate.Name) PID=$($candidate.ProcessId)" Stop-Process -Id $candidate.ProcessId -Force -ErrorAction SilentlyContinue } } function Get-RotationFilter { param([int]$Orientation) switch ($Orientation) { 1 { return "transpose=clock" } 2 { return "hflip,vflip" } 3 { return "transpose=cclock" } default { return $null } } } function Start-AudioCaptureWorker { param($Endpoint, [string]$PipeName) $arguments = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-AudioWorker", "-AudioEndpointId", $Endpoint.Id, "-AudioPipeName", $PipeName ) $errorLog = Join-Path $LogDir "audio-loopback.log" return Start-Process -FilePath $PowerShellPath -ArgumentList (Join-ProcessArguments $arguments) ` -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $errorLog -PassThru } function Wait-AudioPipe { param($Process, [string]$PipeName, [int]$TimeoutMilliseconds = 15000) $pipePath = "\\.\pipe\$PipeName" $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) { if (-not (Test-ProcessRunning $Process)) { throw "오디오 loopback worker가 pipe 생성 전에 종료되었습니다: $PipeName" } # Test-Path connects to a Windows named pipe and would consume the only # server connection before FFmpeg opens it. Directory enumeration only # observes the pipe name and leaves the server waiting for FFmpeg. if ([System.IO.Directory]::GetFiles("\\.\pipe\") -contains $pipePath) { return } Start-Sleep -Milliseconds 100 } throw "오디오 named pipe 생성 시간 초과: $PipeName" } function Get-VideoFilter { param($Screen) $filters = @("hwdownload", "format=bgra") $rotationFilter = Get-RotationFilter $Screen.Orientation if ($rotationFilter) { $filters += $rotationFilter.Split(',') } $filters += @("setpts=N/(${FrameRate}*TB)", "format=nv12") return ($filters -join ",") } function New-FFmpegArguments { param( $SelectedScreens, $PlaybackEndpoint, $MicrophoneEndpoint, [string]$MicrophoneInputName, $Encoder, [string]$AudioPipePath ) $arguments = @( "-hide_banner", "-loglevel", "warning", "-nostdin" ) $filterParts = @() for ($index = 0; $index -lt $SelectedScreens.Count; $index++) { $screen = $SelectedScreens[$index] $ddaInput = "ddagrab=output_idx=$($screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1" $arguments += @("-f", "lavfi", "-i", $ddaInput) $filterParts += "[$index`:v]$(Get-VideoFilter $screen)[v$index]" } if ($AudioEnabled -eq 1) { $playbackInputIndex = $SelectedScreens.Count $arguments += @( "-thread_queue_size", "1024", "-f", $PlaybackEndpoint.SampleFormat, "-ar", $PlaybackEndpoint.SampleRate, "-ac", $PlaybackEndpoint.Channels, "-i", $AudioPipePath ) $filterParts += "[$playbackInputIndex`:a]aresample=48000:async=1:first_pts=0," + "aformat=sample_fmts=fltp:channel_layouts=stereo[sys]" if ($MicrophoneEnabled -eq 1) { $microphoneInputIndex = $playbackInputIndex + 1 $arguments += @( "-thread_queue_size", "1024", "-f", "dshow", "-i", "audio=$MicrophoneInputName" ) $filterParts += "[$microphoneInputIndex`:a]aresample=48000:async=1:first_pts=0," + "aformat=sample_fmts=fltp:channel_layouts=stereo[mic]" $filterParts += "[sys][mic]amix=inputs=2:duration=longest:" + "dropout_transition=2:normalize=1[mixed]" } else { $filterParts += "[sys]anull[mixed]" } if ($SelectedScreens.Count -eq 1) { $filterParts += "[mixed]anull[a0]" } else { $audioLabels = (0..($SelectedScreens.Count - 1) | ForEach-Object { "[a$_]" }) -join "" $filterParts += "[mixed]asplit=$($SelectedScreens.Count)$audioLabels" } } $arguments += @("-filter_complex", ($filterParts -join ";")) for ($index = 0; $index -lt $SelectedScreens.Count; $index++) { $channel = $index + 1 $arguments += @("-map", "[v$index]") if ($AudioEnabled -eq 1) { $arguments += @("-map", "[a$index]") } $arguments += @("-c:v", $Encoder.Name) $arguments += @($Encoder.Arguments) $arguments += @( "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12", "-force_key_frames", "expr:gte(t,n_forced*1)" ) if ($AudioEnabled -eq 1) { $arguments += @( "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2" ) } $arguments += @( "-f", "rtsp", "-rtsp_transport", "tcp", "$RTSPPublishBase/monitor$channel" ) } return $arguments } function Start-Streams { param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint, $Encoder) $selected = @(Select-StreamingScreens $Screens) if ($selected.Count -lt $ChannelCount) { return } $audioPipePath = "" if ($AudioEnabled -eq 1) { $pipeName = "screen-rtsp-{0}-{1}" -f $PID,([Guid]::NewGuid().ToString("N")) $audioProcess = Start-AudioCaptureWorker -Endpoint $PlaybackEndpoint -PipeName $pipeName $script:AudioProcesses["playback"] = $audioProcess Wait-AudioPipe -Process $audioProcess -PipeName $pipeName $audioPipePath = "\\.\pipe\$pipeName" } $microphoneInputName = "" if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) { $directShowDeviceNames = @(Get-DirectShowAudioDeviceNames) $microphoneInputName = Resolve-DirectShowMicrophoneName ` -Endpoint $MicrophoneEndpoint -DeviceNames $directShowDeviceNames if (-not $microphoneInputName) { throw "선택된 마이크를 FFmpeg DirectShow 장치와 연결할 수 없습니다: $($MicrophoneEndpoint.Name)" } } $arguments = @(New-FFmpegArguments -SelectedScreens $selected ` -PlaybackEndpoint $PlaybackEndpoint -MicrophoneEndpoint $MicrophoneEndpoint ` -MicrophoneInputName $microphoneInputName ` -Encoder $Encoder -AudioPipePath $audioPipePath) $ffmpegLog = Join-Path $LogDir "ffmpeg.log" $ffmpegProcess = Start-Process -FilePath $FFmpegPath ` -ArgumentList (Join-ProcessArguments $arguments) -WorkingDirectory $BaseDir ` -WindowStyle Hidden -RedirectStandardError $ffmpegLog -PassThru $script:StreamProcesses["pipeline"] = $ffmpegProcess Write-Log "RTSP $ChannelCount 채널 시작: 단일 FFmpeg PID=$($ffmpegProcess.Id)" for ($index = 0; $index -lt $selected.Count; $index++) { $channel = $index + 1 $screen = $selected[$index] Write-Log ("monitor{0}: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}" -f ` $channel,$screen.OutputIndex,$screen.Width,$screen.Height,$screen.Orientation,$Encoder.Name) } if ($AudioEnabled -eq 1) { Write-Log "시스템 재생음: $($PlaybackEndpoint.Name) (peak=$([Math]::Round($PlaybackEndpoint.Peak, 4)))" if ($MicrophoneEnabled -eq 1) { Write-Log "마이크: $($MicrophoneEndpoint.Name) (peak=$([Math]::Round($MicrophoneEndpoint.Peak, 4)))" Write-Log "오디오 혼합: 시스템 재생음 + 마이크 -> AAC $AudioBitrate" } else { Write-Log "마이크 캡처: 사용 안 함" } } else { Write-Log "오디오 캡처: 사용 안 함" } for ($channel = 1; $channel -le $ChannelCount; $channel++) { Write-Log "수신 URL: rtsp://$($Network.IPv4.IPAddress):$RtspPort/monitor$channel" } Start-Sleep -Seconds 1 if (-not (Test-StreamsRunning)) { if (Test-Path -LiteralPath $ffmpegLog) { Get-Content -LiteralPath $ffmpegLog -Tail 30 | ForEach-Object { Write-Log "FFmpeg: $_" } } throw "FFmpeg 또는 오디오 loopback worker가 실행 직후 종료되었습니다." } $script:LastRtspHealthCheck = Get-Date $script:RtspHealthFailures = 0 $script:StreamStartedAt = Get-Date } function Test-StreamsRunning { $expectedAudioProcessCount = if ($AudioEnabled -eq 1) { 1 } else { 0 } if ($StreamProcesses.Count -ne 1 -or $AudioProcesses.Count -ne $expectedAudioProcessCount) { return $false } foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { if (-not (Test-ProcessRunning $process)) { return $false } } return $true } function Test-RtspHealthCheckDue { param([datetime]$Now = (Get-Date)) if (-not (Test-StreamsRunning)) { return $false } if ($script:StreamStartedAt -eq [DateTime]::MinValue) { return $false } if (($Now - $script:StreamStartedAt).TotalSeconds -lt $RtspHealthStartupGraceSec) { return $false } return (($Now - $script:LastRtspHealthCheck).TotalSeconds -ge $RtspHealthCheckIntervalSec) } function New-RtspHealthProbeArguments { param([int]$Channel) return @( "-hide_banner", "-loglevel", "error", "-nostdin", "-nostats", "-progress", "pipe:1", "-rtsp_transport", "tcp", "-timeout", [string]($RtspHealthTimeoutSec * 1000000), "-i", "$RTSPPublishBase/monitor$Channel", "-map", "0:v:0", "-frames:v", "1", "-an", "-sn", "-dn", "-f", "null", "NUL" ) } function Test-RtspStreamFrames { $probes = @() $failures = New-Object System.Collections.Generic.List[string] try { for ($channel = 1; $channel -le $ChannelCount; $channel++) { $errorLog = Join-Path $LogDir "rtsp-health-monitor$channel.log" $progressLog = Join-Path $LogDir "rtsp-health-monitor$channel.progress" Remove-Item -LiteralPath $errorLog,$progressLog ` -Force -ErrorAction SilentlyContinue $process = Start-Process -FilePath $FFmpegPath ` -ArgumentList (Join-ProcessArguments (New-RtspHealthProbeArguments $channel)) ` -WorkingDirectory $BaseDir -WindowStyle Hidden ` -RedirectStandardOutput $progressLog ` -RedirectStandardError $errorLog -PassThru $probes += [PSCustomObject]@{ Channel = $channel Process = $process ErrorLog = $errorLog ProgressLog = $progressLog } } # Start every receiver first and give the group one shared deadline. # This keeps recovery time bounded even when every channel is stalled. $deadline = [DateTime]::UtcNow.AddSeconds($RtspHealthTimeoutSec + 2) foreach ($probe in $probes) { $remainingMs = [Math]::Max( 1, [int]($deadline - [DateTime]::UtcNow).TotalMilliseconds ) $completed = $probe.Process.WaitForExit($remainingMs) if (-not $completed) { Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue $failures.Add( "monitor$($probe.Channel): ${RtspHealthTimeoutSec}초 내 영상 프레임 없음" ) continue } $probe.Process.WaitForExit() $progress = @() if (Test-Path -LiteralPath $probe.ProgressLog) { $progress = @( Get-Content -LiteralPath $probe.ProgressLog ` -ErrorAction SilentlyContinue ) } $hasFrame = @($progress | Where-Object { $_ -match '^frame=([1-9][0-9]*)$' }).Count -gt 0 $reachedEnd = $progress -contains "progress=end" if (-not ($hasFrame -and $reachedEnd)) { $detail = "FFmpeg progress에 디코딩 완료 프레임 없음" if (Test-Path -LiteralPath $probe.ErrorLog) { $tail = @( Get-Content -LiteralPath $probe.ErrorLog -Tail 3 ` -ErrorAction SilentlyContinue ) if ($tail.Count -gt 0) { $detail = ($tail -join " | ") } } $failures.Add("monitor$($probe.Channel): $detail") } } } catch { $failures.Add($_.Exception.Message) } finally { foreach ($probe in $probes) { try { if (-not $probe.Process.HasExited) { Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue } } catch { } } } return [PSCustomObject]@{ Healthy = ($failures.Count -eq 0) Reason = ($failures -join "; ") } } function Get-StreamingReadinessReason { param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint) $capturableScreenCount = @($Screens | Where-Object { $_.OutputIndex -ge 0 }).Count if ($Screens.Count -lt $ChannelCount) { return "활성 모니터 $ChannelCount 개 미만" } if ($capturableScreenCount -lt $ChannelCount) { return "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" } $networkReason = Test-NetworkHealth $Network if ($networkReason) { return $networkReason } if ($AudioEnabled -eq 1 -and -not $PlaybackEndpoint) { return "활성 오디오 출력 장치 없음" } if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1 -and -not $MicrophoneEndpoint) { return "활성 마이크 입력 장치 없음" } return $null } function Test-WatchdogResumeGap { param( [DateTime]$PreviousPollAt, [DateTime]$CurrentPollAt ) $resumeThresholdSec = [Math]::Max(10.0, [double]$PollIntervalSec * 3.0) return (($CurrentPollAt - $PreviousPollAt).TotalSeconds -ge $resumeThresholdSec) } function Receive-WatchdogPowerResume { param([string]$SourceIdentifier) $resumeDetected = $false foreach ($powerEvent in @(Get-Event -SourceIdentifier $SourceIdentifier ` -ErrorAction SilentlyContinue)) { if ([string]$powerEvent.SourceEventArgs.Mode -eq "Resume") { $resumeDetected = $true } Remove-Event -EventIdentifier $powerEvent.EventIdentifier -ErrorAction SilentlyContinue } return $resumeDetected } function Invoke-SimulationTests { $results = New-Object 'System.Collections.Generic.List[object]' function Invoke-SimulationCase { param([string]$Name, [scriptblock]$Body) try { $passed = [bool](& $Body) $detail = if ($passed) { "PASS" } else { "조건 결과가 False입니다." } } catch { $passed = $false $detail = $_.Exception.Message } $results.Add([PSCustomObject]@{ Number = $results.Count + 1 Name = $Name Passed = $passed Detail = $detail }) } function New-SimulationIni { param([string]$Content) $path = Join-Path ([System.IO.Path]::GetTempPath()) ` ("screen-rtsp-simulation-" + [Guid]::NewGuid().ToString("N") + ".ini") [System.IO.File]::WriteAllText($path, $Content, (New-Object System.Text.UTF8Encoding($false))) return $path } $originalChannelCount = $script:ChannelCount $originalSelectedAudioEndpointId = $script:SelectedAudioEndpointId $originalSelectedMicrophoneEndpointId = $script:SelectedMicrophoneEndpointId $originalHealthTarget = $script:HealthTarget $originalAudioEnabled = $script:AudioEnabled $originalMicrophoneEnabled = $script:MicrophoneEnabled $originalStreamProcesses = $script:StreamProcesses $originalAudioProcesses = $script:AudioProcesses try { $script:ChannelCount = 2 $script:HealthTarget = "" Invoke-SimulationCase "INI 주석(#, ;, //)과 따옴표 값 파싱" { $path = New-SimulationIni "# one`r`n; two`r`n// three`r`n[Audio]`r`nEnabled=1`r`nMicrophoneEnabled=0`r`nBitrate=`"128k`"`r`nSignalThreshold=0.1" try { $parsed = Read-IniConfiguration $path return ($parsed.Audio.Enabled -eq "1" -and $parsed.Audio.MicrophoneEnabled -eq "0" -and $parsed.Audio.Bitrate -eq "128k") } 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 "RTSP 실제 프레임 검사 설정 파싱" { $path = New-SimulationIni ( "[RTSP]`r`nPort=8554`r`nHealthCheckIntervalSec=10`r`n" + "HealthTimeoutSec=5`r`nHealthFailureThreshold=2`r`n" + "StartupGraceSec=45`r`n" ) try { $parsed = Read-IniConfiguration $path Assert-IniSettings $parsed return ($parsed.RTSP.HealthCheckIntervalSec -eq "10" -and $parsed.RTSP.HealthTimeoutSec -eq "5" -and $parsed.RTSP.HealthFailureThreshold -eq "2" -and $parsed.RTSP.StartupGraceSec -eq "45") } 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 { $parsed = Read-IniConfiguration $path try { Assert-IniSettings $parsed; return $false } catch { return $true } } finally { [System.IO.File]::Delete($path) } } Invoke-SimulationCase "기능 스위치 1/0 허용" { return ((Convert-IniSwitch "1" "test") -eq 1 -and (Convert-IniSwitch "0" "test") -eq 0) } Invoke-SimulationCase "기능 스위치 2 거부" { try { $null = Convert-IniSwitch "2" "test"; return $false } catch { return $true } } $screenPrimary = [PSCustomObject]@{ DeviceName="DISPLAY1"; Primary=$true; X=0; Y=0; Width=2560; Height=1440 Orientation=0; OutputIndex=0; DxgiRotation=1 } $screenSecond = [PSCustomObject]@{ DeviceName="DISPLAY2"; Primary=$false; X=2560; Y=0; Width=1080; Height=1920 Orientation=3; OutputIndex=1; DxgiRotation=4 } $screenThird = [PSCustomObject]@{ DeviceName="DISPLAY3"; Primary=$false; X=-1920; Y=0; Width=1920; Height=1080 Orientation=0; OutputIndex=2; DxgiRotation=1 } Invoke-SimulationCase "모니터 1대이면 2채널 시작 보류" { return (@(Select-StreamingScreens @($screenPrimary)).Count -eq 0) } Invoke-SimulationCase "모니터 2대이면 Primary가 monitor1" { $selected = @(Select-StreamingScreens @($screenSecond,$screenPrimary)) return ($selected.Count -eq 2 -and $selected[0].DeviceName -eq "DISPLAY1") } Invoke-SimulationCase "모니터 3대여도 설정된 2대만 선택" { $selected = @(Select-StreamingScreens @($screenPrimary,$screenSecond,$screenThird)) return ($selected.Count -eq 2 -and $selected[0].Primary -and $selected[1].DeviceName -eq "DISPLAY3") } Invoke-SimulationCase "D3D11에 매핑되지 않은 모니터가 있으면 시작 보류" { $unmapped = $screenSecond.PSObject.Copy() $unmapped.OutputIndex = -1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"} IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } $script:AudioEnabled = 0 return ((Get-StreamingReadinessReason @($screenPrimary,$unmapped) $network $null) -like "기본 D3D11 adapter*") } Invoke-SimulationCase "해상도 변경 signature 감지" { $changed = $screenPrimary.PSObject.Copy() $changed.Width = 1920 return ((Get-ScreenSignature @($screenPrimary)) -ne (Get-ScreenSignature @($changed))) } Invoke-SimulationCase "위치/Primary/회전 변경 signature 감지" { $changed = $screenSecond.PSObject.Copy() $changed.X = -1080 $changed.Primary = $true $changed.Orientation = 1 return ((Get-ScreenSignature @($screenSecond)) -ne (Get-ScreenSignature @($changed))) } Invoke-SimulationCase "세로 모니터 회전 필터 선택" { return ((Get-RotationFilter 1) -eq "transpose=clock" -and (Get-RotationFilter 2) -eq "hflip,vflip" -and (Get-RotationFilter 3) -eq "transpose=cclock" -and $null -eq (Get-RotationFilter 0)) } $defaultAudio = [PSCustomObject]@{ Id="default"; Name="Default"; IsDefault=$true; Peak=0.0 SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" } $audibleAudio = [PSCustomObject]@{ Id="audible"; Name="HDMI"; IsDefault=$false; Peak=0.2 SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" } $defaultMicrophone = [PSCustomObject]@{ Id="mic-default"; Name="Default Microphone"; IsDefault=$true; Peak=0.0 SampleRate=48000; Channels=1; BitsPerSample=16; SampleFormat="s16le" } $activeMicrophone = [PSCustomObject]@{ Id="mic-active"; Name="USB Microphone"; IsDefault=$false; Peak=0.2 SampleRate=48000; Channels=1; BitsPerSample=16; SampleFormat="s16le" } Invoke-SimulationCase "실제 peak가 있는 오디오 장치 우선" { $script:SelectedAudioEndpointId = $null return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") } Invoke-SimulationCase "모든 장치 무음이면 기본 출력 선택" { $script:SelectedAudioEndpointId = $null $audibleAudio.Peak = 0.0 return ((Select-AudioEndpoint @($audibleAudio,$defaultAudio)).Id -eq "default") } Invoke-SimulationCase "현재 출력 장치에 신호가 있으면 선택 유지" { $audibleAudio.Peak = 0.1 $defaultAudio.Peak = 0.3 $script:SelectedAudioEndpointId = "audible" return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") } Invoke-SimulationCase "실제 입력 신호가 있는 마이크 자동 선택" { $script:SelectedMicrophoneEndpointId = $null return ((Select-MicrophoneEndpoint @($defaultMicrophone,$activeMicrophone)).Id -eq "mic-active") } Invoke-SimulationCase "마이크가 모두 무음이면 기본 입력 선택" { $script:SelectedMicrophoneEndpointId = $null $activeMicrophone.Peak = 0.0 return ((Select-MicrophoneEndpoint @($activeMicrophone,$defaultMicrophone)).Id -eq "mic-default") } Invoke-SimulationCase "Core Audio와 DirectShow 마이크 이름 공백 차이 매핑" { $endpoint = $defaultMicrophone.PSObject.Copy() $endpoint.Name = "마이크 (Divoom Audio)" return ((Resolve-DirectShowMicrophoneName $endpoint ` @("마이크(Divoom Audio)", "다른 마이크")) -eq "마이크(Divoom Audio)") } Invoke-SimulationCase "오디오 mix format 변경 signature 감지" { $changedAudio = $defaultAudio.PSObject.Copy() $changedAudio.SampleRate = 44100 return ((Get-AudioSignature $defaultAudio $defaultMicrophone) -ne (Get-AudioSignature $changedAudio $defaultMicrophone)) } Invoke-SimulationCase "기본 입력 장치 변경 signature 감지" { $changedMicrophone = $defaultMicrophone.PSObject.Copy() $changedMicrophone.Id = "mic-reconnected" return ((Get-AudioSignature $defaultAudio $defaultMicrophone) -ne (Get-AudioSignature $defaultAudio $changedMicrophone)) } Invoke-SimulationCase "NIC 부재 signature" { $network = [PSCustomObject]@{ Adapter=$null; IPv4=$null } return ((Get-NetworkSignature $network) -eq "NIC_MISSING") } Invoke-SimulationCase "NIC IPv4 변경 signature 감지" { $adapter = [PSCustomObject]@{ InterfaceGuid="guid"; ifIndex=8; Status="Up"; LinkSpeed="1 Gbps" } $network1 = [PSCustomObject]@{ Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.34"} } $network2 = [PSCustomObject]@{ Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.35"} } return ((Get-NetworkSignature $network1) -ne (Get-NetworkSignature $network2)) } Invoke-SimulationCase "NIC가 없으면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{Adapter=$null; IPv4=$null} return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "활성 네트워크 어댑터 없음") } Invoke-SimulationCase "NIC Link Down이면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Disconnected"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "네트워크 Link Down") } Invoke-SimulationCase "NIC IPv4가 없으면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{Adapter=[PSCustomObject]@{Status="Up"}; IPv4=$null} return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "네트워크 IPv4 없음") } Invoke-SimulationCase "오디오 사용 중 endpoint가 없으면 시작 보류" { $script:AudioEnabled = 1 $script:MicrophoneEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "활성 오디오 출력 장치 없음") } Invoke-SimulationCase "마이크 사용 중 입력 endpoint가 없으면 시작 보류" { $script:AudioEnabled = 1 $script:MicrophoneEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) ` $network $defaultAudio $null) -eq "활성 마이크 입력 장치 없음") } Invoke-SimulationCase "모니터/NIC/오디오가 정상이면 시작 가능" { $script:AudioEnabled = 1 $script:MicrophoneEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ($null -eq (Get-StreamingReadinessReason ` @($screenPrimary,$screenSecond) $network $defaultAudio $defaultMicrophone)) } Invoke-SimulationCase "GPU별 H.264 인코더 매핑" { return ((Get-EncoderDefinition "NVIDIA").Name -eq "h264_nvenc" -and (Get-EncoderDefinition "AMD").Name -eq "h264_amf" -and (Get-EncoderDefinition "Intel").Name -eq "h264_qsv") } Invoke-SimulationCase "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 $arguments -match '-WatchdogWorker' -and $arguments -match '-ConfigPath' -and $arguments -notmatch '-VideoBitrate') } Invoke-SimulationCase "직접 숨김 실행은 최종 설정과 override를 전달" { $arguments = Get-HiddenWorkerArgumentList return ($arguments -match '-EncoderPriorityCsv' -and $arguments -match '-ChannelCount\s+2' -and $arguments -match '-AudioEnabled\s+[01]' -and $arguments -match '-MicrophoneEnabled\s+[01]' -and $arguments -match '-RtspPort\s+8554' -and $arguments -match '-RtspHealthCheckIntervalSec\s+10' -and $arguments -match '-RtspHealthTimeoutSec\s+5' -and $arguments -match '-RtspHealthFailureThreshold\s+2' -and $arguments -match '-RtspHealthStartupGraceSec\s+45' -and $arguments -match '-WatchdogWorker') } Invoke-SimulationCase "예약 작업은 1분 간격으로 무기한 복구 확인" { $triggers = @(Get-StartupTriggers) $recoveryTrigger = $triggers | Where-Object { $_.Repetition -and $_.Repetition.Interval } | Select-Object -First 1 return ($triggers.Count -eq 2 -and $recoveryTrigger.Repetition.Interval -eq "PT1M" -and [string]::IsNullOrWhiteSpace($recoveryTrigger.Repetition.Duration)) } Invoke-SimulationCase "예약 작업 숨김/누락 실행/재시작 설정" { $settings = Get-StartupSettings return ($settings.Hidden -and $settings.StartWhenAvailable -and $settings.RestartCount -eq 999 -and $settings.RestartInterval -eq "PT1M" -and $settings.ExecutionTimeLimit -eq "PT0S" -and $settings.MultipleInstances -eq "IgnoreNew") } Invoke-SimulationCase "정상 polling 간격은 절전 복구로 오인하지 않음" { $previous = [DateTime]::UtcNow return (-not (Test-WatchdogResumeGap $previous $previous.AddSeconds(2))) } Invoke-SimulationCase "긴 polling 공백을 절전 해제로 감지" { $previous = [DateTime]::UtcNow return (Test-WatchdogResumeGap $previous $previous.AddMinutes(5)) } Invoke-SimulationCase "단일 FFmpeg가 2화면과 혼합 오디오를 2개 RTSP로 구성" { $script:AudioEnabled = 1 $script:MicrophoneEnabled = 1 $encoderDefinition = Get-EncoderDefinition "NVIDIA" $arguments = @(New-FFmpegArguments ` -SelectedScreens @($screenPrimary,$screenSecond) ` -PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $defaultMicrophone ` -MicrophoneInputName $defaultMicrophone.Name ` -Encoder $encoderDefinition -AudioPipePath "\\.\pipe\simulation") $filterIndex = [Array]::IndexOf($arguments, "-filter_complex") $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) } Invoke-SimulationCase "마이크 OFF이면 시스템 재생음만 두 채널에 분배" { $script:AudioEnabled = 1 $script:MicrophoneEnabled = 0 $arguments = @(New-FFmpegArguments ` -SelectedScreens @($screenPrimary,$screenSecond) ` -PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $null ` -Encoder (Get-EncoderDefinition "Intel") -AudioPipePath "\\.\pipe\simulation") $filterIndex = [Array]::IndexOf($arguments, "-filter_complex") $filter = $arguments[$filterIndex + 1] return ($filter -notmatch 'amix=' -and $filter -match 'asplit=2' -and $arguments -notcontains 'dshow') } Invoke-SimulationCase "단일 FFmpeg/단일 loopback worker가 모두 생존" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 1 $script:StreamProcesses = @{pipeline=$alive} $script:AudioProcesses = @{playback=$alive} return (Test-StreamsRunning) } Invoke-SimulationCase "단일 FFmpeg가 종료되면 전체 재시작 필요" { $dead = [PSCustomObject]@{HasExited=$true} $script:AudioEnabled = 1 $script:StreamProcesses = @{pipeline=$dead} $script:AudioProcesses = @{playback=[PSCustomObject]@{HasExited=$false}} return (-not (Test-StreamsRunning)) } Invoke-SimulationCase "오디오 OFF이면 worker 없이 단일 FFmpeg 정상" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 0 $script:StreamProcesses = @{pipeline=$alive} $script:AudioProcesses = @{} return (Test-StreamsRunning) } Invoke-SimulationCase "RTSP 상태 검사는 새 수신기로 실제 영상 1프레임 요구" { $arguments = @(New-RtspHealthProbeArguments 2) return ($arguments -contains '-timeout' -and $arguments -contains '-progress' -and $arguments -contains 'pipe:1' -and $arguments -contains '-frames:v' -and $arguments -contains '1' -and $arguments -contains '0:v:0' -and $arguments -contains "$RTSPPublishBase/monitor2") } Invoke-SimulationCase "RTSP 시작 유예 중에는 프레임 검사를 실행하지 않음" { $savedAudioEnabled = $script:AudioEnabled $savedStreamProcesses = $script:StreamProcesses $savedAudioProcesses = $script:AudioProcesses $savedStartedAt = $script:StreamStartedAt $savedLastCheck = $script:LastRtspHealthCheck try { $now = Get-Date $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 0 $script:StreamProcesses = @{pipeline=$alive} $script:AudioProcesses = @{} $script:LastRtspHealthCheck = $now.AddHours(-1) $script:StreamStartedAt = $now $blockedDuringGrace = -not (Test-RtspHealthCheckDue -Now $now) $script:StreamStartedAt = $now.AddSeconds(-($RtspHealthStartupGraceSec + 1)) $allowedAfterGrace = Test-RtspHealthCheckDue -Now $now return ($blockedDuringGrace -and $allowedAfterGrace) } finally { $script:AudioEnabled = $savedAudioEnabled $script:StreamProcesses = $savedStreamProcesses $script:AudioProcesses = $savedAudioProcesses $script:StreamStartedAt = $savedStartedAt $script:LastRtspHealthCheck = $savedLastCheck } } Invoke-SimulationCase "INI FPS 허용 범위 밖 값 거부" { try { Assert-IntegerRange "Video.FrameRate" 0 1 240 return $false } catch { return $true } } Invoke-SimulationCase "비 HTTPS 의존성 URL 거부" { try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false } catch { return $true } } Invoke-SimulationCase "자동 설치 OFF에서 파일 부재 오류" { $savedFFmpegPath = $script:FFmpegPath $savedMediaMTXPath = $script:MediaMTXPath $savedAutoInstall = $script:AutoInstallDependencies try { $script:FFmpegPath = Join-Path $BaseDir "definitely-missing-ffmpeg.exe" $script:MediaMTXPath = Join-Path $BaseDir "definitely-missing-mediamtx.exe" $script:AutoInstallDependencies = 0 try { Assert-RequiredFiles; return $false } catch { return $true } } finally { $script:FFmpegPath = $savedFFmpegPath $script:MediaMTXPath = $savedMediaMTXPath $script:AutoInstallDependencies = $savedAutoInstall } } Invoke-SimulationCase "안전 범위 밖 임시 폴더 삭제 거부" { try { Remove-DependencyTempDirectory ([System.IO.Path]::GetTempPath()); return $false } catch { return $true } } } finally { $script:ChannelCount = $originalChannelCount $script:SelectedAudioEndpointId = $originalSelectedAudioEndpointId $script:SelectedMicrophoneEndpointId = $originalSelectedMicrophoneEndpointId $script:HealthTarget = $originalHealthTarget $script:AudioEnabled = $originalAudioEnabled $script:MicrophoneEnabled = $originalMicrophoneEnabled $script:StreamProcesses = $originalStreamProcesses $script:AudioProcesses = $originalAudioProcesses } 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 $passedCount = @($simulationResults | Where-Object Passed).Count $failedCount = $simulationResults.Count - $passedCount Write-Host "Simulation summary: total=$($simulationResults.Count), passed=$passedCount, failed=$failedCount" if ($failedCount -gt 0) { exit 1 } exit 0 } if (-not $WatchdogWorker -and -not $Foreground) { Start-HiddenWatchdog exit 0 } $mutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog") $hasMutex = $false $powerEventSourceIdentifier = "ScreenRtspPowerMode-$PID" $powerEventRegistered = $false try { $hasMutex = $mutex.WaitOne(0, $false) if (-not $hasMutex) { throw "Screen RTSP Watchdog가 이미 실행 중입니다." } Stop-OrphanedPipelineProcesses Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) ` -EventName PowerModeChanged -SourceIdentifier $powerEventSourceIdentifier | Out-Null $powerEventRegistered = $true Assert-RequiredFiles Ensure-MediaMTXConfig $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" } else { Write-Log "설정 파일 없음 - 내장 기본값 사용: $ConfigPath" } Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')" Write-Log "오디오 캡처: $(if ($AudioEnabled -eq 1) { '사용' } else { '사용 안 함' })" if ($AudioEnabled -eq 1) { Write-Log "마이크 혼합: $(if ($MicrophoneEnabled -eq 1) { '사용' } else { '사용 안 함' })" } if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } Write-Log ( "RTSP 프레임 검사: 시작 후 ${RtspHealthStartupGraceSec}초 유예 / " + "${RtspHealthCheckIntervalSec}초 주기 / ${RtspHealthTimeoutSec}초 제한 / " + "${RtspHealthFailureThreshold}회 연속 실패 시 재시작" ) Write-Log "영상 설정: ${FrameRate}fps / 채널당 $VideoBitrate" $lastSystemSignature = "" $healthySince = $null $lastReason = "" $lastGpuCheck = [DateTime]::MinValue $lastPollAt = Get-Date while ($true) { try { $currentPollAt = Get-Date $powerResumeDetected = Receive-WatchdogPowerResume ` -SourceIdentifier $powerEventSourceIdentifier $resumeGapDetected = Test-WatchdogResumeGap ` -PreviousPollAt $lastPollAt -CurrentPollAt $currentPollAt if ($powerResumeDetected -or $resumeGapDetected) { $pollGapSec = [Math]::Round(($currentPollAt - $lastPollAt).TotalSeconds, 1) $resumeSource = if ($powerResumeDetected) { "Windows 절전 해제 이벤트" } ` else { "polling 공백 ${pollGapSec}초" } Write-Log "$resumeSource 감지 - 전체 채널 재구성" Stop-Streams $lastSystemSignature = "" $healthySince = $null $lastReason = "" $lastGpuCheck = [DateTime]::MinValue } $lastPollAt = $currentPollAt $mediaReady = Start-MediaMTX if (-not $mediaReady) { Stop-Streams $healthySince = $null $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec continue } $screens = @(Get-ActiveScreens) if ($AudioEnabled -eq 1) { $endpoints = @(Get-AudioEndpoints) $playbackEndpoint = Select-AudioEndpoint $endpoints if ($MicrophoneEnabled -eq 1) { $microphoneEndpoints = @(Get-MicrophoneEndpoints) $microphoneEndpoint = Select-MicrophoneEndpoint $microphoneEndpoints } else { $microphoneEndpoints = @() $microphoneEndpoint = $null } $audioSignature = Get-AudioSignature $playbackEndpoint $microphoneEndpoint } else { $endpoints = @() $microphoneEndpoints = @() $playbackEndpoint = $null $microphoneEndpoint = $null $audioSignature = "AUDIO_DISABLED" } $network = Get-NetworkState if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) { $currentEnvironmentSignature = Get-RuntimeEnvironmentSignature $lastGpuCheck = Get-Date if ($currentEnvironmentSignature -ne $runtimeEnvironmentSignature) { Write-Log "GPU, 드라이버 또는 실행 파일 변경 감지" Stop-Streams $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 } } $systemSignature = (Get-ScreenSignature $screens) + "||" + (Get-NetworkSignature $network) + "||" + $audioSignature + "||" + $encoder.Name if ($systemSignature -ne $lastSystemSignature) { if ($lastSystemSignature) { Write-Log "모니터, NIC, 오디오 또는 GPU 구성 변경 감지" Stop-Streams } $lastSystemSignature = $systemSignature $healthySince = $null } $reason = Get-StreamingReadinessReason -Screens $screens -Network $network ` -PlaybackEndpoint $playbackEndpoint -MicrophoneEndpoint $microphoneEndpoint if ($reason) { if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" } $lastReason = $reason Stop-Streams $healthySince = $null $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec continue } if ($lastReason) { Write-Log "송출 조건 복구" $lastReason = "" } if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and -not (Test-StreamsRunning)) { Write-Log "FFmpeg 또는 보조 프로세스 비정상 종료 감지" Write-StreamFailureDiagnostics "프로세스 종료 감지" Stop-Streams $healthySince = $null } # A live FFmpeg PID does not prove that DDA and both RTSP outputs # are progressing. A new local receiver must decode one frame from # every channel within the configured deadline. if (Test-RtspHealthCheckDue) { $script:LastRtspHealthCheck = Get-Date $rtspHealth = Test-RtspStreamFrames if ($rtspHealth.Healthy) { if ($script:RtspHealthFailures -gt 0) { Write-Log "RTSP 실제 프레임 수신 상태 복구" } $script:RtspHealthFailures = 0 } else { $script:RtspHealthFailures++ Write-Log ( "RTSP 실제 프레임 검사 실패 " + "($($script:RtspHealthFailures)/$RtspHealthFailureThreshold): " + $rtspHealth.Reason ) if ($script:RtspHealthFailures -ge $RtspHealthFailureThreshold) { Write-Log "RTSP 송출 정지 감지 - 전체 AV 파이프라인 재시작" Write-StreamFailureDiagnostics $rtspHealth.Reason Stop-Streams $healthySince = $null } } } if (-not (Test-StreamsRunning)) { if (-not $healthySince) { $healthySince = Get-Date Write-Log "송출 조건 정상 - $StableTimeSec 초 안정화 확인 시작" } if (((Get-Date) - $healthySince).TotalSeconds -ge $StableTimeSec) { Start-Streams -Screens $screens -Network $network ` -PlaybackEndpoint $playbackEndpoint -MicrophoneEndpoint $microphoneEndpoint ` -Encoder $encoder $healthySince = $null } } } catch { Write-Log "감시 루프 예외: $($_.Exception.Message)" Stop-Streams $healthySince = $null } $lastPollAt = Get-Date Start-Sleep -Seconds $PollIntervalSec } } catch { Write-Log "종료 오류: $($_.Exception.Message)" exit 1 } finally { Stop-Streams if ($powerEventRegistered) { Unregister-Event -SourceIdentifier $powerEventSourceIdentifier ` -ErrorAction SilentlyContinue Get-Event -SourceIdentifier $powerEventSourceIdentifier -ErrorAction SilentlyContinue | Remove-Event -ErrorAction SilentlyContinue } if ($OwnsMediaMTX -and (Test-ProcessRunning $MediaMTXProcess)) { Stop-Process -Id $MediaMTXProcess.Id -Force -ErrorAction SilentlyContinue } if ($hasMutex) { try { $mutex.ReleaseMutex() } catch { } } if ($mutex) { $mutex.Dispose() } }