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.
207 lines
8.3 KiB
C#
207 lines
8.3 KiB
C#
namespace ytLive.Services.Compositor;
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
///
|
||
/// <para><b>The algorithm is the webcam's:</b> box-average downscale by
|
||
/// <see cref="Downscale"/>, three separable box passes at <see cref="Radius"/> (≈ a Gaussian), then
|
||
/// blend back inside the mask. Working at ¼ resolution is what keeps a strong blur affordable.</para>
|
||
///
|
||
/// <para><b>Region-scoped.</b> 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.</para>
|
||
/// </summary>
|
||
public static class RegionBlur
|
||
{
|
||
/// <summary>Blur work happens at 1/<see cref="Downscale"/> resolution and is upsampled while
|
||
/// blending — a strong blur at a fraction of the cost.</summary>
|
||
public const int Downscale = 4;
|
||
|
||
/// <summary>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).</summary>
|
||
public const int MinRadius = 1;
|
||
public const int MaxRadius = 6;
|
||
|
||
/// <summary>Strength (0–1) → box radius. Squared so the low half of the slider stays gentle.</summary>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Blur a rectangle of <paramref name="buffer"/> (BGRA, <paramref name="stride"/> bytes per row)
|
||
/// in place, inside a rectangular or elliptical mask.
|
||
/// </summary>
|
||
/// <param name="coverage">
|
||
/// 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.
|
||
/// </param>
|
||
/// <returns>True when it actually blurred something.</returns>
|
||
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;
|
||
}
|