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.
This commit is contained in:
@@ -157,6 +157,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var fullFrame = frameFor(element);
|
||||
@@ -175,6 +176,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -191,6 +193,14 @@ public sealed class SceneCompositor
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
|
||||
// from the frame just composited (all layers). Drawn LAST — above everything.
|
||||
// The box/2 capture is anchored at the click, so dragging the box only moves
|
||||
// the box; the content it shows never changes (creator ruling 2026-10-08).
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(
|
||||
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
@@ -249,6 +259,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -299,6 +310,7 @@ public sealed class SceneCompositor
|
||||
case Source { IsBackground: true }:
|
||||
case Source { Type: SourceType.Background }:
|
||||
case Source { Type: SourceType.TextOverlay }:
|
||||
case ZoomSceneElement { IsZoomActive: true }:
|
||||
continue;
|
||||
}
|
||||
var frame = frameFor(element);
|
||||
@@ -309,6 +321,10 @@ public sealed class SceneCompositor
|
||||
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
|
||||
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
|
||||
|
||||
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
|
||||
if (zoom != null)
|
||||
BlitZoom(buffer, cropW, cropH, options, zoom);
|
||||
|
||||
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
|
||||
}
|
||||
|
||||
@@ -834,6 +850,23 @@ public sealed class SceneCompositor
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The zoom box layer (n+1): map the element's master-space box and capture to
|
||||
/// crop space and 2x-magnify the capture onto the box from the composed frame.
|
||||
/// The active zoom element is never blitted by the normal layer loops — the
|
||||
/// switch skips it and this pass draws it above everything, last.
|
||||
/// </summary>
|
||||
private static void BlitZoom(
|
||||
byte[] dst, int cropW, int cropH, CompositorOptions options, ZoomSceneElement zoom)
|
||||
{
|
||||
ZoomMagnifier.MagnifyInto(
|
||||
dst, cropW, cropH,
|
||||
zoom.X - options.SourceRectX, zoom.Y - options.SourceRectY,
|
||||
zoom.Width, zoom.Height,
|
||||
zoom.CaptureX - options.SourceRectX, zoom.CaptureY - options.SourceRectY,
|
||||
zoom.ClipShape == ClipShape.Round);
|
||||
}
|
||||
|
||||
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
|
||||
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user