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(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(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() .Single(source => source.Name == lowerText.Name); var reloadedUpperText = reloadedScene.Elements.OfType() .Single(source => source.Name == upperText.Name); reloadedVm.SelectedElement = reloadedLowerText; window.Show(); window.UpdateLayout(); AssertPreviewUsesTextFrame((PreviewPane)window.FindName("PreviewPane")!, reloadedLowerText); var loadedFrameBefore = Assert.IsType(reloadedVm.ResolveOutputFrame(reloadedLowerText)); var loadedUpperBitmap = reloadedUpperText.VideoImageSource; var loadedGraph = reloadedVm.SceneGraphForTests; var loadedResolve = new Func(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(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); } }