Docs: record the zoom box as TASK 52 and fix the task-numbering drift (Unit: zoom docs)

The zoom feature shipped without a task record while its index rows carried the
"TASK 51" label — which belongs to the webcam background effects (Remove + Blur).
For an honest catalogue and handoff:

- TASKS/task-52-zoom-box.md: feature provenance (creator-requested), the three
  2026-10-08 corrections (loupe -> anchored capture over the full composite drawn
  last -> never show the dim shading), shapes, implementation map, tests, DONE.
- TASKS.md: row 52 added (shipped); row 51 reworded — unblocked now that the
  zoom build shipped.
- TASK 51 -> TASK 52 in Models/index.md, ViewModels/index.md, and the two code
  comments that pointed at the zoom as "TASK 51".
- HANDOFF.md: pre-push readiness notes (5 commits ahead, push-safe).
This commit is contained in:
2026-10-08 08:52:36 -07:00
parent 948dd08214
commit fe80e8c994
7 changed files with 67 additions and 7 deletions
+12 -1
View File
@@ -1,8 +1,16 @@
# HANDOFF — 2026-10-08 (zoom rework + dims-shading fix)
# HANDOFF — 2026-10-08 (zoom box shipped, READY FOR PUSH)
## Branch
- main (no feature branch)
## Push readiness
- **5 commits ahead of origin/main, clean to push** (fetch confirmed origin has not moved):
`966e718` zoom box visuals → `4b3d82f` zoom as layered + TASK 51 booking → `6bff21b`
anchored-capture rework → `948dd08` dims-shading fix → (docs unit: TASK 52 record +
numbering fixes + this HANDOFF).
- The pinned-chat work unit below is **uncommitted on purpose** — `git push` sends only
commits, so pushing is safe; the dirty files stay local.
## Dirty files (NOT mine — the in-flight "pinned chat" work unit; do NOT commit or co-edit)
- `Models/YouTube.cs`
- `ViewModels/MainViewModel.Chat.cs`
@@ -25,6 +33,9 @@ is dimmed". Structural invariant added to `ZoomBoxInteractionTests`: `DimRectsLa
child of `PreviewCanvas`, not inside `CanvasGrid`, and renders below the `ZoomBox`.
## Zoom feature state
- **TASK 52 (zoom box) is now a first-class record** — see [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md):
feature provenance + the three creator corrections, shapes, implementation map, tests.
TASK 51 (webcam background Remove + Blur) is unblocked for the next unit.
- Models: `ZoomState.cs`, `ZoomShape.cs` (Circle/VerticalRectangle/HorizontalRectangle), `ZoomSceneElement.cs`.
- ViewModel (`MainViewModel.Zoom.cs`): `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RebuildDimRectsForZoom`; transient element healed/mirrored by `HealZoomLayers`/`EnsureZoomLayer`.
- Output: box composited last via `BlitZoom`/`ZoomMagnifier`; output crop = tier (box never the crop).
+2 -2
View File
@@ -8,8 +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`/`virtual IsZoomLayer` (TASK 51); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 51, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`/`virtual IsZoomLayer` (TASK 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `ZoomSceneElement.cs` | **Transient zoom box (TASK 52, creator-corrected 2026-10-08):** carries the box rect AND the anchored `CaptureX`/`CaptureY` (the fixed box/2 capture window set on `ActivateZoom`; moving the box never touches it). `IsZoomActive` flips `Kind` Static→Dynamic (leaves the baked base while active); never persisted (LayoutStore save skips non-Source/non-WebcamSceneConfig rows), never shown in the layers list or the element template (`IsZoomLayer` gates both). The compositor SKIPS the active element in every layer loop and draws its 2x content LAST (layer n+1) via `ZoomMagnifier`. |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
+2 -1
View File
@@ -66,7 +66,8 @@
| 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) |
| 51 | **Webcam background effects: Remove + Blur** | ☐ **v1 requirement** — booked 2026-10-08; next up now that the zoom box shipped (TASK 52) | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) |
| 52 | **Zoom box (anchored 2× magnifier)** | ✅ Done — shipped 2026-10-08 (3 creator corrections during the build: loupe → anchored capture over the full composite, drawn last → never shows the dim shading) | [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md) |
---
+48
View File
@@ -0,0 +1,48 @@
# TASK 52 — Zoom box (anchored 2× magnifier)
**Status:** ✅ Done — shipped 2026-10-08 (three creator corrections during the build).
## What it is
Right-clicking the live preview parks a movable **2× magnifier box** that shows a
**live, always-on magnified view of a FIXED capture region** — the box/2 window centred
on the click. The captured area is whatever was under the click; it stays the same no
matter where the box is dragged.
## Creator rulings (order received, 2026-10-08)
1. **Not a loupe.** The click fixes the capture; **moving the box never changes its
content**. First cut shipped `4b3d82f` as a box-following loupe — reworked in
`6bff21b` to anchored `CaptureX/Y` (drag = `UpdateZoomMove` moves box only).
2. **Content = every layer.** The box magnifies the **fully-composited frame**
(backdrop + every element incl. webcam), drawn **LAST, on top of everything**
(layer n+1) — `SceneCompositor` skips the active `ZoomSceneElement` in every layer
loop and `BlitZoom`s it at the end via `Services/Compositor/ZoomMagnifier`.
3. **Never show shading.** The dim rectangles around the box must never appear inside
it — the `DimRects` overlay is a sibling of `CanvasGrid`, never a descendant, so the
zoom box's `VisualBrush` can't pick it up (`948dd08`).
4. **Rectangle = 4:3** ("more of a 640×480 aspect ratio"); Circle square, Phone 9:16.
## Shapes (master 1920×1080; max box 0.7·frame)
| Shape | Box | Notes |
|---|---|---|
| Circle | square, min(maxW,maxH) | round clip |
| VerticalRectangle (Phone) | 9:16 | h = 0.7·1080 |
| HorizontalRectangle (Tablet, default) | **4:3** | w = h·4/3 |
Box and capture are each centred on the click and clamped independently.
## Implementation
- `ViewModels/MainViewModel.Zoom.cs` — activate/move/dismiss + dims; transient
`ZoomSceneElement` healed/mirrored (`HealZoomLayers`/`EnsureZoomLayer`, z-index 1, never persisted).
- `Services/Compositor/ZoomMagnifier.cs` — pure 2× bilinear over an already-composited
opaque buffer (temp snapshot via `ArrayPool`, optional round clip).
- `Models/ZoomSceneElement.cs` / `Models/ZoomState.cs` — box rect + `CaptureX/Y`.
- `Controls/PreviewPane.xaml(.cs)` — `ZoomBox` (sibling above `CanvasGrid`, brushes the
whole composed preview at the fixed capture, 2×) + `DimRectsLayer` (sibling above the
content, below the box) + `UpdateZoomBox`.
- Output crop always equals the tier — the box is never the crop source.
## Tests
`ZoomMagnifierTests` (money proof: box over blue shows green capture at 2× incl. an extra
red "layer"; round-clip) + `ZoomBoxInteractionTests` (aspects, capture anchor centred on the
click, move-moves-only-the-box, corner clamp, dims-layer-structure invariant).
Full suite 406/406 on a clean run at `948dd08`.
+1 -1
View File
@@ -85,7 +85,7 @@ public partial class MainViewModel : ViewModelBase
public bool ShowZoomOverlay => _zoomState.IsActive;
// 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).
// MainViewModel.Zoom.cs — the zoom box is a real composited layer (see TASK 52).
private void RebuildQualityDimRects()
{
+1 -1
View File
@@ -12,7 +12,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
| `MainViewModel.Zoom.cs` | **Zoom box (TASK 51, creator-corrected 2026-10-08)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom`. Right-clicking the live preview anchors a FIXED **capture window** (the box/2 region centred on the click) and parks a movable **2x magnifier**. The box shows a live always-on 2x view of the anchored capture; **dragging moves only the box — `CaptureX/Y` are deliberately untouched in `UpdateZoomMove`** (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = **4:3** (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient `ZoomSceneElement` mirrors box + capture; the output compositor draws it **LAST (layer n+1)** from the already-composed frame (`SceneCompositor.BlitZoom`→`ZoomMagnifier`) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built `ZoomFrameBuilder` frames was removed. `BuildCompositorOptions` (internal = test seam) always returns the tier crop — the box is never the crop source. |
| `MainViewModel.Zoom.cs` | **Zoom box (TASK 52, creator-corrected 2026-10-08)** `ActivateZoom`/`ToggleZoom`/`DismissZoom`/`StartZoomMove`/`UpdateZoomMove`/`EndZoomMove` + `RefreshZoomUi`/`RebuildDimRectsForZoom`. Right-clicking the live preview anchors a FIXED **capture window** (the box/2 region centred on the click) and parks a movable **2x magnifier**. The box shows a live always-on 2x view of the anchored capture; **dragging moves only the box — `CaptureX/Y` are deliberately untouched in `UpdateZoomMove`** (creator ruling). Each shape is centred on the click and clamped independently (box to the master, capture to box/2 windows): Circle = square, VerticalRectangle = 9:16, HorizontalRectangle = **4:3** (w=h·4/3 with h=0.7·1080; NOT 16:9 — first cut read as a square). The transient `ZoomSceneElement` mirrors box + capture; the output compositor draws it **LAST (layer n+1)** from the already-composed frame (`SceneCompositor.BlitZoom`→`ZoomMagnifier`) so it captures EVERY layer. No per-frame builder/cache — the resolver case that built `ZoomFrameBuilder` frames was removed. `BuildCompositorOptions` (internal = test seam) always returns the tier crop — the box is never the crop source. |
| `MainViewModel.Background.cs` | Screen backdrop (Live-only by policy): `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture); background model `EnsureBackground`/`CreateBackground`/`NormalizeBackgrounds`/`HealBackgrounds`/`StampDefaultBackgroundAsset`; screen capture `ReacquireScreenCaptures`/`ResolveAutoCaptureKey`/`RedesignateBackgroundAsync`/`ClearBackgroundCaptureAsync`/`SetBackgroundCapture`/`OnScreenPreviewBitmapChanged`/`OnFullscreenMonitorChanged`/`RefreshBackgroundAutoCapture`/`BackgroundShowDesktop` (keys on full-screen-game monitor or primary display via `Win32FullScreenDetector`); display picker `Displays`/`SetBackgroundDisplayCommand`; preview fns `ActiveBackgroundImage`/`BackgroundImage`/`UpdateActiveBackground`/`UpdateBackgroundImage`/`RefreshSnapshotsAsync`/`LoadBackgroundImage`/`CreatePlaceholderSnapshot`; `ChangeCaptureCommand`/`RefreshCaptureCommand`/`ToggleShowDesktopCommand`/`BrowseBackgroundCommand`. `ShowPreviewPlaceholder` stays in core. |
| `MainViewModel.Webcam.cs` | Webcam identity: `_webcam`/`ReacquireWebcam`/`SwapWebcamIdentityAsync`; per-scene configs `AddWebcamToStagedSceneAsync`/`ShowWebcamInStagedScene`/`ChangeWebcamAsync` (Windows picker always opens); capture `OnCameraPreviewBitmapChanged`/`OnCameraFailed`; gating `CanAddWebcam`/`CanChangeWebcam`/`CanShowWebcamInStagedScene`/`IsWebcamSelected`/`WebcamError`; size safeguards `WebcamMax*`/`ClampWebcamToBounds`/`HealLegacySquareRect`; `AddWebcamCommand`/`ChangeWebcamCommand`/`ShowWebcamCommand`. |
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
+1 -1
View File
@@ -256,7 +256,7 @@ 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. Only persisted elements
// count: the transient zoom placeholder (TASK 51) is never written,
// count: the transient zoom placeholder (TASK 52) 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();