3983 lines
163 KiB
PowerShell
3983 lines
163 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,
|
|
|
|
[ValidateRange(1, 3600)]
|
|
[int]$RtspHealthCheckIntervalSec = 10,
|
|
|
|
[ValidateRange(1, 60)]
|
|
[int]$RtspHealthTimeoutSec = 5,
|
|
|
|
[ValidateRange(1, 10)]
|
|
[int]$RtspHealthFailureThreshold = 2,
|
|
|
|
[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]$FFmpegFallbackDownloadUrl = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/ffmpeg-n8.1.1-9-g58d4114d36-win64-gpl-8.1.zip",
|
|
[string]$FFmpegFallbackSha256Url = "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-05-31-13-22/checksums.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]$ForceCompatibilityTest,
|
|
[switch]$ResetManagedInfo,
|
|
[switch]$InstallGitHooks,
|
|
[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",
|
|
"HealthCheckIntervalSec",
|
|
"HealthTimeoutSec",
|
|
"HealthFailureThreshold"
|
|
)
|
|
Dependencies = @(
|
|
"AutoInstall",
|
|
"FFmpegUrl",
|
|
"FFmpegSha256Url",
|
|
"FFmpegFallbackUrl",
|
|
"FFmpegFallbackSha256Url",
|
|
"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 "RTSP" "HealthCheckIntervalSec"
|
|
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthCheckIntervalSec")) {
|
|
$RtspHealthCheckIntervalSec = [int]$setting.Value
|
|
}
|
|
$setting = Get-IniSetting $ini "RTSP" "HealthTimeoutSec"
|
|
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthTimeoutSec")) {
|
|
$RtspHealthTimeoutSec = [int]$setting.Value
|
|
}
|
|
$setting = Get-IniSetting $ini "RTSP" "HealthFailureThreshold"
|
|
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthFailureThreshold")) {
|
|
$RtspHealthFailureThreshold = [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" "FFmpegFallbackUrl"
|
|
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegFallbackDownloadUrl")) {
|
|
$FFmpegFallbackDownloadUrl = [string]$setting.Value
|
|
}
|
|
$setting = Get-IniSetting $ini "Dependencies" "FFmpegFallbackSha256Url"
|
|
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("FFmpegFallbackSha256Url")) {
|
|
$FFmpegFallbackSha256Url = [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"
|
|
$CompatibilityStatePath = Join-Path $LogDir "compatibility-state.json"
|
|
$DependencyEvidencePath = Join-Path $LogDir "dependency-evidence.json"
|
|
$TaskName = "Screen RTSP Watchdog"
|
|
$PowerShellPath = Join-Path $PSHOME "powershell.exe"
|
|
$RTSPPublishBase = "rtsp://127.0.0.1:$RtspPort"
|
|
$DependencyInstallEvidence = @{}
|
|
$ManagedInfoStart = "# >>> SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT >>>"
|
|
$ManagedInfoEnd = "# <<< SCREENRTSP AUTO-MANAGED INSTALLATION ENVIRONMENT <<<"
|
|
$ManagedInfoKeys = @(
|
|
"CheckedAt",
|
|
"EnvironmentFingerprint",
|
|
"GPU",
|
|
"NvidiaNvencApi",
|
|
"NvidiaNvencRequiredApi",
|
|
"NvidiaNvencSupport",
|
|
"AmdAmfSupport",
|
|
"IntelQsvSupport",
|
|
"SelectedHardwareEncoder",
|
|
"FFmpegVersion",
|
|
"FFmpegBuild",
|
|
"FFmpegExecutableSha256",
|
|
"FFmpegDownloadSource",
|
|
"FFmpegArchiveSha256Verification",
|
|
"MediaMTXVersion",
|
|
"MediaMTXExecutableSha256",
|
|
"MediaMTXDownloadSource",
|
|
"MediaMTXArchiveSha256Verification",
|
|
"CompatibilityTest"
|
|
)
|
|
|
|
function ConvertTo-ManagedInfoValue {
|
|
param($Value)
|
|
if ($null -eq $Value) { return "" }
|
|
return (([string]$Value) -replace '[\r\n]+', ' ' -replace '[\x00-\x1f]', '').Trim()
|
|
}
|
|
|
|
function New-ManagedInfoBlock {
|
|
param([hashtable]$Values = @{})
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add($ManagedInfoStart)
|
|
$lines.Add("# 이 부분은 실제 컴퓨터에서 실행한 후 자동 기록되며 사용자가 직접 수정하지 않습니다.")
|
|
$lines.Add("# 설정값으로 해석되지 않으며 송출 동작을 결정하는 입력으로 사용되지 않습니다.")
|
|
foreach ($key in $ManagedInfoKeys) {
|
|
$value = if ($Values.ContainsKey($key)) {
|
|
ConvertTo-ManagedInfoValue $Values[$key]
|
|
}
|
|
else { "" }
|
|
$lines.Add("# $key=$value")
|
|
}
|
|
$lines.Add($ManagedInfoEnd)
|
|
return ($lines -join "`r`n")
|
|
}
|
|
|
|
function Set-ManagedInfoBlock {
|
|
param(
|
|
[hashtable]$Values = @{},
|
|
[string]$Path = $ConfigPath
|
|
)
|
|
$block = New-ManagedInfoBlock -Values $Values
|
|
$existing = if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
[System.IO.File]::ReadAllText($Path)
|
|
}
|
|
else { "" }
|
|
|
|
$pattern = '(?ms)^' + [regex]::Escape($ManagedInfoStart) +
|
|
'.*?^' + [regex]::Escape($ManagedInfoEnd) + '\s*(?:\r?\n)?'
|
|
$matches = [regex]::Matches($existing, $pattern)
|
|
if ($matches.Count -gt 1) {
|
|
throw "config.ini 자동 기록 영역이 둘 이상 존재합니다. 파일을 먼저 정리하십시오."
|
|
}
|
|
if ($matches.Count -eq 1) {
|
|
$updated = [regex]::Replace($existing, $pattern, $block + "`r`n`r`n", 1)
|
|
}
|
|
else {
|
|
$updated = $block + "`r`n`r`n" + $existing.TrimStart("`r", "`n")
|
|
}
|
|
if ($updated -eq $existing) { return }
|
|
|
|
$directory = Split-Path -Parent ([System.IO.Path]::GetFullPath($Path))
|
|
if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
|
|
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
|
}
|
|
$tempPath = Join-Path $directory (".{0}.{1}.tmp" -f (Split-Path -Leaf $Path),[Guid]::NewGuid().ToString("N"))
|
|
$backupPath = Join-Path $directory (".{0}.{1}.previous" -f (Split-Path -Leaf $Path),[Guid]::NewGuid().ToString("N"))
|
|
try {
|
|
# Windows PowerShell 5.1의 Get-Content가 한국어를 정확히 읽도록 UTF-8 BOM을 유지합니다.
|
|
[System.IO.File]::WriteAllText($tempPath, $updated, (New-Object System.Text.UTF8Encoding($true)))
|
|
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
[System.IO.File]::Replace($tempPath, $Path, $backupPath, $true)
|
|
}
|
|
else {
|
|
Move-Item -LiteralPath $tempPath -Destination $Path
|
|
}
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
|
|
if (Test-Path -LiteralPath $backupPath) { Remove-Item -LiteralPath $backupPath -Force }
|
|
}
|
|
}
|
|
|
|
function Install-RepositoryGitHooks {
|
|
$gitDirectory = Join-Path $BaseDir ".git"
|
|
$hooksDirectory = Join-Path $BaseDir ".githooks"
|
|
if (-not (Test-Path -LiteralPath $gitDirectory) -or
|
|
-not (Test-Path -LiteralPath $hooksDirectory -PathType Container) -or
|
|
-not (Get-Command git.exe -ErrorAction SilentlyContinue)) {
|
|
return $false
|
|
}
|
|
& git.exe -C $BaseDir config core.hooksPath .githooks
|
|
if ($LASTEXITCODE -ne 0) { throw "Git hooksPath 설정에 실패했습니다." }
|
|
return $true
|
|
}
|
|
|
|
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,
|
|
"-RtspHealthCheckIntervalSec", [string]$RtspHealthCheckIntervalSec,
|
|
"-RtspHealthTimeoutSec", [string]$RtspHealthTimeoutSec,
|
|
"-RtspHealthFailureThreshold", [string]$RtspHealthFailureThreshold,
|
|
"-AutoInstallDependencies", [string]$AutoInstallDependencies,
|
|
"-FFmpegDownloadUrl", $FFmpegDownloadUrl,
|
|
"-FFmpegSha256Url", $FFmpegSha256Url,
|
|
"-FFmpegFallbackDownloadUrl", $FFmpegFallbackDownloadUrl,
|
|
"-FFmpegFallbackSha256Url", $FFmpegFallbackSha256Url,
|
|
"-MediaMTXDownloadUrl", $MediaMTXDownloadUrl,
|
|
"-MediaMTXSha256", $MediaMTXSha256
|
|
)
|
|
if ($ForceCompatibilityTest) { $values += "-ForceCompatibilityTest" }
|
|
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 ($ResetManagedInfo) {
|
|
Set-ManagedInfoBlock -Values @{}
|
|
Write-Host "config.ini 자동 기록 영역을 저장소용 빈 템플릿으로 초기화했습니다: $ConfigPath"
|
|
exit 0
|
|
}
|
|
|
|
if ($InstallGitHooks) {
|
|
if (Install-RepositoryGitHooks) {
|
|
Write-Host "Git 커밋 전 자동 기록 정보 제거 hook을 활성화했습니다."
|
|
}
|
|
else {
|
|
throw "현재 폴더에서 Git 저장소 또는 .githooks 폴더를 찾을 수 없습니다."
|
|
}
|
|
exit 0
|
|
}
|
|
|
|
# 개발 저장소에서 실행할 때만 저장소에 포함된 hook을 자동 활성화합니다.
|
|
# 배포 폴더에는 .git이 없으므로 아무 변경도 하지 않습니다.
|
|
$null = Install-RepositoryGitHooks
|
|
|
|
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
|
|
RtspHealthCheckIntervalSec = $RtspHealthCheckIntervalSec
|
|
RtspHealthTimeoutSec = $RtspHealthTimeoutSec
|
|
RtspHealthFailureThreshold = $RtspHealthFailureThreshold
|
|
AutoInstallDependencies = $AutoInstallDependencies
|
|
FFmpegDownloadUrl = $FFmpegDownloadUrl
|
|
FFmpegSha256Url = $FFmpegSha256Url
|
|
FFmpegFallbackDownloadUrl = $FFmpegFallbackDownloadUrl
|
|
FFmpegFallbackSha256Url = $FFmpegFallbackSha256Url
|
|
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 Import-DependencyEvidence {
|
|
if (-not (Test-Path -LiteralPath $DependencyEvidencePath -PathType Leaf)) { return }
|
|
try {
|
|
$saved = Get-Content -LiteralPath $DependencyEvidencePath -Raw | ConvertFrom-Json
|
|
foreach ($name in @("FFmpeg", "MediaMTX")) {
|
|
if ($saved.$name) { $script:DependencyInstallEvidence[$name] = $saved.$name }
|
|
}
|
|
}
|
|
catch { Write-Log "의존성 검증 이력 읽기 실패: $($_.Exception.Message)" }
|
|
}
|
|
|
|
function Save-DependencyEvidence {
|
|
$tempPath = "$DependencyEvidencePath.$([Guid]::NewGuid().ToString('N')).tmp"
|
|
try {
|
|
[System.IO.File]::WriteAllText(
|
|
$tempPath,
|
|
($script:DependencyInstallEvidence | ConvertTo-Json -Depth 6),
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
Move-Item -LiteralPath $tempPath -Destination $DependencyEvidencePath -Force
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
|
|
}
|
|
}
|
|
|
|
Import-DependencyEvidence
|
|
|
|
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, [string]$TargetFileName = "")
|
|
Assert-HttpsDependencyUrl -Url $Url -Name "$Name SHA-256"
|
|
$previousProgressPreference = $ProgressPreference
|
|
$ProgressPreference = "SilentlyContinue"
|
|
try {
|
|
$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 60
|
|
}
|
|
finally {
|
|
$ProgressPreference = $previousProgressPreference
|
|
}
|
|
$content = if ($response.Content -is [byte[]]) {
|
|
[System.Text.Encoding]::UTF8.GetString($response.Content)
|
|
}
|
|
else { [string]$response.Content }
|
|
$match = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($TargetFileName)) {
|
|
foreach ($line in ($content -split '[\r\n]+')) {
|
|
if ($line -match [regex]::Escape($TargetFileName) -and
|
|
$line -match '(?i)\b([0-9a-f]{64})\b') {
|
|
$match = [regex]::Match($line, '(?i)\b[0-9a-f]{64}\b')
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $match) {
|
|
$match = [regex]::Match($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,
|
|
[scriptblock]$Validator
|
|
)
|
|
|
|
$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)) {
|
|
$archiveFileName = [System.IO.Path]::GetFileName(([Uri]$DownloadUrl).AbsolutePath)
|
|
$ExpectedSha256 = Get-RemoteSha256 -Url $ExpectedSha256Url -Name $Name `
|
|
-TargetFileName $archiveFileName
|
|
}
|
|
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 파일을 찾을 수 없습니다."
|
|
}
|
|
|
|
if ($Validator) {
|
|
$validation = & $Validator $candidate.FullName
|
|
if ($validation -is [bool]) {
|
|
if (-not $validation) { throw "$Name 후보 실행 파일 검증에 실패했습니다." }
|
|
}
|
|
elseif (-not $validation.Passed) {
|
|
throw "$Name 후보 실행 파일 검증에 실패했습니다: $($validation.Reason)"
|
|
}
|
|
}
|
|
|
|
$destinationDirectory = Split-Path -Parent $DestinationPath
|
|
$stagingPath = Join-Path $destinationDirectory `
|
|
(".{0}.{1}.staging" -f (Split-Path -Leaf $DestinationPath),[Guid]::NewGuid().ToString("N"))
|
|
$backupDirectory = Join-Path $LogDir "backups"
|
|
if (-not (Test-Path -LiteralPath $backupDirectory -PathType Container)) {
|
|
New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
|
|
}
|
|
$backupPath = Join-Path $backupDirectory `
|
|
("{0}-{1}.bak" -f (Split-Path -Leaf $DestinationPath),(Get-Date -Format "yyyyMMdd-HHmmss"))
|
|
Copy-Item -LiteralPath $candidate.FullName -Destination $stagingPath
|
|
try {
|
|
Unblock-File -LiteralPath $stagingPath -ErrorAction SilentlyContinue
|
|
if (Test-Path -LiteralPath $DestinationPath -PathType Leaf) {
|
|
[System.IO.File]::Replace($stagingPath, $DestinationPath, $backupPath, $true)
|
|
Write-Log "$Name 기존 실행 파일 백업: $backupPath"
|
|
}
|
|
else {
|
|
Move-Item -LiteralPath $stagingPath -Destination $DestinationPath
|
|
}
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $stagingPath) {
|
|
Remove-Item -LiteralPath $stagingPath -Force
|
|
}
|
|
}
|
|
if (-not (Test-Path -LiteralPath $DestinationPath -PathType Leaf)) {
|
|
throw "$Name 안전 교체 후 실행 파일을 찾을 수 없습니다: $DestinationPath"
|
|
}
|
|
$script:DependencyInstallEvidence[$Name] = [PSCustomObject]@{
|
|
Source = $DownloadUrl
|
|
ArchiveSha256 = $actualSha256
|
|
Sha256Verified = $true
|
|
InstalledAt = (Get-Date).ToString("o")
|
|
}
|
|
Save-DependencyEvidence
|
|
Write-Log "$Name 설치 완료: $DestinationPath (source=$DownloadUrl, sha256=$actualSha256)"
|
|
return $script:DependencyInstallEvidence[$Name]
|
|
}
|
|
finally {
|
|
Remove-DependencyTempDirectory -Path $tempDirectory
|
|
}
|
|
}
|
|
|
|
function Assert-RequiredFiles {
|
|
$missing = @()
|
|
if (-not (Test-Path -LiteralPath $FFmpegPath -PathType Leaf)) {
|
|
$missing += "ffmpeg.exe"
|
|
}
|
|
else {
|
|
try { $null = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath }
|
|
catch {
|
|
Write-Log "기존 FFmpeg 기능 검증 실패: $($_.Exception.Message)"
|
|
$missing += "ffmpeg.exe"
|
|
}
|
|
}
|
|
if (-not (Test-Path -LiteralPath $MediaMTXPath -PathType Leaf)) {
|
|
$missing += "mediamtx.exe"
|
|
}
|
|
else {
|
|
$mediaCheck = Invoke-ProcessWithTimeout -FilePath $MediaMTXPath `
|
|
-ArgumentList @("--version") -TimeoutMilliseconds 10000
|
|
if ($mediaCheck.ExitCode -ne 0 -or
|
|
$mediaCheck.Output -notmatch '(?i)MediaMTX|v\d+\.\d+') {
|
|
Write-Log "기존 MediaMTX 실행 검증 실패: $(Get-CompactFailureReason $mediaCheck.Output)"
|
|
$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 `
|
|
-Validator {
|
|
param($candidatePath)
|
|
try {
|
|
$null = Test-FFmpegCapabilities -FFmpegExecutable $candidatePath
|
|
[PSCustomObject]@{Passed=$true;Reason=""}
|
|
}
|
|
catch { [PSCustomObject]@{Passed=$false;Reason=$_.Exception.Message} }
|
|
}
|
|
}
|
|
if ($missing -contains "mediamtx.exe") {
|
|
Install-ZipDependency -Name "MediaMTX" -DownloadUrl $MediaMTXDownloadUrl `
|
|
-ExpectedSha256 $MediaMTXSha256 -ExpectedSha256Url "" `
|
|
-ExecutableName "mediamtx.exe" -DestinationPath $MediaMTXPath `
|
|
-Validator {
|
|
param($candidatePath)
|
|
$result = Invoke-ProcessWithTimeout -FilePath $candidatePath `
|
|
-ArgumentList @("--version") -TimeoutMilliseconds 10000
|
|
[PSCustomObject]@{
|
|
Passed = ($result.ExitCode -eq 0 -and $result.Output -match '(?i)MediaMTX|v\d+\.\d+')
|
|
Reason = $result.Output.Trim()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
param([string]$FFmpegExecutable = $FFmpegPath)
|
|
|
|
$filtersResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
|
|
-ArgumentList @("-hide_banner", "-filters") -TimeoutMilliseconds 15000
|
|
$encodersResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
|
|
-ArgumentList @("-hide_banner", "-encoders") -TimeoutMilliseconds 15000
|
|
$devicesResult = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
|
|
-ArgumentList @("-hide_banner", "-devices") -TimeoutMilliseconds 15000
|
|
if ($filtersResult.ExitCode -ne 0 -or $encodersResult.ExitCode -ne 0) {
|
|
throw "FFmpeg 기능 목록을 읽지 못했습니다."
|
|
}
|
|
$filters = $filtersResult.Output
|
|
$encoders = $encodersResult.Output
|
|
$devices = $devicesResult.Output
|
|
if ($filters -notmatch '(?m)\bddagrab\b') {
|
|
throw "현재 FFmpeg에 ddagrab 필터가 없습니다."
|
|
}
|
|
foreach ($requiredEncoder in @("h264_nvenc", "h264_amf", "h264_qsv", "aac")) {
|
|
if ($encoders -notmatch ("(?m)\b" + [regex]::Escape($requiredEncoder) + "\b")) {
|
|
throw "현재 FFmpeg에 필수 인코더가 없습니다: $requiredEncoder"
|
|
}
|
|
}
|
|
if ($AudioEnabled -eq 1 -and $MicrophoneEnabled -eq 1) {
|
|
if ($devices -notmatch '(?m)\bdshow\b') {
|
|
throw "마이크 캡처가 켜져 있지만 현재 FFmpeg에 DirectShow(dshow) 입력이 없습니다."
|
|
}
|
|
}
|
|
return [PSCustomObject]@{
|
|
DdaGrab = $true
|
|
Nvenc = $true
|
|
Amf = $true
|
|
Qsv = $true
|
|
Aac = $true
|
|
Dshow = ($devices -match '(?m)\bdshow\b')
|
|
EncoderList = $encoders
|
|
}
|
|
}
|
|
|
|
function Invoke-ProcessWithTimeout {
|
|
param(
|
|
[string]$FilePath,
|
|
[string[]]$ArgumentList,
|
|
[int]$TimeoutMilliseconds = 10000
|
|
)
|
|
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
|
|
$startInfo.FileName = $FilePath
|
|
$startInfo.Arguments = Join-ProcessArguments $ArgumentList
|
|
$startInfo.WorkingDirectory = $BaseDir
|
|
$startInfo.UseShellExecute = $false
|
|
$startInfo.CreateNoWindow = $true
|
|
$startInfo.RedirectStandardOutput = $true
|
|
$startInfo.RedirectStandardError = $true
|
|
$process = New-Object System.Diagnostics.Process
|
|
$process.StartInfo = $startInfo
|
|
[void]$process.Start()
|
|
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
|
$stderrTask = $process.StandardError.ReadToEndAsync()
|
|
if (-not $process.WaitForExit($TimeoutMilliseconds)) {
|
|
try { $process.Kill() } catch { }
|
|
try { $process.WaitForExit(5000) | Out-Null } catch { }
|
|
$partialOutput = ""
|
|
try { $partialOutput = $stdoutTask.Result + $stderrTask.Result } catch { }
|
|
return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out`r`n$partialOutput" }
|
|
}
|
|
$output = $stdoutTask.Result + $stderrTask.Result
|
|
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-RuntimeEnvironmentSignature {
|
|
$binaryParts = foreach ($path in @($FFmpegPath, $MediaMTXPath)) {
|
|
if (Test-Path -LiteralPath $path -PathType Leaf) {
|
|
$item = Get-Item -LiteralPath $path
|
|
"$($item.Name):$($item.Length):$($item.LastWriteTimeUtc.Ticks)"
|
|
}
|
|
else { "MISSING:$path" }
|
|
}
|
|
return (Get-GpuSignature) + "||" + ($binaryParts -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",
|
|
"-forced-idr","1","-strict_gop","1"
|
|
)
|
|
}
|
|
}
|
|
"AMD" {
|
|
return [PSCustomObject]@{
|
|
Vendor = "AMD"; Name = "h264_amf"
|
|
Arguments = @(
|
|
"-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1",
|
|
"-forced_idr","1","-header_spacing",[string]$FrameRate
|
|
)
|
|
}
|
|
}
|
|
"Intel" {
|
|
return [PSCustomObject]@{
|
|
Vendor = "Intel"; Name = "h264_qsv"
|
|
Arguments = @(
|
|
"-preset","veryfast","-look_ahead","0","-low_delay_brc","1",
|
|
"-forced_idr","1","-idr_interval","1","-repeat_pps","1"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-H264IdrMetrics {
|
|
param(
|
|
[string]$FFmpegExecutable,
|
|
[string]$BitstreamPath,
|
|
[int]$ExpectedMinimumIdrFrames = 3
|
|
)
|
|
$bytes = [System.IO.File]::ReadAllBytes($BitstreamPath)
|
|
$nalIdrCount = 0
|
|
for ($index = 0; $index -lt ($bytes.Length - 4); $index++) {
|
|
$nalStart = -1
|
|
if ($bytes[$index] -eq 0 -and $bytes[$index + 1] -eq 0 -and
|
|
$bytes[$index + 2] -eq 1) {
|
|
$nalStart = $index + 3
|
|
}
|
|
elseif ($bytes[$index] -eq 0 -and $bytes[$index + 1] -eq 0 -and
|
|
$bytes[$index + 2] -eq 0 -and $bytes[$index + 3] -eq 1) {
|
|
$nalStart = $index + 4
|
|
}
|
|
if ($nalStart -ge 0 -and (($bytes[$nalStart] -band 0x1f) -eq 5)) {
|
|
$nalIdrCount++
|
|
}
|
|
}
|
|
|
|
$decode = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
|
|
"-hide_banner", "-loglevel", "info", "-nostdin",
|
|
"-i", $BitstreamPath, "-vf", "showinfo", "-f", "null", "NUL"
|
|
) -TimeoutMilliseconds 30000
|
|
$idrFrameCount = [regex]::Matches(
|
|
$decode.Output,
|
|
'(?im)Parsed_showinfo[^\r\n]*iskey:\s*1[^\r\n]*type:\s*I'
|
|
).Count
|
|
$frameMatches = [regex]::Matches($decode.Output, '(?im)Parsed_showinfo[^\r\n]*\bn:\s*(\d+)')
|
|
$decodedFrames = 0
|
|
foreach ($match in $frameMatches) {
|
|
$decodedFrames = [Math]::Max($decodedFrames, ([int]$match.Groups[1].Value + 1))
|
|
}
|
|
return [PSCustomObject]@{
|
|
Passed = ($decode.ExitCode -eq 0 -and
|
|
$nalIdrCount -ge $ExpectedMinimumIdrFrames -and
|
|
$idrFrameCount -ge $ExpectedMinimumIdrFrames)
|
|
NalIdrCount = $nalIdrCount
|
|
IdrFrameCount = $idrFrameCount
|
|
DecodedFrames = $decodedFrames
|
|
DecodeExitCode = $decode.ExitCode
|
|
}
|
|
}
|
|
|
|
function Get-CompactFailureReason {
|
|
param([string]$Output, [int]$MaximumLength = 600)
|
|
$importantLines = @($Output -split '[\r\n]+' | Where-Object {
|
|
$_ -match '(?i)error|failed|failure|does not support|not supported|minimum required|required:|cannot|could not|timed out|invalid'
|
|
})
|
|
$source = if ($importantLines.Count -gt 0) { $importantLines -join " " } else { $Output }
|
|
$reason = (($source -replace '[\r\n]+', ' ') -replace '\s+', ' ').Trim()
|
|
if ($reason.Length -gt $MaximumLength) {
|
|
return $reason.Substring($reason.Length - $MaximumLength, $MaximumLength)
|
|
}
|
|
return $reason
|
|
}
|
|
|
|
function Test-HardwareEncoder {
|
|
param(
|
|
$Definition,
|
|
[string]$FFmpegExecutable = $FFmpegPath,
|
|
[int]$TestSeconds = 4
|
|
)
|
|
$testDirectory = New-DependencyTempDirectory
|
|
$bitstreamPath = Join-Path $testDirectory ("{0}.h264" -f $Definition.Name)
|
|
$testFrameRate = [Math]::Max(1, [int]$FrameRate)
|
|
$frameCount = $testFrameRate * [Math]::Max(3, $TestSeconds)
|
|
$expectedIdrFrames = [Math]::Max(3, [int][Math]::Floor(($frameCount - 1) / $testFrameRate) + 1)
|
|
$arguments = @(
|
|
"-hide_banner", "-loglevel", "verbose", "-nostdin",
|
|
"-f", "lavfi", "-i", "testsrc2=size=1280x720:rate=$testFrameRate",
|
|
"-frames:v", [string]$frameCount, "-vf", "format=nv12",
|
|
"-c:v", $Definition.Name
|
|
) + $Definition.Arguments + @(
|
|
"-b:v", "4000k", "-maxrate", "4000k", "-bufsize", "4000k",
|
|
"-g", [string]$testFrameRate, "-bf", "0",
|
|
"-force_key_frames", "expr:gte(t,n_forced*1)",
|
|
"-f", "h264", "-y", $bitstreamPath
|
|
)
|
|
try {
|
|
$result = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable `
|
|
-ArgumentList $arguments -TimeoutMilliseconds 45000
|
|
$requiredApi = ""
|
|
$foundApi = ""
|
|
if ($result.Output -match '(?i)Required:\s*([0-9.]+)\s+Found:\s*([0-9.]+)') {
|
|
$requiredApi = $Matches[1]
|
|
$foundApi = $Matches[2]
|
|
}
|
|
elseif ($result.Output -match '(?i)Loaded Nvenc version\s+([0-9.]+)') {
|
|
$foundApi = $Matches[1]
|
|
}
|
|
|
|
$metrics = $null
|
|
if ($result.ExitCode -eq 0 -and
|
|
(Test-Path -LiteralPath $bitstreamPath -PathType Leaf) -and
|
|
(Get-Item -LiteralPath $bitstreamPath).Length -gt 0) {
|
|
$metrics = Get-H264IdrMetrics -FFmpegExecutable $FFmpegExecutable `
|
|
-BitstreamPath $bitstreamPath -ExpectedMinimumIdrFrames $expectedIdrFrames
|
|
}
|
|
$passed = ($result.ExitCode -eq 0 -and $metrics -and $metrics.Passed)
|
|
$failureReason = ""
|
|
if (-not $passed) {
|
|
if ($result.ExitCode -ne 0) {
|
|
$failureReason = Get-CompactFailureReason $result.Output
|
|
}
|
|
elseif (-not $metrics) {
|
|
$failureReason = "인코더가 H.264 비트스트림을 생성하지 않았습니다."
|
|
}
|
|
else {
|
|
$failureReason = "주기 IDR 검증 실패: expected>=$expectedIdrFrames, " +
|
|
"idrFrames=$($metrics.IdrFrameCount), nalType5=$($metrics.NalIdrCount)"
|
|
}
|
|
}
|
|
return [PSCustomObject]@{
|
|
Vendor = $Definition.Vendor
|
|
Encoder = $Definition.Name
|
|
Passed = $passed
|
|
ExitCode = $result.ExitCode
|
|
TestSeconds = [Math]::Max(3, $TestSeconds)
|
|
Frames = if ($metrics) { $metrics.DecodedFrames } else { 0 }
|
|
IdrFrames = if ($metrics) { $metrics.IdrFrameCount } else { 0 }
|
|
NalIdrCount = if ($metrics) { $metrics.NalIdrCount } else { 0 }
|
|
RequiredApi = $requiredApi
|
|
FoundApi = $foundApi
|
|
ApiMismatch = (-not [string]::IsNullOrWhiteSpace($requiredApi) -and
|
|
-not [string]::IsNullOrWhiteSpace($foundApi) -and
|
|
([version]$requiredApi -gt [version]$foundApi))
|
|
FailureReason = $failureReason
|
|
}
|
|
}
|
|
finally {
|
|
Remove-DependencyTempDirectory -Path $testDirectory
|
|
}
|
|
}
|
|
|
|
function Test-AvailableHardwareEncoders {
|
|
param([string]$FFmpegExecutable = $FFmpegPath)
|
|
$inventory = @(Get-GpuInventory)
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
foreach ($vendor in @("NVIDIA", "AMD", "Intel")) {
|
|
$gpu = @($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" })
|
|
$definition = Get-EncoderDefinition $vendor
|
|
if ($gpu.Count -eq 0) {
|
|
$results.Add([PSCustomObject]@{
|
|
Vendor=$vendor; Encoder=$definition.Name; Passed=$false; ExitCode=$null
|
|
TestSeconds=0; Frames=0; IdrFrames=0; NalIdrCount=0
|
|
RequiredApi=""; FoundApi=""; ApiMismatch=$false
|
|
FailureReason="GPU가 감지되지 않았습니다."
|
|
})
|
|
continue
|
|
}
|
|
Write-Log "하드웨어 인코더 4초/IDR 시험: $vendor / $($definition.Name)"
|
|
$test = Test-HardwareEncoder -Definition $definition `
|
|
-FFmpegExecutable $FFmpegExecutable -TestSeconds 4
|
|
$results.Add($test)
|
|
if ($test.Passed) {
|
|
Write-Log ("인코더 시험 성공: {0}, frames={1}, IDR frames={2}, NAL type5={3}" -f `
|
|
$test.Encoder,$test.Frames,$test.IdrFrames,$test.NalIdrCount)
|
|
}
|
|
else {
|
|
Write-Log "인코더 시험 실패: $($test.Encoder) - $($test.FailureReason)"
|
|
}
|
|
}
|
|
return $results.ToArray()
|
|
}
|
|
|
|
function Select-HardwareEncoder {
|
|
param($TestResults)
|
|
foreach ($vendor in $EncoderPriority) {
|
|
$test = $TestResults | Where-Object { $_.Vendor -eq $vendor } | Select-Object -First 1
|
|
if ($test -and $test.Passed) {
|
|
$definition = Get-EncoderDefinition $vendor
|
|
$gpuNames = ((Get-GpuInventory | Where-Object Vendor -eq $vendor).Name -join ", ")
|
|
Write-Log "하드웨어 인코더 선택 후보: $($definition.Name) ($gpuNames)"
|
|
return $definition
|
|
}
|
|
}
|
|
$failureSummary = ($TestResults | ForEach-Object {
|
|
"$($_.Encoder): $($_.FailureReason)"
|
|
}) -join " | "
|
|
throw "감지된 GPU에서 IDR 검증을 통과한 H.264 하드웨어 인코더가 없습니다. $failureSummary"
|
|
}
|
|
|
|
function Get-FreeTcpPort {
|
|
$listener = New-Object System.Net.Sockets.TcpListener(
|
|
[System.Net.IPAddress]::Loopback,
|
|
0
|
|
)
|
|
$listener.Start()
|
|
try { return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port }
|
|
finally { $listener.Stop() }
|
|
}
|
|
|
|
function Wait-TcpPort {
|
|
param([int]$Port, [int]$TimeoutMilliseconds = 10000)
|
|
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
|
while ($stopwatch.ElapsedMilliseconds -lt $TimeoutMilliseconds) {
|
|
$client = New-Object System.Net.Sockets.TcpClient
|
|
try {
|
|
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
|
|
if ($async.AsyncWaitHandle.WaitOne(250) -and $client.Connected) {
|
|
$client.EndConnect($async)
|
|
return $true
|
|
}
|
|
}
|
|
catch { }
|
|
finally { $client.Dispose() }
|
|
Start-Sleep -Milliseconds 100
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Stop-TestProcess {
|
|
param($Process)
|
|
if ($Process -and -not $Process.HasExited) {
|
|
try { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } catch { }
|
|
try { $Process.WaitForExit(5000) | Out-Null } catch { }
|
|
}
|
|
}
|
|
|
|
function Get-MediaDurationSeconds {
|
|
param([string]$Output)
|
|
if ($Output -match '(?i)Duration:\s*(\d+):(\d+):([0-9.]+)') {
|
|
return ([int]$Matches[1] * 3600) + ([int]$Matches[2] * 60) +
|
|
[double]::Parse($Matches[3], [System.Globalization.CultureInfo]::InvariantCulture)
|
|
}
|
|
return 0.0
|
|
}
|
|
|
|
function Test-DualRtspCompatibility {
|
|
param(
|
|
$Definition,
|
|
[string]$FFmpegExecutable = $FFmpegPath,
|
|
[string]$MediaMTXExecutable = $MediaMTXPath,
|
|
[int]$ReceiveSeconds = 11
|
|
)
|
|
$testDirectory = New-DependencyTempDirectory
|
|
$port = Get-FreeTcpPort
|
|
$serverProcess = $null
|
|
$publisherProcess = $null
|
|
$receiverProcesses = @()
|
|
try {
|
|
$serverConfig = Join-Path $testDirectory "mediamtx-compat.yml"
|
|
$serverOut = Join-Path $testDirectory "mediamtx.out.log"
|
|
$serverErr = Join-Path $testDirectory "mediamtx.err.log"
|
|
$serverText = @"
|
|
logLevel: warn
|
|
rtsp: yes
|
|
rtspAddress: 127.0.0.1:$port
|
|
rtspTransports: [tcp]
|
|
rtmp: no
|
|
hls: no
|
|
webrtc: no
|
|
srt: no
|
|
moq: no
|
|
paths:
|
|
all_others:
|
|
"@
|
|
[System.IO.File]::WriteAllText(
|
|
$serverConfig,
|
|
$serverText,
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
$serverProcess = Start-Process -FilePath $MediaMTXExecutable `
|
|
-ArgumentList (Quote-ProcessArgument $serverConfig) `
|
|
-WorkingDirectory $testDirectory -WindowStyle Hidden `
|
|
-RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -PassThru
|
|
if (-not (Wait-TcpPort -Port $port -TimeoutMilliseconds 10000)) {
|
|
throw "임시 MediaMTX가 포트 $port 에서 시작되지 않았습니다."
|
|
}
|
|
|
|
$publishSeconds = $ReceiveSeconds + 8
|
|
$publisherArguments = @(
|
|
"-hide_banner", "-loglevel", "warning", "-nostdin",
|
|
"-re", "-f", "lavfi", "-i", "testsrc2=size=2560x1440:rate=30",
|
|
"-re", "-f", "lavfi", "-i", "testsrc2=size=2560x1440:rate=30",
|
|
"-re", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
|
|
"-filter_complex", "[2:a]asplit=2[a0][a1]"
|
|
)
|
|
for ($channelIndex = 0; $channelIndex -lt 2; $channelIndex++) {
|
|
$publisherArguments += @(
|
|
"-map", "$channelIndex`:v:0", "-map", "[a$channelIndex]",
|
|
"-c:v", $Definition.Name
|
|
)
|
|
$publisherArguments += @($Definition.Arguments)
|
|
$publisherArguments += @(
|
|
"-b:v", "8000k", "-maxrate", "8000k", "-bufsize", "8000k",
|
|
"-g", "30", "-bf", "0", "-pix_fmt", "nv12",
|
|
"-force_key_frames", "expr:gte(t,n_forced*1)",
|
|
"-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2",
|
|
"-t", [string]$publishSeconds,
|
|
"-f", "rtsp", "-rtsp_transport", "tcp",
|
|
"rtsp://127.0.0.1:$port/compat-monitor$($channelIndex + 1)"
|
|
)
|
|
}
|
|
$publisherLog = Join-Path $testDirectory "publisher.log"
|
|
$publisherOut = Join-Path $testDirectory "publisher.out.log"
|
|
$publisherProcess = Start-Process -FilePath $FFmpegExecutable `
|
|
-ArgumentList (Join-ProcessArguments $publisherArguments) `
|
|
-WorkingDirectory $testDirectory -WindowStyle Hidden `
|
|
-RedirectStandardOutput $publisherOut -RedirectStandardError $publisherLog -PassThru
|
|
|
|
Start-Sleep -Seconds 3
|
|
if ($publisherProcess.HasExited) {
|
|
$publisherError = if (Test-Path $publisherLog) {
|
|
Get-Content -LiteralPath $publisherLog -Raw
|
|
}
|
|
else { "" }
|
|
throw "2채널 시험 publisher가 조기 종료했습니다: $(Get-CompactFailureReason $publisherError)"
|
|
}
|
|
|
|
$receiverStart = Get-Date
|
|
for ($channel = 1; $channel -le 2; $channel++) {
|
|
$recordingPath = Join-Path $testDirectory "monitor$channel.mkv"
|
|
$receiverOut = Join-Path $testDirectory "receiver$channel.out.log"
|
|
$receiverErr = Join-Path $testDirectory "receiver$channel.err.log"
|
|
$receiverArguments = @(
|
|
"-hide_banner", "-loglevel", "warning", "-nostdin",
|
|
"-fflags", "+genpts+discardcorrupt", "-rtsp_transport", "tcp",
|
|
"-i", "rtsp://127.0.0.1:$port/compat-monitor$channel",
|
|
"-t", [string]$ReceiveSeconds,
|
|
"-map", "0:v:0", "-map", "0:a:0", "-c", "copy", "-y", $recordingPath
|
|
)
|
|
$process = Start-Process -FilePath $FFmpegExecutable `
|
|
-ArgumentList (Join-ProcessArguments $receiverArguments) `
|
|
-WorkingDirectory $testDirectory -WindowStyle Hidden `
|
|
-RedirectStandardOutput $receiverOut -RedirectStandardError $receiverErr -PassThru
|
|
$receiverProcesses += [PSCustomObject]@{
|
|
Channel=$channel; Process=$process; RecordingPath=$recordingPath
|
|
ErrorPath=$receiverErr; FirstDataSeconds=$null
|
|
}
|
|
}
|
|
|
|
$joinDeadline = (Get-Date).AddSeconds(5)
|
|
while ((Get-Date) -lt $joinDeadline) {
|
|
foreach ($receiver in $receiverProcesses) {
|
|
if ($null -eq $receiver.FirstDataSeconds -and
|
|
(Test-Path -LiteralPath $receiver.RecordingPath -PathType Leaf) -and
|
|
(Get-Item -LiteralPath $receiver.RecordingPath).Length -gt 0) {
|
|
$receiver.FirstDataSeconds = [Math]::Round(((Get-Date) - $receiverStart).TotalSeconds, 2)
|
|
}
|
|
}
|
|
if (@($receiverProcesses | Where-Object { $null -eq $_.FirstDataSeconds }).Count -eq 0) {
|
|
break
|
|
}
|
|
Start-Sleep -Milliseconds 100
|
|
}
|
|
|
|
foreach ($receiver in $receiverProcesses) {
|
|
if (-not $receiver.Process.WaitForExit(($ReceiveSeconds + 10) * 1000)) {
|
|
Stop-TestProcess $receiver.Process
|
|
throw "monitor$($receiver.Channel) 수신기가 제한 시간 안에 종료되지 않았습니다."
|
|
}
|
|
}
|
|
|
|
$channelResults = New-Object System.Collections.Generic.List[object]
|
|
foreach ($receiver in $receiverProcesses) {
|
|
$channel = $receiver.Channel
|
|
if ($null -eq $receiver.FirstDataSeconds) {
|
|
throw "monitor$channel 새 수신기가 5초 안에 첫 데이터를 받지 못했습니다."
|
|
}
|
|
if (-not (Test-Path -LiteralPath $receiver.RecordingPath -PathType Leaf) -or
|
|
(Get-Item -LiteralPath $receiver.RecordingPath).Length -le 0) {
|
|
$receiverError = if (Test-Path $receiver.ErrorPath) {
|
|
Get-Content -LiteralPath $receiver.ErrorPath -Raw
|
|
}
|
|
else { "" }
|
|
throw "monitor$channel RTSP 수신 실패: $(Get-CompactFailureReason $receiverError)"
|
|
}
|
|
|
|
$verify = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
|
|
"-hide_banner", "-loglevel", "info", "-nostdin",
|
|
"-i", $receiver.RecordingPath,
|
|
"-map", "0:v:0", "-vf", "showinfo", "-map", "0:a:0",
|
|
"-f", "null", "NUL"
|
|
) -TimeoutMilliseconds 60000
|
|
$duration = Get-MediaDurationSeconds $verify.Output
|
|
$rawPath = Join-Path $testDirectory "monitor$channel.h264"
|
|
$extract = Invoke-ProcessWithTimeout -FilePath $FFmpegExecutable -ArgumentList @(
|
|
"-hide_banner", "-loglevel", "error", "-nostdin",
|
|
"-i", $receiver.RecordingPath, "-map", "0:v:0", "-c:v", "copy",
|
|
"-bsf:v", "h264_mp4toannexb", "-f", "h264", "-y", $rawPath
|
|
) -TimeoutMilliseconds 30000
|
|
if ($extract.ExitCode -ne 0) {
|
|
throw "monitor$channel H.264 비트스트림 추출 실패: $(Get-CompactFailureReason $extract.Output)"
|
|
}
|
|
$expectedIdr = [Math]::Max(3, [int][Math]::Floor($duration) - 2)
|
|
$metrics = Get-H264IdrMetrics -FFmpegExecutable $FFmpegExecutable `
|
|
-BitstreamPath $rawPath -ExpectedMinimumIdrFrames $expectedIdr
|
|
$ptsTimes = New-Object System.Collections.Generic.List[double]
|
|
foreach ($ptsMatch in [regex]::Matches(
|
|
$verify.Output,
|
|
'(?im)Parsed_showinfo[^\r\n]*\bpts_time:\s*(-?[0-9.]+)'
|
|
)) {
|
|
$ptsTimes.Add([double]::Parse(
|
|
$ptsMatch.Groups[1].Value,
|
|
[System.Globalization.CultureInfo]::InvariantCulture
|
|
))
|
|
}
|
|
$videoDuration = if ($ptsTimes.Count -gt 1) {
|
|
($ptsTimes[$ptsTimes.Count - 1] - $ptsTimes[0]) + (1.0 / 30.0)
|
|
}
|
|
else { 0.0 }
|
|
$fps = if ($videoDuration -gt 0) {
|
|
$metrics.DecodedFrames / $videoDuration
|
|
}
|
|
else { 0 }
|
|
$bitrate = if ($duration -gt 0) {
|
|
((Get-Item -LiteralPath $receiver.RecordingPath).Length * 8.0 / $duration)
|
|
}
|
|
else { 0 }
|
|
$passed = ($verify.ExitCode -eq 0 -and $duration -ge 10.0 -and
|
|
$metrics.Passed -and $fps -ge 28.5 -and $fps -le 31.5 -and
|
|
$bitrate -ge 4000000 -and $bitrate -le 12000000 -and
|
|
$receiver.FirstDataSeconds -le 5.0)
|
|
$channelResults.Add([PSCustomObject]@{
|
|
Channel=$channel; Passed=$passed; Duration=[Math]::Round($duration,2)
|
|
VideoDuration=[Math]::Round($videoDuration,2)
|
|
Frames=$metrics.DecodedFrames; Fps=[Math]::Round($fps,2)
|
|
IdrFrames=$metrics.IdrFrameCount; NalIdrCount=$metrics.NalIdrCount
|
|
Bitrate=[Math]::Round($bitrate); AudioTrack=($verify.ExitCode -eq 0)
|
|
FirstDataSeconds=$receiver.FirstDataSeconds
|
|
})
|
|
}
|
|
$passedAll = @($channelResults | Where-Object { -not $_.Passed }).Count -eq 0
|
|
return [PSCustomObject]@{
|
|
Passed=$passedAll
|
|
Encoder=$Definition.Name
|
|
Resolution="2560x1440"
|
|
FrameRate=30
|
|
TargetBitrate="8000k"
|
|
Audio="AAC 48kHz stereo 192k"
|
|
Channels=$channelResults.ToArray()
|
|
FailureReason=if ($passedAll) { "" } else { "하나 이상의 채널이 품질 기준을 충족하지 못했습니다." }
|
|
}
|
|
}
|
|
catch {
|
|
return [PSCustomObject]@{
|
|
Passed=$false; Encoder=$Definition.Name; Resolution="2560x1440"
|
|
FrameRate=30; TargetBitrate="8000k"; Audio="AAC 48kHz stereo 192k"
|
|
Channels=@(); FailureReason=$_.Exception.Message
|
|
}
|
|
}
|
|
finally {
|
|
foreach ($receiver in $receiverProcesses) { Stop-TestProcess $receiver.Process }
|
|
Stop-TestProcess $publisherProcess
|
|
Stop-TestProcess $serverProcess
|
|
Remove-DependencyTempDirectory -Path $testDirectory
|
|
}
|
|
}
|
|
|
|
function Get-EnvironmentFingerprint {
|
|
param([string]$FFmpegExecutable = $FFmpegPath, [string]$MediaMTXExecutable = $MediaMTXPath)
|
|
$ffmpegHash = if (Test-Path -LiteralPath $FFmpegExecutable -PathType Leaf) {
|
|
(Get-FileHash -LiteralPath $FFmpegExecutable -Algorithm SHA256).Hash
|
|
}
|
|
else { "" }
|
|
$mediaHash = if (Test-Path -LiteralPath $MediaMTXExecutable -PathType Leaf) {
|
|
(Get-FileHash -LiteralPath $MediaMTXExecutable -Algorithm SHA256).Hash
|
|
}
|
|
else { "" }
|
|
$scriptHash = if (Test-Path -LiteralPath $PSCommandPath -PathType Leaf) {
|
|
(Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash
|
|
}
|
|
else { "" }
|
|
$source = (Get-GpuSignature) + "|" + $ffmpegHash + "|" + $mediaHash + "|" + $scriptHash + "|" +
|
|
($EncoderPriority -join ",") + "|$FrameRate|$VideoBitrate"
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try {
|
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($source)
|
|
return ([BitConverter]::ToString($sha.ComputeHash($bytes))).Replace("-", "").ToLowerInvariant()
|
|
}
|
|
finally { $sha.Dispose() }
|
|
}
|
|
|
|
function Get-CompatibilityState {
|
|
if (-not (Test-Path -LiteralPath $CompatibilityStatePath -PathType Leaf)) { return $null }
|
|
try { return (Get-Content -LiteralPath $CompatibilityStatePath -Raw | ConvertFrom-Json) }
|
|
catch {
|
|
Write-Log "호환성 상태 파일을 읽지 못해 전체 검사를 다시 수행합니다: $($_.Exception.Message)"
|
|
return $null
|
|
}
|
|
}
|
|
|
|
function Save-CompatibilityState {
|
|
param([string]$Fingerprint, [string]$Encoder, $Result)
|
|
$state = [ordered]@{
|
|
Fingerprint=$Fingerprint
|
|
Encoder=$Encoder
|
|
Passed=[bool]$Result.Passed
|
|
CheckedAt=(Get-Date).ToString("o")
|
|
}
|
|
$tempPath = "$CompatibilityStatePath.$([Guid]::NewGuid().ToString('N')).tmp"
|
|
try {
|
|
[System.IO.File]::WriteAllText(
|
|
$tempPath,
|
|
($state | ConvertTo-Json -Depth 5),
|
|
(New-Object System.Text.UTF8Encoding($false))
|
|
)
|
|
Move-Item -LiteralPath $tempPath -Destination $CompatibilityStatePath -Force
|
|
}
|
|
finally {
|
|
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force }
|
|
}
|
|
}
|
|
|
|
function Confirm-HardwareEncoderForEnvironment {
|
|
param(
|
|
$EncoderTests,
|
|
[string]$FFmpegExecutable = $FFmpegPath,
|
|
[switch]$Force
|
|
)
|
|
$fingerprint = Get-EnvironmentFingerprint -FFmpegExecutable $FFmpegExecutable
|
|
$state = Get-CompatibilityState
|
|
foreach ($vendor in $EncoderPriority) {
|
|
$test = $EncoderTests | Where-Object { $_.Vendor -eq $vendor } | Select-Object -First 1
|
|
if (-not $test -or -not $test.Passed) { continue }
|
|
$definition = Get-EncoderDefinition $vendor
|
|
$useCachedResult = (-not $Force -and $state -and $state.Passed -and
|
|
$state.Fingerprint -eq $fingerprint -and $state.Encoder -eq $definition.Name)
|
|
if ($useCachedResult) {
|
|
Write-Log "2채널 2560x1440 RTSP 최종 검증: 환경 변경 없음, 이전 성공 결과 재사용"
|
|
return [PSCustomObject]@{
|
|
Encoder=$definition; Fingerprint=$fingerprint
|
|
FinalTest=[PSCustomObject]@{Passed=$true;Cached=$true;Channels=@()}
|
|
}
|
|
}
|
|
|
|
Write-Log "2채널 최종 호환성 시험 시작: $($definition.Name), 2560x1440, 30fps, 채널당 8Mbps, 11초 수신"
|
|
$finalTest = Test-DualRtspCompatibility -Definition $definition `
|
|
-FFmpegExecutable $FFmpegExecutable -MediaMTXExecutable $MediaMTXPath
|
|
if ($finalTest.Passed) {
|
|
foreach ($channelResult in $finalTest.Channels) {
|
|
Write-Log (("최종 시험 monitor{0}: duration={1}s fps={2} bitrate={3}bps " +
|
|
"IDR={4} audio={5} firstData={6}s") -f $channelResult.Channel,
|
|
$channelResult.Duration,$channelResult.Fps,$channelResult.Bitrate,
|
|
$channelResult.IdrFrames,$channelResult.AudioTrack,$channelResult.FirstDataSeconds)
|
|
}
|
|
Save-CompatibilityState -Fingerprint $fingerprint -Encoder $definition.Name -Result $finalTest
|
|
Write-Log "2채널 최종 호환성 시험 성공: $($definition.Name)"
|
|
return [PSCustomObject]@{
|
|
Encoder=$definition; Fingerprint=$fingerprint; FinalTest=$finalTest
|
|
}
|
|
}
|
|
$test.Passed = $false
|
|
$test.FailureReason = "2채널 RTSP 최종 검증 실패: $($finalTest.FailureReason)"
|
|
Write-Log "2채널 최종 호환성 시험 실패: $($definition.Name) - $($finalTest.FailureReason)"
|
|
}
|
|
$null = Select-HardwareEncoder -TestResults $EncoderTests
|
|
}
|
|
|
|
function Resolve-FFmpegCompatibility {
|
|
$capabilities = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath
|
|
$tests = @(Test-AvailableHardwareEncoders -FFmpegExecutable $FFmpegPath)
|
|
$inventory = @(Get-GpuInventory)
|
|
$firstAvailableVendor = $null
|
|
foreach ($vendor in $EncoderPriority) {
|
|
if (@($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" }).Count -gt 0) {
|
|
$firstAvailableVendor = $vendor
|
|
break
|
|
}
|
|
}
|
|
$nvidiaTest = $tests | Where-Object Vendor -eq "NVIDIA" | Select-Object -First 1
|
|
$fallbackAlreadyInstalled = ($script:DependencyInstallEvidence.ContainsKey("FFmpeg") -and
|
|
$script:DependencyInstallEvidence["FFmpeg"].Source -eq $FFmpegFallbackDownloadUrl)
|
|
$shouldTryFallback = ($AutoInstallDependencies -eq 1 -and -not $fallbackAlreadyInstalled -and
|
|
$firstAvailableVendor -eq "NVIDIA" -and $nvidiaTest -and $nvidiaTest.ApiMismatch)
|
|
|
|
if ($shouldTryFallback) {
|
|
Write-Log "현재 FFmpeg와 NVIDIA NVENC API 불일치 또는 사용 가능 인코더 부재 - 검증된 이전 빌드 시험"
|
|
$script:FallbackValidationTests = $null
|
|
$script:FallbackFinalTest = $null
|
|
try {
|
|
Install-ZipDependency -Name "FFmpeg" `
|
|
-DownloadUrl $FFmpegFallbackDownloadUrl `
|
|
-ExpectedSha256 "" -ExpectedSha256Url $FFmpegFallbackSha256Url `
|
|
-ExecutableName "ffmpeg.exe" -DestinationPath $FFmpegPath `
|
|
-Validator {
|
|
param($candidatePath)
|
|
try {
|
|
$null = Test-FFmpegCapabilities -FFmpegExecutable $candidatePath
|
|
$candidateTests = @(Test-AvailableHardwareEncoders -FFmpegExecutable $candidatePath)
|
|
$candidateNvenc = $candidateTests | Where-Object Vendor -eq "NVIDIA" |
|
|
Select-Object -First 1
|
|
if (-not $candidateNvenc -or -not $candidateNvenc.Passed) {
|
|
$reason = if ($candidateNvenc) { $candidateNvenc.FailureReason } `
|
|
else { "NVIDIA 시험 결과가 없습니다." }
|
|
return [PSCustomObject]@{Passed=$false;Reason=$reason}
|
|
}
|
|
$definition = Get-EncoderDefinition "NVIDIA"
|
|
$final = Test-DualRtspCompatibility -Definition $definition `
|
|
-FFmpegExecutable $candidatePath -MediaMTXExecutable $MediaMTXPath
|
|
if (-not $final.Passed) {
|
|
return [PSCustomObject]@{Passed=$false;Reason=$final.FailureReason}
|
|
}
|
|
$script:FallbackValidationTests = $candidateTests
|
|
$script:FallbackFinalTest = $final
|
|
return [PSCustomObject]@{Passed=$true;Reason=""}
|
|
}
|
|
catch { return [PSCustomObject]@{Passed=$false;Reason=$_.Exception.Message} }
|
|
} | Out-Null
|
|
$capabilities = Test-FFmpegCapabilities -FFmpegExecutable $FFmpegPath
|
|
$tests = @($script:FallbackValidationTests)
|
|
if ($script:FallbackFinalTest -and $script:FallbackFinalTest.Passed) {
|
|
$installedFingerprint = Get-EnvironmentFingerprint
|
|
Save-CompatibilityState -Fingerprint $installedFingerprint `
|
|
-Encoder "h264_nvenc" -Result $script:FallbackFinalTest
|
|
}
|
|
Write-Log "NVENC API 호환 이전 FFmpeg 빌드로 안전 교체 완료"
|
|
}
|
|
catch {
|
|
Write-Log "이전 FFmpeg 호환 후보를 채택하지 않음 - 기존 파일 유지: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
return [PSCustomObject]@{Capabilities=$capabilities;Tests=$tests}
|
|
}
|
|
|
|
function Get-ExecutableVersionInformation {
|
|
param([string]$Path, [ValidateSet("FFmpeg", "MediaMTX")][string]$Kind)
|
|
$arguments = if ($Kind -eq "FFmpeg") { @("-hide_banner", "-version") } else { @("--version") }
|
|
$result = Invoke-ProcessWithTimeout -FilePath $Path -ArgumentList $arguments `
|
|
-TimeoutMilliseconds 15000
|
|
$lines = @($result.Output -split '[\r\n]+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
|
$buildText = ""
|
|
if ($Kind -eq "FFmpeg" -and $lines.Count -gt 1) {
|
|
$buildText = $lines[1].Trim()
|
|
if ($lines.Count -gt 2 -and $lines[2] -match '^configuration:') {
|
|
$buildText += "; " + $lines[2].Trim()
|
|
}
|
|
}
|
|
return [PSCustomObject]@{
|
|
Version = if ($lines.Count -gt 0) { $lines[0].Trim() } else { "감지 실패" }
|
|
Build = $buildText
|
|
Sha256 = if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
|
(Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
}
|
|
else { "" }
|
|
}
|
|
}
|
|
|
|
function Get-EncoderSupportText {
|
|
param($Test)
|
|
if (-not $Test) { return "감지 실패" }
|
|
if ($Test.FailureReason -eq "GPU가 감지되지 않았습니다.") { return "GPU 미감지" }
|
|
if ($Test.Passed) {
|
|
return "지원(IDR 통과: frames=$($Test.Frames), idr=$($Test.IdrFrames))"
|
|
}
|
|
return "실패: $($Test.FailureReason)"
|
|
}
|
|
|
|
function Update-InstallationEnvironmentInfo {
|
|
param($EncoderTests, $Encoder, [string]$Fingerprint, $FinalTest)
|
|
try {
|
|
$ffmpegInfo = Get-ExecutableVersionInformation -Path $FFmpegPath -Kind FFmpeg
|
|
$mediaInfo = Get-ExecutableVersionInformation -Path $MediaMTXPath -Kind MediaMTX
|
|
$inventory = @(Get-GpuInventory)
|
|
$gpuText = ($inventory | Sort-Object Vendor,Name | ForEach-Object {
|
|
"$($_.Name) (driver $($_.DriverVersion), status $($_.Status))"
|
|
}) -join " | "
|
|
$nvenc = $EncoderTests | Where-Object Vendor -eq "NVIDIA" | Select-Object -First 1
|
|
$amf = $EncoderTests | Where-Object Vendor -eq "AMD" | Select-Object -First 1
|
|
$qsv = $EncoderTests | Where-Object Vendor -eq "Intel" | Select-Object -First 1
|
|
$ffmpegEvidence = if ($script:DependencyInstallEvidence.ContainsKey("FFmpeg")) {
|
|
$script:DependencyInstallEvidence["FFmpeg"]
|
|
}
|
|
else { $null }
|
|
$mediaEvidence = if ($script:DependencyInstallEvidence.ContainsKey("MediaMTX")) {
|
|
$script:DependencyInstallEvidence["MediaMTX"]
|
|
}
|
|
else { $null }
|
|
if (-not $FinalTest) {
|
|
$FinalTest = [PSCustomObject]@{Passed=$false;Cached=$false;FailureReason="검사 완료 전 실패"}
|
|
}
|
|
$compatibilityText = if ($FinalTest.Cached) {
|
|
"통과(동일 환경의 2채널 시험 결과 재사용)"
|
|
}
|
|
elseif ($FinalTest.Passed) {
|
|
"통과(2560x1440/30fps/8Mbps/AAC/11초/2채널)"
|
|
}
|
|
else { "실패: $($FinalTest.FailureReason)" }
|
|
$values = @{
|
|
CheckedAt=(Get-Date).ToString("yyyy-MM-ddTHH:mm:ssK")
|
|
EnvironmentFingerprint=$Fingerprint
|
|
GPU=$gpuText
|
|
NvidiaNvencApi=if ($nvenc) { $nvenc.FoundApi } else { "감지 실패" }
|
|
NvidiaNvencRequiredApi=if ($nvenc) { $nvenc.RequiredApi } else { "감지 실패" }
|
|
NvidiaNvencSupport=(Get-EncoderSupportText $nvenc)
|
|
AmdAmfSupport=(Get-EncoderSupportText $amf)
|
|
IntelQsvSupport=(Get-EncoderSupportText $qsv)
|
|
SelectedHardwareEncoder=if ($Encoder) { $Encoder.Name } else { "" }
|
|
FFmpegVersion=$ffmpegInfo.Version
|
|
FFmpegBuild=$ffmpegInfo.Build
|
|
FFmpegExecutableSha256=$ffmpegInfo.Sha256
|
|
FFmpegDownloadSource=if ($ffmpegEvidence) { $ffmpegEvidence.Source } else { "기존 파일(출처 기록 없음)" }
|
|
FFmpegArchiveSha256Verification=if ($ffmpegEvidence -and $ffmpegEvidence.Sha256Verified) {
|
|
"성공: $($ffmpegEvidence.ArchiveSha256)"
|
|
} else { "감지 실패" }
|
|
MediaMTXVersion=$mediaInfo.Version
|
|
MediaMTXExecutableSha256=$mediaInfo.Sha256
|
|
MediaMTXDownloadSource=if ($mediaEvidence) { $mediaEvidence.Source } else { "기존 파일(출처 기록 없음)" }
|
|
MediaMTXArchiveSha256Verification=if ($mediaEvidence -and $mediaEvidence.Sha256Verified) {
|
|
"성공: $($mediaEvidence.ArchiveSha256)"
|
|
} else { "감지 실패" }
|
|
CompatibilityTest=$compatibilityText
|
|
}
|
|
Set-ManagedInfoBlock -Values $values
|
|
|
|
Write-Log "GPU/드라이버: $gpuText"
|
|
Write-Log "FFmpeg: $($ffmpegInfo.Version); build=$($ffmpegInfo.Build); sha256=$($ffmpegInfo.Sha256)"
|
|
Write-Log "MediaMTX: $($mediaInfo.Version); sha256=$($mediaInfo.Sha256)"
|
|
foreach ($test in $EncoderTests) {
|
|
Write-Log (("인코더 호환 결과: {0}, pass={1}, apiRequired={2}, apiFound={3}, " +
|
|
"frames={4}, idr={5}, reason={6}") -f $test.Encoder,$test.Passed,
|
|
$test.RequiredApi,$test.FoundApi,$test.Frames,$test.IdrFrames,$test.FailureReason)
|
|
}
|
|
$selectedEncoderName = if ($Encoder) { $Encoder.Name } else { "선택 실패" }
|
|
Write-Log "최종 선택 인코더: $selectedEncoderName; 최종 호환성=$compatibilityText"
|
|
if ($ffmpegEvidence) {
|
|
Write-Log "FFmpeg 다운로드 검증: source=$($ffmpegEvidence.Source), sha256=$($ffmpegEvidence.ArchiveSha256)"
|
|
}
|
|
if ($mediaEvidence) {
|
|
Write-Log "MediaMTX 다운로드 검증: source=$($mediaEvidence.Source), sha256=$($mediaEvidence.ArchiveSha256)"
|
|
}
|
|
}
|
|
catch {
|
|
Write-Log "config.ini 자동 기록 영역 갱신 실패(기존 파일 유지): $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
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 = @{}
|
|
$script:LastRtspHealthCheck = [DateTime]::MinValue
|
|
$script:RtspHealthFailures = 0
|
|
|
|
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 = @{}
|
|
$script:LastRtspHealthCheck = [DateTime]::MinValue
|
|
$script:RtspHealthFailures = 0
|
|
}
|
|
|
|
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",
|
|
"-force_key_frames", "expr:gte(t,n_forced*1)"
|
|
)
|
|
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가 실행 직후 종료되었습니다."
|
|
}
|
|
|
|
$script:LastRtspHealthCheck = Get-Date
|
|
$script:RtspHealthFailures = 0
|
|
}
|
|
|
|
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 New-RtspHealthProbeArguments {
|
|
param([int]$Channel)
|
|
|
|
return @(
|
|
"-hide_banner", "-loglevel", "error", "-nostdin", "-nostats",
|
|
"-progress", "pipe:1",
|
|
"-rtsp_transport", "tcp",
|
|
"-timeout", [string]($RtspHealthTimeoutSec * 1000000),
|
|
"-i", "$RTSPPublishBase/monitor$Channel",
|
|
"-map", "0:v:0", "-frames:v", "1",
|
|
"-an", "-sn", "-dn", "-f", "null", "NUL"
|
|
)
|
|
}
|
|
|
|
function Test-RtspStreamFrames {
|
|
$probes = @()
|
|
$failures = New-Object System.Collections.Generic.List[string]
|
|
|
|
try {
|
|
for ($channel = 1; $channel -le $ChannelCount; $channel++) {
|
|
$errorLog = Join-Path $LogDir "rtsp-health-monitor$channel.log"
|
|
$progressLog = Join-Path $LogDir "rtsp-health-monitor$channel.progress"
|
|
Remove-Item -LiteralPath $errorLog,$progressLog `
|
|
-Force -ErrorAction SilentlyContinue
|
|
|
|
$process = Start-Process -FilePath $FFmpegPath `
|
|
-ArgumentList (Join-ProcessArguments (New-RtspHealthProbeArguments $channel)) `
|
|
-WorkingDirectory $BaseDir -WindowStyle Hidden `
|
|
-RedirectStandardOutput $progressLog `
|
|
-RedirectStandardError $errorLog -PassThru
|
|
|
|
$probes += [PSCustomObject]@{
|
|
Channel = $channel
|
|
Process = $process
|
|
ErrorLog = $errorLog
|
|
ProgressLog = $progressLog
|
|
}
|
|
}
|
|
|
|
# Start every receiver first and give the group one shared deadline.
|
|
# This keeps recovery time bounded even when every channel is stalled.
|
|
$deadline = [DateTime]::UtcNow.AddSeconds($RtspHealthTimeoutSec + 2)
|
|
foreach ($probe in $probes) {
|
|
$remainingMs = [Math]::Max(
|
|
1,
|
|
[int]($deadline - [DateTime]::UtcNow).TotalMilliseconds
|
|
)
|
|
$completed = $probe.Process.WaitForExit($remainingMs)
|
|
|
|
if (-not $completed) {
|
|
Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue
|
|
$failures.Add(
|
|
"monitor$($probe.Channel): ${RtspHealthTimeoutSec}초 내 영상 프레임 없음"
|
|
)
|
|
continue
|
|
}
|
|
|
|
$probe.Process.WaitForExit()
|
|
$progress = @()
|
|
if (Test-Path -LiteralPath $probe.ProgressLog) {
|
|
$progress = @(
|
|
Get-Content -LiteralPath $probe.ProgressLog `
|
|
-ErrorAction SilentlyContinue
|
|
)
|
|
}
|
|
$hasFrame = @($progress | Where-Object {
|
|
$_ -match '^frame=([1-9][0-9]*)$'
|
|
}).Count -gt 0
|
|
$reachedEnd = $progress -contains "progress=end"
|
|
|
|
if (-not ($hasFrame -and $reachedEnd)) {
|
|
$detail = "FFmpeg progress에 디코딩 완료 프레임 없음"
|
|
if (Test-Path -LiteralPath $probe.ErrorLog) {
|
|
$tail = @(
|
|
Get-Content -LiteralPath $probe.ErrorLog -Tail 3 `
|
|
-ErrorAction SilentlyContinue
|
|
)
|
|
if ($tail.Count -gt 0) { $detail = ($tail -join " | ") }
|
|
}
|
|
$failures.Add("monitor$($probe.Channel): $detail")
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
$failures.Add($_.Exception.Message)
|
|
}
|
|
finally {
|
|
foreach ($probe in $probes) {
|
|
try {
|
|
if (-not $probe.Process.HasExited) {
|
|
Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
Healthy = ($failures.Count -eq 0)
|
|
Reason = ($failures -join "; ")
|
|
}
|
|
}
|
|
|
|
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 설정으로 해석하지 않음" {
|
|
$content = (New-ManagedInfoBlock -Values @{GPU="Example GPU"}) +
|
|
"`r`n[General]`r`nChannelCount=2"
|
|
$path = New-SimulationIni $content
|
|
try {
|
|
$parsed = Read-IniConfiguration $path
|
|
return ($parsed.General.ChannelCount -eq "2" -and
|
|
-not $parsed.ContainsKey("ManagedEnvironment"))
|
|
}
|
|
finally { [System.IO.File]::Delete($path) }
|
|
}
|
|
|
|
Invoke-SimulationCase "RTSP 실제 프레임 검사 설정 파싱" {
|
|
$path = New-SimulationIni (
|
|
"[RTSP]`r`nPort=8554`r`nHealthCheckIntervalSec=10`r`n" +
|
|
"HealthTimeoutSec=5`r`nHealthFailureThreshold=2`r`n"
|
|
)
|
|
try {
|
|
$parsed = Read-IniConfiguration $path
|
|
Assert-IniSettings $parsed
|
|
return ($parsed.RTSP.HealthCheckIntervalSec -eq "10" -and
|
|
$parsed.RTSP.HealthTimeoutSec -eq "5" -and
|
|
$parsed.RTSP.HealthFailureThreshold -eq "2")
|
|
}
|
|
finally { [System.IO.File]::Delete($path) }
|
|
}
|
|
|
|
Invoke-SimulationCase "자동 기록 갱신은 사용자 설정과 주석 보존" {
|
|
$path = New-SimulationIni "# 사용자 주석`r`n[General]`r`nChannelCount=7`r`n"
|
|
try {
|
|
Set-ManagedInfoBlock -Path $path -Values @{GPU="Test GPU"}
|
|
$updated = [System.IO.File]::ReadAllText($path)
|
|
return ($updated -match '# GPU=Test GPU' -and
|
|
$updated -match '# 사용자 주석' -and
|
|
$updated -match 'ChannelCount=7')
|
|
}
|
|
finally { [System.IO.File]::Delete($path) }
|
|
}
|
|
|
|
Invoke-SimulationCase "자동 기록 영역만 두 번 갱신해도 표식은 하나" {
|
|
$path = New-SimulationIni "[General]`r`nChannelCount=2`r`n"
|
|
try {
|
|
Set-ManagedInfoBlock -Path $path -Values @{GPU="First"}
|
|
Set-ManagedInfoBlock -Path $path -Values @{GPU="Second"}
|
|
$updated = [System.IO.File]::ReadAllText($path)
|
|
return ([regex]::Matches($updated, [regex]::Escape($ManagedInfoStart)).Count -eq 1 -and
|
|
$updated -match '# GPU=Second' -and $updated -notmatch '# GPU=First')
|
|
}
|
|
finally { [System.IO.File]::Delete($path) }
|
|
}
|
|
|
|
Invoke-SimulationCase "저장소용 초기화는 자동 기록 값만 비움" {
|
|
$path = New-SimulationIni ((New-ManagedInfoBlock -Values @{
|
|
GPU="Secret GPU"; CheckedAt="2026-01-01"
|
|
}) + "`r`n[General]`r`nChannelCount=3")
|
|
try {
|
|
Set-ManagedInfoBlock -Path $path -Values @{}
|
|
$updated = [System.IO.File]::ReadAllText($path)
|
|
return ($updated -match '(?m)^# GPU=\r?$' -and
|
|
$updated -match '(?m)^# CheckedAt=\r?$' -and
|
|
$updated -match 'ChannelCount=3' -and
|
|
$updated -notmatch 'Secret GPU')
|
|
}
|
|
finally { [System.IO.File]::Delete($path) }
|
|
}
|
|
|
|
Invoke-SimulationCase "자동 기록은 허용되지 않은 개인정보 키를 버림" {
|
|
$block = New-ManagedInfoBlock -Values @{
|
|
GPU="Safe GPU"; UserName="admin"; ComputerName="DESKTOP-SECRET"; IP="10.0.0.1"
|
|
}
|
|
return ($block -match '# GPU=Safe GPU' -and
|
|
$block -notmatch 'UserName|ComputerName|DESKTOP-SECRET|10\.0\.0\.1')
|
|
}
|
|
|
|
Invoke-SimulationCase "자동 기록 값의 줄바꿈 제거" {
|
|
$block = New-ManagedInfoBlock -Values @{GPU="GPU-A`r`n# Injected=1"}
|
|
return ($block -match '# GPU=GPU-A # Injected=1' -and
|
|
$block -notmatch '(?m)^# Injected=1$')
|
|
}
|
|
|
|
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 "GPU별 명시적 IDR 옵션 구성" {
|
|
$nvenc = (Get-EncoderDefinition "NVIDIA").Arguments
|
|
$amf = (Get-EncoderDefinition "AMD").Arguments
|
|
$qsv = (Get-EncoderDefinition "Intel").Arguments
|
|
return ($nvenc -contains "-forced-idr" -and $nvenc -contains "-strict_gop" -and
|
|
$amf -contains "-forced_idr" -and $amf -contains "-header_spacing" -and
|
|
$qsv -contains "-forced_idr" -and $qsv -contains "-idr_interval")
|
|
}
|
|
|
|
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 '-RtspHealthCheckIntervalSec\s+10' -and
|
|
$arguments -match '-RtspHealthTimeoutSec\s+5' -and
|
|
$arguments -match '-RtspHealthFailureThreshold\s+2' -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 -contains '-force_key_frames' -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 "RTSP 상태 검사는 새 수신기로 실제 영상 1프레임 요구" {
|
|
$arguments = @(New-RtspHealthProbeArguments 2)
|
|
return ($arguments -contains '-timeout' -and
|
|
$arguments -contains '-progress' -and
|
|
$arguments -contains 'pipe:1' -and
|
|
$arguments -contains '-frames:v' -and
|
|
$arguments -contains '1' -and
|
|
$arguments -contains '0:v:0' -and
|
|
$arguments -contains "$RTSPPublishBase/monitor2")
|
|
}
|
|
|
|
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 ($InstallDependenciesOnly) {
|
|
$installMutex = New-Object System.Threading.Mutex($false, "Local\ScreenRtspWatchdog")
|
|
$ownsInstallMutex = $false
|
|
try {
|
|
$ownsInstallMutex = $installMutex.WaitOne(0, $false)
|
|
if (-not $ownsInstallMutex) {
|
|
throw "실행 중인 Watchdog가 GPU 인코더를 사용하고 있습니다. 완전히 중지한 뒤 호환성 시험을 실행하십시오."
|
|
}
|
|
Assert-RequiredFiles
|
|
Ensure-MediaMTXConfig
|
|
$compatibility = $null
|
|
try {
|
|
$compatibility = Resolve-FFmpegCompatibility
|
|
$confirmation = Confirm-HardwareEncoderForEnvironment `
|
|
-EncoderTests $compatibility.Tests -Force:$ForceCompatibilityTest
|
|
}
|
|
catch {
|
|
if ($compatibility -and $compatibility.Tests) {
|
|
Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
|
|
-Encoder $null -Fingerprint (Get-EnvironmentFingerprint) `
|
|
-FinalTest ([PSCustomObject]@{
|
|
Passed=$false;Cached=$false;FailureReason=$_.Exception.Message
|
|
})
|
|
}
|
|
throw
|
|
}
|
|
$encoder = $confirmation.Encoder
|
|
Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
|
|
-Encoder $encoder -Fingerprint $confirmation.Fingerprint `
|
|
-FinalTest $confirmation.FinalTest
|
|
$ffmpegVersion = Get-ExecutableVersionInformation -Path $FFmpegPath -Kind FFmpeg
|
|
$mediaMTXVersion = Get-ExecutableVersionInformation -Path $MediaMTXPath -Kind MediaMTX
|
|
[PSCustomObject]@{
|
|
FFmpegPath = $FFmpegPath
|
|
FFmpegVersion = $ffmpegVersion.Version
|
|
MediaMTXPath = $MediaMTXPath
|
|
MediaMTXVersion = $mediaMTXVersion.Version
|
|
SelectedEncoder = $encoder.Name
|
|
CompatibilityPassed = $confirmation.FinalTest.Passed
|
|
CompatibilityCached = [bool]$confirmation.FinalTest.Cached
|
|
} | Format-List
|
|
exit 0
|
|
}
|
|
finally {
|
|
if ($ownsInstallMutex) { try { $installMutex.ReleaseMutex() } catch { } }
|
|
$installMutex.Dispose()
|
|
}
|
|
}
|
|
|
|
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
|
|
$compatibility = $null
|
|
try {
|
|
$compatibility = Resolve-FFmpegCompatibility
|
|
$confirmation = Confirm-HardwareEncoderForEnvironment `
|
|
-EncoderTests $compatibility.Tests -Force:$ForceCompatibilityTest
|
|
$encoder = $confirmation.Encoder
|
|
Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
|
|
-Encoder $encoder -Fingerprint $confirmation.Fingerprint `
|
|
-FinalTest $confirmation.FinalTest
|
|
}
|
|
catch {
|
|
if ($compatibility -and $compatibility.Tests) {
|
|
Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
|
|
-Encoder $null -Fingerprint (Get-EnvironmentFingerprint) `
|
|
-FinalTest ([PSCustomObject]@{
|
|
Passed=$false;Cached=$false;FailureReason=$_.Exception.Message
|
|
})
|
|
}
|
|
throw
|
|
}
|
|
$runtimeEnvironmentSignature = Get-RuntimeEnvironmentSignature
|
|
|
|
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" }
|
|
Write-Log (
|
|
"RTSP 프레임 검사: ${RtspHealthCheckIntervalSec}초 주기 / " +
|
|
"${RtspHealthTimeoutSec}초 제한 / " +
|
|
"${RtspHealthFailureThreshold}회 연속 실패 시 재시작"
|
|
)
|
|
|
|
$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) {
|
|
$currentEnvironmentSignature = Get-RuntimeEnvironmentSignature
|
|
$lastGpuCheck = Get-Date
|
|
if ($currentEnvironmentSignature -ne $runtimeEnvironmentSignature) {
|
|
Write-Log "GPU, 드라이버 또는 실행 파일 변경 감지"
|
|
Stop-Streams
|
|
$compatibility = Resolve-FFmpegCompatibility
|
|
$confirmation = Confirm-HardwareEncoderForEnvironment `
|
|
-EncoderTests $compatibility.Tests
|
|
$encoder = $confirmation.Encoder
|
|
Update-InstallationEnvironmentInfo -EncoderTests $compatibility.Tests `
|
|
-Encoder $encoder -Fingerprint $confirmation.Fingerprint `
|
|
-FinalTest $confirmation.FinalTest
|
|
$runtimeEnvironmentSignature = Get-RuntimeEnvironmentSignature
|
|
$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
|
|
}
|
|
|
|
# A live FFmpeg PID does not prove that DDA and both RTSP outputs
|
|
# are progressing. A new local receiver must decode one frame from
|
|
# every channel within the configured deadline.
|
|
if ((Test-StreamsRunning) -and
|
|
((Get-Date) - $script:LastRtspHealthCheck).TotalSeconds -ge
|
|
$RtspHealthCheckIntervalSec) {
|
|
$script:LastRtspHealthCheck = Get-Date
|
|
$rtspHealth = Test-RtspStreamFrames
|
|
|
|
if ($rtspHealth.Healthy) {
|
|
if ($script:RtspHealthFailures -gt 0) {
|
|
Write-Log "RTSP 실제 프레임 수신 상태 복구"
|
|
}
|
|
$script:RtspHealthFailures = 0
|
|
}
|
|
else {
|
|
$script:RtspHealthFailures++
|
|
Write-Log (
|
|
"RTSP 실제 프레임 검사 실패 " +
|
|
"($($script:RtspHealthFailures)/$RtspHealthFailureThreshold): " +
|
|
$rtspHealth.Reason
|
|
)
|
|
|
|
if ($script:RtspHealthFailures -ge $RtspHealthFailureThreshold) {
|
|
Write-Log "RTSP 송출 정지 감지 - 전체 AV 파이프라인 재시작"
|
|
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() }
|
|
}
|