diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml
index 45ceed5..28d91b5 100644
--- a/Controls/PreviewPane.xaml
+++ b/Controls/PreviewPane.xaml
@@ -63,8 +63,8 @@
+ image renders above it). The zoom box samples the
+ whole CanvasGrid, not just this layer (TASK 51). -->
@@ -72,19 +72,6 @@
+
+
+
+
+
+
+
+
+
-/// The transient zoom layer: a real scene element parked at z-index 1 (above the
-/// background, below every other layer) whose pixels are the 2x-magnified backdrop
-/// centred on the zoom box. The app paints it into the stream/recording exactly like
-/// any other layer. While inactive it is a 0x0 invisible placeholder that is never
-/// persisted (LayoutStore hand-rolls only Source/WebcamSceneConfig rows) — the VM
-/// re-inserts it on load and on scene creation, mirroring how backgrounds heal.
+/// The transient zoom layer. It is a real scene element, but a special one: while
+/// active the SceneCompositor draws it LAST (layer n+1, above everything) as a live
+/// 2x view of a FIXED capture window (the box/2 region centred on where the creator
+/// clicked), sampled from the just-composited frame — so it captures ALL layers and
+/// keeps showing the same content no matter where the box is dragged (creator ruling
+/// 2026-10-08). Its X/Y/W/H are the movable BOX placement; CaptureX/CaptureY is the
+/// anchored capture window. While inactive it is an invisible placeholder that is
+/// never persisted (LayoutStore hand-rolls only Source/WebcamSceneConfig rows) — the
+/// VM re-inserts it on load and on scene creation, mirroring how backgrounds heal.
///
public sealed class ZoomSceneElement : SceneElement
{
@@ -16,13 +19,18 @@ public sealed class ZoomSceneElement : SceneElement
/// the baked static base and composites per-frame.
public bool IsZoomActive { get; set; }
+ /// Master-space top-left of the fixed capture window. Set once by
+ /// ActivateZoom; moving the box must never touch it.
+ public double CaptureX { get; set; }
+ public double CaptureY { get; set; }
+
public override ElementKind Kind => IsZoomActive ? ElementKind.Dynamic : ElementKind.Static;
public override bool IsZoomLayer => true;
public override bool IsDraggable => false;
- /// Never rendered by the element template — the preview magnifier
- /// (ZoomBox) and the output resolver own its pixels.
+ /// Never rendered by the element template or the resolver — the preview
+ /// magnifier (ZoomBox) and the output compositor's zoom pass own its pixels.
public override ImageSource? DisplaySource => null;
}
\ No newline at end of file
diff --git a/Models/ZoomState.cs b/Models/ZoomState.cs
index 2d8a13e..bee3663 100644
--- a/Models/ZoomState.cs
+++ b/Models/ZoomState.cs
@@ -9,6 +9,13 @@ public class ZoomState
public double Y { get; set; }
public double Width { get; set; }
public double Height { get; set; }
+
+ /// Master-space top-left of the FIXED capture window (the box/2 region
+ /// centred on the click). The box can be dragged anywhere; this never changes —
+ /// the box always shows this same region magnified 2x (creator ruling 2026-10-08).
+ public double CaptureX { get; set; }
+ public double CaptureY { get; set; }
+
public bool IsMoving { get; set; }
public double GrabOffsetX { get; set; }
public double GrabOffsetY { get; set; }
diff --git a/Models/index.md b/Models/index.md
index 61185ae..73ffeb5 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -9,7 +9,7 @@ Plain data types. No logic beyond what a property can carry. See
| `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay/ChatBox/Countdown/WebSource/MediaSource/**AlertBox (TASK 43 — the celebration zone, one per layout)** — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 51); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
-| `ZoomSceneElement.cs` | **Transient zoom layer (TASK 51):** a real composited element parked at z-index 1 (above the background, below every other layer) whose pixels are the 2x-magnified backdrop? — the frame pump builds them via `ZoomFrameBuilder` and composites like any layer. `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both) |
+| `ZoomSceneElement.cs` | **Transient zoom box (TASK 51, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
diff --git a/MyMistakes.md b/MyMistakes.md
index 3bf748b..7f12fdc 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -1526,3 +1526,22 @@ A one-constant difference (`W/4` vs nothing) is invisible in a real scene but fa
gradient test — which is exactly why the magnification proof exists: 4 vertical bands, a box whose
1x region straddles two of them, assert ALL output pixels are the center-band color. If you change
the preview window, change `ZoomFrameBuilder` in the same edit or the preview lies about the output.
+
+## 2026-10-08 — The zoom box is NOT a loupe: anchored capture, all-layer content, drawn LAST
+
+I shipped the whole first zoom layer (commit `4b3d82f`) with a "loupe" mental model — the box shows
+what's *under it* in the output — and the creator corrected it in one message: the click fixes a
+**capture region** and the box is a live, always-on 2x view of THAT region; moving the box changes
+NOTHING about its content; it magnifies the fully-composited frame (every layer, including webcam),
+and it must draw **on top of everything** (layer n+1), not slot at z-index 1. Three wrongs, all made
+"elegantly": a BoxFollowing frame builder, a Backdrop+first-layer-only sample, and a z=1 layering.
+
+The corrected test that would have caught all three in one shot (`ZoomMagnifierTests`): a banded
+buffer (blue left / green right) with the box's left quarter OVER a blue region while the capture
+sits wholly in green — assert **no box pixel is blue**. A loupe-style sampler fails it, a backdrop-
+only sampler fails the "red webcam square appears magnified" assert, and `SceneCompositor` skips the
+active zoom in every layer loop so nothing else can cover it. Lessons: (1) ask the *triggering
+semantics* — anchored-capture vs follow-the-box — before building any "zoom" UI; (2) prove content
+captures ALL layers with an extra layer in the fixture and assert it appears; (3) "draw above
+everything" is a compositor ordering property (skip in the layer loops + blit last), not a z-index
+of the element among the others.
diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs
index 21df448..a57360c 100644
--- a/Services/Compositor/SceneCompositor.cs
+++ b/Services/Compositor/SceneCompositor.cs
@@ -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().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().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
});
}
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
/// Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
{
diff --git a/Services/Compositor/ZoomMagnifier.cs b/Services/Compositor/ZoomMagnifier.cs
new file mode 100644
index 0000000..4691cf9
--- /dev/null
+++ b/Services/Compositor/ZoomMagnifier.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Buffers;
+
+namespace ytLive.Services.Compositor;
+
+///
+/// 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.
+///
+internal static class ZoomMagnifier
+{
+ ///
+ /// Magnify a box/2 capture window onto a box/2 ² box in
+ /// (an opaque, already-composited crop-space buffer). dst pixel centres map to
+ /// capture coords as cap + (dstLocal + 0.5) / 2 — the exact 2x the preview's
+ /// VisualBrush shows via a box/2 Viewbox with Stretch=Fill.
+ /// clips the box to a true circle (zoom shape Circle).
+ ///
+ 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.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.Shared.Return(src);
+ }
+ }
+
+ /// Two-stage bilinear in 8.8 fixed point (same precision as
+ /// 's sampler).
+ 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;
+ }
+}
\ No newline at end of file
diff --git a/ViewModels/MainViewModel.Zoom.cs b/ViewModels/MainViewModel.Zoom.cs
index add13c3..05eb706 100644
--- a/ViewModels/MainViewModel.Zoom.cs
+++ b/ViewModels/MainViewModel.Zoom.cs
@@ -2,18 +2,20 @@ using System;
using System.Linq;
using System.Windows;
using ytLive.Models;
-using ytLive.Services;
-using ytLive.Services.Compositor;
namespace ytLive.ViewModels;
///
-/// The zoom box as a real composited layer (TASK 51): right-clicking the live
-/// preview places a 2x magnifier one z-slot above the background and below every
-/// other layer. The box drives a transient whose
-/// pixels are the magnified backdrop inside the box; the frame pump composites it
-/// into the stream/recording like any other layer (output stays the normal tier
-/// crop — the box is NEVER the crop source). All shapes are centered on the click.
+/// The zoom box (creator-corrected model, 2026-10-08): right-clicking the live
+/// preview anchors a CAPTURE window — the box/2 region centred on the click — and
+/// parks a movable 2x magnifier box on screen. The box shows a live, always-on,
+/// magnified view of that anchored capture region (the CLICKED area, never re-follows
+/// the box); dragging it only repositions the box. The content is composited LAST
+/// (layer n+1) by from the already-
+/// composed frame, so it captures every layer — prove it with the ZoomMagnifierTests
+/// banded-buffer "box over blue shows green" proof. The transient
+/// carries the box placement + capture anchor; the
+/// output crop stays the normal tier (the box is NEVER the crop source).
///
public partial class MainViewModel
{
@@ -34,18 +36,27 @@ public partial class MainViewModel
w = h * 9.0 / 16.0;
break;
default:
- w = maxW;
- h = w * 9.0 / 16.0;
+ // HorizontalRectangle: 4:3 per creator ("more of a 640x480 aspect
+ // ratio") — NOT the 16:9 of the first cut, which read as a square.
+ h = maxH;
+ w = h * 4.0 / 3.0;
break;
}
- // Option B anchor: the click point is the CENTER of every shape. Clamped
- // so the whole box stays inside the master 1920x1080 frame.
+
+ // The box AND the capture are both centred on the click; the capture window
+ // is the box/2 interior and is FIXED after this call. Each is clamped to the
+ // master 1920x1080 frame independently (an edge click can push the capture
+ // toward centre while the box still sits right at the edge).
var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w);
var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h);
+ var capX = Math.Clamp(clickX - w / 4, 0, MasterFrameWidth - w / 2);
+ var capY = Math.Clamp(clickY - h / 4, 0, MasterFrameHeight - h / 2);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
+ _zoomState.CaptureX = capX;
+ _zoomState.CaptureY = capY;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
@@ -54,6 +65,8 @@ public partial class MainViewModel
zoomTarget.Y = y;
zoomTarget.Width = w;
zoomTarget.Height = h;
+ zoomTarget.CaptureX = capX;
+ zoomTarget.CaptureY = capY;
zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional;
zoomTarget.IsZoomActive = true;
zoomTarget.IsVisible = true;
@@ -76,6 +89,8 @@ public partial class MainViewModel
{
zoomTarget.IsZoomActive = false;
zoomTarget.IsVisible = false;
+ zoomTarget.CaptureX = 0;
+ zoomTarget.CaptureY = 0;
}
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
RefreshZoomUi();
@@ -102,6 +117,8 @@ public partial class MainViewModel
{
zoomTarget.X = _zoomState.X;
zoomTarget.Y = _zoomState.Y;
+ // CaptureX/Y are deliberately NOT touched: dragging relocates the box,
+ // never the anchored captured area (creator ruling 2026-10-08).
}
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
@@ -145,9 +162,9 @@ public partial class MainViewModel
}
/// Heals a missing zoom placeholder on every scene after a layout
- /// load: the invisible 0x0 row parked at z-index 1 (above the background,
- /// below every other layer), mirroring how backgrounds heal. The zoom box
- /// itself is transient — the placeholder survives so the layer has a seat.
+ /// load: the invisible 0x0 row mirrored by the healed backgrounds. The zoom box
+ /// itself is transient — the placeholder survives so the layer has a seat. The
+ /// compositor ranks the ACTIVE box last regardless of this index.
private void HealZoomLayers()
{
foreach (var scene in Scenes)
@@ -163,171 +180,4 @@ public partial class MainViewModel
_sceneGraph.InsertElement(scene!, Math.Min(1, scene!.Elements.Count), zoom);
return zoom;
}
-
- // Single-slot zoom frame cache: the frame-pump resolver runs every tick, so
- // rebuilding the ~4 MB box raster per tick would churn the LOH even when the
- // backdrop and box did not change. One active zoom → one cached frame; the
- // key is the backdrop frames' identity+Epoch and the box rect's ints.
- private VideoFrame? _zoomFrame;
- private (byte[]?, long?, byte[]?, long?, int, int, int, int) _zoomFrameKey;
-
- private VideoFrame? BuildZoomFrame(ZoomSceneElement zoom)
- {
- var scene = StagedScene;
- if (scene == null) return null;
-
- // Mirror the compositor's backdrop ordering: live capture first, static
- // background art over it (SceneCompositor.Render, same conditions — live
- // only when visible, art whenever present).
- var live = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
- var liveFrame = live is { IsVisible: true } ? ResolveOutputFrameCore(live) : null;
- var art = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
- var artFrame = art != null ? ResolveOutputFrameCore(art) : null;
- if (liveFrame == null && artFrame == null) return null;
-
- var zx = (int)Math.Round(zoom.X);
- var zy = (int)Math.Round(zoom.Y);
- var zw = (int)Math.Ceiling(zoom.Width);
- var zh = (int)Math.Ceiling(zoom.Height);
- var key = (liveFrame?.BgraPixels, liveFrame?.Epoch, artFrame?.BgraPixels, artFrame?.Epoch, zx, zy, zw, zh);
- if (key != _zoomFrameKey)
- {
- _zoomFrame = ZoomFrameBuilder.Build(
- liveFrame, artFrame, zx, zy, zw, zh, MasterFrameWidth, MasterFrameHeight);
- _zoomFrameKey = key;
- }
- return _zoomFrame;
- }
-}
-
-/// Pure pixel builder for the zoom layer: samples the box/2 interior
-/// centred on the box centre from the sequenced backdrop (live capture then art)
-/// and upscales it 2x — the same region and magnification the preview's
-/// VisualBrush shows. Static, so the magnification proof is a plain unit test.
-internal static class ZoomFrameBuilder
-{
- internal static VideoFrame? Build(
- VideoFrame? liveFrame, VideoFrame? artFrame,
- int zx, int zy, int zw, int zh,
- double masterW, double masterH)
- {
- if (zw <= 0 || zh <= 0) return null;
- if (liveFrame == null && artFrame == null) return null;
- var buffer = new byte[zw * zh * 4];
- for (var oy = 0; oy < zh; oy++)
- {
- // Source window = the box/2 interior CENTERED on the box center (the
- // same region as the preview's VisualBrush Viewbox), sampled at 2x.
- var my = zy + zh / 4.0 + (oy + 0.5) * 0.5;
- var di = oy * zw * 4;
- for (var ox = 0; ox < zw; ox++, di += 4)
- {
- var mx = zx + zw / 4.0 + (ox + 0.5) * 0.5;
- var b = 0;
- var g = 0;
- var r = 0;
- var a = 0;
- if (liveFrame != null)
- Sample(liveFrame, mx, my, masterW, masterH, ref b, ref g, ref r, ref a);
- if (artFrame != null)
- {
- var ab = 0;
- var ag = 0;
- var ar = 0;
- var aa = 0;
- Sample(artFrame, mx, my, masterW, masterH, ref ab, ref ag, ref ar, ref aa);
- if (aa >= 255)
- {
- b = ab; g = ag; r = ar;
- }
- else if (aa > 0)
- {
- var inv = 255 - aa;
- b = (b * inv + ab * aa + 127) / 255;
- g = (g * inv + ag * aa + 127) / 255;
- r = (r * inv + ar * aa + 127) / 255;
- }
- a = 255;
- }
- else if (liveFrame != null)
- {
- a = 255;
- }
- buffer[di] = (byte)b;
- buffer[di + 1] = (byte)g;
- buffer[di + 2] = (byte)r;
- buffer[di + 3] = (byte)a;
- }
- }
- return new VideoFrame(zw, zh, buffer);
- }
-
- /// Inverse of the compositor's full-frame backdrop blit: maps a master
- /// pixel back through (or the frame's
- /// CropBounds) into the source canvas and bilinear-samples it. Fixed-point
- /// bilinear, same precision as SceneCompositor.Bilinear.
- private static void Sample(
- VideoFrame src, double mx, double my, double masterW, double masterH,
- ref int b, ref int g, ref int r, ref int a)
- {
- var cbX = 0;
- var cbY = 0;
- var cbW = src.Width;
- var cbH = src.Height;
- float scale, ox, oy, drawnW, drawnH;
- if (src.CropBounds is { } cb && cb.W > 0 && cb.H > 0)
- {
- cbX = cb.X;
- cbY = cb.Y;
- cbW = cb.W;
- cbH = cb.H;
- scale = Math.Max((float)masterW / cbW, (float)masterH / cbH);
- drawnW = cbW * scale;
- drawnH = cbH * scale;
- ox = (float)((masterW - drawnW) / 2);
- oy = (float)((masterH - drawnH) / 2);
- }
- else
- {
- var (uScale, uOx, uOy) = StretchMath.UniformToFill((float)masterW, (float)masterH, src.Width, src.Height);
- scale = uScale;
- drawnW = src.Width * uScale;
- drawnH = src.Height * uScale;
- ox = uOx;
- oy = uOy;
- }
-
- var sxFloat = cbX + (mx - ox) / drawnW * cbW;
- var syFloat = cbY + (my - oy) / drawnH * cbH;
- if (sxFloat < 0f) sxFloat = 0f;
- if (sxFloat > src.Width - 1f) sxFloat = src.Width - 1f;
- if (syFloat < 0f) syFloat = 0f;
- if (syFloat > src.Height - 1f) syFloat = src.Height - 1f;
-
- var sxi = (int)sxFloat;
- var syi = (int)syFloat;
- var sxInv = Math.Min(sxi + 1, src.Width - 1);
- var syInv = Math.Min(syi + 1, src.Height - 1);
- var fx8 = (int)((sxFloat - sxi) * 256);
- var fxInv = 256 - fx8;
- var fy8 = (int)((syFloat - syi) * 256);
- var fyInv = 256 - fy8;
- var stride = src.Width;
- var p00 = (syi * stride + sxi) * 4;
- var p10 = (syi * stride + sxInv) * 4;
- var p01 = (syInv * stride + sxi) * 4;
- var p11 = (syInv * stride + sxInv) * 4;
- var px = src.BgraPixels;
- b = Bilinear(px[p00], px[p10], px[p01], px[p11], fx8, fxInv, fy8, fyInv);
- g = Bilinear(px[p00 + 1], px[p10 + 1], px[p01 + 1], px[p11 + 1], fx8, fxInv, fy8, fyInv);
- r = Bilinear(px[p00 + 2], px[p10 + 2], px[p01 + 2], px[p11 + 2], fx8, fxInv, fy8, fyInv);
- a = Bilinear(px[p00 + 3], px[p10 + 3], px[p01 + 3], px[p11 + 3], fx8, fxInv, fy8, fyInv);
- }
-
- 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;
- }
}
\ No newline at end of file
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 702d6a5..61f1e5e 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -577,7 +577,6 @@ 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,
};
}
diff --git a/ViewModels/index.md b/ViewModels/index.md
index b09c296..4f11666 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -12,7 +12,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
-| `MainViewModel.Zoom.cs` | **Zoom layer (TASK 51)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom` — right-click in the live preview parks a **2x magnifier as a real composited layer** at z-index 1 (`ZoomSceneElement`, healed by `HealZoomLayers`/`EnsureZoomLayer` on load + scene create; never persisted). All shapes are CENTERED on the click and clamped to the master frame. `BuildZoomFrame` resolves the backdrop (live capture under static art — same frames/conditions as `SceneCompositor.Render`) and calls the pure static `ZoomFrameBuilder.Build` (box/2 interior centred on the box centre, bilinear, single-slot cache keyed on frame identity+Epoch+box ints so the per-tick resolver allocates nothing while idle). `BuildCompositorOptions` (moved internal = test seam) always returns the tier crop — the box is never the crop source. |
+| `MainViewModel.Zoom.cs` | **Zoom box (TASK 51, creator-corrected 2026-10-08)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom`. Right-clicking the live preview anchors a FIXED **capture window** (the box/2 region centred on the click) and parks a movable **2x magnifier**. The box shows a live always-on 2x view of the anchored capture; **dragging moves only the box — `CaptureX/Y` are deliberately untouched in `UpdateZoomMove`** (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = **4:3** (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient `ZoomSceneElement` mirrors box + capture; the output compositor draws it **LAST (layer n+1)** from the already-composed frame (`SceneCompositor.BlitZoom`→`ZoomMagnifier`) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built `ZoomFrameBuilder` frames was removed. `BuildCompositorOptions` (internal = test seam) always returns the tier crop — the box is never the crop source. |
| `MainViewModel.Background.cs` | Screen backdrop (Live-only by policy): `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture); background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/`HealBackgrounds`/`StampDefaultBackgroundAsset`; screen capture `ReacquireScreenCaptures`/`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/`SetBackgroundCapture`/`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop` (keys on full-screen-game monitor or primary display via `Win32FullScreenDetector`); display picker `Displays`/`SetBackgroundDisplayCommand`; preview fns `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`/`CreatePlaceholderSnapshot`; `ChangeCaptureCommand`/`RefreshCaptureCommand`/`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`. `ShowPreviewPlaceholder` stays in core. |
| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. |
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
diff --git a/ytLive.Tests/ZoomBoxInteractionTests.cs b/ytLive.Tests/ZoomBoxInteractionTests.cs
index 2fdf669..f8582a3 100644
--- a/ytLive.Tests/ZoomBoxInteractionTests.cs
+++ b/ytLive.Tests/ZoomBoxInteractionTests.cs
@@ -12,13 +12,15 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
///
-/// Verifies the zoom box created by a right-click in the live preview: every
-/// ZoomView shape procures the right bounding box CENTERED on the click (circle
-/// square, phone 9:16, tablet 16:9), the box stays fully inside the 1920x1080
-/// preview, and — because the box is now a real composited layer (TASK 51) —
-/// a ZoomSceneElement placeholder sits at z-index 1 whose rect mirrors the box,
-/// its Kind flips Dynamic only while active, and the output crop stays the tier
-/// (the box is never the crop source).
+/// Verifies the zoom box created by a right-click in the live preview (creator-
+/// corrected model, 2026-10-08): every ZoomView shape procures the right bounding
+/// box CENTERED on the click and a FIXED capture window (the box/2 region centred
+/// on the click). Dragging the box relocates it WITHOUT changing the capture — the
+/// box always shows the same anchored content magnified 2x. The rectangle is 4:3
+/// ("more of a 640x480 aspect ratio"). The box is a real composited layer drawn
+/// LAST (a ZoomSceneElement placeholder sits at z-index 1 whose rect + capture
+/// mirror the box; Kind flips Dynamic only while active), and the output crop
+/// stays the tier (the box is never the crop source).
///
[Collection("RealApp")]
public sealed class ZoomBoxInteractionTests
@@ -58,13 +60,15 @@ public sealed class ZoomBoxInteractionTests
var rectOutline = (Rectangle)pane.FindName("ZoomRectOutline")!;
var circleOutline = (Ellipse)pane.FindName("ZoomCircleOutline")!;
- // Circle: square box, click point is the CENTER.
+ // Circle: square box, click point is the CENTER of box AND capture.
vm.ZoomShape = ZoomShape.Circle;
vm.ActivateZoom(1000, 500);
Assert.True(vm.IsZoomActive);
Assert.Equal(vm.ZoomState.Width, vm.ZoomState.Height, 3);
Assert.Equal(1000 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(500 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
+ Assert.Equal(1000 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
+ Assert.Equal(500 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
AssertTrueActiveLayer(vm);
window.UpdateLayout();
@@ -73,13 +77,15 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(Visibility.Visible, circleOutline.Visibility);
Assert.Equal(Visibility.Collapsed, rectOutline.Visibility);
- // Phone (vertical rectangle): tall 9:16 box, click point is the CENTER.
+ // Phone (vertical rectangle): tall 9:16 box; capture centred on the click.
vm.ZoomShape = ZoomShape.VerticalRectangle;
vm.ActivateZoom(960, 540);
Assert.True(vm.ZoomState.Height > vm.ZoomState.Width);
Assert.Equal(9.0 / 16.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
+ Assert.Equal(960 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
+ Assert.Equal(540 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
AssertTrueActiveLayer(vm);
window.UpdateLayout();
@@ -87,22 +93,53 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
Assert.Equal(Visibility.Visible, rectOutline.Visibility);
- // Tablet (horizontal rectangle): wide 16:9 box, click point is the CENTER.
+ // Tablet (horizontal rectangle): 4:3 wide box (creator: "more of a
+ // 640x480 aspect ratio" — the first-cut 16:9 read as a square).
vm.ZoomShape = ZoomShape.HorizontalRectangle;
vm.ActivateZoom(960, 540);
Assert.True(vm.ZoomState.Width > vm.ZoomState.Height);
- Assert.Equal(16.0 / 9.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
+ Assert.Equal(4.0 / 3.0, vm.ZoomState.Width / vm.ZoomState.Height, 3);
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
+ Assert.Equal(960 - vm.ZoomState.Width / 4, vm.ZoomState.CaptureX, 3);
+ Assert.Equal(540 - vm.ZoomState.Height / 4, vm.ZoomState.CaptureY, 3);
AssertTrueActiveLayer(vm);
- // Clamp keeps the box fully inside the 1920x1080 preview even for a
- // corner click that would otherwise overhang it.
+ window.UpdateLayout();
+ Assert.Null(zoomBox.Clip);
+ Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
+ Assert.Equal(Visibility.Visible, rectOutline.Visibility);
+
+ // THE core semantic: dragging the box moves only the box. The capture —
+ // and therefore the content the box shows — never changes. (Box is still
+ // the un-clamped tablet box [456,162] at this point with room to move.)
+ var boxX = vm.ZoomState.X;
+ var boxY = vm.ZoomState.Y;
+ var captureX = vm.ZoomState.CaptureX;
+ var captureY = vm.ZoomState.CaptureY;
+ vm.StartZoomMove(800, 400);
+ vm.UpdateZoomMove(1200, 600);
+ vm.EndZoomMove();
+ Assert.True(Math.Abs(vm.ZoomState.X - boxX) >= 100, "the box must have moved");
+ Assert.True(Math.Abs(vm.ZoomState.Y - boxY) >= 100, "the box must have moved");
+ Assert.Equal(captureX, vm.ZoomState.CaptureX);
+ Assert.Equal(captureY, vm.ZoomState.CaptureY);
+ var zoom = Assert.IsType(vm.StagedScene.Elements[1]);
+ Assert.Equal(vm.ZoomState.X, zoom.X, 3);
+ Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
+ Assert.Equal(captureX, zoom.CaptureX, 3);
+ Assert.Equal(captureY, zoom.CaptureY, 3);
+
+ // Clamp keeps the box AND the capture fully inside the 1920x1080 master
+ // even for a corner click that would otherwise overhang both.
vm.ActivateZoom(2000, 2000);
const double frameW = 1920, frameH = 1080;
Assert.True(vm.ZoomState.X >= 0 && vm.ZoomState.Y >= 0);
Assert.True(vm.ZoomState.X + vm.ZoomState.Width <= frameW);
Assert.True(vm.ZoomState.Y + vm.ZoomState.Height <= frameH);
+ Assert.True(vm.ZoomState.CaptureX >= 0 && vm.ZoomState.CaptureY >= 0);
+ Assert.True(vm.ZoomState.CaptureX + vm.ZoomState.Width / 2 <= frameW);
+ Assert.True(vm.ZoomState.CaptureY + vm.ZoomState.Height / 2 <= frameH);
AssertTrueActiveLayer(vm);
// The box is a composited layer, NEVER the crop: output stays the tier.
@@ -137,6 +174,8 @@ public sealed class ZoomBoxInteractionTests
Assert.Equal(vm.ZoomState.Y, zoom.Y, 3);
Assert.Equal(vm.ZoomState.Width, zoom.Width, 3);
Assert.Equal(vm.ZoomState.Height, zoom.Height, 3);
+ Assert.Equal(vm.ZoomState.CaptureX, zoom.CaptureX, 3);
+ Assert.Equal(vm.ZoomState.CaptureY, zoom.CaptureY, 3);
Assert.Equal(vm.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
}
}
\ No newline at end of file
diff --git a/ytLive.Tests/ZoomFrameBuilderTests.cs b/ytLive.Tests/ZoomFrameBuilderTests.cs
deleted file mode 100644
index c6835a1..0000000
--- a/ytLive.Tests/ZoomFrameBuilderTests.cs
+++ /dev/null
@@ -1,83 +0,0 @@
-using System;
-using Xunit;
-using ytLive.Services;
-using ytLive.ViewModels;
-
-namespace ytLive.Tests;
-
-///
-/// Proves the zoom layer's pixels are the GENUINE 2x magnification of the
-/// backdrop: the source window is the box/2 interior centred on the box centre
-/// (the same region as the preview's VisualBrush), with the static art composited
-/// over the live capture exactly as the SceneCompositor draws the backdrop. This
-/// is the "don't ship a magnifier you only think exists" proof — a 1x box, a
-/// mis-positioned window, or a wrong backdrop layer all fail hard below.
-///
-public sealed class ZoomFrameBuilderTests
-{
- private static VideoFrame BandedBackdrop(int bandW, int bandCount)
- {
- var w = bandW * bandCount;
- const int h = 1080;
- var buf = new byte[w * h * 4];
- var bandColors = new (byte B, byte G, byte R)[]
- {
- (10, 20, 30), // band 0
- (0, 0, 255), // band 1 red
- (0, 255, 0), // band 2 green — the box window lands here
- (255, 0, 0), // band 3 blue
- };
- for (var y = 0; y < h; y++)
- {
- for (var x = 0; x < w; x++)
- {
- var (b, g, r) = bandColors[Math.Min(bandCount - 1, x / bandW)];
- var i = (y * w + x) * 4;
- buf[i] = b;
- buf[i + 1] = g;
- buf[i + 2] = r;
- buf[i + 3] = 255;
- }
- }
- return new VideoFrame(w, h, buf);
- }
-
- private static VideoFrame SolidFrame(int w, int h, byte b, byte g, byte r)
- {
- var buf = new byte[w * h * 4];
- for (var i = 0; i < w * h; i++)
- {
- buf[i * 4] = b;
- buf[i * 4 + 1] = g;
- buf[i * 4 + 2] = r;
- buf[i * 4 + 3] = 255;
- }
- return new VideoFrame(w, h, buf);
- }
-
- [Fact]
- public void Build_Produces_2x_Centered_ArtOverLive_Magnification()
- {
- var art = BandedBackdrop(480, 4); // [0,480) 1/4 dim, [480,960) red, [960,1440) green, [1440,1920) blue
- var live = SolidFrame(1920, 1080, 200, 60, 60); // magenta-ish — must never show through
-
- // Box [840,1320)x[100,580). 2x window = [960,1200)x[160,520) = band 2, all green.
- // A 1x (broken) magnifier samples [840,1320) → red+green+blue mixed.
- var zoom = ZoomFrameBuilder.Build(live, art, 840, 100, 480, 480, 1920, 1080);
-
- Assert.NotNull(zoom);
- Assert.Equal(480, zoom.Width);
- Assert.Equal(480, zoom.Height);
- for (var y = 0; y < zoom.Height; y++)
- {
- for (var x = 0; x < zoom.Width; x++)
- {
- var i = (y * zoom.Width + x) * 4;
- Assert.Equal(0, zoom.BgraPixels[i]); // B
- Assert.Equal(255, zoom.BgraPixels[i + 1]); // G
- Assert.Equal(0, zoom.BgraPixels[i + 2]); // R
- Assert.Equal(255, zoom.BgraPixels[i + 3]); // A
- }
- }
- }
-}
\ No newline at end of file
diff --git a/ytLive.Tests/ZoomMagnifierTests.cs b/ytLive.Tests/ZoomMagnifierTests.cs
new file mode 100644
index 0000000..f7fdb7f
--- /dev/null
+++ b/ytLive.Tests/ZoomMagnifierTests.cs
@@ -0,0 +1,118 @@
+using System;
+using Xunit;
+using ytLive.Services.Compositor;
+
+namespace ytLive.Tests;
+
+///
+/// Proves the zoom box's pixels match the creator-corrected model (2026-10-08):
+/// the box is a GENUINE 2x magnified view of a FIXED capture window (the box/2
+/// region centred on the click) sampled from the ALREADY-COMPOSITED frame — i.e.
+/// every layer, and NOT what happens to sit under the box. The money test below
+/// puts the box over the blue half of the frame while its capture sits in the
+/// green half (with a red webcam square + a white 2px stripe inside): a 1x
+/// loupe, a box-following loupe, or a backdrop-only sample all fail hard.
+///
+public sealed class ZoomMagnifierTests
+{
+ private const int W = 1920;
+ private const int H = 1080;
+
+ /// 1920x1080 opaque "composited scene": blue left half (x<960),
+ /// green right half, plus a red 40x40 webcam square and a white 2px stripe
+ /// inside the capture region — the extra "layers" the box must capture.
+ private static byte[] ComposedFrame()
+ {
+ var buf = new byte[W * H * 4];
+ for (var y = 0; y < H; y++)
+ {
+ for (var x = 0; x < W; x++)
+ {
+ var i = (y * W + x) * 4;
+ buf[i] = 0; buf[i + 1] = 0; buf[i + 2] = 0; buf[i + 3] = 255; // black
+ if (x >= 960) { buf[i] = 0; buf[i + 1] = 255; buf[i + 2] = 0; } // green
+ else { buf[i] = 255; buf[i + 1] = 0; buf[i + 2] = 0; } // blue
+ if (x >= 1000 && x < 1040 && y >= 520 && y < 560) { buf[i] = 0; buf[i + 1] = 0; buf[i + 2] = 255; } // red webcam square
+ if (x >= 1100 && x < 1102) { buf[i] = 255; buf[i + 1] = 255; buf[i + 2] = 255; } // white stripe
+ }
+ }
+ return buf;
+ }
+
+ private static byte[] Pixel(byte[] buf, int x, int y)
+ {
+ var i = (y * W + x) * 4;
+ return new[] { buf[i], buf[i + 1], buf[i + 2] };
+ }
+
+ private static void AssertColor(byte[] buf, int x, int y, byte b, byte g, byte r)
+ {
+ var p = Pixel(buf, x, y);
+ Assert.Equal(new[] { b, g, r }, p);
+ }
+
+ [Fact]
+ public void Magnify_BoxOverBlue_ShowsFixedGreenCapture_WithAllLayers_At2x()
+ {
+ var buf = ComposedFrame();
+
+ // Click at (1080, 540). Box 400x200 centred on the click → box [880,1280)x[440,640).
+ // Capture = box/2 → [980,1180)x[490,590), entirely in the GREEN half and holding
+ // the red webcam square [1000,1040)x[520,560) and the white stripe [1100,1102).
+ ZoomMagnifier.MagnifyInto(buf, W, H,
+ boxX: 880, boxY: 440, boxW: 400, boxH: 200,
+ capX: 980, capY: 490, round: false);
+
+ // The whole box shows the CAPTURE (green + its layer content), not the frame
+ // beneath it — the box's left quarter sits over the BLUE half. Any blue inside
+ // the box means the sampler followed the box (the loupe bug). Green/red/white
+ // are all fine (edges blend at the bright stripes), blue (B≫G) is the crime.
+ for (var y = 440; y < 640; y++)
+ {
+ for (var x = 880; x < 1280; x++)
+ {
+ var p = Pixel(buf, x, y);
+ Assert.True(p[1] >= p[0],
+ $"box pixel ({x},{y}) = ({p[0]},{p[1]},{p[2]}) — blue means the sampler followed the box, never the capture");
+ }
+ }
+
+ // The red webcam square is inside the capture → it is captured (all layers!).
+ // Capture-local [20,60)x[30,70) maps 2x to box [920,1000)x[500,580).
+ for (var x = 925; x < 995; x++)
+ for (var y = 505; y < 575; y++)
+ AssertColor(buf, x, y, 0, 0, 255);
+ AssertColor(buf, 1010, 570, 0, 255, 0); // past the square → green
+
+ // The 2x proof: the 2px white stripe at capture [1100,1102) must land at box
+ // [1120,1121] (offset*2) — a 1x sample would leave it near capture space and a
+ // box-following sample would miss it entirely. Probe the interior (solid white)
+ // and either side (solid green), skipping bilinear edge blends.
+ AssertColor(buf, 1120, 540, 255, 255, 255);
+ AssertColor(buf, 1121, 540, 255, 255, 255);
+ AssertColor(buf, 1115, 540, 0, 255, 0);
+ AssertColor(buf, 1124, 540, 0, 255, 0);
+
+ // Outside the box the frame is untouched.
+ AssertColor(buf, 400, 540, 255, 0, 0); // still blue (left half)
+ AssertColor(buf, 1500, 540, 0, 255, 0); // still green
+ }
+
+ [Fact]
+ public void Magnify_Round_Clips_To_Circle_Leaving_Corners_Untouched()
+ {
+ var buf = ComposedFrame();
+
+ // Same geometry as the money test but circle: radius = min(400,200)/2 = 100.
+ ZoomMagnifier.MagnifyInto(buf, W, H,
+ boxX: 880, boxY: 440, boxW: 400, boxH: 200,
+ capX: 980, capY: 490, round: true);
+
+ // Box corners are outside the circle: the underlying blue frame shows through.
+ AssertColor(buf, 884, 444, 255, 0, 0);
+ AssertColor(buf, 884, 636, 255, 0, 0);
+ AssertColor(buf, 1276, 444, 0, 255, 0); // right side over green stays green
+ // Box centre is inside the circle: captured green.
+ AssertColor(buf, 1080, 540, 0, 255, 0);
+ }
+}
\ No newline at end of file