Zoom: compose the box as a real 2x layer above the background (Unit: zoom layer + magnification proof)

Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
This commit is contained in:
2026-10-07 23:39:54 -07:00
parent 966e718f1e
commit 4b3d82ff0f
17 changed files with 734 additions and 210 deletions
+8 -132
View File
@@ -84,122 +84,10 @@ public partial class MainViewModel : ViewModelBase
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();
}
// Zoom activation/move/dismiss and the zoom frame builder live in
// MainViewModel.Zoom.cs — the zoom box is a real composited layer (see TASK 51).
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()
private void RebuildQualityDimRects()
{
DimRects.Clear();
if (OutputRectWidth <= 0 || OutputRectHeight <= 0) return;
@@ -513,6 +401,7 @@ public partial class MainViewModel : ViewModelBase
AddScene(name);
}
HealBackgrounds();
HealZoomLayers();
RefreshHotkeyLabels();
foreach (var scene in Scenes)
@@ -688,31 +577,18 @@ public partial class MainViewModel : ViewModelBase
Source { Type: SourceType.WebSource } web => _webView2Manager?.GetLatestFrame(web.Id),
Source { IsMediaSource: true, MediaPath: not null } media => _mediaManager.GetLatestFrame(media.MediaPath),
Source { AssetId: not null } image => StaticPixelCache.Get(image.AssetId),
ZoomSceneElement zoom when zoom.IsZoomActive => BuildZoomFrame(zoom),
_ => null,
};
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
// internal = test seam (the zoom layer must NOT change the output crop — the
// box is a composited layer, never the crop source).
internal 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),