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;
+ }
+}