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:
2026-10-08 08:26:29 -07:00
parent 4b3d82ff0f
commit 6bff21b234
15 changed files with 467 additions and 330 deletions
+33
View File
@@ -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)
{