dragon-iso/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs
Zac Gaetano 909237f454 feat(ndi): plumb NDI groups (discovery + output) through the engine
Adds an NdiGroupSettings record carrying optional comma-separated NDI group lists for the finder and the senders. Extends INdiInterop.CreateFinder / CreateSender with optional groups arguments and populates NDIlib_find_create_t.p_groups and NDIlib_send_create_t.p_groups via P/Invoke. IsoController reads the settings on construction, threads DiscoveryGroups into NdiDiscoveryService and OutputGroups into IsoPipelineConfig, and exposes SetGroupSettingsAsync for runtime updates (group changes apply on next process restart so live pipelines aren't orphaned).

This unblocks the 'transcoder' topology where Teams broadcasts NDI on a private group (e.g. teamsiso-input) and TeamsISO re-emits clean normalized streams on Public — keeping raw, wrong-framerate Teams sources off the production network.

EngineConfig schema is JSON-back-compat: existing config.json files (no NdiGroups field) deserialize with NdiGroups=null and load as NdiGroupSettings.Default. UI surface for these settings comes in a follow-up.

Tests: 72/72 passing (was 69) — added IsoController coverage that group settings are read from ConfigStore on startup, passed to the finder, threaded into per-pipeline config, and round-trip through SetGroupSettingsAsync/Save/Load.
2026-05-07 23:48:49 -04:00

279 lines
10 KiB
C#

using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Extensions.Logging;
using TeamsISO.Engine.Interop;
using TeamsISO.Engine.Pipeline;
namespace TeamsISO.Engine.NdiInterop;
/// <summary>
/// Production <see cref="INdiInterop"/> implementation backed by the NDI 6 SDK.
/// Initializes the NDI runtime on construction and tears it down on dispose.
/// All frame buffers crossing the managed boundary are copied so the engine never holds
/// a reference to NDI-owned memory after the call returns.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
{
private readonly ILogger<NdiInteropPInvoke> _logger;
private bool _initialized;
private bool _disposed;
public NdiInteropPInvoke(ILogger<NdiInteropPInvoke> logger)
{
_logger = logger;
if (!NdiNative.Initialize())
throw new InvalidOperationException(
"NDI runtime failed to initialize. Ensure the NDI Runtime is installed and " +
"Processing.NDI.Lib.x64.dll is on the application's DLL search path.");
_initialized = true;
}
public string GetRuntimeVersion()
{
var ptr = NdiNative.Version();
return Marshal.PtrToStringAnsi(ptr) ?? string.Empty;
}
// ---- Discovery ----
public NdiFindHandle CreateFinder(string? groups = null)
{
// Empty/whitespace -> default (Public). Otherwise allocate a UTF-8 buffer
// for the comma-separated group list and pin a settings struct around it.
var trimmed = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim();
if (trimmed is null)
{
var nativeDefault = NdiNative.FindCreateV2(IntPtr.Zero);
if (nativeDefault == IntPtr.Zero)
throw new InvalidOperationException("NDIlib_find_create_v2 returned null.");
return new NdiPInvokeFindHandle(nativeDefault);
}
var groupsUtf8 = Marshal.StringToHGlobalAnsi(trimmed);
try
{
var settings = new NdiNative.FindCreateSettings
{
show_local_sources = true,
p_groups = groupsUtf8,
p_extra_ips = IntPtr.Zero,
};
var settingsPtr = Marshal.AllocHGlobal(Marshal.SizeOf<NdiNative.FindCreateSettings>());
try
{
Marshal.StructureToPtr(settings, settingsPtr, false);
var native = NdiNative.FindCreateV2(settingsPtr);
if (native == IntPtr.Zero)
throw new InvalidOperationException(
$"NDIlib_find_create_v2 returned null for groups='{trimmed}'.");
_logger.LogInformation("NDI finder created with groups: {Groups}", trimmed);
return new NdiPInvokeFindHandle(native);
}
finally
{
Marshal.FreeHGlobal(settingsPtr);
}
}
finally
{
Marshal.FreeHGlobal(groupsUtf8);
}
}
public IReadOnlyList<string> GetCurrentSources(NdiFindHandle finder)
{
var pInvokeFinder = (NdiPInvokeFindHandle)finder;
var ptr = NdiNative.FindGetCurrentSources(pInvokeFinder.Native, out var count);
if (ptr == IntPtr.Zero || count == 0) return Array.Empty<string>();
// The native call returns a pointer to an array of NDIlib_source_t.
// Each entry is sizeof(IntPtr)*2 bytes (p_ndi_name + p_url_address).
var sourceSize = Marshal.SizeOf<NdiNative.Source>();
var result = new string[count];
for (var i = 0; i < count; i++)
{
var sourcePtr = IntPtr.Add(ptr, i * sourceSize);
var src = Marshal.PtrToStructure<NdiNative.Source>(sourcePtr);
result[i] = Marshal.PtrToStringAnsi(src.p_ndi_name) ?? string.Empty;
}
return result;
}
// ---- Receive ----
public NdiReceiverHandle CreateReceiver(string sourceFullName)
{
var nameUtf8 = Marshal.StringToHGlobalAnsi(sourceFullName);
var recvNameUtf8 = Marshal.StringToHGlobalAnsi("TeamsISO");
try
{
var settings = new NdiNative.RecvCreateV3Settings
{
source_to_connect_to = new NdiNative.Source
{
p_ndi_name = nameUtf8,
p_url_address = IntPtr.Zero
},
color_format = NdiNative.RecvColorFormat.BgrxBgra,
bandwidth = NdiNative.RecvBandwidth.Highest,
allow_video_fields = false,
p_ndi_recv_name = recvNameUtf8
};
var native = NdiNative.RecvCreateV3(ref settings);
if (native == IntPtr.Zero)
throw new InvalidOperationException($"NDIlib_recv_create_v3 returned null for source '{sourceFullName}'.");
return new NdiPInvokeReceiverHandle(native, sourceFullName);
}
finally
{
Marshal.FreeHGlobal(nameUtf8);
Marshal.FreeHGlobal(recvNameUtf8);
}
}
public RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs)
{
var pInvokeReceiver = (NdiPInvokeReceiverHandle)receiver;
var frameType = NdiNative.RecvCaptureV3(
pInvokeReceiver.Native,
out var nativeFrame,
IntPtr.Zero,
IntPtr.Zero,
(uint)Math.Max(0, timeoutMs));
if (frameType != NdiNative.FrameType.Video || nativeFrame.p_data == IntPtr.Zero)
{
// Even non-video frame types may need to be released; recv_free_video_v2 is safe on zeroed structs.
if (nativeFrame.p_data != IntPtr.Zero)
NdiNative.RecvFreeVideoV2(pInvokeReceiver.Native, ref nativeFrame);
return null;
}
try
{
var pixelFormat = nativeFrame.FourCC switch
{
NdiNative.FourCC.BGRA or NdiNative.FourCC.BGRX => PixelFormat.Bgra,
NdiNative.FourCC.UYVY => PixelFormat.Uyvy,
NdiNative.FourCC.RGBA => PixelFormat.Rgba,
_ => PixelFormat.Bgra
};
// Compute total byte length. line_stride_in_bytes is the union slot for video frames.
var byteLength = nativeFrame.line_stride_in_bytes * nativeFrame.yres;
if (byteLength <= 0)
{
_logger.LogWarning("NDI video frame had non-positive byte length ({Length}); skipping.", byteLength);
return null;
}
var managed = new byte[byteLength];
Marshal.Copy(nativeFrame.p_data, managed, 0, byteLength);
return new RawFrame(
Width: nativeFrame.xres,
Height: nativeFrame.yres,
TimestampTicks: nativeFrame.timestamp,
Pixels: managed,
Format: pixelFormat);
}
finally
{
NdiNative.RecvFreeVideoV2(pInvokeReceiver.Native, ref nativeFrame);
}
}
// ---- Send ----
public NdiSenderHandle CreateSender(string outputName, string? groups = null)
{
var trimmedGroups = string.IsNullOrWhiteSpace(groups) ? null : groups!.Trim();
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
var groupsUtf8 = trimmedGroups is null
? IntPtr.Zero
: Marshal.StringToHGlobalAnsi(trimmedGroups);
try
{
var settings = new NdiNative.SendCreateSettings
{
p_ndi_name = nameUtf8,
p_groups = groupsUtf8,
clock_video = true,
clock_audio = false
};
var native = NdiNative.SendCreate(ref settings);
if (native == IntPtr.Zero)
throw new InvalidOperationException(
$"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? "<default>"}'.");
if (trimmedGroups is not null)
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
return new NdiPInvokeSenderHandle(native, outputName);
}
finally
{
Marshal.FreeHGlobal(nameUtf8);
if (groupsUtf8 != IntPtr.Zero) Marshal.FreeHGlobal(groupsUtf8);
}
}
public void SendFrame(NdiSenderHandle sender, ProcessedFrame frame)
{
var pInvokeSender = (NdiPInvokeSenderHandle)sender;
var fourcc = frame.Format switch
{
PixelFormat.Bgra => NdiNative.FourCC.BGRA,
PixelFormat.Rgba => NdiNative.FourCC.RGBA,
PixelFormat.Uyvy => NdiNative.FourCC.UYVY,
_ => NdiNative.FourCC.BGRA
};
// Pin the managed buffer so the native call can read it directly.
var pixels = frame.Pixels.ToArray();
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
try
{
var nativeFrame = new NdiNative.VideoFrameV2
{
xres = frame.Width,
yres = frame.Height,
FourCC = fourcc,
frame_rate_N = 60000,
frame_rate_D = 1001, // 59.94 default; controller-supplied later
picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres
frame_format_type = NdiNative.FrameFormatType.Progressive,
timecode = NdiTimecodeSynthesize, // synthesize
p_data = handle.AddrOfPinnedObject(),
line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format),
p_metadata = IntPtr.Zero,
timestamp = frame.TimestampTicks
};
NdiNative.SendSendVideoV2(pInvokeSender.Native, ref nativeFrame);
}
finally
{
handle.Free();
}
}
private const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize
private static int BytesPerPixel(PixelFormat fmt) => fmt switch
{
PixelFormat.Bgra or PixelFormat.Rgba => 4,
PixelFormat.Uyvy => 2,
_ => 4
};
public void Dispose()
{
if (_disposed) return;
if (_initialized)
{
NdiNative.Destroy();
_initialized = false;
}
_disposed = true;
}
}