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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user