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.
116 lines
4.9 KiB
C#
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 ² 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;
|
|
}
|
|
} |