feat(blur): the editor shows the blur — whatever is beneath it is blurred, in the preview

The preview canvas is WPF visuals; a blur layer contributes no visual of its own, so until now
the creator saw nothing where the blur was and could not tell what it was hiding.

Each visible blur now draws a rectangle (or ellipse) filled with a VisualBrush of the whole
BackdropLayer — the desktop capture plus the background art — sampled at the blur's own rect and
carrying a BlurEffect at the same strength the compositor uses. The host canvas sits after the
backdrop and before the element layers, which is the model's own z-rule, so the preview shows
exactly what the output will: desktop blurred, brand and everything above it untouched.

Rebuilt on scene change, on add/remove, and on any blur property change (position, size, shape,
strength) — the brush samples the live backdrop, so the picture keeps moving underneath.
This commit is contained in:
2026-11-01 13:12:27 -08:00
parent 0ed341fd18
commit 1416548f24
2 changed files with 101 additions and 0 deletions
+9
View File
@@ -71,6 +71,15 @@
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">
+92
View File
@@ -47,6 +47,7 @@ public partial class PreviewPane : UserControl
newVm.PreviewTransition += PlayPreviewTransition;
UpdateZoomBox();
UpdateSelectedLayerZOrder();
HookBlurPreview();
}
}
@@ -737,4 +738,95 @@ public partial class PreviewPane : UserControl
if (DataContext is MainViewModel vm) vm.ToggleSettingsDrawer();
e.Handled = true;
}
// ─── 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.
private MainViewModel? _blurVm;
private Scene? _blurScene;
private void HookBlurPreview()
{
if (_blurVm != null) _blurVm.PropertyChanged -= BlurViewModelChanged;
_blurVm = DataContext as MainViewModel;
if (_blurVm != null) _blurVm.PropertyChanged += BlurViewModelChanged;
HookBlurScene();
}
private void BlurViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MainViewModel.StagedScene)) HookBlurScene();
else if (e.PropertyName is nameof(MainViewModel.ActiveBackgroundImage)
or nameof(MainViewModel.BackgroundImage)) RebuildBlurPreview();
}
private void HookBlurScene()
{
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged -= BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged -= BlurElementChanged;
}
_blurScene = _blurVm?.StagedScene;
if (_blurScene != null)
{
_blurScene.Elements.CollectionChanged += BlurElementsChanged;
foreach (var element in _blurScene.Elements) element.PropertyChanged += BlurElementChanged;
}
RebuildBlurPreview();
}
private void BlurElementsChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
foreach (var element in e.OldItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged -= BlurElementChanged;
foreach (var element in e.NewItems?.OfType<SceneElement>() ?? System.Linq.Enumerable.Empty<SceneElement>())
element.PropertyChanged += BlurElementChanged;
RebuildBlurPreview();
}
private void BlurElementChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (sender is Source { IsBlur: true }) RebuildBlurPreview();
}
/// <summary>Rebuild one overlay shape per visible blur. Cheap by design: a handful of shapes,
/// and the VisualBrush samples the live backdrop so the preview keeps moving underneath.</summary>
private void RebuildBlurPreview()
{
if (BlurPreviewHost == null) return;
BlurPreviewHost.Children.Clear();
if (_blurScene == null) return;
foreach (var blur in _blurScene.Elements.OfType<Source>()
.Where(s => s.IsBlur && s.IsVisible && s.Width > 0 && s.Height > 0))
{
var shape = new Rectangle
{
Width = blur.Width,
Height = blur.Height,
Fill = new VisualBrush(BackdropLayer)
{
Viewbox = new Rect(blur.X, blur.Y, blur.Width, blur.Height),
ViewboxUnits = BrushMappingMode.Absolute,
Stretch = Stretch.Fill,
},
// 4..24 px at master scale, the same shape of curve the compositor's radius uses.
Effect = new BlurEffect { Radius = 4 + 20 * Math.Clamp(blur.BlurAmount, 0, 1), KernelType = KernelType.Gaussian },
};
if (blur.ClipShape == ClipShape.Round)
shape.Clip = new EllipseGeometry(new Rect(0, 0, blur.Width, blur.Height));
Canvas.SetLeft(shape, blur.X);
Canvas.SetTop(shape, blur.Y);
BlurPreviewHost.Children.Add(shape);
}
}
}