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.
135 lines
5.9 KiB
C#
135 lines
5.9 KiB
C#
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 */ }
|
||
}
|
||
}
|
||
}
|