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.
237 lines
9.3 KiB
C#
237 lines
9.3 KiB
C#
using System.Windows;
|
|
using System.Windows.Media;
|
|
|
|
namespace ytLive.Models;
|
|
|
|
/// <summary>
|
|
/// A scene's usage of the app-wide webcam — conceptually `webcam.{scene}.config`.
|
|
/// The identity lives once on the <see cref="Webcam"/> entity; this object is that
|
|
/// scene's placement, clip/mirror, border, and visibility for it. Exists only in
|
|
/// scenes where the creator added the webcam.
|
|
/// </summary>
|
|
public class WebcamSceneConfig : SceneElement
|
|
{
|
|
public string WebcamId { get; init; } = string.Empty;
|
|
public override bool IsWebcam => true;
|
|
public override bool IsDraggable => true;
|
|
public override ElementKind Kind => ElementKind.Dynamic;
|
|
public override ImageSource? DisplaySource => VideoImageSource;
|
|
|
|
private BackgroundEffect _backgroundEffect = BackgroundEffect.None;
|
|
/// <summary>TASK 51: this scene's background effect for the webcam (persisted).</summary>
|
|
public BackgroundEffect BackgroundEffect
|
|
{
|
|
get => _backgroundEffect;
|
|
set
|
|
{
|
|
if (!Set(ref _backgroundEffect, value)) return;
|
|
Raise(nameof(RemoveBackground));
|
|
Raise(nameof(BlurBackground));
|
|
Raise(nameof(HasBackgroundEffect));
|
|
}
|
|
}
|
|
|
|
/// <summary>True while Remove or Blur is on — shows the strength slider.</summary>
|
|
public bool HasBackgroundEffect => BackgroundEffect != BackgroundEffect.None;
|
|
|
|
private double _backgroundStrength = 0.5;
|
|
/// <summary>TASK 51: 0..1 — Blur amount, or Remove's edge softness (persisted).</summary>
|
|
public double BackgroundStrength
|
|
{
|
|
get => _backgroundStrength;
|
|
set => Set(ref _backgroundStrength, Math.Clamp(value, 0, 1));
|
|
}
|
|
|
|
/// <summary>Pill-switch surface: Remove and Blur behave like radio buttons — turning one
|
|
/// on turns the other off; turning the active one off means <see cref="BackgroundEffect.None"/>.</summary>
|
|
public bool RemoveBackground
|
|
{
|
|
get => BackgroundEffect == BackgroundEffect.Remove;
|
|
set => BackgroundEffect = value ? BackgroundEffect.Remove
|
|
: BackgroundEffect == BackgroundEffect.Remove ? BackgroundEffect.None : BackgroundEffect;
|
|
}
|
|
|
|
/// <inheritdoc cref="RemoveBackground"/>
|
|
public bool BlurBackground
|
|
{
|
|
get => BackgroundEffect == BackgroundEffect.Blur;
|
|
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>True while the vertical placement must stay RECT — the Chat
|
|
/// scene's vertical layout (the creator's table: "vv · Chat = rect only, no
|
|
/// circle option"). The circle is not offered there, so the shape cannot
|
|
/// become Round by any writer.</summary>
|
|
public bool VerticalShapeLocked { get; internal 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 Chat scene's vertical layout is rect-only (the creator's table, the
|
|
// circle option is not offered there). Locking the SHAPE property — not
|
|
// just the toggle — means no writer can slip a Round camera in: the
|
|
// toggle, the shape button and the placement swap all go through here.
|
|
public override ClipShape ClipShape
|
|
{
|
|
get => base.ClipShape;
|
|
set => base.ClipShape = VerticalShapeLocked ? ClipShape.Traditional : value;
|
|
}
|
|
|
|
public override void ToggleClipShape()
|
|
{
|
|
if (VerticalShapeLocked)
|
|
{
|
|
ClipShape = ClipShape.Traditional;
|
|
return;
|
|
}
|
|
base.ToggleClipShape();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|