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
+14
View File
@@ -2,6 +2,7 @@ using System.Diagnostics;
using System.IO;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.ViewModels;
@@ -75,6 +76,19 @@ public partial class MainViewModel : ViewModelBase
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
private ZoomShape _zoomShape = ZoomShape.Circle;
public ZoomShape ZoomShape
{
get => _zoomShape;
set
{
if (SetProperty(ref _zoomShape, value))
{
try { _layoutStore.UpsertSetting("ZoomShape", value.ToString()); } catch { }
}
}
}
public string OverlayTitle
{
get => _overlayTitle;
@@ -334,6 +334,16 @@ public partial class MainViewModel : ViewModelBase
RecordPillOn = false;
OnAirPillOn = false;
WindowTitle = "LlamaCasty";
// Deactivate zoom when stopping
if (_zoomState.IsActive || _zoomState.IsMoving)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
OnPropertyChanged(nameof(ZoomState));
RebuildQualityDimRects();
}
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
+1 -7
View File
@@ -411,13 +411,7 @@ public partial class MainViewModel : ViewModelBase
OutputRectWidth = rectW;
OutputRectHeight = rectH;
DimRects.Clear();
if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
if (rectX + rectW < MasterFrameWidth)
DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
if (rectY + rectH < MasterFrameHeight)
DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
RebuildQualityDimRects();
IsOutputCropped = DimRects.Count > 0;
ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
+161 -3
View File
@@ -28,6 +28,7 @@ public partial class MainViewModel : ViewModelBase
private string? _activeLayoutPath;
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
private ZoomState _zoomState = new();
// Toasts (TASK 24): non-blocking user-facing messages. Parameterless ctor
// news up the real service; the VM is built from XAML so no DI seam exists.
@@ -74,6 +75,142 @@ public partial class MainViewModel : ViewModelBase
}
}
public ZoomState ZoomState
{
get => _zoomState;
set => SetProperty(ref _zoomState, value);
}
public bool IsZoomActive => _zoomState.IsActive;
public bool ShowZoomOverlay => _zoomState.IsActive;
public void ActivateZoom(double clickX, double clickY)
{
if (!(IsLive || IsRecording)) return;
var maxW = MasterFrameWidth * 0.7;
var maxH = MasterFrameHeight * 0.7;
double w, h;
switch (_zoomShape)
{
case ZoomShape.Circle:
var size = Math.Min(maxW, maxH);
w = size; h = size;
break;
case ZoomShape.VerticalRectangle:
h = maxH;
w = h * 9.0 / 16.0;
break;
default:
w = maxW;
h = w * 9.0 / 16.0;
break;
}
double x, y;
if (_zoomShape == ZoomShape.Circle)
{
x = clickX - w / 2;
y = clickY - h / 2;
}
else
{
x = clickX;
y = clickY;
}
x = Math.Clamp(x, 0, MasterFrameWidth - w);
y = Math.Clamp(y, 0, MasterFrameHeight - h);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
public void ToggleZoom()
{
if (_zoomState.IsActive)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
}
public void DismissZoom()
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
public void StartZoomMove(double x, double y)
{
if (!_zoomState.IsActive) return;
_zoomState.IsMoving = true;
_zoomState.GrabOffsetX = x - _zoomState.X;
_zoomState.GrabOffsetY = y - _zoomState.Y;
OnPropertyChanged(nameof(ZoomState));
}
public void UpdateZoomMove(double x, double y)
{
if (!_zoomState.IsActive || !_zoomState.IsMoving) return;
var nx = x - _zoomState.GrabOffsetX;
var ny = y - _zoomState.GrabOffsetY;
_zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width);
_zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height);
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
}
public void EndZoomMove()
{
if (_zoomState.IsMoving)
{
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(ZoomState));
}
}
private void RefreshZoomUi()
{
OnPropertyChanged(nameof(ZoomState));
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
RebuildDimRectsForZoom();
}
private void RebuildDimRectsForZoom()
{
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
DimRects.Clear();
if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight));
var rx = zx + zw;
if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight));
if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy));
var ry = zy + zh;
if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry));
return;
}
RebuildQualityDimRects();
} private void RebuildQualityDimRects()
{
DimRects.Clear();
if (OutputRectWidth <= 0 || OutputRectHeight <= 0) return;
if (OutputRectX > 0) DimRects.Add(new Rect(0, 0, OutputRectX, MasterFrameHeight));
if (OutputRectX + OutputRectWidth < MasterFrameWidth)
DimRects.Add(new Rect(OutputRectX + OutputRectWidth, 0, MasterFrameWidth - OutputRectX - OutputRectWidth, MasterFrameHeight));
if (OutputRectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, OutputRectY));
if (OutputRectY + OutputRectHeight < MasterFrameHeight)
DimRects.Add(new Rect(0, OutputRectY + OutputRectHeight, MasterFrameWidth, MasterFrameHeight - OutputRectY - OutputRectHeight));
}
private void OnSelectedElementPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(SceneElement.UseDefaultBackground))
@@ -239,6 +376,13 @@ public partial class MainViewModel : ViewModelBase
// (yellow) instead of silently falling back to the default.
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
try
{
var s = _layoutStore.GetSetting("ZoomShape");
if (!string.IsNullOrWhiteSpace(s) && Enum.TryParse<ZoomShape>(s, true, out var zs))
_zoomShape = zs;
}
catch { }
_cameraEnumerator = CameraEnumeratorOverride ?? new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
@@ -553,6 +697,22 @@ public partial class MainViewModel : ViewModelBase
private CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
return new CompositorOptions
{
SourceRectX = (int)Math.Clamp(Math.Round(zx), 0, 1919),
SourceRectY = (int)Math.Clamp(Math.Round(zy), 0, 1079),
SourceRectWidth = (int)Math.Max(1, Math.Round(zw)),
SourceRectHeight = (int)Math.Max(1, Math.Round(zh)),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
@@ -562,9 +722,7 @@ public partial class MainViewModel : ViewModelBase
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
// The credit's cadence, timing, placement and licence gate all live in
} // The credit's cadence, timing, placement and licence gate all live in
// BrandFlashPresenter; MainViewModel only routes it to the frame pump and the
// preview (see MainViewModel.BrandFlash.cs).