From 6be062c45fb982839b039c3f60a51e6092d30ee6 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 21 Oct 2026 08:58:24 -0700 Subject: [PATCH] vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene The vertical tier is a centred band crop of the SAME 1920x1080 master (band x 656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the cropped window and the creator is simply not in the vertical stream. Each scene now keeps its own vertical position, size and shape; switching back to 16:9 restores the desktop layout exactly (the creator's locked table, 2026-10-17). Geometry only - NO render-path change. The preview and the compositor both read the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry: - Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540 areas, the six spots (top/bottom x left/centre/right), the 32px inset and the 270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot, the corner the 16:9 default webcam uses. - Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this scene has no vertical placement yet"). The 16:9 layout stays in the old columns and is parked in the transient Landscape* fields while the vertical tier is live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The X/Y/W/H overrides clamp every write into the band - outside it the camera is invisible in the vertical output. - Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element constrains every writer (drag, resize handle, typed fields) in ONE choke point instead of three writers each re-implementing the rule. - ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand, ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier / SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre), EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The Live scene gets its small tile automatically on the first vertical switch (the vertical stream would otherwise have no camera at all); the other scenes keep their 16:9 placement until the creator drags them into the band. - Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under the vertical tier instead of free-floating over the master. Controls/LeftPanel.xaml: the webcam properties gained a Geometry section (X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam - the TASK 51 test finds that panel by binding text and would match twice). - The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is live the save writes the parked values there and the live ones into Vertical*. Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The vertical tier brings the Live camera INTO the band as a small tile; dragging it to the top-right lands it top-right inside the band; back to 16:9 the desktop layout is restored exactly (1408/778/480x270); both placements survive a save + reload. Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails ALONE on a clean tree in this environment - pre-existing flake, proven by stashing this work, not a regression. Derivative work: OBS has no built-in per-aspect-ratio layout storage - the maintainers' answer is "create a second scene and add the scene you want to send as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect guide says the same ("no built-in way to store separate layouts per aspect ratio", so you rely on manual workarounds and plugins): https://streamyard.com/blog/dual-aspect-ratio-streaming-tool Our per-scene, per-tier stored placement, resolved on the tier change, is the step past both. Memory in the same commit: ai.md (new section), TASKS.md + TASKS/task-57-*.md (Unit 1 shipped, Unit 2 = the Chat scene still open), HANDOFF.md, MyMistakes.md (the per-tier geometry-swap recipe, the binding-text panel trap, the store's webcam-identity rule) and the Models/Services/ViewModels/Controls index rows. --- Controls/LeftPanel.xaml | 30 +++++ Controls/PreviewPane.xaml.cs | 26 +++- Controls/index.md | 4 +- HANDOFF.md | 92 +++++++++---- Models/SceneElement.cs | 13 +- Models/VerticalCameraLayout.cs | 69 ++++++++++ Models/WebcamSceneConfig.cs | 150 ++++++++++++++++++++++ Models/index.md | 5 +- MyMistakes.md | 59 +++++++++ Services/LayoutStore.Load.cs | 15 ++- Services/LayoutStore.Migrations.cs | 22 ++++ Services/LayoutStore.Save.cs | 57 ++++++-- Services/index.md | 2 +- TASKS.md | 10 ++ TASKS/task-57-vertical-camera-layout.md | 69 ++++++++++ ViewModels/MainViewModel.Streaming.cs | 8 ++ ViewModels/MainViewModel.Webcam.cs | 130 +++++++++++++++++++ ViewModels/index.md | 4 +- ai.md | 66 ++++++++++ ytLive.Tests/VerticalWebcamLayoutTests.cs | 134 +++++++++++++++++++ 20 files changed, 911 insertions(+), 54 deletions(-) create mode 100644 Models/VerticalCameraLayout.cs create mode 100644 TASKS/task-57-vertical-camera-layout.md create mode 100644 ytLive.Tests/VerticalWebcamLayoutTests.cs diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 59ef205..7530d09 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -252,6 +252,36 @@ Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/> + + + + + + + + + + + + + + + + + diff --git a/Controls/PreviewPane.xaml.cs b/Controls/PreviewPane.xaml.cs index c7b7f5f..2c92b5a 100644 --- a/Controls/PreviewPane.xaml.cs +++ b/Controls/PreviewPane.xaml.cs @@ -442,12 +442,22 @@ public partial class PreviewPane : UserControl if (_isDraggingOverlay) { - var minX = -(selected.Width - 20); - var maxX = 1920 - 20; - var minY = -(selected.Height - 20); - var maxY = 1080 - 20; - selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX)); - selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY)); + // TASK 57: under the vertical tier the camera snaps into the + // cropped band (one of six spots) instead of free-floating over + // the master — outside the band it is not in the stream at all. + if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam) + { + vm.MoveWebcamInVerticalTier(verticalCam, p.X - _grabOffset.X, p.Y - _grabOffset.Y); + } + else + { + var minX = -(selected.Width - 20); + var maxX = 1920 - 20; + var minY = -(selected.Height - 20); + var maxY = 1080 - 20; + selected.X = Math.Clamp(p.X - _grabOffset.X, Math.Min(minX, maxX), Math.Max(minX, maxX)); + selected.Y = Math.Clamp(p.Y - _grabOffset.Y, Math.Min(minY, maxY), Math.Max(minY, maxY)); + } } else if (_isResizing) { @@ -465,6 +475,10 @@ public partial class PreviewPane : UserControl } selected.Width = newW; selected.Height = newH; + + // The spot's anchor follows the new size (TASK 57). + if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam) + vm.SnapWebcamInVerticalTier(verticalCam); } } diff --git a/Controls/index.md b/Controls/index.md index 6c587cc..83adb18 100644 --- a/Controls/index.md +++ b/Controls/index.md @@ -10,9 +10,9 @@ See [`schema.md`](../schema.md) for the memory-map conventions. | `BottomBar.xaml(.cs)` | Footer (Row 3): stream stats (bitrate/fps/dropped/duration/health), resolution dropdown (gear menu moved up to TopBar 2026-09-22) | | `HealthBanner.xaml(.cs)` | Row 1 status banner + the 7 value→visibility converters live in the app theme, not here | | `TopBar.xaml(.cs)` | Row 0 session surface (2026-09-22 redesign): brand + gear menu (left), one segmented REC|ON-AIR mode switch + world actions (Record: Start Recording · Stream: Go Live + Test) + reality line while running (dot/word/elapsed) + End (center), account zone — Sign In or avatar w/ Change Account/Logout context menu (right). `RefreshAvatar()`, `GearButton_Click` | -| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials) | +| `PreviewPane.xaml(.cs)` | Row 1 Col 1 center: the 1920×1080 `OverlayCanvas`/`SelectionOverlay`/`BrandFlashLayer`/`SocialBarElement`, text pull-out drawer, and the live-controls row (TRAX / game / mic meters + sliders + speakers + socials). **TASK 57:** dragging a webcam under the vertical tier routes through `MainViewModel.MoveWebcamInVerticalTier` (the six-spot snap inside the cropped band) instead of free-floating over the master, and a resize re-snaps the tile onto its spot | | `SceneThumbnailStrip.xaml(.cs)` | Row 0 scene-picker strip (`ItemsControl` over `Scenes`), staged-red / live-green border highlight | -| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on) | +| `LeftPanel.xaml(.cs)` | Row 1 Col 0 left panel: edit mode → layers+properties; live → YouTube chat. Source add/edit/remove, list drag-to-reorder, snapshot revert-accept. **TASK 51:** the webcam properties carry the Background section — `RemoveBackgroundPill` / `BlurBackgroundPill` (`PillToggle`, radio-exclusive via `WebcamSceneConfig.RemoveBackground`/`BlurBackground`) + `BackgroundStrengthSlider` (0..1, 5% snaps, shown only while an effect is on). **TASK 57:** the webcam properties carry a Geometry section (X/Y/W/H) — manual placement of the camera, bound to the **live** placement (the one the current tier shows), gated on `IsWebcamSelected` (not `SelectedElement.IsWebcam` — the TASK 51 test finds that panel by binding text and would match twice) | | `OverlayHost.xaml(.cs)` | Root-grid overlay dialogs: Settings / Report Bug / Feature Request / About (+ licenses + license-key entry). `BackToAbout_Click` lives here | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above | | `LeftPanel.xaml.cs` | Code-behind for the panel above | diff --git a/HANDOFF.md b/HANDOFF.md index 736de4b..0818356 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,17 +1,20 @@ -# HANDOFF — 2026-10-21 (HA lights session; the day's planned llamacasty work did NOT happen) +# HANDOFF — 2026-10-21 (vv webcam Unit 1 shipped; the previous HA-lights session's state below) ## At a glance -- **Branch** main, tracking `origin/main`. **origin/main = `931ab46`; HEAD = `d1e73a3`** — **6 ahead, 0 - behind** (nothing pushed; push only at sub-milestones/milestones, at the creator's word). This file - cannot record its own commit's hash, so the tip is named by subject. - - Local-only commits: `3cb29ea`, `a3a4681`, `84cbec2`, `cdf82f6`, `2a15be2`, `d1e73a3`, plus this - session's. Always check `git log origin/main..HEAD` — never trust a handoff's own "0 ahead" claim. -- **This session produced NO ytLive code, NO build, NO test run.** It was spent — entirely and - wastefully — on the creator's Home Assistant light alert system (off-repo, `192.168.50.145`). See - `MyMistakes.md` → 2026-10-21: the plan was one tweak to a verified-working script; I rewrote the - script twice, broke the alert path, and burned the day. The creator had to say so before I stopped. -- **The HA work landed OK in the end** (recipes + measurements now in `MyMistakes.md`). Records below so - the next session does not re-hunt. **llamacasty / the vv webcam Unit 1 is still NOT started.** +- **Branch** main, tracking `origin/main`. **origin/main = `931ab46`; HEAD = `58b9a7d` + this session's + commit** — **9 ahead, 0 behind** (nothing pushed; push only at sub-milestones/milestones, at the + creator's word). This file cannot record its own commit's hash, so the tip is named by subject. + - Local-only commits: `3cb29ea`, `a3a4681`, `84cbec2`, `cdf82f6`, `2a15be2`, `d1e73a3`, `fb851f0`, + `b1d77ee`, `58b9a7d`, plus this session's. Always check `git log origin/main..HEAD` — never trust a + handoff's own "0 ahead" claim. +- **This session shipped vv webcam Unit 1** (the vertical 9:16 tier keeps its OWN camera placement per + scene; 16:9 restores the desktop layout exactly). Code + memory in ONE commit; the ONE Good Dog test + is `ytLive.Tests/VerticalWebcamLayoutTests.cs`. **Unit 2 (vv · Chat) is next** — see the vv section. +- **The previous session (HA lights) produced NO ytLive code** — it was spent on the creator's Home + Assistant light alert system (off-repo, `192.168.50.145`). See `MyMistakes.md` → 2026-10-21: the plan + was one tweak to a verified-working script; the script was rewritten twice, the alert path broke, and + the day was burned. **The HA work landed OK in the end** (recipes + measurements now in + `MyMistakes.md`). Records below so the next session does not re-hunt. ## HA light alert system — current, verified state (2026-10-21) Five Tapo bulbs: `light.br1`, `light.br2`, `light.officeone`, `light.officetwo`, @@ -51,7 +54,7 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel "unavailable" claim was a bad read during a reload; `alert_leak_on` now also triggers on the latched `sensor.pump_leak_last_water_leak_alert`. -## vv webcam feature — locked rules (creator, 2026-10-17 — unchanged, still awaiting go-ahead) +## vv webcam feature — locked rules (creator, 2026-10-17) — **Unit 1 shipped 2026-10-21** | mode | shape | size | position | |---|---|---|---| | 16:9 | as today | as today | as today | @@ -83,21 +86,53 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel - **Precedent for remembered geometry:** `RectWidth/RectHeight` + `ToggleClipShape` (`Models/SceneElement.cs` 124–152). +### What shipped in Unit 1 (2026-10-21) — do not re-hunt +- **Geometry only — NO render-path change.** The tier switch swaps the element's LIVE geometry; the + preview and the compositor read `X/Y/W/H` + `ClipShape` exactly as before. +- `Models/VerticalCameraLayout.cs` (new): the pure math — band x 656.25, w 607.5, h 1080; two + 607×540 areas; six spots; default tile **270×152 master px** (= 480×270 in the 1080×1920 output), + **bottom-right** spot; inset 32 master px. +- `Models/WebcamSceneConfig.cs`: the vertical placement is persisted in **seven additive columns** + (`VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight`, NULL = "this + scene has no vertical placement yet"); the 16:9 layout stays in the old columns and is **parked** in + the transient `Landscape*` fields while the vertical tier is live. `BeginVerticalLayout` / + `EndVerticalLayout` / `CommitLiveLayoutToVertical` do the swap. +- `Models/SceneElement.cs`: `X/Y/Width/Height` are now **`virtual`** so the webcam clamps EVERY write + (drag, resize, typed fields) into the band — outside the band = invisible in vv. +- `ViewModels/MainViewModel.Webcam.cs`: `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts` + (the tier swap; the **Live scene gets its tile automatically** on the first vertical switch) + + `MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap, by the tile's CENTRE). + `MainViewModel.Streaming.cs` `ApplyStreamQuality` is the tier-change hook. +- `Controls/LeftPanel.xaml`: the webcam properties gained a **Geometry** section (X/Y/W/H) bound to + the live placement, gated on the VM's `IsWebcamSelected` (NOT `SelectedElement.IsWebcam` — the TASK 51 + test finds that panel by binding text and would match twice). +- `Controls/PreviewPane.xaml.cs`: dragging/resizing a webcam under the vertical tier routes through the + VM's snap instead of free-floating over the master. +- Test seam: the drag lives in a **VM method**, so the test drives it without synthesising mouse input. + ### Units (each: ONE Good Dog integration test, own commit, memory in the same commit) -1. **vv camera layout** — stored separately per scene, resolved when the tier changes. Live: small tile - (circle or rect) snapped to one of six spots; drag like the socials + properties entry. - *Test:* with the vertical tier active, dragging the Live camera to top-right places it top-right - inside the band; switching back to 16:9 restores the original layout. -2. **vv · Chat** — rect enforced with the circle option unavailable, plus enlarge-to-one-area (607×540) - snapping top or bottom. +1. ✅ **vv camera layout (Unit 1)** — shipped 2026-10-21. Stored separately per scene, resolved when the + tier changes. Live: small tile (circle or rect) snapped to one of six spots; drag like the socials + + properties entry. + *Test:* `ytLive.Tests/VerticalWebcamLayoutTests.cs` — with the vertical tier active, dragging the Live + camera to top-right places it top-right inside the band; switching back to 16:9 restores the original + layout exactly; both placements survive a save + reload. **PASSES.** +2. ☐ **vv · Chat (Unit 2)** — rect enforced with the circle option unavailable, plus enlarge-to-one-area + (607×540) snapping top or bottom. *Test:* in Chat under the vertical tier, enlarge sets the full top area and the circle option is not - offered. + offered. **Not started.** ## Next step -- **Implement vv Unit 1 on the creator's go-ahead.** Nothing is queued in `TASKS.md` — the creator queues - work; this plan lives here until then. Queue it in `TASKS.md` as part of the first Unit 1 commit. -- **The creator's stated plan for "today" (2026-10-21) was llamacasty. It did not happen.** Confirm the - intended target before starting — do not assume. +- **vv Unit 2 (the Chat scene)** — the only queued piece of this feature. Requirements are locked in + `TASKS/task-57-vertical-camera-layout.md`: rect ONLY in the vertical tier (no circle option offered), + size either normal or enlarged to fill one 607×540 area, same six-spot snap; and the Chat scene's + 16:9 exemption from the half-frame cap (`MaxWebcamWidthFor/HeightFor`) must not leak into the + vertical tier. +- **Creator check (needs the creator's hands):** switch the resolution dropdown to "Vertical 1080p60", + confirm the Live camera tile lands inside the band and the Shape button toggles circle/rect there, + then switch back and confirm the desktop layout is untouched. The unit test covers the geometry, not + the eyeball. +- Nothing else is queued for llamacasty — the creator queues work. ## Open (operational) - **GOG Galaxy launcher shows "offline" (2026-10-21) — GOG's own overnight outage, NOT Windows.** GOG @@ -124,11 +159,16 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel - TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s). ## Tests -- Last full run: **428/428 passed, 0 warnings** (2026-10-16, the gate run for `84cbec2`). **No run this - session (no ytLive code was touched).** +- Last full run: **429/429 passed, 0 warnings** (2026-10-21, this session — the vv Unit 1 gate run). - Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`, `MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`, `Round_Clip_Corner…`. + - ⚠️ **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is NOT + "passes in isolation" any more.** During this session it FAILED ALONE on a **clean tree** at + `58b9a7d` (the vv work stashed) with the same "Expected: ImgC / Actual: ImgA" — i.e. it is an + environment/real-mouse flake, **not** a regression from the vv work — and it PASSES in the + full-suite run (429/429). Treat it as environment-dependent, and don't chase it as a regression + without stashing first. ## Secrets / paths (do not re-hunt) - Layout DB: `%APPDATA%\ytLlive\ytLlive.db` (SQLite, full-rewrite per save). Auth: diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs index 04cfcbf..bed4f84 100644 --- a/Models/SceneElement.cs +++ b/Models/SceneElement.cs @@ -184,15 +184,18 @@ public abstract class SceneElement : INotifyPropertyChanged /// What the preview shows: live frames (webcam) or a static image (sources). public abstract ImageSource? DisplaySource { get; } - // Position/transform (per-scene usage) + // Position/transform (per-scene usage). The geometry setters are virtual so + // a derived element can CONSTRAIN them (the webcam clamps itself into the + // vertical tier's band — TASK 57) instead of every writer re-implementing + // the rule. private double _x; - public double X { get => _x; set => Set(ref _x, value); } + public virtual double X { get => _x; set => Set(ref _x, value); } private double _y; - public double Y { get => _y; set => Set(ref _y, value); } + public virtual double Y { get => _y; set => Set(ref _y, value); } private double _width; - public double Width + public virtual double Width { get => _width; set @@ -203,7 +206,7 @@ public abstract class SceneElement : INotifyPropertyChanged } private double _height; - public double Height + public virtual double Height { get => _height; set diff --git a/Models/VerticalCameraLayout.cs b/Models/VerticalCameraLayout.cs new file mode 100644 index 0000000..54f9813 --- /dev/null +++ b/Models/VerticalCameraLayout.cs @@ -0,0 +1,69 @@ +using System.Windows; + +namespace ytLive.Models; + +/// +/// The vertical (9:16) tier's camera rules — TASK 57, the "vv" feature. +/// +/// The vertical tier is a centred band crop of the SAME 1920×1080 master the +/// 16:9 tiers use, so a camera placed for 16:9 lands outside the cropped +/// window and vanishes from the vertical output. The vertical tier therefore +/// keeps its OWN placement per scene: a small tile inside the band, snapped to +/// one of six spots — the band splits into a top and a bottom half, each with +/// a left, a centre and a right slot. +/// +/// Geometry only: the preview and the compositor both read the element's own +/// X/Y/W/H + ClipShape, so the placement is resolved by swapping those values +/// when the tier changes. No render-path change. +/// +public static class VerticalCameraLayout +{ + /// The vertical tier's camera tile over the master frame — + /// 270×152 master px = 480×270 in the 1080×1920 output. + public const double TileWidth = 270; + public const double TileHeight = 152; + + /// How far the tile keeps from the band's outer edge — the app's + /// own placement margin (32 master px) used by the default webcam spot. + public const double Inset = 32; + + /// True for the portrait tier (the vertical 1080×1920 one). + public static bool IsVerticalTier(QualityOption quality) => quality.Height > quality.Width; + + /// The half of the band a tile lives in — top or bottom. + public static Rect AreaFor(Rect band, bool bottom) + => new(band.Left, bottom ? band.Top + band.Height / 2 : band.Top, band.Width, band.Height / 2); + + /// The largest a camera may be inside the band: one whole area. + public static (double Width, double Height) MaxSizeFor(Rect band) + => (band.Width, band.Height / 2); + + /// + /// The spot a tile snaps to: the area (top/bottom) and the column + /// (left/centre/right) its centre falls in, then the tile's top-left for + /// that spot — inset from the band's outer edge, and never outside the band + /// (a tile wider than the inset eats it). + /// + public static (double X, double Y) SpotFor(Rect band, double width, double height, double centreX, double centreY) + { + var bottom = centreY >= band.Top + band.Height / 2; + var area = AreaFor(band, bottom); + var third = area.Width / 3; + + double x; + if (centreX < area.Left + third) x = area.Left + Inset; + else if (centreX < area.Left + 2 * third) x = area.Left + (area.Width - width) / 2; + else x = area.Right - Inset - width; + + var y = bottom ? area.Bottom - Inset - height : area.Top + Inset; + + x = Math.Clamp(x, band.Left, Math.Max(band.Left, band.Right - width)); + y = Math.Clamp(y, band.Top, Math.Max(band.Top, band.Bottom - height)); + return (x, y); + } + + /// The default tile spot: the bottom-right corner of the band — + /// the same corner the 16:9 default webcam placement uses. + public static (double X, double Y) DefaultSpot(Rect band, double width, double height) + => SpotFor(band, width, height, band.Right, band.Bottom); +} diff --git a/Models/WebcamSceneConfig.cs b/Models/WebcamSceneConfig.cs index 2a84665..d9e5102 100644 --- a/Models/WebcamSceneConfig.cs +++ b/Models/WebcamSceneConfig.cs @@ -1,3 +1,4 @@ +using System.Windows; using System.Windows.Media; namespace ytLive.Models; @@ -57,4 +58,153 @@ public class WebcamSceneConfig : SceneElement set => BackgroundEffect = value ? BackgroundEffect.Blur : BackgroundEffect == BackgroundEffect.Blur ? BackgroundEffect.None : BackgroundEffect; } + + // ─── Vertical (9:16) tier placement (TASK 57, the "vv" feature) ──────── + // The vertical tier is a centred band crop of the same master frame, so the + // 16:9 placement lands outside the cropped window and the camera vanishes + // from the vertical output. The vertical tier therefore keeps its OWN + // position, size and shape per scene, stored here and resolved when the + // tier changes; switching back to 16:9 restores the desktop layout exactly. + + /// The vertical placement's X (null until the scene has one). + public double? VerticalX { get; set; } + + /// The vertical placement's Y (null until the scene has one). + public double? VerticalY { get; set; } + + /// The vertical placement's width (null until the scene has one). + public double? VerticalWidth { get; set; } + + /// The vertical placement's height (null until the scene has one). + public double? VerticalHeight { get; set; } + + /// The vertical placement's clip shape (circle or rect) — the + /// creator's own choice per scene, independent of the 16:9 shape. + public ClipShape? VerticalClipShape { get; set; } + + /// The vertical placement's pre-Round rect (so a Round tile + /// restores its aspect on toggle-back, like the 16:9 one does). + public double? VerticalRectWidth { get; set; } + + /// + public double? VerticalRectHeight { get; set; } + + /// True once this scene has a vertical placement of its own. + public bool HasVerticalLayout => VerticalX is not null; + + /// True while the vertical placement is the LIVE geometry (the + /// element's own X/Y/W/H). The 16:9 layout is parked in + /// and friends until the tier switches back. + public bool VerticalLayoutLive { get; private set; } + + /// The vertical tier's band over the master frame (transient — the + /// VM sets it when the tier changes). While the vertical placement is live + /// the camera's geometry is clamped to it: outside the band is invisible. + public Rect VerticalBand { get; set; } = Rect.Empty; + + // The parked 16:9 layout — meaningful only while the vertical placement is + // live (it is the 16:9 geometry the tier switch restores). + public double LandscapeX { get; private set; } + public double LandscapeY { get; private set; } + public double LandscapeWidth { get; private set; } + public double LandscapeHeight { get; private set; } + public ClipShape LandscapeClipShape { get; private set; } + public double? LandscapeRectWidth { get; private set; } + public double? LandscapeRectHeight { get; private set; } + + /// Parks the live (16:9) layout and brings a vertical placement + /// in. The vertical placement becomes the live geometry from here on. + public void BeginVerticalLayout(double x, double y, double width, double height, ClipShape clipShape) + { + LandscapeX = X; + LandscapeY = Y; + LandscapeWidth = Width; + LandscapeHeight = Height; + LandscapeClipShape = ClipShape; + LandscapeRectWidth = RectWidth; + LandscapeRectHeight = RectHeight; + + VerticalLayoutLive = true; + Width = width; + Height = height; + X = x; + Y = y; + ClipShape = clipShape; + RectWidth = VerticalRectWidth; + RectHeight = VerticalRectHeight; + } + + /// Restores the parked 16:9 layout — the tier switched back. + public void EndVerticalLayout() + { + if (!VerticalLayoutLive) return; + + // The vertical placement is the creator's own — keep it, so switching + // back and forth does not lose where they put it. + CommitLiveLayoutToVertical(); + + VerticalLayoutLive = false; + Width = LandscapeWidth; + Height = LandscapeHeight; + X = LandscapeX; + Y = LandscapeY; + ClipShape = LandscapeClipShape; + RectWidth = LandscapeRectWidth; + RectHeight = LandscapeRectHeight; + } + + /// Copies the live geometry into the stored vertical placement, so + /// the stored copy never lags the live one (the save path calls this). + public void CommitLiveLayoutToVertical() + { + VerticalX = X; + VerticalY = Y; + VerticalWidth = Width; + VerticalHeight = Height; + VerticalClipShape = ClipShape; + VerticalRectWidth = RectWidth; + VerticalRectHeight = RectHeight; + } + + // The geometry is clamped into the band while the vertical placement is + // live — a camera outside the cropped window is a camera the viewers never + // see, and every writer (drag, resize handle, the properties fields) goes + // through these setters. + public override double X + { + get => base.X; + set => base.X = ClampToBand(value, base.Width, vertical: true); + } + + public override double Y + { + get => base.Y; + set => base.Y = ClampToBand(value, base.Height, vertical: false); + } + + public override double Width + { + get => base.Width; + set => base.Width = VerticalLayoutLive && !VerticalBand.IsEmpty + ? Math.Min(value, VerticalBand.Width) + : value; + } + + public override double Height + { + get => base.Height; + set => base.Height = VerticalLayoutLive && !VerticalBand.IsEmpty + ? Math.Min(value, VerticalBand.Height / 2) + : value; + } + + private double ClampToBand(double value, double size, bool vertical) + { + if (!VerticalLayoutLive || VerticalBand.IsEmpty) return value; + var band = VerticalBand; + var min = vertical ? band.Left : band.Top; + var max = vertical ? band.Right - size : band.Bottom - size; + if (max < min) return min; + return Math.Clamp(value, min, max); + } } diff --git a/Models/index.md b/Models/index.md index 3c7a68e..ea8fbc0 100644 --- a/Models/index.md +++ b/Models/index.md @@ -8,11 +8,12 @@ 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 52); `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 52); `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload); **TASK 57:** the geometry setters (`X`/`Y`/`Width`/`Height`) are `virtual` so a derived element can CONSTRAIN every write (the webcam clamps itself into the vertical tier's band) instead of every writer re-implementing the rule | +| `VerticalCameraLayout.cs` | **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera rules as pure math — `TileWidth`/`TileHeight` (270×152 master px = 480×270 in the 1080×1920 output), `Inset` (32 master px from the band's outer edge), `IsVerticalTier(QualityOption)` (portrait tier), `AreaFor(band, bottom)` (the band splits into two 607×540 areas), `MaxSizeFor(band)`, `SpotFor(band, w, h, centreX, centreY)` (the six spots — the area and column the tile's **centre** falls in, inset, never outside the band) and `DefaultSpot` (bottom-right, the corner the 16:9 default webcam uses) | | `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) | | `BackgroundEffect.cs` | **TASK 51:** per-webcam background effect enum `None` / `Remove` / `Blur`, carried by `WebcamSceneConfig` | -| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider) | +| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam`; **TASK 51:** `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5) + the radio-pill surface `RemoveBackground`/`BlurBackground` (one on turns the other off; both off = None) + `HasBackgroundEffect` (shows the strength slider); **TASK 57 (the "vv" feature):** the vertical (9:16) tier's OWN placement per scene — `VerticalX/Y/Width/Height/VerticalClipShape/VerticalRectWidth/VerticalRectHeight` (persisted, NULL = no vertical placement yet) + the transient `VerticalBand`/`VerticalLayoutLive`/`LandscapeX…LandscapeRectHeight` (the parked 16:9 layout) + `BeginVerticalLayout`/`EndVerticalLayout`/`CommitLiveLayoutToVertical` (the tier swap) and the `X`/`Y`/`Width`/`Height` overrides that clamp every write into the band | | `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 | | `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum | | `MicStatus.cs` | Footer mic indicator: `NotConnected` / `Connected` / `Problem` — the state behind the MIC button's status dot (red = no device/capture off, green = capturing, yellow = a requested mic failed: in use/unplugged) | diff --git a/MyMistakes.md b/MyMistakes.md index 1ffa2f6..e43b73e 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -1923,3 +1923,62 @@ Producer/consumer split, and **one single writer** of the bulbs: - Alerts **hold** until a human clears them; a sensor returning to normal notifies but does not clear. - Colours: leak purple, alarm red, motion orange (reserved), pump blue, doorbell green (doorbell is not an alert — it guards on `light_alert != 'none'` and hands back to the render). + +## 2026-10-21 — TWO LAYOUTS FOR ONE ELEMENT ACROSS OUTPUT TIERS, WITHOUT TOUCHING THE RENDER PATH (RECIPE — TASK 57) + +The vertical (9:16) tier is a centred **band crop** of the same 1920×1080 master, so the 16:9 +placement of an element lands outside the cropped window and the element vanishes from the +stream. The render path (preview + compositor) reads the element's own `X`/`Y`/`Width`/`Height` + +`ClipShape` and knows nothing about tiers — and that is the point: **do not teach the render +path about tiers.** + +**The recipe (four moves):** + +1. **Park the inactive layout in the model.** The element keeps a transient copy of the layout + that is not live (`LandscapeX/Y/Width/Height/ClipShape/RectWidth/RectHeight`) next to the + persisted copy of the other one (`VerticalX/…`, NULL = "not set for this scene"). +2. **Swap on the tier change.** `BeginVerticalLayout(...)` parks the live layout and brings the + other in; `EndVerticalLayout()` commits the outgoing layout back into its stored slot and + restores the parked one. The live geometry IS the render input → the compositor and the + preview XAML stay untouched. +3. **Keep the persisted columns for ONE layout.** The old columns (X/Y/W/H/ClipShape) keep + meaning "the 16:9 layout"; the save writes the parked values there while the other tier is + live. The new columns carry the other layout. A save can then never lose either. +4. **Commit the live copy before you persist it.** The stored copy lags the live one until the + save — so the tier switch itself commits (otherwise switching away and back loses the + creator's placement), and the save commits again (the DB write must not race the copy). + +**The clamp belongs in ONE place: the model's own setters.** Make the base geometry setters +`virtual` and override them in the derived element, so *every* writer — drag, resize handle, +typed properties fields, code — is constrained by the same rule. Three writers each re-implementing +"inside the band" is three chances to forget one. (This is why `SceneElement.X/Y/Width/Height` +became virtual.) + +**Why not the alternative:** a computed "effective geometry" property (`RenderX`) would have +touched the compositor's element read *and* every preview binding — a render-path change for a +geometry-only problem. The swap keeps the render path a pure consumer of the live geometry. + +**Test seam:** the drag logic must live in a method the VM exposes, not in the control's mouse +handler — the test then drives the placement without synthesising mouse input. + +## 2026-10-21 — A XAML TEST THAT FINDS A PANEL BY BINDING TEXT BREAKS WHEN A SECOND PANEL SHARES THAT BINDING (TASK 57) + +`WebcamBackgroundPillTests` locates the webcam's properties panel by **searching the XAML text +for `SelectedElement.IsWebcam`** and then asserts on it. Adding a second panel with the same +visibility binding made the selector match twice → `Sequence contains more than one matching +element` → a test failure that had nothing to do with the feature under construction. + +**Rule:** a properties panel that a test finds by binding text must have a **unique** binding. +Use the VM's semantic flag (`IsWebcamSelected`) for a new panel instead of repeating +`SelectedElement.IsWebcam`, and when a test selects UI by binding text, expect to have to keep +that text unique. Cheap fix, but only if you know the test selects that way — read the test +before you add UI. + +## 2026-10-21 — THE LAYOUT STORE WRITES THE WEBCAM ROWS ONLY WHEN A WEBCAM IDENTITY EXISTS (TASK 57) + +`LayoutStore.Save(scenes, webcam, …)` writes the `Webcam` row **and** the per-scene +`WebcamSceneConfig` rows only when `webcam != null` — the config rows hang off the identity. +A test that adds a `WebcamSceneConfig` to a scene and saves **without** an identity persists +nothing, and the reload comes back with no camera (a confusing "my placement vanished" +failure). Supply a `Webcam` identity in the test (`new Webcam { Id = "webcam-1", DeviceId = … }`) +or the round-trip assertion is meaningless. diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index fa0ffec..dfd9261 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -166,7 +166,9 @@ public partial class LayoutStore : IDisposable cmd.CommandText = """ SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, - RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength + RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength, + VerticalX, VerticalY, VerticalWidth, VerticalHeight, VerticalClipShape, + VerticalRectWidth, VerticalRectHeight FROM WebcamSceneConfig ORDER BY SortOrder """; using var reader = cmd.ExecuteReader(); @@ -193,6 +195,17 @@ public partial class LayoutStore : IDisposable RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15), BackgroundEffect = Enum.TryParse(reader.GetString(17), out var effect) ? effect : BackgroundEffect.None, BackgroundStrength = reader.GetDouble(18), + // TASK 57: the vertical tier's own placement (null = this + // scene keeps its 16:9 placement under the vertical tier). + VerticalX = reader.IsDBNull(19) ? null : reader.GetDouble(19), + VerticalY = reader.IsDBNull(20) ? null : reader.GetDouble(20), + VerticalWidth = reader.IsDBNull(21) ? null : reader.GetDouble(21), + VerticalHeight = reader.IsDBNull(22) ? null : reader.GetDouble(22), + VerticalClipShape = reader.IsDBNull(23) + ? null + : Enum.TryParse(reader.GetString(23), out var vClip) ? vClip : null, + VerticalRectWidth = reader.IsDBNull(24) ? null : reader.GetDouble(24), + VerticalRectHeight = reader.IsDBNull(25) ? null : reader.GetDouble(25), }; if (!configsByScene.TryGetValue(sceneId, out var list)) configsByScene[sceneId] = list = new List<(WebcamSceneConfig, int)>(); diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 70148de..6dd5e9b 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -378,6 +378,28 @@ public partial class LayoutStore : IDisposable cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN BackgroundStrength REAL NOT NULL DEFAULT 0.5;"; cmd.ExecuteNonQuery(); } + + // TASK 57 (the "vv" feature): the vertical (9:16) tier keeps its OWN + // camera placement per scene — position, size, shape and the pre-Round + // rect. Additive, no version bump: a NULL column means "this scene has + // no vertical placement yet" (the scene keeps its 16:9 placement). + AddWebcamConfigColumnIfMissing(columns, "VerticalX", "REAL"); + AddWebcamConfigColumnIfMissing(columns, "VerticalY", "REAL"); + AddWebcamConfigColumnIfMissing(columns, "VerticalWidth", "REAL"); + AddWebcamConfigColumnIfMissing(columns, "VerticalHeight", "REAL"); + AddWebcamConfigColumnIfMissing(columns, "VerticalClipShape", "TEXT"); + AddWebcamConfigColumnIfMissing(columns, "VerticalRectWidth", "REAL"); + AddWebcamConfigColumnIfMissing(columns, "VerticalRectHeight", "REAL"); + } + + // One guarded ADD COLUMN for the webcam config's vertical-placement columns. + private void AddWebcamConfigColumnIfMissing(HashSet columns, string name, string type) + { + if (columns.Contains(name)) return; + using var cmd = _connection.CreateCommand(); + cmd.CommandText = $"ALTER TABLE WebcamSceneConfig ADD COLUMN {name} {type};"; + cmd.ExecuteNonQuery(); + columns.Add(name); } // v2 → v3: the webcam leaves the Source table. Any webcam Source rows become diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index b52a550..da4555d 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -211,10 +211,13 @@ public partial class LayoutStore : IDisposable INSERT INTO WebcamSceneConfig (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity, ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, - RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength) + RectWidth, RectHeight, SortOrder, BackgroundEffect, BackgroundStrength, + VerticalX, VerticalY, VerticalWidth, VerticalHeight, VerticalClipShape, + VerticalRectWidth, VerticalRectHeight) VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity, $clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation, - $rectWidth, $rectHeight, $sort, $bgEffect, $bgStrength) + $rectWidth, $rectHeight, $sort, $bgEffect, $bgStrength, + $vX, $vY, $vW, $vH, $vClip, $vRectWidth, $vRectHeight) """; cmd.Transaction = tx; var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text); @@ -236,6 +239,13 @@ public partial class LayoutStore : IDisposable var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); var bgEffectP = cmd.Parameters.Add("$bgEffect", SqliteType.Text); var bgStrengthP = cmd.Parameters.Add("$bgStrength", SqliteType.Real); + var vXP = cmd.Parameters.Add("$vX", SqliteType.Real); + var vYP = cmd.Parameters.Add("$vY", SqliteType.Real); + var vWP = cmd.Parameters.Add("$vW", SqliteType.Real); + var vHP = cmd.Parameters.Add("$vH", SqliteType.Real); + var vClipP = cmd.Parameters.Add("$vClip", SqliteType.Text); + var vRectWidthP = cmd.Parameters.Add("$vRectWidth", SqliteType.Real); + var vRectHeightP = cmd.Parameters.Add("$vRectHeight", SqliteType.Real); foreach (var scene in scenes) { @@ -248,20 +258,49 @@ public partial class LayoutStore : IDisposable sceneIdP.Value = scene.Id; webcamIdP.Value = config.WebcamId; visibleP.Value = config.IsVisible ? 1 : 0; - xP.Value = config.X; - yP.Value = config.Y; - wP.Value = config.Width; - hP.Value = config.Height; opacityP.Value = config.Opacity; - clipP.Value = config.ClipShape.ToString(); mirroredP.Value = config.IsMirrored ? 1 : 0; colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value; borderOpacityP.Value = config.BorderOpacity; borderWidthP.Value = config.BorderWidth; animationP.Value = config.BorderAnimation.ToString(); - rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value; - rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value; sortP.Value = z++; + + // TASK 57: the X/Y/W/H/ClipShape columns stay the 16:9 + // layout — while the vertical tier is live the LIVE + // geometry is the vertical placement and the 16:9 one is + // parked in the model — and the Vertical* columns carry + // the vertical placement itself. + if (config.VerticalLayoutLive) + { + config.CommitLiveLayoutToVertical(); + xP.Value = config.LandscapeX; + yP.Value = config.LandscapeY; + wP.Value = config.LandscapeWidth; + hP.Value = config.LandscapeHeight; + clipP.Value = config.LandscapeClipShape.ToString(); + rectWidthP.Value = (object?)config.LandscapeRectWidth ?? DBNull.Value; + rectHeightP.Value = (object?)config.LandscapeRectHeight ?? DBNull.Value; + } + else + { + xP.Value = config.X; + yP.Value = config.Y; + wP.Value = config.Width; + hP.Value = config.Height; + clipP.Value = config.ClipShape.ToString(); + rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value; + rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value; + } + + vXP.Value = (object?)config.VerticalX ?? DBNull.Value; + vYP.Value = (object?)config.VerticalY ?? DBNull.Value; + vWP.Value = (object?)config.VerticalWidth ?? DBNull.Value; + vHP.Value = (object?)config.VerticalHeight ?? DBNull.Value; + vClipP.Value = (object?)config.VerticalClipShape?.ToString() ?? DBNull.Value; + vRectWidthP.Value = (object?)config.VerticalRectWidth ?? DBNull.Value; + vRectHeightP.Value = (object?)config.VerticalRectHeight ?? DBNull.Value; + cmd.ExecuteNonQuery(); } } diff --git a/Services/index.md b/Services/index.md index 2cf56f3..b2d8fcc 100644 --- a/Services/index.md +++ b/Services/index.md @@ -15,7 +15,7 @@ External-facing logic: YouTube API, persistence. See | `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card. **Still the fallback** when no alert clip is playable (TASK 47) | | `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43; video branch TASK 47):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame). Since TASK 47 a clip branch owns `IAlertClipDecoder` per play — ctor seams `(clipDecoderFactory, clipPathResolver, audioSink)`, fade in/out over `FadeDurationSeconds = 0.30` (straight-alpha copies + volume×fade audio), EOF freeze-frame → drain, and `AlertTickerFrame` composes the scrolling message strip | | `AlertClipDecoder.cs` | **Per-play alert video decoder (TASK 47):** `IAlertClipDecoder` (Start/Stop/Dispose + `FrameAvailable`/`AudioReady`/`Completed`) + ffmpeg child emitting `rawvideo bgra` (`-vf scale=W:H`, read via `RawVideoFrameReader`) and `f32le -ar 48000 -ac 2` on a **carry-buffer** audio loop (float boundary straddles never drop samples), both real-time paced; bgra is opaque (layer owns alpha). One-shot lifetime — created per alert, disposed at drain; tests inject a fake | -| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | +| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 10 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; v9→v10 (TASK 39) = `YppSnapshot` history table; all later columns stay additive guarded ALTERs — TASK 51 adds `WebcamSceneConfig.BackgroundEffect`/`BackgroundStrength`; **TASK 57 (the "vv" feature) adds the seven `WebcamSceneConfig.Vertical*` columns** (`VerticalX`/`VerticalY`/`VerticalWidth`/`VerticalHeight`/`VerticalClipShape`/`VerticalRectWidth`/`VerticalRectHeight` — the vertical tier's own camera placement per scene; NULL = the scene keeps its 16:9 placement. The X/Y/W/H/ClipShape columns stay the **16:9** layout: while the vertical tier is live the save writes the model's parked `Landscape*` values there and the live ones into the `Vertical*` columns); TASK 47 adds the five alert `Source` columns (`AlertVideoAssetId`/`AlertVideoPath`/`AlertUseDefaultVideo`/`AlertShowTicker`/`AlertVideoVolume`) without a version bump; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup), `AlertDefaultVideoAssetId` (TASK 47 — the stamped built-in alert clip's asset row; save's prune exempts it via UNION) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes | | `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) | diff --git a/TASKS.md b/TASKS.md index ebe956c..fc783f0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -72,6 +72,7 @@ | 54 | **Marcom round: free-forever positioning + merch storefront** | 🔶 In progress — llamacasty.com copy live 2026-10-08 (pricing stripped); merch POD + app About links queued | [`TASKS/task-54-marcom-free-merch.md`](TASKS/task-54-marcom-free-merch.md) | | 55 | **Disk hygiene: log rotation + low-disk guard** | ☐ Queued (2026-10-09) — `startup.log` appears uncapped; also temp alert-clip cleanup + free-space check before recording | [`TASKS/task-55-disk-hygiene.md`](TASKS/task-55-disk-hygiene.md) | | 56 | **Pre-publication secret scan (full git history)** | ☐ Queued (2026-10-09) — gate before the Gitea repo goes public; report commit/path only, never values | [`TASKS/task-56-secret-scan.md`](TASKS/task-56-secret-scan.md) | +| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | 🔶 **Unit 1 shipped 2026-10-21** (Live scene: own placement per tier — small tile, circle-or-rect, one of six spots; drag-snap + manual entry; 16:9 restored exactly). **Unit 2 open** (Chat scene: rect only, normal or enlarged to one 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) | --- @@ -154,6 +155,15 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw - **TASK 14** — Branch 2 shipped; branch 3+ in progress - **TASK 21** — UI picker slice remaining (Windows-only verification pending) - **TASK 32–36** — all queued future work (2026-09-01) +- **🔶 TASK 57 — the "vv" vertical camera layout.** The vertical tier is a centred band crop of + the same master, so the 16:9 camera placement falls outside the cropped window and the creator + is not in the vertical stream at all. **Unit 1 shipped 2026-10-21:** each scene keeps its own + vertical placement (position, size, shape); the Live scene gets a small tile in one of six spots + (top/bottom × left/centre/right), circle or rect, placed by drag-snap or typed into the new + Geometry fields; switching back to 16:9 restores the desktop layout exactly. **Unit 2 is next:** + the Chat scene in the vertical tier — rect only, either normal or enlarged to one 607×540 area + (the creator's table, 2026-10-17). See [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) + and `ai.md` → "Vertical (9:16) tier camera placement". - **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate slice shipped same day — webcam = app-level default, one per stream per scene, Add places the diff --git a/TASKS/task-57-vertical-camera-layout.md b/TASKS/task-57-vertical-camera-layout.md new file mode 100644 index 0000000..a9bdcd5 --- /dev/null +++ b/TASKS/task-57-vertical-camera-layout.md @@ -0,0 +1,69 @@ +# TASK 57 — Vertical (9:16) tier camera placement — the "vv" feature + +> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. + +**Status:** 🔶 Unit 1 shipped 2026-10-21 (the Live scene). **Unit 2 open** (the Chat scene). + +## Why +The vertical tier (1080×1920) is a centred **band crop** of the same 1920×1080 master every +16:9 tier uses: `OutputRect*` = x 656.25, y 0, w 607.5, h 1080. The default webcam sits at +x≈1408 — **outside the cropped window entirely**, so in a vertical stream the creator is simply +not in the picture. Switching tiers must not cost the creator their camera. + +## The creator's locked table (2026-10-17) + +| mode | shape | size | position | +|---|---|---|---| +| 16:9 | as today | as today | as today | +| **vv · Live** | circle **or** rect, the creator's choice | small tile | one of **six** spots: top/bottom × left/centre/right | +| vv · Chat | rect only | normal, or enlarged to one 607×540 area | the same six spots (Unit 2) | +| Starting / BRB / Ending | unchanged | unchanged | unchanged | + +Plus: each scene keeps its **own** vertical position, size and shape; switching back to 16:9 +restores the desktop layout **exactly**; placement is set by drag with snap (like the socials) +**and** by manual entry in the properties. + +## Design (Unit 1) + +- **Geometry only — no render-path change.** The preview and the compositor read the element's + own `X`/`Y`/`Width`/`Height` + `ClipShape`, so the tier switch swaps the live geometry: + `WebcamSceneConfig.BeginVerticalLayout(...)` parks the 16:9 layout and brings the vertical one + in; `EndVerticalLayout()` commits the vertical placement back to its stored slot and restores + the parked 16:9 layout. +- **Storage.** Seven additive columns on `WebcamSceneConfig` (`VerticalX/Y/Width/Height/ + VerticalClipShape/VerticalRectWidth/VerticalRectHeight`), NULL = "this scene has no vertical + placement yet". The X/Y/W/H/ClipShape columns stay the **16:9** layout, so a save never loses + the desktop placement. +- **The band and the six spots** (`Models/VerticalCameraLayout.cs`, pure math): the band splits + into two 607×540 areas (top/bottom), each with three columns (left/centre/right). A tile snaps + to the spot its **centre** falls in, inset 32 master px from the band's outer edge and never + outside the band. Default tile: 270×152 master px (480×270 in the 1080×1920 output), bottom-right + spot — the corner the 16:9 default webcam uses. The Live scene gets it automatically the first + time the vertical tier is picked; the other scenes keep their 16:9 placement until the creator + drags them into the band. +- **One clamp, one choke point.** `SceneElement.X/Y/Width/Height` became `virtual` and the webcam + constrains every write (drag, resize handle, typed Geometry fields) into the band — outside it + the camera is invisible in the vertical stream. +- **Manual entry.** The webcam's Layers properties gained a Geometry section (X/Y/W/H) bound to + the **live** placement — the one the current tier shows. + +## Slices (one integration test each) + +1. ✅ **Unit 1 — the Live scene** (shipped 2026-10-21): the vertical tier brings the Live camera + into the band as a small tile; dragging it to the top-right snaps it top-right inside the + band; switching back to 16:9 restores the desktop layout exactly; both placements survive a + save + reload. `ytLive.Tests/VerticalWebcamLayoutTests.cs` — the ONE test for the unit. +2. ☐ **Unit 2 — the Chat scene**: rect only (no circle) in the vertical tier, either the normal + size or enlarged to fill one 607×540 area; the same six-spot snap. The Chat scene's 16:9 + exemption from the half-frame cap must not leak into the vertical tier. +3. ☐ **Creator check** (needs the creator's hands): switch the dropdown to "Vertical 1080p60", + confirm the camera tile lands inside the band and the Shape button toggles circle/rect in the + vertical tier, then switch back and confirm the desktop layout is untouched. + +## Notes + +- OBS cannot do this (no per-aspect layout storage — the maintainers' answer is a second scene + fed as a source); Streamyard documents the same gap. Our per-scene, per-tier stored placement + resolved on the tier change is the step past both. +- The vertical tier's output crop is TASK 53's business (safe zones, caption clearance); the + camera tile is inside the band, but the band is not yet proven against TikTok/Shorts chrome. diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 35701a0..5569a7c 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -422,6 +422,14 @@ public partial class MainViewModel : ViewModelBase OnPropertyChanged(nameof(OutputRectHeight)); OnPropertyChanged(nameof(IsOutputCropped)); OnPropertyChanged(nameof(ResolutionBadgeText)); + + // TASK 57 (the "vv" feature): the tier decides which camera placement + // is live — the vertical tier brings each scene's own vertical placement + // in, 16:9 puts the desktop layout back exactly where it was. + OnPropertyChanged(nameof(IsVerticalTier)); + OnPropertyChanged(nameof(VerticalBand)); + if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality))) + ScheduleSave(); } public ICommand StartStreamCommand { get; } diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs index a2cd6b1..0973816 100644 --- a/ViewModels/MainViewModel.Webcam.cs +++ b/ViewModels/MainViewModel.Webcam.cs @@ -367,6 +367,12 @@ public partial class MainViewModel _sceneGraph.AddElement(scene, config); SelectedElement = config; + + // A camera added while the vertical tier is live starts inside the + // band (TASK 57) — outside it, the camera would not be in the stream. + if (IsVerticalTier) + ResolveVerticalCameraLayout(scene, config, vertical: true); + OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); UpdateActiveBackground(); @@ -448,4 +454,128 @@ public partial class MainViewModel _ = AddWebcamToStagedSceneAsync(); } + + // ─── Vertical (9:16) tier camera placement (TASK 57, the "vv" feature) ── + // The vertical tier is a centred band crop of the same 1920×1080 master, so + // the 16:9 camera placement falls outside the cropped window and the camera + // is simply not in the vertical stream. Each scene therefore keeps its OWN + // vertical placement, and the tier switch swaps the live geometry (the + // preview and the compositor both read the element's own X/Y/W/H — no + // render-path change). + + /// True while the portrait tier (1080×1920) is the active tier. + public bool IsVerticalTier => VerticalCameraLayout.IsVerticalTier(SelectedQuality); + + /// The vertical tier's cropped window over the master frame. + public Rect VerticalBand => new(OutputRectX, OutputRectY, OutputRectWidth, OutputRectHeight); + + /// + /// Resolves every scene's camera placement for the tier that was just + /// picked: the vertical tier brings each scene's own vertical placement in + /// (the Live scene gets its small tile on first use), and switching back to + /// 16:9 puts the desktop layout back exactly where it was. + /// + private bool ResolveVerticalCameraLayouts(bool vertical) + { + var changed = false; + foreach (var scene in Scenes) + foreach (var config in scene.Elements.OfType()) + changed |= ResolveVerticalCameraLayout(scene, config, vertical); + return changed; + } + + private bool ResolveVerticalCameraLayout(Scene scene, WebcamSceneConfig config, bool vertical) + { + if (!vertical) + { + config.VerticalBand = Rect.Empty; + if (!config.VerticalLayoutLive) return false; + config.EndVerticalLayout(); + return true; + } + + config.VerticalBand = VerticalBand; + if (config.VerticalLayoutLive) return false; + + // A scene with a vertical placement of its own gets it back. + if (config.HasVerticalLayout) + { + config.BeginVerticalLayout( + config.VerticalX!.Value, + config.VerticalY!.Value, + config.VerticalWidth ?? VerticalCameraLayout.TileWidth, + config.VerticalHeight ?? VerticalCameraLayout.TileHeight, + config.VerticalClipShape ?? config.ClipShape); + return true; + } + + // The Live scene's camera must be INSIDE the band or the vertical + // output has no camera at all — it starts as a small tile in the + // bottom-right spot (the corner the 16:9 default uses). The other + // scenes keep their 16:9 placement until the creator drags them into + // the band (creator ruling 2026-10-17: Starting/BRB/Ending unchanged). + if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live)) return false; + + var spot = VerticalCameraLayout.DefaultSpot( + VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight); + config.BeginVerticalLayout( + spot.X, spot.Y, + VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight, + config.ClipShape); + return true; + } + + /// + /// Moves a camera inside the vertical band and snaps it to one of the six + /// spots (top/bottom × left/centre/right). The drop point is where the + /// camera's top-left would land; the spot is the one its centre falls in. + /// + public void MoveWebcamInVerticalTier(WebcamSceneConfig config, double x, double y) + { + if (!IsVerticalTier) return; + EnsureVerticalCameraLayout(config); + var spot = VerticalCameraLayout.SpotFor( + VerticalBand, config.Width, config.Height, + x + config.Width / 2, y + config.Height / 2); + config.X = spot.X; + config.Y = spot.Y; + } + + /// Snaps a camera back onto its spot — the resize handle grew or + /// shrank it and the spot's anchor has to follow. + public void SnapWebcamInVerticalTier(WebcamSceneConfig config) + { + if (!IsVerticalTier) return; + EnsureVerticalCameraLayout(config); + var spot = VerticalCameraLayout.SpotFor( + VerticalBand, config.Width, config.Height, + config.X + config.Width / 2, config.Y + config.Height / 2); + config.X = spot.X; + config.Y = spot.Y; + } + + /// Makes the scene's vertical placement live — creating it (a small + /// tile in the default spot) when the scene has none yet. + private void EnsureVerticalCameraLayout(WebcamSceneConfig config) + { + if (config.VerticalLayoutLive) return; + + if (config.HasVerticalLayout) + { + config.BeginVerticalLayout( + config.VerticalX!.Value, + config.VerticalY!.Value, + config.VerticalWidth ?? VerticalCameraLayout.TileWidth, + config.VerticalHeight ?? VerticalCameraLayout.TileHeight, + config.VerticalClipShape ?? config.ClipShape); + return; + } + + var spot = VerticalCameraLayout.DefaultSpot( + VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight); + config.BeginVerticalLayout( + spot.X, spot.Y, + VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight, + config.ClipShape); + } } \ No newline at end of file diff --git a/ViewModels/index.md b/ViewModels/index.md index 348d1b6..586f508 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -14,11 +14,11 @@ areas' docs in their own rows keeps this index small to read. | `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 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.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`. **TASK 57 (the "vv" feature):** the vertical (9:16) tier's camera placement — `IsVerticalTier`/`VerticalBand` + `ResolveVerticalCameraLayouts`/`ResolveVerticalCameraLayout` (the tier swap: the vertical tier brings each scene's own vertical placement in, the Live scene gets its small tile on first use, 16:9 puts the desktop layout back exactly) + `MoveWebcamInVerticalTier`/`SnapWebcamInVerticalTier` (the six-spot snap) + `EnsureVerticalCameraLayout`. | | `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`. | | `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). | | `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). | -| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. | +| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. | | `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` on first open, never the OS viewer); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public, integration-test live) + `PremiumUrl` (tabled billing seam — TASK 12 → greyed "Unlock Premium" lights up when filled) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. Ctor command assignments stay in core. | | `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. | | `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests | diff --git a/ai.md b/ai.md index 1b07247..ee600c0 100644 --- a/ai.md +++ b/ai.md @@ -703,6 +703,72 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro (`BlurRadius(0.5)` = 2, `BlurRadius(1)` = 6) and the behaviour at a step edge: default smears light ~9px into the dark half vs ~26px at full strength. +### Vertical (9:16) tier camera placement (TASK 57 — "vv" Unit 1, built 2026-10-21) + +**The problem.** The vertical tier (1080×1920) is a centred **band crop** of the SAME 1920×1080 +master: `OutputRect*` = x 656.25, y 0, w 607.5, h 1080. A camera placed for 16:9 (the default +webcam sits at x≈1408) lands **outside the cropped window entirely** — in the vertical stream the +creator is simply not in the picture. + +**The rule (creator's locked table, 2026-10-17).** Each scene keeps its **own** vertical +placement — position, size and shape — and switching back to 16:9 restores the desktop layout +exactly: + +| mode | shape | size | position | +|---|---|---|---| +| 16:9 | as today | as today | as today | +| **vv · Live** | circle **or** rect (the creator's choice, per scene) | small tile | one of **six** spots: top/bottom × left/centre/right | +| vv · Chat | *Unit 2* (rect only; normal, or enlarged to one 607×540 area) | — | — | +| Starting / BRB / Ending | unchanged | unchanged | unchanged | + +**Design — geometry only, no render-path change.** The preview and the compositor both read the +element's OWN `X`/`Y`/`Width`/`Height` + `ClipShape`, so the tier switch **swaps the live +geometry** instead of teaching the render path about tiers: + +- `WebcamSceneConfig` carries the vertical placement (`VerticalX/Y/Width/Height/VerticalClipShape` + + `VerticalRectWidth/Height` — seven additive DB columns, NULL = "this scene has no vertical + placement yet") and the **parked** 16:9 layout (`LandscapeX/…/LandscapeRectHeight`, transient). +- `BeginVerticalLayout(...)` parks the live 16:9 layout and brings the vertical one in; + `EndVerticalLayout()` commits the vertical placement back into the stored slot and restores the + parked 16:9 layout. The live geometry IS the render input → **the compositor and + `PreviewPane.xaml` are untouched**. +- The save keeps the X/Y/W/H/ClipShape columns as the **16:9** layout (while the vertical tier is + live, the parked values are written there and the live ones into the `Vertical*` columns). +- `SceneElement.X/Y/Width/Height` became **virtual** so the webcam can CONSTRAIN every write + (drag, resize handle, properties fields) into the band — one choke point instead of three + writers re-implementing the rule. Outside the band = invisible in the vertical stream. + +**The band and the six spots** (`Models/VerticalCameraLayout.cs`, pure math): the band is +656.25…1263.75 × 0…1080; it splits into two 607×540 areas (top/bottom), each with three columns +(left/centre/right) → six spots. A tile snaps to the spot its **centre** falls in, inset 32 master +px from the band's outer edge (the app's own placement margin) and never outside the band. The +default tile is **270×152 master px** (480×270 in the 1080×1920 output) in the **bottom-right** +spot — the corner the 16:9 default webcam uses. The Live scene gets that tile automatically the +first time the vertical tier is picked (otherwise the vertical stream has no camera at all); the +other scenes keep their 16:9 placement until the creator drags them into the band. + +**Manual entry** (creator rule): the webcam's Layers properties gained a **Geometry** section +(X/Y/W/H) — it edits the LIVE placement, i.e. the one the current tier shows. The webcam's +geometry setters clamp it into the band, so a typed value can never push the camera out of the +cropped window. + +**Derivative work.** OBS has no built-in way to store a scene's layout per aspect ratio — the +maintainers' answer is "create a new empty scene and add the scene you want to send as source" +(obsproject.com/forum/threads/…161307), and Streamyard's dual-aspect guide says the same ("no +built-in way to store separate layouts per aspect ratio, so you rely on manual workarounds and +plugins" — streamyard.com/blog/dual-aspect-ratio-streaming-tool). Our per-scene, per-tier stored +placement, resolved on the tier change, is the step past that. + +**Tests.** `VerticalWebcamLayoutTests` — the ONE integration test for the unit: the vertical tier +is picked → the Live camera comes INTO the band as a small tile → dragged to the top-right it +lands top-right inside the band → back to 16:9 the desktop layout is restored exactly → and both +placements survive a save + reload. (The store writes the webcam rows only when the app has a +webcam identity — the config rows hang off it — so the test supplies one.) + +**Known trap (see `MyMistakes.md`):** a second properties panel bound to `SelectedElement.IsWebcam` +broke `WebcamBackgroundPillTests` — that test finds the webcam's panel by **binding text**, so a +new panel must use a different binding (`IsWebcamSelected`) or the selector matches twice. + ### Frame cadence — how a dropped frame became measurable (UNIT 0, 2026-10-05) **The problem this exists to solve.** The recorded file cannot distinguish a dropped camera frame diff --git a/ytLive.Tests/VerticalWebcamLayoutTests.cs b/ytLive.Tests/VerticalWebcamLayoutTests.cs new file mode 100644 index 0000000..8f79003 --- /dev/null +++ b/ytLive.Tests/VerticalWebcamLayoutTests.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Linq; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Models; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// TASK 57, the "vv" feature, Unit 1 — the vertical (9:16) tier's camera +/// placement. +/// +/// The vertical tier is a centred band crop of the SAME 1920×1080 master, so a +/// camera placed for 16:9 lands outside the cropped window and is simply not +/// in the vertical stream (the Live scene's camera sat at x≈1408 — off the +/// 607.5-wide band entirely). Each scene therefore keeps its OWN vertical +/// placement, and the tier switch swaps the live geometry. +/// +/// One test, the whole story (the Good Dog rule): the vertical tier is picked → +/// the Live camera comes into the band as a small tile → dragging it to the +/// top-right snaps it top-right INSIDE the band → switching back to 16:9 +/// restores the desktop layout exactly → and the two placements survive a save +/// and reload. +/// +[Collection("RealApp")] +public sealed class VerticalWebcamLayoutTests +{ + private readonly RealAppHost _app; + + public VerticalWebcamLayoutTests(RealAppHost app) => _app = app; + + [Fact] + public void Vertical_Tier_Puts_The_Live_Camera_In_The_Band_And_16x9_Restores_The_Landscape_Layout() + { + _app.Run(Run); + } + + private void Run() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-vv-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + try + { + var vm = new MainViewModel(); + + var live = vm.Scenes.First(s => s.Name == "Live"); + var camera = new WebcamSceneConfig + { + WebcamId = "webcam-1", + Name = "Webcam", + X = 1408, + Y = 778, + Width = 480, + Height = 270, + }; + live.Elements.Add(camera); + + // The 16:9 placement: the camera sits where the desktop layout put it. + Assert.Equal(1408, camera.X); + Assert.Equal(778, camera.Y); + Assert.Equal(480, camera.Width); + + // ── The vertical tier is picked ─────────────────────────────────── + vm.SelectedQuality = vm.QualityOptions[^1]; + Assert.True(vm.IsVerticalTier); + var band = vm.VerticalBand; + + // The camera came INTO the band (it used to sit outside it entirely). + Assert.True(camera.X >= band.Left, $"camera X {camera.X} is left of the band {band.Left}"); + Assert.True(camera.X + camera.Width <= band.Right, "the camera hangs over the band's right edge"); + Assert.True(camera.Y >= band.Top, "the camera hangs over the band's top edge"); + Assert.True(camera.Y + camera.Height <= band.Bottom, "the camera hangs over the band's bottom edge"); + Assert.Equal(VerticalCameraLayout.TileWidth, camera.Width); + Assert.Equal(VerticalCameraLayout.TileHeight, camera.Height); + + // ── Drag it to the top-right ───────────────────────────────────── + vm.MoveWebcamInVerticalTier(camera, band.Right - 40, band.Top + 20); + + Assert.Equal(band.Right - VerticalCameraLayout.Inset - camera.Width, camera.X, 3); + Assert.Equal(band.Top + VerticalCameraLayout.Inset, camera.Y, 3); + Assert.True(camera.X >= band.Left && camera.X + camera.Width <= band.Right); + Assert.True(camera.Y >= band.Top && camera.Y + camera.Height <= band.Bottom); + + var verticalX = camera.X; + var verticalY = camera.Y; + + // ── Back to 16:9: the desktop layout is restored exactly ───────── + vm.SelectedQuality = vm.QualityOptions[0]; + Assert.False(vm.IsVerticalTier); + Assert.Equal(1408, camera.X); + Assert.Equal(778, camera.Y); + Assert.Equal(480, camera.Width); + Assert.Equal(270, camera.Height); + + // ── And the vertical tier comes back with the creator's placement ─ + vm.SelectedQuality = vm.QualityOptions[^1]; + Assert.Equal(verticalX, camera.X, 3); + Assert.Equal(verticalY, camera.Y, 3); + + // ── It survives a save + reload: both placements, side by side ─── + // The store writes the webcam rows only when the app has a webcam + // identity (the config rows hang off it) — this box has no camera + // plugged in, so the identity is supplied directly. + using (var store = new Services.LayoutStore(tempDb)) + { + store.Save(vm.Scenes, new Webcam { Id = "webcam-1", DeviceId = "device-1", Name = "Webcam" }, null); + } + + using var reloaded = new Services.LayoutStore(tempDb); + var reloadedCamera = reloaded.Load().First(s => s.Name == "Live").WebcamConfig; + Assert.NotNull(reloadedCamera); + + // The 16:9 layout is the one in the live columns… + Assert.Equal(1408, reloadedCamera.X); + Assert.Equal(778, reloadedCamera.Y); + Assert.Equal(480, reloadedCamera.Width); + Assert.Equal(270, reloadedCamera.Height); + + // …and the vertical placement is the creator's own, stored beside it. + Assert.Equal(verticalX, reloadedCamera.VerticalX!.Value, 3); + Assert.Equal(verticalY, reloadedCamera.VerticalY!.Value, 3); + Assert.Equal(VerticalCameraLayout.TileWidth, reloadedCamera.VerticalWidth!.Value, 3); + Assert.Equal(VerticalCameraLayout.TileHeight, reloadedCamera.VerticalHeight!.Value, 3); + } + finally + { + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } +}