Zoom: fix preview zoom box + shape-aspect anchoring (Unit: zoom box visuals)

Right-click in the live preview now procs a box that is actually visible and
populated with the zoomed region, and each Zoom View Shape sculpts the box per
spec. OBS-style derivative: magnifier box shows the composited region and dims
the surroundings (same pattern as OBS "zoom" / preview region highlights).

Root causes of the invisible box (two WPF traps, recorded in MyMistakes.md):
- Empty <VisualBrush Stretch="None"/> used as an OpacityMask paints nothing,
  so the mask alpha is 0 and the WHOLE box (border included) went transparent.
- ZoomBox lived inside CanvasGrid while its VisualBrush sampled CanvasGrid — a
  circular reference WPF renders as nothing. Hoisted ZoomBox to a sibling of
  CanvasGrid inside a new PreviewCanvas wrapper (same 1920x1080 space).

Shape semantics (MainViewModel.ActivateZoom):
- Circle: square, click point = center.
- VerticalRectangle (phone): tall 9:16, click point = top-left corner.
- HorizontalRectangle (tablet): wide 16:9, click point = top-left corner.
- Boxes stay fully inside 1920x1080 via Math.Clamp.

UI: ZoomRectOutline/ZoomCircleOutline shapes replace the square Border;
circle clips the box to an EllipseGeometry; brush Visual released when inactive.
Zoom View Shape dropdown now uses the standard YtComboBox style.

Test (Good Dog rule, one integration test): ZoomBoxInteractionTests verifies
aspect/anchoring/clamp per shape and the matching outline+clip in the real
MainWindow. Full suite 404 passed, 0 warnings.

Memory: HANDOFF.md zoom state updated; WPF VisualBrush traps appended to
MyMistakes.md.
This commit is contained in:
2026-10-07 20:46:45 -07:00
parent 6b6284d05c
commit 966e718f1e
13 changed files with 530 additions and 567 deletions
+9 -2
View File
@@ -1,7 +1,8 @@
<UserControl x:Class="ytLive.Controls.OverlayHost"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Helpers="clr-namespace:ytLive.Helpers">
xmlns:Helpers="clr-namespace:ytLive.Helpers"
xmlns:Models="clr-namespace:ytLive.Models">
<Grid Grid.RowSpan="3"
Visibility="{Binding IsAnyOverlayOpen, Converter={StaticResource BoolToVis}}">
<Button Background="#99000000" BorderThickness="0" Cursor="Hand"
@@ -42,12 +43,18 @@
<TextBox Style="{StaticResource YtTextBox}"
Text="{Binding RecordFolderDisplay, Mode=OneWay}"
IsReadOnly="True" Margin="0,0,0,12" Foreground="#a0a0b0"/>
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Margin="0,0,0,16">
<Button Content="Browse…" Command="{Binding ChooseRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Downloads" Command="{Binding ResetRecordFolderCommand}"
Style="{StaticResource YtButtonSecondary}"/>
</StackPanel>
<TextBlock Text="Zoom View Shape" Style="{StaticResource YtLabel}" Margin="0,0,0,4"/>
<ComboBox Style="{StaticResource YtComboBox}" SelectedValue="{Binding ZoomShape}" SelectedValuePath="Tag">
<ComboBoxItem Content="Circle" Tag="{x:Static Models:ZoomShape.Circle}"/>
<ComboBoxItem Content="Phone (Vertical Rectangle)" Tag="{x:Static Models:ZoomShape.VerticalRectangle}"/>
<ComboBoxItem Content="Tablet (Horizontal Rectangle)" Tag="{x:Static Models:ZoomShape.HorizontalRectangle}"/>
</ComboBox>
</StackPanel>
<!-- Report Bug -->
+25 -5
View File
@@ -14,9 +14,18 @@
<Grid x:Name="PreviewGrid" Grid.Row="0"
PreviewMouseLeftButtonDown="Preview_MouseLeftButtonDown"
PreviewMouseMove="Preview_MouseMove"
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp">
PreviewMouseLeftButtonUp="Preview_MouseLeftButtonUp"
PreviewMouseRightButtonDown="Preview_MouseRightButtonDown"
PreviewMouseRightButtonUp="Preview_MouseRightButtonUp">
<Viewbox Stretch="Uniform">
<Canvas x:Name="PreviewCanvas" Width="1920" Height="1080" ClipToBounds="True">
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="ZoomScale"/>
<TranslateTransform x:Name="ZoomTranslate"/>
</TransformGroup>
</Grid.RenderTransform>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Desktop" IsCheckable="True"
@@ -388,11 +397,22 @@
Margin="8,0,0,0"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Canvas>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Canvas>
</Grid>
<Grid x:Name="ZoomBox" IsHitTestVisible="False"
Canvas.Left="{Binding ZoomState.X}" Canvas.Top="{Binding ZoomState.Y}"
Width="{Binding ZoomState.Width}" Height="{Binding ZoomState.Height}"
Visibility="{Binding IsZoomActive, Converter={StaticResource BoolToVis}}">
<Rectangle x:Name="ZoomRectOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Grid.Background>
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute"/>
</Grid.Background>
</Grid>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
Background="#CCe94560" CornerRadius="4" Padding="8,3" IsHitTestVisible="False"
+119 -6
View File
@@ -17,6 +17,7 @@ public partial class PreviewPane : UserControl
{
private bool _isDraggingOverlay;
private bool _isResizing;
private bool _isZoomDragging;
private Point _grabOffset;
private double _resizeAspect;
@@ -29,8 +30,30 @@ public partial class PreviewPane : UserControl
public PreviewPane()
{
InitializeComponent();
DataContextChanged += PreviewPane_DataContextChanged;
}
private void PreviewPane_DataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is MainViewModel oldVm)
oldVm.PropertyChanged -= ViewModel_PropertyChanged;
if (e.NewValue is MainViewModel newVm)
{
newVm.PropertyChanged += ViewModel_PropertyChanged;
UpdateZoomBox();
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MainViewModel.IsZoomActive) || e.PropertyName == nameof(MainViewModel.ZoomState) || e.PropertyName == nameof(MainViewModel.ZoomShape))
{
UpdateZoomBox();
}
}
// Static images and the webcam share the move/resize/selection behavior.
public static bool IsDraggableElement(SceneElement element)
=> element.IsDraggable;
@@ -387,12 +410,29 @@ public partial class PreviewPane : UserControl
private void Preview_MouseMove(object sender, MouseEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm is null || !vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
if (vm is null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if (e.LeftButton == MouseButtonState.Pressed && vm.IsZoomActive)
{
if (!_isZoomDragging)
{
vm.StartZoomMove(p.X, p.Y);
_isZoomDragging = true;
grid.CaptureMouse();
}
else
{
vm.UpdateZoomMove(p.X, p.Y);
}
return;
}
if (!vm.IsEditMode) return;
if (!_isDraggingOverlay && !_isResizing) return;
var selected = vm.SelectedElement;
if (selected == null)
{
@@ -429,14 +469,60 @@ public partial class PreviewPane : UserControl
}
private void Preview_MouseLeftButtonUp(object sender, MouseButtonEventArgs e)
=> EndPreviewDrag((Grid)sender);
{
if (_isZoomDragging && DataContext is MainViewModel vm)
{
vm.EndZoomMove();
_isZoomDragging = false;
((Grid)sender).ReleaseMouseCapture();
e.Handled = true;
return;
}
EndPreviewDrag((Grid)sender);
}
private void Preview_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
var vm = DataContext as MainViewModel;
if (vm == null) return;
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
if ((vm.IsLive || vm.IsRecording) && !vm.IsEditMode)
{
if (vm.IsZoomActive)
{
vm.DismissZoom();
e.Handled = true;
return;
}
vm.ActivateZoom(p.X, p.Y);
e.Handled = true;
return;
}
vm.ToggleZoom();
e.Handled = true;
} private void Preview_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (_isZoomDragging)
{
var grid = (Grid)sender;
grid.ReleaseMouseCapture();
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
e.Handled = true;
return;
}
}
private void EndPreviewDrag(Grid grid)
{
if (_isDraggingOverlay || _isResizing)
if (_isDraggingOverlay || _isResizing || _isZoomDragging)
grid.ReleaseMouseCapture();
_isDraggingOverlay = false;
_isResizing = false;
_isZoomDragging = false;
if (DataContext is MainViewModel vm) vm.EndZoomMove();
}
private bool HitHandle(Point mouseScreen, SceneElement element)
@@ -461,4 +547,31 @@ public partial class PreviewPane : UserControl
}
return null;
}
}
private void UpdateZoomBox()
{
if (DataContext is not MainViewModel vm) return;
if (vm.IsZoomActive && ZoomVisualBrush != null)
{
ZoomVisualBrush.Visual = CanvasGrid;
ZoomVisualBrush.Viewbox = new Rect(vm.ZoomState.X, vm.ZoomState.Y, vm.ZoomState.Width, vm.ZoomState.Height);
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
if (vm.ZoomShape == Models.ZoomShape.Circle)
{
ZoomCircleOutline.Visibility = Visibility.Visible;
ZoomRectOutline.Visibility = Visibility.Collapsed;
ZoomBox.Clip = new EllipseGeometry(new Rect(0, 0, vm.ZoomState.Width, vm.ZoomState.Height));
}
else
{
ZoomCircleOutline.Visibility = Visibility.Collapsed;
ZoomRectOutline.Visibility = Visibility.Visible;
ZoomBox.Clip = null;
}
}
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
{
ZoomVisualBrush.Visual = null;
}
}
}