0f441d825a
take-19-2/take-20 'webcam black square under the web widget': the capture was alpha-cropped (FindContentBounds) and that CROP was fed to the compositor, whose UniformToFill zoomed the opaque content box to cover the whole element rect the moment the widget drew any content (idle transparent page = full-frame crop = the correct viewport, hence take-1-good/take-2-bad). OBS model verified: the page is a fixed 1920x1080 canvas and the element rect is a viewport onto it — measure with the alpha crop (preview/selection), hand the composite the FULL canvas on an 8-deep ring + Epoch (same identity discipline as camera/screen). Regression test: Composite_FullCanvasWebSource_TransparentMarginsRevealWebcam (the reveal contract: margin pixel = webcam, badge pixel = widget).
464 lines
20 KiB
C#
464 lines
20 KiB
C#
using Xunit;
|
||
using ytLive.Models;
|
||
using ytLive.Services;
|
||
using ytLive.Services.Compositor;
|
||
|
||
namespace ytLive.Tests;
|
||
|
||
/// <summary>
|
||
/// The output compositor (TASK 4 ship step 1): renders a scene into the encoder's
|
||
/// master frame, mirroring the XAML preview minus the editing chrome. The integration
|
||
/// test composites a full scene (background + round webcam + images + mirror + border)
|
||
/// and asserts per-layer probe pixels; the vertical-tier test asserts the 9:16 crop
|
||
/// and upscale. Frames are pushed by the test — no capture managers involved.
|
||
/// </summary>
|
||
public class SceneCompositorTests
|
||
{
|
||
public static VideoFrame Solid(int w, int h, byte r, byte g, byte b)
|
||
{
|
||
var pixels = new byte[w * h * 4];
|
||
for (var i = 0; i < pixels.Length; i += 4)
|
||
{
|
||
pixels[i] = b;
|
||
pixels[i + 1] = g;
|
||
pixels[i + 2] = r;
|
||
pixels[i + 3] = 255;
|
||
}
|
||
return new VideoFrame(w, h, pixels);
|
||
}
|
||
|
||
/// <summary>Left half = leftColor, right half = rightColor.</summary>
|
||
private static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb)
|
||
{
|
||
var pixels = new byte[w * h * 4];
|
||
var half = w / 2;
|
||
for (var y = 0; y < h; y++)
|
||
{
|
||
for (var x = 0; x < w; x++)
|
||
{
|
||
var i = (y * w + x) * 4;
|
||
pixels[i] = x < half ? lb : rb;
|
||
pixels[i + 1] = x < half ? lg : rg;
|
||
pixels[i + 2] = x < half ? lr : rr;
|
||
pixels[i + 3] = 255;
|
||
}
|
||
}
|
||
return new VideoFrame(w, h, pixels);
|
||
}
|
||
|
||
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
|
||
{
|
||
var i = (y * frame.Width + x) * 4;
|
||
var br = frame.BgraPixels[i + 2];
|
||
var bg = frame.BgraPixels[i + 1];
|
||
var bb = frame.BgraPixels[i];
|
||
Assert.True(
|
||
Math.Abs(br - r) <= 2 && Math.Abs(bg - g) <= 2 && Math.Abs(bb - b) <= 2,
|
||
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
|
||
}
|
||
|
||
[Fact]
|
||
public void Composite_FullScene_MasterPixels()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0); // background
|
||
var green = Solid(1280, 720, 0, 255, 0); // webcam
|
||
var split = Split(100, 100, 0, 255, 255, 255, 0, 255); // image: left cyan, right magenta
|
||
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
|
||
var imageA = new Source { Type = SourceType.Image, X = 1200, Y = 600, Width = 200, Height = 200 };
|
||
var imageB = new Source
|
||
{
|
||
Type = SourceType.Image, X = 500, Y = 700, Width = 100, Height = 100, IsMirrored = true,
|
||
BorderColor = "#ffffff", BorderOpacity = 1, BorderWidth = 4,
|
||
};
|
||
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
scene.Elements.Add(roundWebcam);
|
||
scene.Elements.Add(imageA);
|
||
scene.Elements.Add(imageB);
|
||
|
||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||
{
|
||
WebcamSceneConfig => green,
|
||
Source { IsBackground: true } => red,
|
||
Source { Type: SourceType.Image } => split,
|
||
_ => null,
|
||
};
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||
OutputWidth = 1920, OutputHeight = 1080,
|
||
};
|
||
|
||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||
|
||
Assert.Equal(1920, output.Width);
|
||
Assert.Equal(1080, output.Height);
|
||
|
||
// background at the frame corner
|
||
AssertColor(output, 0, 0, 255, 0, 0);
|
||
// round webcam: center is the (square-cropped) webcam feed
|
||
AssertColor(output, 250, 250, 0, 255, 0);
|
||
// round webcam: element-square corner is OUTSIDE the circle -> background shows
|
||
AssertColor(output, 101, 101, 255, 0, 0);
|
||
// imageA (unmirrored): left half cyan, right half magenta
|
||
AssertColor(output, 1220, 700, 0, 255, 255);
|
||
AssertColor(output, 1380, 700, 255, 0, 255);
|
||
// imageB (mirrored): halves swap — element left shows the source's right (magenta)
|
||
AssertColor(output, 520, 750, 255, 0, 255);
|
||
AssertColor(output, 580, 750, 0, 255, 255);
|
||
// imageB border: white ring at the top edge (drawn over the content)
|
||
AssertColor(output, 550, 700, 255, 255, 255);
|
||
}
|
||
|
||
/// <summary>The ONE integration test for the web-overlay transparency regression
|
||
/// (take-19-2/take-20 "black square over the webcam"): a FULL-canvas web frame
|
||
/// (the page background is transparent; the widget content lives at its canvas
|
||
/// position) is composited into a webcam-covered scene. The element rect is a
|
||
/// viewport onto the page, so the page's transparent margins must REVEAL the
|
||
/// layers beneath (the webcam), while the widget's opaque content still draws.
|
||
/// Feeding the ALPHA CROP instead of the full canvas zoomed the opaque content
|
||
/// box to cover the whole element rect — this test pins the reveal contract.</summary>
|
||
[Fact]
|
||
public void Composite_FullCanvasWebSource_TransparentMarginsRevealWebcam()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0);
|
||
var green = Solid(1280, 720, 0, 255, 0);
|
||
var blue = Solid(1920, 1080, 0, 0, 255);
|
||
|
||
// FULL-canvas web frame (what WebView2Manager hands to the compositor):
|
||
// transparent everywhere except an opaque badge at its canvas position.
|
||
var webPixels = new byte[1920 * 1080 * 4];
|
||
for (var y = 800; y < 880; y++)
|
||
for (var x = 1400; x < 1520; x++)
|
||
{
|
||
var i = (y * 1920 + x) * 4;
|
||
webPixels[i] = blue.BgraPixels[i];
|
||
webPixels[i + 1] = blue.BgraPixels[i + 1];
|
||
webPixels[i + 2] = blue.BgraPixels[i + 2];
|
||
webPixels[i + 3] = 255;
|
||
}
|
||
var webCanvas = new VideoFrame(1920, 1080, webPixels);
|
||
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var webcam = new WebcamSceneConfig { X = 1360, Y = 760, Width = 536, Height = 294, ClipShape = ClipShape.Traditional };
|
||
var webSource = new Source { Type = SourceType.WebSource, X = 1279, Y = 709, Width = 703, Height = 389 };
|
||
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
scene.Elements.Add(webcam);
|
||
scene.Elements.Add(webSource);
|
||
|
||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||
{
|
||
WebcamSceneConfig => green,
|
||
Source { IsBackground: true } => red,
|
||
Source { Type: SourceType.WebSource } => webCanvas,
|
||
_ => null,
|
||
};
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||
OutputWidth = 1920, OutputHeight = 1080,
|
||
};
|
||
|
||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||
|
||
// (1777,984) ≈ canvas (1360,760): inside the web element rect AND inside the
|
||
// webcam rect, but in the page's transparent margin -> the WEBCAM must show.
|
||
AssertColor(output, 1777, 984, 0, 255, 0);
|
||
// (1810,1010) ≈ canvas (1450,825): inside the widget's opaque badge -> the
|
||
// widget content draws over the webcam.
|
||
AssertColor(output, 1810, 1010, 0, 0, 255);
|
||
// deep margin north of the webcam: the backdrop shows through untouched.
|
||
AssertColor(output, 1300, 720, 255, 0, 0);
|
||
}
|
||
|
||
/// <summary>The take-4 memcpy fast path: a full-cover opaque backdrop (the live
|
||
/// capture's contract) must reproduce the source EXACTLY, and when supplied via the
|
||
/// <c>scratch</c> pool parameter the render must overwrite every byte of the reused
|
||
/// buffer — stale sentinels from a previous frame may not survive (this is what
|
||
/// makes buffer recycling safe at the pump level).</summary>
|
||
[Fact]
|
||
public void Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch()
|
||
{
|
||
const int w = 128, h = 64;
|
||
var src = new byte[w * h * 4];
|
||
for (var y = 0; y < h; y++)
|
||
for (var x = 0; x < w; x++)
|
||
{
|
||
var i = (y * w + x) * 4;
|
||
src[i] = (byte)(x * 2);
|
||
src[i + 1] = (byte)(y * 4);
|
||
src[i + 2] = (byte)(x + y);
|
||
src[i + 3] = 255; // the IsOpaque contract
|
||
}
|
||
var opaque = new VideoFrame(w, h, src) { IsOpaque = true };
|
||
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = w, SourceRectHeight = h,
|
||
OutputWidth = w, OutputHeight = h,
|
||
};
|
||
|
||
var scratch = new byte[w * h * 4];
|
||
Array.Fill(scratch, (byte)0xFD); // sentinel: x*2 (even), y*4 (×4), x+y (≤190) can never produce 253
|
||
|
||
var output = new SceneCompositor().Render(scene, _ => opaque, null, options, scratch: scratch);
|
||
|
||
Assert.Equal(src, output.BgraPixels); // byte-exact: the memcpy path must not round
|
||
Assert.DoesNotContain((byte)0xFD, output.BgraPixels); // full cover — nothing stale
|
||
}
|
||
|
||
/// <summary>The ONE integration test for the paste cache (take-8): a second render of
|
||
/// the SAME frames must produce byte-identical output (the hit path pastes a cached
|
||
/// element-space raster — position/opacity/margins/round-clip must survive it), and a
|
||
/// NEW source array must propagate (the cache keys on frame identity, so a stale
|
||
/// raster can never outlive its content). Covers the margins the FullScene probe
|
||
/// pixels don't: outside-the-circle alpha and the black pre-fill interaction.</summary>
|
||
[Fact]
|
||
public void PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0);
|
||
var green = Solid(1280, 720, 0, 255, 0);
|
||
var blue = Solid(1280, 720, 0, 0, 255);
|
||
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
scene.Elements.Add(roundWebcam);
|
||
|
||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||
{
|
||
WebcamSceneConfig => green,
|
||
Source { IsBackground: true } => red,
|
||
_ => null,
|
||
};
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||
OutputWidth = 1920, OutputHeight = 1080,
|
||
};
|
||
|
||
var compositor = new SceneCompositor();
|
||
var first = compositor.Render(scene, FrameFor, null, options);
|
||
var second = compositor.Render(scene, FrameFor, null, options); // paste-cache hit
|
||
Assert.Equal(first.BgraPixels, second.BgraPixels);
|
||
|
||
// margins: inside the circle = webcam, outside = backdrop (both renders)
|
||
foreach (var f in new[] { first, second })
|
||
{
|
||
AssertColor(f, 250, 250, 0, 255, 0);
|
||
AssertColor(f, 101, 101, 255, 0, 0);
|
||
Assert.Equal((byte)255, f.BgraPixels[(250 * 1920 + 250) * 4 + 3]); // alpha stays opaque
|
||
}
|
||
|
||
// Content change (new array, same geometry) must show through the cache.
|
||
VideoFrame? FrameForBlue(SceneElement e) => e switch
|
||
{
|
||
WebcamSceneConfig => blue,
|
||
Source { IsBackground: true } => red,
|
||
_ => null,
|
||
};
|
||
var third = compositor.Render(scene, FrameForBlue, null, options);
|
||
AssertColor(third, 250, 250, 0, 0, 255);
|
||
AssertColor(third, 101, 101, 255, 0, 0);
|
||
}
|
||
|
||
/// <summary>The ONE integration test for the capture buffer ring + paste-cache epoch
|
||
/// (take-11 spike fix): a recycled capture buffer hands out the SAME byte[] with
|
||
/// different content; only VideoFrame.Epoch can keep the paste cache honest. Without
|
||
/// the epoch in the key, frame 2 false-hits frame 1's raster and the recording shows
|
||
/// stale pixels for as long as the array keeps cycling — the exact bug this pins.</summary>
|
||
[Fact]
|
||
public void PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits()
|
||
{
|
||
var pixels = new byte[64 * 32 * 4];
|
||
for (var i = 0; i < pixels.Length; i += 4)
|
||
{
|
||
pixels[i] = 0; pixels[i + 1] = 0; pixels[i + 2] = 255; pixels[i + 3] = 255; // red
|
||
}
|
||
var gen1 = new VideoFrame(64, 32, pixels) { IsOpaque = true, Epoch = 1 };
|
||
|
||
var webcam = new WebcamSceneConfig { X = 4, Y = 4, Width = 32, Height = 16 };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(webcam);
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 128, SourceRectHeight = 64,
|
||
OutputWidth = 128, OutputHeight = 64,
|
||
};
|
||
|
||
var compositor = new SceneCompositor();
|
||
var out1 = compositor.Render(scene, _ => gen1, null, options);
|
||
AssertColor(out1, 20, 12, 255, 0, 0);
|
||
|
||
// Generation 2: the SAME array, new content, bumped epoch (what the capture
|
||
// ring does 60 times a second).
|
||
for (var i = 0; i < pixels.Length; i += 4)
|
||
{
|
||
pixels[i] = 0; pixels[i + 1] = 255; pixels[i + 2] = 0; pixels[i + 3] = 255; // green
|
||
}
|
||
var gen2 = new VideoFrame(64, 32, pixels) { IsOpaque = true, Epoch = 2 };
|
||
var out2 = compositor.Render(scene, _ => gen2, null, options);
|
||
AssertColor(out2, 20, 12, 0, 255, 0); // a stale hit would still be red
|
||
|
||
// And the same epoch twice still hits the cache (idempotent paste).
|
||
var out3 = compositor.Render(scene, _ => gen2, null, options);
|
||
Assert.Equal(out2.BgraPixels, out3.BgraPixels);
|
||
}
|
||
|
||
[Fact]
|
||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0);
|
||
var green = Solid(1280, 720, 0, 255, 0);
|
||
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var roundWebcam = new WebcamSceneConfig { X = 656, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
scene.Elements.Add(roundWebcam);
|
||
|
||
VideoFrame? FrameFor(SceneElement e) => e switch
|
||
{
|
||
WebcamSceneConfig => green,
|
||
Source { IsBackground: true } => red,
|
||
_ => null,
|
||
};
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 656, SourceRectY = 0, SourceRectWidth = 607, SourceRectHeight = 1080,
|
||
OutputWidth = 1080, OutputHeight = 1920,
|
||
};
|
||
|
||
var output = new SceneCompositor().Render(scene, FrameFor, null, options);
|
||
|
||
Assert.Equal(1080, output.Width);
|
||
Assert.Equal(1920, output.Height);
|
||
// top-left of the crop is pure background (webcam starts at crop y=100, inset from the corner)
|
||
AssertColor(output, 0, 0, 255, 0, 0);
|
||
// the webcam center (crop 150,250) scales to output ~(267,444) and stays green
|
||
AssertColor(output, 267, 444, 0, 255, 0);
|
||
// far from the webcam, still background
|
||
AssertColor(output, 978, 1688, 255, 0, 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void Composite_WithFlash_BlendsOverContent()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0);
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
|
||
// flash: a semi-transparent white pixel at the center of a master-sized frame
|
||
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||
var ci = (1080 / 2 * 1920 + 1920 / 2) * 4;
|
||
flash.BgraPixels[ci] = 255;
|
||
flash.BgraPixels[ci + 1] = 255;
|
||
flash.BgraPixels[ci + 2] = 255;
|
||
flash.BgraPixels[ci + 3] = 64; // ~25% alpha
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||
OutputWidth = 1920, OutputHeight = 1080,
|
||
};
|
||
|
||
var output = new SceneCompositor().Render(scene, _ => red, flash, options);
|
||
|
||
// red lightened by 25% white: ~(255, 63, 63)
|
||
AssertColor(output, 960, 540, 255, 64, 64);
|
||
// untouched corner stays pure red
|
||
AssertColor(output, 0, 0, 255, 0, 0);
|
||
}
|
||
|
||
[Fact]
|
||
public void Composite_WithSocialBar_OverlaysAboveFlash_AtTopOrBottom()
|
||
{
|
||
var red = Solid(1920, 1080, 255, 0, 0);
|
||
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||
var scene = new Scene { Name = "Live" };
|
||
scene.Elements.Add(background);
|
||
|
||
var options = new CompositorOptions
|
||
{
|
||
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
||
OutputWidth = 1920, OutputHeight = 1080,
|
||
};
|
||
var compositor = new SceneCompositor();
|
||
|
||
// master-sized overlay: opaque green at (0,0), 50%-white at the center
|
||
var bar = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||
var g = 0;
|
||
bar.BgraPixels[g] = 0; bar.BgraPixels[g + 1] = 255; bar.BgraPixels[g + 2] = 0; bar.BgraPixels[g + 3] = 255;
|
||
var w = (540 * 1920 + 960) * 4;
|
||
bar.BgraPixels[w] = 255; bar.BgraPixels[w + 1] = 255; bar.BgraPixels[w + 2] = 255; bar.BgraPixels[w + 3] = 128;
|
||
|
||
// flash: opaque magenta at (0,0) — the bar must cover it
|
||
var flash = new VideoFrame(1920, 1080, new byte[1920 * 1080 * 4]);
|
||
flash.BgraPixels[0] = 255; flash.BgraPixels[1] = 0; flash.BgraPixels[2] = 255; flash.BgraPixels[3] = 255;
|
||
|
||
var top = compositor.Render(scene, _ => red, flash, options, bar, 0);
|
||
AssertColor(top, 0, 0, 0, 255, 0); // bar above flash at the top-left
|
||
AssertColor(top, 960, 540, 255, 127, 127); // 50% white over red
|
||
AssertColor(top, 100, 100, 255, 0, 0); // empty overlay area: background
|
||
|
||
var bottom = compositor.Render(scene, _ => red, flash, options, bar, 1040);
|
||
AssertColor(bottom, 0, 1040, 0, 255, 0); // bar drawn at the bottom edge
|
||
AssertColor(bottom, 0, 1039, 255, 0, 0); // background just above the bar
|
||
AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center
|
||
}
|
||
}
|
||
|
||
public class StretchMathTests
|
||
{
|
||
[Fact]
|
||
public void UniformToFill_SameAspect_Is_Exact_Fit_With_No_Offset()
|
||
{
|
||
var (scale, ox, oy) = StretchMath.UniformToFill(400, 300, 800, 600);
|
||
Assert.Equal(0.5f, scale, 3);
|
||
Assert.Equal(0f, ox, 3);
|
||
Assert.Equal(0f, oy, 3);
|
||
}
|
||
|
||
[Fact]
|
||
public void UniformToFill_WiderSource_Crops_And_Centers_Vertically()
|
||
{
|
||
var (scale, ox, oy) = StretchMath.UniformToFill(400, 200, 800, 600);
|
||
Assert.Equal(0.5f, scale, 3);
|
||
Assert.Equal(0f, ox, 3);
|
||
Assert.Equal(-50f, oy, 3); // drawn 400x300 into 400x200 -> 50px crop top and bottom
|
||
}
|
||
|
||
[Fact]
|
||
public void BilinearScale_SameSize_ReturnsTheInput()
|
||
{
|
||
var src = SceneCompositorTests.Solid(4, 4, 10, 20, 30);
|
||
Assert.Same(src, StretchMath.BilinearScale(src, 4, 4));
|
||
}
|
||
|
||
[Fact]
|
||
public void BilinearScale_Downscales_To_Target_Size()
|
||
{
|
||
var src = SceneCompositorTests.Solid(16, 16, 0, 255, 0);
|
||
var scaled = StretchMath.BilinearScale(src, 8, 8);
|
||
Assert.Equal(8, scaled.Width);
|
||
Assert.Equal(8, scaled.Height);
|
||
var i = 0;
|
||
Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale
|
||
}
|
||
}
|