Files
LlamaCasty/ViewModels/MainViewModel.Zoom.cs
T
gramps 6bff21b234 Zoom: anchor the capture, magnify the full composite, draw on top (Unit: zoom rework per creator correction)
Creator corrected the "loupe" zoom on 2026-10-08: the click anchors a fixed
capture region (box/2 centred on the click) and the box is a live 2x view of
that region — dragging the box never changes its content; it magnifies the
fully-composited frame (every layer, incl. webcam) and draws LAST (n+1).
Rectangle shape is 4:3 ("more of a 640x480 aspect").

- MainViewModel.Zoom.cs rewritten: ActivateZoom anchors CaptureX/CaptureY
  (independently clamped); UpdateZoomMove relocates the box only; the per-frame
  BuildZoomFrame/ZoomFrameBuilder path is gone.
- SceneCompositor: skips the active ZoomSceneElement in every layer loop and
  BlitZooms it last via the new pure ZoomMagnifier (2x bilinear, temp snapshot
  via ArrayPool, round clip).
- Preview: ZoomBox hoisted above CanvasGrid as a sibling; its brush samples the
  whole composed preview at the fixed capture (2x).
- Tests: ZoomMagnifierTests (box-over-blue shows green capture at 2x incl. an
  extra red "layer" — the loupe/backdrop-only regressions both fail it) +
  round-clip; ZoomBoxInteractionTests rewritten (anchored capture, move-doesn't-
  change-capture, 4:3/9:16/square). 404 pass, 2 known environment flakes.
2026-10-08 08:26:29 -07:00

183 lines
6.9 KiB
C#

using System;
using System.Linq;
using System.Windows;
using ytLive.Models;
namespace ytLive.ViewModels;
/// <summary>
/// The zoom box (creator-corrected model, 2026-10-08): right-clicking the live
/// preview anchors a CAPTURE window — the box/2 region centred on the click — and
/// parks a movable 2x magnifier box on screen. The box shows a live, always-on,
/// magnified view of that anchored capture region (the CLICKED area, never re-follows
/// the box); dragging it only repositions the box. The content is composited LAST
/// (layer n+1) by <see cref="Services.Compositor.SceneCompositor"/> from the already-
/// composed frame, so it captures every layer — prove it with the ZoomMagnifierTests
/// banded-buffer "box over blue shows green" proof. The transient
/// <see cref="ZoomSceneElement"/> carries the box placement + capture anchor; the
/// output crop stays the normal tier (the box is NEVER the crop source).
/// </summary>
public partial class MainViewModel
{
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:
// HorizontalRectangle: 4:3 per creator ("more of a 640x480 aspect
// ratio") — NOT the 16:9 of the first cut, which read as a square.
h = maxH;
w = h * 4.0 / 3.0;
break;
}
// The box AND the capture are both centred on the click; the capture window
// is the box/2 interior and is FIXED after this call. Each is clamped to the
// master 1920x1080 frame independently (an edge click can push the capture
// toward centre while the box still sits right at the edge).
var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w);
var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h);
var capX = Math.Clamp(clickX - w / 4, 0, MasterFrameWidth - w / 2);
var capY = Math.Clamp(clickY - h / 4, 0, MasterFrameHeight - h / 2);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
_zoomState.CaptureX = capX;
_zoomState.CaptureY = capY;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
var zoomTarget = EnsureZoomLayer(StagedScene);
zoomTarget.X = x;
zoomTarget.Y = y;
zoomTarget.Width = w;
zoomTarget.Height = h;
zoomTarget.CaptureX = capX;
zoomTarget.CaptureY = capY;
zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional;
zoomTarget.IsZoomActive = true;
zoomTarget.IsVisible = true;
ScheduleSave();
RefreshZoomUi();
}
public void ToggleZoom()
{
if (_zoomState.IsActive) DismissZoom();
}
public void DismissZoom()
{
if (!_zoomState.IsActive) return;
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
if (zoomTarget != null)
{
zoomTarget.IsZoomActive = false;
zoomTarget.IsVisible = false;
zoomTarget.CaptureX = 0;
zoomTarget.CaptureY = 0;
}
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
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);
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault(e => e.IsZoomActive);
if (zoomTarget != null)
{
zoomTarget.X = _zoomState.X;
zoomTarget.Y = _zoomState.Y;
// CaptureX/Y are deliberately NOT touched: dragging relocates the box,
// never the anchored captured area (creator ruling 2026-10-08).
}
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();
}
/// <summary>Heals a missing zoom placeholder on every scene after a layout
/// load: the invisible 0x0 row mirrored by the healed backgrounds. The zoom box
/// itself is transient — the placeholder survives so the layer has a seat. The
/// compositor ranks the ACTIVE box last regardless of this index.</summary>
private void HealZoomLayers()
{
foreach (var scene in Scenes)
EnsureZoomLayer(scene);
}
/// <summary>Gets or inserts the scene's zoom placeholder at z-index 1.</summary>
private ZoomSceneElement EnsureZoomLayer(Scene? scene)
{
var existing = scene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
if (existing != null) return existing;
var zoom = new ZoomSceneElement();
_sceneGraph.InsertElement(scene!, Math.Min(1, scene!.Elements.Count), zoom);
return zoom;
}
}