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.
300 lines
9.7 KiB
C#
300 lines
9.7 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;
|
|
public 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 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);
|
|
}
|
|
}
|