670fe3a10a
- Add ElementKind (Static/Dynamic) to SceneElement base; Source/WebcamSceneConfig classify - Services/SceneGraph.cs: owns the Scenes collection (ViewModel's Scenes delegates to it), the element mutation surface (Add/Insert/Remove/Move, each invalidating the bake), and the queries that were scattered LINQ (GetBackground/GetWebcam/GetChatBoxes/GetSplitPoint/IsStatic) - SceneCompositor: split-aware BakeStaticBase + CompositeLayers + Render(.., staticBase, split); builds/caches the static base below the split point in source-rect space - FramePump: optional SceneGraph -> optimized bake+composite path; falls back to full render - MainViewModel: routes element mutations through the graph; invalidates the bake on static layout/opacity/visibility/useDefaultBackground changes and after background heal/Ensure - Integration test SceneGraphTests.BakedStaticBase_WithDynamicLayer_CompositesCorrectly Derivative survey (mandated): tried before writing — OBS does per-source opacity/visibility caching and static-scene baking; this mirrors OBS's 'cached static source' optimization. 3 documented defensive deviations from the spec (ChatOverlayLayer stays decoupled; background helpers stay VM-static for direct testability; full facade peel deferred post-1.0) in TASKS.md + ai.md. Scope check passed; clean build 0 warnings.
119 lines
4.9 KiB
C#
119 lines
4.9 KiB
C#
using Xunit;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
using ytLive.Services.Compositor;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// TASK 31 — the baked-crust compositor optimization. Static layers below the split
|
|
/// point (first Dynamic element) are baked and cached once; only the dynamic /
|
|
/// above-split layers compose per frame. A static mutation invalidates the cache;
|
|
/// a dynamic-only pixel change never does.
|
|
/// </summary>
|
|
public class SceneGraphTests
|
|
{
|
|
private 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);
|
|
}
|
|
|
|
private static void AssertColor(VideoFrame frame, int x, int y, byte r, byte g, byte b)
|
|
{
|
|
var i = (y * frame.Width + x) * 4;
|
|
Assert.True(
|
|
Math.Abs(frame.BgraPixels[i + 2] - r) <= 2
|
|
&& Math.Abs(frame.BgraPixels[i + 1] - g) <= 2
|
|
&& Math.Abs(frame.BgraPixels[i] - b) <= 2,
|
|
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({frame.BgraPixels[i + 2]},"
|
|
+ $"{frame.BgraPixels[i + 1]},{frame.BgraPixels[i]})");
|
|
}
|
|
|
|
private static CompositorOptions Options()
|
|
=> new()
|
|
{
|
|
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080,
|
|
OutputWidth = 1920, OutputHeight = 1080,
|
|
};
|
|
|
|
[Fact]
|
|
public void BakedStaticBase_WithDynamicLayer_CompositesCorrectly()
|
|
{
|
|
var red = Solid(1920, 1080, 255, 0, 0); // static background art
|
|
var cyan = Solid(200, 200, 0, 255, 255); // static image
|
|
var green = Solid(1280, 720, 0, 255, 0); // dynamic webcam
|
|
|
|
// Z-order: static background -> static image -> dynamic webcam.
|
|
var background = new Source { Name = "Background", Type = SourceType.Background };
|
|
var image = new Source { Type = SourceType.Image, X = 1200, Y = 600, Width = 200, Height = 200 };
|
|
var webcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300 };
|
|
|
|
var scene = new Scene { Name = "Live" };
|
|
scene.Elements.Add(background);
|
|
scene.Elements.Add(image);
|
|
scene.Elements.Add(webcam);
|
|
|
|
var graph = new SceneGraph();
|
|
graph.Scenes.Add(scene);
|
|
|
|
// Split point: first dynamic element is the webcam at index 2.
|
|
Assert.Equal(2, graph.GetSplitPoint(scene));
|
|
|
|
var staticResolves = 0;
|
|
VideoFrame? FrameFor(SceneElement e)
|
|
{
|
|
switch (e)
|
|
{
|
|
case Source { Type: SourceType.Background }: staticResolves++; return red;
|
|
case Source { Type: SourceType.Image }: staticResolves++; return cyan;
|
|
case WebcamSceneConfig: return green;
|
|
default: return null;
|
|
}
|
|
}
|
|
|
|
// Ask twice. The static base must be baked/cached: its frameFor calls happen
|
|
// once (staticResolves at the first-bake count), and GetBakedBase returns the
|
|
// same instance the second time (cache hit — no re-bake).
|
|
var base1 = graph.GetBakedBase(scene, FrameFor, Options);
|
|
var resolvesAfterFirstBake = staticResolves;
|
|
var base2 = graph.GetBakedBase(scene, FrameFor, Options);
|
|
|
|
Assert.NotNull(base1);
|
|
Assert.Same(base1, base2); // cached: identical instance on second call
|
|
Assert.Equal(resolvesAfterFirstBake, staticResolves); // no re-bake on cache hit
|
|
|
|
// The baked base contains the static background (red) + static image (cyan).
|
|
AssertColor(base1!, 0, 0, 255, 0, 0);
|
|
AssertColor(base1!, 1220, 700, 0, 255, 255);
|
|
|
|
// Composite the dynamic layer on top: webcam covers its rect, image still below.
|
|
var split = graph.GetSplitPoint(scene);
|
|
var output = SceneCompositor.CompositeLayers(base1!, scene, split, FrameFor, Options());
|
|
|
|
AssertColor(output, 250, 250, 0, 255, 0); // webcam (dynamic) on top
|
|
AssertColor(output, 0, 0, 255, 0, 0); // static background still shows
|
|
AssertColor(output, 1220, 700, 0, 255, 255); // static image still shows
|
|
|
|
// Dynamic-only pixel change must NOT invalidate the cache: the webcam's frame
|
|
// identity is unchanged, so GetBakedBase still returns the same baked frame.
|
|
var afterDynamicOnly = graph.GetBakedBase(scene, FrameFor, Options);
|
|
Assert.Same(base1, afterDynamicOnly);
|
|
|
|
// A static mutation (moving the image) invalidates the cache: a fresh base is
|
|
// baked and the moved image lands at its new location.
|
|
image.X = 500; image.Y = 700;
|
|
graph.InvalidateBake(scene);
|
|
var rebaked = graph.GetBakedBase(scene, FrameFor, Options);
|
|
Assert.NotNull(rebaked);
|
|
AssertColor(rebaked!, 520, 750, 0, 255, 255); // image now at new location
|
|
}
|
|
}
|