The creator's locked table (2026-10-17) gives the Chat scene its own rules in the 9:16 tier: RECT ONLY - the circle option is not offered - and a size that is either the normal small tile or ENLARGED to fill one whole 607x540 area (top or bottom). Unit 1 shipped the per-scene placement; this unit is the Chat scene's own variant of it. The rule lives on the MODEL, not on the button: - Models/SceneElement.cs: ClipShape and ToggleClipShape() are now virtual. - Models/WebcamSceneConfig.cs: VerticalShapeLocked (set per scene by the VM) OVERRIDES the ClipShape property, so no writer can slip a circle in - the toggle, the Shape button and the placement swap all go through it. The lock is cleared BEFORE the 16:9 layout comes back, so the desktop shape returns. - ViewModels/MainViewModel.Webcam.cs: CanToggleClipShape (the circle is NOT offered in the vertical Chat layout - the Shape button disappears, it is not merely greyed out), IsChatSceneCamera, ResizeWebcamInVerticalTier. - Models/VerticalCameraLayout.cs: FillsArea (the "enlarged" state, derived - no new column) and SizeFor - the Chat camera's TWO sizes, the tile or the whole area, whichever is nearer the requested width, so the resize handle has two detents instead of any size in between. - An enlarged camera FILLS the area its centre is in (top or bottom), so dragging it up/down swaps the areas. - The Chat camera is auto-tiled like the Live one when the vertical tier is picked - otherwise the vertical chat stream has no camera at all. - Controls/PreviewPane.xaml.cs: the resize routes through the VM; the drag assigns, lets the model clamp into the band, then snaps (so the enlarged camera can be dragged from the top area to the bottom one). Test: ytLive.Tests/VerticalChatCameraLayoutTests.cs - the ONE Good Dog test for the unit: the vertical tier is picked -> the Chat camera comes into the band as a small RECT tile -> the circle option is not offered and cannot be set even on the model -> dragged up and enlarged it fills the TOP area exactly -> back to 16:9 the desktop layout is restored exactly and the circle option is offered again. 430/430 tests pass, 0 warnings. Research (the creator's table is the established practice, not our invention): - Streamlabs' vertical guide: "splitting the screen in half horizontally, placing the camera feed in the top half and the main content on the bottom (or vice versa) may be the bet", and a just-chatting vertical wants the camera "to take up most of the screen" (streamlabs.com/content-hub/post/overlays-vertical-and-horizontal-streaming) - Bloopbot's vertical overlay docs describe the same per-layer model we use at scene granularity: "move or resize a layer on Vertical and it stops being Auto-placed. It keeps the place you gave it, and the horizontal layout is not touched" (bloopbot.com/docs/vertical-overlays-twitch-dual-format) - our swap-at-tier-change, stored per scene. - Landmine recorded: "about the bottom 560 px of the vertical video can be covered by Twitch's chat and player controls on phones" (Bloopbot) - the Chat camera's enlarged BOTTOM area may be half-hidden by the platform chrome; TASK 53's safe-zone work decides that. Memory in the same commit: ai.md (the Chat rules, the shape lock, the tests), TASKS.md + TASKS/task-57-vertical-camera-layout.md (TASK 57 complete), HANDOFF.md, MyMistakes.md (3 recipes: lock the model property not the UI button; two detents for a discrete size set; the edit tool cannot match an oldString with an em dash), Models/ViewModels/Controls index rows.
86 lines
4.1 KiB
C#
86 lines
4.1 KiB
C#
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>True when a camera is wide enough to fill one whole area of the
|
||
/// band — the "enlarged" Chat layout (one 607×540 area).</summary>
|
||
public static bool FillsArea(Rect band, double width) => width >= band.Width - 1;
|
||
|
||
/// <summary>The two sizes a camera may take in the vertical tier's Chat
|
||
/// layout: the small tile (normal) or one whole area (enlarged). Returns
|
||
/// the one nearer the requested width, so the resize handle snaps between
|
||
/// them instead of taking any size in between.</summary>
|
||
public static (double Width, double Height) SizeFor(Rect band, double requestedWidth)
|
||
{
|
||
var (areaWidth, areaHeight) = MaxSizeFor(band);
|
||
return Math.Abs(requestedWidth - TileWidth) <= Math.Abs(requestedWidth - areaWidth)
|
||
? (TileWidth, TileHeight)
|
||
: (areaWidth, areaHeight);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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);
|
||
}
|