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 */ }
+ }
+ }
+}