Creator request 2026-11-01: a layer that hides part of the desktop from viewers. Box or circle, drag/resize like any layer, one strength slider, several allowed. **Z-order is policy, not preference.** `AddSource` inserts a blur directly above the background and therefore below the brand logo, and `OnSceneElementsReordered` pushes any blur that ends up above the brand back down. Nothing else in the app can move it into the brand's way. **Overlap = the strongest single blur, never a stack.** `SceneCompositor.ApplyBlurLayers` collects every visible blur the first time one is reached in the z-order, sorts them strongest-first, and shares one coverage map: a pixel already covered by a stronger blur is skipped. Blurring back-to-front would have produced something stronger than any slider setting allows, which is exactly what was ruled out. **One blur curve, shared with the webcam.** `RegionBlur` carries the constants the webcam blur already used — downscale 4, radius 1–6 downscaled px, `RadiusFor(strength)` — and `BackgroundEffectProcessor` now delegates to it instead of holding its own copy. The layer's slider is the same 0–1 scale with the same 0.5 default, so "half strength" means the same blur in both features. Cost is proportional to the blur's own rect, not the frame. **Live scene only**, enforced in `AddSource` as well as the (+) menu (`CanAddBlur`), so a stale menu item or a scripted call cannot plant one on BRB. `ElementKind.Dynamic`, so it is never baked into the static base and every render path gets it — pump, preview, thumbnails, scene dump. Persistence: `Source.BlurAmount` column + migration; shape rides the existing `ClipShape`. Properties panel: Box | Circle plus the strength slider, gated on `IsBlurSelected`. 4 tests: outside the mask is byte-identical, inside loses its hard edges, a circle leaves its box corners alone, and two identical stacked blurs equal one (the coverage map). Release builds at 0 warnings.
117 lines
4.3 KiB
C#
117 lines
4.3 KiB
C#
using System;
|
|
using Xunit;
|
|
using ytLive.Services.Compositor;
|
|
|
|
namespace ytLive.Tests;
|
|
|
|
/// <summary>
|
|
/// The privacy blur (creator ruling 2026-11-01): a Live-scene layer that blurs the frame beneath it
|
|
/// inside a box or circle, so a creator can hide part of their desktop.
|
|
///
|
|
/// <para>Two rules are load-bearing and both are here: pixels OUTSIDE the mask must be untouched
|
|
/// (otherwise the layer is a full-frame filter), and overlapping layers must resolve to the
|
|
/// strongest single blur, never to a blurred blur — the creator was explicit that a stack cannot
|
|
/// be stronger than any one slider setting.</para>
|
|
/// </summary>
|
|
public class RegionBlurTests
|
|
{
|
|
private const int W = 320;
|
|
private const int H = 180;
|
|
private const int Stride = W * 4;
|
|
|
|
/// <summary>A frame of vertical hard edges: blurring is then obvious (edges go grey).</summary>
|
|
private static byte[] StripedFrame()
|
|
{
|
|
var pixels = new byte[Stride * H];
|
|
for (var y = 0; y < H; y++)
|
|
for (var x = 0; x < W; x++)
|
|
{
|
|
var p = y * Stride + x * 4;
|
|
var on = (x / 8) % 2 == 0;
|
|
pixels[p] = pixels[p + 1] = pixels[p + 2] = (byte)(on ? 255 : 0);
|
|
pixels[p + 3] = 255;
|
|
}
|
|
return pixels;
|
|
}
|
|
|
|
private static int CenterVariance(byte[] pixels, int x0, int y0, int x1, int y1)
|
|
{
|
|
// Crude "is this still hard-edged" measure: count horizontal transitions in the middle row.
|
|
var y = (y0 + y1) / 2;
|
|
var transitions = 0;
|
|
for (var x = x0 + 1; x < x1; x++)
|
|
{
|
|
var a = pixels[y * Stride + (x - 1) * 4];
|
|
var b = pixels[y * Stride + x * 4];
|
|
if (Math.Abs(a - b) > 32) transitions++;
|
|
}
|
|
return transitions;
|
|
}
|
|
|
|
[Fact]
|
|
public void Pixels_outside_the_mask_are_untouched_and_inside_are_blurred()
|
|
{
|
|
var pixels = StripedFrame();
|
|
var before = (byte[])pixels.Clone();
|
|
|
|
var applied = RegionBlur.ApplyInPlace(
|
|
pixels, Stride, W, H, 100, 60, 120, 60, strength: 0.8, round: false);
|
|
|
|
Assert.True(applied);
|
|
|
|
// Outside: untouched, byte for byte.
|
|
for (var y = 0; y < H; y++)
|
|
for (var x = 0; x < W; x++)
|
|
{
|
|
var inside = x >= 100 && x < 220 && y >= 60 && y < 120;
|
|
if (inside) continue;
|
|
var p = y * Stride + x * 4;
|
|
Assert.Equal(before[p], pixels[p]);
|
|
}
|
|
|
|
// Inside: the hard stripes are gone.
|
|
Assert.True(CenterVariance(pixels, 110, 70, 210, 110) < CenterVariance(before, 110, 70, 210, 110));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_round_blur_leaves_the_corners_of_its_box_alone()
|
|
{
|
|
var pixels = StripedFrame();
|
|
var before = (byte[])pixels.Clone();
|
|
|
|
RegionBlur.ApplyInPlace(pixels, Stride, W, H, 100, 60, 120, 60, strength: 0.8, round: true);
|
|
|
|
// The box's corner is outside the inscribed ellipse.
|
|
var corner = 60 * Stride + 100 * 4;
|
|
Assert.Equal(before[corner], pixels[corner]);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_overlap_uses_the_strongest_blur_once_and_never_stacks()
|
|
{
|
|
// Same region blurred twice: without the coverage map the second pass blurs an already
|
|
// blurred region, which is stronger than either slider setting. With it, the second pass
|
|
// skips the covered pixels and the result equals a single strong pass.
|
|
var once = StripedFrame();
|
|
RegionBlur.ApplyInPlace(once, Stride, W, H, 100, 60, 120, 60, 0.8, false);
|
|
var onceVariance = CenterVariance(once, 110, 70, 210, 110);
|
|
|
|
var stacked = StripedFrame();
|
|
var coverage = new byte[W * H];
|
|
RegionBlur.ApplyInPlace(stacked, Stride, W, H, 100, 60, 120, 60, 0.8, false, coverage);
|
|
RegionBlur.ApplyInPlace(stacked, Stride, W, H, 100, 60, 120, 60, 0.8, false, coverage);
|
|
|
|
Assert.Equal(onceVariance, CenterVariance(stacked, 110, 70, 210, 110));
|
|
}
|
|
|
|
[Fact]
|
|
public void Strength_zero_still_blurs_a_little_and_the_curve_is_the_webcams()
|
|
{
|
|
// The shared radius curve: 1 downscaled px at 0, 6 at 1 — the webcam's own numbers.
|
|
Assert.Equal(RegionBlur.MinRadius, RegionBlur.RadiusFor(0));
|
|
Assert.Equal(RegionBlur.MaxRadius, RegionBlur.RadiusFor(1));
|
|
Assert.Equal(RegionBlur.MaxRadius, RegionBlur.RadiusFor(4)); // clamped
|
|
Assert.Equal(RegionBlur.MinRadius, RegionBlur.RadiusFor(-1));
|
|
}
|
|
}
|