From 9b337ad91b17578625cbe187ff4fef3c84f33af8 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 1 Nov 2026 13:32:23 -0800 Subject: [PATCH] feat(blur): the brand logo cannot be blurred The blur is universal, so it would swallow the logo along with everything else. The compositor now defers every visible brand logo: skipped in both element loops, blitted immediately AFTER the blur group, before the flash/social/ticker overlays. One logo per scene, so it is cheap. Preview mirrors it: the staged scene's brand logo is drawn above the blur host (StagedBrandLogo), so what you see in the editor is what the encoder gets. Test: a blur covering the logo's area leaves the logo's pixels exactly white while the coloured background beneath it blends. --- Controls/PreviewPane.xaml | 7 +++ Services/Compositor/SceneCompositor.cs | 15 +++++ ViewModels/MainViewModel.BrandLogo.cs | 5 ++ ViewModels/MainViewModel.Scenes.cs | 1 + ytLive.Tests/BlurCompositorTests.cs | 87 ++++++++++++++++++++++++++ 5 files changed, 115 insertions(+) create mode 100644 ytLive.Tests/BlurCompositorTests.cs diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 7ca04a8..97b092c 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -471,6 +471,13 @@ + + ().Where(s => s.IsBrandLogo && s.IsVisible)) + { + var brandFrame = frameFor(brand); + if (brandFrame == null) continue; + BlitBakedElement(buffer, cropW, cropH, options, brand, brandFrame, timings, $"brand {brand.Name}"); + } + if (flashFrame != null) { var tFlash = System.Diagnostics.Stopwatch.GetTimestamp(); @@ -358,6 +370,9 @@ public sealed class SceneCompositor case Source { IsBackground: true }: case Source { Type: SourceType.Background }: case Source { Type: SourceType.TextOverlay }: + // ⛔ The brand logo is drawn AFTER the blur group (creator ruling 2026-11-01): + // the blur is universal and must never hide the brand. + case Source { IsBrandLogo: true }: case ZoomSceneElement { IsZoomActive: true }: continue; } diff --git a/ViewModels/MainViewModel.BrandLogo.cs b/ViewModels/MainViewModel.BrandLogo.cs index b9e86fe..3e42a4f 100644 --- a/ViewModels/MainViewModel.BrandLogo.cs +++ b/ViewModels/MainViewModel.BrandLogo.cs @@ -97,6 +97,11 @@ public partial class MainViewModel Height = BrandLogoHeight, }; + /// The staged scene's brand logo, exposed so the preview can draw it ABOVE the universal + /// blur (the compositor blits it after the blur group — the logo is never obscured). + public Source? StagedBrandLogo => + StagedScene?.Elements.OfType().FirstOrDefault(s => s.IsBrandLogo); + /// Exactly one brand logo per scene, placed directly above the background, carrying /// the current App Settings image. internal void EnsureBrandLogos() diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index e95bcad..2fcde58 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -37,6 +37,7 @@ public partial class MainViewModel // binding that is never re-notified stays collapsed for the rest of the session // (build 1082: the option vanished after the first layout load). OnPropertyChanged(nameof(CanAddBlur)); + OnPropertyChanged(nameof(StagedBrandLogo)); SelectedElement = null; if (value != null) diff --git a/ytLive.Tests/BlurCompositorTests.cs b/ytLive.Tests/BlurCompositorTests.cs new file mode 100644 index 0000000..71d8f6b --- /dev/null +++ b/ytLive.Tests/BlurCompositorTests.cs @@ -0,0 +1,87 @@ +using Xunit; +using ytLive.Models; +using ytLive.Services; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// +/// The universal privacy blur at the compositor level (creator ruling 2026-11-01): it blankets +/// every layer beneath it, and the brand logo — blitted after the blur — is never swallowed. +/// +public class BlurCompositorTests +{ + private static VideoFrame Solid(int w, int h, byte r, byte g, byte b) + { + var pixels = new byte[w * h * 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(w, h, pixels); + } + + /// Hard left/right colour split — blurring is unmistakable on it. + private static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb) + { + var pixels = new byte[w * h * 4]; + var half = w / 2; + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + { + var i = (y * w + x) * 4; + pixels[i] = x < half ? lb : rb; + pixels[i + 1] = x < half ? lg : rg; + pixels[i + 2] = x < half ? lr : rr; + pixels[i + 3] = 255; + } + return new VideoFrame(w, h, pixels); + } + + private static void AssertPixel(VideoFrame frame, int x, int y, byte r, byte g, byte b) + { + var i = (y * frame.Width + x) * 4; + Assert.True( + frame.BgraPixels[i + 2] == r && frame.BgraPixels[i + 1] == g && frame.BgraPixels[i] == b, + $"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({frame.BgraPixels[i + 2]},{frame.BgraPixels[i + 1]},{frame.BgraPixels[i]})"); + } + + [Fact] + public void The_blur_covers_what_is_beneath_it_and_never_the_brand_logo() + { + var stripes = Split(1920, 1080, 0, 0, 255, 0, 255, 0); // blue | green, hard edge at x=960 + var brand = Solid(120, 60, 255, 255, 255); // white logo + + var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" }; + var logo = new Source { Type = SourceType.Image, IsBrandLogo = true, X = 900, Y = 500, Width = 120, Height = 60 }; + var blur = new Source { Type = SourceType.Blur, X = 700, Y = 400, Width = 600, Height = 300, BlurAmount = 1.0 }; + + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(background); + scene.Elements.Add(logo); + scene.Elements.Add(blur); + + VideoFrame? FrameFor(SceneElement e) => e switch + { + Source { IsBackground: true } => stripes, + Source { IsBrandLogo: true } => brand, + _ => null, + }; + + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080, + OutputWidth = 1920, OutputHeight = 1080, + }; + + var output = new SceneCompositor().Render(scene, FrameFor, null, options); + + // 1. Inside the blur, over the colour edge: blended, not the hard split. + var i = (450 * output.Width + 950) * 4; + Assert.True(output.BgraPixels[i] > 0 && output.BgraPixels[i + 1] > 0, + "a blurred pixel on the colour edge must be a blend of both sides"); + + // 2. The logo, sitting inside the blur's area, is untouched. + AssertPixel(output, 950, 530, 255, 255, 255); + } +}