#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, [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, [ValidateSet(0, 1)] [int]$AutoInstallDependencies = 1, [string]$FFmpegDownloadUrl = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip", [string]$FFmpegSha256Url = "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip.sha256", [string]$MediaMTXDownloadUrl = "https://github.com/bluenviron/mediamtx/releases/download/v1.21.1/mediamtx_v1.21.1_windows_amd64.zip", [string]$MediaMTXSha256 = "faa97974861eb75a68b5aa326c78e7e7a6f670b5ef191bace78e715130381f23", [string]$ConfigPath = "", [switch]$InstallStartup, [switch]$UninstallStartup, [switch]$StartupStatus, [switch]$ShowConfiguration, [switch]$InstallDependenciesOnly, [switch]$RunSimulationTests, [Parameter(DontShow = $true)] [switch]$AudioWorker, [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 capture # - 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); } } public static EndpointInfo[] GetRenderEndpoints() { IMMDeviceEnumerator enumerator = null; IMMDeviceCollection collection = null; IMMDevice defaultDevice = null; string defaultId = null; List result = new List(); try { enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); if (enumerator.GetDefaultAudioEndpoint(EDataFlow.Render, ERole.Multimedia, out defaultDevice) >= 0 && defaultDevice != null) defaultId = GetId(defaultDevice); ThrowIfFailed(enumerator.EnumAudioEndpoints(EDataFlow.Render, 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 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", "Bitrate", "SignalThreshold") Network = @("NicDescription", "HealthTarget") RTSP = @("Port") Dependencies = @( "AutoInstall", "FFmpegUrl", "FFmpegSha256Url", "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" "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 "Dependencies" "AutoInstall" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) { $AutoInstallDependencies = Convert-IniSwitch $setting.Value "Dependencies.AutoInstall" } $setting = Get-IniSetting $ini "Dependencies" "FFmpegUrl" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegDownloadUrl")) { $FFmpegDownloadUrl = [string]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "FFmpegSha256Url" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegSha256Url")) { $FFmpegSha256Url = [string]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "MediaMTXUrl" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXDownloadUrl")) { $MediaMTXDownloadUrl = [string]$setting.Value } $setting = Get-IniSetting $ini "Dependencies" "MediaMTXSha256" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("MediaMTXSha256")) { $MediaMTXSha256 = [string]$setting.Value } } if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { $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" $TaskName = "Screen RTSP Watchdog" $PowerShellPath = Join-Path $PSHOME "powershell.exe" $RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort" 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", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-ConfigPath", $ConfigPath ) return Join-ProcessArguments $values } function Install-StartupTask { $action = New-ScheduledTaskAction -Execute $PowerShellPath -Argument (Get-StartupArgumentList) ` -WorkingDirectory $BaseDir $trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" $principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" ` -LogonType Interactive -RunLevel Highest $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries ` -DontStopIfGoingOnBatteries -RestartCount 10 -RestartInterval (New-TimeSpan -Minutes 1) ` -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger ` -Principal $principal -Settings $settings -Force | Out-Null Write-Host "자동 실행 작업을 등록했습니다: $TaskName" Write-Host "설정 파일: $ConfigPath" } if ($ShowConfiguration) { [PSCustomObject]@{ ConfigPath = $ConfigPath ConfigLoaded = $ConfigWasLoaded ChannelCount = $ChannelCount StableTimeSec = $StableTimeSec PollIntervalSec = $PollIntervalSec FrameRate = $FrameRate VideoBitrate = $VideoBitrate EncoderPriority = ($EncoderPriority -join " > ") AudioEnabled = $AudioEnabled AudioBitrate = $AudioBitrate AudioSignalThreshold = $AudioSignalThreshold NicDescription = $NicDescription HealthTarget = $HealthTarget RtspPort = $RtspPort AutoInstallDependencies = $AutoInstallDependencies FFmpegDownloadUrl = $FFmpegDownloadUrl FFmpegSha256Url = $FFmpegSha256Url 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 } | 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 Assert-HttpsDependencyUrl { param([string]$Url, [string]$Name) $uri = $null if (-not [Uri]::TryCreate($Url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") { throw "$Name 다운로드 URL은 유효한 HTTPS 주소여야 합니다: $Url" } } function New-DependencyTempDirectory { $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) $path = Join-Path $tempRoot ("screen-rtsp-dependencies-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $path -Force | Out-Null return $path } function Remove-DependencyTempDirectory { param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return } $tempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') + '\' $resolvedPath = [System.IO.Path]::GetFullPath($Path) $leaf = Split-Path -Leaf $resolvedPath if (-not $resolvedPath.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or $leaf -notlike "screen-rtsp-dependencies-*") { throw "안전하지 않은 임시 폴더 삭제 요청을 거부했습니다: $resolvedPath" } if (Test-Path -LiteralPath $resolvedPath) { Remove-Item -LiteralPath $resolvedPath -Recurse -Force } } function Save-DependencyFile { param([string]$Url, [string]$Destination, [string]$Name) Assert-HttpsDependencyUrl -Url $Url -Name $Name Write-Log "$Name 다운로드 시작: $Url" $previousProgressPreference = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination -TimeoutSec 900 } finally { $ProgressPreference = $previousProgressPreference } if (-not (Test-Path -LiteralPath $Destination -PathType Leaf) -or (Get-Item -LiteralPath $Destination).Length -le 0) { throw "$Name 다운로드 결과가 비어 있습니다." } } function Get-RemoteSha256 { param([string]$Url, [string]$Name) Assert-HttpsDependencyUrl -Url $Url -Name "$Name SHA-256" $previousProgressPreference = $ProgressPreference $ProgressPreference = "SilentlyContinue" try { $response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 60 } finally { $ProgressPreference = $previousProgressPreference } $match = [regex]::Match([string]$response.Content, '(?i)\b[0-9a-f]{64}\b') if (-not $match.Success) { throw "$Name SHA-256 응답에서 해시를 찾을 수 없습니다: $Url" } return $match.Value.ToLowerInvariant() } function Install-ZipDependency { param( [string]$Name, [string]$DownloadUrl, [string]$ExpectedSha256, [string]$ExpectedSha256Url, [string]$ExecutableName, [string]$DestinationPath ) $tempDirectory = New-DependencyTempDirectory try { $archivePath = Join-Path $tempDirectory "$Name.zip" $extractPath = Join-Path $tempDirectory "extracted" Save-DependencyFile -Url $DownloadUrl -Destination $archivePath -Name $Name if (-not [string]::IsNullOrWhiteSpace($ExpectedSha256Url)) { $ExpectedSha256 = Get-RemoteSha256 -Url $ExpectedSha256Url -Name $Name } if ($ExpectedSha256 -notmatch '(?i)^[0-9a-f]{64}$') { throw "$Name SHA-256 값이 올바르지 않습니다." } $actualSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "$Name SHA-256 검증 실패: expected=$ExpectedSha256 actual=$actualSha256" } Write-Log "$Name SHA-256 검증 성공" Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force $candidate = Get-ChildItem -LiteralPath $extractPath -Recurse -File | Where-Object Name -eq $ExecutableName | Select-Object -First 1 if (-not $candidate) { throw "$Name 압축 파일에서 $ExecutableName 파일을 찾을 수 없습니다." } Copy-Item -LiteralPath $candidate.FullName -Destination $DestinationPath -Force Unblock-File -LiteralPath $DestinationPath -ErrorAction SilentlyContinue if (-not (Test-Path -LiteralPath $DestinationPath -PathType Leaf)) { throw "$Name 설치에 실패했습니다: $DestinationPath" } Write-Log "$Name 설치 완료: $DestinationPath" } finally { Remove-DependencyTempDirectory -Path $tempDirectory } } function Assert-RequiredFiles { $missing = @() if (-not (Test-Path -LiteralPath $FFmpegPath -PathType Leaf)) { $missing += "ffmpeg.exe" } if (-not (Test-Path -LiteralPath $MediaMTXPath -PathType Leaf)) { $missing += "mediamtx.exe" } if ($missing.Count -eq 0) { return } if ($AutoInstallDependencies -ne 1) { throw "필수 파일이 없고 자동 설치가 꺼져 있습니다: $($missing -join ', ')" } Write-Log "필수 프로그램 자동 설치 시작: $($missing -join ', ')" if ($missing -contains "ffmpeg.exe") { Install-ZipDependency -Name "FFmpeg" -DownloadUrl $FFmpegDownloadUrl ` -ExpectedSha256 "" -ExpectedSha256Url $FFmpegSha256Url ` -ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath } if ($missing -contains "mediamtx.exe") { Install-ZipDependency -Name "MediaMTX" -DownloadUrl $MediaMTXDownloadUrl ` -ExpectedSha256 $MediaMTXSha256 -ExpectedSha256Url "" ` -ExecutableName "mediamtx.exe" -DestinationPath $MediaMTXPath } foreach ($path in @($FFmpegPath, $MediaMTXPath)) { if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "자동 설치 후에도 필수 파일을 찾을 수 없습니다: $path" } } } 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 { $filters = (& $FFmpegPath -hide_banner -filters 2>&1 | Out-String) if ($filters -notmatch '(?m)\bddagrab\b') { throw "현재 FFmpeg에 ddagrab 필터가 없습니다." } $encoders = (& $FFmpegPath -hide_banner -encoders 2>&1 | Out-String) if ($encoders -notmatch '(?m)\bh264_(nvenc|amf|qsv)\b') { throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다." } if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') { throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다." } return $encoders } if ($InstallDependenciesOnly) { Assert-RequiredFiles $null = Test-FFmpegCapabilities $ffmpegVersion = (& $FFmpegPath -hide_banner -version 2>&1 | Select-Object -First 1) $mediaMTXVersion = (& $MediaMTXPath --version 2>&1 | Select-Object -First 1) [PSCustomObject]@{ FFmpegPath = $FFmpegPath FFmpegVersion = $ffmpegVersion MediaMTXPath = $MediaMTXPath MediaMTXVersion = $mediaMTXVersion } | Format-List exit 0 } function Invoke-ProcessWithTimeout { param( [string]$FilePath, [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() if (-not $process.WaitForExit($TimeoutMilliseconds)) { try { $process.Kill() } catch { } return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out" } } $output = $process.StandardOutput.ReadToEnd() + $process.StandardError.ReadToEnd() 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-EncoderDefinition { param([string]$Vendor) switch ($Vendor) { "NVIDIA" { return [PSCustomObject]@{ Vendor = "NVIDIA"; Name = "h264_nvenc" Arguments = @("-preset","p1","-tune","ull","-rc","cbr","-zerolatency","1") } } "AMD" { return [PSCustomObject]@{ Vendor = "AMD"; Name = "h264_amf" Arguments = @("-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1") } } "Intel" { return [PSCustomObject]@{ Vendor = "Intel"; Name = "h264_qsv" Arguments = @("-preset","veryfast","-look_ahead","0","-low_delay_brc","1") } } } } function Test-HardwareEncoder { param($Definition) $arguments = @( "-hide_banner", "-loglevel", "error", "-nostdin", "-f", "lavfi", "-i", "color=c=black:s=128x128:r=30:d=0.1", "-frames:v", "1", "-vf", "format=nv12", "-c:v", $Definition.Name ) + $Definition.Arguments + @("-b:v", "1000k", "-f", "null", "NUL") $result = Invoke-ProcessWithTimeout -FilePath $FFmpegPath -ArgumentList $arguments ` -TimeoutMilliseconds 8000 return ($result.ExitCode -eq 0) } function Select-HardwareEncoder { param([string]$EncoderList) $inventory = @(Get-GpuInventory) foreach ($vendor in $EncoderPriority) { if (-not ($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" })) { continue } $definition = Get-EncoderDefinition $vendor if ($EncoderList -notmatch [regex]::Escape($definition.Name)) { continue } Write-Log "하드웨어 인코더 시험: $vendor / $($definition.Name)" if (Test-HardwareEncoder $definition) { $gpuNames = (($inventory | Where-Object Vendor -eq $vendor).Name -join ", ") Write-Log "하드웨어 인코더 선택: $($definition.Name) ($gpuNames)" return $definition } Write-Log "하드웨어 인코더 사용 불가: $($definition.Name)" } throw "감지된 GPU에서 사용할 수 있는 H.264 하드웨어 인코더가 없습니다." } 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()) } $script:SelectedAudioEndpointId = $null function Select-AudioEndpoint { param($Endpoints) if ($Endpoints.Count -eq 0) { $script:SelectedAudioEndpointId = $null return $null } $current = $null if ($script:SelectedAudioEndpointId) { $current = $Endpoints | Where-Object Id -eq $script:SelectedAudioEndpointId | Select-Object -First 1 } $audible = @($Endpoints | Where-Object { $_.Peak -ge $AudioSignalThreshold } | Sort-Object Peak -Descending) if ($audible.Count -gt 0) { if ($current -and $current.Peak -ge $AudioSignalThreshold) { return $current } $script:SelectedAudioEndpointId = $audible[0].Id return $audible[0] } $default = $Endpoints | Where-Object IsDefault | Select-Object -First 1 if ($default) { $script:SelectedAudioEndpointId = $default.Id return $default } if ($current) { return $current } $script:SelectedAudioEndpointId = $Endpoints[0].Id return $Endpoints[0] } function Get-AudioSignature { param($Endpoint) if (-not $Endpoint) { return "AUDIO_MISSING" } return "{0}:{1}:{2}:{3}:{4}" -f $Endpoint.Id,$Endpoint.SampleRate,$Endpoint.Channels, $Endpoint.BitsPerSample,$Endpoint.SampleFormat } 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 = @{} 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 = @{} } 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([int]$Channel, $Endpoint, [string]$PipeName) $arguments = @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath, "-AudioWorker", "-AudioEndpointId", $Endpoint.Id, "-AudioPipeName", $PipeName ) $errorLog = Join-Path $LogDir "monitor$Channel-audio.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 Start-Stream { param( [int]$Channel, $Screen, $Endpoint, $Encoder ) $ddaInput = "ddagrab=output_idx=$($Screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1" $filters = @("hwdownload", "format=bgra") $rotationFilter = Get-RotationFilter $Screen.Orientation if ($rotationFilter) { $filters += $rotationFilter.Split(',') } $filters += @("setpts=N/(${FrameRate}*TB)", "format=nv12") $videoFilter = $filters -join "," $rtspUrl = "$RTSPPublishBase/monitor$Channel" $logFile = Join-Path $LogDir "monitor$Channel-ffmpeg.log" $arguments = @( "-hide_banner", "-loglevel", "warning", "-nostdin", "-f", "lavfi", "-i", $ddaInput ) if ($AudioEnabled -eq 1) { $pipeName = "screen-rtsp-{0}-{1}-{2}" -f $PID,$Channel,([Guid]::NewGuid().ToString("N")) $audioProcess = Start-AudioCaptureWorker -Channel $Channel -Endpoint $Endpoint -PipeName $pipeName $script:AudioProcesses["monitor$Channel"] = $audioProcess Wait-AudioPipe -Process $audioProcess -PipeName $pipeName $pipePath = "\\.\pipe\$pipeName" $arguments += @( "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat, "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, "-map", "0:v:0", "-map", "1:a:0" ) } else { $arguments += @("-map", "0:v:0") } $arguments += @("-vf", $videoFilter, "-c:v", $Encoder.Name) $arguments += $Encoder.Arguments $arguments += @( "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12" ) if ($AudioEnabled -eq 1) { $arguments += @( "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2", "-af", "aresample=async=1000:first_pts=0" ) } $arguments += @( "-f", "rtsp", "-rtsp_transport", "tcp", $rtspUrl ) $audioDescription = if ($AudioEnabled -eq 1) { $Endpoint.Name } else { "사용 안 함" } Write-Log ("monitor{0} 시작: DDA output_idx={1}, {2}x{3}, 회전={4}, {5}, 오디오={6}" -f ` $Channel,$Screen.OutputIndex,$Screen.Width,$Screen.Height,$Screen.Orientation, $Encoder.Name,$audioDescription) return Start-Process -FilePath $FFmpegPath -ArgumentList (Join-ProcessArguments $arguments) ` -WorkingDirectory $BaseDir -WindowStyle Hidden -RedirectStandardError $logFile -PassThru } function Start-Streams { param($Screens, $Network, $Endpoint, $Encoder) $selected = @(Select-StreamingScreens $Screens) if ($selected.Count -lt $ChannelCount) { return } Write-Log "RTSP $ChannelCount 채널 시작" if ($AudioEnabled -eq 1) { Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" } else { Write-Log "오디오 캡처: 사용 안 함" } for ($index = 0; $index -lt $ChannelCount; $index++) { $channel = $index + 1 $process = Start-Stream -Channel $channel -Screen $selected[$index] ` -Endpoint $Endpoint -Encoder $Encoder $script:StreamProcesses["monitor$channel"] = $process Start-Sleep -Milliseconds 250 } for ($channel = 1; $channel -le $ChannelCount; $channel++) { Write-Log "수신 URL: rtsp://$($Network.IPv4.IPAddress):$RtspPort/monitor$channel" } } function Test-StreamsRunning { $expectedAudioProcessCount = if ($AudioEnabled -eq 1) { $ChannelCount } else { 0 } if ($StreamProcesses.Count -ne $ChannelCount -or $AudioProcesses.Count -ne $expectedAudioProcessCount) { return $false } foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { if (-not (Test-ProcessRunning $process)) { return $false } } return $true } function Get-StreamingReadinessReason { param($Screens, $Network, $Endpoint) $capturableScreenCount = @($Screens | Where-Object { $_.OutputIndex -ge 0 }).Count if ($Screens.Count -lt $ChannelCount) { return "활성 모니터 $ChannelCount 개 미만" } if ($capturableScreenCount -lt $ChannelCount) { return "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" } $networkReason = Test-NetworkHealth $Network if ($networkReason) { return $networkReason } if ($AudioEnabled -eq 1 -and -not $Endpoint) { return "활성 오디오 출력 장치 없음" } return $null } function Invoke-SimulationTests { $results = New-Object 'System.Collections.Generic.List[object]' function Invoke-SimulationCase { param([string]$Name, [scriptblock]$Body) try { $passed = [bool](& $Body) $detail = if ($passed) { "PASS" } else { "조건 결과가 False입니다." } } catch { $passed = $false $detail = $_.Exception.Message } $results.Add([PSCustomObject]@{ Number = $results.Count + 1 Name = $Name Passed = $passed Detail = $detail }) } function New-SimulationIni { param([string]$Content) $path = Join-Path ([System.IO.Path]::GetTempPath()) ` ("screen-rtsp-simulation-" + [Guid]::NewGuid().ToString("N") + ".ini") [System.IO.File]::WriteAllText($path, $Content, (New-Object System.Text.UTF8Encoding($false))) return $path } $originalChannelCount = $script:ChannelCount $originalSelectedAudioEndpointId = $script:SelectedAudioEndpointId $originalHealthTarget = $script:HealthTarget $originalAudioEnabled = $script:AudioEnabled $originalStreamProcesses = $script:StreamProcesses $originalAudioProcesses = $script:AudioProcesses try { $script:ChannelCount = 2 $script:HealthTarget = "" Invoke-SimulationCase "INI 주석(#, ;, //)과 따옴표 값 파싱" { $path = New-SimulationIni "# one`r`n; two`r`n// three`r`n[Audio]`r`nEnabled=0`r`nBitrate=`"128k`"`r`nSignalThreshold=0.1" try { $parsed = Read-IniConfiguration $path return ($parsed.Audio.Enabled -eq "0" -and $parsed.Audio.Bitrate -eq "128k") } finally { [System.IO.File]::Delete($path) } } Invoke-SimulationCase "알 수 없는 INI 설정 거부" { $path = New-SimulationIni "[Audio]`r`nUnknownOption=1" try { $parsed = Read-IniConfiguration $path try { Assert-IniSettings $parsed; return $false } catch { return $true } } finally { [System.IO.File]::Delete($path) } } Invoke-SimulationCase "기능 스위치 1/0 허용" { return ((Convert-IniSwitch "1" "test") -eq 1 -and (Convert-IniSwitch "0" "test") -eq 0) } Invoke-SimulationCase "기능 스위치 2 거부" { try { $null = Convert-IniSwitch "2" "test"; return $false } catch { return $true } } $screenPrimary = [PSCustomObject]@{ DeviceName="DISPLAY1"; Primary=$true; X=0; Y=0; Width=2560; Height=1440 Orientation=0; OutputIndex=0; DxgiRotation=1 } $screenSecond = [PSCustomObject]@{ DeviceName="DISPLAY2"; Primary=$false; X=2560; Y=0; Width=1080; Height=1920 Orientation=3; OutputIndex=1; DxgiRotation=4 } $screenThird = [PSCustomObject]@{ DeviceName="DISPLAY3"; Primary=$false; X=-1920; Y=0; Width=1920; Height=1080 Orientation=0; OutputIndex=2; DxgiRotation=1 } Invoke-SimulationCase "모니터 1대이면 2채널 시작 보류" { return (@(Select-StreamingScreens @($screenPrimary)).Count -eq 0) } Invoke-SimulationCase "모니터 2대이면 Primary가 monitor1" { $selected = @(Select-StreamingScreens @($screenSecond,$screenPrimary)) return ($selected.Count -eq 2 -and $selected[0].DeviceName -eq "DISPLAY1") } Invoke-SimulationCase "모니터 3대여도 설정된 2대만 선택" { $selected = @(Select-StreamingScreens @($screenPrimary,$screenSecond,$screenThird)) return ($selected.Count -eq 2 -and $selected[0].Primary -and $selected[1].DeviceName -eq "DISPLAY3") } Invoke-SimulationCase "D3D11에 매핑되지 않은 모니터가 있으면 시작 보류" { $unmapped = $screenSecond.PSObject.Copy() $unmapped.OutputIndex = -1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"} IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } $script:AudioEnabled = 0 return ((Get-StreamingReadinessReason @($screenPrimary,$unmapped) $network $null) -like "기본 D3D11 adapter*") } Invoke-SimulationCase "해상도 변경 signature 감지" { $changed = $screenPrimary.PSObject.Copy() $changed.Width = 1920 return ((Get-ScreenSignature @($screenPrimary)) -ne (Get-ScreenSignature @($changed))) } Invoke-SimulationCase "위치/Primary/회전 변경 signature 감지" { $changed = $screenSecond.PSObject.Copy() $changed.X = -1080 $changed.Primary = $true $changed.Orientation = 1 return ((Get-ScreenSignature @($screenSecond)) -ne (Get-ScreenSignature @($changed))) } Invoke-SimulationCase "세로 모니터 회전 필터 선택" { return ((Get-RotationFilter 1) -eq "transpose=clock" -and (Get-RotationFilter 2) -eq "hflip,vflip" -and (Get-RotationFilter 3) -eq "transpose=cclock" -and $null -eq (Get-RotationFilter 0)) } $defaultAudio = [PSCustomObject]@{ Id="default"; Name="Default"; IsDefault=$true; Peak=0.0 SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" } $audibleAudio = [PSCustomObject]@{ Id="audible"; Name="HDMI"; IsDefault=$false; Peak=0.2 SampleRate=48000; Channels=2; BitsPerSample=32; SampleFormat="f32le" } Invoke-SimulationCase "실제 peak가 있는 오디오 장치 우선" { $script:SelectedAudioEndpointId = $null return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") } Invoke-SimulationCase "모든 장치 무음이면 기본 출력 선택" { $script:SelectedAudioEndpointId = $null $audibleAudio.Peak = 0.0 return ((Select-AudioEndpoint @($audibleAudio,$defaultAudio)).Id -eq "default") } Invoke-SimulationCase "현재 출력 장치에 신호가 있으면 선택 유지" { $audibleAudio.Peak = 0.1 $defaultAudio.Peak = 0.3 $script:SelectedAudioEndpointId = "audible" return ((Select-AudioEndpoint @($defaultAudio,$audibleAudio)).Id -eq "audible") } Invoke-SimulationCase "오디오 mix format 변경 signature 감지" { $changedAudio = $defaultAudio.PSObject.Copy() $changedAudio.SampleRate = 44100 return ((Get-AudioSignature $defaultAudio) -ne (Get-AudioSignature $changedAudio)) } Invoke-SimulationCase "NIC 부재 signature" { $network = [PSCustomObject]@{ Adapter=$null; IPv4=$null } return ((Get-NetworkSignature $network) -eq "NIC_MISSING") } Invoke-SimulationCase "NIC IPv4 변경 signature 감지" { $adapter = [PSCustomObject]@{ InterfaceGuid="guid"; ifIndex=8; Status="Up"; LinkSpeed="1 Gbps" } $network1 = [PSCustomObject]@{ Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.34"} } $network2 = [PSCustomObject]@{ Adapter=$adapter; IPv4=[PSCustomObject]@{IPAddress="192.168.50.35"} } return ((Get-NetworkSignature $network1) -ne (Get-NetworkSignature $network2)) } Invoke-SimulationCase "NIC가 없으면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{Adapter=$null; IPv4=$null} return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "활성 네트워크 어댑터 없음") } Invoke-SimulationCase "NIC Link Down이면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Disconnected"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "네트워크 Link Down") } Invoke-SimulationCase "NIC IPv4가 없으면 송출 시작 보류" { $script:AudioEnabled = 0 $network = [PSCustomObject]@{Adapter=[PSCustomObject]@{Status="Up"}; IPv4=$null} return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "네트워크 IPv4 없음") } Invoke-SimulationCase "오디오 사용 중 endpoint가 없으면 시작 보류" { $script:AudioEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ((Get-StreamingReadinessReason @($screenPrimary,$screenSecond) $network $null) -eq "활성 오디오 출력 장치 없음") } Invoke-SimulationCase "모니터/NIC/오디오가 정상이면 시작 가능" { $script:AudioEnabled = 1 $network = [PSCustomObject]@{ Adapter=[PSCustomObject]@{Status="Up"}; IPv4=[PSCustomObject]@{IPAddress="192.168.1.10"} } return ($null -eq (Get-StreamingReadinessReason ` @($screenPrimary,$screenSecond) $network $defaultAudio)) } Invoke-SimulationCase "GPU별 H.264 인코더 매핑" { return ((Get-EncoderDefinition "NVIDIA").Name -eq "h264_nvenc" -and (Get-EncoderDefinition "AMD").Name -eq "h264_amf" -and (Get-EncoderDefinition "Intel").Name -eq "h264_qsv") } Invoke-SimulationCase "자동 실행은 config.ini 경로만 전달" { $arguments = Get-StartupArgumentList return ($arguments -match '-ConfigPath' -and $arguments -notmatch '-VideoBitrate') } Invoke-SimulationCase "2채널 FFmpeg/오디오 worker가 모두 생존" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 1 $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} return (Test-StreamsRunning) } Invoke-SimulationCase "FFmpeg 하나가 종료되면 전체 재시작 필요" { $alive = [PSCustomObject]@{HasExited=$false} $dead = [PSCustomObject]@{HasExited=$true} $script:AudioEnabled = 1 $script:StreamProcesses = @{monitor1=$alive; monitor2=$dead} $script:AudioProcesses = @{monitor1=$alive; monitor2=$alive} return (-not (Test-StreamsRunning)) } Invoke-SimulationCase "오디오 OFF이면 worker 없이 2채널 정상" { $alive = [PSCustomObject]@{HasExited=$false} $script:AudioEnabled = 0 $script:StreamProcesses = @{monitor1=$alive; monitor2=$alive} $script:AudioProcesses = @{} return (Test-StreamsRunning) } Invoke-SimulationCase "비 HTTPS 의존성 URL 거부" { try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false } catch { return $true } } Invoke-SimulationCase "자동 설치 OFF에서 파일 부재 오류" { $savedFFmpegPath = $script:FFmpegPath $savedMediaMTXPath = $script:MediaMTXPath $savedAutoInstall = $script:AutoInstallDependencies try { $script:FFmpegPath = Join-Path $BaseDir "definitely-missing-ffmpeg.exe" $script:MediaMTXPath = Join-Path $BaseDir "definitely-missing-mediamtx.exe" $script:AutoInstallDependencies = 0 try { Assert-RequiredFiles; return $false } catch { return $true } } finally { $script:FFmpegPath = $savedFFmpegPath $script:MediaMTXPath = $savedMediaMTXPath $script:AutoInstallDependencies = $savedAutoInstall } } Invoke-SimulationCase "안전 범위 밖 임시 폴더 삭제 거부" { try { Remove-DependencyTempDirectory ([System.IO.Path]::GetTempPath()); return $false } catch { return $true } } } finally { $script:ChannelCount = $originalChannelCount $script:SelectedAudioEndpointId = $originalSelectedAudioEndpointId $script:HealthTarget = $originalHealthTarget $script:AudioEnabled = $originalAudioEnabled $script:StreamProcesses = $originalStreamProcesses $script:AudioProcesses = $originalAudioProcesses } return $results.ToArray() } if ($RunSimulationTests) { $simulationResults = @(Invoke-SimulationTests) $simulationResults | Format-Table Number,Passed,Name,Detail -AutoSize $passedCount = @($simulationResults | Where-Object Passed).Count $failedCount = $simulationResults.Count - $passedCount Write-Host "Simulation summary: total=$($simulationResults.Count), passed=$passedCount, failed=$failedCount" if ($failedCount -gt 0) { exit 1 } exit 0 } $mutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog") $hasMutex = $false try { $hasMutex = $mutex.WaitOne(0, $false) if (-not $hasMutex) { throw "Screen RTSP Watchdog가 이미 실행 중입니다." } Assert-RequiredFiles Ensure-MediaMTXConfig $encoderList = Test-FFmpegCapabilities $encoder = Select-HardwareEncoder -EncoderList $encoderList $gpuSignature = Get-GpuSignature 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 ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } $lastSystemSignature = "" $healthySince = $null $lastReason = "" $lastGpuCheck = [DateTime]::MinValue while ($true) { try { $mediaReady = Start-MediaMTX if (-not $mediaReady) { Stop-Streams $healthySince = $null Start-Sleep -Seconds $PollIntervalSec continue } $screens = @(Get-ActiveScreens) if ($AudioEnabled -eq 1) { $endpoints = @(Get-AudioEndpoints) $endpoint = Select-AudioEndpoint $endpoints $audioSignature = Get-AudioSignature $endpoint } else { $endpoints = @() $endpoint = $null $audioSignature = "AUDIO_DISABLED" } $network = Get-NetworkState if (((Get-Date) - $lastGpuCheck).TotalSeconds -ge 10) { $currentGpuSignature = Get-GpuSignature $lastGpuCheck = Get-Date if ($currentGpuSignature -ne $gpuSignature) { Write-Log "GPU 구성 변경 감지" Stop-Streams $encoder = Select-HardwareEncoder -EncoderList $encoderList $gpuSignature = $currentGpuSignature $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 ` -Endpoint $endpoint if ($reason) { if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" } $lastReason = $reason Stop-Streams $healthySince = $null 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 또는 보조 프로세스 비정상 종료 감지" 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 -Endpoint $endpoint -Encoder $encoder $healthySince = $null } } } catch { Write-Log "감시 루프 예외: $($_.Exception.Message)" Stop-Streams $healthySince = $null } Start-Sleep -Seconds $PollIntervalSec } } catch { Write-Log "종료 오류: $($_.Exception.Message)" exit 1 } finally { Stop-Streams if ($OwnsMediaMTX -and (Test-ProcessRunning $MediaMTXProcess)) { Stop-Process -Id $MediaMTXProcess.Id -Force -ErrorAction SilentlyContinue } if ($hasMutex) { try { $mutex.ReleaseMutex() } catch { } } if ($mutex) { $mutex.Dispose() } }