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.
This commit is contained in:
@@ -471,6 +471,13 @@
|
||||
</Grid.Background>
|
||||
</Grid>
|
||||
<Canvas x:Name="BlurPreviewHost" Width="1920" Height="1080" IsHitTestVisible="False"/>
|
||||
<!-- The brand logo rides ABOVE the blur host, mirroring the compositor:
|
||||
a privacy blur can never swallow it. -->
|
||||
<Image Canvas.Left="{Binding StagedBrandLogo.X}" Canvas.Top="{Binding StagedBrandLogo.Y}"
|
||||
Width="{Binding StagedBrandLogo.Width}" Height="{Binding StagedBrandLogo.Height}"
|
||||
Source="{Binding StagedBrandLogo.DisplaySource}" Stretch="Fill"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="{Binding StagedBrandLogo, Converter={StaticResource NotNullToVis}}"/>
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
|
||||
|
||||
@@ -187,6 +187,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;
|
||||
}
|
||||
@@ -201,6 +204,15 @@ public sealed class SceneCompositor
|
||||
// overlapping blurs still resolve to the strongest single one.
|
||||
ApplyBlurLayers(scene, buffer, cropW, cropH, options, timings);
|
||||
|
||||
// ⛔ The brand logo cannot be blurred (creator ruling 2026-11-01): it is blitted after the
|
||||
// universal blur, so a privacy cover never swallows it. One per scene, so this is cheap.
|
||||
foreach (var brand in scene.Elements.OfType<Source>().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;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ public partial class MainViewModel
|
||||
Height = BrandLogoHeight,
|
||||
};
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public Source? StagedBrandLogo =>
|
||||
StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBrandLogo);
|
||||
|
||||
/// <summary>Exactly one brand logo per scene, placed directly above the background, carrying
|
||||
/// the current App Settings image.</summary>
|
||||
internal void EnsureBrandLogos()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Compositor;
|
||||
|
||||
namespace ytLive.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Hard left/right colour split — blurring is unmistakable on it.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user