diff --git a/AGENTS.md b/AGENTS.md
index 1c05a21..a2bca3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,8 +51,14 @@ conventions live here and in `ai.md`.
## Build
+From WSL, ALWAYS use the Windows dotnet host — Linux `dotnet` re-downloads the
+`windowsdesktop.app.*` packs over the slow 9p bridge and re-restores twice (WPF
+`_wpftmp`), and `--no-restore` right after an interrupted restore produces bogus
+`NETSDK1064` errors. See `ai.md` → Run for the exact commands and why.
+
```bash
-dotnet build # Windows-only WPF; builds from WSL via EnableWindowsTargeting
+"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
+"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
Keep it at **0 warnings**. Running requires Windows. On a silent startup crash,
diff --git a/Helpers/EnumToBoolConverter.cs b/Helpers/EnumToBoolConverter.cs
new file mode 100644
index 0000000..aee9f0d
--- /dev/null
+++ b/Helpers/EnumToBoolConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Windows.Data;
+
+namespace ytLive.Helpers;
+
+/// True when the bound value's ToString() equals the ConverterParameter string.
+public class EnumToBoolConverter : IValueConverter
+{
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => value?.ToString() == parameter?.ToString();
+
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+ => Binding.DoNothing;
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index cae6c48..815e55b 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -30,6 +30,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
+
@@ -230,7 +232,7 @@
@@ -244,8 +246,8 @@
-
+
+
+
+
+
+
+ Canvas.Left="{Binding SelectedElement.X}" Canvas.Top="{Binding SelectedElement.Y}"
+ Width="{Binding SelectedElement.Width}" Height="{Binding SelectedElement.Height}">
@@ -475,17 +611,17 @@
-
-
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index cb8017e..e251594 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -37,7 +37,7 @@ public partial class MainWindow : Window
{
if (e.PropertyName == nameof(MainViewModel.IsLive))
UpdateTaskbarOverlay();
- else if (e.PropertyName == nameof(MainViewModel.SelectedSource))
+ else if (e.PropertyName == nameof(MainViewModel.SelectedElement))
UpdateSelectionOverlay();
}
@@ -103,7 +103,7 @@ public partial class MainWindow : Window
if (IsDescendantOf(original, PreviewGrid)) return;
if (IsDescendantOf(original, SourceList)) return;
if (IsDescendantOf(original, OpacityChip)) return;
- _viewModel.SelectedSource = null;
+ _viewModel.SelectedElement = null;
}
private static bool IsDescendantOf(DependencyObject? child, DependencyObject ancestor)
@@ -115,27 +115,55 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay()
{
- var selected = _viewModel.SelectedSource is { } s && IsDraggableSource(s);
+ var selected = _viewModel.SelectedElement is { } s && IsDraggableElement(s);
SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected)
- OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%";
+ OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedElement!.Opacity * 100)}%";
}
// Static images and the webcam share the move/resize/selection behavior.
- private static bool IsDraggableSource(Source source)
- => source.Type is SourceType.Image or SourceType.Webcam;
+ private static bool IsDraggableElement(SceneElement element)
+ => element is Source { Type: SourceType.Image } or WebcamSceneConfig;
private void MirrorButton_Click(object sender, RoutedEventArgs e)
{
- if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
- source.IsMirrored = !source.IsMirrored;
+ if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
+ webcam.IsMirrored = !webcam.IsMirrored;
}
private void ShapeButton_Click(object sender, RoutedEventArgs e)
{
- if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
- source.ClipShape = source.ClipShape == ClipShape.Traditional ? ClipShape.Round : ClipShape.Traditional;
+ if (_viewModel.SelectedElement is WebcamSceneConfig webcam)
+ webcam.ToggleClipShape();
+ }
+
+ private void WebcamMenu_ChangeWebcam(object sender, RoutedEventArgs e)
+ => _viewModel.ChangeWebcamCommand.Execute(null);
+
+ private void WebcamMenu_SetBorderAnimation(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string tag }
+ && Enum.TryParse(tag, out var animation))
+ webcam.BorderAnimation = animation;
+ }
+
+ private void WebcamMenu_SetBorderColor(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: WebcamSceneConfig webcam, Tag: string color })
+ webcam.BorderColor = color;
+ }
+
+ private void WebcamMenu_HideInScene(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: WebcamSceneConfig webcam })
+ webcam.IsVisible = false;
+ }
+
+ private void WebcamMenu_Remove(object sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem { DataContext: SceneElement element })
+ _viewModel.RemoveSourceCommand.Execute(element);
}
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
@@ -148,9 +176,9 @@ public partial class MainWindow : Window
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
- var selected = _viewModel.SelectedSource;
+ var selected = _viewModel.SelectedElement;
- if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
+ if (selected is { } sel && IsDraggableElement(sel) && HitHandle(e.GetPosition(grid), sel))
{
_isResizing = true;
_resizeAspect = sel.ClipShape == ClipShape.Round ? 1 : sel.Width / Math.Max(1, sel.Height);
@@ -159,10 +187,10 @@ public partial class MainWindow : Window
return;
}
- var hit = HitImage(p);
+ var hit = HitElement(p);
if (hit != null)
{
- _viewModel.SelectedSource = hit;
+ _viewModel.SelectedElement = hit;
_isDraggingOverlay = true;
_grabOffset = new Point(p.X - hit.X, p.Y - hit.Y);
grid.CaptureMouse();
@@ -170,7 +198,7 @@ public partial class MainWindow : Window
return;
}
- _viewModel.SelectedSource = null;
+ _viewModel.SelectedElement = null;
}
private void Preview_MouseMove(object sender, MouseEventArgs e)
@@ -180,7 +208,7 @@ public partial class MainWindow : Window
var grid = (Grid)sender;
var toCanvas = CanvasGrid.TransformToVisual(grid).Inverse;
var p = toCanvas.Transform(e.GetPosition(grid));
- var selected = _viewModel.SelectedSource;
+ var selected = _viewModel.SelectedElement;
if (selected == null)
{
EndPreviewDrag(grid);
@@ -198,11 +226,14 @@ public partial class MainWindow : Window
}
else if (_isResizing)
{
- var newW = Math.Clamp(p.X - selected.X, 32, 1920);
+ var isWebcam = selected is WebcamSceneConfig;
+ var maxW = isWebcam ? MainViewModel.WebcamMaxWidth : 1920;
+ var maxH = isWebcam ? MainViewModel.WebcamMaxHeight : 1080;
+ var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect;
- if (newH > 1080)
+ if (newH > maxH)
{
- newH = 1080;
+ newH = maxH;
newW = newH * _resizeAspect;
}
selected.Width = newW;
@@ -221,23 +252,25 @@ public partial class MainWindow : Window
_isResizing = false;
}
- private bool HitHandle(Point mouseScreen, Source source)
+ private bool HitHandle(Point mouseScreen, SceneElement element)
{
- var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(source.X + source.Width, source.Y + source.Height));
+ var corner = CanvasGrid.TransformToVisual(PreviewGrid).Transform(new Point(element.X + element.Width, element.Y + element.Height));
return Math.Abs(mouseScreen.X - corner.X) <= 20 && Math.Abs(mouseScreen.Y - corner.Y) <= 20;
}
- private Source? HitImage(Point p)
+ private SceneElement? HitElement(Point p)
{
var scene = _viewModel.ActiveScene;
if (scene == null) return null;
- for (var i = scene.Sources.Count - 1; i >= 0; i--)
+ for (var i = scene.Elements.Count - 1; i >= 0; i--)
{
- var source = scene.Sources[i];
- if (!IsDraggableSource(source) || !source.IsEnabled) continue;
- if (p.X >= source.X && p.X <= source.X + source.Width &&
- p.Y >= source.Y && p.Y <= source.Y + source.Height)
- return source;
+ var element = scene.Elements[i];
+ if (!IsDraggableElement(element)) continue;
+ if (element is Source { IsEnabled: false }) continue;
+ if (element is WebcamSceneConfig { IsVisible: false }) continue;
+ if (p.X >= element.X && p.X <= element.X + element.Width &&
+ p.Y >= element.Y && p.Y <= element.Y + element.Height)
+ return element;
}
return null;
}
@@ -275,7 +308,7 @@ public partial class MainWindow : Window
if (item == null)
{
if (ReferenceEquals(listBox, SourceList))
- _viewModel.SelectedSource = null;
+ _viewModel.SelectedElement = null;
_dragIndex = -1;
return;
}
diff --git a/Models/Scene.cs b/Models/Scene.cs
index 7c4d801..e063fcb 100644
--- a/Models/Scene.cs
+++ b/Models/Scene.cs
@@ -30,7 +30,15 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _isHidden, value);
}
- public ObservableCollection Sources { get; } = new();
+ ///
+ /// The scene's rendered elements in z-order (back to front): multi-instance
+ /// Sources plus this scene's webcam usage (), if any.
+ ///
+ public ObservableCollection Elements { get; } = new();
+
+ /// This scene's webcam usage, or null if the creator hasn't added the webcam here.
+ public WebcamSceneConfig? WebcamConfig => Elements.OfType().FirstOrDefault();
+
public bool IsChatScene { get; init; }
public event PropertyChangedEventHandler? PropertyChanged;
diff --git a/Models/SceneElement.cs b/Models/SceneElement.cs
new file mode 100644
index 0000000..7d94225
--- /dev/null
+++ b/Models/SceneElement.cs
@@ -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
+}
+
+///
+/// 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.
+///
+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(ref T field, T value, [CallerMemberName] string? name = null)
+ {
+ if (EqualityComparer.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); }
+
+ /// True only for singleton resources (webcam). Gates webcam-only UI.
+ public virtual bool IsWebcam => false;
+
+ /// True only for image sources — the raw preview renderer's discriminator.
+ 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));
+ }
+ }
+
+ /// What the preview shows: live frames (webcam) or a static image (sources).
+ 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); }
+
+ /// Round clip/border diameter = the shorter dimension (true circle).
+ 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);
+ }
+}
diff --git a/Models/Source.cs b/Models/Source.cs
index c5932ad..e6ec022 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -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
+///
+/// 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 the singleton-vs-multi-instance
+/// discriminator in ai.md.
+///
+public class Source : SceneElement
{
- public event PropertyChangedEventHandler? PropertyChanged;
-
- private void Raise([CallerMemberName] string? name = null)
- => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
-
- private bool Set(ref T field, T value, [CallerMemberName] string? name = null)
- {
- if (EqualityComparer.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;
-
- /// Mirror toggle label (mirrored → "Unmirror").
- public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
-
- /// Clip-shape toggle label (round → "Rect").
- public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
-
- /// What the preview shows: static image for image/background, live frames for webcam.
- 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;
}
diff --git a/Models/Webcam.cs b/Models/Webcam.cs
new file mode 100644
index 0000000..2ae56b7
--- /dev/null
+++ b/Models/Webcam.cs
@@ -0,0 +1,13 @@
+namespace ytLive.Models;
+
+///
+/// The app-wide webcam identity — one camera input (the limit is the hardware,
+/// not us). Scenes reference it via ; the DeviceId
+/// lives only here, so the camera is a Windows-controlled singleton.
+///
+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;
+}
diff --git a/Models/WebcamSceneConfig.cs b/Models/WebcamSceneConfig.cs
new file mode 100644
index 0000000..0a7ec7d
--- /dev/null
+++ b/Models/WebcamSceneConfig.cs
@@ -0,0 +1,16 @@
+using System.Windows.Media;
+
+namespace ytLive.Models;
+
+///
+/// A scene's usage of the app-wide webcam — conceptually `webcam.{scene}.config`.
+/// The identity lives once on the 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.
+///
+public class WebcamSceneConfig : SceneElement
+{
+ public string WebcamId { get; init; } = string.Empty;
+ public override bool IsWebcam => true;
+ public override ImageSource? DisplaySource => VideoImageSource;
+}
diff --git a/Models/index.md b/Models/index.md
index 06a38bc..cf76703 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -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) |
diff --git a/Services/CameraManager.cs b/Services/CameraManager.cs
index 4c89384..d8088ed 100644
--- a/Services/CameraManager.cs
+++ b/Services/CameraManager.cs
@@ -122,6 +122,28 @@ public sealed class CameraManager : IDisposable
toStop.PreviewBitmap = null;
}
+ ///
+ /// Releases a device unconditionally (every ref), regardless of how many scenes
+ /// hold it. Used on identity changes (webcam swap, layout reload) where the old
+ /// device's refcount isn't known after the scenes are replaced.
+ ///
+ public async Task ReleaseAllAsync(string deviceId)
+ {
+ CameraSession? toStop = null;
+ lock (_gate)
+ {
+ if (!_sessions.TryGetValue(deviceId, out var session)) return;
+ session.RefCount = 0;
+ _sessions.Remove(deviceId);
+ toStop = session;
+ }
+
+ if (toStop == null) return;
+ toStop.Source.FrameAvailable -= toStop.FrameHandler;
+ await SafeStopAsync(toStop.Source);
+ toStop.PreviewBitmap = null;
+ }
+
public VideoFrame? GetLatestFrame(string deviceId)
{
lock (_gate)
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index 08bddf1..a3b1bd8 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -12,6 +12,9 @@ public class LayoutStore : IDisposable
private readonly SqliteConnection _connection;
public string ActivePath { get; }
+ /// The app-wide webcam identity loaded with the last Load() (null = never picked).
+ public Webcam? Webcam { get; private set; }
+
public LayoutStore(string path)
{
ActivePath = path;
@@ -31,7 +34,6 @@ public class LayoutStore : IDisposable
{
string[] statements =
{
- "PRAGMA user_version = 2;",
"""
CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY,
@@ -70,6 +72,35 @@ public class LayoutStore : IDisposable
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
+ """
+ CREATE TABLE IF NOT EXISTS Webcam (
+ Id TEXT PRIMARY KEY,
+ DeviceId TEXT NOT NULL UNIQUE,
+ Name TEXT NOT NULL
+ );
+ """,
+ """
+ CREATE TABLE IF NOT EXISTS WebcamSceneConfig (
+ SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
+ WebcamId TEXT NOT NULL REFERENCES Webcam(Id) ON DELETE CASCADE,
+ IsVisible INTEGER NOT NULL DEFAULT 1,
+ X REAL NOT NULL DEFAULT 0,
+ Y REAL NOT NULL DEFAULT 0,
+ Width REAL NOT NULL DEFAULT 0,
+ Height REAL NOT NULL DEFAULT 0,
+ Opacity REAL NOT NULL DEFAULT 1,
+ ClipShape TEXT NOT NULL DEFAULT 'Traditional',
+ IsMirrored INTEGER NOT NULL DEFAULT 0,
+ RectWidth REAL,
+ RectHeight REAL,
+ BorderColor TEXT,
+ BorderOpacity REAL NOT NULL DEFAULT 1,
+ BorderWidth INTEGER NOT NULL DEFAULT 0,
+ BorderAnimation TEXT NOT NULL DEFAULT 'None',
+ SortOrder INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (SceneId, WebcamId)
+ );
+ """,
};
foreach (var sql in statements)
{
@@ -78,6 +109,21 @@ public class LayoutStore : IDisposable
cmd.ExecuteNonQuery();
}
MigrateSourceTable();
+ MigrateWebcamConfigTable();
+ if (GetUserVersion() < 3)
+ MigrateToV3();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA user_version = 4;";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ private int GetUserVersion()
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "PRAGMA user_version;";
+ return Convert.ToInt32(cmd.ExecuteScalar());
}
// v1 → v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
@@ -109,10 +155,117 @@ public class LayoutStore : IDisposable
}
}
+ // v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
+ // so a reloaded Round webcam restores its aspect on toggle-back).
+ private void MigrateWebcamConfigTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(WebcamSceneConfig);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (!columns.Contains("RectWidth"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectWidth REAL;";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("RectHeight"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE WebcamSceneConfig ADD COLUMN RectHeight REAL;";
+ cmd.ExecuteNonQuery();
+ }
+ }
+
+ // v2 → v3: the webcam leaves the Source table. Any webcam Source rows become
+ // one Webcam identity (first row — one camera app-wide) + a WebcamSceneConfig
+ // per scene that had one, then the webcam rows are deleted. No backfill:
+ // scenes without the webcam stay webcam-free.
+ private void MigrateToV3()
+ {
+ var rows = new List<(string Id, string SceneId, string Name, double X, double Y, double W, double H, double Opacity, string DeviceId, string ClipShape, bool IsMirrored)>();
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ SELECT Id, SceneId, Name, X, Y, Width, Height, Opacity, DeviceId, ClipShape, IsMirrored
+ FROM Source WHERE Type = 'Webcam' ORDER BY SortOrder;
+ """;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ rows.Add((
+ reader.GetString(0),
+ reader.GetString(1),
+ reader.GetString(2),
+ reader.GetDouble(3),
+ reader.GetDouble(4),
+ reader.GetDouble(5),
+ reader.GetDouble(6),
+ reader.GetDouble(7),
+ reader.IsDBNull(8) ? string.Empty : reader.GetString(8),
+ reader.GetString(9),
+ reader.GetInt32(10) != 0));
+ }
+ }
+ if (rows.Count == 0) return;
+
+ using var tx = _connection.BeginTransaction();
+ var webcamId = Guid.NewGuid().ToString();
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", webcamId);
+ cmd.Parameters.AddWithValue("$device", rows[0].DeviceId);
+ cmd.Parameters.AddWithValue("$name", rows[0].Name);
+ cmd.ExecuteNonQuery();
+ }
+
+ foreach (var row in rows)
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO WebcamSceneConfig
+ (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation, SortOrder)
+ VALUES ($sceneId, $webcamId, 1, $x, $y, $w, $h, $opacity,
+ $clip, $mirrored, NULL, 1, 0, 'None', 0);
+ """;
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$sceneId", row.SceneId);
+ cmd.Parameters.AddWithValue("$webcamId", webcamId);
+ cmd.Parameters.AddWithValue("$x", row.X);
+ cmd.Parameters.AddWithValue("$y", row.Y);
+ cmd.Parameters.AddWithValue("$w", row.W);
+ cmd.Parameters.AddWithValue("$h", row.H);
+ cmd.Parameters.AddWithValue("$opacity", row.Opacity);
+ cmd.Parameters.AddWithValue("$clip", row.ClipShape);
+ cmd.Parameters.AddWithValue("$mirrored", row.IsMirrored ? 1 : 0);
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Source WHERE Type = 'Webcam';";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ tx.Commit();
+ }
+
public List Load()
{
+ Webcam = null;
var scenes = new List();
var sourcesByScene = new Dictionary>();
+ var configsByScene = new Dictionary>();
using (var cmd = _connection.CreateCommand())
{
@@ -130,11 +283,26 @@ public class LayoutStore : IDisposable
}
}
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "SELECT Id, DeviceId, Name FROM Webcam;";
+ using var reader = cmd.ExecuteReader();
+ if (reader.Read())
+ {
+ Webcam = new Webcam
+ {
+ Id = reader.GetString(0),
+ DeviceId = reader.GetString(1),
+ Name = reader.GetString(2),
+ };
+ }
+ }
+
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored
+ X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -154,9 +322,8 @@ public class LayoutStore : IDisposable
Height = reader.GetDouble(9),
Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
- DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12),
- ClipShape = Enum.TryParse(reader.GetString(13), out var clip) ? clip : ClipShape.Traditional,
- IsMirrored = reader.GetInt32(14) != 0,
+ ClipShape = Enum.TryParse(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
+ IsMirrored = reader.GetInt32(13) != 0,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List();
@@ -164,26 +331,78 @@ public class LayoutStore : IDisposable
}
}
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ SELECT SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
+ RectWidth, RectHeight
+ FROM WebcamSceneConfig ORDER BY SortOrder
+ """;
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ {
+ var sceneId = reader.GetString(0);
+ var config = new WebcamSceneConfig
+ {
+ WebcamId = reader.GetString(1),
+ Name = Webcam?.Name ?? "Webcam",
+ IsVisible = reader.GetInt32(2) != 0,
+ X = reader.GetDouble(3),
+ Y = reader.GetDouble(4),
+ Width = reader.GetDouble(5),
+ Height = reader.GetDouble(6),
+ Opacity = reader.GetDouble(7),
+ ClipShape = Enum.TryParse(reader.GetString(8), out var clip) ? clip : ClipShape.Traditional,
+ IsMirrored = reader.GetInt32(9) != 0,
+ BorderColor = reader.IsDBNull(10) ? string.Empty : reader.GetString(10),
+ BorderOpacity = reader.IsDBNull(11) ? 1.0 : reader.GetDouble(11),
+ BorderWidth = reader.IsDBNull(12) ? 0 : reader.GetInt32(12),
+ BorderAnimation = Enum.TryParse(reader.GetString(13), out var anim) ? anim : BorderAnimation.None,
+ RectWidth = reader.IsDBNull(14) ? null : reader.GetDouble(14),
+ RectHeight = reader.IsDBNull(15) ? null : reader.GetDouble(15),
+ };
+ if (!configsByScene.TryGetValue(sceneId, out var list))
+ configsByScene[sceneId] = list = new List();
+ list.Add(config);
+ }
+ }
+
foreach (var scene in scenes)
{
- if (sourcesByScene.TryGetValue(scene.Id, out var list))
- foreach (var source in list)
- scene.Sources.Add(source);
+ if (sourcesByScene.TryGetValue(scene.Id, out var sources))
+ foreach (var source in sources)
+ scene.Elements.Add(source);
+ if (configsByScene.TryGetValue(scene.Id, out var configs))
+ foreach (var config in configs)
+ scene.Elements.Add(config);
}
return scenes;
}
- public void Save(IEnumerable scenes)
+ public void Save(IEnumerable scenes, Webcam? webcam)
{
using var tx = _connection.BeginTransaction();
using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM WebcamSceneConfig;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Source;";
cmd.Transaction = tx;
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "DELETE FROM Webcam;";
+ cmd.Transaction = tx;
+ cmd.ExecuteNonQuery();
+ }
+ using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Scene;";
cmd.Transaction = tx;
@@ -219,10 +438,10 @@ public class LayoutStore : IDisposable
{
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex, DeviceId,
+ X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, SortOrder)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
- $x, $y, $w, $h, $opacity, $monitor, $device,
+ $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $sort)
""";
cmd.Transaction = tx;
@@ -238,7 +457,6 @@ public class LayoutStore : IDisposable
var hP = cmd.Parameters.Add("$h", SqliteType.Real);
var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
- var deviceP = cmd.Parameters.Add("$device", SqliteType.Text);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
@@ -246,8 +464,9 @@ public class LayoutStore : IDisposable
foreach (var scene in scenes)
{
var sort = 0;
- foreach (var source in scene.Sources)
+ foreach (var element in scene.Elements)
{
+ if (element is not Source source) continue;
idP.Value = source.Id;
sceneIdP.Value = scene.Id;
assetIdP.Value = (object?)source.AssetId ?? DBNull.Value;
@@ -260,7 +479,6 @@ public class LayoutStore : IDisposable
hP.Value = source.Height;
opacityP.Value = source.Opacity;
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
- deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
sortP.Value = sort++;
@@ -269,6 +487,76 @@ public class LayoutStore : IDisposable
}
}
+ if (webcam != null)
+ {
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "INSERT INTO Webcam (Id, DeviceId, Name) VALUES ($id, $device, $name);";
+ cmd.Transaction = tx;
+ cmd.Parameters.AddWithValue("$id", webcam.Id);
+ cmd.Parameters.AddWithValue("$device", webcam.DeviceId);
+ cmd.Parameters.AddWithValue("$name", webcam.Name);
+ cmd.ExecuteNonQuery();
+ }
+
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = """
+ INSERT INTO WebcamSceneConfig
+ (SceneId, WebcamId, IsVisible, X, Y, Width, Height, Opacity,
+ ClipShape, IsMirrored, BorderColor, BorderOpacity, BorderWidth, BorderAnimation,
+ RectWidth, RectHeight, SortOrder)
+ VALUES ($sceneId, $webcamId, $isVisible, $x, $y, $w, $h, $opacity,
+ $clip, $mirrored, $borderColor, $borderOpacity, $borderWidth, $borderAnimation,
+ $rectWidth, $rectHeight, $sort)
+ """;
+ cmd.Transaction = tx;
+ var sceneIdP = cmd.Parameters.Add("$sceneId", SqliteType.Text);
+ var webcamIdP = cmd.Parameters.Add("$webcamId", SqliteType.Text);
+ var visibleP = cmd.Parameters.Add("$isVisible", SqliteType.Integer);
+ var xP = cmd.Parameters.Add("$x", SqliteType.Real);
+ var yP = cmd.Parameters.Add("$y", SqliteType.Real);
+ var wP = cmd.Parameters.Add("$w", SqliteType.Real);
+ var hP = cmd.Parameters.Add("$h", SqliteType.Real);
+ var opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
+ var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
+ var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
+ var colorP = cmd.Parameters.Add("$borderColor", SqliteType.Text);
+ var borderOpacityP = cmd.Parameters.Add("$borderOpacity", SqliteType.Real);
+ var borderWidthP = cmd.Parameters.Add("$borderWidth", SqliteType.Integer);
+ var animationP = cmd.Parameters.Add("$borderAnimation", SqliteType.Text);
+ var rectWidthP = cmd.Parameters.Add("$rectWidth", SqliteType.Real);
+ var rectHeightP = cmd.Parameters.Add("$rectHeight", SqliteType.Real);
+ var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
+
+ foreach (var scene in scenes)
+ {
+ var sort = 0;
+ foreach (var config in scene.Elements.OfType())
+ {
+ sceneIdP.Value = scene.Id;
+ webcamIdP.Value = config.WebcamId;
+ visibleP.Value = config.IsVisible ? 1 : 0;
+ xP.Value = config.X;
+ yP.Value = config.Y;
+ wP.Value = config.Width;
+ hP.Value = config.Height;
+ opacityP.Value = config.Opacity;
+ clipP.Value = config.ClipShape.ToString();
+ mirroredP.Value = config.IsMirrored ? 1 : 0;
+ colorP.Value = (object?)(string.IsNullOrEmpty(config.BorderColor) ? null : config.BorderColor) ?? DBNull.Value;
+ borderOpacityP.Value = config.BorderOpacity;
+ borderWidthP.Value = config.BorderWidth;
+ animationP.Value = config.BorderAnimation.ToString();
+ rectWidthP.Value = (object?)config.RectWidth ?? DBNull.Value;
+ rectHeightP.Value = (object?)config.RectHeight ?? DBNull.Value;
+ sortP.Value = sort++;
+ cmd.ExecuteNonQuery();
+ }
+ }
+ }
+ }
+
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "DELETE FROM Asset WHERE Id NOT IN (SELECT AssetId FROM Source WHERE AssetId IS NOT NULL);";
diff --git a/Services/index.md b/Services/index.md
index 73caafb..c4fa94d 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -8,14 +8,14 @@ External-facing logic: YouTube API, persistence. See
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
-| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 2 (`Source.ClipShape`/`IsMirrored` — added by `ALTER TABLE` for pre-v2 DBs) |
+| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 4 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
-| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events |
+| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload) |
Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).
diff --git a/TASKS.md b/TASKS.md
index 3f992e0..bf2404a 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -159,7 +159,7 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
- **Scenes list:** drag rows to reorder scenes
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented
-### Status: 🔶 In progress — milestone 1 (webcam) shipped; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, and encoding pending
+### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, encoding pending
---
@@ -240,8 +240,10 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
5. **Schema** — `Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
- `user_version` 2 (v1 → v2 = `ALTER TABLE` adds the two webcam columns). `WindowHandle` stays
- in-memory (per-session). Save = transactional rewrite; orphaned assets pruned.
+ `user_version` **4** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
+ `Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
+ round-to-rect restore). `WindowHandle` stays in-memory (per-session). Save = transactional
+ rewrite; orphaned assets pruned.
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
only when the DB is empty.
diff --git a/Themes/Controls.xaml b/Themes/Controls.xaml
index aede043..7f8e1a0 100644
--- a/Themes/Controls.xaml
+++ b/Themes/Controls.xaml
@@ -373,7 +373,9 @@
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="5" Padding="{TemplateBinding Padding}">
-
+
+
+
@@ -387,18 +389,60 @@
-
-
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 59b5ced..4bee11e 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -23,7 +23,7 @@ public class MainViewModel : ViewModelBase
private readonly DispatcherTimer _liveTimer;
private Scene? _activeScene;
- private Source? _selectedSource;
+ private SceneElement? _selectedElement;
private ImageSource? _activeBackgroundImage;
private bool _isConnected;
private StreamStatus _streamStatus = StreamStatus.Offline;
@@ -57,11 +57,12 @@ public class MainViewModel : ViewModelBase
private DispatcherTimer? _saveDebounce;
private bool _isLoading;
- // Webcam: one camera app-wide. The single Source is tracked here so the
- // Add menu can be disabled and the live preview bitmap can be forwarded.
+ // Webcam: one camera input app-wide. The identity (DeviceId) lives on the
+ // Webcam entity; each scene that shows the webcam has a WebcamSceneConfig
+ // (webcam.{scene}.config). CameraManager still owns the single session.
private readonly ICameraEnumerator _cameraEnumerator;
private readonly CameraManager _cameraManager;
- private Source? _webcamSource;
+ private Webcam? _webcam;
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
@@ -87,11 +88,13 @@ public class MainViewModel : ViewModelBase
if (value != null && value.IsHidden) return;
if (SetProperty(ref _activeScene, value))
{
- SelectedSource = null;
+ SelectedElement = null;
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
+ OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground();
}
}
@@ -103,21 +106,31 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _activeBackgroundImage, value);
}
- public Source? SelectedSource
+ public SceneElement? SelectedElement
{
- get => _selectedSource;
+ get => _selectedElement;
set
{
- if (SetProperty(ref _selectedSource, value))
+ if (SetProperty(ref _selectedElement, value))
OnPropertyChanged(nameof(IsWebcamSelected));
}
}
- /// The Add → Webcam menu item. One webcam app-wide — once one exists it's greyed out.
- public bool CanAddWebcam => _webcamSource == null;
+ /// The Add → Webcam menu item: enabled when the active scene doesn't show the webcam yet.
+ public bool CanAddWebcamToActiveScene => ActiveScene?.WebcamConfig == null;
+
+ ///
+ /// Right-click-on-preview → "Show Webcam": offered when the active scene has no
+ /// webcam config (add one) or hides it (unhide — keeps the config row).
+ ///
+ public bool CanShowWebcamInActiveScene
+ => ActiveScene is { } scene && (scene.WebcamConfig == null || !scene.WebcamConfig.IsVisible);
+
+ /// Swap-the-device item: enabled once a camera has been picked at all.
+ public bool CanChangeWebcam => _webcam != null;
/// Shows the mirror / clip-shape row in the source chip when a webcam is selected.
- public bool IsWebcamSelected => SelectedSource?.Type == SourceType.Webcam;
+ public bool IsWebcamSelected => SelectedElement is WebcamSceneConfig;
public StreamStatus StreamStatus
{
@@ -152,9 +165,9 @@ public class MainViewModel : ViewModelBase
public bool LiveIndicatorVisible => IsLive;
public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive;
- public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
+ public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
- public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Sources.Count == 0;
+ public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
@@ -185,7 +198,7 @@ public class MainViewModel : ViewModelBase
private void UpdateActiveBackground()
{
- var background = ActiveScene?.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
+ var background = ActiveScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
: null;
@@ -315,6 +328,44 @@ public class MainViewModel : ViewModelBase
private const double MasterFrameWidth = 1920;
private const double MasterFrameHeight = 1080;
+ // Webcam size safeguard: no more than half the frame in any dimension
+ // (960x540 over the 1920x1080 master), and no less than 10% of it
+ // (192x108). Enforced at resize and on layout load.
+ public const double WebcamMaxWidth = 960;
+ public const double WebcamMaxHeight = 540;
+ public const double WebcamMinWidth = MasterFrameWidth * 0.1;
+ public const double WebcamMinHeight = MasterFrameHeight * 0.1;
+
+ internal static void ClampWebcamToBounds(WebcamSceneConfig config)
+ {
+ var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height);
+ if (scale < 1)
+ {
+ config.Width = Math.Round(config.Width * scale);
+ config.Height = Math.Round(config.Height * scale);
+ return;
+ }
+
+ var minScale = Math.Max(WebcamMinWidth / config.Width, WebcamMinHeight / config.Height);
+ if (minScale > 1)
+ {
+ config.Width = Math.Round(config.Width * minScale);
+ config.Height = Math.Round(config.Height * minScale);
+ }
+ }
+
+ // One-time heal for layouts saved before the rect dims were persisted (v3→v4):
+ // a Traditional webcam that ended up square (Round resize then reload lost the
+ // pre-Round rect) gets widened to 16:9, keeping the height. Round is skipped —
+ // a square bounding box is correct there — and an explicit pre-Round rect wins.
+ internal static void HealLegacySquareRect(WebcamSceneConfig config)
+ {
+ if (config.ClipShape != ClipShape.Traditional) return;
+ if (config.RectWidth != null || config.RectHeight != null) return;
+ if (Math.Abs(config.Width - config.Height) >= 1) return;
+ config.Width = Math.Round(config.Height * 16.0 / 9.0);
+ }
+
public QualityOption[] QualityOptions { get; } =
{
new("1080p60", 60, 8.0, 1920, 1080),
@@ -419,6 +470,8 @@ public class MainViewModel : ViewModelBase
public ICommand AddSourceCommand { get; }
public ICommand AddImageCommand { get; }
public ICommand RemoveSourceCommand { get; }
+ public ICommand ChangeWebcamCommand { get; }
+ public ICommand ShowWebcamCommand { get; }
public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; }
@@ -466,7 +519,9 @@ public class MainViewModel : ViewModelBase
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
AddSourceCommand = new RelayCommand(type => AddSource(type as string));
AddImageCommand = new RelayCommand(_ => AddImage());
- RemoveSourceCommand = new RelayCommand(source => RemoveSource(source as Source));
+ RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
+ ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
+ ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -555,6 +610,12 @@ public class MainViewModel : ViewModelBase
AddScene("Chat", isChatScene: true);
AddScene("Ending");
}
+ foreach (var scene in Scenes)
+ foreach (var config in scene.Elements.OfType())
+ {
+ ClampWebcamToBounds(config);
+ HealLegacySquareRect(config);
+ }
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
}
finally
@@ -568,29 +629,35 @@ public class MainViewModel : ViewModelBase
AppLog.Write("LoadLayout end");
}
- // Finds the persisted webcam source (at most one app-wide) and re-acquires
- // its camera after a layout load / file open. Releasing the previous session
- // unconditionally keeps the refcount honest even when the device is unchanged.
+ // After a layout load / file open: re-point the webcam identity, stop the
+ // previous device if it changed, and acquire the current one once per scene
+ // that uses it (CameraManager refcounts by DeviceId — one camera session).
private void ReacquireWebcam()
{
- var webcam = Scenes.SelectMany(s => s.Sources).FirstOrDefault(s => s.Type == SourceType.Webcam);
- if (ReferenceEquals(_webcamSource, webcam)) return;
+ var previousDevice = _webcam?.DeviceId;
+ _webcam = _layoutStore.Webcam;
+ var newDevice = _webcam?.DeviceId;
- if (_webcamSource != null && !string.IsNullOrWhiteSpace(_webcamSource.DeviceId))
- _ = _cameraManager.ReleaseAsync(_webcamSource.DeviceId);
+ if (!string.IsNullOrWhiteSpace(previousDevice) && previousDevice != newDevice)
+ _ = _cameraManager.ReleaseAllAsync(previousDevice);
- _webcamSource = webcam;
- OnPropertyChanged(nameof(CanAddWebcam));
- if (webcam != null && !string.IsNullOrWhiteSpace(webcam.DeviceId))
- _ = _cameraManager.AcquireAsync(webcam.DeviceId);
+ OnPropertyChanged(nameof(CanChangeWebcam));
+ OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
+
+ if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
+ if (previousDevice == newDevice) return;
+ foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType()))
+ _ = _cameraManager.AcquireAsync(newDevice);
}
// CameraManager creates the shared WriteableBitmap on the UI thread at the
- // device's frame size; the webcam Source's preview picks it up from here.
+ // device's frame size; every scene's webcam config picks it up from here.
private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
{
- if (_webcamSource?.DeviceId == deviceId)
- _webcamSource.VideoImageSource = bitmap;
+ if (_webcam?.DeviceId != deviceId) return;
+ foreach (var scene in Scenes)
+ foreach (var config in scene.Elements.OfType())
+ config.VideoImageSource = bitmap;
}
public void Shutdown()
@@ -606,7 +673,7 @@ public class MainViewModel : ViewModelBase
_saveDebounce?.Stop();
try
{
- _layoutStore.Save(Scenes);
+ _layoutStore.Save(Scenes, _webcam);
}
catch (Exception ex)
{
@@ -661,31 +728,36 @@ public class MainViewModel : ViewModelBase
private void WireScene(Scene scene)
{
scene.PropertyChanged += OnScenePropertyChanged;
- scene.Sources.CollectionChanged += OnSourcesChanged;
+ scene.Elements.CollectionChanged += OnElementsChanged;
}
private void UnwireScene(Scene scene)
{
scene.PropertyChanged -= OnScenePropertyChanged;
- scene.Sources.CollectionChanged -= OnSourcesChanged;
+ scene.Elements.CollectionChanged -= OnElementsChanged;
}
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ScheduleSave();
- private void OnSourcesChanged(object? sender, NotifyCollectionChangedEventArgs e)
+ private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.NewItems != null)
- foreach (Source source in e.NewItems)
- source.PropertyChanged += OnSourcePropertyChanged;
+ foreach (SceneElement element in e.NewItems)
+ element.PropertyChanged += OnElementPropertyChanged;
if (e.OldItems != null)
- foreach (Source source in e.OldItems)
- source.PropertyChanged -= OnSourcePropertyChanged;
+ foreach (SceneElement element in e.OldItems)
+ element.PropertyChanged -= OnElementPropertyChanged;
+ OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
ScheduleSave();
}
- private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e)
- => ScheduleSave();
+ private void OnElementPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (sender is WebcamSceneConfig && e.PropertyName == nameof(SceneElement.IsVisible))
+ OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
+ ScheduleSave();
+ }
private void ScheduleSave()
{
@@ -728,9 +800,14 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene;
if (scene == null) return;
+ if (string.Equals(type, "webcam", StringComparison.OrdinalIgnoreCase))
+ {
+ _ = AddWebcamToActiveSceneAsync();
+ return;
+ }
+
var sourceType = type?.ToLowerInvariant() switch
{
- "webcam" => SourceType.Webcam,
"screen" => SourceType.DisplayCapture,
"window" => SourceType.WindowCapture,
"background" => SourceType.Background,
@@ -739,7 +816,6 @@ public class MainViewModel : ViewModelBase
};
var baseName = sourceType switch
{
- SourceType.Webcam => "Webcam",
SourceType.DisplayCapture => "Screen",
SourceType.WindowCapture => "Window",
SourceType.Background => "Background",
@@ -748,12 +824,6 @@ public class MainViewModel : ViewModelBase
_ => "Source",
};
- if (sourceType == SourceType.Webcam)
- {
- _ = AddWebcamSourceAsync();
- return;
- }
-
if (sourceType == SourceType.Background)
{
var bytes = PickImageBytes("Choose a backdrop image");
@@ -761,7 +831,7 @@ public class MainViewModel : ViewModelBase
var assetId = AddAsset(bytes);
if (assetId == null) return;
- var existing = scene.Sources.FirstOrDefault(s => s.Type == SourceType.Background);
+ var existing = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
if (existing != null)
{
existing.AssetId = assetId;
@@ -769,26 +839,73 @@ public class MainViewModel : ViewModelBase
return;
}
- scene.Sources.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
+ scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
return;
}
- var count = scene.Sources.Count(s => s.Type == sourceType);
+ var count = scene.Elements.OfType().Count(s => s.Type == sourceType);
var name = count == 0 ? baseName : $"{baseName} {count + 1}";
- scene.Sources.Add(new Source { Name = name, Type = sourceType });
+ scene.Elements.Add(new Source { Name = name, Type = sourceType });
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
- private async Task AddWebcamSourceAsync()
+ // Adds the webcam to the active scene. The camera is picked once app-wide
+ // (first add); afterwards "Add Webcam" just places the existing webcam here
+ // at the default spot — each scene's config is independent (webcam.{scene}.config).
+ private async Task AddWebcamToActiveSceneAsync()
{
var scene = ActiveScene;
- if (scene == null || _webcamSource != null) return;
+ if (scene == null || scene.WebcamConfig != null) return;
+
+ if (_webcam == null)
+ {
+ var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
+ {
+ Owner = Application.Current.MainWindow
+ };
+ if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
+ var device = dialog.PickedDevice;
+ _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
+ OnPropertyChanged(nameof(CanChangeWebcam));
+ }
+
+ var config = new WebcamSceneConfig
+ {
+ WebcamId = _webcam.Id,
+ Name = _webcam.Name,
+ Width = 480,
+ Height = 270,
+ X = MasterFrameWidth - 480 - 32,
+ Y = MasterFrameHeight - 270 - 32,
+ };
+
+ scene.Elements.Add(config);
+ SelectedElement = config;
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+
+ var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
+ if (!started)
+ {
+ MessageBox.Show(
+ "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
+ "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
+ }
+ }
+
+ // Swaps the device on the app-wide webcam identity. The old device is stopped
+ // unconditionally; each scene that uses the webcam re-acquires the new one so
+ // the per-config refcount stays honest.
+ private async Task ChangeWebcamAsync()
+ {
+ if (_webcam == null) return;
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{
@@ -796,35 +913,49 @@ public class MainViewModel : ViewModelBase
};
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
- var device = dialog.PickedDevice;
- var source = new Source
- {
- Name = "Webcam",
- Type = SourceType.Webcam,
- DeviceId = device.Id,
- Width = 480,
- Height = 270,
- X = 1920 - 480 - 32,
- Y = 1080 - 270 - 32,
- };
+ var oldDevice = _webcam.DeviceId;
+ var newDevice = dialog.PickedDevice.Id;
+ if (oldDevice == newDevice) return;
- _webcamSource = source;
- OnPropertyChanged(nameof(CanAddWebcam));
- scene.Sources.Add(source);
- SelectedSource = source;
- OnPropertyChanged(nameof(ShowEmptySceneHint));
- OnPropertyChanged(nameof(ShowSourcesEmptyHint));
- UpdateActiveBackground();
+ _webcam.DeviceId = newDevice;
+ _webcam.Name = dialog.PickedDevice.DisplayName;
+ ScheduleSave();
- var started = await _cameraManager.AcquireAsync(device.Id);
- if (!started)
+ if (!string.IsNullOrWhiteSpace(oldDevice))
+ await _cameraManager.ReleaseAllAsync(oldDevice);
+
+ foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType()))
{
- MessageBox.Show(
- "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
- "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
+ var started = await _cameraManager.AcquireAsync(newDevice);
+ if (!started)
+ {
+ MessageBox.Show(
+ "Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
+ "ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
+ break;
+ }
}
}
+ // "Show Webcam" from a right-click on the empty preview. Unhides this scene's
+ // existing (hidden) config — the config row survives Hide in this scene — or
+ // adds the webcam here for the first time (which opens the camera picker).
+ private void ShowWebcamInActiveScene()
+ {
+ var scene = ActiveScene;
+ if (scene == null) return;
+
+ var config = scene.WebcamConfig;
+ if (config != null)
+ {
+ config.IsVisible = true;
+ SelectedElement = config;
+ return;
+ }
+
+ _ = AddWebcamToActiveSceneAsync();
+ }
+
private void AddImage()
{
var scene = ActiveScene;
@@ -859,7 +990,7 @@ public class MainViewModel : ViewModelBase
{
var candidates = new List();
foreach (var scene in Scenes)
- foreach (var source in scene.Sources.Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
+ foreach (var source in scene.Elements.OfType().Where(s => s.Type == SourceType.Image && !string.IsNullOrWhiteSpace(s.AssetId)))
{
candidates.Add(new ReuseImageCandidate
{
@@ -919,7 +1050,7 @@ public class MainViewModel : ViewModelBase
var scene = ActiveScene;
if (scene == null || string.IsNullOrWhiteSpace(assetId)) return;
- var count = scene.Sources.Count(s => s.Type == SourceType.Image);
+ var count = scene.Elements.OfType().Count(s => s.Type == SourceType.Image);
var name = count == 0 ? "Image" : $"Image {count + 1}";
var source = new Source { Name = name, Type = SourceType.Image, AssetId = assetId };
@@ -934,25 +1065,39 @@ public class MainViewModel : ViewModelBase
source.Y = (1080 - source.Height) / 2;
}
- scene.Sources.Add(source);
- SelectedSource = source;
+ scene.Elements.Add(source);
+ SelectedElement = source;
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
}
- private void RemoveSource(Source? source)
+ // Removes an element from the active scene. For a webcam config this drops the
+ // scene's usage and releases one camera reference (CameraManager stops the
+ // session when the last using scene lets go). When the last config anywhere is
+ // removed, the webcam identity is cleared too — re-adding opens the picker
+ // again instead of silently resurrecting the old camera.
+ private void RemoveElement(SceneElement? element)
{
var scene = ActiveScene;
- if (scene == null || source == null) return;
- if (source.Type == SourceType.Webcam && ReferenceEquals(source, _webcamSource))
+ if (scene == null || element == null) return;
+ if (element is WebcamSceneConfig && _webcam != null)
{
- _webcamSource = null;
- OnPropertyChanged(nameof(CanAddWebcam));
- if (!string.IsNullOrWhiteSpace(source.DeviceId))
- _ = _cameraManager.ReleaseAsync(source.DeviceId);
+ if (SelectedElement == element)
+ SelectedElement = null;
+ _ = _cameraManager.ReleaseAsync(_webcam.DeviceId);
}
- scene.Sources.Remove(source);
+ scene.Elements.Remove(element);
+
+ if (element is WebcamSceneConfig && _webcam != null
+ && !Scenes.Any(s => s.Elements.OfType().Any()))
+ {
+ _webcam = null;
+ OnPropertyChanged(nameof(CanChangeWebcam));
+ OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
+ OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
+ }
+
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground();
diff --git a/ViewModels/index.md b/ViewModels/index.md
index 6bf82b1..d39ed5c 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose |
|------|---------|
-| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamSourceAsync` (picker → default 480×270 bottom-right placement → acquire), one-camera app-wide (`CanAddWebcam` greys the menu), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into `Source.VideoImageSource` |
+| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds` (50% cap seam) |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
| `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
diff --git a/ai.md b/ai.md
index f9afcde..76c8d33 100644
--- a/ai.md
+++ b/ai.md
@@ -28,11 +28,18 @@ default response style above stays in effect unless invoked.
## Run
```bash
-dotnet build # Windows only — WPF requires Windows target
-dotnet run
+# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
+"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
+"/mnt/c/Program Files/dotnet/dotnet.exe" run
```
-Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
+`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
+`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
+(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
+project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
+packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
+Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
+produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
## Tests
@@ -46,7 +53,8 @@ dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLi
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
-interaction test, LayoutStore delete roundtrip — 13 passing.
+interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip —
+25 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -61,8 +69,8 @@ test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
-save/load round trips are exact: a source removed in the UI (`RemoveSource` →
-`scene.Sources.Remove` → `OnSourcesChanged` → debounced `ScheduleSave`, plus `Shutdown` on
+save/load round trips are exact: an element removed in the UI (`RemoveElement` →
+`scene.Elements.Remove` → `OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Architecture
@@ -92,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
-- Scene/source/asset layout persists (SQLite, schema v2); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
+- Scene/source/asset layout persists (SQLite, schema v4); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next**
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
@@ -118,26 +126,62 @@ C# / WPF (.NET 8) following MVVM:
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
-- **One camera app-wide:** `CameraManager` refcounts sessions by `DeviceId` (a session is created with
- `RefCount = 1`; repeat acquire bumps it; the last release stops + disposes). The Add menu greys Webcam
- out once a webcam source exists anywhere (`CanAddWebcam`); the "OBS time" story is a one-camera limit.
+- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
+ (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
+ border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
+ (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
+ sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
+ release stops + disposes). The Add Webcam menu greys out when the **active** scene already has a
+ config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the existing config
+ (`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam"). **Removing the last webcam
+ config anywhere clears the identity** (`_webcam = null`), so re-adding opens the picker again
+ instead of resurrecting the old camera.
+- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
+ snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
+ restores them when switching back — otherwise the Round resize lock (square) would leave a square
+ behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
+ reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
+ `HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
+ (keeps height; Round and explicit rect dims are untouched).
+- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
+ release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
+ stops the source regardless of how many scenes held it (the per-config count isn't known once the
+ scenes are replaced) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
- at the device's frame size (first frame), forwarded to the single webcam `Source.VideoImageSource` via
+ at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread.
-- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
- (`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
- `Ellipse` for Round); toggled from the source chip; persisted in the layout DB.
+- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
+ (`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
+ 0–1, `BorderWidth` 0–20, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
+ Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
+ context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
+ rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
+ this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
+ preview canvas has its own Show Webcam entry.
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders
- as a true circle (diameter = the shorter source dimension) instead of an oval stretched to the
- source rect — and the traditional `Image` keeps `UniformToFill` over the full rect.
+ as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the
+ element rect — and the traditional `Image` keeps `UniformToFill` over the full rect. The Round
+ border is a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
+ - **Webcam size clamp:** `ClampWebcamToBounds` (internal — test seam) enforces 50% of the 1920×1080
+ master per dimension (960×540 max) and no less than 10% (192×108) at resize + load;
+ `RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
- the source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"`
- (whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is
- `IsHitTestVisible="False"` so it never intercepts the click.
+ the element — making the corner handle ungrabbable. The element Grid carries
+ `Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
+ right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
+ `IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
+ (1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
+ isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
+ IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
+ template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
+ sliders live in Items, so they render in a hover flyout), a `✓` checkmark column, and a `›` arrow
+ driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
+ opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
+ Click handlers were fine.
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task.
diff --git a/run.bat b/run.bat
new file mode 100644
index 0000000..6e89e0f
--- /dev/null
+++ b/run.bat
@@ -0,0 +1,3 @@
+@echo off
+cd /d "%~dp0"
+dotnet run --project ytLive.csproj
diff --git a/run.sh b/run.sh
new file mode 100644
index 0000000..fd592bc
--- /dev/null
+++ b/run.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+cd "$(dirname "$0")"
+WINPATH=$(wslpath -w "$PWD")
+"/mnt/c/Program Files/dotnet/dotnet.exe" run --project "$WINPATH\\ytLive.csproj"
diff --git a/ytLive.Tests/LayoutStorePersistenceTests.cs b/ytLive.Tests/LayoutStorePersistenceTests.cs
index 36db8eb..69c3454 100644
--- a/ytLive.Tests/LayoutStorePersistenceTests.cs
+++ b/ytLive.Tests/LayoutStorePersistenceTests.cs
@@ -9,37 +9,79 @@ namespace ytLive.Tests;
///
/// The layout DB is a full rewrite on every save (DELETE all scenes/sources,
-/// re-insert from memory). This guards the round trip: sources the user deletes
-/// in the UI must not come back after a save + reload.
+/// re-insert from memory). This guards the round trip: webcam configs the user
+/// removes in the UI must not come back after a save + reload.
///
public class LayoutStorePersistenceTests
{
[Fact]
- public void Deleted_Webcam_Source_Does_Not_Return_After_Save_And_Reload()
+ public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
+ var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
var scene = new Scene { Name = "Starting" };
- scene.Sources.Add(new Source
+ scene.Elements.Add(new WebcamSceneConfig
{
- Name = "Webcam",
- Type = SourceType.Webcam,
- DeviceId = "real-device",
+ WebcamId = webcam.Id,
+ Name = webcam.Name,
Width = 480,
Height = 270,
});
- store.Save(new[] { scene });
+ store.Save(new[] { scene }, webcam);
var reloaded = store.Load();
- Assert.Single(reloaded[0].Sources);
+ var config = Assert.Single(reloaded[0].Elements);
+ Assert.IsType(config);
- reloaded[0].Sources.RemoveAt(0);
- store.Save(reloaded);
+ reloaded[0].Elements.RemoveAt(0);
+ store.Save(reloaded, store.Webcam);
var afterDelete = store.Load();
- Assert.Empty(afterDelete[0].Sources);
+ Assert.Empty(afterDelete[0].Elements);
+ }
+ finally
+ {
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(path); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ // Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
+ // square saves its pre-Round rect dims, and a reloaded config restores them on
+ // toggle-back instead of staying square.
+ [Fact]
+ public void Pre_Round_Rect_Dims_Survive_Save_And_Reload()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
+ try
+ {
+ using var store = new LayoutStore(path);
+ var webcam = new Webcam { DeviceId = "real-device", Name = "Logitech" };
+ var scene = new Scene { Name = "Starting" };
+ scene.Elements.Add(new WebcamSceneConfig
+ {
+ WebcamId = webcam.Id,
+ Name = webcam.Name,
+ Width = 400,
+ Height = 400,
+ ClipShape = ClipShape.Round,
+ RectWidth = 480,
+ RectHeight = 270,
+ });
+ store.Save(new[] { scene }, webcam);
+
+ var reloaded = store.Load();
+ var config = Assert.IsType(Assert.Single(reloaded[0].Elements));
+
+ config.ToggleClipShape();
+ Assert.Equal(ClipShape.Traditional, config.ClipShape);
+ Assert.Equal(480, config.Width);
+ Assert.Equal(270, config.Height);
+ Assert.Null(config.RectWidth);
+ Assert.Null(config.RectHeight);
}
finally
{
diff --git a/ytLive.Tests/RoundClipInteractionTests.cs b/ytLive.Tests/RoundClipInteractionTests.cs
index 24abbba..4dd0be8 100644
--- a/ytLive.Tests/RoundClipInteractionTests.cs
+++ b/ytLive.Tests/RoundClipInteractionTests.cs
@@ -61,29 +61,28 @@ public sealed class RoundClipInteractionTests
window.UpdateLayout();
var scene = vm.ActiveScene!;
- var source = new Source
+ var webcam = new WebcamSceneConfig
{
+ WebcamId = "test-camera",
Name = "Webcam",
- Type = SourceType.Webcam,
- DeviceId = "test-camera",
X = 1408,
Y = 778,
Width = 480,
Height = 270,
};
- scene.Sources.Add(source);
- vm.SelectedSource = source;
+ scene.Elements.Add(webcam);
+ vm.SelectedElement = webcam;
window.UpdateLayout();
var previewGrid = (Grid)window.FindName("PreviewGrid")!;
var canvasGrid = (Grid)window.FindName("CanvasGrid")!;
var toWindow = canvasGrid.TransformToVisual(window);
- var corner = new Point(source.X + source.Width, source.Y + source.Height);
+ var corner = new Point(webcam.X + webcam.Width, webcam.Y + webcam.Height);
foreach (var shape in new[] { ClipShape.Traditional, ClipShape.Round })
{
- source.ClipShape = shape;
+ webcam.ClipShape = shape;
window.UpdateLayout();
var cornerInWindow = toWindow.Transform(corner);
@@ -100,7 +99,7 @@ public sealed class RoundClipInteractionTests
}
// The round clip must render as a circle (square bounding box), not an oval.
- source.ClipShape = ClipShape.Round;
+ webcam.ClipShape = ClipShape.Round;
window.UpdateLayout();
var ellipse = FindRoundEllipse(window);
Assert.NotNull(ellipse);
diff --git a/ytLive.Tests/WebcamSafeguardTests.cs b/ytLive.Tests/WebcamSafeguardTests.cs
new file mode 100644
index 0000000..8430fe1
--- /dev/null
+++ b/ytLive.Tests/WebcamSafeguardTests.cs
@@ -0,0 +1,137 @@
+using Xunit;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// The webcam size safeguard: no scene placement may exceed half the 1920×1080
+/// master frame (960×540), nor drop below 10% of it (192×108). Enforced at
+/// resize (MainWindow) and defensively again on every layout load.
+///
+public class WebcamSafeguardTests
+{
+ [Fact]
+ public void Oversize_Webcam_Is_Capped_To_Half_The_Frame()
+ {
+ var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
+ MainViewModel.ClampWebcamToBounds(webcam);
+ Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
+ Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
+ }
+
+ [Fact]
+ public void Wide_Webcam_Scales_To_Fit_Both_Dimensions()
+ {
+ var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 };
+ MainViewModel.ClampWebcamToBounds(webcam);
+ Assert.Equal(960, webcam.Width);
+ Assert.Equal(480, webcam.Height);
+ }
+
+ [Fact]
+ public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum()
+ {
+ var webcam = new WebcamSceneConfig { Width = 10, Height = 10 };
+ MainViewModel.ClampWebcamToBounds(webcam);
+ Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width);
+ Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height);
+ }
+
+ [Fact]
+ public void Short_Wide_Webcam_Meets_Both_Minimums()
+ {
+ var webcam = new WebcamSceneConfig { Width = 100, Height = 20 };
+ MainViewModel.ClampWebcamToBounds(webcam);
+ Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth);
+ Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height);
+ }
+
+ [Fact]
+ public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension()
+ {
+ var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 };
+ MainViewModel.ClampWebcamToBounds(webcam);
+ Assert.Equal(960, webcam.Width);
+ Assert.Equal(250, webcam.Height);
+ Assert.Equal(250, webcam.RoundBorderSize);
+ }
+
+ [Fact]
+ public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect()
+ {
+ var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
+ webcam.ToggleClipShape();
+ Assert.Equal(ClipShape.Round, webcam.ClipShape);
+ Assert.Equal(480, webcam.Width);
+ Assert.Equal(270, webcam.Height);
+ Assert.Equal(480, webcam.RectWidth);
+ Assert.Equal(270, webcam.RectHeight);
+
+ webcam.ToggleClipShape();
+ Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
+ Assert.Equal(480, webcam.Width);
+ Assert.Equal(270, webcam.Height);
+ Assert.Null(webcam.RectWidth);
+ Assert.Null(webcam.RectHeight);
+ }
+
+ // The persisted-reload repro: Round + square dims (from a resize while Round),
+ // no in-memory snapshot — exactly what a relaunch produces. The persisted rect
+ // dims must restore the 16:9 on toggle-back.
+ [Fact]
+ public void Reloaded_Round_Config_Restores_Persisted_Rect_Dims()
+ {
+ var webcam = new WebcamSceneConfig
+ {
+ Width = 400,
+ Height = 400,
+ ClipShape = ClipShape.Round,
+ RectWidth = 480,
+ RectHeight = 270,
+ };
+
+ webcam.ToggleClipShape();
+ Assert.Equal(ClipShape.Traditional, webcam.ClipShape);
+ Assert.Equal(480, webcam.Width);
+ Assert.Equal(270, webcam.Height);
+ Assert.Null(webcam.RectWidth);
+ Assert.Null(webcam.RectHeight);
+ }
+
+ [Fact]
+ public void Legacy_Square_Rect_Is_Widened_To_16x9()
+ {
+ var webcam = new WebcamSceneConfig { Width = 414.92, Height = 414.92 };
+ MainViewModel.HealLegacySquareRect(webcam);
+ Assert.Equal(738, webcam.Width);
+ Assert.Equal(414.92, webcam.Height);
+ }
+
+ [Fact]
+ public void Legacy_Square_Heal_Leaves_Non_Square_Untouched()
+ {
+ var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
+ MainViewModel.HealLegacySquareRect(webcam);
+ Assert.Equal(480, webcam.Width);
+ Assert.Equal(270, webcam.Height);
+ }
+
+ [Fact]
+ public void Legacy_Square_Heal_Leaves_Round_Untouched()
+ {
+ var webcam = new WebcamSceneConfig { Width = 400, Height = 400, ClipShape = ClipShape.Round };
+ MainViewModel.HealLegacySquareRect(webcam);
+ Assert.Equal(400, webcam.Width);
+ Assert.Equal(400, webcam.Height);
+ }
+
+ [Fact]
+ public void Legacy_Square_Heal_Respects_Explicit_Rect_Dims()
+ {
+ var webcam = new WebcamSceneConfig { Width = 400, Height = 400, RectWidth = 480, RectHeight = 270 };
+ MainViewModel.HealLegacySquareRect(webcam);
+ Assert.Equal(400, webcam.Width);
+ Assert.Equal(400, webcam.Height);
+ }
+}