Zoom: anchor the capture, magnify the full composite, draw on top (Unit: zoom rework per creator correction)

Creator corrected the "loupe" zoom on 2026-10-08: the click anchors a fixed
capture region (box/2 centred on the click) and the box is a live 2x view of
that region — dragging the box never changes its content; it magnifies the
fully-composited frame (every layer, incl. webcam) and draws LAST (n+1).
Rectangle shape is 4:3 ("more of a 640x480 aspect").

- MainViewModel.Zoom.cs rewritten: ActivateZoom anchors CaptureX/CaptureY
  (independently clamped); UpdateZoomMove relocates the box only; the per-frame
  BuildZoomFrame/ZoomFrameBuilder path is gone.
- SceneCompositor: skips the active ZoomSceneElement in every layer loop and
  BlitZooms it last via the new pure ZoomMagnifier (2x bilinear, temp snapshot
  via ArrayPool, round clip).
- Preview: ZoomBox hoisted above CanvasGrid as a sibling; its brush samples the
  whole composed preview at the fixed capture (2x).
- Tests: ZoomMagnifierTests (box-over-blue shows green capture at 2x incl. an
  extra red "layer" — the loupe/backdrop-only regressions both fail it) +
  round-clip; ZoomBoxInteractionTests rewritten (anchored capture, move-doesn't-
  change-capture, 4:3/9:16/square). 404 pass, 2 known environment flakes.
This commit is contained in:
2026-10-08 08:26:29 -07:00
parent 4b3d82ff0f
commit 6bff21b234
15 changed files with 467 additions and 330 deletions
+16 -8
View File
@@ -3,12 +3,15 @@ using System.Windows.Media;
namespace ytLive.Models;
/// <summary>
/// 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.
/// </summary>
public sealed class ZoomSceneElement : SceneElement
{
@@ -16,13 +19,18 @@ public sealed class ZoomSceneElement : SceneElement
/// the baked static base and composites per-frame.</summary>
public bool IsZoomActive { get; set; }
/// <summary>Master-space top-left of the fixed capture window. Set once by
/// <c>ActivateZoom</c>; moving the box must never touch it.</summary>
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;
/// <summary>Never rendered by the element template — the preview magnifier
/// (ZoomBox) and the output resolver own its pixels.</summary>
/// <summary>Never rendered by the element template or the resolver — the preview
/// magnifier (ZoomBox) and the output compositor's zoom pass own its pixels.</summary>
public override ImageSource? DisplaySource => null;
}
+7
View File
@@ -9,6 +9,13 @@ public class ZoomState
public double Y { get; set; }
public double Width { get; set; }
public double Height { get; set; }
/// <summary>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).</summary>
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; }
+1 -1
View File
@@ -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:<n>`/`window:<hwnd>`/`picker:<name>`), `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 |