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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user