Files
LlamaCasty/Services/SceneGraph.cs
T
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

160 lines
6.3 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using ytLive.Models;
using ytLive.Services.Compositor;
namespace ytLive.Services;
/// <summary>
/// The scene graph (TASK 31): single owner of the scene collection and the
/// element inventory / mutation surface, plus the static/dynamic classification
/// queries the compositor optimization depends on.
///
/// Scenes are referenced (not owned): the same <see cref="Scene"/> instances live
/// on the ViewModel's bindable surface; SceneGraph adds/removes/reorders their
/// <see cref="Scene.Elements"/> and answers queries (background / webcam / chat /
/// split point) that used to be scattered LINQ across MainViewModel.
///
/// Classification: <see cref="ElementKind.Static"/> (images, background art) is
/// baked once; <see cref="ElementKind.Dynamic"/> (webcam, live capture, chat, web)
/// is composited per frame. The <b>split point</b> is the index of the first
/// dynamic element — everything below it forms the baked base.
/// </summary>
public sealed class SceneGraph
{
private readonly Dictionary<string, SceneRegion> _regionCache = new(StringComparer.Ordinal);
private readonly object _gate = new();
/// <summary>The shared scene collection (same instances the ViewModel binds).</summary>
public ObservableCollection<Scene> Scenes { get; } = new();
/// <summary>The one Background row for a scene — null when the scene has none.</summary>
public Source? GetBackground(Scene scene)
=> scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
/// <summary>This scene's webcam usage, if any.</summary>
public WebcamSceneConfig? GetWebcam(Scene scene)
=> scene.Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
/// <summary>All chat-box elements across every scene (for ChatOverlayLayer).</summary>
public IEnumerable<Source> GetChatBoxes()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.Type == SourceType.ChatBox));
/// <summary>Index of the first dynamic element (the split point). If no dynamic
/// element exists this equals <see cref="Scene.Elements"/> count — fully baked.</summary>
public int GetSplitPoint(Scene scene)
{
var elements = scene.Elements;
for (var i = 0; i < elements.Count; i++)
{
if (elements[i].Kind == ElementKind.Dynamic)
return i;
}
return elements.Count;
}
/// <summary>True when the whole scene is static (no dynamic element) — the
/// compositor can bake it once and skip per-frame compositing.</summary>
public bool IsStatic(Scene scene)
=> scene.Elements.All(e => e.Kind == ElementKind.Static);
/// <summary>Invalidate the cached bake region for a scene after any mutation
/// (element add/remove/reorder, static resize/opacity/visibility, asset change).</summary>
public void InvalidateBake(Scene scene)
{
lock (_gate) _regionCache.Remove(scene.Id);
}
// ─── Mutation surface (single owner of element operations) ───
/// <summary>Append an element to a scene and (re)bake accordingly.</summary>
public void AddElement(Scene scene, SceneElement element)
{
scene.Elements.Add(element);
InvalidateBake(scene);
}
/// <summary>Insert an element at a specific z-index and (re)bake accordingly.</summary>
public void InsertElement(Scene scene, int index, SceneElement element)
{
scene.Elements.Insert(index, element);
InvalidateBake(scene);
}
/// <summary>Remove an element and (re)bake accordingly.</summary>
public bool RemoveElement(Scene scene, SceneElement element)
{
var removed = scene.Elements.Remove(element);
if (removed) InvalidateBake(scene);
return removed;
}
/// <summary>Reorder an element in z-space and (re)bake accordingly.</summary>
public void MoveElement(Scene scene, SceneElement element, int newIndex)
{
var current = scene.Elements.IndexOf(element);
if (current < 0) return;
scene.Elements.Move(current, newIndex);
InvalidateBake(scene);
}
/// <summary>Add a scene to the collection (wiring is handled by the ViewModel).</summary>
public void AddScene(Scene scene) => Scenes.Add(scene);
/// <summary>Get (or bake) the cached static base for a scene. Null means "no static
/// base" (first layer is dynamic or the scene is empty) — caller falls back to full render.</summary>
public VideoFrame? GetBakedBase(
Scene scene,
Func<SceneElement, VideoFrame?> frameFor,
Func<CompositorOptions> options,
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null)
{
var split = GetSplitPoint(scene);
if (split == 0) return null;
lock (_gate)
{
if (_regionCache.TryGetValue(scene.Id, out var cached)
&& cached.Matches(scene, split))
return cached.Frame;
}
var baked = SceneCompositor.BakeStaticBase(
scene, split, frameFor, options, socialBarFrame, socialBarTop, flashFrame);
if (baked == null) return null;
lock (_gate)
{
_regionCache[scene.Id] = new SceneRegion(baked, scene.Elements.Take(split).ToList());
}
return baked;
}
/// <summary>The baked frame + the element identity it was baked from (for invalidation).</summary>
private sealed class SceneRegion
{
public VideoFrame Frame { get; }
private readonly string[] _staticElementIds;
public SceneRegion(VideoFrame frame, List<SceneElement> staticBelow)
{
Frame = frame;
_staticElementIds = staticBelow.Select(e => e.Id).ToArray();
}
/// <summary>Only static layers below the split are part of the base. Two bakes
/// match iff the same static element identities, in the same order, are below
/// the split — dynamic-only pixel/layout changes never invalidate.</summary>
public bool Matches(Scene scene, int split)
{
var below = scene.Elements.Take(split).Select(e => e.Id).ToArray();
return below.Length == _staticElementIds.Length
&& below.SequenceEqual(_staticElementIds);
}
}
}