#nullable enable using System.Diagnostics; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Text.RegularExpressions; using System.Threading; using T3.Core.Utils; // ReSharper disable MemberCanBePrivate.Global namespace Lib.io.dmx; [Guid("e5a8d9e6-3c5a-4bbb-9da3-737b6330b9c3")] internal sealed class SacnOutput : Instance, IStatusProvider, ICustomDropdownHolder, IDisposable { private const int SacnPort = 5568; private const string SacnDiscoveryIp = "239.255.250.214"; [Output(Guid = "a3c4a2e8-bc1b-453a-9773-1952a6ea10a3")] public readonly Slot Result = new(); // --- State and Configuration --- private readonly ConnectionSettings _connectionSettings = new(); private readonly object _dataLock = new(); private readonly object _connectionLock = new(); private bool _wasSendingLastFrame; private string? _lastErrorMessage; private IStatusProvider.StatusLevel _lastStatusLevel = IStatusProvider.StatusLevel.Notice; private readonly byte[] _cid = Guid.NewGuid().ToByteArray(); private double _lastNetworkRefreshTime; private double _lastRetryTime; // --- High-Performance Sending Resources --- private Thread? _senderThread; private CancellationTokenSource? _senderCts; private SacnPacketOptions _packetOptions; private readonly byte[] _packetBuffer = new byte[126 + 512]; private readonly byte[] _universeSequenceNumbers = new byte[65536]; // --- MULTI-THREADING OPTIMIZATION: Zero-Allocation Snapshot Architecture --- private readonly ConcurrentQueue _dataQueue = new(); private readonly ConcurrentBag _frameDataPool = new(); private struct InputMapping { public int Offset; public int Count; public int StartUniverse; } private sealed class FrameData { public int[] Buffer = Array.Empty(); public InputMapping[] Mappings = Array.Empty(); public int MappingCount; } private FrameData RentFrameData(int requiredChannels, int requiredMappings) { if (_frameDataPool.TryTake(out var fd)) { if (fd.Buffer.Length < requiredChannels) fd.Buffer = new int[requiredChannels + 1024]; if (fd.Mappings.Length < requiredMappings) fd.Mappings = new InputMapping[requiredMappings + 4]; return fd; } return new FrameData { Buffer = new int[requiredChannels + 1024], Mappings = new InputMapping[requiredMappings + 4] }; } // --- Discovery & Network --- private Thread? _discoveryListenerThread; private volatile bool _isDiscovering; private UdpClient? _discoveryUdpClient; private readonly ConcurrentDictionary _discoveredSources = new(); private Socket? _socket; private volatile bool _connected; public SacnOutput() { Result.UpdateAction = Update; } private void Update(EvaluationContext context) { var localIpString = LocalIpAddress.GetValue(context); if (string.IsNullOrEmpty(localIpString) && context.LocalTime - _lastNetworkRefreshTime > 5.0) { _lastNetworkRefreshTime = context.LocalTime; _networkInterfaces = GetNetworkInterfaces(); } var settingsChanged = _connectionSettings.Update( localIpString, TargetIpAddress.GetValue(context), SendUnicast.GetValue(context) ); bool needsReconnect = Reconnect.GetValue(context) || settingsChanged; bool shouldAutoRevive = !_connected && _connectionSettings.LocalIp != null && (_lastRetryTime == 0 || context.LocalTime - _lastRetryTime > 2.0); if (needsReconnect || shouldAutoRevive) { Reconnect.SetTypedInputValue(false); bool needsThreadRestart = _wasSendingLastFrame && _senderThread != null; if (needsThreadRestart) StopSenderThread(); lock (_connectionLock) { CloseSocket(); _connected = TryConnectSacn(_connectionSettings.LocalIp); if (shouldAutoRevive) _lastRetryTime = context.LocalTime; } if (needsThreadRestart) StartSenderThread(); } var discoverSources = DiscoverSources.GetValue(context); if (discoverSources && !_isDiscovering) StartSacnDiscovery(); else if (!discoverSources && _isDiscovering) StopSacnDiscovery(); var enableSending = SendTrigger.GetValue(context); if (enableSending != _wasSendingLastFrame) { if (enableSending) StartSenderThread(); else StopSenderThread(); _wasSendingLastFrame = enableSending; } if (!enableSending) { SetStatus("Sending is disabled. Enable 'Send Trigger'.", IStatusProvider.StatusLevel.Notice); return; } lock (_connectionLock) { if (!_connected) { SetStatus($"Not connected. {(_lastErrorMessage ?? "Check settings.")}", IStatusProvider.StatusLevel.Warning); return; } } SetStatus("Connected and sending.", IStatusProvider.StatusLevel.Success); // --- Calculate Size and Prepare Offload --- var inputValueLists = InputsValues.GetCollectedTypedInputs(); int totalChannels = 0; // Fast pre-pass to determine memory size for (int i = 0; i < inputValueLists.Count; i++) { var buf = inputValueLists[i].GetValue(context); if (buf != null) totalChannels += buf.Count; } if (totalChannels == 0) return; var universeChannels = UniverseChannels.GetValue(context); if (universeChannels == null || universeChannels.Count < inputValueLists.Count) { if (universeChannels == null) universeChannels = new List(); int nextUniverse = universeChannels.Count > 0 ? universeChannels[^1] + 1 : 1; while (universeChannels.Count < inputValueLists.Count) { universeChannels.Add(nextUniverse); nextUniverse++; } UniverseChannels.SetTypedInputValue(universeChannels); } // Rent pooled object for background thread (No allocation!) var frameData = RentFrameData(totalChannels, inputValueLists.Count); frameData.MappingCount = 0; int currentOffset = 0; // Perform hyper-fast native memory copy. // We do ZERO math on the main thread now. for (int i = 0; i < inputValueLists.Count; i++) { var buf = inputValueLists[i].GetValue(context); if (buf == null || buf.Count == 0) continue; buf.CopyTo(frameData.Buffer, currentOffset); frameData.Mappings[frameData.MappingCount++] = new InputMapping { Offset = currentOffset, Count = buf.Count, StartUniverse = universeChannels[i] }; currentOffset += buf.Count; } _packetOptions.MaxFps = MaxFps.GetValue(context); _packetOptions.Priority = (byte)Priority.GetValue(context).Clamp(0, 200); _packetOptions.SourceName = SourceName.GetValue(context) ?? string.Empty; _packetOptions.EnableSync = EnableSync.GetValue(context); _packetOptions.SyncUniverse = (ushort)SyncUniverse.GetValue(context).Clamp(1, 63999); _dataQueue.Enqueue(frameData); // Keep queue extremely tight. 1 active, 1 waiting max. while (_dataQueue.Count > 2) { if (_dataQueue.TryDequeue(out var oldData)) { _frameDataPool.Add(oldData); } } } #region Sender Thread Management and Loop private void StartSenderThread() { if (_senderThread != null) return; _senderCts = new CancellationTokenSource(); _senderThread = new Thread(() => SenderLoop(_senderCts.Token)) { IsBackground = true, Name = "sACNSender", Priority = ThreadPriority.AboveNormal }; _senderThread.Start(); } private void StopSenderThread() { if (_senderThread == null) return; _senderCts?.Cancel(); if (_senderThread.Join(500)) _senderCts?.Dispose(); _senderCts = null; _senderThread = null; } private void SenderLoop(CancellationToken token) { var stopwatch = new Stopwatch(); long nextFrameTimeTicks = 0; byte syncSequenceNumber = 0; int consecutiveErrors = 0; const int MAX_CONSECUTIVE_ERRORS = 10; while (!token.IsCancellationRequested) { try { if (!_dataQueue.TryDequeue(out var frameData) || frameData == null) { Thread.Sleep(1); continue; } SacnPacketOptions optionsCopy; bool isConnected; lock (_dataLock) optionsCopy = _packetOptions; lock (_connectionLock) isConnected = _connected; if (optionsCopy.MaxFps > 0) { if (!stopwatch.IsRunning) stopwatch.Start(); while (true) { long now = stopwatch.ElapsedTicks; if (now >= nextFrameTimeTicks) { if (now > nextFrameTimeTicks + Stopwatch.Frequency) nextFrameTimeTicks = now; nextFrameTimeTicks += (long)(Stopwatch.Frequency / (double)optionsCopy.MaxFps); break; } if (nextFrameTimeTicks - now > Stopwatch.Frequency / 1000) Thread.Sleep(1); else Thread.SpinWait(10); } } Socket? currentSocket; bool useUnicast; IPEndPoint? cachedTargetEndPoint; lock (_connectionSettings) { currentSocket = _socket; if (currentSocket == null || !isConnected) { _frameDataPool.Add(frameData); Thread.Sleep(10); continue; } useUnicast = (_connectionSettings.SendUnicast && _connectionSettings.TargetIp != null) || _connectionSettings.TargetIp != null; cachedTargetEndPoint = useUnicast ? new IPEndPoint(_connectionSettings.TargetIp!, SacnPort) : null; } // Background thread now does the math and packet building for (int m = 0; m < frameData.MappingCount; m++) { if (token.IsCancellationRequested) break; var mapping = frameData.Mappings[m]; int remainingChannels = mapping.Count; int currentOffset = mapping.Offset; int currentUniverse = mapping.StartUniverse; while (remainingChannels > 0) { if (token.IsCancellationRequested) break; int chunkCount = Math.Min(remainingChannels, 512); byte seq = _universeSequenceNumbers[currentUniverse]++; var packetLength = BuildSacnDataPacket(currentUniverse, optionsCopy, frameData.Buffer, currentOffset, chunkCount, seq); var targetEndPoint = cachedTargetEndPoint ?? new IPEndPoint(GetSacnMulticastAddress(currentUniverse), SacnPort); bool success = SendSacnPacket(currentSocket, targetEndPoint, _packetBuffer, packetLength, currentUniverse); if (success) consecutiveErrors = 0; else { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { lock (_connectionLock) _connected = false; consecutiveErrors = 0; } } currentOffset += chunkCount; remainingChannels -= chunkCount; currentUniverse++; } } // Return native block memory to pool _frameDataPool.Add(frameData); if (optionsCopy.EnableSync) SendSacnSync(currentSocket, optionsCopy.SyncUniverse, syncSequenceNumber++); } catch (ThreadAbortException) { break; } catch (Exception) { consecutiveErrors++; if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { lock (_connectionLock) _connected = false; consecutiveErrors = 0; } Thread.Sleep(10); } } } #endregion #region Packet Sending private bool SendSacnPacket(Socket socket, IPEndPoint target, byte[] packetBuffer, int packetLength, int universe) { try { socket.SendTo(packetBuffer, packetLength, SocketFlags.None, target); return true; } catch (SocketException e) { return e.SocketErrorCode is SocketError.WouldBlock or SocketError.NoBufferSpaceAvailable or SocketError.ConnectionReset; } catch (ObjectDisposedException) { return false; } } private void SendSacnSync(Socket socket, ushort syncAddress, byte sequenceNumber) { try { var packetLength = BuildSacnSyncPacket(syncAddress, sequenceNumber); var useUnicast = _connectionSettings.TargetIp != null; var syncEndPoint = useUnicast ? new IPEndPoint(_connectionSettings.TargetIp!, SacnPort) : new IPEndPoint(GetSacnMulticastAddress(syncAddress), SacnPort); socket.SendTo(_packetBuffer, packetLength, SocketFlags.None, syncEndPoint); } catch (SocketException e) { if (e.SocketErrorCode is not (SocketError.WouldBlock or SocketError.NoBufferSpaceAvailable or SocketError.ConnectionReset)) lock (_connectionLock) _connected = false; } catch (ObjectDisposedException) { lock (_connectionLock) _connected = false; } } private int BuildSacnSyncPacket(ushort syncUniverse, byte sequenceNumber) { _packetBuffer[0] = 0x00; _packetBuffer[1] = 0x10; _packetBuffer[2] = 0x00; _packetBuffer[3] = 0x00; Encoding.ASCII.GetBytes("ASC-E1.17", 0, 9, _packetBuffer, 4); _packetBuffer[13] = 0x00; _packetBuffer[14] = 0x00; _packetBuffer[15] = 0x00; short rootFlagsAndLength = IPAddress.HostToNetworkOrder((short)(0x7000 | 31)); Array.Copy(BitConverter.GetBytes(rootFlagsAndLength), 0, _packetBuffer, 16, 2); int vector = IPAddress.HostToNetworkOrder(0x00000004); Array.Copy(BitConverter.GetBytes(vector), 0, _packetBuffer, 18, 4); Array.Copy(_cid, 0, _packetBuffer, 22, 16); short frameFlagsAndLength = IPAddress.HostToNetworkOrder((short)(0x7000 | 9)); Array.Copy(BitConverter.GetBytes(frameFlagsAndLength), 0, _packetBuffer, 38, 2); int frameVector = IPAddress.HostToNetworkOrder(0x00000001); Array.Copy(BitConverter.GetBytes(frameVector), 0, _packetBuffer, 40, 4); _packetBuffer[44] = sequenceNumber; short syncUni = IPAddress.HostToNetworkOrder((short)syncUniverse); Array.Copy(BitConverter.GetBytes(syncUni), 0, _packetBuffer, 45, 2); _packetBuffer[47] = 0x00; _packetBuffer[48] = 0x00; return 49; } private int BuildSacnDataPacket(int universe, SacnPacketOptions options, int[] rawData, int offset, int chunkCount, byte sequenceNumber) { var dmxLength = (short)chunkCount; _packetBuffer[0] = 0x00; _packetBuffer[1] = 0x10; _packetBuffer[2] = 0x00; _packetBuffer[3] = 0x00; Encoding.ASCII.GetBytes("ASC-E1.17", 0, 9, _packetBuffer, 4); _packetBuffer[13] = 0x00; _packetBuffer[14] = 0x00; _packetBuffer[15] = 0x00; short rootFlagsAndLength = IPAddress.HostToNetworkOrder((short)(0x7000 | (108 + dmxLength))); Array.Copy(BitConverter.GetBytes(rootFlagsAndLength), 0, _packetBuffer, 16, 2); int vector = IPAddress.HostToNetworkOrder(0x00000004); Array.Copy(BitConverter.GetBytes(vector), 0, _packetBuffer, 18, 4); Array.Copy(_cid, 0, _packetBuffer, 22, 16); short frameFlagsAndLength = IPAddress.HostToNetworkOrder((short)(0x7000 | (86 + dmxLength))); Array.Copy(BitConverter.GetBytes(frameFlagsAndLength), 0, _packetBuffer, 38, 2); int frameVector = IPAddress.HostToNetworkOrder(0x00000002); Array.Copy(BitConverter.GetBytes(frameVector), 0, _packetBuffer, 40, 4); Array.Clear(_packetBuffer, 44, 64); if (!string.IsNullOrEmpty(options.SourceName)) { var sourceBytes = Encoding.UTF8.GetBytes(options.SourceName); int copyCount = Math.Min(sourceBytes.Length, 63); Array.Copy(sourceBytes, 0, _packetBuffer, 44, copyCount); } _packetBuffer[108] = options.Priority; short syncUni = IPAddress.HostToNetworkOrder((short)(options.EnableSync ? options.SyncUniverse : 0)); Array.Copy(BitConverter.GetBytes(syncUni), 0, _packetBuffer, 109, 2); _packetBuffer[111] = sequenceNumber; _packetBuffer[112] = 0x00; short netUniverse = IPAddress.HostToNetworkOrder((short)universe); Array.Copy(BitConverter.GetBytes(netUniverse), 0, _packetBuffer, 113, 2); short dmpFlagsAndLength = IPAddress.HostToNetworkOrder((short)(0x7000 | (9 + dmxLength))); Array.Copy(BitConverter.GetBytes(dmpFlagsAndLength), 0, _packetBuffer, 115, 2); _packetBuffer[117] = 0x02; _packetBuffer[118] = (byte)0xa1; _packetBuffer[119] = 0x00; _packetBuffer[120] = 0x00; _packetBuffer[121] = 0x00; _packetBuffer[122] = 0x01; short propValueCount = IPAddress.HostToNetworkOrder((short)(dmxLength + 1)); Array.Copy(BitConverter.GetBytes(propValueCount), 0, _packetBuffer, 123, 2); _packetBuffer[125] = 0x00; // CRITICAL PERFORMANCE LOOP: Execute Math directly into the sending buffer for (int i = 0; i < chunkCount; i++) { int val = rawData[offset + i]; _packetBuffer[126 + i] = (byte)(val < 0 ? 0 : (val > 255 ? 255 : val)); } return 126 + dmxLength; } #endregion #region Discovery & Boilerplate Helpers private void StartSacnDiscovery() { _isDiscovering = true; _discoveredSources.Clear(); _discoveryListenerThread = new Thread(ListenForSacnDiscovery) { IsBackground = true, Name = "sACNDiscoveryListener" }; _discoveryListenerThread.Start(); } private void StopSacnDiscovery() { if (!_isDiscovering) return; _isDiscovering = false; _discoveryUdpClient?.Close(); _discoveryListenerThread?.Join(200); _discoveryListenerThread = null; } private void ListenForSacnDiscovery() { try { _discoveryUdpClient = new UdpClient(); var localEp = new IPEndPoint(IPAddress.Any, SacnPort); _discoveryUdpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _discoveryUdpClient.Client.Bind(localEp); _discoveryUdpClient.JoinMulticastGroup(IPAddress.Parse(SacnDiscoveryIp)); while (_isDiscovering) { try { var remoteEp = new IPEndPoint(IPAddress.Any, 0); var data = _discoveryUdpClient.Receive(ref remoteEp); if (data.Length <= 125) continue; var sourceName = Encoding.UTF8.GetString(data, 44, 64).TrimEnd('\0'); var ipString = remoteEp.Address.ToString(); var displayName = string.IsNullOrWhiteSpace(sourceName) ? ipString : sourceName; _discoveredSources[ipString] = $"{displayName} ({ipString})"; } catch (SocketException) { if (_isDiscovering) break; } catch (Exception e) { if (_isDiscovering) Log.Error($"sACN discovery listener error: {e.Message}", this); } } } catch (Exception e) { if (_isDiscovering) Log.Error($"sACN discovery listener failed to bind: {e.Message}", this); } finally { _discoveryUdpClient?.Close(); _discoveryUdpClient = null; } } public void Dispose() { StopSenderThread(); StopSacnDiscovery(); CloseSocket(); } private void CloseSocket() { lock (_connectionLock) { lock (_connectionSettings) { if (_socket == null) return; try { _socket.Close(); } catch { /* Ignore */ } finally { _socket = null; _connected = false; _lastErrorMessage = "Socket closed."; } } } } private bool TryConnectSacn(IPAddress? localIp) { lock (_connectionLock) { lock (_connectionSettings) { if (localIp == null) { _lastErrorMessage = "Local IP Address is not valid."; return false; } try { _socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); _socket.SendBufferSize = 10 * 1024 * 1024; _socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true); try { _socket.IOControl(-1744830452, new byte[] { 0 }, null); } catch { /* Ignore SIO_UDP_CONNRESET */ } _socket.Bind(new IPEndPoint(localIp, 0)); _socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1); _lastErrorMessage = null; return _connected = true; } catch (Exception e) { _lastErrorMessage = $"Failed to bind: {e.Message}"; CloseSocket(); return false; } } } } private static IPAddress GetSacnMulticastAddress(int universe) { var u = (ushort)universe.Clamp(1, 63999); return new IPAddress(new byte[] { 239, 255, (byte)(u >> 8), (byte)(u & 0xFF) }); } private static List _networkInterfaces = GetNetworkInterfaces(); private static List GetNetworkInterfaces() { var list = new List { new(IPAddress.Loopback, IPAddress.Parse("255.0.0.0"), "Localhost (127.0.0.1)") }; try { list.AddRange(from ni in NetworkInterface.GetAllNetworkInterfaces() where ni.OperationalStatus == OperationalStatus.Up && ni.NetworkInterfaceType != NetworkInterfaceType.Loopback from ip in ni.GetIPProperties().UnicastAddresses where ip.Address.AddressFamily == AddressFamily.InterNetwork select new NetworkAdapterInfo(ip.Address, ip.IPv4Mask, ni.Name)); } catch { /* Ignore */ } return list; } private sealed record NetworkAdapterInfo(IPAddress IpAddress, IPAddress SubnetMask, string Name) { public string DisplayName => $"{Name}: {IpAddress}"; } private struct SacnPacketOptions { public int MaxFps; public byte Priority; public string SourceName; public bool EnableSync; public ushort SyncUniverse; } private sealed class ConnectionSettings { public IPAddress? LocalIp { get; private set; } public IPAddress? TargetIp { get; private set; } public bool SendUnicast { get; private set; } private string? _lastLocalIpStr, _lastTargetIpStr; private bool _lastSendUnicast; public bool Update(string? localIpStr, string? targetIpStr, bool sendUnicast) { if (_lastLocalIpStr == localIpStr && _lastTargetIpStr == targetIpStr && _lastSendUnicast == sendUnicast) return false; _lastLocalIpStr = localIpStr; _lastTargetIpStr = targetIpStr; _lastSendUnicast = sendUnicast; SendUnicast = sendUnicast; IPAddress.TryParse(localIpStr, out var parsedLocalIp); LocalIp = parsedLocalIp; IPAddress? targetIp = null; if (LocalIp != null && LocalIp.Equals(IPAddress.Loopback)) targetIp = IPAddress.Loopback; else if (sendUnicast) IPAddress.TryParse(targetIpStr, out targetIp); TargetIp = targetIp; return true; } } public IStatusProvider.StatusLevel GetStatusLevel() => _lastStatusLevel; public string? GetStatusMessage() => _lastErrorMessage; public void SetStatus(string m, IStatusProvider.StatusLevel l) { _lastErrorMessage = m; _lastStatusLevel = l; } string ICustomDropdownHolder.GetValueForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) return LocalIpAddress.Value ?? string.Empty; if (inputId == TargetIpAddress.Id) return TargetIpAddress.Value ?? string.Empty; return string.Empty; } IEnumerable ICustomDropdownHolder.GetOptionsForInput(Guid inputId) { if (inputId == LocalIpAddress.Id) { _networkInterfaces = GetNetworkInterfaces(); foreach (var adapter in _networkInterfaces) yield return adapter.DisplayName; } else if (inputId == TargetIpAddress.Id) { if (!_isDiscovering && _discoveredSources.IsEmpty) yield return "Enable 'Discover Sources' to search..."; else if (_isDiscovering && _discoveredSources.IsEmpty) yield return "Searching for sources..."; else foreach (var sourceName in _discoveredSources.Values.OrderBy(name => name)) yield return sourceName; } } void ICustomDropdownHolder.HandleResultForInput(Guid inputId, string? selected, bool isAListItem) { if (string.IsNullOrEmpty(selected) || !isAListItem) return; if (inputId == LocalIpAddress.Id) { var foundAdapter = _networkInterfaces.FirstOrDefault(i => i.DisplayName == selected); if (foundAdapter == null) return; LocalIpAddress.SetTypedInputValue(foundAdapter.IpAddress.ToString()); } else if (inputId == TargetIpAddress.Id) { var match = Regex.Match(selected, @"\(([^)]*)\)"); TargetIpAddress.SetTypedInputValue(match.Success ? match.Groups[1].Value : selected); } } #endregion #region Inputs [Input(Guid = "2a8d39a3-5a41-477d-815a-8b8b9d8b1e4a")] public readonly MultiInputSlot> InputsValues = new(); [Input(Guid = "B2C3D4E5-F6A7-8901-BCDE-F234567890AB")] public readonly InputSlot> UniverseChannels = new(); [Input(Guid = "f8a7e0c8-c6c7-4b53-9a3a-3e5f2a4f4e1c")] public readonly InputSlot LocalIpAddress = new(); [Input(Guid = "9c233633-959f-4447-b248-4d431c1b18e7")] public readonly InputSlot SendTrigger = new(); [Input(Guid = "c2a9e3e3-a4e9-430b-9c6a-4e1a1e0b8e2e")] public readonly InputSlot Reconnect = new(); [Input(Guid = "8c6c9a8d-29c5-489e-8c6b-9e4a3c1e2b6a")] public readonly InputSlot SendUnicast = new(); [Input(Guid = "d9e8d7c6-b5a4-434a-9e3a-4e2b1d0c9a7b")] public readonly InputSlot TargetIpAddress = new(); [Input(Guid = "3f25c04c-0a88-42fb-93d3-05992b861e61")] public readonly InputSlot DiscoverSources = new(); [Input(Guid = "4a9e2d3b-8c6f-4b1d-8d7e-9f3a5b2c1d0e")] public readonly InputSlot Priority = new(100); [Input(Guid = "5b1d9c8a-7e3f-4a2b-9c8d-1e0f3a5b2c1d")] public readonly InputSlot SourceName = new("T3 sACN Output"); [Input(Guid = "6f5c4b3a-2e1d-4f9c-8a7b-3d2e1f0c9b8a")] public readonly InputSlot MaxFps = new(60); [Input(Guid = "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d")] public readonly InputSlot EnableSync = new(); [Input(Guid = "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e")] public readonly InputSlot SyncUniverse = new(1); #endregion }