diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml
index 7530d09..c4047d9 100644
--- a/Controls/LeftPanel.xaml
+++ b/Controls/LeftPanel.xaml
@@ -246,7 +246,8 @@
Click="MirrorButton_Click"/>
+ Margin="8,0,0,0" Click="ShapeButton_Click"
+ Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}"/>
diff --git a/Controls/PreviewPane.xaml.cs b/Controls/PreviewPane.xaml.cs
index 2c92b5a..211c595 100644
--- a/Controls/PreviewPane.xaml.cs
+++ b/Controls/PreviewPane.xaml.cs
@@ -476,9 +476,11 @@ public partial class PreviewPane : UserControl
selected.Width = newW;
selected.Height = newH;
- // The spot's anchor follows the new size (TASK 57).
+ // TASK 57: inside the vertical band the size snaps (the Chat
+ // camera has two sizes: the small tile or one whole area) and the
+ // camera ends up on its spot.
if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
- vm.SnapWebcamInVerticalTier(verticalCam);
+ vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH);
}
}
diff --git a/Controls/index.md b/Controls/index.md
index 83adb18..0d9b93a 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). **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 |
+| `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 (TASK 57 Unit 2: the resize routes through `MainViewModel.ResizeWebcamInVerticalTier` — the Chat camera's two sizes, then the snap) |
| `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). **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) |
+| `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); the Shape button's `Visibility` is bound to `CanToggleClipShape`, so the circle option is NOT OFFERED in the vertical Chat layout |
| `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 85c5a69..4416931 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,9 +1,10 @@
-# HANDOFF — 2026-10-21 (vv webcam Unit 1 shipped; the previous HA-lights session's state below)
+# HANDOFF — 2026-10-21 (vv webcam Units 1+2 shipped; the previous HA-lights session's state below)
## At a glance
-- **Branch** main, tracking `origin/main`. **origin/main = `6be062c` = HEAD — up to date, nothing
- waiting.** The vv Unit 1 commit was **pushed on the creator's approval** (2026-10-21, after the
- creator tested the placement). Push only at sub-milestones/milestones, at the creator's word.
+- **Branch** main, tracking `origin/main`. **origin/main = `ccc1db5` = the last pushed commit; the
+ vv Unit 2 work is committed on top of it.** The vv Unit 1 commit was **pushed on the creator's
+ approval** (2026-10-21, after the creator tested the placement). Push only at
+ sub-milestones/milestones, at the creator's word.
- **The push path changed:** `origin` is now
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored
@@ -11,9 +12,12 @@
was **renamed `gramps/ytLlive` → `gramps/LlamaCasty`**, and `llgit:2214` refuses the publickey
of this machine's key even though it IS registered (account key id 6 "Corsair VSC"). Ask the
Gitea REST API, don't do key archaeology — recipe in `MyMistakes.md` → 2026-10-21.
-- **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.
+- **This session shipped vv webcam Units 1+2** (the vertical 9:16 tier keeps its OWN camera placement
+ per scene; 16:9 restores the desktop layout exactly; the Chat scene in vv is rect-only and can be
+ enlarged to fill a whole 607×540 area). Code + memory in ONE commit each; the ONE Good Dog tests
+ are `ytLive.Tests/VerticalWebcamLayoutTests.cs` (Unit 1) and
+ `ytLive.Tests/VerticalChatCameraLayoutTests.cs` (Unit 2). **Only the creator's hands-on check is
+ left** — 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
@@ -58,7 +62,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) — **Unit 1 shipped 2026-10-21**
+## vv webcam feature — locked rules (creator, 2026-10-17) — **Units 1+2 shipped 2026-10-21**
| mode | shape | size | position |
|---|---|---|---|
| 16:9 | as today | as today | as today |
@@ -114,6 +118,28 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
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.
+### What shipped in Unit 2 (the Chat scene, 2026-10-21) — do not re-hunt
+- **Rect only, and the lock is on the MODEL, not the UI.** `WebcamSceneConfig.VerticalShapeLocked`
+ (set per scene by the VM) **overrides the `ClipShape` property** → a locked camera cannot become
+ Round by any writer (the toggle, the Shape button, the placement swap). `SceneElement.ClipShape`
+ and `SceneElement.ToggleClipShape()` are `virtual` for this. The lock is cleared **before** the
+ 16:9 layout comes back, so the desktop shape is restored.
+- **The circle is not OFFERED:** `MainViewModel.CanToggleClipShape` is false for a Chat camera in the
+ vertical tier → the Shape button in the webcam's properties **disappears** (not just disabled).
+ `MainViewModel.cs` raises it on the selection change; `MainViewModel.Streaming.cs` on the tier change.
+- **Two sizes, snapped by the nearer one:** `VerticalCameraLayout.SizeFor(band, requestedWidth)` →
+ the small tile (270×152) or one whole area (607.5×540). The resize handle therefore has two
+ detents instead of any size in between.
+- **An enlarged camera fills the area its centre is in** — top or bottom (`VerticalCameraLayout.AreaFor`).
+- **The Chat camera is auto-tiled** like the Live one when the vertical tier is picked (otherwise the
+ vertical chat stream has no camera at all). `ResolveVerticalCameraLayout` now auto-tiles Live **and**
+ Chat; Starting/BRB/Ending keep their 16:9 placement.
+- The preview's resize routes through `MainViewModel.ResizeWebcamInVerticalTier` (the Chat camera's
+ discrete size + the snap); the drag routes through `MoveWebcamInVerticalTier` (assign → clamp →
+ snap, so the enlarged camera can be dragged from the top area to the bottom one).
+- *Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — **PASSES** (in Chat under the vertical
+ tier, enlarging fills the full top area and the circle option is not offered).
+
### Units (each: ONE Good Dog integration test, own commit, memory in the same commit)
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 +
@@ -121,21 +147,18 @@ Zombie entities deleted from the registry: `script.leak_strobe`, `script.doorbel
*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. **Not started.**
+2. ✅ **vv · Chat (Unit 2)** — shipped 2026-10-21. Rect enforced with the circle option unavailable, plus
+ enlarge-to-one-area (607×540) filling the top or the bottom area by where the camera is.
+ *Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
+ the full top area and the circle option is not offered. **PASSES.**
## Next step
-- **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.
+- **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
+ resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
+ the Shape button toggles circle/rect there, open the **Chat** scene and confirm the Shape button is
+ **gone** (rect only) and that dragging the tile up and enlarging it fills a whole 607×540 area, then
+ switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
+ eyeball.
- Nothing else is queued for llamacasty — the creator queues work.
## Open (operational)
@@ -163,7 +186,9 @@ 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: **429/429 passed, 0 warnings** (2026-10-21, this session — the vv Unit 1 gate run).
+- Last full run: **429/430 passed, 0 warnings** (2026-10-21, this session — the vv Unit 2 gate run).
+ The one failure was `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder`,
+ which **passes in isolation** (re-run: 3/3) — the known real-mouse flake, not a regression.
- Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
`Round_Clip_Corner…`.
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
index bed4f84..ab72042 100644
--- a/Models/SceneElement.cs
+++ b/Models/SceneElement.cs
@@ -111,7 +111,10 @@ public abstract class SceneElement : INotifyPropertyChanged
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional;
- public ClipShape ClipShape
+ // Virtual so a derived element can CONSTRAIN the shape (the webcam is
+ // rect-only in the vertical tier's Chat layout — TASK 57) instead of every
+ // writer re-implementing the rule.
+ public virtual ClipShape ClipShape
{
get => _clipShape;
set
@@ -130,7 +133,7 @@ public abstract class SceneElement : INotifyPropertyChanged
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
- public void ToggleClipShape()
+ public virtual void ToggleClipShape()
{
if (ClipShape == ClipShape.Traditional)
{
diff --git a/Models/VerticalCameraLayout.cs b/Models/VerticalCameraLayout.cs
index 54f9813..86d24dc 100644
--- a/Models/VerticalCameraLayout.cs
+++ b/Models/VerticalCameraLayout.cs
@@ -38,6 +38,22 @@ public static class VerticalCameraLayout
public static (double Width, double Height) MaxSizeFor(Rect band)
=> (band.Width, band.Height / 2);
+ /// True when a camera is wide enough to fill one whole area of the
+ /// band — the "enlarged" Chat layout (one 607×540 area).
+ public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
+
+ /// The two sizes a camera may take in the vertical tier's Chat
+ /// layout: the small tile (normal) or one whole area (enlarged). Returns
+ /// the one nearer the requested width, so the resize handle snaps between
+ /// them instead of taking any size in between.
+ public static (double Width, double Height) SizeFor(Rect band, double requestedWidth)
+ {
+ var (areaWidth, areaHeight) = MaxSizeFor(band);
+ return Math.Abs(requestedWidth - TileWidth) <= Math.Abs(requestedWidth - areaWidth)
+ ? (TileWidth, TileHeight)
+ : (areaWidth, areaHeight);
+ }
+
///
/// 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
diff --git a/Models/WebcamSceneConfig.cs b/Models/WebcamSceneConfig.cs
index d9e5102..61fdba8 100644
--- a/Models/WebcamSceneConfig.cs
+++ b/Models/WebcamSceneConfig.cs
@@ -97,6 +97,12 @@ public class WebcamSceneConfig : SceneElement
/// and friends until the tier switches back.
public bool VerticalLayoutLive { get; private set; }
+ /// True while the vertical placement must stay RECT — the Chat
+ /// scene's vertical layout (the creator's table: "vv · Chat = rect only, no
+ /// circle option"). The circle is not offered there, so the shape cannot
+ /// become Round by any writer.
+ public bool VerticalShapeLocked { get; internal 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.
@@ -166,6 +172,26 @@ public class WebcamSceneConfig : SceneElement
VerticalRectHeight = RectHeight;
}
+ // The Chat scene's vertical layout is rect-only (the creator's table, the
+ // circle option is not offered there). Locking the SHAPE property — not
+ // just the toggle — means no writer can slip a Round camera in: the
+ // toggle, the shape button and the placement swap all go through here.
+ public override ClipShape ClipShape
+ {
+ get => base.ClipShape;
+ set => base.ClipShape = VerticalShapeLocked ? ClipShape.Traditional : value;
+ }
+
+ public override void ToggleClipShape()
+ {
+ if (VerticalShapeLocked)
+ {
+ ClipShape = ClipShape.Traditional;
+ return;
+ }
+ base.ToggleClipShape();
+ }
+
// 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
diff --git a/Models/index.md b/Models/index.md
index ea8fbc0..1c4a513 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -8,12 +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); **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) |
+| `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; `ClipShape` + `ToggleClipShape()` are `virtual` too, so a derived element can CONSTRAIN the shape (the Chat scene's vertical camera is rect-only) |
+| `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), `FillsArea(band, width)` (the camera is wide enough to fill a whole area = "enlarged") and `SizeFor(band, requestedWidth)` (the Chat camera's TWO sizes - the small tile or one whole 607×540 area — whichever is nearer the requested width, so the resize handle has two detents) |
| `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); **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 |
+| `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; `VerticalShapeLocked` (the Chat scene's vertical layout is rect-only) **overrides** `ClipShape` + `ToggleClipShape()` so no writer can slip a circle in |
| `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 fae13e0..4abe9b6 100644
--- a/MyMistakes.md
+++ b/MyMistakes.md
@@ -2021,3 +2021,60 @@ the password works, use HTTPS".
**Also:** `ssh_askpass` is not installed in this WSL — `SSH_ASKPASS`/`GIT_ASKPASS` helpers must be
scripts you write yourself (`chmod 700`), and they should read the secret from `CREDENTIALS.md`
rather than embed it.
+
+## 2026-10-21 — A RULE THAT APPLIES TO SOME ELEMENTS ONLY: LOCK THE MODEL PROPERTY, NOT THE UI BUTTON (RECIPE — TASK 57)
+
+The creator's table says the **Chat** scene's vertical camera is **rect only — the circle option is
+not offered**, while the Live scene's camera can be a circle. The obvious implementation hides the
+Shape button for that camera. That implementation is wrong: the button is only ONE writer. The
+model, the placement swap and the load path are the others, and a rule that lives only in the UI is
+a rule that will be broken by the next writer.
+
+**The recipe — lock the property, and let the UI follow:**
+1. Make the property **`virtual`** in the base class (`SceneElement.ClipShape`) and **override** it
+ in the derived class (`WebcamSceneConfig`): the setter coerces the value when the lock is on.
+ Make the *action* virtual too (`ToggleClipShape()`) so the toggle can no-op **without** touching
+ the round-trip fields (`RectWidth/RectHeight`) — a half-applied toggle is worse than none.
+2. The lock flag lives on the model (`VerticalShapeLocked`), is set **per scene** by the view model
+ (`ResolveVerticalCameraLayout`) and is **cleared BEFORE the other layout is restored** — clear it
+ after and the restore is silently coerced (the desktop circle comes back as a rectangle).
+3. The UI gate is then a *read-out* of the model rule, not the rule itself: a view-model property
+ (`CanToggleClipShape`) that the button binds its `Visibility` to. "Not offered" = the button is
+ **gone**, not merely greyed out.
+4. Test the model path as well as the UI path: call the toggle directly and assert the shape did not
+ change — the UI's absence is not the rule.
+
+**The general shape:** a per-element variant of a layout rule → one `virtual` property override +
+one lock flag, with the UI's visibility derived from it.
+
+## 2026-10-21 — "SIZE A OR SIZE B": GIVE THE RESIZE HANDLE TWO DETENTS, NOT A FREE SIZE (RECIPE — TASK 57)
+
+The creator's table says the Chat camera in the vertical tier is either the small tile (270×152) or
+**enlarged to fill one whole 607×540 area**. The obvious implementation lets the resize handle take
+any width in between — and then the camera is neither of the two things the creator asked for, and
+the "enlarged" state has to be *guessed* from the width.
+
+**The recipe:**
+- Put the allowed sizes in one pure function next to the other band math
+ (`VerticalCameraLayout.SizeFor(band, requestedWidth)`) and return **the size nearest the
+ requested one** — the handle then has exactly two detents and no in-between.
+- The "enlarged" state is **derived**, not stored: `FillsArea(band, width)` (the camera is wide
+ enough to fill an area). No new DB column, no state to keep in sync.
+- The position of an enlarged camera is **not free**: it fills the area its **centre** is in (top or
+ bottom) — `AreaFor(band, isBottom)`. Drag it up/down and it swaps areas; that IS the interaction.
+- Watch the order of the writes: set the **size first**, then the position, because a position clamp
+ that knows the size (the band's edge minus the width) will otherwise fight the new size.
+
+## 2026-10-21 — THE EDIT TOOL CANNOT MATCH AN oldString CONTAINING AN EM DASH (RECIPE)
+
+Three `edit` calls in a row failed with "Could not find oldString" on text I had just read in the
+file. The common factor: the matched block contained the **em dash** `—` (and in one case the
+`×` multiplication sign) — the same non-ASCII typography the repo's comments are full of. The tool
+documents a retry that ignores "typographic quote, dash, and space variants", but that retry did not
+save the match.
+
+**The workaround that works:** keep the `oldString` **ASCII-only**. Match the smallest unique slice
+that does not contain a dash (e.g. the `if (...) return false;` line instead of the comment above
+it), and put the new prose in the `newString` where any character is fine. When a whole stale block
+must go (e.g. half-overwritten comments), a three-line `python3` script that deletes the exact line
+range is faster and safer than fighting the matcher.
diff --git a/TASKS.md b/TASKS.md
index fc783f0..c576197 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -72,7 +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) |
+| 57 | **Vertical (9:16) tier camera placement — the "vv" feature** | ✅ **Both units shipped 2026-10-21** (Unit 1 = 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 = Chat scene: rect only — the circle is not offered — the tile or one whole 607×540 area) | [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) |
---
@@ -155,14 +155,16 @@ 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)
+- **✅ TASK 57 — the "vv" vertical camera layout** (both units shipped 2026-10-21). 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:** 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:** the
+ Chat scene in the vertical tier — rect only (the circle option is not offered and the model
+ refuses it), the small tile or enlarged to fill one whole 607×540 area, top or bottom by where the
+ camera is (the creator's table, 2026-10-17). **Left:** the creator's hands-on check of the whole
+ vv feature. 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
diff --git a/TASKS/task-57-vertical-camera-layout.md b/TASKS/task-57-vertical-camera-layout.md
index a9bdcd5..5750c17 100644
--- a/TASKS/task-57-vertical-camera-layout.md
+++ b/TASKS/task-57-vertical-camera-layout.md
@@ -2,7 +2,8 @@
> 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).
+**Status:** ✅ Both units shipped 2026-10-21 (Unit 1 = the Live scene, Unit 2 = the Chat scene).
+Only the creator's hands-on check (slice 3) is outstanding.
## Why
The vertical tier (1080×1920) is a centred **band crop** of the same 1920×1080 master every
@@ -16,7 +17,7 @@ not in the picture. Switching tiers must not cost the creator their camera.
|---|---|---|---|
| 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) |
+| vv · Chat | rect only | normal, or enlarged to one 607×540 area | the same six spots; enlarged fills the top or the bottom area |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
Plus: each scene keeps its **own** vertical position, size and shape; switching back to 16:9
@@ -47,23 +48,53 @@ restores the desktop layout **exactly**; placement is set by drag with snap (lik
- **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.
+## Design (Unit 2 — the Chat scene)
+
+- **Rect only, and the lock sits on the model.** `WebcamSceneConfig.VerticalShapeLocked` (set per
+ scene by the VM) **overrides the `ClipShape` property**, so a locked camera cannot become Round
+ by ANY writer — the toggle, the Shape button, the placement swap. The lock is cleared *before*
+ the 16:9 layout comes back, so the desktop shape is restored.
+- **The circle is not offered.** `MainViewModel.CanToggleClipShape` is false for a Chat camera in
+ the vertical tier → the Shape button in the webcam's properties disappears (not just disabled).
+- **Two sizes, snapped by the nearer one.** `VerticalCameraLayout.SizeFor(band, requestedWidth)`
+ returns the small tile (270×152) or one whole area (607.5×540) — whichever is nearer the
+ requested width, so the resize handle has two detents instead of any size in between.
+- **An enlarged camera fills the area its centre is in** — the top one or the bottom one
+ (`VerticalCameraLayout.AreaFor`).
+- **The Chat camera is auto-tiled** like the Live one when the vertical tier is picked — otherwise
+ the vertical chat stream has no camera at all.
+
## 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.
+2. ✅ **Unit 2 — the Chat scene** (shipped 2026-10-21): in the vertical tier the Chat camera comes
+ into the band as a small RECT tile; the circle option is not offered and cannot be set even on
+ the model; enlarged it fills one whole 607×540 area — the top one or the bottom one, by where
+ the camera is; switching back to 16:9 restores the desktop layout exactly and the circle option
+ is offered again. `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — the ONE test for the unit.
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.
+ vertical tier (and is gone in the Chat scene), enlarge the Chat camera to see it fill a whole
+ area, 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.
+- **Research (Unit 2, 2026-10-21).** The "one 607×540 area" rule is the established vertical-stream
+ layout: Streamlabs' vertical guide says "splitting the screen in half horizontally, placing the
+ camera feed in the top half and the main content on the bottom (or vice versa) may be the bet",
+ and that a just-chatting vertical wants the camera "to take up most of the screen"
+ (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming). Bloopbot's vertical
+ overlay docs describe the same per-layer model we use at scene granularity: "move or resize a
+ layer on Vertical and it stops being Auto-placed. It keeps the place you gave it, and the
+ horizontal layout is not touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format).
+- ⚠️ **Landmine for the bottom area:** "about the bottom 560 px of the vertical video can be covered
+ by Twitch's chat and player controls on phones" (Bloopbot, above) — the Chat camera's enlarged
+ **bottom** area may be half-hidden by the platform chrome. TASK 53's safe-zone work decides that.
- 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 5569a7c..66941b5 100644
--- a/ViewModels/MainViewModel.Streaming.cs
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -428,6 +428,7 @@ public partial class MainViewModel : ViewModelBase
// in, 16:9 puts the desktop layout back exactly where it was.
OnPropertyChanged(nameof(IsVerticalTier));
OnPropertyChanged(nameof(VerticalBand));
+ OnPropertyChanged(nameof(CanToggleClipShape));
if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality)))
ScheduleSave();
}
diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs
index 0973816..d2d485c 100644
--- a/ViewModels/MainViewModel.Webcam.cs
+++ b/ViewModels/MainViewModel.Webcam.cs
@@ -486,6 +486,11 @@ public partial class MainViewModel
private bool ResolveVerticalCameraLayout(Scene scene, WebcamSceneConfig config, bool vertical)
{
+ // The Chat scene's vertical layout is RECT-only (the creator's table:
+ // "vv · Chat = rect only — no circle option"). The lock is cleared
+ // BEFORE the 16:9 layout comes back, so the desktop shape is restored.
+ config.VerticalShapeLocked = vertical && SceneCatalog.IsChat(scene.Name);
+
if (!vertical)
{
config.VerticalBand = Rect.Empty;
@@ -509,12 +514,14 @@ public partial class MainViewModel
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;
+ // The Live and Chat scenes are the camera scenes in the vertical tier:
+ // without a placement INSIDE the band the vertical output has no camera
+ // at all. They start as a small tile in the bottom-right spot (the
+ // corner the 16:9 default webcam uses). Starting/BRB/Ending keep their
+ // 16:9 placement (creator ruling 2026-10-17: unchanged) until the
+ // creator drags them into the band.
+ if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live) && !SceneCatalog.IsChat(scene.Name))
+ return false;
var spot = VerticalCameraLayout.DefaultSpot(
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
@@ -534,11 +541,9 @@ public partial class MainViewModel
{
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;
+ config.X = x;
+ config.Y = y;
+ SnapWebcamInVerticalTier(config);
}
/// Snaps a camera back onto its spot — the resize handle grew or
@@ -547,13 +552,63 @@ public partial class MainViewModel
{
if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config);
+
+ var centreX = config.X + config.Width / 2;
+ var centreY = config.Y + config.Height / 2;
+
+ // The enlarged Chat camera fills one whole area — the top one or the
+ // bottom one, by where its centre is.
+ if (VerticalCameraLayout.FillsArea(VerticalBand, config.Width))
+ {
+ var area = VerticalCameraLayout.AreaFor(
+ VerticalBand, centreY >= VerticalBand.Top + VerticalBand.Height / 2);
+ config.Width = area.Width;
+ config.Height = area.Height;
+ config.X = area.Left;
+ config.Y = area.Top;
+ return;
+ }
+
var spot = VerticalCameraLayout.SpotFor(
- VerticalBand, config.Width, config.Height,
- config.X + config.Width / 2, config.Y + config.Height / 2);
+ VerticalBand, config.Width, config.Height, centreX, centreY);
config.X = spot.X;
config.Y = spot.Y;
}
+ /// True when this camera belongs to the Chat scene — the vertical
+ /// tier's Chat layout has its own rules (rect only, two sizes).
+ public bool IsChatSceneCamera(WebcamSceneConfig config)
+ => Scenes.Any(s => SceneCatalog.IsChat(s.Name) && s.Elements.Contains(config));
+
+ /// False when the circle option is not offered — the Chat scene's
+ /// vertical layout is rect-only (the creator's table, 2026-10-17).
+ public bool CanToggleClipShape => !(IsVerticalTier
+ && SelectedElement is WebcamSceneConfig selected
+ && IsChatSceneCamera(selected));
+
+ /// Applies a resize inside the vertical tier: the Chat camera's
+ /// size is discrete — the small tile (normal) or one whole 607×540 area
+ /// (enlarged) — and every camera ends up snapped to its spot.
+ public void ResizeWebcamInVerticalTier(WebcamSceneConfig config, double requestedWidth, double requestedHeight)
+ {
+ if (!IsVerticalTier) return;
+ EnsureVerticalCameraLayout(config);
+
+ if (IsChatSceneCamera(config))
+ {
+ var (w, h) = VerticalCameraLayout.SizeFor(VerticalBand, requestedWidth);
+ config.Width = w;
+ config.Height = h;
+ }
+ else
+ {
+ config.Width = requestedWidth;
+ config.Height = requestedHeight;
+ }
+
+ SnapWebcamInVerticalTier(config);
+ }
+
/// 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)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index c9aa948..af76267 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -67,6 +67,7 @@ public partial class MainViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsWebcamSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty));
+ OnPropertyChanged(nameof(CanToggleClipShape));
if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
}
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 586f508..cff3e3f 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -8,17 +8,17 @@ areas' docs in their own rows keeps this index small to read.
| File | Purpose |
|------|---------|
-| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox`/`RenderAlertBox` for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial. |
+| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Lifecycle/ctor wiring:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged` (the selection change also raises `CanToggleClipShape` — TASK 57), `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox`/`RenderAlertBox` for chat + alert layers) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
| `MainViewModel.Zoom.cs` | **Zoom box (TASK 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`. **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.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`; the Chat scene's own rules (Unit 2) — `IsChatSceneCamera`/`CanToggleClipShape` (the circle is NOT offered in the vertical Chat layout) and `ResizeWebcamInVerticalTier` (the Chat camera's two sizes, then the snap; the Live camera resizes freely inside the band) |
| `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; **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.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`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `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 4494dc4..65d9448 100644
--- a/ai.md
+++ b/ai.md
@@ -717,7 +717,7 @@ 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)
+### Vertical (9:16) tier camera placement (TASK 57 — "vv" Units 1+2, 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
@@ -732,7 +732,7 @@ exactly:
|---|---|---|---|
| 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) | — | — |
+| vv · Chat | **rect only** (the circle option is not offered) | the small tile (normal), or enlarged to fill one 607×540 area | the same six spots; enlarged fills the top or the bottom area |
| Starting / BRB / Ending | unchanged | unchanged | unchanged |
**Design — geometry only, no render-path change.** The preview and the compositor both read the
@@ -761,6 +761,17 @@ spot — the corner the 16:9 default webcam uses. The Live scene gets that tile
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.
+**The Chat scene's own rules (Unit 2).** In the vertical tier the Chat camera is **rect only**:
+the circle is not *offered* (`MainViewModel.CanToggleClipShape` is false → the Shape button
+disappears) and the model refuses it too — `WebcamSceneConfig.ClipShape` is **overridden**, so a
+locked camera cannot become Round by ANY writer (the toggle, the button, the placement swap). The
+lock is `WebcamSceneConfig.VerticalShapeLocked`, set by the VM per scene and cleared **before** the
+16:9 layout comes back, so the desktop shape is restored. Its **size is discrete**: the small tile
+(normal) or one whole 607×540 area (enlarged) — the resize handle snaps to the nearer of the two
+(`VerticalCameraLayout.SizeFor`), and an enlarged camera **fills the area its centre is in** (top
+or bottom). The Chat scene also gets its tile automatically the first time the vertical tier is
+picked (like Live) — otherwise the vertical chat stream has no camera at all.
+
**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
@@ -773,11 +784,15 @@ built-in way to store separate layouts per aspect ratio, so you rely on manual w
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
+**Tests.** `VerticalWebcamLayoutTests` — the ONE integration test for Unit 1: 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.)
+`VerticalChatCameraLayoutTests` — the ONE integration test for Unit 2: the vertical tier is picked
+→ the Chat camera comes into the band as a small RECT tile → the circle option is not offered and
+cannot be set even on the model → dragged up and enlarged it fills the **top** area exactly → back
+to 16:9 the desktop layout is restored exactly and the circle option is offered again.
**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
diff --git a/ytLive.Tests/VerticalChatCameraLayoutTests.cs b/ytLive.Tests/VerticalChatCameraLayoutTests.cs
new file mode 100644
index 0000000..3ca6b12
--- /dev/null
+++ b/ytLive.Tests/VerticalChatCameraLayoutTests.cs
@@ -0,0 +1,114 @@
+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 2 — the vertical (9:16) tier's CHAT scene
+/// camera.
+///
+/// Unit 1 gave every scene its own vertical placement. Unit 2 is the Chat
+/// scene's own rules (the creator's table, 2026-10-17): in the vertical tier
+/// the Chat camera is **rect only — the circle option is not offered** — and
+/// its size is either the normal small tile or **enlarged to fill one whole
+/// 607×540 area** (the top one or the bottom one, by where the camera is).
+///
+/// One test, the whole story (the Good Dog rule): the vertical tier is picked →
+/// the Chat camera comes into the band as a small RECT tile → the circle option
+/// is not offered and cannot be set even by the model → the camera is dragged up
+/// and enlarged, and it fills the TOP area exactly → switching back to 16:9
+/// restores the desktop layout exactly (and the circle option is offered again).
+///
+[Collection("RealApp")]
+public sealed class VerticalChatCameraLayoutTests
+{
+ private readonly RealAppHost _app;
+
+ public VerticalChatCameraLayoutTests(RealAppHost app) => _app = app;
+
+ [Fact]
+ public void Vertical_Tier_Chat_Camera_Is_Rect_Only_And_Enlarges_To_One_Area()
+ {
+ _app.Run(Run);
+ }
+
+ private void Run()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-vv-chat-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ try
+ {
+ var vm = new MainViewModel();
+
+ var chat = vm.Scenes.First(s => s.Name == "Chat");
+ var camera = new WebcamSceneConfig
+ {
+ WebcamId = "webcam-1",
+ Name = "Webcam",
+ X = 120,
+ Y = 120,
+ Width = 640,
+ Height = 360,
+ };
+ chat.Elements.Add(camera);
+
+ // The 16:9 placement: the camera sits where the desktop layout put it.
+ Assert.Equal(120, camera.X);
+ Assert.Equal(120, camera.Y);
+ Assert.Equal(640, camera.Width);
+
+ // ── The vertical tier is picked ───────────────────────────────────
+ vm.SelectedQuality = vm.QualityOptions[^1];
+ Assert.True(vm.IsVerticalTier);
+ var band = vm.VerticalBand;
+
+ // The Chat camera came INTO the band as a small tile...
+ Assert.Equal(VerticalCameraLayout.TileWidth, camera.Width);
+ Assert.Equal(VerticalCameraLayout.TileHeight, camera.Height);
+ 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");
+
+ // ── The circle option is not offered ─────────────────────────────
+ vm.SelectedElement = camera;
+ Assert.False(vm.CanToggleClipShape);
+ Assert.Equal(ClipShape.Traditional, camera.ClipShape);
+
+ // ...and it cannot be slipped in behind the UI's back either.
+ camera.ToggleClipShape();
+ Assert.Equal(ClipShape.Traditional, camera.ClipShape);
+
+ // ── Drag it up, then enlarge it: it fills the TOP area ──────────
+ vm.MoveWebcamInVerticalTier(camera, band.Left + 40, band.Top + 40);
+ vm.ResizeWebcamInVerticalTier(camera, band.Width, band.Height);
+
+ Assert.Equal(band.Left, camera.X, 3);
+ Assert.Equal(band.Top, camera.Y, 3);
+ Assert.Equal(band.Width, camera.Width, 3);
+ Assert.Equal(band.Height / 2, camera.Height, 3);
+
+ // ── Back to 16:9: the desktop layout is restored exactly ─────────
+ vm.SelectedQuality = vm.QualityOptions[0];
+ Assert.False(vm.IsVerticalTier);
+ Assert.Equal(120, camera.X);
+ Assert.Equal(120, camera.Y);
+ Assert.Equal(640, camera.Width);
+ Assert.Equal(360, camera.Height);
+
+ // The circle option is offered again outside the vertical tier.
+ Assert.True(vm.CanToggleClipShape);
+ }
+ finally
+ {
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+}