feat(blur): universal — the blur covers every layer, webcam included

The creator found the use case that reframes the feature: a moment of privacy while the webcam is
on. A blur that can only cover the desktop cannot do that.

- SceneCompositor applies the blur group AFTER every element is composited (still before the
  flash / social bar / ticker / zoom overlays), so it covers webcam, chat, alerts — anything. Its
  position in the layer list no longer affects the output, so it is added at the top and the
  reorder clamp is gone.
- Supersedes the earlier "blur sits below the brand" rule: a universal blur does obscure the logo
  when it covers it. Called out in frame-pipeline.md so it is not re-litigated.
- Preview mirrors it exactly: the blur host moved OUT of CanvasGrid to a sibling above it, so the
  VisualBrush samples every layer (and cannot sample itself). It used to sample only the backdrop,
  which is why a circle over the webcam would have shown sharp pixels in the editor and blurred
  ones in the stream.
This commit is contained in:
2026-11-01 13:29:15 -08:00
parent bb80ec8370
commit bb51164f2f
5 changed files with 29 additions and 73 deletions
+6 -9
View File
@@ -20,6 +20,11 @@
PreviewMouseRightButtonUp="Preview_MouseRightButtonUp">
<Viewbox Stretch="Uniform">
<Canvas x:Name="PreviewCanvas" Width="1920" Height="1080" ClipToBounds="True">
<!-- Privacy blur preview (2026-11-01): the blur is UNIVERSAL — it covers
every layer beneath it, so this host sits ABOVE the whole CanvasGrid
rather than inside it. Each child samples CanvasGrid (all layers, no
recursion because this canvas is a sibling) at the blur's rect and
carries a BlurEffect at the output's strength. -->
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.RenderTransform>
<TransformGroup>
@@ -71,15 +76,6 @@
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
</Grid>
<!-- Privacy blur preview (2026-11-01): whatever is BENEATH a blur layer
is blurred, right here in the editor — not a hatched placeholder. Each
child is a rectangle/ellipse filled with a VisualBrush of the whole
BackdropLayer (the desktop + background art), clipped to the blur's
shape and blurred at the same strength the output uses. Hosted AFTER
the backdrop and BEFORE the element layers, which is the model's own
z-rule: the blur sits above the desktop and below the brand. -->
<Canvas x:Name="BlurPreviewHost" Width="1920" Height="1080" IsHitTestVisible="False"/>
<Image x:Name="TransitionOverlay" Width="1920" Height="1080" Stretch="Fill"
IsHitTestVisible="False" Visibility="Collapsed"/>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
@@ -474,6 +470,7 @@
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute" Stretch="Fill"/>
</Grid.Background>
</Grid>
<Canvas x:Name="BlurPreviewHost" Width="1920" Height="1080" IsHitTestVisible="False"/>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
+6 -6
View File
@@ -740,11 +740,11 @@ public partial class PreviewPane : UserControl
}
// ─── Privacy blur preview (2026-11-01) ───
// The editor has to show the blur it is about to bake into the output: "whatever is beneath the
// blur layer is blurred". Each blur becomes a rectangle/ellipse filled with a VisualBrush of the
// whole BackdropLayer (desktop + background art) sampled at that rect, with a BlurEffect at the
// same strength the compositor uses. The host canvas sits between the backdrop and the element
// layers, which is the model's own z-rule.
// The editor has to show the blur it is about to bake into the output, and the blur is
// UNIVERSAL: it covers every layer, webcam and chat included. Each blur becomes a
// rectangle/ellipse filled with a VisualBrush of the whole CanvasGrid sampled at that rect,
// with a BlurEffect at the same strength the compositor uses. The host canvas is a SIBLING
// above CanvasGrid, which is what stops the brush from sampling itself.
private MainViewModel? _blurVm;
private Scene? _blurScene;
@@ -811,7 +811,7 @@ public partial class PreviewPane : UserControl
{
Width = blur.Width,
Height = blur.Height,
Fill = new VisualBrush(BackdropLayer)
Fill = new VisualBrush(CanvasGrid)
{
Viewbox = new Rect(blur.X, blur.Y, blur.Width, blur.Height),
ViewboxUnits = BrushMappingMode.Absolute,
+8 -14
View File
@@ -174,25 +174,13 @@ 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;
}
// Blurs are applied once, after every layer (see below) — they are universal.
if (element is Source { Type: SourceType.Blur }) continue;
switch (element)
{
@@ -207,6 +195,12 @@ public sealed class SceneCompositor
BlitBakedElement(buffer, cropW, cropH, options, element, frame, timings, $"#{i} {element.Name}");
}
// ⛔ Privacy blur is UNIVERSAL (creator ruling 2026-11-01): it is applied after every
// layer has been composited, so it can cover the webcam, chat, alerts, anything — the
// creator's "moment of privacy" case. It is the only layer that ignores its z-position;
// overlapping blurs still resolve to the strongest single one.
ApplyBlurLayers(scene, buffer, cropW, cropH, options, timings);
if (flashFrame != null)
{
var tFlash = System.Diagnostics.Stopwatch.GetTimestamp();
+4 -40
View File
@@ -81,18 +81,10 @@ public partial class MainViewModel
: sourceType == SourceType.Blur ? 160 : 0,
};
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);
}
// The blur is universal (creator ruling 2026-11-01): the compositor applies it over every
// layer, so its position in the list does not affect the output. It is added at the top so
// the layer list reads "this covers everything", and no reorder clamp is needed.
_sceneGraph.AddElement(scene, source);
if (sourceType == SourceType.WebSource)
{
@@ -133,38 +125,10 @@ 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.
+5 -4
View File
@@ -788,15 +788,16 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
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
composed frame inside a rect or ellipse. **It is universal**: `SceneCompositor` applies the blur
group AFTER every layer has been composited (before the flash/social/ticker overlays), so it can
cover the webcam, chat, alerts — anything — which is the creator's "moment of privacy" case. Its
position in the layer list is cosmetic for that reason (it is added at the top), and the earlier
"never above the brand" rule is superseded for blurs: a blur over the logo does obscure it. `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