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
+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);
}
}