Files
RTSP/screen-rtsp.ps1
T

4228 lines
174 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 = 20,
[ValidateRange(1, 10)]
[int]$GopSeconds = 2,
[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,
[ValidateRange(0, 600)]
[int]$RtspHealthStartupGraceSec = 45,
[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 maximumSilenceFrames = Math.Max(1, (int)(format.SamplesPerSec / 20));
long framesWritten = 0;
System.Diagnostics.Stopwatch streamClock =
System.Diagnostics.Stopwatch.StartNew();
while (pipe.IsConnected)
{
uint packetFrames;
ThrowIfFailed(capture.GetNextPacketSize(out packetFrames),
"IAudioCaptureClient.GetNextPacketSize");
if (packetFrames == 0)
{
// Sleep(2) can wake at roughly 15.6 ms on Windows. A
// fixed 10 ms block per wake therefore makes the audio
// clock run slow. Pace silence from elapsed wall time.
long targetFrames = (long)(
streamClock.Elapsed.TotalSeconds * format.SamplesPerSec);
long missingFrames = targetFrames - framesWritten;
if (missingFrames > 0)
{
int silenceFrames = (int)Math.Min(
missingFrames, maximumSilenceFrames);
int silenceBytes = checked(silenceFrames * format.BlockAlign);
if (buffer.Length < silenceBytes) buffer = new byte[silenceBytes];
Array.Clear(buffer, 0, silenceBytes);
pipe.Write(buffer, 0, silenceBytes);
framesWritten += silenceFrames;
}
Thread.Sleep(1);
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);
framesWritten += frames;
}
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", "GopSeconds", "Bitrate", "EncoderPriority")
Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold")
Network = @("NicDescription", "HealthTarget")
RTSP = @(
"Port",
"HealthCheckIntervalSec",
"HealthTimeoutSec",
"HealthFailureThreshold",
"StartupGraceSec"
)
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" "GopSeconds"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("GopSeconds")) {
$GopSeconds = [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 "RTSP" "StartupGraceSec"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthStartupGraceSec")) {
$RtspHealthStartupGraceSec = [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
}
}
function Assert-IntegerRange {
param(
[string]$Name,
[int]$Value,
[int]$Minimum,
[int]$Maximum
)
if ($Value -lt $Minimum -or $Value -gt $Maximum) {
throw "$Name 값은 $Minimum 이상 $Maximum 이하여야 합니다: $Value"
}
}
# Parameter validation runs before config.ini is loaded. Validate the final
# effective values again so an invalid INI value cannot bypass ValidateRange.
Assert-IntegerRange "General.ChannelCount" $ChannelCount 1 16
Assert-IntegerRange "General.StableTimeSec" $StableTimeSec 1 60
Assert-IntegerRange "General.PollIntervalSec" $PollIntervalSec 1 60
Assert-IntegerRange "Video.FrameRate" $FrameRate 1 240
Assert-IntegerRange "Video.GopSeconds" $GopSeconds 1 10
Assert-IntegerRange "RTSP.Port" $RtspPort 1 65535
Assert-IntegerRange "RTSP.HealthCheckIntervalSec" $RtspHealthCheckIntervalSec 1 3600
Assert-IntegerRange "RTSP.HealthTimeoutSec" $RtspHealthTimeoutSec 1 60
Assert-IntegerRange "RTSP.HealthFailureThreshold" $RtspHealthFailureThreshold 1 10
Assert-IntegerRange "RTSP.StartupGraceSec" $RtspHealthStartupGraceSec 0 600
if ($VideoBitrate -notmatch '^[0-9]+[kKmM]$') {
throw "Video.Bitrate 형식이 잘못되었습니다: $VideoBitrate"
}
if ($AudioBitrate -notmatch '^[0-9]+[kKmM]$') {
throw "Audio.Bitrate 형식이 잘못되었습니다: $AudioBitrate"
}
if ($AudioSignalThreshold -lt 0.0 -or $AudioSignalThreshold -gt 1.0) {
throw "Audio.SignalThreshold 값은 0.0 이상 1.0 이하여야 합니다: $AudioSignalThreshold"
}
$GopSize = $FrameRate * $GopSeconds
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,
"-GopSeconds", [string]$GopSeconds,
"-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,
"-RtspHealthStartupGraceSec", [string]$RtspHealthStartupGraceSec,
"-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
GopSeconds = $GopSeconds
GopSize = $GopSize
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
RtspHealthStartupGraceSec = $RtspHealthStartupGraceSec
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]$GopSize
)
}
}
"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)
$testGopSize = [Math]::Max(1, [int]$GopSize)
$effectiveTestSeconds = [Math]::Max([Math]::Max(3, $TestSeconds), (($GopSeconds * 2) + 1))
$frameCount = $testFrameRate * $effectiveTestSeconds
$expectedIdrFrames = [Math]::Max(3, [int][Math]::Floor(($frameCount - 1) / $testGopSize) + 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]$testGopSize, "-bf", "0",
"-force_key_frames", "expr:gte(t,n_forced*$GopSeconds)",
"-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 = $effectiveTestSeconds
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|$GopSeconds|$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
$script:StreamStartedAt = [DateTime]::MinValue
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
$script:StreamStartedAt = [DateTime]::MinValue
}
function Get-StreamProcessDiagnosticLines {
$lines = New-Object System.Collections.Generic.List[string]
foreach ($group in @(
[PSCustomObject]@{Name="FFmpeg"; Processes=$script:StreamProcesses},
[PSCustomObject]@{Name="Audio"; Processes=$script:AudioProcesses}
)) {
foreach ($entry in $group.Processes.GetEnumerator()) {
$process = $entry.Value
try {
$process.Refresh()
if ($process.HasExited) {
$lines.Add("$($group.Name)/$($entry.Key): PID=$($process.Id), 종료, ExitCode=$($process.ExitCode)")
}
else {
$lines.Add("$($group.Name)/$($entry.Key): PID=$($process.Id), 실행 중")
}
}
catch {
$lines.Add("$($group.Name)/$($entry.Key): 상태 확인 실패 - $($_.Exception.Message)")
}
}
}
return @($lines)
}
function Write-StreamFailureDiagnostics {
param([string]$Reason)
Write-Log "AV 파이프라인 진단: $Reason"
foreach ($line in @(Get-StreamProcessDiagnosticLines)) {
Write-Log "프로세스: $line"
}
foreach ($path in @(
(Join-Path $LogDir "ffmpeg.log"),
(Join-Path $LogDir "audio-loopback.log")
)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
$tail = @(Get-Content -LiteralPath $path -Tail 8 -ErrorAction SilentlyContinue)
foreach ($line in $tail) {
if (-not [string]::IsNullOrWhiteSpace($line)) {
Write-Log "$([System.IO.Path]::GetFileName($path)): $line"
}
}
}
}
function Stop-OrphanedPipelineProcesses {
$scriptPath = [System.IO.Path]::GetFullPath($PSCommandPath)
$candidates = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object { $_.Name -in @("ffmpeg.exe", "powershell.exe") })
foreach ($candidate in $candidates) {
$owned = $false
if ($candidate.Name -eq "ffmpeg.exe" -and
-not [string]::IsNullOrWhiteSpace($candidate.ExecutablePath)) {
$owned = [string]::Equals(
[System.IO.Path]::GetFullPath($candidate.ExecutablePath),
[System.IO.Path]::GetFullPath($FFmpegPath),
[System.StringComparison]::OrdinalIgnoreCase
)
}
elseif ($candidate.Name -eq "powershell.exe" -and $candidate.CommandLine) {
$owned = ($candidate.CommandLine.IndexOf(
$scriptPath,
[System.StringComparison]::OrdinalIgnoreCase
) -ge 0 -and $candidate.CommandLine -match '(?i)-AudioWorker(?:\s|$)')
}
if (-not $owned -or $candidate.ProcessId -eq $PID) { continue }
Write-Log "이전 Watchdog의 잔여 프로세스 정리: $($candidate.Name) PID=$($candidate.ProcessId)"
Stop-Process -Id $candidate.ProcessId -Force -ErrorAction SilentlyContinue
}
}
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(',') }
# ddagrab emits one probe frame before slower inputs (notably DirectShow)
# finish opening. Drop that stale frame, then preserve DDA elapsed-time PTS.
# Do not synthesize missed frames: doing so creates an encode backlog and
# makes media time lag wall time on an overloaded GPU.
$filters += @(
"trim=start_frame=1", "setpts=PTS-STARTPTS", "settb=AVTB",
"format=nv12"
)
return ($filters -join ",")
}
function New-FFmpegArguments {
param(
$SelectedScreens,
$PlaybackEndpoint,
$MicrophoneEndpoint,
[string]$MicrophoneInputName,
$Encoder,
[string]$AudioPipePath
)
$arguments = @(
"-hide_banner", "-loglevel", "warning", "-nostdin"
)
$filterParts = @()
$videoInputOffset = 0
if ($AudioEnabled -eq 1) {
# Open slower audio devices first. ddagrab creates a probe frame as
# soon as its input is opened; opening it before DirectShow gives the
# first two DDA frames timestamps that are several seconds apart.
$playbackInputIndex = 0
$arguments += @(
"-thread_queue_size", "1024", "-f", $PlaybackEndpoint.SampleFormat,
"-ar", $PlaybackEndpoint.SampleRate, "-ac", $PlaybackEndpoint.Channels,
"-i", $AudioPipePath
)
$filterParts += "[$playbackInputIndex`:a]asetpts=PTS-STARTPTS," +
"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", "-rtbufsize", "32M", "-f", "dshow",
"-i", "audio=$MicrophoneInputName"
)
$filterParts += "[$microphoneInputIndex`:a]asetpts=PTS-STARTPTS," +
"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]"
$videoInputOffset = 2
}
else {
$filterParts += "[sys]anull[mixed]"
$videoInputOffset = 1
}
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"
}
}
for ($index = 0; $index -lt $SelectedScreens.Count; $index++) {
$screen = $SelectedScreens[$index]
$videoInputIndex = $videoInputOffset + $index
$ddaInput = "ddagrab=output_idx=$($screen.OutputIndex):draw_mouse=1:framerate=${FrameRate}:dup_frames=1"
$arguments += @("-f", "lavfi", "-i", $ddaInput)
$filterParts += "[$videoInputIndex`:v]$(Get-VideoFilter $screen)[v$index]"
}
$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", $GopSize, "-bf", "0", "-pix_fmt", "nv12",
"-force_key_frames", "expr:gte(t,n_forced*$GopSeconds)",
"-enc_time_base", "filter",
"-fps_mode", "passthrough"
)
if ($AudioEnabled -eq 1) {
$arguments += @(
"-c:a", "aac", "-b:a", $AudioBitrate, "-ar", "48000", "-ac", "2"
)
}
$arguments += @(
"-f", "rtsp", "-max_interleave_delta", "100000", "-flush_packets", "1",
"-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
$script:StreamStartedAt = Get-Date
}
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 Test-RtspHealthCheckDue {
param([datetime]$Now = (Get-Date))
if (-not (Test-StreamsRunning)) { return $false }
if ($script:StreamStartedAt -eq [DateTime]::MinValue) { return $false }
if (($Now - $script:StreamStartedAt).TotalSeconds -lt $RtspHealthStartupGraceSec) {
return $false
}
return (($Now - $script:LastRtspHealthCheck).TotalSeconds -ge
$RtspHealthCheckIntervalSec)
}
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" +
"StartupGraceSec=45`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" -and
$parsed.RTSP.StartupGraceSec -eq "45")
}
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 "무음 WASAPI loopback은 경과시간 기준으로 PCM 시계 유지" {
return ($AudioInteropSource -match 'streamClock\.Elapsed\.TotalSeconds' -and
$AudioInteropSource -match 'targetFrames\s*-\s*framesWritten' -and
$AudioInteropSource -match 'framesWritten\s*\+=\s*silenceFrames' -and
$AudioInteropSource -notmatch 'packetClock\.Restart')
}
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 '-RtspHealthStartupGraceSec\s+45' -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
@([regex]::Matches($filter, 'asetpts=PTS-STARTPTS')).Count -eq 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 "DDA와 인코더가 20fps/GOP40 및 원본 PTS를 일관되게 사용" {
$script:AudioEnabled = 1
$script:MicrophoneEnabled = 1
$arguments = @(New-FFmpegArguments `
-SelectedScreens @($screenPrimary,$screenSecond) `
-PlaybackEndpoint $defaultAudio -MicrophoneEndpoint $defaultMicrophone `
-MicrophoneInputName $defaultMicrophone.Name `
-Encoder (Get-EncoderDefinition "Intel") -AudioPipePath "\\.\pipe\simulation")
$filterIndex = [Array]::IndexOf($arguments, "-filter_complex")
$filter = if ($filterIndex -ge 0) { $arguments[$filterIndex + 1] } else { "" }
$joined = $arguments -join " "
$pipePosition = $joined.IndexOf('\\.\pipe\simulation')
$ddaPosition = $joined.IndexOf('ddagrab=')
return ($FrameRate -eq 20 -and $GopSize -eq 40 -and
$pipePosition -ge 0 -and $ddaPosition -gt $pipePosition -and
$joined -match 'framerate=20' -and
$filter -match 'trim=start_frame=1' -and
$filter -match 'setpts=PTS-STARTPTS' -and $filter -match 'settb=AVTB' -and
$filter -notmatch 'setpts=N/' -and
@($arguments | Where-Object { $_ -eq "40" }).Count -ge 2 -and
@($arguments | Where-Object { $_ -eq "filter" }).Count -eq 2 -and
@($arguments | Where-Object { $_ -eq "passthrough" }).Count -eq 2 -and
@($arguments | Where-Object { $_ -eq "100000" }).Count -eq 2 -and
$joined -match 'n_forced\*2')
}
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 "RTSP 시작 유예 중에는 프레임 검사를 실행하지 않음" {
$savedAudioEnabled = $script:AudioEnabled
$savedStreamProcesses = $script:StreamProcesses
$savedAudioProcesses = $script:AudioProcesses
$savedStartedAt = $script:StreamStartedAt
$savedLastCheck = $script:LastRtspHealthCheck
try {
$now = Get-Date
$alive = [PSCustomObject]@{HasExited=$false}
$script:AudioEnabled = 0
$script:StreamProcesses = @{pipeline=$alive}
$script:AudioProcesses = @{}
$script:LastRtspHealthCheck = $now.AddHours(-1)
$script:StreamStartedAt = $now
$blockedDuringGrace = -not (Test-RtspHealthCheckDue -Now $now)
$script:StreamStartedAt = $now.AddSeconds(-($RtspHealthStartupGraceSec + 1))
$allowedAfterGrace = Test-RtspHealthCheckDue -Now $now
return ($blockedDuringGrace -and $allowedAfterGrace)
}
finally {
$script:AudioEnabled = $savedAudioEnabled
$script:StreamProcesses = $savedStreamProcesses
$script:AudioProcesses = $savedAudioProcesses
$script:StreamStartedAt = $savedStartedAt
$script:LastRtspHealthCheck = $savedLastCheck
}
}
Invoke-SimulationCase "INI FPS 허용 범위 밖 값 거부" {
try {
Assert-IntegerRange "Video.FrameRate" 0 1 240
return $false
}
catch { return $true }
}
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가 이미 실행 중입니다." }
Stop-OrphanedPipelineProcesses
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 프레임 검사: 시작 후 ${RtspHealthStartupGraceSec}초 유예 / " +
"${RtspHealthCheckIntervalSec}초 주기 / ${RtspHealthTimeoutSec}초 제한 / " +
"${RtspHealthFailureThreshold}회 연속 실패 시 재시작"
)
Write-Log "영상 설정: ${FrameRate}fps / GOP ${GopSize}프레임(${GopSeconds}초) / DDA PTS 유지 / 채널당 $VideoBitrate"
$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 또는 보조 프로세스 비정상 종료 감지"
Write-StreamFailureDiagnostics "프로세스 종료 감지"
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-RtspHealthCheckDue) {
$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 파이프라인 재시작"
Write-StreamFailureDiagnostics $rtspHealth.Reason
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() }
}