Files
LlamaCasty/Services/SceneGraph.cs
T
gramps a3a4681aac Per-element composite timing + scene dump (measurement only)
The cadence work ended with a question the log could not answer: the compositor
manages 6.8 composites/s at ~141ms while the 10:03 take did 29-34ms on the "same
scene shape" (full-render split=0 elements=6 dynamic=5). Nothing in the stats line
said WHICH of the six elements cost what, and nothing recorded the scene itself, so
the two takes could only be compared by eye. This closes that gap and nothing else:
no fix is attempted until a real take names the culprit.

- Services/Compositor/CompositeTimings.cs (new): per-window collector.
  Add(stage, ticks) accumulates; Describe(renders, minMs = 0.1) prints the averaged
  breakdown slowest-first, dropping sub-0.1ms noise.
- SceneCompositor.Render / CompositeLayers / BakeStaticBase / BlitBakedElement /
  BlitCachedLayer + SceneGraph.GetBakedBase: trailing optional CompositeTimings?
  Every stage is timed (backdrop, base, flash, social, ticker, zoom, scale, overlay,
  bake backdrop, bake overlays) and EVERY element gets its own entry keyed by
  z-index + name ("#2 Webcam"), with the nested "raster" sub-stage recording the
  paste-cache rasterize miss - the split between "re-samples every tick" and "slow
  paste" without a second session. Bake-pass elements are prefixed "bake #..." so a
  one-off rebake cannot masquerade as a per-composite cost. Callers that pass
  nothing (thumbnails, existing tests) are unaffected and pay nothing.
- FramePump: owns the collector, threads it through RenderScene/RenderFull/
  GetBakedBase/CompositeLayers, and logs "FramePump parts (N renders/5s): ..." once
  per 5s window beside the stats line (averaged per render, so it compares directly
  with "avg render"), then resets it. Also dumps the scene - "FramePump scene dump:"
  header plus one line per element (z-index, name, kind, source type, visibility,
  rect, opacity, clip) - at pump start and on every scene change.
- One Good Dog integration test (ytLive.Tests/CompositeTimingTests.cs): a real
  composite of a full-canvas element plus a 64x64 one through the real collector
  must rank the full-canvas element first, carry the raster sub-stage and the fixed
  stages, and the dump must name both elements with z-index/type/rect and echo the
  split point.

Derivative work (AGENTS.md): OBS's answer to "which source is slow" is the Source
Profiler - average execution time of each source's tick and render functions
(https://docs.obsproject.com/reference-libobs-util-source-profiler, util/profiler.h).
Same shape here: one timer per element per composite, reported as an average, plus
the stage split this case needs.

Docs in the same commit: ai.md (new subsection "Per-element composite timing + scene
dump", the closed gap corrected in "Frame cadence"), Services/index.md (new file row
+ FramePump/SceneCompositor rows), TASKS.md (unit marked shipped, next = creator
take), HANDOFF.md (rewritten: unpushed commit, next step, verify.sh whitelist hole).

Also recorded this session (from the creator): the launch command is
`dotnet run -c Release` - Release is the DEFAULT, Debug only when explicitly named,
and perf takes must read Release. ai.md -> Run updated.

Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
2026-10-16 18:23:20 -07:00

161 lines
6.4 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,
CompositeTimings? timings = 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, timings);
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);
}
}
}