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:
@@ -27,7 +27,9 @@ public sealed class SceneCompositor
|
||||
VideoFrame? flashFrame,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0)
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? staticBase = null,
|
||||
int split = -1)
|
||||
{
|
||||
if (scene == null) throw new ArgumentNullException(nameof(scene));
|
||||
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
|
||||
@@ -40,45 +42,63 @@ public sealed class SceneCompositor
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base — video frames are never transparent
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
// staticBase != null: the caller pre-baked all static layers below `split`.
|
||||
// We copy it (never mutate the cached base) and composite elements[split..] on top.
|
||||
if (staticBase != null)
|
||||
{
|
||||
if (staticBase.Width != cropW || staticBase.Height != cropH)
|
||||
throw new ArgumentException("The static base must match the source rect.", nameof(staticBase));
|
||||
Buffer.BlockCopy(staticBase.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (split < 0) split = scene.Elements.Count;
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255; // opaque black base
|
||||
|
||||
var elements = scene.Elements;
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
var liveCaptureFrame = liveCapture != null && liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
var backgroundFrame = background != null ? frameFor(background) : null;
|
||||
if (backgroundFrame != null)
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
|
||||
foreach (var element in elements)
|
||||
{
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var fullFrame = frameFor(element);
|
||||
if (fullFrame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, fullFrame);
|
||||
}
|
||||
}
|
||||
|
||||
// Composite the above-split layers (dynamic + any static above them) per frame.
|
||||
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue; // background/background are their own layers; Text isn't shipped
|
||||
continue;
|
||||
}
|
||||
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
|
||||
if (flashFrame != null)
|
||||
@@ -91,6 +111,136 @@ public sealed class SceneCompositor
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render the static base of a scene: the opaque black layer, the live-capture
|
||||
/// background, the background image, and every <paramref name="split"/> z-layers
|
||||
/// below the split point (all of which are static by construction — see
|
||||
/// <see cref="SceneElement.Kind"/>). The result is a source-rect sized frame that
|
||||
/// <see cref="Render"/> can start from and composite the dynamic/above-split layers
|
||||
/// onto each tick. Returns null when there is no static layer at all.
|
||||
/// </summary>
|
||||
public static VideoFrame? BakeStaticBase(
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
Func<CompositorOptions> options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null)
|
||||
{
|
||||
if (split <= 0) return null;
|
||||
var opts = options();
|
||||
if (opts.SourceRectWidth <= 0 || opts.SourceRectHeight <= 0) return null;
|
||||
|
||||
var cropW = opts.SourceRectWidth;
|
||||
var cropH = opts.SourceRectHeight;
|
||||
var buffer = new byte[cropW * cropH * 4];
|
||||
for (var i = 3; i < buffer.Length; i += 4)
|
||||
buffer[i] = 255;
|
||||
|
||||
var elements = scene.Elements;
|
||||
|
||||
var liveCapture = elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
||||
if (liveCapture != null)
|
||||
{
|
||||
var liveCaptureFrame = liveCapture.IsVisible ? frameFor(liveCapture) : null;
|
||||
if (liveCaptureFrame != null && split > elements.IndexOf(liveCapture))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
var background = elements.OfType<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
|
||||
if (background != null)
|
||||
{
|
||||
var backgroundFrame = frameFor(background);
|
||||
if (backgroundFrame != null && split > elements.IndexOf(background))
|
||||
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, backgroundFrame, 1f, false, false);
|
||||
}
|
||||
|
||||
for (var i = 0; i < split; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, opts, element, frame);
|
||||
}
|
||||
|
||||
var baked = new VideoFrame(cropW, cropH, buffer);
|
||||
if (flashFrame != null)
|
||||
baked = Overlay(baked, opts, flashFrame, 0, 0);
|
||||
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
|
||||
baked = Overlay(baked, opts, socialBarFrame, 0, socialBarTop);
|
||||
return baked;
|
||||
}
|
||||
|
||||
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
|
||||
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
|
||||
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.</summary>
|
||||
public static VideoFrame CompositeLayers(
|
||||
VideoFrame baseFrame,
|
||||
Scene scene,
|
||||
int split,
|
||||
Func<SceneElement, VideoFrame?> frameFor,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame = null,
|
||||
int socialBarTop = 0,
|
||||
VideoFrame? flashFrame = null)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
// We must not mutate the cached base: copy it into a fresh buffer.
|
||||
var buffer = new byte[baseFrame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(baseFrame.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
|
||||
for (var i = split; i < scene.Elements.Count; i++)
|
||||
{
|
||||
var element = scene.Elements[i];
|
||||
if (!element.IsVisible) continue;
|
||||
switch (element)
|
||||
{
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
if (frame == null) continue;
|
||||
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
|
||||
}
|
||||
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
private static void BlitBakedElement(
|
||||
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame)
|
||||
{
|
||||
var ex = (float)(element.X - options.SourceRectX);
|
||||
var ey = (float)(element.Y - options.SourceRectY);
|
||||
var ew = (float)element.Width;
|
||||
var eh = (float)element.Height;
|
||||
var isRound = element.ClipShape == ClipShape.Round;
|
||||
BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame,
|
||||
(float)element.Opacity, isRound, element.IsMirrored);
|
||||
if (element.HasBorder)
|
||||
DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound);
|
||||
}
|
||||
|
||||
private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0)
|
||||
{
|
||||
var cropW = options.SourceRectWidth;
|
||||
var cropH = options.SourceRectHeight;
|
||||
var buffer = new byte[frame.BgraPixels.Length];
|
||||
Buffer.BlockCopy(frame.BgraPixels, 0, buffer, 0, buffer.Length);
|
||||
BlitOverlay(buffer, cropW, cropH, options, overlay, sx0, sy0);
|
||||
return new VideoFrame(cropW, cropH, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render a cross-fade blend between two scenes. Used by <see cref="TransitionService"/>
|
||||
/// during fade/move transitions: the caller renders each scene individually, then blends
|
||||
|
||||
@@ -31,6 +31,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly Func<Scene?>? _fromSceneProvider;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
|
||||
private readonly object _gate = new();
|
||||
@@ -55,7 +56,8 @@ public sealed class FramePump : IDisposable
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
TransitionService? transition = null,
|
||||
Func<Scene?>? fromSceneProvider = null)
|
||||
Func<Scene?>? fromSceneProvider = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
_frameResolver = frameResolver ?? throw new ArgumentNullException(nameof(frameResolver));
|
||||
@@ -67,6 +69,7 @@ public sealed class FramePump : IDisposable
|
||||
_socialBar = socialBar;
|
||||
_transition = transition;
|
||||
_fromSceneProvider = fromSceneProvider;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
@@ -240,16 +243,16 @@ public sealed class FramePump : IDisposable
|
||||
{
|
||||
var fromScene = _fromSceneProvider();
|
||||
var fromFrame = fromScene != null
|
||||
? _compositor.Render(fromScene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
? RenderScene(fromScene, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = transition.BlendFrame(toFrame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
lastTick.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = _compositor.Render(scene, _frameResolver, null, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
lastTick.Restart();
|
||||
}
|
||||
|
||||
@@ -287,6 +290,39 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
private void OnHealthUpdated(object? sender, StreamHealth health) => HealthUpdated?.Invoke(this, health);
|
||||
|
||||
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
|
||||
/// is wired in: bake/cache the static layers below the split point, then composite the
|
||||
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
|
||||
/// (identical output — see SceneCompositorTests).</summary>
|
||||
private VideoFrame RenderScene(
|
||||
Scene scene,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop)
|
||||
{
|
||||
if (_sceneGraph == null)
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop);
|
||||
|
||||
var split = _sceneGraph.GetSplitPoint(scene);
|
||||
if (split == scene.Elements.Count)
|
||||
{
|
||||
// Fully static scene: bake once, reuse.
|
||||
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baked != null)
|
||||
return StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
|
||||
if (baseFrame != null)
|
||||
{
|
||||
return SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, _frameResolver, options, socialBarFrame, socialBarTop);
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop);
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
{
|
||||
_log?.Invoke($"FramePump: encoder process failed: {message}");
|
||||
|
||||
@@ -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