using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using ytLive.Models; using ytLive.Services.Compositor; namespace ytLive.Services; /// /// 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 instances live /// on the ViewModel's bindable surface; SceneGraph adds/removes/reorders their /// and answers queries (background / webcam / chat / /// split point) that used to be scattered LINQ across MainViewModel. /// /// Classification: (images, background art) is /// baked once; (webcam, live capture, chat, web) /// is composited per frame. The split point is the index of the first /// dynamic element — everything below it forms the baked base. /// public sealed class SceneGraph { private readonly Dictionary _regionCache = new(StringComparer.Ordinal); private readonly object _gate = new(); /// The shared scene collection (same instances the ViewModel binds). public ObservableCollection Scenes { get; } = new(); /// The one Background row for a scene — null when the scene has none. public Source? GetBackground(Scene scene) => scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); /// This scene's webcam usage, if any. public WebcamSceneConfig? GetWebcam(Scene scene) => scene.Elements.OfType().FirstOrDefault(); /// All chat-box elements across every scene (for ChatOverlayLayer). public IEnumerable GetChatBoxes() => Scenes.SelectMany(s => s.Elements.OfType().Where(x => x.Type == SourceType.ChatBox)); /// Index of the first dynamic element (the split point). If no dynamic /// element exists this equals count — fully baked. 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; } /// True when the whole scene is static (no dynamic element) — the /// compositor can bake it once and skip per-frame compositing. public bool IsStatic(Scene scene) => scene.Elements.All(e => e.Kind == ElementKind.Static); /// Invalidate the cached bake region for a scene after any mutation /// (element add/remove/reorder, static resize/opacity/visibility, asset change). public void InvalidateBake(Scene scene) { lock (_gate) _regionCache.Remove(scene.Id); } // ─── Mutation surface (single owner of element operations) ─── /// Append an element to a scene and (re)bake accordingly. public void AddElement(Scene scene, SceneElement element) { scene.Elements.Add(element); InvalidateBake(scene); } /// Insert an element at a specific z-index and (re)bake accordingly. public void InsertElement(Scene scene, int index, SceneElement element) { scene.Elements.Insert(index, element); InvalidateBake(scene); } /// Remove an element and (re)bake accordingly. public bool RemoveElement(Scene scene, SceneElement element) { var removed = scene.Elements.Remove(element); if (removed) InvalidateBake(scene); return removed; } /// Reorder an element in z-space and (re)bake accordingly. 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); } /// Add a scene to the collection (wiring is handled by the ViewModel). public void AddScene(Scene scene) => Scenes.Add(scene); /// 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. public VideoFrame? GetBakedBase( Scene scene, Func frameFor, Func 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; } /// The baked frame + the element identity it was baked from (for invalidation). private sealed class SceneRegion { public VideoFrame Frame { get; } private readonly string[] _staticElementIds; public SceneRegion(VideoFrame frame, List staticBelow) { Frame = frame; _staticElementIds = staticBelow.Select(e => e.Id).ToArray(); } /// 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. 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); } } }