Multi-scene webcam (schema v3/v4): singleton Webcam + per-scene WebcamSceneConfig, right-click OBS-style border/context menu, persisted round-to-rect restore + legacy-square 16:9 heal, dark MenuItem template, tests (25 passing)
This commit is contained in:
+9
-1
@@ -30,7 +30,15 @@ public class Scene : INotifyPropertyChanged
|
||||
set => Set(ref _isHidden, value);
|
||||
}
|
||||
|
||||
public ObservableCollection<Source> Sources { get; } = new();
|
||||
/// <summary>
|
||||
/// The scene's rendered elements in z-order (back to front): multi-instance
|
||||
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
|
||||
/// </summary>
|
||||
public ObservableCollection<SceneElement> Elements { get; } = new();
|
||||
|
||||
/// <summary>This scene's webcam usage, or null if the creator hasn't added the webcam here.</summary>
|
||||
public WebcamSceneConfig? WebcamConfig => Elements.OfType<WebcamSceneConfig>().FirstOrDefault();
|
||||
|
||||
public bool IsChatScene { get; init; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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>
|
||||
/// 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>True only for singleton resources (webcam). Gates webcam-only UI.</summary>
|
||||
public virtual bool IsWebcam => false;
|
||||
|
||||
/// <summary>True only for image sources — the raw preview renderer's discriminator.</summary>
|
||||
public virtual bool IsImageSource => false;
|
||||
|
||||
private bool _isVisible = true;
|
||||
public bool IsVisible { get => _isVisible; set => Set(ref _isVisible, 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)
|
||||
private double _x;
|
||||
public double X { get => _x; set => Set(ref _x, value); }
|
||||
|
||||
private double _y;
|
||||
public double Y { get => _y; set => Set(ref _y, value); }
|
||||
|
||||
private double _width;
|
||||
public double Width
|
||||
{
|
||||
get => _width;
|
||||
set
|
||||
{
|
||||
if (Set(ref _width, value))
|
||||
Raise(nameof(RoundBorderSize));
|
||||
}
|
||||
}
|
||||
|
||||
private double _height;
|
||||
public 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));
|
||||
}
|
||||
}
|
||||
|
||||
private 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);
|
||||
}
|
||||
}
|
||||
+9
-89
@@ -1,7 +1,4 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Imaging;
|
||||
using ytLive.Helpers;
|
||||
|
||||
namespace ytLive.Models;
|
||||
@@ -10,7 +7,6 @@ public enum SourceType
|
||||
{
|
||||
DisplayCapture,
|
||||
WindowCapture,
|
||||
Webcam,
|
||||
Background,
|
||||
Image,
|
||||
TextOverlay
|
||||
@@ -22,26 +18,14 @@ public enum ClipShape
|
||||
Round
|
||||
}
|
||||
|
||||
public class Source : INotifyPropertyChanged
|
||||
/// <summary>
|
||||
/// A multi-instance scene object: image/background/text (screen/window later).
|
||||
/// The webcam is NOT a Source — it's a singleton resource whose per-scene usage
|
||||
/// is a <see cref="WebcamSceneConfig"/>. See the singleton-vs-multi-instance
|
||||
/// discriminator in ai.md.
|
||||
/// </summary>
|
||||
public class Source : SceneElement
|
||||
{
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void Raise([CallerMemberName] string? name = null)
|
||||
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
|
||||
private 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); }
|
||||
|
||||
private SourceType _type;
|
||||
public SourceType Type { get => _type; set => Set(ref _type, value); }
|
||||
|
||||
@@ -55,58 +39,6 @@ public class Source : INotifyPropertyChanged
|
||||
private IntPtr? _windowHandle;
|
||||
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
|
||||
|
||||
// Webcam
|
||||
private string? _deviceId;
|
||||
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); }
|
||||
|
||||
// Webcam live preview: the shared WriteableBitmap owned by CameraManager.
|
||||
private WriteableBitmap? _videoImageSource;
|
||||
public WriteableBitmap? VideoImageSource
|
||||
{
|
||||
get => _videoImageSource;
|
||||
set
|
||||
{
|
||||
if (Set(ref _videoImageSource, value))
|
||||
Raise(nameof(DisplaySource));
|
||||
}
|
||||
}
|
||||
|
||||
private ClipShape _clipShape = ClipShape.Traditional;
|
||||
public ClipShape ClipShape
|
||||
{
|
||||
get => _clipShape;
|
||||
set
|
||||
{
|
||||
if (Set(ref _clipShape, value))
|
||||
Raise(nameof(ShapeButtonText));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>Mirror toggle label (mirrored → "Unmirror").</summary>
|
||||
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
|
||||
|
||||
/// <summary>Clip-shape toggle label (round → "Rect").</summary>
|
||||
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
|
||||
|
||||
/// <summary>What the preview shows: static image for image/background, live frames for webcam.</summary>
|
||||
public ImageSource? DisplaySource => Type == SourceType.Webcam ? _videoImageSource : _imageSource;
|
||||
|
||||
// Image (asset stored in the layout database)
|
||||
private string? _assetId;
|
||||
private ImageSource? _imageSource;
|
||||
@@ -127,19 +59,7 @@ public class Source : INotifyPropertyChanged
|
||||
|
||||
public ImageSource? ImageSource => _imageSource;
|
||||
|
||||
// Position/transform (per-scene usage)
|
||||
private double _x;
|
||||
public double X { get => _x; set => Set(ref _x, value); }
|
||||
public override ImageSource? DisplaySource => _imageSource;
|
||||
|
||||
private double _y;
|
||||
public double Y { get => _y; set => Set(ref _y, value); }
|
||||
|
||||
private double _width;
|
||||
public double Width { get => _width; set => Set(ref _width, value); }
|
||||
|
||||
private double _height;
|
||||
public double Height { get => _height; set => Set(ref _height, value); }
|
||||
|
||||
private double _opacity = 1.0;
|
||||
public double Opacity { get => _opacity; set => Set(ref _opacity, value); }
|
||||
public override bool IsImageSource => Type == SourceType.Image;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ytLive.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The app-wide webcam identity — one camera input (the limit is the hardware,
|
||||
/// not us). Scenes reference it via <see cref="WebcamSceneConfig"/>; the DeviceId
|
||||
/// lives only here, so the camera is a Windows-controlled singleton.
|
||||
/// </summary>
|
||||
public class Webcam
|
||||
{
|
||||
public string Id { get; init; } = Guid.NewGuid().ToString();
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
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 ImageSource? DisplaySource => VideoImageSource;
|
||||
}
|
||||
+5
-2
@@ -5,8 +5,11 @@ Plain data types. No logic beyond what a property can carry. See
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection |
|
||||
| `Source.cs` | A source: `SourceType` enum (Image/Webcam/Screen/Background/Text), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (webcam live frames), `DisplaySource` (whichever the preview shows), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText` toggle labels, asset identity, webcam `DeviceId` |
|
||||
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Elements` collection (images + webcam config), `WebcamConfig` accessor |
|
||||
| `Source.cs` | An image source: `SourceType` enum (Image/Screen/Background/Text — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource`, `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`) |
|
||||
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
|
||||
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
|
||||
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
|
||||
| `QualityOption.cs` | Resolution tier record `(Display, Fps, Bitrate, Width, Height)`; `Label` formats as `1080p60 (8Mbps)` — drives the bottom-bar dropdown and the output-rect math |
|
||||
| `StreamConfig.cs` | `StreamConfig` (note: `TargetBitrate`/`Resolution` defaults are stale — not yet wired to the dropdown), `StreamHealth` (bitrate/FPS/dropped/duration), `StreamStatus` enum |
|
||||
| `YouTube.cs` | `YouTubeChannel` and `ChatMessage` (chat feed) |
|
||||
|
||||
Reference in New Issue
Block a user