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.
160 lines
6.3 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|