Files
LlamaCasty/Models/SceneElement.cs
T
gramps 3a77662a95 vv webcam Unit 2: the Chat scene in the vertical tier - rect only, tile or one whole 607x540 area
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.
2026-10-21 19:23:29 -07:00

303 lines
9.9 KiB
C#

using System.ComponentModel;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using System.Windows.Media.Imaging;
namespace ytLive.Models;
public enum BorderAnimation
{
None,
Pulse,
Chase,
Rainbow,
Shimmer,
MarchingAnts,
Glow,
Electricity,
Sparkles
}
/// <summary>Whether an element's pixel content is fixed (bake once into the
/// compositor's cached base) or changes every frame (composite each tick).
/// See ai.md "SceneGraph component + baked-crust compositor" (TASK 31).</summary>
public enum ElementKind
{
/// <summary>Pixels never change at runtime (images, background art).</summary>
Static,
/// <summary>Pixels change every frame (webcam, live capture, chat, web source).</summary>
Dynamic
}
/// <summary>
/// A thing rendered in a scene: a multi-instance Source (image/background/text)
/// or a singleton resource's per-scene config (webcam). Carries the shared
/// layout + clip/mirror + border surface the preview pipeline renders from.
/// </summary>
public abstract class SceneElement : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
protected void Raise([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
protected bool Set<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(name);
return true;
}
public string Id { get; init; } = Guid.NewGuid().ToString();
private string _name = string.Empty;
public string Name { get => _name; set => Set(ref _name, value); }
/// <summary>Whether this element's pixels are fixed (Static) or change every
/// frame (Dynamic). The compositor bakes all Static layers below the first
/// Dynamic layer once (see <c>SceneGraph.GetSplitPoint</c>).</summary>
public virtual ElementKind Kind => ElementKind.Static;
/// <summary>True only for the transient zoom layer. Gates zoom-only UI:
/// hidden from the layers list, never selectable/draggable in the preview.</summary>
public virtual bool IsZoomLayer => false;
/// <summary>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
public virtual bool IsWebcam => false;
/// <summary>True only for WebSource-type elements. Gates URI input visibility.</summary>
public virtual bool IsWebSource => false;
/// <summary>True for elements that can be dragged/resized in the preview (image, webcam).</summary>
public virtual bool IsDraggable => false;
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
public virtual bool IsImageSource => false;
/// <summary>True only for ChatBox-type sources. Gates chat overlay properties panel.</summary>
public virtual bool IsChatBox => false;
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
public virtual bool IsCountdown => false;
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsBackground { get; set; }
private bool _useDefaultBackground = true;
/// <summary>When true, the shipped default background image is used and CustomBackgroundPath
/// is ignored (but preserved). Toggled by the pill-switch in the properties panel.</summary>
public bool UseDefaultBackground
{
get => _useDefaultBackground;
set => Set(ref _useDefaultBackground, value);
}
private string? _customBackgroundPath;
/// <summary>User-selected background image file path (FQFN). Null means use the app default.
/// Stored as a reference only — the image file is not copied into the DB.</summary>
public string? CustomBackgroundPath
{
get => _customBackgroundPath;
set => Set(ref _customBackgroundPath, value);
}
private bool _isVisible = true;
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, value); }
private bool _isEditing;
public bool IsEditing { get => _isEditing; set => Set(ref _isEditing, value); }
private ClipShape _clipShape = ClipShape.Traditional;
// Virtual so a derived element can CONSTRAIN the shape (the webcam is
// rect-only in the vertical tier's Chat layout — TASK 57) instead of every
// writer re-implementing the rule.
public virtual ClipShape ClipShape
{
get => _clipShape;
set
{
if (Set(ref _clipShape, value))
Raise(nameof(ShapeButtonText));
}
}
// The rectangular dimensions before a Round toggle, so switching back restores
// the aspect instead of staying locked to the square Round used. Persisted so
// the restore survives a reload of a Round element.
private double? _rectWidth;
private double? _rectHeight;
public double? RectWidth { get => _rectWidth; set => Set(ref _rectWidth, value); }
public double? RectHeight { get => _rectHeight; set => Set(ref _rectHeight, value); }
public virtual void ToggleClipShape()
{
if (ClipShape == ClipShape.Traditional)
{
RectWidth = Width;
RectHeight = Height;
ClipShape = ClipShape.Round;
}
else
{
ClipShape = ClipShape.Traditional;
if (RectWidth is { } rw && RectHeight is { } rh)
{
Width = rw;
Height = rh;
RectWidth = null;
RectHeight = null;
}
}
}
private bool _isMirrored;
public bool IsMirrored
{
get => _isMirrored;
set
{
if (Set(ref _isMirrored, value))
{
Raise(nameof(MirrorScale));
Raise(nameof(MirrorButtonText));
}
}
}
public double MirrorScale => IsMirrored ? -1 : 1;
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
// Live camera frames: the shared WriteableBitmap owned by CameraManager.
private WriteableBitmap? _videoImageSource;
public WriteableBitmap? VideoImageSource
{
get => _videoImageSource;
set
{
if (Set(ref _videoImageSource, value))
Raise(nameof(DisplaySource));
}
}
/// <summary>What the preview shows: live frames (webcam) or a static image (sources).</summary>
public abstract ImageSource? DisplaySource { get; }
// Position/transform (per-scene usage). The geometry setters are virtual so
// a derived element can CONSTRAIN them (the webcam clamps itself into the
// vertical tier's band — TASK 57) instead of every writer re-implementing
// the rule.
private double _x;
public virtual double X { get => _x; set => Set(ref _x, value); }
private double _y;
public virtual double Y { get => _y; set => Set(ref _y, value); }
private double _width;
public virtual double Width
{
get => _width;
set
{
if (Set(ref _width, value))
Raise(nameof(RoundBorderSize));
}
}
private double _height;
public virtual double Height
{
get => _height;
set
{
if (Set(ref _height, value))
Raise(nameof(RoundBorderSize));
}
}
private double _opacity = 1.0;
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
/// <summary>Round clip/border diameter = the shorter dimension (true circle).</summary>
public double RoundBorderSize => Math.Min(Width, Height);
// Border (OBS-style): #RRGGBB color, alpha opacity, pixel width. Off by default.
private string _borderColor = string.Empty;
public string BorderColor
{
get => _borderColor;
set
{
if (Set(ref _borderColor, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private double _borderOpacity = 1.0;
public double BorderOpacity
{
get => _borderOpacity;
set
{
if (Set(ref _borderOpacity, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private int _borderWidth;
public int BorderWidth
{
get => _borderWidth;
set
{
if (Set(ref _borderWidth, value))
{
Raise(nameof(HasBorder));
Raise(nameof(BorderBrush));
}
}
}
private BorderAnimation _borderAnimation = BorderAnimation.None;
public BorderAnimation BorderAnimation { get => _borderAnimation; set => Set(ref _borderAnimation, value); }
public bool HasBorder
{
get
{
if (BorderWidth <= 0) return false;
return TryGetBorderColor(out _, out _, out _);
}
}
public Brush? BorderBrush
{
get
{
if (!HasBorder || !TryGetBorderColor(out var r, out var g, out var b)) return null;
var alpha = (byte)Math.Round(Math.Clamp(BorderOpacity, 0, 1) * 255);
return new SolidColorBrush(Color.FromArgb(alpha, r, g, b));
}
}
public bool TryGetBorderColor(out byte r, out byte g, out byte b)
{
r = g = b = 0;
var hex = BorderColor.Trim().TrimStart('#');
if (hex.Length != 6) return false;
return byte.TryParse(hex.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out r)
&& byte.TryParse(hex.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out g)
&& byte.TryParse(hex.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out b);
}
}