From fe80e8c9942073c3072ac1ef3bdfa2516c5284be Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 8 Oct 2026 08:52:36 -0700 Subject: [PATCH] Docs: record the zoom box as TASK 52 and fix the task-numbering drift (Unit: zoom docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- HANDOFF.md | 13 +++++- Models/index.md | 4 +- TASKS.md | 3 +- TASKS/task-52-zoom-box.md | 48 ++++++++++++++++++++ ViewModels/MainViewModel.cs | 2 +- ViewModels/index.md | 2 +- ytLive.Tests/LayerReorderPersistenceTests.cs | 2 +- 7 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 TASKS/task-52-zoom-box.md diff --git a/HANDOFF.md b/HANDOFF.md index 8ba7c47..5bdeb6d 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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). diff --git a/Models/index.md b/Models/index.md index 73ffeb5..4bacf08 100644 --- a/Models/index.md +++ b/Models/index.md @@ -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:`/`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`/`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 | diff --git a/TASKS.md b/TASKS.md index 39da509..88cf5b3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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) | --- diff --git a/TASKS/task-52-zoom-box.md b/TASKS/task-52-zoom-box.md new file mode 100644 index 0000000..5a4e026 --- /dev/null +++ b/TASKS/task-52-zoom-box.md @@ -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`. \ No newline at end of file diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 61f1e5e..3b8d696 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -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() { diff --git a/ViewModels/index.md b/ViewModels/index.md index 4f11666..348d1b6 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -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`. | diff --git a/ytLive.Tests/LayerReorderPersistenceTests.cs b/ytLive.Tests/LayerReorderPersistenceTests.cs index 8cbd2f3..f35c84f 100644 --- a/ytLive.Tests/LayerReorderPersistenceTests.cs +++ b/ytLive.Tests/LayerReorderPersistenceTests.cs @@ -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();