From da5818b690c8dc012dcc757c02dbe2a7667c26ed Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 7 May 2026 15:35:59 +0000 Subject: [PATCH] feat(interop): add NdiInteropPInvoke production INdiInterop implementation --- .../NdiInteropPInvoke.cs | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs diff --git a/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs new file mode 100644 index 0000000..1c9727f --- /dev/null +++ b/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs @@ -0,0 +1,235 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; +using TeamsISO.Engine.Interop; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Engine.NdiInterop; + +/// +/// Production 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. +/// +[SupportedOSPlatform("windows")] +public sealed class NdiInteropPInvoke : INdiInterop, IDisposable +{ + private readonly ILogger _logger; + private bool _initialized; + private bool _disposed; + + public NdiInteropPInvoke(ILogger 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() + { + var native = NdiNative.FindCreateV2(IntPtr.Zero); + if (native == IntPtr.Zero) + throw new InvalidOperationException("NDIlib_find_create_v2 returned null."); + return new NdiPInvokeFindHandle(native); + } + + public IReadOnlyList 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(); + + // 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(); + var result = new string[count]; + for (var i = 0; i < count; i++) + { + var sourcePtr = IntPtr.Add(ptr, i * sourceSize); + var src = Marshal.PtrToStructure(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) + { + var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName); + try + { + var settings = new NdiNative.SendCreateSettings + { + p_ndi_name = nameUtf8, + p_groups = IntPtr.Zero, + 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}'."); + return new NdiPInvokeSenderHandle(native, outputName); + } + finally + { + Marshal.FreeHGlobal(nameUtf8); + } + } + + 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; + } +}