Files
RTSP/screen-rtsp.ps1
T

2198 lines
83 KiB
PowerShell

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