diff --git a/src/TeamsISO.Engine/Interop/INdiInterop.cs b/src/TeamsISO.Engine/Interop/INdiInterop.cs new file mode 100644 index 0000000..f78f5fd --- /dev/null +++ b/src/TeamsISO.Engine/Interop/INdiInterop.cs @@ -0,0 +1,32 @@ +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Engine.Interop; + +/// +/// Test seam over the NDI SDK. Production: P/Invoke shim. Tests: FakeNdiInterop. +/// All methods are synchronous; the engine threads are responsible for orchestration. +/// +public interface INdiInterop +{ + // ----- Discovery ----- + NdiFindHandle CreateFinder(); + + /// Snapshots the currently-known sources visible to the finder. + IReadOnlyList GetCurrentSources(NdiFindHandle finder); + + // ----- Receive ----- + NdiReceiverHandle CreateReceiver(string sourceFullName); + + /// + /// Blocks for up to waiting for a frame. + /// Returns null on timeout. Returned ownership transfers to the caller. + /// + RawFrame? CaptureFrame(NdiReceiverHandle receiver, int timeoutMs); + + // ----- Send ----- + NdiSenderHandle CreateSender(string outputName); + void SendFrame(NdiSenderHandle sender, ProcessedFrame frame); + + // ----- Runtime probe ----- + string GetRuntimeVersion(); +} diff --git a/src/TeamsISO.Engine/Interop/NdiFindHandle.cs b/src/TeamsISO.Engine/Interop/NdiFindHandle.cs new file mode 100644 index 0000000..fec94a6 --- /dev/null +++ b/src/TeamsISO.Engine/Interop/NdiFindHandle.cs @@ -0,0 +1,7 @@ +namespace TeamsISO.Engine.Interop; + +/// Opaque handle to an NDI Find instance. Implementation-private. +public abstract class NdiFindHandle : IDisposable +{ + public abstract void Dispose(); +} diff --git a/src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs b/src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs new file mode 100644 index 0000000..c988da6 --- /dev/null +++ b/src/TeamsISO.Engine/Interop/NdiReceiverHandle.cs @@ -0,0 +1,6 @@ +namespace TeamsISO.Engine.Interop; + +public abstract class NdiReceiverHandle : IDisposable +{ + public abstract void Dispose(); +} diff --git a/src/TeamsISO.Engine/Interop/NdiSenderHandle.cs b/src/TeamsISO.Engine/Interop/NdiSenderHandle.cs new file mode 100644 index 0000000..ad8f645 --- /dev/null +++ b/src/TeamsISO.Engine/Interop/NdiSenderHandle.cs @@ -0,0 +1,6 @@ +namespace TeamsISO.Engine.Interop; + +public abstract class NdiSenderHandle : IDisposable +{ + public abstract void Dispose(); +} diff --git a/src/TeamsISO.Engine/Pipeline/IFrameClock.cs b/src/TeamsISO.Engine/Pipeline/IFrameClock.cs new file mode 100644 index 0000000..b7980e7 --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/IFrameClock.cs @@ -0,0 +1,14 @@ +namespace TeamsISO.Engine.Pipeline; + +/// +/// Test seam over the wall clock. Production: . +/// Tests: FakeFrameClock in TeamsISO.Engine.Tests. +/// +public interface IFrameClock +{ + /// Current monotonic time as ticks (100 ns). + long NowTicks { get; } + + /// Awaits the next tick at the current period. + ValueTask WaitForNextTickAsync(CancellationToken cancellationToken); +} diff --git a/src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs b/src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs new file mode 100644 index 0000000..cfacce9 --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/PeriodicTimerFrameClock.cs @@ -0,0 +1,21 @@ +namespace TeamsISO.Engine.Pipeline; + +public sealed class PeriodicTimerFrameClock : IFrameClock, IDisposable +{ + private readonly PeriodicTimer _timer; + + public PeriodicTimerFrameClock(double framesPerSecond) + { + if (framesPerSecond <= 0) + throw new ArgumentOutOfRangeException(nameof(framesPerSecond)); + var periodMs = 1000.0 / framesPerSecond; + _timer = new PeriodicTimer(TimeSpan.FromMilliseconds(periodMs)); + } + + public long NowTicks => DateTime.UtcNow.Ticks; + + public ValueTask WaitForNextTickAsync(CancellationToken cancellationToken) => + _timer.WaitForNextTickAsync(cancellationToken); + + public void Dispose() => _timer.Dispose(); +} diff --git a/src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs b/src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs new file mode 100644 index 0000000..25300d7 --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/ProcessedFrame.cs @@ -0,0 +1,11 @@ +namespace TeamsISO.Engine.Pipeline; + +/// +/// A frame after framerate, resolution, and aspect normalization. Ready to send. +/// +public sealed record ProcessedFrame( + int Width, + int Height, + long TimestampTicks, + ReadOnlyMemory Pixels, + PixelFormat Format); diff --git a/src/TeamsISO.Engine/Pipeline/RawFrame.cs b/src/TeamsISO.Engine/Pipeline/RawFrame.cs new file mode 100644 index 0000000..417d06b --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/RawFrame.cs @@ -0,0 +1,19 @@ +namespace TeamsISO.Engine.Pipeline; + +/// +/// A frame as captured from an NDI receiver. Pixel buffer is opaque to the engine — its +/// shape is determined by the NDI receive format. Timestamp is the source's reported time. +/// +public sealed record RawFrame( + int Width, + int Height, + long TimestampTicks, + ReadOnlyMemory Pixels, + PixelFormat Format); + +public enum PixelFormat +{ + Bgra, + Uyvy, + Rgba +}