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