diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 17b3117..340b264 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -65,6 +65,11 @@ + + + + diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 610d979..45ceed5 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -63,14 +63,28 @@ - - - + image renders above it). The zoom magnifier samples + this layer — see UpdateZoomBox (TASK 51). --> + + + + + + + + + + + + + @@ -103,6 +117,12 @@ + + + + + @@ -402,16 +422,6 @@ - - - - - - - SceneGraph.GetSplitPoint). public virtual ElementKind Kind => ElementKind.Static; + /// True only for the transient zoom layer. Gates zoom-only UI: + /// hidden from the layers list, never selectable/draggable in the preview. + public virtual bool IsZoomLayer => false; + /// True only for singleton resources (webcam). Gates webcam-only UI. public virtual bool IsWebcam => false; diff --git a/Models/ZoomSceneElement.cs b/Models/ZoomSceneElement.cs new file mode 100644 index 0000000..e60fc33 --- /dev/null +++ b/Models/ZoomSceneElement.cs @@ -0,0 +1,28 @@ +using System.Windows.Media; + +namespace ytLive.Models; + +/// +/// 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. +/// +public sealed class ZoomSceneElement : SceneElement +{ + /// True only while a zoom box is live; flips Kind so the layer leaves + /// the baked static base and composites per-frame. + public bool IsZoomActive { get; set; } + + public override ElementKind Kind => IsZoomActive ? ElementKind.Dynamic : ElementKind.Static; + + public override bool IsZoomLayer => true; + + public override bool IsDraggable => false; + + /// Never rendered by the element template — the preview magnifier + /// (ZoomBox) and the output resolver own its pixels. + public override ImageSource? DisplaySource => null; +} \ No newline at end of file diff --git a/Models/index.md b/Models/index.md index d303e19..61185ae 100644 --- a/Models/index.md +++ b/Models/index.md @@ -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:`/`window:`/`picker:`), `IsLiveCapture` (`Type` is Display/WindowCapture) | -| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `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 | diff --git a/MyMistakes.md b/MyMistakes.md index bbd9d82..3bf748b 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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}' -- ` 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. diff --git a/TASKS.md b/TASKS.md index 2537a38..39da509 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) diff --git a/TASKS/task-51-webcam-bg-effects.md b/TASKS/task-51-webcam-bg-effects.md new file mode 100644 index 0000000..a0d9efa --- /dev/null +++ b/TASKS/task-51-webcam-bg-effects.md @@ -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. \ No newline at end of file diff --git a/ViewModels/MainViewModel.Scenes.cs b/ViewModels/MainViewModel.Scenes.cs index ed8c4ef..c5dfb4e 100644 --- a/ViewModels/MainViewModel.Scenes.cs +++ b/ViewModels/MainViewModel.Scenes.cs @@ -288,6 +288,7 @@ public partial class MainViewModel HasBackground = SceneCatalog.HasBackground(name), }; EnsureBackground(scene); + EnsureZoomLayer(scene); Scenes.Add(scene); StagedScene = scene; } diff --git a/ViewModels/MainViewModel.Zoom.cs b/ViewModels/MainViewModel.Zoom.cs new file mode 100644 index 0000000..add13c3 --- /dev/null +++ b/ViewModels/MainViewModel.Zoom.cs @@ -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; + +/// +/// The zoom box as a real composited layer (TASK 51): right-clicking the live +/// preview places a 2x magnifier one z-slot above the background and below every +/// other layer. The box drives a transient whose +/// pixels are the magnified backdrop inside the box; the frame pump composites it +/// into the stream/recording like any other layer (output stays the normal tier +/// crop — the box is NEVER the crop source). All shapes are centered on the click. +/// +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().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().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(); + } + + /// 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. + private void HealZoomLayers() + { + foreach (var scene in Scenes) + EnsureZoomLayer(scene); + } + + /// Gets or inserts the scene's zoom placeholder at z-index 1. + private ZoomSceneElement EnsureZoomLayer(Scene? scene) + { + var existing = scene?.Elements.OfType().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().FirstOrDefault(s => s.IsBackground); + var liveFrame = live is { IsVisible: true } ? ResolveOutputFrameCore(live) : null; + var art = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background); + var artFrame = art != null ? ResolveOutputFrameCore(art) : null; + if (liveFrame == null && artFrame == null) return null; + + var zx = (int)Math.Round(zoom.X); + var zy = (int)Math.Round(zoom.Y); + var zw = (int)Math.Ceiling(zoom.Width); + var zh = (int)Math.Ceiling(zoom.Height); + var key = (liveFrame?.BgraPixels, liveFrame?.Epoch, artFrame?.BgraPixels, artFrame?.Epoch, zx, zy, zw, zh); + if (key != _zoomFrameKey) + { + _zoomFrame = ZoomFrameBuilder.Build( + liveFrame, artFrame, zx, zy, zw, zh, MasterFrameWidth, MasterFrameHeight); + _zoomFrameKey = key; + } + return _zoomFrame; + } +} + +/// Pure pixel builder for the zoom layer: samples the box/2 interior +/// centred on the box centre from the sequenced backdrop (live capture then art) +/// and upscales it 2x — the same region and magnification the preview's +/// VisualBrush shows. Static, so the magnification proof is a plain unit test. +internal static class ZoomFrameBuilder +{ + internal static VideoFrame? Build( + VideoFrame? liveFrame, VideoFrame? artFrame, + int zx, int zy, int zw, int zh, + double masterW, double masterH) + { + if (zw <= 0 || zh <= 0) return null; + if (liveFrame == null && artFrame == null) return null; + var buffer = new byte[zw * zh * 4]; + for (var oy = 0; oy < zh; oy++) + { + // Source window = the box/2 interior CENTERED on the box center (the + // same region as the preview's VisualBrush Viewbox), sampled at 2x. + var my = zy + zh / 4.0 + (oy + 0.5) * 0.5; + var di = oy * zw * 4; + for (var ox = 0; ox < zw; ox++, di += 4) + { + var mx = zx + zw / 4.0 + (ox + 0.5) * 0.5; + var b = 0; + var g = 0; + var r = 0; + var a = 0; + if (liveFrame != null) + Sample(liveFrame, mx, my, masterW, masterH, ref b, ref g, ref r, ref a); + if (artFrame != null) + { + var ab = 0; + var ag = 0; + var ar = 0; + var aa = 0; + Sample(artFrame, mx, my, masterW, masterH, ref ab, ref ag, ref ar, ref aa); + if (aa >= 255) + { + b = ab; g = ag; r = ar; + } + else if (aa > 0) + { + var inv = 255 - aa; + b = (b * inv + ab * aa + 127) / 255; + g = (g * inv + ag * aa + 127) / 255; + r = (r * inv + ar * aa + 127) / 255; + } + a = 255; + } + else if (liveFrame != null) + { + a = 255; + } + buffer[di] = (byte)b; + buffer[di + 1] = (byte)g; + buffer[di + 2] = (byte)r; + buffer[di + 3] = (byte)a; + } + } + return new VideoFrame(zw, zh, buffer); + } + + /// Inverse of the compositor's full-frame backdrop blit: maps a master + /// pixel back through (or the frame's + /// CropBounds) into the source canvas and bilinear-samples it. Fixed-point + /// bilinear, same precision as SceneCompositor.Bilinear. + private static void Sample( + VideoFrame src, double mx, double my, double masterW, double masterH, + ref int b, ref int g, ref int r, ref int a) + { + var cbX = 0; + var cbY = 0; + var cbW = src.Width; + var cbH = src.Height; + float scale, ox, oy, drawnW, drawnH; + if (src.CropBounds is { } cb && cb.W > 0 && cb.H > 0) + { + cbX = cb.X; + cbY = cb.Y; + cbW = cb.W; + cbH = cb.H; + scale = Math.Max((float)masterW / cbW, (float)masterH / cbH); + drawnW = cbW * scale; + drawnH = cbH * scale; + ox = (float)((masterW - drawnW) / 2); + oy = (float)((masterH - drawnH) / 2); + } + else + { + var (uScale, uOx, uOy) = StretchMath.UniformToFill((float)masterW, (float)masterH, src.Width, src.Height); + scale = uScale; + drawnW = src.Width * uScale; + drawnH = src.Height * uScale; + ox = uOx; + oy = uOy; + } + + var sxFloat = cbX + (mx - ox) / drawnW * cbW; + var syFloat = cbY + (my - oy) / drawnH * cbH; + if (sxFloat < 0f) sxFloat = 0f; + if (sxFloat > src.Width - 1f) sxFloat = src.Width - 1f; + if (syFloat < 0f) syFloat = 0f; + if (syFloat > src.Height - 1f) syFloat = src.Height - 1f; + + var sxi = (int)sxFloat; + var syi = (int)syFloat; + var sxInv = Math.Min(sxi + 1, src.Width - 1); + var syInv = Math.Min(syi + 1, src.Height - 1); + var fx8 = (int)((sxFloat - sxi) * 256); + var fxInv = 256 - fx8; + var fy8 = (int)((syFloat - syi) * 256); + var fyInv = 256 - fy8; + var stride = src.Width; + var p00 = (syi * stride + sxi) * 4; + var p10 = (syi * stride + sxInv) * 4; + var p01 = (syInv * stride + sxi) * 4; + var p11 = (syInv * stride + sxInv) * 4; + var px = src.BgraPixels; + b = Bilinear(px[p00], px[p10], px[p01], px[p11], fx8, fxInv, fy8, fyInv); + g = Bilinear(px[p00 + 1], px[p10 + 1], px[p01 + 1], px[p11 + 1], fx8, fxInv, fy8, fyInv); + r = Bilinear(px[p00 + 2], px[p10 + 2], px[p01 + 2], px[p11 + 2], fx8, fxInv, fy8, fyInv); + a = Bilinear(px[p00 + 3], px[p10 + 3], px[p01 + 3], px[p11 + 3], fx8, fxInv, fy8, fyInv); + } + + private static int Bilinear(int tl, int tr, int bl, int br, int fx8, int fxInv, int fy8, int fyInv) + { + var top = tl * fxInv + tr * fx8; + var bot = bl * fxInv + br * fx8; + return (top * fyInv + bot * fy8 + 32768) >> 16; + } +} \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index dff5595..702d6a5 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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), diff --git a/ViewModels/index.md b/ViewModels/index.md index 141bfbf..b09c296 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -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`. | diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs index 2107e43..8cbd2f3 100644 --- a/ytLive.Tests/LayerReorderPersistenceTests.cs +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -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().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 } } - /// Screen (device-pixel) point one-third into the list row — over the - /// name TextBlock, away from the per-row buttons/rename TextBox. - private static Point RowScreenPoint(FocusPreservingListBox list, int index) + /// Screen (device-pixel) point one-third into an item's list row — over + /// the name TextBlock, away from the per-row buttons/rename TextBox. + 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)), diff --git a/ytLive.Tests/ZoomBoxInteractionTests.cs b/ytLive.Tests/ZoomBoxInteractionTests.cs index f1b7426..2fdf669 100644 --- a/ytLive.Tests/ZoomBoxInteractionTests.cs +++ b/ytLive.Tests/ZoomBoxInteractionTests.cs @@ -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; /// -/// 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). /// [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(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); + } } \ No newline at end of file diff --git a/ytLive.Tests/ZoomFrameBuilderTests.cs b/ytLive.Tests/ZoomFrameBuilderTests.cs new file mode 100644 index 0000000..c6835a1 --- /dev/null +++ b/ytLive.Tests/ZoomFrameBuilderTests.cs @@ -0,0 +1,83 @@ +using System; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Proves the zoom layer's pixels are the GENUINE 2x magnification of the +/// backdrop: the source window is the box/2 interior centred on the box centre +/// (the same region as the preview's VisualBrush), with the static art composited +/// over the live capture exactly as the SceneCompositor draws the backdrop. This +/// is the "don't ship a magnifier you only think exists" proof — a 1x box, a +/// mis-positioned window, or a wrong backdrop layer all fail hard below. +/// +public sealed class ZoomFrameBuilderTests +{ + private static VideoFrame BandedBackdrop(int bandW, int bandCount) + { + var w = bandW * bandCount; + const int h = 1080; + var buf = new byte[w * h * 4]; + var bandColors = new (byte B, byte G, byte R)[] + { + (10, 20, 30), // band 0 + (0, 0, 255), // band 1 red + (0, 255, 0), // band 2 green — the box window lands here + (255, 0, 0), // band 3 blue + }; + for (var y = 0; y < h; y++) + { + for (var x = 0; x < w; x++) + { + var (b, g, r) = bandColors[Math.Min(bandCount - 1, x / bandW)]; + var i = (y * w + x) * 4; + buf[i] = b; + buf[i + 1] = g; + buf[i + 2] = r; + buf[i + 3] = 255; + } + } + return new VideoFrame(w, h, buf); + } + + private static VideoFrame SolidFrame(int w, int h, byte b, byte g, byte r) + { + var buf = new byte[w * h * 4]; + for (var i = 0; i < w * h; i++) + { + buf[i * 4] = b; + buf[i * 4 + 1] = g; + buf[i * 4 + 2] = r; + buf[i * 4 + 3] = 255; + } + return new VideoFrame(w, h, buf); + } + + [Fact] + public void Build_Produces_2x_Centered_ArtOverLive_Magnification() + { + var art = BandedBackdrop(480, 4); // [0,480) 1/4 dim, [480,960) red, [960,1440) green, [1440,1920) blue + var live = SolidFrame(1920, 1080, 200, 60, 60); // magenta-ish — must never show through + + // Box [840,1320)x[100,580). 2x window = [960,1200)x[160,520) = band 2, all green. + // A 1x (broken) magnifier samples [840,1320) → red+green+blue mixed. + var zoom = ZoomFrameBuilder.Build(live, art, 840, 100, 480, 480, 1920, 1080); + + Assert.NotNull(zoom); + Assert.Equal(480, zoom.Width); + Assert.Equal(480, zoom.Height); + for (var y = 0; y < zoom.Height; y++) + { + for (var x = 0; x < zoom.Width; x++) + { + var i = (y * zoom.Width + x) * 4; + Assert.Equal(0, zoom.BgraPixels[i]); // B + Assert.Equal(255, zoom.BgraPixels[i + 1]); // G + Assert.Equal(0, zoom.BgraPixels[i + 2]); // R + Assert.Equal(255, zoom.BgraPixels[i + 3]); // A + } + } + } +} \ No newline at end of file