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.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user