Zoom: compose the box as a real 2x layer above the background (Unit: zoom layer + magnification proof)

Right-click now creates a transient ZoomSceneElement at z-index 1 whose pixels are a
genuine 2x magnified sample of the backdrop (live capture under art), composited into
stream/recording like any layer while the output crop stays the tier. All shapes are
centered on the click and clamped to frame; healed on load/scene-create, never persisted,
hidden from the layers list + element template (IsZoomLayer). BuildCompositorOptions is
now internal = always tier crop (test seam). ZoomFrameBuilder is a pure static sampler
(box/2 window centered on the box center, single-slot cache) proven by a banded-backdrop
test; LayerReorderPersistenceTests drag resolves rows by item since the collapsed zoom
row shifts container indices. Also books TASK 51 (webcam background Remove + Blur) and
syncs memory docs (HANDOFF, MyMistakes, indexes). Full suite: 405 passed, 0 warnings.
This commit is contained in:
2026-10-07 23:39:54 -07:00
parent 966e718f1e
commit 4b3d82ff0f
17 changed files with 734 additions and 210 deletions
+5
View File
@@ -65,6 +65,11 @@
<DataTrigger Binding="{Binding IsVisible}" Value="False">
<Setter Property="Opacity" Value="0.45"/>
</DataTrigger>
<!-- The transient zoom layer has no editing UI — never
show it as a layer row (TASK 51). -->
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Helpers:FocusPreservingListBox.ItemContainerStyle>
+27 -17
View File
@@ -63,14 +63,28 @@
</ContextMenu>
</Grid.ContextMenu>
<!-- Live desktop/game background (bottom layer; the static
image renders above it). -->
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"
IsHitTestVisible="False"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
image renders above it). The zoom magnifier samples
this layer — see UpdateZoomBox (TASK 51). -->
<Grid x:Name="BackdropLayer" IsHitTestVisible="False">
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
<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>
@@ -103,6 +117,12 @@
<DataTrigger Binding="{Binding IsBackground}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
</DataTrigger>
<!-- The zoom layer never renders here — the ZoomBox magnifier (and the
output resolver) own its pixels (TASK 51). -->
<DataTrigger Binding="{Binding IsZoomLayer}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
<Setter Property="IsHitTestVisible" Value="False"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Grid.Style>
@@ -402,16 +422,6 @@
</Grid>
</Canvas>
</Grid>
<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"/>
</Grid.Background>
</Grid>
</Canvas>
</Viewbox>
<Border HorizontalAlignment="Left" VerticalAlignment="Top" Margin="8,8,0,0"
+10 -3
View File
@@ -553,8 +553,15 @@ public partial class PreviewPane : UserControl
if (DataContext is not MainViewModel vm) return;
if (vm.IsZoomActive && ZoomVisualBrush != null)
{
ZoomVisualBrush.Visual = CanvasGrid;
ZoomVisualBrush.Viewbox = new Rect(vm.ZoomState.X, vm.ZoomState.Y, vm.ZoomState.Width, vm.ZoomState.Height);
// 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;
ZoomVisualBrush.Viewbox = new Rect(
vm.ZoomState.X + vm.ZoomState.Width / 4,
vm.ZoomState.Y + vm.ZoomState.Height / 4,
vm.ZoomState.Width / 2,
vm.ZoomState.Height / 2);
ZoomVisualBrush.ViewboxUnits = BrushMappingMode.Absolute;
if (vm.ZoomShape == Models.ZoomShape.Circle)
{
@@ -569,7 +576,7 @@ public partial class PreviewPane : UserControl
ZoomBox.Clip = null;
}
}
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, CanvasGrid))
else if (!vm.IsZoomActive && ZoomVisualBrush != null && ReferenceEquals(ZoomVisualBrush.Visual, BackdropLayer))
{
ZoomVisualBrush.Visual = null;
}
+23 -25
View File
@@ -1,36 +1,34 @@
# HANDOFF — 2026-10-07
# HANDOFF — 2026-10-08
## Branch
- main (no feature branch)
## Dirty files
- None (zoom box fix + test committed as a work unit this session).
## 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)
- Untracked: `Helpers/RelayCommandGeneric.cs`
## What just happened
- Fixed the zoom box: right-click in live preview now procs the selected Zoom View
Shape with correct anchoring and a visible, populated box (previously invisible —
empty OpacityMask made the whole box transparent and the VisualBrush pointed at the
CanvasGrid *ancestor*, a circular reference WPF renders as nothing).
- Zoom View Shape dropdown now uses the app's standard `YtComboBox` style.
- One integration test added (Good Dog Rule): `ZoomBoxInteractionTests` — circle is a
centered square, phone is a tall 9:16 rect and tablet a wide 16:9 rect, both anchored
top-left at the click, box stays inside 1920x1080 (clamp), and the UI shows the right
outline/clip per shape. Full suite: 404 passed, 0 warnings.
## 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**.
## Zoom feature state
- Models/ZoomState.cs + Models/ZoomShape.cs (Circle, VerticalRectangle, HorizontalRectangle)
- ViewModel: zoom box activation/move/dismiss; compositor output crops+scales the zoom region; dimming updates
- PreviewPane: zoom box is a *sibling* of CanvasGrid inside a new `PreviewCanvas` wrapper
(so the VisualBrush can sample CanvasGrid without a circular reference); shape-aware
outline (`ZoomRectOutline`/`ZoomCircleOutline`) + ellipse clip for Circle
- Shape semantics in `MainViewModel.ActivateZoom`: Circle = click point is center, w=h=min(maxW,maxH);
VerticalRectangle = 9:16 tall, HorizontalRectangle = 16:9 wide, both anchored top-left at the click; clamped to frame
- Settings (OverlayHost) Zoom View Shape persisted via LayoutStore
- 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`.
## Known issues / follow-ups
- Background toggle bug mentioned earlier: when clicking "Use default background" in layer properties, nothing happens. Need to investigate - binding exists (UseDefaultBackground), VM has handler that invalidates bake and updates. May need to look up Source background logic.
- **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.
## Next step
- User runs the app live/test to eyeball the zoom box visuals (border shows, region content visible,
circle is round, phone/tablet anchored at the click point). Then background "Use default background"
toggle if still needed. Otherwise stand down.
- 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.
+4
View File
@@ -60,6 +60,10 @@ public abstract class SceneElement : INotifyPropertyChanged
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
public virtual ElementKind Kind => ElementKind.Static;
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
public virtual bool IsZoomLayer => false;
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
+28
View File
@@ -0,0 +1,28 @@
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.
/// </summary>
public sealed class ZoomSceneElement : SceneElement
{
/// <summary>True only while a zoom box is live; flips Kind so the layer leaves
/// the baked static base and composites per-frame.</summary>
public bool IsZoomActive { 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>
public override ImageSource? DisplaySource => null;
}
+2 -1
View File
@@ -8,7 +8,8 @@ Plain data types. No logic beyond what a property can carry. See
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `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`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `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) |
| `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 |
+32
View File
@@ -1494,3 +1494,35 @@ box magnifies the already-composited preview.
Release the retained visual when the box hides (`VisualBrush.Visual = null`) or the long-lived
source visual stays pinned for the app lifetime.
## 2026-10-08 — Editing a file that carries ANOTHER work unit's uncommitted changes
LeftPanel.xaml was dirty with unrelated "pinned chat" work; the zoom build needed one DataTrigger
inserted into its ItemContainerStyle. Committing `git add Controls/LeftPanel.xaml` would have swept
the OTHER work unit's hunks into my commit — a scope-lock violation. The clean recipe:
```
git stash push -m my-wunit-isolate -- Controls/LeftPanel.xaml # park the OTHER work unit
# edit + build + test + commit my hunk (file is now only-mine vs HEAD)
git stash pop # restore the other work unit
```
Rules that make it safe: stash the WHOLE file (a per-hunk stash is a conflict factory), verify the
playback diff only touches regions I did not edit (`git show 'stash@{0}' -- <file>` before popping),
and check the other unit is preserved after the pop. Non-overlapping hunks pop cleanly; if a future
stash pop conflicts, resolve with the OTHER work unit winning.
## 2026-10-08 — The zoom window formula: source = box/2 centered on the BOX CENTER
First cut of `ZoomFrameBuilder` sampled the box's LEFT HALF (`mx = zx + (ox+.5)/2`), a silent
half-frame mis-focus — the preview's VisualBrush Viewbox is `(X+W/4, Y+H/4, W/2, H/2)`, i.e. the
half-width window centered on the box center. The output sampler must mirror it exactly:
```
mx = zx + W/4 + (ox + 0.5)/2 // ox over [0,W)
```
A one-constant difference (`W/4` vs nothing) is invisible in a real scene but fatal to a banded
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.
+10 -4
View File
@@ -66,6 +66,7 @@
| 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) |
| 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) |
| 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) |
| 51 | **Webcam background effects: Remove + Blur** | ☐ **v1 requirement** — booked 2026-10-08; queued behind the zoom build | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) |
---
@@ -177,7 +178,9 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
exclusive camera lock.** API probe already done — see HANDOFF.md, incl. the `SetFormatAsync`
dead end.
- ⛔ **No fallback-camera feature, ever** — *"that's bypassing the user's explicit choice."*
- ⛔ **No background removal** — re-affirmed out 2026-10-05.
- **Background removal + blur = v1 requirement (2026-10-08)** — reverses the 2026-10-05
exclusion. Bundled ONNX + onnxruntime, no downloads, no GPU requirement. Per-element
Remove/Blur + strength. See [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md).
- **TASK 40 — App Settings round** — plan saved 2026-09-22 (`TASKS/task-40-app-settings-round.md`),
four units, one Good Dog test each: **A** active web camera section under Recordings (Change…
sub-menu = live enumeration incl. vcams; Tune… config dialog via `ICameraControlProbe`, live-only
@@ -379,9 +382,12 @@ See [`TASKS/research-youtube-api.md`](TASKS/research-youtube-api.md) — authori
These were explicitly ruled out:
- **D3D11 swap compositor** (superseded by software compositor; TASK 3 item 16)
- **Background removal** (ONNX/DirectML; TASK 3 item 19) — ⛔ **re-affirmed out 2026-10-05**: *"no - not
doing it. the note is correct and accurate."* A vcam app does it free; an in-app model is a runtime,
a GPU/driver matrix, and per-scene lighting tuning. **Never reopen without a new ruling.**
- **Background removal + blur** (ONNX/DirectML; TASK 3 item 19) — ⛔ ruled out 2026-10-05
(*"no - not doing it... a vcam app does it free; an in-app model is a runtime, a GPU/driver
matrix, per-scene lighting tuning"*). **REVERSED 2026-10-08 — now a v1 requirement**
([`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md)): bundled
~2–5 MB ONNX model + onnxruntime, CPU-capable, no downloads, no NVIDIA. NVIDIA VFX SDK
(~1 GB per-client redistributable, RTX 2000+) remains ruled out.
- **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS)
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
+94
View File
@@ -0,0 +1,94 @@
# TASK 51 — Webcam background effects: Remove + Blur (v1 requirement)
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here.
> **Booked 2026-10-08 by creator ruling** — *"we're doing the bg removal and blur for v1 - book it, Danno"*.
> This **reverses** the 2026-10-05 "background removal: out of product" exclusion (TASK 3 item 19).
**Status:** ☐ Booked — **v1 requirement**. Queued behind the zoom build (current in-flight work unit).
**Goal:** per-webcam **Background Remove** and **Background Blur** on the live webcam layer,
selected per element in its properties panel, composited into stream + recording like any
other layer. No external downloads, no hardware requirement, works on any machine.
Replacement backgrounds are **free via layers** — drop any element behind the webcam.
---
## Why this is now IN (and what changed)
The 2026-10-05 exclusion was based on: an in-app AI model = a runtime, a GPU/driver matrix,
per-scene lighting tuning, and "a vcam app does it free." The ruling is reversed because the
delivery shape changed:
1. **Bundled small ONNX model (~2–5 MB)** — MediaPipe Selfie-Segmentation family —
shipped in the installer/fixture (TASK 48 MSIX), executed by **onnxruntime** (CPU default;
DirectML optional later). No NVIDIA SDK, no GPU requirement, no per-client download.
2. **Quality reality (creator):** a 720p webcam displays at ~15% of a 1080p frame — premium
segmentation quality is invisible at that size; commodity class is sufficient.
3. **⛔ Ruled out: NVIDIA Video Effects SDK (VFX / Maxine, AI Green Screen + Background Blur).**
Works on RTX 2000+/R570+ only, and needs a ~1 GB user-side redistributable on first use.
Creator: *"I don't think I'm putting a 1gb download requirement on a client box."*
## Constraints — non-negotiable
1. **No external download at runtime.** Model ships in the installer. (Store policy 10.2.2 —
same posture as the ffmpeg bundling in TASK 48 item 1.)
2. **No hardware requirement.** CPU path must work; DirectML is an optional accelerator.
3. **Per-element (Meld-style), not global.** Each webcam element carries its own effect.
4. **No feature gating.** Works for free users; only the branding flash is paid.
5. **Scene change / webcam add/remove must not crash or stall the pump** — a failing model
run skips to passthrough, never drops the layer.
## Out of scope for v1 (same engine later)
- ❌ **"Remove chair"-style object selection / quality tiers** — not a picker engine
- ❌ **Super resolution / denoise / prettify passes**
- ❌ **Virtual camera output** — permanently out of product (no vcam)
## 1. ☐ Engine
- ☐ Bundle an ONNX person-segmentation model (MediaPipe Selfie-Segmentation class, ~2–5 MB)
into the app (csproj copy like `ffmpeg/`, or as an `Asset`). Verify license + THIRD-PARTY
notices (TASK 36 item 6 gate).
- ☐ `onnxruntime` via NuGet. CPU EP by default; optionally DirectML EP later. Failing EP
init → passthrough, log once, never throw from the pump.
- ☐ Blur = **separable in-app blur** (box/gaussian) over the masked background region. No
model needed for blur once the mask exists.
- ☐ Remove = **alpha 0 outside the person region.** The compositor already alpha-blends per
element (`SceneCompositor`, source-over) — no compositor change expected.
- ☐ Feed the **VideoFrame seam** (`Services/VideoFrame.cs` doc explicitly anticipates
*"every future capture source (screen, background-removed webcam) feeds the same seam"*):
the frame handed to BOTH the preview bitmap path and the compositor's
`_cameraManager.GetLatestFrame` is the processed frame. One processing point, two consumers.
## 2. ☐ Config surface + persistence
- ☐ `WebcamSceneConfig` gains `BackgroundEffect` (`None | Remove | Blur`) + `Strength`.
- ☐ Persist via the existing `WebcamSceneConfig` table path in `LayoutStore.Save.cs`/
`Load.cs` (new columns + migration). Follow `LayoutStore.Migrations.cs` conventions.
- ☐ Properties panel: effect dropdown + strength slider, per webcam element.
- ☐ Replace-background affordance: **none in v1** — the creator drops any layer behind the
webcam. Effect preview must not obscure the fact that layers below still show through.
## 3. ☐ Test seam (Good Dog — one integration test per unit)
- ☐ Model-runner interface seam (like `CameraEnumeratorOverride`) so tests inject a fake
segmenter — no real ONNX inference in the test suite.
- ☐ Assert the **processed frame reaches both consumers** (preview bitmap + compositor frame).
- ☐ `None` = byte-identical passthrough of the raw frame.
- ☐ `Remove` = alpha 0 outside person rect; `Blur` = background region blurred, person sharp.
- ☐ Config → persistence roundtrip (per webcam element).
- ☐ Engine failure → passthrough + single log, layer never dropped.
- ☐ Real inference: **manual on the creator's box** (camera + lighting), not in CI.
## 4. ☐ Sequencing
- ☐ **Separate work unit AFTER the zoom build.** This file is booked + scoped; do not
co-commit it with the zoom work.
## Related (not duplicated here)
- [`task-48-distribution-msix.md`](task-48-distribution-msix.md) — the model ships in the
MSIX; bundled-binary posture established by the ffmpeg work.
- `TASKS.md` Webcam cadence section — the "⛔ no background removal" line is superseded
by this ruling; the **judder/compositor cost work is unaffected and still heads its lane.**
- `research-store-certification.md` — 11.12/10.2.2 posture; nothing here sends data off-box.
+1
View File
@@ -288,6 +288,7 @@ public partial class MainViewModel
HasBackground = SceneCatalog.HasBackground(name),
};
EnsureBackground(scene);
EnsureZoomLayer(scene);
Scenes.Add(scene);
StagedScene = scene;
}
+333
View File
@@ -0,0 +1,333 @@
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.
/// </summary>
public partial class MainViewModel
{
public void ActivateZoom(double clickX, double clickY)
{
if (!(IsLive || IsRecording)) return;
var maxW = MasterFrameWidth * 0.7;
var maxH = MasterFrameHeight * 0.7;
double w, h;
switch (_zoomShape)
{
case ZoomShape.Circle:
var size = Math.Min(maxW, maxH);
w = size; h = size;
break;
case ZoomShape.VerticalRectangle:
h = maxH;
w = h * 9.0 / 16.0;
break;
default:
w = maxW;
h = w * 9.0 / 16.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.
var x = Math.Clamp(clickX - w / 2, 0, MasterFrameWidth - w);
var y = Math.Clamp(clickY - h / 2, 0, MasterFrameHeight - h);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
var zoomTarget = EnsureZoomLayer(StagedScene);
zoomTarget.X = x;
zoomTarget.Y = y;
zoomTarget.Width = w;
zoomTarget.Height = h;
zoomTarget.ClipShape = _zoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional;
zoomTarget.IsZoomActive = true;
zoomTarget.IsVisible = true;
ScheduleSave();
RefreshZoomUi();
}
public void ToggleZoom()
{
if (_zoomState.IsActive) DismissZoom();
}
public void DismissZoom()
{
if (!_zoomState.IsActive) return;
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
if (zoomTarget != null)
{
zoomTarget.IsZoomActive = false;
zoomTarget.IsVisible = false;
}
if (StagedScene != null) _sceneGraph.InvalidateBake(StagedScene);
RefreshZoomUi();
}
public void StartZoomMove(double x, double y)
{
if (!_zoomState.IsActive) return;
_zoomState.IsMoving = true;
_zoomState.GrabOffsetX = x - _zoomState.X;
_zoomState.GrabOffsetY = y - _zoomState.Y;
OnPropertyChanged(nameof(ZoomState));
}
public void UpdateZoomMove(double x, double y)
{
if (!_zoomState.IsActive || !_zoomState.IsMoving) return;
var nx = x - _zoomState.GrabOffsetX;
var ny = y - _zoomState.GrabOffsetY;
_zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width);
_zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height);
var zoomTarget = StagedScene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault(e => e.IsZoomActive);
if (zoomTarget != null)
{
zoomTarget.X = _zoomState.X;
zoomTarget.Y = _zoomState.Y;
}
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
}
public void EndZoomMove()
{
if (_zoomState.IsMoving)
{
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(ZoomState));
}
}
private void RefreshZoomUi()
{
OnPropertyChanged(nameof(ZoomState));
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
RebuildDimRectsForZoom();
}
private void RebuildDimRectsForZoom()
{
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
DimRects.Clear();
if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight));
var rx = zx + zw;
if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight));
if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy));
var ry = zy + zh;
if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry));
return;
}
RebuildQualityDimRects();
}
/// <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>
private void HealZoomLayers()
{
foreach (var scene in Scenes)
EnsureZoomLayer(scene);
}
/// <summary>Gets or inserts the scene's zoom placeholder at z-index 1.</summary>
private ZoomSceneElement EnsureZoomLayer(Scene? scene)
{
var existing = scene?.Elements.OfType<ZoomSceneElement>().FirstOrDefault();
if (existing != null) return existing;
var zoom = new ZoomSceneElement();
_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;
}
}
+8 -132
View File
@@ -84,122 +84,10 @@ public partial class MainViewModel : ViewModelBase
public bool IsZoomActive => _zoomState.IsActive;
public bool ShowZoomOverlay => _zoomState.IsActive;
public void ActivateZoom(double clickX, double clickY)
{
if (!(IsLive || IsRecording)) return;
var maxW = MasterFrameWidth * 0.7;
var maxH = MasterFrameHeight * 0.7;
double w, h;
switch (_zoomShape)
{
case ZoomShape.Circle:
var size = Math.Min(maxW, maxH);
w = size; h = size;
break;
case ZoomShape.VerticalRectangle:
h = maxH;
w = h * 9.0 / 16.0;
break;
default:
w = maxW;
h = w * 9.0 / 16.0;
break;
}
double x, y;
if (_zoomShape == ZoomShape.Circle)
{
x = clickX - w / 2;
y = clickY - h / 2;
}
else
{
x = clickX;
y = clickY;
}
x = Math.Clamp(x, 0, MasterFrameWidth - w);
y = Math.Clamp(y, 0, MasterFrameHeight - h);
_zoomState.X = x;
_zoomState.Y = y;
_zoomState.Width = w;
_zoomState.Height = h;
_zoomState.IsActive = true;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
// Zoom activation/move/dismiss and the zoom frame builder live in
// MainViewModel.Zoom.cs — the zoom box is a real composited layer (see TASK 51).
public void ToggleZoom()
{
if (_zoomState.IsActive)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
}
public void DismissZoom()
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
RefreshZoomUi();
}
public void StartZoomMove(double x, double y)
{
if (!_zoomState.IsActive) return;
_zoomState.IsMoving = true;
_zoomState.GrabOffsetX = x - _zoomState.X;
_zoomState.GrabOffsetY = y - _zoomState.Y;
OnPropertyChanged(nameof(ZoomState));
}
public void UpdateZoomMove(double x, double y)
{
if (!_zoomState.IsActive || !_zoomState.IsMoving) return;
var nx = x - _zoomState.GrabOffsetX;
var ny = y - _zoomState.GrabOffsetY;
_zoomState.X = Math.Clamp(nx, 0, MasterFrameWidth - _zoomState.Width);
_zoomState.Y = Math.Clamp(ny, 0, MasterFrameHeight - _zoomState.Height);
OnPropertyChanged(nameof(ZoomState));
RebuildDimRectsForZoom();
}
public void EndZoomMove()
{
if (_zoomState.IsMoving)
{
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(ZoomState));
}
}
private void RefreshZoomUi()
{
OnPropertyChanged(nameof(ZoomState));
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
RebuildDimRectsForZoom();
}
private void RebuildDimRectsForZoom()
{
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
DimRects.Clear();
if (zx > 0) DimRects.Add(new Rect(0, 0, zx, MasterFrameHeight));
var rx = zx + zw;
if (rx < MasterFrameWidth) DimRects.Add(new Rect(rx, 0, MasterFrameWidth - rx, MasterFrameHeight));
if (zy > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, zy));
var ry = zy + zh;
if (ry < MasterFrameHeight) DimRects.Add(new Rect(0, ry, MasterFrameWidth, MasterFrameHeight - ry));
return;
}
RebuildQualityDimRects();
} private void RebuildQualityDimRects()
private void RebuildQualityDimRects()
{
DimRects.Clear();
if (OutputRectWidth <= 0 || OutputRectHeight <= 0) return;
@@ -513,6 +401,7 @@ public partial class MainViewModel : ViewModelBase
AddScene(name);
}
HealBackgrounds();
HealZoomLayers();
RefreshHotkeyLabels();
foreach (var scene in Scenes)
@@ -688,31 +577,18 @@ 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,
};
}
// The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's
// OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608).
private CompositorOptions BuildCompositorOptions()
// internal = test seam (the zoom layer must NOT change the output crop — the
// box is a composited layer, never the crop source).
internal CompositorOptions BuildCompositorOptions()
{
var quality = SelectedQuality;
if (_zoomState.IsActive)
{
var zx = _zoomState.X;
var zy = _zoomState.Y;
var zw = _zoomState.Width;
var zh = _zoomState.Height;
return new CompositorOptions
{
SourceRectX = (int)Math.Clamp(Math.Round(zx), 0, 1919),
SourceRectY = (int)Math.Clamp(Math.Round(zy), 0, 1079),
SourceRectWidth = (int)Math.Max(1, Math.Round(zw)),
SourceRectHeight = (int)Math.Max(1, Math.Round(zh)),
OutputWidth = quality.Width,
OutputHeight = quality.Height,
};
}
return new CompositorOptions
{
SourceRectX = (int)Math.Round(OutputRectX),
+1
View File
@@ -12,6 +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.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`. |
+21 -12
View File
@@ -94,11 +94,15 @@ public sealed class LayerReorderPersistenceTests
var list = (FocusPreservingListBox)panel.FindName("SourceList")!;
list.UpdateLayout();
// Elements = [Background, ImgA, ImgB, ImgC]. Physically drag ImgC
// (row 3) up onto ImgA (row 1): the handlers reorder live elements to
// [Background, ImgC, ImgA, ImgB] and EndListDrag must persist that.
var from = RowScreenPoint(list, 3);
var to = RowScreenPoint(list, 1);
// Elements = [Background, Zoom(placeholder, collapsed), ImgA, ImgB,
// ImgC]. Physically drag ImgC up onto ImgA: the handlers reorder the
// live elements to [Background, Zoom, ImgC, ImgA, ImgB] and EndListDrag
// must persist that. Rows are resolved BY ITEM — the collapsed zoom row
// shifts container indices, but ItemContainerGenerator maps items to
// their (collapsed = zero-height) containers transparently.
var sources = vm.StagedScene.Elements.OfType<Source>().ToArray();
var from = RowScreenPoint(list, sources.First(s => s.Name == "ImgC"));
var to = RowScreenPoint(list, sources.First(s => s.Name == "ImgA"));
SetCursorPos((int)from.X, (int)from.Y);
LetInputSettle(80);
@@ -133,13 +137,13 @@ public sealed class LayerReorderPersistenceTests
}
}
/// <summary>Screen (device-pixel) point one-third into the list row — over the
/// name TextBlock, away from the per-row buttons/rename TextBox.</summary>
private static Point RowScreenPoint(FocusPreservingListBox list, int index)
/// <summary>Screen (device-pixel) point one-third into an item's list row — over
/// the name TextBlock, away from the per-row buttons/rename TextBox.</summary>
private static Point RowScreenPoint(FocusPreservingListBox list, Models.SceneElement item)
{
var item = (ListBoxItem)list.ItemContainerGenerator.ContainerFromIndex(index)!;
var inList = item.TransformToVisual(list)
.Transform(new Point(Math.Min(30, item.ActualWidth - 12), item.ActualHeight / 2));
var container = (ListBoxItem)list.ItemContainerGenerator.ContainerFromItem(item)!;
var inList = container.TransformToVisual(list)
.Transform(new Point(Math.Min(30, container.ActualWidth - 12), container.ActualHeight / 2));
return list.PointToScreen(inList);
}
@@ -251,7 +255,12 @@ public sealed class LayerReorderPersistenceTests
// Restart: a brand-new LayoutStore reading the same DB must restore
// the cross-type order exactly — webcam between Image and Web
// Resource-0, not appended after all sources.
// Resource-0, not appended after all sources. Only persisted elements
// count: the transient zoom placeholder (TASK 51) is never written,
// so the DB order naturally has no seat for it.
expected = elements.Where(e => e is Source or WebcamSceneConfig)
.Select(e => e.Name).ToArray();
Assert.True(PumpUntil(() =>
TryReadSceneOrder(tempDb, out var names) && names.SequenceEqual(expected),
TimeSpan.FromSeconds(10)),
+52 -16
View File
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
@@ -11,12 +12,13 @@ using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Verifies the zoom box created by a right-click in the live preview: each
/// ZoomView Shape procures the right bounding box (circle is square and
/// centered on the click, phone is a tall 9:16 rect, tablet a wide 16:9 rect
/// both anchored top-left at the click), the box stays fully inside the
/// 1920x1080 preview, and the UI renders the matching outline with the zoomed
/// region clipped to the shape.
/// 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).
/// </summary>
[Collection("RealApp")]
public sealed class ZoomBoxInteractionTests
@@ -29,7 +31,7 @@ public sealed class ZoomBoxInteractionTests
}
[Fact]
public void Zoom_Shape_Controls_Box_Aspect_Anchor_And_Render()
public void Zoom_Shape_Controls_Box_Aspect_Anchor_Layer_And_Render()
{
_app.Run(Run);
}
@@ -44,8 +46,12 @@ public sealed class ZoomBoxInteractionTests
{
window.Show();
window.UpdateLayout();
Assert.NotNull(vm.StagedScene);
// The healed zoom placeholder sits at z-index 1, inert.
var placeholder = (ZoomSceneElement)vm.StagedScene.Elements[1];
Assert.False(placeholder.IsZoomActive);
Assert.Equal(ElementKind.Static, placeholder.Kind);
// Same internal test seam as IsRecording allows ActivateZoom (IsEditMode goes false).
vm.IsRecording = true;
var pane = (UserControl)window.FindName("PreviewPane")!;
var zoomBox = (Grid)pane.FindName("ZoomBox")!;
@@ -59,6 +65,7 @@ public sealed class ZoomBoxInteractionTests
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);
AssertTrueActiveLayer(vm);
window.UpdateLayout();
Assert.Equal(Visibility.Visible, zoomBox.Visibility);
@@ -66,26 +73,28 @@ 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 TOP-LEFT corner.
// Phone (vertical rectangle): tall 9:16 box, click point is the CENTER.
vm.ZoomShape = ZoomShape.VerticalRectangle;
vm.ActivateZoom(200, 100);
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(200, vm.ZoomState.X, 3);
Assert.Equal(100, vm.ZoomState.Y, 3);
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
AssertTrueActiveLayer(vm);
window.UpdateLayout();
Assert.Null(zoomBox.Clip);
Assert.Equal(Visibility.Collapsed, circleOutline.Visibility);
Assert.Equal(Visibility.Visible, rectOutline.Visibility);
// Tablet (horizontal rectangle): wide 16:9 box, anchored top-left.
// Tablet (horizontal rectangle): wide 16:9 box, click point is the CENTER.
vm.ZoomShape = ZoomShape.HorizontalRectangle;
vm.ActivateZoom(200, 100);
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(200, vm.ZoomState.X, 3);
Assert.Equal(100, vm.ZoomState.Y, 3);
Assert.Equal(960 - vm.ZoomState.Width / 2, vm.ZoomState.X, 3);
Assert.Equal(540 - vm.ZoomState.Height / 2, vm.ZoomState.Y, 3);
AssertTrueActiveLayer(vm);
// Clamp keeps the box fully inside the 1920x1080 preview even for a
// corner click that would otherwise overhang it.
@@ -94,6 +103,20 @@ public sealed class ZoomBoxInteractionTests
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);
AssertTrueActiveLayer(vm);
// The box is a composited layer, NEVER the crop: output stays the tier.
var opts = vm.BuildCompositorOptions();
Assert.True(opts.SourceRectWidth > vm.ZoomState.Width);
Assert.Equal((int)Math.Round(vm.OutputRectWidth), opts.SourceRectWidth);
Assert.Equal((int)Math.Round(vm.OutputRectHeight), opts.SourceRectHeight);
// Dismiss restores the inert placeholder (Kind back to Static).
vm.DismissZoom();
Assert.False(vm.IsZoomActive);
Assert.False(placeholder.IsZoomActive);
Assert.False(placeholder.IsVisible);
Assert.Equal(ElementKind.Static, placeholder.Kind);
}
finally
{
@@ -103,4 +126,17 @@ public sealed class ZoomBoxInteractionTests
try { System.IO.File.Delete(tempDb); } catch { }
}
}
private static void AssertTrueActiveLayer(MainViewModel vm)
{
var zoom = Assert.IsType<ZoomSceneElement>(vm.StagedScene!.Elements[1]);
Assert.True(zoom.IsZoomActive);
Assert.True(zoom.IsVisible);
Assert.Equal(ElementKind.Dynamic, zoom.Kind);
Assert.Equal(vm.ZoomState.X, zoom.X, 3);
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.ZoomShape == ZoomShape.Circle ? ClipShape.Round : ClipShape.Traditional, zoom.ClipShape);
}
}
+83
View File
@@ -0,0 +1,83 @@
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
}
}
}
}