diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 7b505c3..e0f09b7 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -82,6 +82,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
The designated alert zone: the six monetization events play their
/// unique celebration animations here (OBS alert-box equivalent, native).
- AlertBox
+ AlertBox,
+ /// A privacy blur: blurs the frame beneath it inside a rect or ellipse. Live scene
+ /// only, always below the brand logo, several allowed (strongest wins on overlap).
+ Blur
}
/// Distinguishes how a media source presents its content (TASK 21).
@@ -86,6 +89,7 @@ public class Source : SceneElement
/// Live captures, chat, web sources and media are dynamic; static art is fixed.
public override ElementKind Kind => Type is SourceType.DisplayCapture or SourceType.WindowCapture
or SourceType.ChatBox or SourceType.WebSource or SourceType.MediaSource or SourceType.AlertBox
+ or SourceType.Blur
? ElementKind.Dynamic : ElementKind.Static;
public override bool IsWebSource => Type == SourceType.WebSource;
@@ -158,6 +162,22 @@ public class Source : SceneElement
// Countdown config
private int _timerMinutes = 15;
+
+ // Privacy blur (Live scene only): 0–1, the same scale and default as the webcam's background
+ // blur so "half strength" means the same blur in both places.
+ private double _blurAmount = 0.5;
+ public double BlurAmount
+ {
+ get => _blurAmount;
+ set => Set(ref _blurAmount, Math.Clamp(value, 0, 1));
+ }
+
+ public bool IsBlur => Type == SourceType.Blur;
+
+ /// True for a round blur (circle) — the shape rides the existing clip surface, so the
+ /// box/circle choice is the same switch every other layer uses rather than a second concept.
+ public bool IsRoundBlur => IsBlur && ClipShape == ClipShape.Round;
+
public int TimerMinutes
{
get => _timerMinutes;
diff --git a/Services/BackgroundEffects/BackgroundEffectProcessor.cs b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
index 1a9355d..5c634e6 100644
--- a/Services/BackgroundEffects/BackgroundEffectProcessor.cs
+++ b/Services/BackgroundEffects/BackgroundEffectProcessor.cs
@@ -1,5 +1,6 @@
using ytLive.Helpers;
using ytLive.Models;
+using ytLive.Services.Compositor;
namespace ytLive.Services.BackgroundEffects;
@@ -28,16 +29,12 @@ public sealed class BackgroundEffectProcessor
{
public const double DefaultStrength = 0.5;
- /// The background is blurred at 1/ resolution and
- /// upsampled while blending — a strong blur at a fraction of the cost.
- internal const int BlurDownscale = 4;
-
- /// Box-blur radius (in downscaled pixels) at strength 0 and 1. The maximum was halved
- /// from 12 (2026-10-09): the creator found full range too heavy — the old max spread ≈48 full
- /// px. The established tools blur far less: StreamFX defaults its Box blur size to 5 and its
- /// author stops downscale-based blurs around 8 px.
- internal const int MinBlurRadius = 1;
- internal const int MaxBlurRadius = 6;
+ /// Shared with the privacy blur layer (Services/Compositor/RegionBlur): one radius
+ /// curve for both, so a strength means the same thing everywhere. The maximum was halved from
+ /// 12 on 2026-10-09 — full range was too heavy, and the established tools blur far less.
+ internal const int BlurDownscale = RegionBlur.Downscale;
+ internal const int MinBlurRadius = RegionBlur.MinRadius;
+ internal const int MaxBlurRadius = RegionBlur.MaxRadius;
private readonly IPersonSegmenter _segmenter;
private bool _loggedFailure;
@@ -218,11 +215,7 @@ public sealed class BackgroundEffectProcessor
/// Blur radius in downscaled pixels for a strength. Quadratic (s²), not linear, so the
/// default 0.5 stays gentle (2 small px ≈ ~8–10 full px of perceived spread, ~3× lighter than
/// the old linear 6) while only the top of the slider approaches the full range.
- internal static int BlurRadius(double strength)
- {
- var s = Math.Clamp(strength, 0, 1);
- return (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * s * s);
- }
+ internal static int BlurRadius(double strength) => RegionBlur.RadiusFor(strength);
/// Row-parallel pixel loops, capped so the effect worker can't starve the compositor
/// and encoder threads.
diff --git a/Services/Compositor/RegionBlur.cs b/Services/Compositor/RegionBlur.cs
new file mode 100644
index 0000000..e1f3e9b
--- /dev/null
+++ b/Services/Compositor/RegionBlur.cs
@@ -0,0 +1,206 @@
+namespace ytLive.Services.Compositor;
+
+///
+/// A region of the composed frame, blurred in place. The privacy layer (a Blur element on the
+/// Live scene) and the webcam's background blur share this arithmetic, so "half strength" means
+/// exactly the same blur in both places — the creator asked for consistency, and two copies of a
+/// radius curve is how two features quietly disagree.
+///
+/// The algorithm is the webcam's: box-average downscale by
+/// , three separable box passes at (≈ a Gaussian), then
+/// blend back inside the mask. Working at ¼ resolution is what keeps a strong blur affordable.
+///
+/// Region-scoped. Cost is proportional to the blur's own rectangle, not the frame, so
+/// a small privacy box costs almost nothing; the full-frame webcam case pays the full price.
+///
+public static class RegionBlur
+{
+ /// Blur work happens at 1/ resolution and is upsampled while
+ /// blending — a strong blur at a fraction of the cost.
+ public const int Downscale = 4;
+
+ /// Box-blur radius (in downscaled pixels) at strength 0 and 1. The maximum was halved
+ /// from 12 on 2026-10-09: the creator found full range too heavy, and the established tools
+ /// blur far less (StreamFX defaults its box blur to 5).
+ public const int MinRadius = 1;
+ public const int MaxRadius = 6;
+
+ /// Strength (0–1) → box radius. Squared so the low half of the slider stays gentle.
+ public static int RadiusFor(double strength)
+ {
+ var s = double.IsFinite(strength) ? Math.Clamp(strength, 0, 1) : 0;
+ return (int)Math.Round(MinRadius + (MaxRadius - MinRadius) * s * s);
+ }
+
+ ///
+ /// Blur a rectangle of (BGRA, bytes per row)
+ /// in place, inside a rectangular or elliptical mask.
+ ///
+ ///
+ /// Optional per-buffer-pixel map (1 byte each) of areas a STRONGER blur already covered. Pixels
+ /// marked there are skipped and never double-blurred, which is what makes overlapping blur
+ /// layers read as "the strongest one wins" instead of stacking into something stronger than any
+ /// single setting allows. The caller sorts blurs strongest-first and allocates this once.
+ ///
+ /// True when it actually blurred something.
+ public static bool ApplyInPlace(
+ byte[] buffer, int stride, int frameWidth, int frameHeight,
+ int x, int y, int width, int height,
+ double strength, bool round, byte[]? coverage = null)
+ {
+ if (buffer is null || width <= 0 || height <= 0) return false;
+
+ // Clamp to the frame; the elements are draggable past the edges by design.
+ var x0 = Math.Clamp(x, 0, frameWidth);
+ var y0 = Math.Clamp(y, 0, frameHeight);
+ var x1 = Math.Clamp(x + width, 0, frameWidth);
+ var y1 = Math.Clamp(y + height, 0, frameHeight);
+ var w = x1 - x0;
+ var h = y1 - y0;
+ if (w <= 0 || h <= 0) return false;
+
+ var f = Downscale;
+ var sw = (w + f - 1) / f;
+ var sh = (h + f - 1) / f;
+ if (sw <= 0 || sh <= 0) return false;
+
+ var radius = RadiusFor(strength);
+
+ // 1. Downscale by box-averaging f×f blocks (3 channels; alpha is preserved as-is).
+ var small = new float[sw * sh * 3];
+ for (var sy = 0; sy < sh; sy++)
+ {
+ var py0 = y0 + sy * f;
+ var py1 = Math.Min(y0 + h, py0 + f);
+ for (var sx = 0; sx < sw; sx++)
+ {
+ var px0 = x0 + sx * f;
+ var px1 = Math.Min(x0 + w, px0 + f);
+ float b = 0, g = 0, r = 0;
+ var n = 0;
+ for (var py = py0; py < py1; py++)
+ {
+ var p = py * stride + px0 * 4;
+ for (var px = px0; px < px1; px++, p += 4)
+ {
+ b += buffer[p]; g += buffer[p + 1]; r += buffer[p + 2];
+ n++;
+ }
+ }
+ if (n == 0) n = 1;
+ var o = (sy * sw + sx) * 3;
+ small[o] = b / n; small[o + 1] = g / n; small[o + 2] = r / n;
+ }
+ }
+
+ // 2. Three separable box passes ≈ a Gaussian (the webcam's exact shape).
+ var scratch = new float[small.Length];
+ for (var pass = 0; pass < 3; pass++)
+ {
+ BoxHorizontal(small, scratch, sw, sh, radius);
+ BoxVertical(scratch, small, sw, sh, radius);
+ }
+
+ // 3. Blend back inside the mask. Bilinear on the small grid so the blur edge is not blocky.
+ var cx = w / 2.0;
+ var cy = h / 2.0;
+ var rx = Math.Max(1.0, w / 2.0);
+ var ry = Math.Max(1.0, h / 2.0);
+
+ for (var row = 0; row < h; row++)
+ {
+ var py = y0 + row;
+ var syf = (row + 0.5) / f - 0.5;
+ var sy0 = Math.Clamp((int)Math.Floor(syf), 0, sh - 1);
+ var sy1 = Math.Clamp(sy0 + 1, 0, sh - 1);
+ var ty = Math.Clamp(syf - sy0, 0, 1);
+
+ // Ellipse test in region space, per row: half-width of the chord.
+ var ny = (row + 0.5 - cy) / ry;
+ var chord = round && Math.Abs(ny) < 1 ? rx * Math.Sqrt(1 - ny * ny) : rx;
+
+ for (var col = 0; col < w; col++)
+ {
+ var px = x0 + col;
+ var index = py * stride + px * 4;
+ if (coverage is not null && coverage[py * frameWidth + px] != 0) continue;
+
+ if (round)
+ {
+ var dx = col + 0.5 - cx;
+ if (Math.Abs(dx) > chord) continue;
+ }
+
+ var sxf = (col + 0.5) / f - 0.5;
+ var sx0 = Math.Clamp((int)Math.Floor(sxf), 0, sw - 1);
+ var sx1 = Math.Clamp(sx0 + 1, 0, sw - 1);
+ var tx = Math.Clamp(sxf - sx0, 0, 1);
+
+ var i00 = (sy0 * sw + sx0) * 3;
+ var i01 = (sy0 * sw + sx1) * 3;
+ var i10 = (sy1 * sw + sx0) * 3;
+ var i11 = (sy1 * sw + sx1) * 3;
+
+ for (var c = 0; c < 3; c++)
+ {
+ var top = small[i00 + c] + (small[i01 + c] - small[i00 + c]) * tx;
+ var bottom = small[i10 + c] + (small[i11 + c] - small[i10 + c]) * tx;
+ var value = top + (bottom - top) * ty;
+ buffer[index + c] = ClampByte((float)value);
+ }
+
+ if (coverage is not null) coverage[py * frameWidth + px] = 1;
+ }
+ }
+
+ return true;
+ }
+
+ private static void BoxHorizontal(float[] src, float[] dst, int w, int h, int radius)
+ {
+ var window = radius * 2 + 1;
+ for (var y = 0; y < h; y++)
+ {
+ var row = y * w * 3;
+ for (var c = 0; c < 3; c++)
+ {
+ float sum = 0;
+ for (var i = -radius; i <= radius; i++)
+ sum += src[row + Math.Clamp(i, 0, w - 1) * 3 + c];
+
+ for (var x = 0; x < w; x++)
+ {
+ dst[row + x * 3 + c] = sum / window;
+ var add = Math.Clamp(x + radius + 1, 0, w - 1);
+ var drop = Math.Clamp(x - radius, 0, w - 1);
+ sum += src[row + add * 3 + c] - src[row + drop * 3 + c];
+ }
+ }
+ }
+ }
+
+ private static void BoxVertical(float[] src, float[] dst, int w, int h, int radius)
+ {
+ var window = radius * 2 + 1;
+ for (var x = 0; x < w; x++)
+ {
+ for (var c = 0; c < 3; c++)
+ {
+ float sum = 0;
+ for (var i = -radius; i <= radius; i++)
+ sum += src[Math.Clamp(i, 0, h - 1) * w * 3 + x * 3 + c];
+
+ for (var y = 0; y < h; y++)
+ {
+ dst[y * w * 3 + x * 3 + c] = sum / window;
+ var add = Math.Clamp(y + radius + 1, 0, h - 1);
+ var drop = Math.Clamp(y - radius, 0, h - 1);
+ sum += src[add * w * 3 + x * 3 + c] - src[drop * w * 3 + x * 3 + c];
+ }
+ }
+ }
+ }
+
+ private static byte ClampByte(float value) =>
+ value <= 0 ? (byte)0 : value >= 255 ? (byte)255 : (byte)value;
+}
diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs
index 62bd178..6055515 100644
--- a/Services/Compositor/SceneCompositor.cs
+++ b/Services/Compositor/SceneCompositor.cs
@@ -174,10 +174,26 @@ public sealed class SceneCompositor
}
// Composite the above-split layers (dynamic + any static above them) per frame.
+ var blurApplied = false;
for (var i = Math.Max(0, split); i < scene.Elements.Count; i++)
{
var element = scene.Elements[i];
if (!element.IsVisible) continue;
+
+ // Privacy blurs are handled as ONE group the first time one is reached: overlapping
+ // boxes must resolve to the strongest single blur, never to a blurred blur. The group
+ // runs at the lowest blur's z-position, which is where the creator put it — directly
+ // above the background and below the brand logo.
+ if (element is Source { Type: SourceType.Blur })
+ {
+ if (!blurApplied)
+ {
+ blurApplied = true;
+ ApplyBlurLayers(scene, buffer, cropW, cropH, options, timings);
+ }
+ continue;
+ }
+
switch (element)
{
case Source { IsBackground: true }:
@@ -377,6 +393,44 @@ public sealed class SceneCompositor
return scaled;
}
+ ///
+ /// Apply every visible privacy blur to the composed-so-far buffer, strongest first, skipping
+ /// pixels a stronger blur already covered. That ordering is what makes an overlap read as "the
+ /// strongest one wins": blurring back-to-front would stack passes and produce something
+ /// stronger than any single slider allows, which the creator ruled out.
+ ///
+ private static void ApplyBlurLayers(
+ Scene scene, byte[] buffer, int cropW, int cropH, CompositorOptions options, CompositeTimings? timings)
+ {
+ var blurs = scene.Elements.OfType()
+ .Where(s => s.Type == SourceType.Blur && s.IsVisible && s.Width > 0 && s.Height > 0)
+ .OrderByDescending(s => s.BlurAmount)
+ .ToArray();
+ if (blurs.Length == 0) return;
+
+ var tBlur = System.Diagnostics.Stopwatch.GetTimestamp();
+ var coverage = System.Buffers.ArrayPool.Shared.Rent(cropW * cropH);
+ try
+ {
+ Array.Clear(coverage, 0, cropW * cropH);
+ var stride = cropW * 4;
+ foreach (var blur in blurs)
+ {
+ var x = (int)Math.Round(blur.X - options.SourceRectX);
+ var y = (int)Math.Round(blur.Y - options.SourceRectY);
+ RegionBlur.ApplyInPlace(
+ buffer, stride, cropW, cropH,
+ x, y, (int)Math.Round(blur.Width), (int)Math.Round(blur.Height),
+ blur.BlurAmount, blur.ClipShape == ClipShape.Round, coverage);
+ }
+ }
+ finally
+ {
+ System.Buffers.ArrayPool.Shared.Return(coverage);
+ }
+ timings?.Add("blur", System.Diagnostics.Stopwatch.GetTimestamp() - tBlur);
+ }
+
private static void BlitBakedElement(
byte[] buffer, int cropW, int cropH, CompositorOptions options, SceneElement element, VideoFrame frame,
CompositeTimings? timings, string stage)
diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs
index 5083d6c..8775c9b 100644
--- a/Services/LayoutStore.Load.cs
+++ b/Services/LayoutStore.Load.cs
@@ -164,6 +164,7 @@ public partial class LayoutStore : IDisposable
AlertVideoVolume = reader.IsDBNull(34) ? 1.0 : reader.GetDouble(34),
AlertDisplayMethod = reader.IsDBNull(36) ? AlertDisplayMethod.TickerScroll : (AlertDisplayMethod)reader.GetInt32(36),
IsBrandLogo = reader.GetInt32(reader.GetOrdinal("IsBrandLogo")) != 0,
+ BlurAmount = reader.IsDBNull(reader.GetOrdinal("BlurAmount")) ? 0.5 : reader.GetDouble(reader.GetOrdinal("BlurAmount")),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<(Source, int)>();
diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs
index 6ef9626..2db47e2 100644
--- a/Services/LayoutStore.Migrations.cs
+++ b/Services/LayoutStore.Migrations.cs
@@ -199,6 +199,13 @@ public partial class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
+ if (!columns.Contains("BlurAmount"))
+ {
+ using var blur = _connection.CreateCommand();
+ blur.CommandText = "ALTER TABLE Source ADD COLUMN BlurAmount REAL NOT NULL DEFAULT 0.5;";
+ blur.ExecuteNonQuery();
+ }
+
if (!columns.Contains("IsBrandLogo"))
{
using var cmd = _connection.CreateCommand();
diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs
index 784f12c..1482fd9 100644
--- a/Services/LayoutStore.Save.cs
+++ b/Services/LayoutStore.Save.cs
@@ -92,7 +92,7 @@ public partial class LayoutStore : IDisposable
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor,
WebUri, MediaPath, MediaIsLooping, MediaVolume, MediaPlaybackState,
AlertVideoAssetId, AlertVideoPath, AlertUseDefaultVideo, AlertShowTicker, AlertVideoVolume,
- AlertDisplayMethod, IsBrandLogo)
+ AlertDisplayMethod, IsBrandLogo, BlurAmount)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackground, $captureKey, $showDesktop, $sort,
@@ -100,7 +100,7 @@ public partial class LayoutStore : IDisposable
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor,
$webUri, $mediaPath, $mediaIsLooping, $mediaVolume, $mediaPlaybackState,
$alertVideoAssetId, $alertVideoPath, $alertUseDefaultVideo, $alertShowTicker, $alertVideoVolume,
- $alertDisplayMethod, $isBrandLogo)
+ $alertDisplayMethod, $isBrandLogo, $blurAmount)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -141,6 +141,7 @@ public partial class LayoutStore : IDisposable
var alertVideoVolumeP = cmd.Parameters.Add("$alertVideoVolume", SqliteType.Real);
var alertDisplayMethodP = cmd.Parameters.Add("$alertDisplayMethod", SqliteType.Integer);
var isBrandLogoP = cmd.Parameters.Add("$isBrandLogo", SqliteType.Integer);
+ cmd.Parameters.Add("$blurAmount", SqliteType.Real);
// Source and WebcamSceneConfig share ONE z-order space per scene:
// each element gets its index within scene.Elements regardless of
@@ -190,6 +191,7 @@ public partial class LayoutStore : IDisposable
alertVideoVolumeP.Value = source.AlertVideoVolume;
alertDisplayMethodP.Value = (int)source.AlertDisplayMethod;
isBrandLogoP.Value = source.IsBrandLogo ? 1 : 0;
+ cmd.Parameters["$blurAmount"].Value = source.BlurAmount;
cmd.ExecuteNonQuery();
}
}
diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs
index 42b2d4e..ae5303c 100644
--- a/ViewModels/MainViewModel.Chat.cs
+++ b/ViewModels/MainViewModel.Chat.cs
@@ -44,6 +44,11 @@ public partial class MainViewModel : ViewModelBase
=> StagedScene != null
&& !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.AlertBox));
+ /// 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.
+ public bool CanAddBlur
+ => StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
+
public bool ShowChatInactiveMessage => !IsLive;
private void OnChatMessageReceived(ChatMessage message)
diff --git a/ViewModels/MainViewModel.Sources.cs b/ViewModels/MainViewModel.Sources.cs
index 06e0d5b..c05ba0e 100644
--- a/ViewModels/MainViewModel.Sources.cs
+++ b/ViewModels/MainViewModel.Sources.cs
@@ -23,8 +23,15 @@ public partial class MainViewModel
var sourceType = parameter is SourceType typed
? typed
: Enum.TryParse(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.
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().FirstOrDefault(s => s.IsBrandLogo);
+ if (brand != null)
+ {
+ foreach (var blur in scene.Elements.OfType().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();
}
+ /// 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).
+ private static int BlurInsertIndex(Scene scene)
+ {
+ var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background != null) return scene.Elements.IndexOf(background) + 1;
+
+ var brand = scene.Elements.OfType().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.
diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs
index ed6cb3a..8e5d1c0 100644
--- a/ViewModels/MainViewModel.Webcam.cs
+++ b/ViewModels/MainViewModel.Webcam.cs
@@ -74,6 +74,9 @@ public partial class MainViewModel
/// Shows the mirror / clip-shape row in the source chip when a webcam is selected.
public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
+ /// The selected layer is a privacy blur — its properties panel shows shape + amount.
+ public bool IsBlurSelected => SelectedElement is Source { IsBlur: true };
+
///
/// 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
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index c43bc12..641e89a 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -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)
diff --git a/docs/ai/frame-pipeline.md b/docs/ai/frame-pipeline.md
index 667573d..70c8d0f 100644
--- a/docs/ai/frame-pipeline.md
+++ b/docs/ai/frame-pipeline.md
@@ -787,6 +787,18 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
blits it **last — above the branding flash** at `socialBarTop` (0 = top, `SourceRectHeight − barHeight` =
bottom) in master space. `MainViewModel` owns the frame (`_socialBarFrame`, rebuilt by `RenderSocialBarFrame`
on load/save/`NotifySocialsChanged`).
+- **Privacy blur layer (2026-11-01):** `SourceType.Blur` is a Live-scene element that blurs the
+ composed-so-far buffer inside a rect or ellipse — it hides part of the creator's desktop without
+ hiding the brand. `SceneCompositor` treats all visible blurs as ONE group the first time one is
+ reached in the z-order (`ApplyBlurLayers`), sorts them **strongest first** and passes a shared
+ coverage map to `RegionBlur.ApplyInPlace`, so an overlap resolves to the strongest single blur
+ rather than a blurred blur. `RegionBlur` shares the webcam's radius curve and constants
+ (`Downscale 4`, `MinRadius 1`, `MaxRadius 6`, `RadiusFor(strength)`), so a strength means the
+ same thing in both features; the webcam's `BackgroundEffectProcessor` delegates to it.
+ ⛔ Z-order is policy: `AddSource` inserts the blur directly above the background (below the brand
+ logo), and `OnSceneElementsReordered` moves any blur that ends up above the brand back down.
+ Blur is `ElementKind.Dynamic`, so it is never baked into the static base and every render path
+ (pump, preview, thumbnails, scene dump) gets it from the one compositor.
- Known consideration: the pump reads the active scene on a background thread while the UI can still edit
it; a concurrent-mutation exception is contained (logged + `Failed` + the pump stops) rather than
crashing. The background thread + video pipeline is the new reality since this step.
diff --git a/ytLive.Tests/RegionBlurTests.cs b/ytLive.Tests/RegionBlurTests.cs
new file mode 100644
index 0000000..9d8a9eb
--- /dev/null
+++ b/ytLive.Tests/RegionBlurTests.cs
@@ -0,0 +1,116 @@
+using System;
+using Xunit;
+using ytLive.Services.Compositor;
+
+namespace ytLive.Tests;
+
+///
+/// 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.
+///
+/// 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.
+///
+public class RegionBlurTests
+{
+ private const int W = 320;
+ private const int H = 180;
+ private const int Stride = W * 4;
+
+ /// A frame of vertical hard edges: blurring is then obvious (edges go grey).
+ 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));
+ }
+}