vv webcam Unit 1: the vertical (9:16) tier keeps its OWN camera placement per scene

The vertical tier is a centred band crop of the SAME 1920x1080 master (band x
656.25, w 607.5, h 1080), so the 16:9 camera placement (x~1408) falls outside the
cropped window and the creator is simply not in the vertical stream. Each scene
now keeps its own vertical position, size and shape; switching back to 16:9
restores the desktop layout exactly (the creator's locked table, 2026-10-17).

Geometry only - NO render-path change. The preview and the compositor both read
the element's own X/Y/W/H + ClipShape, so the tier switch swaps the LIVE geometry:
- Models/VerticalCameraLayout.cs (new): the pure math - the band, its two 607x540
  areas, the six spots (top/bottom x left/centre/right), the 32px inset and the
  270x152 default tile (480x270 in the 1080x1920 output) in the bottom-right spot,
  the corner the 16:9 default webcam uses.
- Models/WebcamSceneConfig.cs: the vertical placement lives in seven additive
  columns (VerticalX/Y/Width/Height/ClipShape/RectWidth/RectHeight; NULL = "this
  scene has no vertical placement yet"). The 16:9 layout stays in the old columns
  and is parked in the transient Landscape* fields while the vertical tier is
  live; Begin/EndVerticalLayout + CommitLiveLayoutToVertical do the swap. The
  X/Y/W/H overrides clamp every write into the band - outside it the camera is
  invisible in the vertical output.
- Models/SceneElement.cs: X/Y/Width/Height became virtual so the derived element
  constrains every writer (drag, resize handle, typed fields) in ONE choke point
  instead of three writers each re-implementing the rule.
- ViewModels/MainViewModel.Webcam.cs + .Streaming.cs: IsVerticalTier/VerticalBand,
  ResolveVerticalCameraLayout(s) (the tier swap), MoveWebcamInVerticalTier /
  SnapWebcamInVerticalTier (the six-spot snap, by the tile's centre),
  EnsureVerticalCameraLayout; ApplyStreamQuality is the tier-change hook. The
  Live scene gets its small tile automatically on the first vertical switch (the
  vertical stream would otherwise have no camera at all); the other scenes keep
  their 16:9 placement until the creator drags them into the band.
- Controls/PreviewPane.xaml.cs: the drag/resize route through the VM snap under
  the vertical tier instead of free-floating over the master.
  Controls/LeftPanel.xaml: the webcam properties gained a Geometry section
  (X/Y/W/H) for the manual entry the creator asked for, bound to the LIVE
  placement, gated on the VM's IsWebcamSelected (NOT SelectedElement.IsWebcam -
  the TASK 51 test finds that panel by binding text and would match twice).
- The store: seven guarded ADD COLUMNs, the INSERT/SELECT extended, and the
  X/Y/W/H/ClipShape columns stay the 16:9 layout - while the vertical tier is
  live the save writes the parked values there and the live ones into Vertical*.

Test: ytLive.Tests/VerticalWebcamLayoutTests.cs - the ONE Good Dog test. The
vertical tier brings the Live camera INTO the band as a small tile; dragging it to
the top-right lands it top-right inside the band; back to 16:9 the desktop layout
is restored exactly (1408/778/480x270); both placements survive a save + reload.
Full suite 429/429, 0 warnings. LayerReorderPersistenceTests.RealMouseDrag fails
ALONE on a clean tree in this environment - pre-existing flake, proven by stashing
this work, not a regression.

Derivative work: OBS has no built-in per-aspect-ratio layout storage - the
maintainers' answer is "create a second scene and add the scene you want to send
as source" (obsproject.com/forum/threads/161307) - and Streamyard's dual-aspect
guide says the same ("no built-in way to store separate layouts per aspect ratio",
so you rely on manual workarounds and plugins):
https://streamyard.com/blog/dual-aspect-ratio-streaming-tool
Our per-scene, per-tier stored placement, resolved on the tier change, is the
step past both.

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