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