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
+19 -15
View File
@@ -63,8 +63,8 @@
</ContextMenu>
</Grid.ContextMenu>
<!-- Live desktop/game background (bottom layer; the static
image renders above it). The zoom magnifier samples
this layer — see UpdateZoomBox (TASK 51). -->
image renders above it). The zoom box samples the
whole CanvasGrid, not just this layer (TASK 51). -->
<Grid x:Name="BackdropLayer" IsHitTestVisible="False">
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
@@ -72,19 +72,6 @@
<Rectangle Stroke="#22c55e" StrokeThickness="6"/>
</Grid>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<!-- The zoom box renders below every element layer so the
preview shows exactly what the output composites (the
zoom is a real layer at z-index 1, not an overlay). -->
<Grid x:Name="ZoomBox" IsHitTestVisible="False"
Canvas.Left="{Binding ZoomState.X}" Canvas.Top="{Binding ZoomState.Y}"
Width="{Binding ZoomState.Width}" Height="{Binding ZoomState.Height}"
Visibility="{Binding IsZoomActive, Converter={StaticResource BoolToVis}}">
<Rectangle x:Name="ZoomRectOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Grid.Background>
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute" Stretch="Fill"/>
</Grid.Background>
</Grid>
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
Width="1920" Height="1080">
<ItemsControl.ItemsPanel>
@@ -422,6 +409,23 @@
</Grid>
</Canvas>
</Grid>
<!-- The zoom box: layer n+1 — a SIBLING of CanvasGrid (not a
descendant), so its VisualBrush can sample the whole
composed preview (CanvasGrid = backdrop + every element
layer) without a circular reference, and it renders ABOVE
everything. It shows a fixed capture window (box/2 at the
click); dragging the box relocates it, never the capture. -->
<Grid x:Name="ZoomBox" IsHitTestVisible="False"
Canvas.Left="{Binding ZoomState.X}" Canvas.Top="{Binding ZoomState.Y}"
Width="{Binding ZoomState.Width}" Height="{Binding ZoomState.Height}"
Visibility="{Binding IsZoomActive, Converter={StaticResource BoolToVis}}">
<Rectangle x:Name="ZoomRectOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent"/>
<Ellipse x:Name="ZoomCircleOutline" Stroke="#d8a72b" StrokeThickness="3" Fill="Transparent" Visibility="Collapsed"/>
<Grid.Background>
<VisualBrush x:Name="ZoomVisualBrush" ViewboxUnits="Absolute" Stretch="Fill"/>
</Grid.Background>
</Grid>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
+9 -7
View File
@@ -553,13 +553,15 @@ public partial class PreviewPane : UserControl
if (DataContext is not MainViewModel vm) return;
if (vm.IsZoomActive && ZoomVisualBrush != null)
{
// The preview mirrors the output zoom layer: sample the backdrop layer
// (the live capture + art, exactly what the compositor magnifies) at the
// box/2 interior centered on the box center → 2x on the screen.
ZoomVisualBrush.Visual = BackdropLayer;
// The preview mirrors the output zoom layer: sample the WHOLE composed
// preview (CanvasGrid = backdrop + every element layer — the same all-layer
// source the output compositor magnifies) at the FIXED capture window (the
// box/2 region centred on the click) → 2x via Stretch=Fill. Moving the box
// only moves the ZoomBox element; the Viewbox stays glued to the capture.
ZoomVisualBrush.Visual = CanvasGrid;
ZoomVisualBrush.Viewbox = new Rect(
vm.ZoomState.X + vm.ZoomState.Width / 4,
vm.ZoomState.Y + vm.ZoomState.Height / 4,
vm.ZoomState.CaptureX,
vm.ZoomState.CaptureY,
vm.ZoomState.Width / 2,
vm.ZoomState.Height / 2);
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
@@ -576,7 +578,7 @@ public partial class PreviewPane : UserControl
ZoomBox.Clip = null;
}
}
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, BackdropLayer))
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
{
ZoomVisualBrush.Visual = null;
}
+44 -19
View File
@@ -1,4 +1,4 @@
# HANDOFF — 2026-10-08
# HANDOFF — 2026-10-08 (zoom rework)
## Branch
- main (no feature branch)
@@ -6,29 +6,54 @@
## Dirty files (NOT mine — the in-flight "pinned chat" work unit; do NOT commit or co-edit)
- `Models/YouTube.cs`
- `ViewModels/MainViewModel.Chat.cs`
- `Controls/LeftPanel.xaml` (pinned-message UI; restored via `git stash pop` after this commit)
- `Controls/LeftPanel.xaml` (pinned-message UI)
- Untracked: `Helpers/RelayCommandGeneric.cs`
## What just happened (2026-10-08 zoom build)
- **TASK 51 booked (webcam background Remove + Blur as a v1 requirement):** `TASKS/task-51-webcam-bg-effects.md` created; TASKS.md row 51 + the two prior "background removal excluded" rulings (`~line 180` webcam cadence + "Out of product — permanently") reversed 2026-10-08. NVIDIA VFX SDK stays ruled out. Implementation queued after the zoom build.
- **Zoom rebuilt from "loupe + output crop" into a REAL composited layer:**
- New `Models/ZoomSceneElement.cs` (z-index 1 above the background, below every other layer; `IsZoomActive` flips `Kind` Static→Dynamic; never persisted, never shown in the layers list or element template). `SceneElement.IsZoomLayer` virtual added.
- New `ViewModels/MainViewModel.Zoom.cs`: `ActivateZoom` (all shapes CENTERED on the click, clamped to the master), move/dismiss, `HealZoomLayers`/`EnsureZoomLayer` (load + `AddScene`), `BuildZoomFrame` (resolves live-capture-under-art exactly like `SceneCompositor.Render`, single-slot cache keyed on frame identity+Epoch+box ints so the per-tick resolver allocates nothing idle), and the pure static `ZoomFrameBuilder.Build` (2x bilinear sample of the box/2 window centered on the box centre).
- `BuildCompositorOptions` now ALWAYS returns the tier crop (internal = test seam); zoom-as-crop branch + `RebuildDimRectsForZoom` old version removed. `ResolveOutputFrameCore` gained the `ZoomSceneElement` case; output composites the zoom like any layer.
- Preview: backdrop images wrapped in `BackdropLayer`; `ZoomBox` moved INSIDE `OverlayCanvas` under the element Layers (renders below layers = honest preview); `ZoomVisualBrush` now samples `BackdropLayer` at `(X+W/4, Y+H/4, W/2, H/2)` + `Stretch=Fill` (2x); element template + LeftPanel list collapse `IsZoomLayer`.
- Tests: `ZoomBoxInteractionTests` rewritten (center-anchor for all shapes, placeholder at Elements[1], Kind flips, tier crop asserted, dismiss resets); new `ZoomFrameBuilderTests` = the genuine-magnification proof (4-band backdrop, box straddling two bands, all output pixels must be the center band → a 1x or mis-positioned window fails hard). `LayerReorderPersistenceTests` updated: cross-type reload compares persisted elements only (the zoom placeholder never persists), and the real-mouse drag targets rows by ITEM (the collapsed zoom row shifts container indices — the handlers hit-test visually so real dragging is unaffected).
- Full suite: **405 passed / 0 failed / 0 warnings**.
## What just happened (2026-10-08, second zoom pass — creator correction)
The zoom box shipped in `4b3d82f` was a **loupe** (box follows its own content, built from the
backdrop only, slotted at z-index 1). The creator corrected it; the model is now:
- **Anchored capture, not a loupe.** Right-clicking the live preview fixes a **capture window** —
the box/2 region centred on the click (`CaptureX/Y`, clamped to the master) — and parks a movable
**2x magnifier** box. The box is a **live, always-on 2x view of that captured region**; dragging the
box relocates it, **`UpdateZoomMove` never touches the capture** (creator ruling).
- **Content = every layer.** The zoom pass runs on the **already-composited frame** (backdrop + all
elements), so the magnified webcam/square/layers all show in the box. `SceneCompositor` skips the
active `ZoomSceneElement` in every layer loop and `BlitZoom`s it **LAST (layer n+1, on top of
everything)** via the pure static `ZoomMagnifier` (2x bilinear, 8.8 fixed-point, temp snapshot via
`ArrayPool` so a box overlapping its own source never recurses, optional round clip).
- **Rectangle = 4:3** ("more of a 640x480 aspect ratio"); Circle = square, Phone = 9:16 (kept).
Changes in this pass: new `Services/Compositor/ZoomMagnifier.cs`; `ZoomSceneElement`/`ZoomState`
gained `CaptureX`/`CaptureY`; `MainViewModel.Zoom.cs` rewritten (`BuildZoomFrame`/`ZoomFrameBuilder`
and the zoom-as-crop resolver case removed — no per-frame builder/cache anymore);
`PreviewPane.xaml`: `ZoomBox` hoisted OUT of `OverlayCanvas` to be a **sibling of `CanvasGrid`**
(renders above everything in the preview); `PreviewPane.xaml.cs` `UpdateZoomBox` samples `CanvasGrid`
(the whole composed preview) at the fixed capture, `Stretch=Fill`. `ZoomFrameBuilderTests.cs` deleted
(superseded); new `ZoomMagnifierTests.cs` (the "box over blue shows green capture, at 2x, all layers"
proof + round-clip); `ZoomBoxInteractionTests.cs` rewritten (per-shape 4:3/9:16/square + capture
anchor centred on the click + move-moves-only-the-box assertion + corner-clamp).
## Zoom feature state
- Models: `ZoomState.cs`, `ZoomShape.cs` (Circle/VerticalRectangle/HorizontalRectangle), `ZoomSceneElement.cs` (TASK 51 build).
- ViewModel (all in `MainViewModel.Zoom.cs`): activation/move/dismiss + layer healing + `BuildZoomFrame`; `MainViewModel.cs` core no longer holds zoom methods.
- Output: zoom layer composited at z-index 1; output crop = tier; dim rects show the box frame while active.
- Preview: `ZoomBox` samples `BackdropLayer` at 2x, circle→ellipse clip, rect/square outline per shape; `LeftPanel` hides the placeholder row via `IsZoomLayer`.
- Models: `ZoomState.cs`, `ZoomShape.cs` (Circle/VerticalRectangle/HorizontalRectangle), `ZoomSceneElement.cs`.
- ViewModel (`MainViewModel.Zoom.cs`): `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RebuildDimRectsForZoom`; transient element healed/mirrored by `HealZoomLayers`/`EnsureZoomLayer`.
- Output: box composited last via `BlitZoom`/`ZoomMagnifier`; output crop = tier (box never the crop).
- Preview: `ZoomBox` = sibling above `CanvasGrid`, samples the full composite at the fixed capture, 2x; circle→ellipse clip, rect/square outline per shape; `LeftPanel` hides the placeholder row via `IsZoomLayer`.
## Tests
- Full suite: **404 passed / 2 failed** (both = known pre-existing environment flakes, unrelated to
zoom): `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (audio timing)
and `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` (needs
interactive desktop). Zoom tests 3/3 green, 0 build warnings.
## Known issues / follow-ups
- **Background "Use default background" toggle still not working** (carried from 2026-10-07): binding exists (`UseDefaultBackground`), VM handler invalidates bake; investigate Source background logic when time permits.
- `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` flaked once in a full-suite run (passed in isolation 27/27 and in the final full run) — treat as machine-contention flake, not a zoom regression.
- `RealMouseDrag_OnTheLayerList_PersistsTheReorder` needs an interactive desktop (documented) — it passes when the window is live; don't read a failure there as a code bug without re-running.
- **Background "Use default background" toggle still not working** (carried from 2026-10-07):
binding exists (`UseDefaultBackground`), VM handler invalidates bake; investigate when time permits.
- Preview shows editing chrome (dim rects, selection) inside the zoom box — accepted preview
artefact; the output only shows composed content.
## Next step
- User runs the app live/test to eyeball the zoom: right-click preview → centered box per shape, output contains a genuine 2x magnified backdrop under the other layers, dimming + outline correct, layers list unchanged. Then TASK 51 (webcam background Remove + Blur) implementation per `TASKS/task-51-webcam-bg-effects.md`, and the background-toggle investigation if still open.
- User runs the app live to eyeball the corrected zoom: right-click preview → anchored capture stays
fixed while the box is dragged; the box shows ALL layers at 2x on top of everything; rectangle box
is 4:3. Then TASK 51 (webcam background Remove + Blur) per `TASKS/task-51-webcam-bg-effects.md`,
and the background-toggle investigation if still open.
+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 |
+19
View File
@@ -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.
+33
View File
@@ -157,6 +157,7 @@ public sealed class SceneCompositor
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var fullFrame = frameFor(element);
@@ -175,6 +176,7 @@ public sealed class SceneCompositor
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var frame = frameFor(element);
@@ -191,6 +193,14 @@ public sealed class SceneCompositor
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
// The zoom box is layer n+1: a live 2x view of a FIXED capture window sampled
// from the frame just composited (all layers). Drawn LAST — above everything.
// The box/2 capture is anchored at the click, so dragging the box only moves
// the box; the content it shows never changes (creator ruling 2026-10-08).
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
BlitZoom(buffer, cropW, cropH, options, zoom);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
@@ -249,6 +259,7 @@ public sealed class SceneCompositor
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement:
continue;
}
var frame = frameFor(element);
@@ -299,6 +310,7 @@ public sealed class SceneCompositor
case Source { IsBackground: true }:
case Source { Type: SourceType.Background }:
case Source { Type: SourceType.TextOverlay }:
case ZoomSceneElement { IsZoomActive: true }:
continue;
}
var frame = frameFor(element);
@@ -309,6 +321,10 @@ public sealed class SceneCompositor
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
var zoom = scene.Elements.OfType<ZoomSceneElement>().FirstOrDefault(z => z.IsZoomActive);
if (zoom != null)
BlitZoom(buffer, cropW, cropH, options, zoom);
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
@@ -834,6 +850,23 @@ public sealed class SceneCompositor
});
}
/// <summary>
/// The zoom box layer (n+1): map the element's master-space box and capture to
/// crop space and 2x-magnify the capture onto the box from the composed frame.
/// The active zoom element is never blitted by the normal layer loops — the
/// switch skips it and this pass draws it above everything, last.
/// </summary>
private static void BlitZoom(
byte[] dst, int cropW, int cropH, CompositorOptions options, ZoomSceneElement zoom)
{
ZoomMagnifier.MagnifyInto(
dst, cropW, cropH,
zoom.X - options.SourceRectX, zoom.Y - options.SourceRectY,
zoom.Width, zoom.Height,
zoom.CaptureX - options.SourceRectX, zoom.CaptureY - options.SourceRectY,
zoom.ClipShape == ClipShape.Round);
}
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
private static void BlendPixel(byte[] dst, int di, (byte B, byte G, byte R, byte A) src, float opacity)
{
+116
View File
@@ -0,0 +1,116 @@
using System;
using System.Buffers;
namespace ytLive.Services.Compositor;
/// <summary>
/// The zoom box's magnifier (creator-corrected model, 2026-10-08): the box is a
/// detached 2x view of a FIXED capture window — the box/2 region centred on the
/// spot the creator clicked — sampled from the ALREADY-COMPOSITED frame (every
/// layer: live backdrop, art, webcam, sources, alerts). Moving the box relocates
/// it on screen without changing the capture; the box is painted LAST (layer n+1)
/// so it sits above everything. The capture snapshot is taken before the box is
/// drawn, so a box overlapping its own source never recurses.
/// </summary>
internal static class ZoomMagnifier
{
/// <summary>
/// Magnify a box/2 capture window onto a box/2&#x2009;² box in <paramref name="dst"/>
/// (an opaque, already-composited crop-space buffer). dst pixel centres map to
/// capture coords as <c>cap + (dstLocal + 0.5) / 2</c> — the exact 2x the preview's
/// VisualBrush shows via a box/2 Viewbox with Stretch=Fill. <paramref name="round"/>
/// clips the box to a true circle (zoom shape Circle).
/// </summary>
internal static void MagnifyInto(
byte[] dst, int dstW, int dstH,
double boxX, double boxY, double boxW, double boxH,
double capX, double capY,
bool round)
{
if (boxW <= 0 || boxH <= 0) return;
if (dstW <= 0 || dstH <= 0) return;
// Integer box draw bounds (clamped to the buffer).
var bx0 = Math.Max(0, (int)Math.Floor(boxX));
var by0 = Math.Max(0, (int)Math.Floor(boxY));
var bx1 = Math.Min(dstW, (int)Math.Ceiling(boxX + boxW));
var by1 = Math.Min(dstH, (int)Math.Ceiling(boxY + boxH));
if (bx0 >= bx1 || by0 >= by1) return;
var bw = bx1 - bx0;
var bh = by1 - by0;
// Integer capture-window read bounds (the 2x window is box/2, clamped).
var rx0 = Math.Max(0, (int)Math.Floor(capX));
var ry0 = Math.Max(0, (int)Math.Floor(capY));
var rx1 = Math.Min(dstW, (int)Math.Ceiling(capX + boxW / 2.0));
var ry1 = Math.Min(dstH, (int)Math.Ceiling(capY + boxH / 2.0));
var rw = rx1 - rx0;
var rh = ry1 - ry0;
if (rw <= 0 || rh <= 0) return;
// Snapshot the capture first: the box may overlap its own source region.
var src = ArrayPool<byte>.Shared.Rent(rw * rh * 4);
try
{
for (var y = 0; y < rh; y++)
Buffer.BlockCopy(dst, ((ry0 + y) * dstW + rx0) * 4, src, y * rw * 4, rw * 4);
var radius = Math.Min(boxW, boxH) / 2.0;
var ccx = boxX + boxW / 2.0;
var ccy = boxY + boxH / 2.0;
for (var y = 0; y < bh; y++)
{
var dy = by0 + y;
var sy = capY + (dy + 0.5 - boxY) * 0.5 - ry0;
if (sy < 0.0) sy = 0.0;
if (sy > rh - 1.0) sy = rh - 1.0;
var syi = (int)sy;
var syInv = Math.Min(syi + 1, rh - 1);
var fy8 = (int)((sy - syi) * 256);
var fyInv = 256 - fy8;
var di = (dy * dstW + bx0) * 4;
for (var x = 0; x < bw; x++, di += 4)
{
var dx = bx0 + x;
if (round)
{
var ox = dx - ccx;
var oy = dy - ccy;
if (ox * ox + oy * oy > radius * radius) continue;
}
var sx = capX + (dx + 0.5 - boxX) * 0.5 - rx0;
if (sx < 0.0) sx = 0.0;
if (sx > rw - 1.0) sx = rw - 1.0;
var sxi = (int)sx;
var sxInv = Math.Min(sxi + 1, rw - 1);
var fx8 = (int)((sx - sxi) * 256);
var fxInv = 256 - fx8;
var p00 = (syi * rw + sxi) * 4;
var p10 = (syi * rw + sxInv) * 4;
var p01 = (syInv * rw + sxi) * 4;
var p11 = (syInv * rw + sxInv) * 4;
dst[di] = (byte)Bilinear(src[p00], src[p10], src[p01], src[p11], fx8, fxInv, fy8, fyInv);
dst[di + 1] = (byte)Bilinear(src[p00 + 1], src[p10 + 1], src[p01 + 1], src[p11 + 1], fx8, fxInv, fy8, fyInv);
dst[di + 2] = (byte)Bilinear(src[p00 + 2], src[p10 + 2], src[p01 + 2], src[p11 + 2], fx8, fxInv, fy8, fyInv);
dst[di + 3] = 255;
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(src);
}
}
/// <summary>Two-stage bilinear in 8.8 fixed point (same precision as
/// <see cref="SceneCompositor"/>'s sampler).</summary>
private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv)
{
var top = tl * fxInv + tr * fx8;
var bot = bl * fxInv + br * fx8;
return (top * fyInv + bot * fy8 + 32768) >> 16;
}
}
+32 -182
View File
@@ -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;
/// <summary>
/// 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 <see cref="ZoomSceneElement"/> 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 <see cref="Services.Compositor.SceneCompositor"/> 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
/// <see cref="ZoomSceneElement"/> carries the box placement + capture anchor; the
/// output crop stays the normal tier (the box is NEVER the crop source).
/// </summary>
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
}
/// <summary>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.</summary>
/// 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.</summary>
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<Source>().FirstOrDefault(s => s.IsBackground);
var liveFrame = live is { IsVisible: true } ? ResolveOutputFrameCore(live) : null;
var art = scene.Elements.OfType<Source>().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;
}
}
/// <summary>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.</summary>
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);
}
/// <summary>Inverse of the compositor's full-frame backdrop blit: maps a master
/// pixel back through <see cref="StretchMath.UniformToFill"/> (or the frame's
/// CropBounds) into the source canvas and bilinear-samples it. Fixed-point
/// bilinear, same precision as SceneCompositor.Bilinear.</summary>
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;
}
}
-1
View File
@@ -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,
};
}
+1 -1
View File
@@ -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`. |
+52 -13
View File
@@ -12,13 +12,15 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 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).
/// </summary>
[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<ZoomSceneElement>(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);
}
}
-83
View File
@@ -1,83 +0,0 @@
using System;
using Xunit;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
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
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
using System;
using Xunit;
using ytLive.Services.Compositor;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
public sealed class ZoomMagnifierTests
{
private const int W = 1920;
private const int H = 1080;
/// <summary>1920x1080 opaque "composited scene": blue left half (x&lt;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.</summary>
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);
}
}