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.
This commit is contained in:
2026-10-21 08:58:24 -07:00
parent 58b9a7d413
commit 6be062c45f
20 changed files with 911 additions and 54 deletions
+59
View File
@@ -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.