diff --git a/src/Dragon-ISO.Engine.NdiInterop/NdiInteropPInvoke.cs b/src/Dragon-ISO.Engine.NdiInterop/NdiInteropPInvoke.cs index 811136b..aa44039 100644 --- a/src/Dragon-ISO.Engine.NdiInterop/NdiInteropPInvoke.cs +++ b/src/Dragon-ISO.Engine.NdiInterop/NdiInteropPInvoke.cs @@ -275,7 +275,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable // ---- Send ---- - public NdiSenderHandle CreateSender(string outputName, string? groups = null) + public NdiSenderHandle CreateSender(string outputName, string? groups = null, int frameRateN = 60000, int frameRateD = 1001) { var trimmedGroups = NormalizeGroups(groups); var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName); @@ -297,7 +297,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable $"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? ""}'."); if (trimmedGroups is not null) _logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups); - return new NdiPInvokeSenderHandle(native, outputName); + return new NdiPInvokeSenderHandle(native, outputName, frameRateN, frameRateD); } finally { @@ -310,6 +310,29 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable { var pInvokeSender = (NdiPInvokeSenderHandle)sender; + // 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 = BuildVideoFrame( + frame, handle.AddrOfPinnedObject(), + pInvokeSender.FrameRateN, pInvokeSender.FrameRateD); + NdiNative.SendSendVideoV2(pInvokeSender.Native, ref nativeFrame); + } + finally + { + handle.Free(); + } + } + + /// + /// Pure assembly of the native frame descriptor — extracted so the stamping + /// (frame rate, stride, dimensions) is unit-testable without an NDI runtime. + /// + internal static NdiNative.VideoFrameV2 BuildVideoFrame( + ProcessedFrame frame, IntPtr data, int frameRateN, int frameRateD) + { var fourcc = frame.Format switch { PixelFormat.Bgra => NdiNative.FourCC.BGRA, @@ -318,32 +341,21 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable _ => 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 + return new NdiNative.VideoFrameV2 { - 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(); - } + xres = frame.Width, + yres = frame.Height, + FourCC = fourcc, + frame_rate_N = frameRateN, + frame_rate_D = frameRateD, + picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres + frame_format_type = NdiNative.FrameFormatType.Progressive, + timecode = NdiTimecodeSynthesize, // synthesize + p_data = data, + line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format), + p_metadata = IntPtr.Zero, + timestamp = frame.TimestampTicks + }; } private const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize diff --git a/src/Dragon-ISO.Engine.NdiInterop/NdiPInvokeHandles.cs b/src/Dragon-ISO.Engine.NdiInterop/NdiPInvokeHandles.cs index dfe6ad0..50c12b2 100644 --- a/src/Dragon-ISO.Engine.NdiInterop/NdiPInvokeHandles.cs +++ b/src/Dragon-ISO.Engine.NdiInterop/NdiPInvokeHandles.cs @@ -44,10 +44,18 @@ internal sealed class NdiPInvokeSenderHandle : NdiSenderHandle public IntPtr Native { get; private set; } public string OutputName { get; } - public NdiPInvokeSenderHandle(IntPtr native, string outputName) + // The NDI SDK carries frame rate per-frame (VideoFrameV2.frame_rate_N/D), + // not on send_create, so the handle remembers the configured rational and + // SendFrame stamps it onto every outgoing frame. + public int FrameRateN { get; } + public int FrameRateD { get; } + + public NdiPInvokeSenderHandle(IntPtr native, string outputName, int frameRateN = 60000, int frameRateD = 1001) { Native = native; OutputName = outputName; + FrameRateN = frameRateN; + FrameRateD = frameRateD; } public override void Dispose() diff --git a/src/tests/Dragon-ISO.Engine.Tests/Fakes/FakeNdiInterop.cs b/src/tests/Dragon-ISO.Engine.Tests/Fakes/FakeNdiInterop.cs index e8cac1f..34b4e9d 100644 --- a/src/tests/Dragon-ISO.Engine.Tests/Fakes/FakeNdiInterop.cs +++ b/src/tests/Dragon-ISO.Engine.Tests/Fakes/FakeNdiInterop.cs @@ -23,6 +23,8 @@ public sealed class FakeNdiInterop : INdiInterop public string? LastFinderGroups { get; private set; } /// Per-output groups string seen by ; null = default Public. public Dictionary SenderGroups { get; } = new(); + /// Per-output rational frame rate (N, D) seen by . + public Dictionary SenderFrameRates { get; } = new(); /// /// Number of finders created so far (initial construction + every rebuild). @@ -77,10 +79,11 @@ public sealed class FakeNdiInterop : INdiInterop return null; // no audio queued — simulate timeout } - public NdiSenderHandle CreateSender(string outputName, string? groups = null) + public NdiSenderHandle CreateSender(string outputName, string? groups = null, int frameRateN = 60000, int frameRateD = 1001) { SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1; SenderGroups[outputName] = groups; + SenderFrameRates[outputName] = (frameRateN, frameRateD); SentFrames.GetOrAdd(outputName, _ => new List()); return new FakeSenderHandle(outputName); } diff --git a/src/tests/Dragon-ISO.Engine.Tests/Interop/NdiInteropSendFrameTests.cs b/src/tests/Dragon-ISO.Engine.Tests/Interop/NdiInteropSendFrameTests.cs new file mode 100644 index 0000000..d88f5f2 --- /dev/null +++ b/src/tests/Dragon-ISO.Engine.Tests/Interop/NdiInteropSendFrameTests.cs @@ -0,0 +1,63 @@ +using System.Runtime.Versioning; +using DragonISO.Engine.NdiInterop; +using DragonISO.Engine.Pipeline; + +namespace DragonISO.Engine.Tests.Interop; + +// NdiInteropPInvoke is [SupportedOSPlatform("windows")]; these tests exercise +// pure helpers that never touch native code, but inherit the platform tag. +// Same pattern as NdiInteropNormalizeGroupsTests. +[SupportedOSPlatform("windows")] + +// NdiPInvokeSenderHandle and NdiInteropPInvoke.BuildVideoFrame are internal; +// the engine tests project has access via InternalsVisibleTo. +public class NdiInteropSendFrameTests +{ + private static ProcessedFrame MakeFrame(int width, int height, PixelFormat format) => + new(width, height, 42, new byte[width * height * 4], format); + + [Fact] + public void SenderHandle_CarriesFrameRate() + { + // CreateSender stashes the configured rational on the handle so SendFrame + // can stamp every outgoing video frame without re-plumbing the settings. + var handle = new NdiPInvokeSenderHandle(IntPtr.Zero, "Dragon-ISO_01", 30000, 1001); + + handle.FrameRateN.Should().Be(30000); + handle.FrameRateD.Should().Be(1001); + } + + [Fact] + public void BuildVideoFrame_StampsConfiguredFrameRate() + { + var frame = MakeFrame(1280, 720, PixelFormat.Bgra); + + var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 24000, 1001); + + native.frame_rate_N.Should().Be(24000, + because: "advertising the wrong rational causes drift and A/V desync downstream"); + native.frame_rate_D.Should().Be(1001); + } + + [Fact] + public void BuildVideoFrame_PreservesDimensionsAndStride() + { + var frame = MakeFrame(1920, 1080, PixelFormat.Bgra); + + var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 60000, 1001); + + native.xres.Should().Be(1920); + native.yres.Should().Be(1080); + native.line_stride_in_bytes.Should().Be(1920 * 4, because: "BGRA is 4 bytes per pixel"); + } + + [Fact] + public void BuildVideoFrame_MapsUyvyStride() + { + var frame = MakeFrame(1280, 720, PixelFormat.Uyvy); + + var native = NdiInteropPInvoke.BuildVideoFrame(frame, IntPtr.Zero, 60000, 1001); + + native.line_stride_in_bytes.Should().Be(1280 * 2, because: "UYVY is 2 bytes per pixel"); + } +} diff --git a/src/tests/Dragon-ISO.Engine.Tests/Pipeline/NdiSenderTests.cs b/src/tests/Dragon-ISO.Engine.Tests/Pipeline/NdiSenderTests.cs index 9a7bd9b..487c54b 100644 --- a/src/tests/Dragon-ISO.Engine.Tests/Pipeline/NdiSenderTests.cs +++ b/src/tests/Dragon-ISO.Engine.Tests/Pipeline/NdiSenderTests.cs @@ -12,6 +12,21 @@ public class NdiSenderTests private static ProcessedFrame MakeFrame(long ts) => new(1920, 1080, ts, new byte[1920 * 1080 * 4], PixelFormat.Bgra); + [Fact] + public void Ctor_ForwardsFrameRate_ToCreateSender() + { + var interop = new FakeNdiInterop(); + var input = Channel.CreateUnbounded(); + + using var sender = new NdiSender( + interop, Output, input.Reader, NullLogger.Instance, + outputGroups: null, frameRateN: 30000, frameRateD: 1001); + + interop.SenderFrameRates[Output].Should().Be((30000, 1001), + because: "the sender must hand the configured rational frame rate to the interop " + + "so the NDI stream advertises 29.97 rather than the 59.94 default"); + } + [Fact] public async Task SendNextAsync_FrameAvailable_ForwardsToInterop() {