#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, [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, [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") 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" "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 "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", "-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, "-AutoInstallDependencies", [string]$AutoInstallDependencies, "-FFmpegDownloadUrl", $FFmpegDownloadUrl, "-FFmpegSha256Url", $FFmpegSha256Url, "-MediaMTXDownloadUrl", $MediaMTXDownloadUrl, "-MediaMTXSha256", $MediaMTXSha256 ) 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 ($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 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 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 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 인코더가 없습니다." } if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) { $devices = (& $FFmpegPath -hide_banner -devices 2>&1 | Out-String) if ($devices -notmatch '(?m)\bdshow\b') { throw "마이크 캡처가 켜져 있지만 현재 FFmpeg에 DirectShow(dshow) 입력이 없습니다." } } return $encoders } if ($InstallDependenciesOnly) { Assert-RequiredFiles $null = Test-FFmpegCapabilities $ffmpegVersion = (& $FFmpegPath -hide_banner -version 2>&1 | Select-Object -First 1) $mediaMTXVersion = (& $MediaMTXPath --version 2>&1 | Select-Object -First 1) [PSCustomObject]@{ FFmpegPath = $FFmpegPath FFmpegVersion = $ffmpegVersion MediaMTXPath = $MediaMTXPath MediaMTXVersion = $mediaMTXVersion } | Format-List exit 0 } 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()) } 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 = @{} 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($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" ) 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가 실행 직후 종료되었습니다." } } 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 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 설정 거부" { $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 "자동 실행은 숨김 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 '-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 | 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 "비 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 ($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가 이미 실행 중입니다." } Register-ObjectEvent -InputObject ([Microsoft.Win32.SystemEvents]) ` -EventName PowerModeChanged -SourceIdentifier $powerEventSourceIdentifier | Out-Null $powerEventRegistered = $true 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 ($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" } $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) { $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 ` -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 또는 보조 프로세스 비정상 종료 감지" 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() } }