Creator report: Ctrl+right in edit mode and left-click in preview did nothing when neither streaming nor recording. The bindings were correct; the action was not. Two causes, both in the model rather than the handler: - ActivateZoom opened with `if (!(IsLive || IsRecording)) return;` — the box could not be created outside a session at all. - ToggleZoom only ever called DismissZoom, so "toggle" could turn it off and never on. Now ToggleZoom(x, y) activates at the cursor or dismisses if already open, and the live-only guard is gone — the magnifier is an editing aid as much as a live one. Both call sites pass the canvas point, so the box still anchors on the click rather than the frame centre. Debug 0 warnings; ZoomBox + ZoomMagnifier tests pass (3).
189 lines
7.3 KiB
C#
189 lines
7.3 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)
|
|
{
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The gesture behind the zoom box: activate it centred on the click, or dismiss it if it is
|
|
/// already open. Both halves live here because the old split (activate while live, dismiss
|
|
/// only otherwise) made the gesture do nothing at all when idle — the box could be turned off
|
|
/// but never on (creator, 2026-11-01).
|
|
/// </summary>
|
|
public void ToggleZoom(double clickX, double clickY)
|
|
{
|
|
if (_zoomState.IsActive) DismissZoom();
|
|
else ActivateZoom(clickX, clickY);
|
|
}
|
|
|
|
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;
|
|
}
|
|
} |