fix(interop): implement frame-rate-carrying CreateSender in P/Invoke + fake
The INdiInterop.CreateSender(frameRateN, frameRateD) overload landed without either implementation, so main stopped compiling on Windows (CS0535) and a tag push would have failed the release build. The NDI SDK carries frame rate per-frame, not on send_create: the handle now remembers the configured rational and SendFrame stamps it onto every outgoing frame instead of the hardwired 60000/1001. Frame assembly is extracted to a pure BuildVideoFrame so the stamping is unit-testable without an NDI runtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f11f2b947f
commit
97d7028a3a
5 changed files with 130 additions and 29 deletions
|
|
@ -275,7 +275,7 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
|
|
||||||
// ---- Send ----
|
// ---- 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 trimmedGroups = NormalizeGroups(groups);
|
||||||
var nameUtf8 = Marshal.StringToHGlobalAnsi(outputName);
|
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 ?? "<default>"}'.");
|
$"NDIlib_send_create returned null for output '{outputName}' on groups '{trimmedGroups ?? "<default>"}'.");
|
||||||
if (trimmedGroups is not null)
|
if (trimmedGroups is not null)
|
||||||
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
|
_logger.LogInformation("NDI sender '{Output}' created on groups: {Groups}", outputName, trimmedGroups);
|
||||||
return new NdiPInvokeSenderHandle(native, outputName);
|
return new NdiPInvokeSenderHandle(native, outputName, frameRateN, frameRateD);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|
@ -310,6 +310,29 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
{
|
{
|
||||||
var pInvokeSender = (NdiPInvokeSenderHandle)sender;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure assembly of the native frame descriptor — extracted so the stamping
|
||||||
|
/// (frame rate, stride, dimensions) is unit-testable without an NDI runtime.
|
||||||
|
/// </summary>
|
||||||
|
internal static NdiNative.VideoFrameV2 BuildVideoFrame(
|
||||||
|
ProcessedFrame frame, IntPtr data, int frameRateN, int frameRateD)
|
||||||
|
{
|
||||||
var fourcc = frame.Format switch
|
var fourcc = frame.Format switch
|
||||||
{
|
{
|
||||||
PixelFormat.Bgra => NdiNative.FourCC.BGRA,
|
PixelFormat.Bgra => NdiNative.FourCC.BGRA,
|
||||||
|
|
@ -318,32 +341,21 @@ public sealed class NdiInteropPInvoke : INdiInterop, IDisposable
|
||||||
_ => NdiNative.FourCC.BGRA
|
_ => NdiNative.FourCC.BGRA
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pin the managed buffer so the native call can read it directly.
|
return new NdiNative.VideoFrameV2
|
||||||
var pixels = frame.Pixels.ToArray();
|
|
||||||
var handle = GCHandle.Alloc(pixels, GCHandleType.Pinned);
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
var nativeFrame = new NdiNative.VideoFrameV2
|
xres = frame.Width,
|
||||||
{
|
yres = frame.Height,
|
||||||
xres = frame.Width,
|
FourCC = fourcc,
|
||||||
yres = frame.Height,
|
frame_rate_N = frameRateN,
|
||||||
FourCC = fourcc,
|
frame_rate_D = frameRateD,
|
||||||
frame_rate_N = 60000,
|
picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres
|
||||||
frame_rate_D = 1001, // 59.94 default; controller-supplied later
|
frame_format_type = NdiNative.FrameFormatType.Progressive,
|
||||||
picture_aspect_ratio = 0.0f, // 0 = derive from xres/yres
|
timecode = NdiTimecodeSynthesize, // synthesize
|
||||||
frame_format_type = NdiNative.FrameFormatType.Progressive,
|
p_data = data,
|
||||||
timecode = NdiTimecodeSynthesize, // synthesize
|
line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format),
|
||||||
p_data = handle.AddrOfPinnedObject(),
|
p_metadata = IntPtr.Zero,
|
||||||
line_stride_in_bytes = frame.Width * BytesPerPixel(frame.Format),
|
timestamp = frame.TimestampTicks
|
||||||
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 const long NdiTimecodeSynthesize = unchecked((long)0x8000000000000000UL); // NDIlib_send_timecode_synthesize
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,18 @@ internal sealed class NdiPInvokeSenderHandle : NdiSenderHandle
|
||||||
public IntPtr Native { get; private set; }
|
public IntPtr Native { get; private set; }
|
||||||
public string OutputName { get; }
|
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;
|
Native = native;
|
||||||
OutputName = outputName;
|
OutputName = outputName;
|
||||||
|
FrameRateN = frameRateN;
|
||||||
|
FrameRateD = frameRateD;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void Dispose()
|
public override void Dispose()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ public sealed class FakeNdiInterop : INdiInterop
|
||||||
public string? LastFinderGroups { get; private set; }
|
public string? LastFinderGroups { get; private set; }
|
||||||
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
|
/// <summary>Per-output <c>groups</c> string seen by <see cref="CreateSender"/>; null = default Public.</summary>
|
||||||
public Dictionary<string, string?> SenderGroups { get; } = new();
|
public Dictionary<string, string?> SenderGroups { get; } = new();
|
||||||
|
/// <summary>Per-output rational frame rate (N, D) seen by <see cref="CreateSender"/>.</summary>
|
||||||
|
public Dictionary<string, (int N, int D)> SenderFrameRates { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Number of finders created so far (initial construction + every rebuild).
|
/// 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
|
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;
|
SenderCreatedCount[outputName] = SenderCreatedCount.GetValueOrDefault(outputName) + 1;
|
||||||
SenderGroups[outputName] = groups;
|
SenderGroups[outputName] = groups;
|
||||||
|
SenderFrameRates[outputName] = (frameRateN, frameRateD);
|
||||||
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
|
SentFrames.GetOrAdd(outputName, _ => new List<ProcessedFrame>());
|
||||||
return new FakeSenderHandle(outputName);
|
return new FakeSenderHandle(outputName);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,21 @@ public class NdiSenderTests
|
||||||
private static ProcessedFrame MakeFrame(long ts) =>
|
private static ProcessedFrame MakeFrame(long ts) =>
|
||||||
new(1920, 1080, ts, new byte[1920 * 1080 * 4], PixelFormat.Bgra);
|
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<ProcessedFrame>();
|
||||||
|
|
||||||
|
using var sender = new NdiSender(
|
||||||
|
interop, Output, input.Reader, NullLogger<NdiSender>.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]
|
[Fact]
|
||||||
public async Task SendNextAsync_FrameAvailable_ForwardsToInterop()
|
public async Task SendNextAsync_FrameAvailable_ForwardsToInterop()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue