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