Files
RTSP/screen-rtsp.ps1
T

1685 lines
62 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,
[string]$ConfigPath = "",
[switch]$InstallStartup,
[switch]$UninstallStartup,
[switch]$StartupStatus,
[switch]$ShowConfiguration,
[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")
}
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
}
}
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
} | 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-RequiredFiles {
foreach ($path in @($FFmpegPath, $MediaMTXPath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "필수 파일을 찾을 수 없습니다: $path"
}
}
}
function Ensure-MediaMTXConfig {
$config = @"
# generated by Screen RTSP Watchdog
rtsp: yes
rtspAddress: :$RtspPort
rtspTransports: [tcp]
rtmp: no
hls: no
webrtc: no
srt: no
moq: no
paths:
all_others:
"@
if (Test-Path -LiteralPath $MediaMTXConfig -PathType Leaf) {
$existingConfig = Get-Content -LiteralPath $MediaMTXConfig -Raw
if ($existingConfig -notmatch '^# generated by Screen RTSP Watchdog') {
$configuredPort = 8554
if ($existingConfig -match '(?m)^\s*rtspAddress:\s*(?:[^:\s]+)?:(\d+)\s*$') {
$configuredPort = [int]$Matches[1]
}
if ($configuredPort -ne $RtspPort) {
throw "mediamtx.yml RTSP 포트($configuredPort)와 -RtspPort($RtspPort)가 다릅니다."
}
return
}
if ($existingConfig.Trim() -eq $config.Trim()) { return }
}
[System.IO.File]::WriteAllText(
$MediaMTXConfig,
$config,
(New-Object System.Text.UTF8Encoding($false))
)
Write-Log "MediaMTX 기본 설정 생성/갱신: $MediaMTXConfig"
}
function Test-FFmpegCapabilities {
$filters = (& $FFmpegPath -hide_banner -filters 2>&1 | Out-String)
if ($filters -notmatch '(?m)\bddagrab\b') {
throw "현재 FFmpeg에 ddagrab 필터가 없습니다."
}
$encoders = (& $FFmpegPath -hide_banner -encoders 2>&1 | Out-String)
if ($encoders -notmatch '(?m)\bh264_(nvenc|amf|qsv)\b') {
throw "현재 FFmpeg에 지원 가능한 H.264 하드웨어 인코더가 없습니다."
}
if ($AudioEnabled -eq 1 -and $encoders -notmatch '(?m)\baac\b') {
throw "오디오 캡처가 켜져 있지만 현재 FFmpeg에 AAC 인코더가 없습니다."
}
return $encoders
}
function Invoke-ProcessWithTimeout {
param(
[string]$FilePath,
[string[]]$ArgumentList,
[int]$TimeoutMilliseconds = 10000
)
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = Join-ProcessArguments $ArgumentList
$startInfo.WorkingDirectory = $BaseDir
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process = New-Object System.Diagnostics.Process
$process.StartInfo = $startInfo
[void]$process.Start()
if (-not $process.WaitForExit($TimeoutMilliseconds)) {
try { $process.Kill() } catch { }
return [PSCustomObject]@{ ExitCode = -1; Output = "Timed out" }
}
$output = $process.StandardOutput.ReadToEnd() + $process.StandardError.ReadToEnd()
return [PSCustomObject]@{ ExitCode = $process.ExitCode; Output = $output }
}
function Get-GpuInventory {
return @(
Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue |
ForEach-Object {
$vendor = $null
$identity = "$($_.Name) $($_.AdapterCompatibility) $($_.PNPDeviceID)"
if ($identity -match '(?i)NVIDIA|VEN_10DE') { $vendor = "NVIDIA" }
elseif ($identity -match '(?i)\bAMD\b|\bATI\b|Advanced Micro Devices|VEN_(1002|1022)') { $vendor = "AMD" }
elseif ($identity -match '(?i)Intel|VEN_8086') { $vendor = "Intel" }
if ($vendor) {
[PSCustomObject]@{
Vendor = $vendor
Name = $_.Name
DriverVersion = $_.DriverVersion
PnpDeviceId = $_.PNPDeviceID
Status = $_.Status
}
}
}
)
}
function Get-GpuSignature {
return ((Get-GpuInventory | Sort-Object Vendor,Name | ForEach-Object {
"{0}:{1}:{2}:{3}" -f $_.Vendor,$_.Name,$_.DriverVersion,$_.Status
}) -join "|")
}
function Get-EncoderDefinition {
param([string]$Vendor)
switch ($Vendor) {
"NVIDIA" {
return [PSCustomObject]@{
Vendor = "NVIDIA"; Name = "h264_nvenc"
Arguments = @("-preset","p1","-tune","ull","-rc","cbr","-zerolatency","1")
}
}
"AMD" {
return [PSCustomObject]@{
Vendor = "AMD"; Name = "h264_amf"
Arguments = @("-usage","lowlatency","-quality","speed","-rc","cbr","-latency","1")
}
}
"Intel" {
return [PSCustomObject]@{
Vendor = "Intel"; Name = "h264_qsv"
Arguments = @("-preset","veryfast","-look_ahead","0","-low_delay_brc","1")
}
}
}
}
function Test-HardwareEncoder {
param($Definition)
$arguments = @(
"-hide_banner", "-loglevel", "error", "-nostdin",
"-f", "lavfi", "-i", "color=c=black:s=128x128:r=30:d=0.1",
"-frames:v", "1", "-vf", "format=nv12",
"-c:v", $Definition.Name
) + $Definition.Arguments + @("-b:v", "1000k", "-f", "null", "NUL")
$result = Invoke-ProcessWithTimeout -FilePath $FFmpegPath -ArgumentList $arguments `
-TimeoutMilliseconds 8000
return ($result.ExitCode -eq 0)
}
function Select-HardwareEncoder {
param([string]$EncoderList)
$inventory = @(Get-GpuInventory)
foreach ($vendor in $EncoderPriority) {
if (-not ($inventory | Where-Object { $_.Vendor -eq $vendor -and $_.Status -eq "OK" })) {
continue
}
$definition = Get-EncoderDefinition $vendor
if ($EncoderList -notmatch [regex]::Escape($definition.Name)) { continue }
Write-Log "하드웨어 인코더 시험: $vendor / $($definition.Name)"
if (Test-HardwareEncoder $definition) {
$gpuNames = (($inventory | Where-Object Vendor -eq $vendor).Name -join ", ")
Write-Log "하드웨어 인코더 선택: $($definition.Name) ($gpuNames)"
return $definition
}
Write-Log "하드웨어 인코더 사용 불가: $($definition.Name)"
}
throw "감지된 GPU에서 사용할 수 있는 H.264 하드웨어 인코더가 없습니다."
}
Add-Type -AssemblyName System.Windows.Forms
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
}
$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 = $null
$capturableScreenCount = @($screens | Where-Object { $_.OutputIndex -ge 0 }).Count
if ($screens.Count -lt $ChannelCount) {
$reason = "활성 모니터 $ChannelCount 개 미만"
}
elseif ($capturableScreenCount -lt $ChannelCount) {
$reason = "기본 D3D11 adapter에서 캡처 가능한 모니터 $ChannelCount 개 미만"
}
if (-not $reason) { $reason = Test-NetworkHealth $network }
if (-not $reason -and $AudioEnabled -eq 1 -and -not $endpoint) {
$reason = "활성 오디오 출력 장치 없음"
}
if ($reason) {
if ($reason -ne $lastReason) { Write-Log "송출 조건 불충족: $reason" }
$lastReason = $reason
Stop-Streams
$healthySince = $null
Start-Sleep -Seconds $PollIntervalSec
continue
}
if ($lastReason) {
Write-Log "송출 조건 복구"
$lastReason = ""
}
if (($StreamProcesses.Count -gt 0 -or $AudioProcesses.Count -gt 0) -and
-not (Test-StreamsRunning)) {
Write-Log "FFmpeg 또는 보조 프로세스 비정상 종료 감지"
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() }
}