vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area

The creator's locked table (2026-10-17) gives the Chat scene its own rules in
the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that
is either the normal small tile or ENLARGED to fill one whole 607x540 area
(top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat
scene's own variant of it.

The rule lives on the MODEL, not on the button:
- Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual.
- Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM)
  OVERRIDES the ClipShape property, so no writer can slip a circle in - the
  toggle, the Shape button and the placement swap all go through it. The lock
  is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns.
- ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT
  offered in the vertical Chat layout - the Shape button disappears, it is not
  merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier.
- Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived -
  no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the
  whole area, 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 (top or bottom), so
  dragging it up/down swaps the areas.
- 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.
- Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag
  assigns, lets the model clamp into the band, then snaps (so the enlarged
  camera can be dragged from the top area to the bottom one).

Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for
the unit: 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. 430/430 tests pass, 0 warnings.

Research (the creator's table is the established practice, not our invention):
- Streamlabs' vertical guide: "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 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) - our
  swap-at-tier-change, stored per scene.
- Landmine recorded: "about the bottom 560 px of the vertical video can be
  covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat
  camera's enlarged BOTTOM area may be half-hidden by the platform chrome;
  TASK 53's safe-zone work decides that.

Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests),
TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md,
MyMistakes.md (3 recipes: lock the model property not the UI button; two detents
for a discrete size set; the edit tool cannot match an oldString with an em
dash), Models/ViewModels/Controls index rows.
This commit is contained in:
2026-10-21 19:23:29 -07:00
parent ccc1db52d3
commit 3a77662a95
17 changed files with 415 additions and 66 deletions
+2 -1
View File
@@ -246,7 +246,8 @@
Click="MirrorButton_Click"/> Click="MirrorButton_Click"/>
<Button Content="{Binding SelectedElement.ShapeButtonText}" <Button Content="{Binding SelectedElement.ShapeButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4" Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Margin="8,0,0,0" Click="ShapeButton_Click"/> Margin="8,0,0,0" Click="ShapeButton_Click"
Visibility="{Binding CanToggleClipShape, Converter={StaticResource BoolToVis}}"/>
</StackPanel> </StackPanel>
<TextBlock Text="Drag to move · drag the corner dot to resize" <TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/> Foreground="#a0a0b0" FontSize="10" Margin="0,2,0,0"/>
+4 -2
View File
@@ -476,9 +476,11 @@ public partial class PreviewPane : UserControl
selected.Width = newW; selected.Width = newW;
selected.Height = newH; 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) if (vm.IsVerticalTier && selected is WebcamSceneConfig verticalCam)
vm.SnapWebcamInVerticalTier(verticalCam); vm.ResizeWebcamInVerticalTier(verticalCam, newW, newH);
} }
} }
+2 -2
View File
@@ -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) | | `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 | | `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` | | `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 | | `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 | | `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 | | `SceneThumbnailStrip.xaml.cs` | Code-behind for the strip above |
| `LeftPanel.xaml.cs` | Code-behind for the panel above | | `LeftPanel.xaml.cs` | Code-behind for the panel above |
+47 -22
View File
@@ -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 ## At a glance
- **Branch** main, tracking `origin/main`. **origin/main = `6be062c` = HEAD — up to date, nothing - **Branch** main, tracking `origin/main`. **origin/main = `ccc1db5` = the last pushed commit; the
waiting.** The vv Unit 1 commit was **pushed on the creator's approval** (2026-10-21, after the vv Unit 2 work is committed on top of it.** The vv Unit 1 commit was **pushed on the creator's
creator tested the placement). Push only at sub-milestones/milestones, at the creator's word. 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 - **The push path changed:** `origin` is now
`https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper `https://llgit.llamachile.tube/gramps/LlamaCasty.git` (HTTPS + the credential helper
`/home/gramps/bin/gitea-credential`, which reads the password from the gitignored `/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 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 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. 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 - **This session shipped vv webcam Units 1+2** (the vertical 9:16 tier keeps its OWN camera placement
scene; 16:9 restores the desktop layout exactly). Code + memory in ONE commit; the ONE Good Dog test per scene; 16:9 restores the desktop layout exactly; the Chat scene in vv is rect-only and can be
is `ytLive.Tests/VerticalWebcamLayoutTests.cs`. **Unit 2 (vv · Chat) is next** — see the vv section. 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 - **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 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 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 "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`. `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 | | mode | shape | size | position |
|---|---|---|---| |---|---|---|---|
| 16:9 | as today | as today | as today | | 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. 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. - 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) ### 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 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 + 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 *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 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.** 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 2. ✅ **vv · Chat (Unit 2)** — shipped 2026-10-21. Rect enforced with the circle option unavailable, plus
(607×540) snapping top or bottom. enlarge-to-one-area (607×540) filling the top or the bottom area by where the camera is.
*Test:* in Chat under the vertical tier, enlarge sets the full top area and the circle option is not *Test:* `ytLive.Tests/VerticalChatCameraLayoutTests.cs` — in Chat under the vertical tier, enlarge sets
offered. **Not started.** the full top area and the circle option is not offered. **PASSES.**
## Next step ## Next step
- **vv Unit 2 (the Chat scene)** — the only queued piece of this feature. Requirements are locked in - **Creator check (needs the creator's hands)** — the last piece of the vv feature: switch the
`TASKS/task-57-vertical-camera-layout.md`: rect ONLY in the vertical tier (no circle option offered), resolution dropdown to "Vertical 1080p60", confirm the Live camera tile lands inside the band and
size either normal or enlarged to fill one 607×540 area, same six-spot snap; and the Chat scene's the Shape button toggles circle/rect there, open the **Chat** scene and confirm the Shape button is
16:9 exemption from the half-frame cap (`MaxWebcamWidthFor/HeightFor`) must not leak into the **gone** (rect only) and that dragging the tile up and enlarging it fills a whole 607×540 area, then
vertical tier. switch back and confirm the desktop layout is untouched. The unit tests cover the geometry, not the
- **Creator check (needs the creator's hands):** switch the resolution dropdown to "Vertical 1080p60", eyeball.
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. - Nothing else is queued for llamacasty — the creator queues work.
## Open (operational) ## 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). - TASK 55 disk hygiene (`startup.log` uncapped — 23MB and growing every 5s).
## Tests ## 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…`, - Known flaky-by-environment (all pass in isolation): `AudioPipelineTests.Mix_HonorsProviderGains…`,
`MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`, `MasterLimiter_CapsTheLiveMix_OnThePipe`, `LayerReorderPersistenceTests.RealMouseDrag…`,
`Round_Clip_Corner…`. `Round_Clip_Corner…`.
+5 -2
View File
@@ -111,7 +111,10 @@ public abstract class SceneElement : INotifyPropertyChanged
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); } public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional; 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; get => _clipShape;
set set
@@ -130,7 +133,7 @@ public abstract class SceneElement : INotifyPropertyChanged
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); } public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); } public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
public void ToggleClipShape() public virtual void ToggleClipShape()
{ {
if (ClipShape == ClipShape.Traditional) if (ClipShape == ClipShape.Traditional)
{ {
+16
View File
@@ -38,6 +38,22 @@ public static class VerticalCameraLayout
public static (double Width, double Height) MaxSizeFor(Rect band) public static (double Width, double Height) MaxSizeFor(Rect band)
=> (band.Width, band.Height / 2); => (band.Width, band.Height / 2);
/// <summary>True when a camera is wide enough to fill one whole area of the
/// band — the "enlarged" Chat layout (one 607×540 area).</summary>
public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
/// <summary>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.</summary>
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);
}
/// <summary> /// <summary>
/// The spot a tile snaps to: the area (top/bottom) and the column /// 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 /// (left/centre/right) its centre falls in, then the tile's top-left for
+26
View File
@@ -97,6 +97,12 @@ public class WebcamSceneConfig : SceneElement
/// <see cref="LandscapeX"/> and friends until the tier switches back.</summary> /// <see cref="LandscapeX"/> and friends until the tier switches back.</summary>
public bool VerticalLayoutLive { get; private set; } public bool VerticalLayoutLive { get; private set; }
/// <summary>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.</summary>
public bool VerticalShapeLocked { get; internal set; }
/// <summary>The vertical tier's band over the master frame (transient — the /// <summary>The vertical tier's band over the master frame (transient — the
/// VM sets it when the tier changes). While the vertical placement is live /// 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.</summary> /// the camera's geometry is clamped to it: outside the band is invisible.</summary>
@@ -166,6 +172,26 @@ public class WebcamSceneConfig : SceneElement
VerticalRectHeight = RectHeight; 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 // 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 // live — a camera outside the cropped window is a camera the viewers never
// see, and every writer (drag, resize handle, the properties fields) goes // see, and every writer (drag, resize handle, the properties fields) goes
+3 -3
View File
@@ -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 | | `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` | | `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:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) | | `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:<n>`/`window:<hwnd>`/`picker:<name>`), `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 | | `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) | | `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`. | | `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) | | `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` | | `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 | | `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 | | `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) | | `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) |
+57
View File
@@ -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 **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` scripts you write yourself (`chmod 700`), and they should read the secret from `CREDENTIALS.md`
rather than embed it. 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.
+11 -9
View File
@@ -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) | | 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) | | 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) | | 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 14** — Branch 2 shipped; branch 3+ in progress
- **TASK 21** — UI picker slice remaining (Windows-only verification pending) - **TASK 21** — UI picker slice remaining (Windows-only verification pending)
- **TASK 32–36** — all queued future work (2026-09-01) - **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 - **✅ TASK 57 — the "vv" vertical camera layout** (both units shipped 2026-10-21). The vertical
the same master, so the 16:9 camera placement falls outside the cropped window and the creator tier is a centred band crop of the same master, so the 16:9 camera placement falls outside the
is not in the vertical stream at all. **Unit 1 shipped 2026-10-21:** each scene keeps its own cropped window and the creator is not in the vertical stream at all. **Unit 1:** each scene keeps
vertical placement (position, size, shape); the Live scene gets a small tile in one of six spots its own vertical placement (position, size, shape); the Live scene gets a small tile in one of
(top/bottom × left/centre/right), circle or rect, placed by drag-snap or typed into the new six spots (top/bottom × left/centre/right), circle or rect, placed by drag-snap or typed into the
Geometry fields; switching back to 16:9 restores the desktop layout exactly. **Unit 2 is next:** new Geometry fields; switching back to 16:9 restores the desktop layout exactly. **Unit 2:** the
the Chat scene in the vertical tier — rect only, either normal or enlarged to one 607×540 area Chat scene in the vertical tier — rect only (the circle option is not offered and the model
(the creator's table, 2026-10-17). See [`TASKS/task-57-vertical-camera-layout.md`](TASKS/task-57-vertical-camera-layout.md) 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". and `ai.md` → "Vertical (9:16) tier camera placement".
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera - **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 auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate
+37 -6
View File
@@ -2,7 +2,8 @@
> Catalog: [`TASKS.md`](../TASKS.md) — status and requirements live here. > 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 ## Why
The vertical tier (1080×1920) is a centred **band crop** of the same 1920×1080 master every 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 | | 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 · 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 | | Starting / BRB / Ending | unchanged | unchanged | unchanged |
Plus: each scene keeps its **own** vertical position, size and shape; switching back to 16:9 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 - **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. 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) ## Slices (one integration test each)
1. ✅ **Unit 1 — the Live scene** (shipped 2026-10-21): the vertical tier brings the Live camera 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 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 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. 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 2. ✅ **Unit 2 — the Chat scene** (shipped 2026-10-21): in the vertical tier the Chat camera comes
size or enlarged to fill one 607×540 area; the same six-spot snap. The Chat scene's 16:9 into the band as a small RECT tile; the circle option is not offered and cannot be set even on
exemption from the half-frame cap must not leak into the vertical tier. 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", 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 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 ## Notes
- OBS cannot do this (no per-aspect layout storage — the maintainers' answer is a second scene - 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 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. 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 - 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. camera tile is inside the band, but the band is not yet proven against TikTok/Shorts chrome.
+1
View File
@@ -428,6 +428,7 @@ public partial class MainViewModel : ViewModelBase
// in, 16:9 puts the desktop layout back exactly where it was. // in, 16:9 puts the desktop layout back exactly where it was.
OnPropertyChanged(nameof(IsVerticalTier)); OnPropertyChanged(nameof(IsVerticalTier));
OnPropertyChanged(nameof(VerticalBand)); OnPropertyChanged(nameof(VerticalBand));
OnPropertyChanged(nameof(CanToggleClipShape));
if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality))) if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality)))
ScheduleSave(); ScheduleSave();
} }
+68 -13
View File
@@ -486,6 +486,11 @@ public partial class MainViewModel
private bool ResolveVerticalCameraLayout(Scene scene, WebcamSceneConfig config, bool vertical) 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) if (!vertical)
{ {
config.VerticalBand = Rect.Empty; config.VerticalBand = Rect.Empty;
@@ -509,12 +514,14 @@ public partial class MainViewModel
return true; return true;
} }
// The Live scene's camera must be INSIDE the band or the vertical // The Live and Chat scenes are the camera scenes in the vertical tier:
// output has no camera at all — it starts as a small tile in the // without a placement INSIDE the band the vertical output has no camera
// bottom-right spot (the corner the 16:9 default uses). The other // at all. They start as a small tile in the bottom-right spot (the
// scenes keep their 16:9 placement until the creator drags them into // corner the 16:9 default webcam uses). Starting/BRB/Ending keep their
// the band (creator ruling 2026-10-17: Starting/BRB/Ending unchanged). // 16:9 placement (creator ruling 2026-10-17: unchanged) until the
if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live)) return false; // creator drags them into the band.
if (!SceneCatalog.Is(scene.Name, SceneCatalog.Live) && !SceneCatalog.IsChat(scene.Name))
return false;
var spot = VerticalCameraLayout.DefaultSpot( var spot = VerticalCameraLayout.DefaultSpot(
VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight); VerticalBand, VerticalCameraLayout.TileWidth, VerticalCameraLayout.TileHeight);
@@ -534,11 +541,9 @@ public partial class MainViewModel
{ {
if (!IsVerticalTier) return; if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config); EnsureVerticalCameraLayout(config);
var spot = VerticalCameraLayout.SpotFor( config.X = x;
VerticalBand, config.Width, config.Height, config.Y = y;
x + config.Width / 2, y + config.Height / 2); SnapWebcamInVerticalTier(config);
config.X = spot.X;
config.Y = spot.Y;
} }
/// <summary>Snaps a camera back onto its spot — the resize handle grew or /// <summary>Snaps a camera back onto its spot — the resize handle grew or
@@ -547,13 +552,63 @@ public partial class MainViewModel
{ {
if (!IsVerticalTier) return; if (!IsVerticalTier) return;
EnsureVerticalCameraLayout(config); 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( var spot = VerticalCameraLayout.SpotFor(
VerticalBand, config.Width, config.Height, VerticalBand, config.Width, config.Height, centreX, centreY);
config.X + config.Width / 2, config.Y + config.Height / 2);
config.X = spot.X; config.X = spot.X;
config.Y = spot.Y; config.Y = spot.Y;
} }
/// <summary>True when this camera belongs to the Chat scene — the vertical
/// tier's Chat layout has its own rules (rect only, two sizes).</summary>
public bool IsChatSceneCamera(WebcamSceneConfig config)
=> Scenes.Any(s => SceneCatalog.IsChat(s.Name) && s.Elements.Contains(config));
/// <summary>False when the circle option is not offered — the Chat scene's
/// vertical layout is rect-only (the creator's table, 2026-10-17).</summary>
public bool CanToggleClipShape => !(IsVerticalTier
&& SelectedElement is WebcamSceneConfig selected
&& IsChatSceneCamera(selected));
/// <summary>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.</summary>
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);
}
/// <summary>Makes the scene's vertical placement live — creating it (a small /// <summary>Makes the scene's vertical placement live — creating it (a small
/// tile in the default spot) when the scene has none yet.</summary> /// tile in the default spot) when the scene has none yet.</summary>
private void EnsureVerticalCameraLayout(WebcamSceneConfig config) private void EnsureVerticalCameraLayout(WebcamSceneConfig config)
+1
View File
@@ -67,6 +67,7 @@ public partial class MainViewModel : ViewModelBase
{ {
OnPropertyChanged(nameof(IsWebcamSelected)); OnPropertyChanged(nameof(IsWebcamSelected));
OnPropertyChanged(nameof(IsElementsPanelEmpty)); OnPropertyChanged(nameof(IsElementsPanelEmpty));
OnPropertyChanged(nameof(CanToggleClipShape));
if (_selectedElement != null) if (_selectedElement != null)
_selectedElement.PropertyChanged += OnSelectedElementPropertyChanged; _selectedElement.PropertyChanged += OnSelectedElementPropertyChanged;
} }
+3 -3
View File
@@ -8,17 +8,17 @@ areas' docs in their own rows keeps this index small to read.
| File | Purpose | | 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.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.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.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.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.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.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.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.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.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. | | `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 | | `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 |
+18 -3
View File
@@ -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 (`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. 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 **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 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 | | 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 · 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 | | Starting / BRB / Ending | unchanged | unchanged | unchanged |
**Design — geometry only, no render-path change.** The preview and the compositor both read the **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 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. 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 **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 (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 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 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. 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 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 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 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.) 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` **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 broke `WebcamBackgroundPillTests` — that test finds the webcam's panel by **binding text**, so a
@@ -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;
/// <summary>
/// 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).
/// </summary>
[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 */ }
}
}
}