commit 5b15b065700b1e75ea6c2f5dadca627f1131ea2e Author: seo-amugae Date: Thu Oct 8 00:12:42 2026 +0900 Add Windows multi-monitor RTSP watchdog diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42c252c --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Runtime dependencies are distributed separately. +/ffmpeg.exe +/mediamtx.exe + +# Generated runtime files. +/mediamtx.yml +/logs/ +/*.log + +# Local certificates and secrets. +/*.crt +/*.key +/*.pfx +/*.p12 + +# Editor and OS files. +/.vscode/ +/.idea/ +Thumbs.db +Desktop.ini +*.bak +*.tmp diff --git a/README.md b/README.md new file mode 100644 index 0000000..014e2b9 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# RTSP + +Windows의 활성 모니터를 독립 RTSP 채널로 송출하고, 실제 재생 신호가 있는 오디오 출력 장치를 자동 선택해 시스템 소리를 함께 전송하는 PowerShell 기반 watchdog입니다. + +## 프로젝트 성격 + +RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서를 캡처하고, 사용 가능한 GPU 하드웨어 인코더를 실제 초기화 시험한 뒤 FFmpeg와 MediaMTX를 관리합니다. + +특정 노트북의 NIC 이름이나 `ifIndex`에 고정되지 않는 범용 구성이 기본입니다. 필요한 환경에서는 `-NicDescription`과 `-HealthTarget`을 함께 지정해 특정 NIC의 Link, IPv4, Source IP 통신까지 송출 조건으로 강제할 수 있습니다. + +## 주요 기능 + +- Windows PowerShell 5.1 지원 +- 활성 모니터별 독립 RTSP 채널 제공 +- Desktop Duplication API `ddagrab` 화면 캡처 +- 전체 화면과 마우스 커서 포함 +- 모니터 연결, 해제, 좌표, 해상도, 회전, Primary 변경 감지 +- NVIDIA NVENC, AMD AMF, Intel QSV 자동 감지 +- GPU 존재 여부와 FFmpeg 1프레임 초기화 시험을 함께 사용한 인코더 선택 +- 기본 우선순위 `NVIDIA > AMD > Intel`, 실행 인자로 변경 가능 +- Windows Core Audio WASAPI loopback 기반 시스템 재생음 캡처 +- 실제 오디오 peak가 있는 출력 장치 우선 선택 +- 무음 상태에서는 기본 출력 장치를 유지해 송출이 불필요하게 끊기지 않음 +- 기본 출력 장치, 재생 장치, 오디오 포맷 변경 시 전체 채널 재시작 +- 기본 30fps, 영상 8Mbps, AAC 오디오 192kbps +- NIC Link, IPv4, `ifIndex`, IP 변경 감지 +- 선택적 Source IP 고정 ping 상태 검사 +- 3초간 정상 상태가 유지된 뒤 전체 채널 시작 +- FFmpeg 또는 오디오 worker 하나가 종료되면 전체 채널 재시작 +- MediaMTX 종료 시 자동 재시작 +- 기존 MediaMTX가 RTSP 포트를 사용 중이면 해당 프로세스 재사용 +- 자동 생성 MediaMTX 설정은 RTSP over TCP만 사용해 UDP RTP 포트 충돌 방지 +- 로그인 시 자동 실행 작업 등록 및 제거 + +## RTSP URL + +기본 2채널 구성은 다음 URL을 유지합니다. + +```text +rtsp://<현재_NIC_IP>:8554/monitor1 +rtsp://<현재_NIC_IP>:8554/monitor2 +``` + +## 구성 + +- `screen-rtsp.ps1`: 캡처, 오디오 loopback, GPU 선택, MediaMTX/FFmpeg watchdog +- `ffmpeg.exe`: `ddagrab`, `h264_nvenc`, `h264_amf`, `h264_qsv`, `aac` 지원 FFmpeg +- `mediamtx.exe`: RTSP 서버. 현재 검증 기준 버전은 v1.21.1 +- `mediamtx.yml`: 없으면 스크립트가 기본 설정을 자동 생성 +- `logs/watchdog.log`: watchdog 상태 로그 +- `logs/monitor1-ffmpeg.log`: monitor1 FFmpeg 로그 +- `logs/monitor2-ffmpeg.log`: monitor2 FFmpeg 로그 +- `logs/monitor1-audio.log`, `logs/monitor2-audio.log`: WASAPI loopback worker 오류 로그 + +실행 파일과 생성 파일은 저장소에 포함하지 않습니다. 배포할 때 `ffmpeg.exe`와 `mediamtx.exe`를 `screen-rtsp.ps1` 옆에 둡니다. + +## 처리 흐름 + +1. FFmpeg의 `ddagrab`과 H.264 하드웨어 인코더 포함 여부를 확인합니다. +2. 설치된 GPU를 `NVIDIA > AMD > Intel` 순서로 찾고 각 인코더를 실제 1프레임으로 초기화합니다. +3. 활성 모니터, IPv4 기본 경로 NIC, 활성 오디오 출력 장치를 읽습니다. +4. Core Audio peak가 감지된 출력 장치를 우선하고, 신호가 없으면 Windows 기본 출력 장치를 사용합니다. +5. 모든 조건이 3초 동안 유지되면 채널마다 WASAPI loopback worker와 FFmpeg를 시작합니다. +6. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다. +7. 시스템 소리는 named pipe를 통해 FFmpeg로 전달하고 AAC로 인코딩합니다. +8. 모니터, NIC, GPU, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 새 구성을 읽어 재시작합니다. + +## 설치 및 실행 + +관리자 PowerShell에서 실행 정책을 우회해 시작합니다. + +```powershell +Set-Location C:\RTSP +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 +``` + +로그인 자동 실행은 대화형 사용자 세션에 예약 작업을 등록합니다. Desktop Duplication과 사용자 재생 오디오를 캡처해야 하므로 Windows 서비스가 아니라 로그인 트리거를 사용합니다. + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -InstallStartup +``` + +자동 실행 제거: + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\screen-rtsp.ps1 -UninstallStartup +``` + +## 실행 옵션 + +GPU 우선순위를 바꾸는 예시: + +```powershell +.\screen-rtsp.ps1 -EncoderPriority Intel,NVIDIA,AMD +``` + +채널 수와 영상 설정을 바꾸는 예시: + +```powershell +.\screen-rtsp.ps1 -ChannelCount 2 -FrameRate 30 -VideoBitrate 8000k -AudioBitrate 192k +``` + +특정 ASIX NIC와 Gateway 통신을 필수 조건으로 사용하는 노트북 예시: + +```powershell +.\screen-rtsp.ps1 ` + -NicDescription "ASIX USB to Gigabit Ethernet Family Adapter" ` + -HealthTarget "192.168.50.1" +``` + +이 모드에서도 NIC 이름이나 `ifIndex`가 아니라 `InterfaceDescription`으로 매번 다시 검색합니다. Source IP ping은 검색된 NIC의 현재 IPv4를 사용합니다. + +## 오디오 장치 선택 정책 + +스크립트는 활성 상태의 Windows Render endpoint를 모두 읽고 각 장치의 peak를 확인합니다. + +- 현재 선택 장치에 실제 신호가 계속 있으면 해당 장치를 유지합니다. +- 다른 활성 장치에서만 신호가 감지되면 해당 장치로 전환합니다. +- 모든 장치가 무음이면 Windows 기본 Multimedia 출력 장치를 선택합니다. +- 선택 장치가 제거되거나 기본 장치와 mix format이 바뀌면 전체 RTSP 채널을 재시작합니다. + +이 정책은 음악이나 알림 사이의 무음 구간마다 송출이 중단되는 것을 막으면서, Bluetooth/HDMI/USB 오디오처럼 실제 재생 장치가 바뀌는 상황을 자동 반영합니다. + +## 운영 체크포인트 + +- `ddagrab`은 대화형 데스크톱 세션이 필요합니다. 사용자가 로그인하지 않은 부팅 직후 세션에서는 화면과 사용자 오디오를 캡처할 수 없습니다. +- GPU 이름만으로 인코더를 확정하지 않습니다. 드라이버 또는 세션 문제로 초기화가 실패하면 다음 우선순위 GPU를 시험합니다. +- 여러 GPU에 모니터가 물린 특수 구성에서는 FFmpeg가 사용하는 D3D11 adapter의 출력 순서를 확인해야 합니다. +- RTSP 포트를 MediaMTX가 아닌 다른 프로세스가 사용하면 충돌 프로세스를 종료하지 않고 송출을 대기합니다. +- 인증이 없는 기본 MediaMTX 설정은 신뢰된 LAN에서만 사용하고, 외부 네트워크에 노출할 때는 MediaMTX 인증과 방화벽 정책을 추가합니다. + +## 저장소 정책 + +FFmpeg와 MediaMTX 바이너리, 로그, 자동 생성 설정, 인증서와 키는 Git에 올리지 않습니다. 비밀번호나 토큰도 스크립트와 README에 기록하지 않습니다. diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 new file mode 100644 index 0000000..1c0d13f --- /dev/null +++ b/screen-rtsp.ps1 @@ -0,0 +1,1422 @@ +#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", + + [string]$NicDescription = "", + [string]$HealthTarget = "", + + [ValidateRange(1, 60)] + [int]$StableTimeSec = 3, + + [ValidateRange(1, 60)] + [int]$PollIntervalSec = 1, + + [ValidateRange(1, 65535)] + [int]$RtspPort = 8554, + + [switch]$InstallStartup, + [switch]$UninstallStartup, + + [Parameter(DontShow = $true)] + [switch]$AudioWorker, + + [Parameter(DontShow = $true)] + [string]$AudioEndpointId, + + [Parameter(DontShow = $true)] + [string]$AudioPipeName +) + +$ErrorActionPreference = "Stop" + +if (-not [string]::IsNullOrWhiteSpace($EncoderPriorityCsv)) { + $EncoderPriority = @($EncoderPriorityCsv -split ',' | ForEach-Object { $_.Trim() }) + $invalidEncoderVendor = $EncoderPriority | Where-Object { $_ -notin @("NVIDIA", "AMD", "Intel") } + if ($invalidEncoderVendor) { + throw "잘못된 EncoderPriorityCsv 값: $($invalidEncoderVendor -join ', ')" + } +} + +# ============================================================ +# 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]; + while (pipe.IsConnected) + { + uint packetFrames; + ThrowIfFailed(capture.GetNextPacketSize(out packetFrames), + "IAudioCaptureClient.GetNextPacketSize"); + + if (packetFrames == 0) + { + Thread.Sleep(3); + 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); + } + 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 +} + +$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" +$AudioSignalThreshold = 0.0005 + +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, + "-ChannelCount", $ChannelCount, + "-FrameRate", $FrameRate, + "-VideoBitrate", $VideoBitrate, + "-AudioBitrate", $AudioBitrate, + "-StableTimeSec", $StableTimeSec, + "-PollIntervalSec", $PollIntervalSec, + "-RtspPort", $RtspPort, + "-EncoderPriorityCsv", ($EncoderPriority -join ",") + ) + if (-not [string]::IsNullOrWhiteSpace($NicDescription)) { + $values += @("-NicDescription", $NicDescription) + } + if (-not [string]::IsNullOrWhiteSpace($HealthTarget)) { + $values += @("-HealthTarget", $HealthTarget) + } + 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" +} + +if ($InstallStartup) { + Install-StartupTask + exit 0 +} + +if ($UninstallStartup) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Host "자동 실행 작업을 제거했습니다: $TaskName" + 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-RequiredFiles { + 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 하드웨어 인코더가 없습니다." + } + return $encoders +} + +function Invoke-ProcessWithTimeout { + param( + [string]$FilePath, + [string[]]$ArgumentList, + [int]$TimeoutMilliseconds = 10000 + ) + $startInfo = New-Object System.Diagnostics.ProcessStartInfo + $startInfo.FileName = $FilePath + $startInfo.Arguments = Join-ProcessArguments $ArgumentList + $startInfo.WorkingDirectory = $BaseDir + $startInfo.UseShellExecute = $false + $startInfo.CreateNoWindow = $true + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $startInfo + [void]$process.Start() + 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 +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 + ) + $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 + + $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" + $pipePath = "\\.\pipe\$pipeName" + + $arguments = @( + "-hide_banner", "-loglevel", "warning", "-nostdin", + "-f", "lavfi", "-i", $ddaInput, + "-thread_queue_size", "1024", "-f", $Endpoint.SampleFormat, + "-ar", $Endpoint.SampleRate, "-ac", $Endpoint.Channels, "-i", $pipePath, + "-map", "0:v:0", "-map", "1:a:0", + "-vf", $videoFilter, + "-c:v", $Encoder.Name + ) + $Encoder.Arguments + @( + "-b:v", $VideoBitrate, "-maxrate", $VideoBitrate, "-bufsize", $VideoBitrate, + "-g", $FrameRate, "-bf", "0", "-pix_fmt", "nv12", + "-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2", + "-af", "aresample=async=1000:first_pts=0", + "-f", "rtsp", "-rtsp_transport", "tcp", $rtspUrl + ) + + 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,$Endpoint.Name) + + 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 채널 시작" + Write-Log "오디오 출력 장치: $($Endpoint.Name) (peak=$([Math]::Round($Endpoint.Peak, 4)))" + 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 { + if ($StreamProcesses.Count -ne $ChannelCount -or $AudioProcesses.Count -ne $ChannelCount) { + return $false + } + foreach ($process in @($StreamProcesses.Values) + @($AudioProcesses.Values)) { + if (-not (Test-ProcessRunning $process)) { return $false } + } + return $true +} + +$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 시작" + Write-Log "인코더 우선순위: $($EncoderPriority -join ' > ')" + 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) + $endpoints = @(Get-AudioEndpoints) + $endpoint = Select-AudioEndpoint $endpoints + $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) + "||" + (Get-AudioSignature $endpoint) + + "||" + $encoder.Name + + if ($systemSignature -ne $lastSystemSignature) { + if ($lastSystemSignature) { + Write-Log "모니터, NIC, 오디오 또는 GPU 구성 변경 감지" + Stop-Streams + } + $lastSystemSignature = $systemSignature + $healthySince = $null + } + + $reason = $null + $capturableScreenCount = @($screens | Where-Object { $_.OutputIndex -ge 0 }).Count + if ($screens.Count -lt $ChannelCount) { + $reason = "활성 모니터 $ChannelCount 개 미만" + } + elseif ($capturableScreenCount -lt $ChannelCount) { + $reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만" + } + if (-not $reason) { $reason = Test-NetworkHealth $network } + if (-not $reason -and -not $endpoint) { $reason = "활성 오디오 출력 장치 없음" } + + 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 또는 오디오 loopback worker 비정상 종료 감지" + 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() } +}