diff --git a/src/TeamsISO.Engine/Pipeline/ManagedNearestNeighborFrameScaler.cs b/src/TeamsISO.Engine/Pipeline/ManagedNearestNeighborFrameScaler.cs new file mode 100644 index 0000000..0b93dbf --- /dev/null +++ b/src/TeamsISO.Engine/Pipeline/ManagedNearestNeighborFrameScaler.cs @@ -0,0 +1,95 @@ +using TeamsISO.Engine.Domain; + +namespace TeamsISO.Engine.Pipeline; + +/// +/// Pure-managed BGRA frame scaler with nearest-neighbor sampling and aspect-mode handling. +/// Suitable for v1.0 ship; libyuv is a v1.5 perf optimization (per spec §6 deferred items). +/// +public sealed class ManagedNearestNeighborFrameScaler : IFrameScaler +{ + public ProcessedFrame Scale(RawFrame source, int targetWidth, int targetHeight, AspectMode aspect, long timestampTicks) + { + if (source.Format != PixelFormat.Bgra) + throw new NotSupportedException( + $"ManagedNearestNeighborFrameScaler only supports BGRA input (got {source.Format}). " + + "v1.0 receivers request BGRA from NDI; UYVY support comes in v1.5."); + + var output = new byte[targetWidth * targetHeight * 4]; + + // Compute the destination rectangle within the target according to aspect mode. + var (drawX, drawY, drawW, drawH) = ComputeFitRect( + source.Width, source.Height, targetWidth, targetHeight, aspect); + + // Pre-fill with black if there will be unfilled bars (Pillarbox/Letterbox). + if (drawX > 0 || drawY > 0 || drawW < targetWidth || drawH < targetHeight) + { + for (var i = 3; i < output.Length; i += 4) output[i] = 0xFF; // alpha + // BGR remain 0x00 from default initialization. + } + + var srcSpan = source.Pixels.Span; + var dstSpan = output.AsSpan(); + + // Nearest-neighbor scale into the (drawX, drawY, drawW, drawH) region. + for (var dy = 0; dy < drawH; dy++) + { + // Map dest-row inside drawn rect → source row + var sy = (int)((long)dy * source.Height / drawH); + if (sy >= source.Height) sy = source.Height - 1; + var srcRowStart = sy * source.Width * 4; + var dstRowStart = ((drawY + dy) * targetWidth + drawX) * 4; + + for (var dx = 0; dx < drawW; dx++) + { + var sx = (int)((long)dx * source.Width / drawW); + if (sx >= source.Width) sx = source.Width - 1; + var srcOffset = srcRowStart + sx * 4; + var dstOffset = dstRowStart + dx * 4; + dstSpan[dstOffset + 0] = srcSpan[srcOffset + 0]; + dstSpan[dstOffset + 1] = srcSpan[srcOffset + 1]; + dstSpan[dstOffset + 2] = srcSpan[srcOffset + 2]; + dstSpan[dstOffset + 3] = srcSpan[srcOffset + 3]; + } + } + + return new ProcessedFrame(targetWidth, targetHeight, timestampTicks, output, PixelFormat.Bgra); + } + + private static (int X, int Y, int W, int H) ComputeFitRect(int srcW, int srcH, int dstW, int dstH, AspectMode aspect) + { + switch (aspect) + { + case AspectMode.Stretch: + return (0, 0, dstW, dstH); + + case AspectMode.Pillarbox: + case AspectMode.Letterbox: + { + // Both modes preserve aspect ratio and fit within the destination — the difference is + // descriptive (which dimension gets bars), but the math is the same: scale to fit. + var srcAspect = (double)srcW / srcH; + var dstAspect = (double)dstW / dstH; + int w, h; + if (srcAspect > dstAspect) + { + // Source is wider → letterbox (fill width, bars top/bottom). + w = dstW; + h = (int)Math.Round(dstW / srcAspect); + } + else + { + // Source is taller/narrower → pillarbox (fill height, bars left/right). + h = dstH; + w = (int)Math.Round(dstH * srcAspect); + } + var x = (dstW - w) / 2; + var y = (dstH - h) / 2; + return (x, y, w, h); + } + + default: + throw new InvalidOperationException($"Unknown aspect mode: {aspect}"); + } + } +} diff --git a/src/tests/TeamsISO.Engine.Tests/Pipeline/ManagedNearestNeighborFrameScalerTests.cs b/src/tests/TeamsISO.Engine.Tests/Pipeline/ManagedNearestNeighborFrameScalerTests.cs new file mode 100644 index 0000000..1ab4a1f --- /dev/null +++ b/src/tests/TeamsISO.Engine.Tests/Pipeline/ManagedNearestNeighborFrameScalerTests.cs @@ -0,0 +1,72 @@ +using TeamsISO.Engine.Domain; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Engine.Tests.Pipeline; + +public class ManagedNearestNeighborFrameScalerTests +{ + private static RawFrame Solid(int w, int h, byte b, byte g, byte r, byte a, long ts = 0) + { + var pixels = new byte[w * h * 4]; + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i + 0] = b; pixels[i + 1] = g; pixels[i + 2] = r; pixels[i + 3] = a; + } + return new RawFrame(w, h, ts, pixels, PixelFormat.Bgra); + } + + [Fact] + public void Stretch_ScalesToTargetDimensions() + { + var scaler = new ManagedNearestNeighborFrameScaler(); + var src = Solid(640, 360, 0x10, 0x20, 0x30, 0xFF); + + var dst = scaler.Scale(src, 1920, 1080, AspectMode.Stretch, 1234); + + dst.Width.Should().Be(1920); + dst.Height.Should().Be(1080); + dst.Format.Should().Be(PixelFormat.Bgra); + dst.Pixels.Length.Should().Be(1920 * 1080 * 4); + dst.TimestampTicks.Should().Be(1234); + // Solid color should be preserved across the entire output + dst.Pixels.Span[0].Should().Be(0x10); + dst.Pixels.Span[dst.Pixels.Length - 4].Should().Be(0x10); + } + + [Fact] + public void Pillarbox_NarrowSource_GetsBlackBars_LeftAndRight() + { + var scaler = new ManagedNearestNeighborFrameScaler(); + // 1:1 aspect source going into a 16:9 target should pillarbox. + var src = Solid(100, 100, 0x10, 0x20, 0x30, 0xFF); + + var dst = scaler.Scale(src, 1920, 1080, AspectMode.Pillarbox, 0); + + // Top-left pixel (in the bar region) should be black. + dst.Pixels.Span[0].Should().Be(0x00); + dst.Pixels.Span[1].Should().Be(0x00); + dst.Pixels.Span[2].Should().Be(0x00); + + // Center pixel should carry the source color. + var centerOffset = (1080 / 2 * 1920 + 1920 / 2) * 4; + dst.Pixels.Span[centerOffset].Should().Be(0x10); + dst.Pixels.Span[centerOffset + 2].Should().Be(0x30); + } + + [Fact] + public void Letterbox_WideSource_GetsBlackBars_TopAndBottom() + { + var scaler = new ManagedNearestNeighborFrameScaler(); + // 21:9 source (e.g. 2100x900) into a 16:9 target should letterbox. + var src = Solid(2100, 900, 0x10, 0x20, 0x30, 0xFF); + + var dst = scaler.Scale(src, 1920, 1080, AspectMode.Letterbox, 0); + + // Top-left pixel should be in the bar region (black). + dst.Pixels.Span[0].Should().Be(0x00); + + // Center pixel should carry the source color. + var centerOffset = (1080 / 2 * 1920 + 1920 / 2) * 4; + dst.Pixels.Span[centerOffset].Should().Be(0x10); + } +}