feat(text): composite shared text rasters in output
Raster static text on UI-thread changes and reuse the same transparent BGRA frame in preview and all compositor paths. Cache text frame identity in the frame-pump signature and invalidate static bakes on edits. Follow OBS text-source conventions: https://obsproject.com/kb/text-sources
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Controls;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
[Collection("RealApp")]
|
||||
public sealed class TextLayerOutputTests
|
||||
{
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public TextLayerOutputTests(RealAppHost app) => _app = app;
|
||||
|
||||
[Fact]
|
||||
public void Text_Layers_Share_The_Preview_Raster_With_Full_And_Baked_Output_And_Refresh_On_Edit()
|
||||
{
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
private static void Run()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"ytLlive-text-output-{Guid.NewGuid():N}.db");
|
||||
MainViewModel.LayoutPathOverride = dbPath;
|
||||
MainWindow? window = null;
|
||||
try
|
||||
{
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
vm.EditingActive = true;
|
||||
var scene = new Scene { Name = "Text output test" };
|
||||
vm.Scenes.Add(scene);
|
||||
vm.StagedScene = scene;
|
||||
|
||||
var art = new Source
|
||||
{
|
||||
Name = "Test background",
|
||||
Type = SourceType.Image,
|
||||
Width = 320,
|
||||
Height = 180,
|
||||
};
|
||||
var lowerText = new Source
|
||||
{
|
||||
Name = "Lower text",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "Lower layer",
|
||||
X = 12,
|
||||
Y = 12,
|
||||
Width = 140,
|
||||
Height = 52,
|
||||
TextFontSize = 32,
|
||||
};
|
||||
var dynamic = new Source
|
||||
{
|
||||
Name = "Dynamic split",
|
||||
Type = SourceType.WebSource,
|
||||
Width = 20,
|
||||
Height = 20,
|
||||
};
|
||||
var upperText = new Source
|
||||
{
|
||||
Name = "Upper text",
|
||||
Type = SourceType.TextOverlay,
|
||||
TextContent = "Upper layer",
|
||||
X = 150,
|
||||
Y = 90,
|
||||
Width = 150,
|
||||
Height = 52,
|
||||
TextFontSize = 30,
|
||||
TextBold = true,
|
||||
};
|
||||
scene.Elements.Add(art);
|
||||
scene.Elements.Add(lowerText);
|
||||
scene.Elements.Add(dynamic);
|
||||
scene.Elements.Add(upperText);
|
||||
vm.SelectedElement = lowerText;
|
||||
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
var preview = (PreviewPane)window.FindName("PreviewPane")!;
|
||||
AssertPreviewUsesTextFrame(preview, lowerText);
|
||||
AssertPreviewUsesTextFrame(preview, upperText);
|
||||
|
||||
var background = Solid(320, 180, 24, 38, 52);
|
||||
VideoFrame? Resolve(SceneElement element) => ReferenceEquals(element, art)
|
||||
? background
|
||||
: vm.ResolveOutputFrame(element);
|
||||
var options = new CompositorOptions
|
||||
{
|
||||
SourceRectX = 0,
|
||||
SourceRectY = 0,
|
||||
SourceRectWidth = 320,
|
||||
SourceRectHeight = 180,
|
||||
OutputWidth = 320,
|
||||
OutputHeight = 180,
|
||||
};
|
||||
var compositor = new SceneCompositor();
|
||||
var fullBefore = compositor.Render(scene, Resolve, null, options);
|
||||
AssertHasTextPixels(fullBefore, background);
|
||||
|
||||
var graph = vm.SceneGraphForTests;
|
||||
var split = graph.GetSplitPoint(scene);
|
||||
Assert.Equal(2, split);
|
||||
var bakedBefore = graph.GetBakedBase(scene, Resolve, () => options);
|
||||
Assert.NotNull(bakedBefore);
|
||||
var mixedBefore = SceneCompositor.CompositeLayers(bakedBefore!, scene, split, Resolve, options);
|
||||
AssertHasTextPixels(mixedBefore, background);
|
||||
|
||||
var lowerFrameBefore = Assert.IsType<VideoFrame>(vm.ResolveOutputFrame(lowerText));
|
||||
var leftPanel = (LeftPanel)window.FindName("LeftPanel")!;
|
||||
var editor = (TextBox)leftPanel.FindName("TextLayerEditor")!;
|
||||
editor.Text = "Edited lower layer";
|
||||
editor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
|
||||
var colorEditor = (TextBox)leftPanel.FindName("TextLayerColorEditor")!;
|
||||
colorEditor.Text = "#FF0000";
|
||||
colorEditor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
|
||||
window.UpdateLayout();
|
||||
|
||||
Assert.Equal("#FF0000", lowerText.TextColor);
|
||||
var updatedFrame = Assert.IsType<VideoFrame>(vm.ResolveOutputFrame(lowerText));
|
||||
var fullAfter = compositor.Render(scene, Resolve, null, options);
|
||||
Assert.NotSame(lowerFrameBefore, updatedFrame);
|
||||
Assert.False(fullBefore.BgraPixels.AsSpan().SequenceEqual(fullAfter.BgraPixels));
|
||||
AssertHasTextPixels(fullAfter, background);
|
||||
|
||||
var bakedAfter = graph.GetBakedBase(scene, Resolve, () => options);
|
||||
Assert.NotNull(bakedAfter);
|
||||
Assert.NotSame(bakedBefore, bakedAfter);
|
||||
var mixedAfter = SceneCompositor.CompositeLayers(bakedAfter!, scene, split, Resolve, options);
|
||||
AssertHasTextPixels(mixedAfter, background);
|
||||
AssertPreviewUsesTextFrame(preview, lowerText);
|
||||
|
||||
scene.Elements.Remove(dynamic);
|
||||
vm.SaveLayoutNow();
|
||||
window.Close();
|
||||
window = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
window = new MainWindow();
|
||||
var reloadedVm = (MainViewModel)window.DataContext;
|
||||
reloadedVm.EditingActive = true;
|
||||
var reloadedScene = reloadedVm.Scenes.Single(item => item.Name == scene.Name);
|
||||
reloadedVm.StagedScene = reloadedScene;
|
||||
var reloadedLowerText = reloadedScene.Elements.OfType<Source>()
|
||||
.Single(source => source.Name == lowerText.Name);
|
||||
var reloadedUpperText = reloadedScene.Elements.OfType<Source>()
|
||||
.Single(source => source.Name == upperText.Name);
|
||||
reloadedVm.SelectedElement = reloadedLowerText;
|
||||
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
AssertPreviewUsesTextFrame((PreviewPane)window.FindName("PreviewPane")!, reloadedLowerText);
|
||||
var loadedFrameBefore = Assert.IsType<VideoFrame>(reloadedVm.ResolveOutputFrame(reloadedLowerText));
|
||||
var loadedUpperBitmap = reloadedUpperText.VideoImageSource;
|
||||
var loadedGraph = reloadedVm.SceneGraphForTests;
|
||||
var loadedResolve = new Func<SceneElement, VideoFrame?>(element =>
|
||||
string.Equals(element.Name, "Test background", StringComparison.Ordinal)
|
||||
? background
|
||||
: reloadedVm.ResolveOutputFrame(element));
|
||||
var loadedBakeBefore = loadedGraph.GetBakedBase(reloadedScene, loadedResolve, () => options);
|
||||
Assert.NotNull(loadedBakeBefore);
|
||||
|
||||
var reloadedPanel = (LeftPanel)window.FindName("LeftPanel")!;
|
||||
var reloadedEditor = (TextBox)reloadedPanel.FindName("TextLayerEditor")!;
|
||||
reloadedEditor.Text = "Edited after reload";
|
||||
reloadedEditor.GetBindingExpression(TextBox.TextProperty)?.UpdateSource();
|
||||
window.UpdateLayout();
|
||||
|
||||
var loadedFrameAfter = Assert.IsType<VideoFrame>(reloadedVm.ResolveOutputFrame(reloadedLowerText));
|
||||
var loadedBakeAfter = loadedGraph.GetBakedBase(reloadedScene, loadedResolve, () => options);
|
||||
Assert.NotSame(loadedFrameBefore, loadedFrameAfter);
|
||||
Assert.NotSame(loadedBakeBefore, loadedBakeAfter);
|
||||
Assert.Same(loadedUpperBitmap, reloadedUpperText.VideoImageSource);
|
||||
Assert.Equal("Edited after reload", reloadedLowerText.TextContent);
|
||||
AssertPreviewUsesTextFrame((PreviewPane)window.FindName("PreviewPane")!, reloadedLowerText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window?.Close();
|
||||
MainViewModel.LayoutPathOverride = null;
|
||||
SqliteConnection.ClearAllPools();
|
||||
try { File.Delete(dbPath); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertPreviewUsesTextFrame(PreviewPane preview, Source source)
|
||||
{
|
||||
var image = FindTextImage(preview, source);
|
||||
Assert.NotNull(image);
|
||||
Assert.Same(source.VideoImageSource, image!.Source);
|
||||
Assert.Same(source.VideoImageSource, source.DisplaySource);
|
||||
Assert.True(image.IsVisible);
|
||||
}
|
||||
|
||||
private static Image? FindTextImage(DependencyObject parent, Source source)
|
||||
{
|
||||
if (parent is Image image && ReferenceEquals(image.DataContext, source))
|
||||
return image;
|
||||
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
|
||||
{
|
||||
var result = FindTextImage(VisualTreeHelper.GetChild(parent, i), source);
|
||||
if (result != null) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AssertHasTextPixels(VideoFrame frame, VideoFrame background)
|
||||
{
|
||||
var changed = false;
|
||||
for (var i = 0; i < frame.BgraPixels.Length; i += 4)
|
||||
{
|
||||
if (frame.BgraPixels[i] != background.BgraPixels[i]
|
||||
|| frame.BgraPixels[i + 1] != background.BgraPixels[i + 1]
|
||||
|| frame.BgraPixels[i + 2] != background.BgraPixels[i + 2])
|
||||
{
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.True(changed, "the text layer did not composite over the background pixels");
|
||||
}
|
||||
|
||||
private static VideoFrame Solid(int width, int height, byte r, byte g, byte b)
|
||||
{
|
||||
var pixels = new byte[width * height * 4];
|
||||
for (var i = 0; i < pixels.Length; i += 4)
|
||||
{
|
||||
pixels[i] = b;
|
||||
pixels[i + 1] = g;
|
||||
pixels[i + 2] = r;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
return new VideoFrame(width, height, pixels);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user