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 @@ - - - - - - - - @@ -422,6 +409,23 @@ + + + + + + + + + -/// 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