Files
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

116 lines
4.9 KiB
C#

using System;
using System.Buffers;
namespace ytLive.Services.Compositor;
/// <summary>
/// The zoom box's magnifier (creator-corrected model, 2026-10-08): the box is a
/// detached 2x view of a FIXED capture window — the box/2 region centred on the
/// spot the creator clicked — sampled from the ALREADY-COMPOSITED frame (every
/// layer: live backdrop, art, webcam, sources, alerts). Moving the box relocates
/// it on screen without changing the capture; the box is painted LAST (layer n+1)
/// so it sits above everything. The capture snapshot is taken before the box is
/// drawn, so a box overlapping its own source never recurses.
/// </summary>
internal static class ZoomMagnifier
{
/// <summary>
/// Magnify a box/2 capture window onto a box/2&#x2009;² box in <paramref name="dst"/>
/// (an opaque, already-composited crop-space buffer). dst pixel centres map to
/// capture coords as <c>cap + (dstLocal + 0.5) / 2</c> — the exact 2x the preview's
/// VisualBrush shows via a box/2 Viewbox with Stretch=Fill. <paramref name="round"/>
/// clips the box to a true circle (zoom shape Circle).
/// </summary>
internal static void MagnifyInto(
byte[] dst, int dstW, int dstH,
double boxX, double boxY, double boxW, double boxH,
double capX, double capY,
bool round)
{
if (boxW <= 0 || boxH <= 0) return;
if (dstW <= 0 || dstH <= 0) return;
// Integer box draw bounds (clamped to the buffer).
var bx0 = Math.Max(0, (int)Math.Floor(boxX));
var by0 = Math.Max(0, (int)Math.Floor(boxY));
var bx1 = Math.Min(dstW, (int)Math.Ceiling(boxX + boxW));
var by1 = Math.Min(dstH, (int)Math.Ceiling(boxY + boxH));
if (bx0 >= bx1 || by0 >= by1) return;
var bw = bx1 - bx0;
var bh = by1 - by0;
// Integer capture-window read bounds (the 2x window is box/2, clamped).
var rx0 = Math.Max(0, (int)Math.Floor(capX));
var ry0 = Math.Max(0, (int)Math.Floor(capY));
var rx1 = Math.Min(dstW, (int)Math.Ceiling(capX + boxW / 2.0));
var ry1 = Math.Min(dstH, (int)Math.Ceiling(capY + boxH / 2.0));
var rw = rx1 - rx0;
var rh = ry1 - ry0;
if (rw <= 0 || rh <= 0) return;
// Snapshot the capture first: the box may overlap its own source region.
var src = ArrayPool<byte>.Shared.Rent(rw * rh * 4);
try
{
for (var y = 0; y < rh; y++)
Buffer.BlockCopy(dst, ((ry0 + y) * dstW + rx0) * 4, src, y * rw * 4, rw * 4);
var radius = Math.Min(boxW, boxH) / 2.0;
var ccx = boxX + boxW / 2.0;
var ccy = boxY + boxH / 2.0;
for (var y = 0; y < bh; y++)
{
var dy = by0 + y;
var sy = capY + (dy + 0.5 - boxY) * 0.5 - ry0;
if (sy < 0.0) sy = 0.0;
if (sy > rh - 1.0) sy = rh - 1.0;
var syi = (int)sy;
var syInv = Math.Min(syi + 1, rh - 1);
var fy8 = (int)((sy - syi) * 256);
var fyInv = 256 - fy8;
var di = (dy * dstW + bx0) * 4;
for (var x = 0; x < bw; x++, di += 4)
{
var dx = bx0 + x;
if (round)
{
var ox = dx - ccx;
var oy = dy - ccy;
if (ox * ox + oy * oy > radius * radius) continue;
}
var sx = capX + (dx + 0.5 - boxX) * 0.5 - rx0;
if (sx < 0.0) sx = 0.0;
if (sx > rw - 1.0) sx = rw - 1.0;
var sxi = (int)sx;
var sxInv = Math.Min(sxi + 1, rw - 1);
var fx8 = (int)((sx - sxi) * 256);
var fxInv = 256 - fx8;
var p00 = (syi * rw + sxi) * 4;
var p10 = (syi * rw + sxInv) * 4;
var p01 = (syInv * rw + sxi) * 4;
var p11 = (syInv * rw + sxInv) * 4;
dst[di] = (byte)Bilinear(src[p00], src[p10], src[p01], src[p11], fx8, fxInv, fy8, fyInv);
dst[di + 1] = (byte)Bilinear(src[p00 + 1], src[p10 + 1], src[p01 + 1], src[p11 + 1], fx8, fxInv, fy8, fyInv);
dst[di + 2] = (byte)Bilinear(src[p00 + 2], src[p10 + 2], src[p01 + 2], src[p11 + 2], fx8, fxInv, fy8, fyInv);
dst[di + 3] = 255;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(src);
}
}
/// <summary>Two-stage bilinear in 8.8 fixed point (same precision as
/// <see cref="SceneCompositor"/>'s sampler).</summary>
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
{
var top = tl * fxInv + tr * fx8;
var bot = bl * fxInv + br * fx8;
return (top * fyInv + bot * fy8 + 32768) >> 16;
}
}