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)); + } +}