1423 lines
52 KiB
PowerShell
1423 lines
52 KiB
PowerShell
#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<EndpointInfo> result = new List<EndpointInfo>();
|
|
|
|
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<T>(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<DdaOutputInfo>();
|
|
|
|
try
|
|
{
|
|
int hr = CreateDXGIFactory1(ref factoryIid, out factory);
|
|
if (hr < 0) Marshal.ThrowExceptionForHR(hr);
|
|
|
|
EnumAdaptersDelegate enumAdapters = GetVtableDelegate<EnumAdaptersDelegate>(factory, 7);
|
|
hr = enumAdapters(factory, 0, out adapter);
|
|
if (hr < 0) Marshal.ThrowExceptionForHR(hr);
|
|
|
|
EnumOutputsDelegate enumOutputs = GetVtableDelegate<EnumOutputsDelegate>(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<GetDescDelegate>(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() }
|
|
}
|