feat(blur): a privacy blur layer for the Live scene — boxes and circles over the desktop

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.
This commit is contained in:
2026-11-01 09:55:04 -08:00
parent 2e12c5651e
commit 71d0e87cac
14 changed files with 534 additions and 23 deletions
+5
View File
@@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType<Source>().Any(e => e.Type == SourceType.AlertBox));
/// <summary>Privacy blur is a Live-scene tool only (creator ruling 2026-11-01) — every other
/// scene's art is authored, and there is no desktop there to hide.</summary>
public bool CanAddBlur
=> StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
public bool ShowChatInactiveMessage => !IsLive;
private void OnChatMessageReceived(ChatMessage message)
+64 -5
View File
@@ -23,8 +23,15 @@ public partial class MainViewModel
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse<SourceType>(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
// Privacy blur is a Live-scene tool: it exists to hide the creator's desktop, and every
// other scene's art is authored. Rejecting here (not only in the (+) menu) keeps a stale
// menu item or a scripted call from planting one on BRB.
if (sourceType == SourceType.Blur && !SceneCatalog.Is(scene.Name, SceneCatalog.Live))
return;
var baseName = sourceType switch
{
SourceType.Blur => "Blur",
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
@@ -53,15 +60,39 @@ public partial class MainViewModel
? $"{baseName}-{webCount}"
: NextSourceName(scene, baseName),
Type = sourceType,
X = sourceType == SourceType.ChatBox ? 1400 : sourceType == SourceType.AlertBox ? 620 : 0,
X = sourceType == SourceType.ChatBox ? 1400
: sourceType == SourceType.AlertBox ? 620
: sourceType == SourceType.Blur ? 700 : 0,
// The scoreboard is a full-width bar: its Y comes from the edge it owns (resolved
// against the social bar at render time), never from a drag.
Y = sourceType == SourceType.Scoreboard ? 1024
: sourceType == SourceType.ChatBox ? 20 : sourceType == SourceType.AlertBox ? 430 : 0,
Width = sourceType == SourceType.WebSource ? 640 : sourceType == SourceType.ChatBox ? 500 : sourceType == SourceType.AlertBox ? 680 : sourceType == SourceType.Scoreboard ? 1920 : 0,
Height = sourceType == SourceType.WebSource ? 480 : sourceType == SourceType.ChatBox ? 648 : sourceType == SourceType.AlertBox ? 200 : sourceType == SourceType.Scoreboard ? 56 : 0,
: sourceType == SourceType.ChatBox ? 20
: sourceType == SourceType.AlertBox ? 430
: sourceType == SourceType.Blur ? 600 : 0,
Width = sourceType == SourceType.WebSource ? 640
: sourceType == SourceType.ChatBox ? 500
: sourceType == SourceType.AlertBox ? 680
: sourceType == SourceType.Scoreboard ? 1920
: sourceType == SourceType.Blur ? 240 : 0,
Height = sourceType == SourceType.WebSource ? 480
: sourceType == SourceType.ChatBox ? 648
: sourceType == SourceType.AlertBox ? 200
: sourceType == SourceType.Scoreboard ? 56
: sourceType == SourceType.Blur ? 160 : 0,
};
_sceneGraph.AddElement(scene, source);
if (sourceType == SourceType.Blur)
{
// ⛔ Z-order is policy, not preference: the blur sits directly above the background and
// BELOW the brand logo, which can never be obscured (creator ruling 2026-11-01).
_sceneGraph.AddElement(scene, source);
var blurIndex = scene.Elements.IndexOf(source);
if (blurIndex >= 0) scene.Elements.Move(blurIndex, BlurInsertIndex(scene));
}
else
{
_sceneGraph.AddElement(scene, source);
}
if (sourceType == SourceType.WebSource)
{
@@ -102,10 +133,38 @@ public partial class MainViewModel
/// X/Y/W/H) in the same Source rows.</summary>
public void OnSceneElementsReordered()
{
// A blur may never end up above the brand logo (creator ruling 2026-11-01). Only offending
// blurs move — a legitimate reorder inside the layer list is left exactly as dropped.
if (StagedScene is { } scene)
{
var brand = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBrandLogo);
if (brand != null)
{
foreach (var blur in scene.Elements.OfType<Source>().Where(s => s.IsBlur).ToList())
{
var brandIndex = scene.Elements.IndexOf(brand);
var blurIndex = scene.Elements.IndexOf(blur);
if (blurIndex > brandIndex) scene.Elements.Move(blurIndex, brandIndex);
}
}
}
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
SaveLayoutNow();
}
/// <summary>The slot a privacy blur belongs in: immediately after the background, which puts it
/// below the brand logo (the logo is inserted directly above the background too, so the blur
/// must land before it).</summary>
private static int BlurInsertIndex(Scene scene)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return scene.Elements.IndexOf(background) + 1;
var brand = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBrandLogo);
return brand != null ? scene.Elements.IndexOf(brand) : 0;
}
// Duplicate resource names get an incrementing suffix with no space: Image,
// Image2, Image3… The next free number is derived from the names actually in
// the scene, so deleting a middle resource never collides with a survivor.
+3
View File
@@ -74,6 +74,9 @@ public partial class MainViewModel
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
/// <summary>The selected layer is a privacy blur — its properties panel shows shape + amount.</summary>
public bool IsBlurSelected => SelectedElement is Source { IsBlur: true };
/// <summary>
/// The reason the webcam feed is down (device in use, offline, locked, no frames),
/// or null when it's alive. Surfaced as a red chip so a dead camera is never a
+1
View File
@@ -66,6 +66,7 @@ public partial class MainViewModel : ViewModelBase
if (SetProperty(ref _selectedElement, value))
{
OnPropertyChanged(nameof(IsWebcamSelected));
OnPropertyChanged(nameof(IsBlurSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty));
OnPropertyChanged(nameof(CanToggleClipShape));
if (_selectedElement != null)