dragon-iso/src/TeamsISO.Engine.NdiInterop/NdiInteropPInvoke.cs
Zac Gaetano 16e0a483e2
All checks were successful
CI / build-and-test (push) Successful in 35s
fix: address review findings on tonight's commits
Code review on d14a33a..bab29b0 turned up three real issues, fixed here.

1. EngineLogging.CreateDefault no longer mutates Serilog.Log.Logger. The static set was a belt-and-suspenders attempt to catch any code path that reaches for the singleton, but it doesn't matter (engine code uses ILogger<T>, never Serilog.Log.*) and it raced under xUnit's parallel test execution.

2. IsoPipeline stops holding a RawFrame reference for stats. The receiver-side TappedChannelWriter callback now snapshots only Width/Height into volatile ints — frame's pixel buffer is allowed to GC on its normal schedule and a late stats poll can never resurrect a dropped frame. (Today the buffer is fully managed so a use-after-free wasn't actually possible, but the snapshot pattern is the right ownership shape.)

3. App.xaml.cs's ComponentDispatcher.ThreadFilterMessage subscription now lives in a field and is unsubscribed in OnExit. Mutex release is gated on a new _ownsSingleInstanceMutex flag so the 'lost the race; shut down silently' path doesn't accidentally try to release a handle it never owned.

Plus a load-bearing comment in NdiInteropPInvoke.CreateFinder explaining why we free the UTF-8 group buffers right after the native call returns — same lifetime contract Phase B-2's CreateReceiver / CreateSender have always relied on; if it's wrong, those would fail too. The loopback discovery integration test would catch a regression.

Tests: 74/74 unit + 9/9 NDI integration green.
2026-05-08 01:01:00 -04:00

286 lines
11 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.
//
// Memory ownership: the NDI SDK's NDIlib_find_create_v2 (and _send_create,
// _recv_create_v3) copy the strings out of the settings struct synchronously
// before returning — they don't retain pointers into our buffers. This is the
// same lifetime contract CreateReceiver / CreateSender below have relied on
// since Phase B-2; if it ever turns out to be wrong, those will fail too. The
// loopback discovery integration test would catch a regression here.
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;
}
}