Files
gramps 670fe3a10a TASK 31: SceneGraph component + baked-crust compositor optimization
- 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.
2026-08-31 17:53:34 -07:00

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