diff --git a/CameraPickerDialog.xaml b/CameraPickerDialog.xaml
new file mode 100644
index 0000000..2351a85
--- /dev/null
+++ b/CameraPickerDialog.xaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/CameraPickerDialog.xaml.cs b/CameraPickerDialog.xaml.cs
new file mode 100644
index 0000000..d26d40a
--- /dev/null
+++ b/CameraPickerDialog.xaml.cs
@@ -0,0 +1,25 @@
+using System.Windows;
+using ytLive.Helpers;
+using ytLive.Services;
+using ytLive.ViewModels;
+
+namespace ytLive;
+
+public partial class CameraPickerDialog : Window
+{
+ public CameraDeviceInfo? PickedDevice { get; private set; }
+
+ public CameraPickerDialog(CameraPickerViewModel viewModel)
+ {
+ AppLog.Write("CameraPickerDialog ctor: before InitializeComponent");
+ InitializeComponent();
+ AppLog.Write("CameraPickerDialog ctor: after InitializeComponent");
+ DataContext = viewModel;
+ viewModel.UseRequested += device =>
+ {
+ PickedDevice = device;
+ DialogResult = true;
+ };
+ viewModel.CancelRequested += () => DialogResult = false;
+ }
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 52ffcfa..6ee6c7e 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -229,7 +229,9 @@
Click="AddSourceButton_Click">
-
+
@@ -319,21 +321,53 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -441,6 +475,15 @@
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index e204418..5d09f14 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -115,13 +115,29 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay()
{
- var selected = _viewModel.SelectedSource is { Type: SourceType.Image };
+ var selected = _viewModel.SelectedSource is { } s && IsDraggableSource(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)}%";
}
+ // 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 void MirrorButton_Click(object sender, RoutedEventArgs e)
+ {
+ if (_viewModel.SelectedSource is { Type: SourceType.Webcam } source)
+ source.IsMirrored = !source.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;
+ }
+
private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
@@ -134,10 +150,10 @@ public partial class MainWindow : Window
var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource;
- if (selected is { Type: SourceType.Image } && HitHandle(e.GetPosition(grid), selected))
+ if (selected is { } sel && IsDraggableSource(sel) && HitHandle(e.GetPosition(grid), sel))
{
_isResizing = true;
- _resizeAspect = selected.Width / Math.Max(1, selected.Height);
+ _resizeAspect = sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse();
e.Handled = true;
return;
@@ -218,7 +234,7 @@ public partial class MainWindow : Window
for (var i = scene.Sources.Count - 1; i >= 0; i--)
{
var source = scene.Sources[i];
- if (source.Type != SourceType.Image || !source.IsEnabled) continue;
+ 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;
diff --git a/Models/Source.cs b/Models/Source.cs
index ca29454..c5932ad 100644
--- a/Models/Source.cs
+++ b/Models/Source.cs
@@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
+using System.Windows.Media.Imaging;
using ytLive.Helpers;
namespace ytLive.Models;
@@ -15,6 +16,12 @@ public enum SourceType
TextOverlay
}
+public enum ClipShape
+{
+ Traditional,
+ Round
+}
+
public class Source : INotifyPropertyChanged
{
public event PropertyChangedEventHandler? PropertyChanged;
@@ -52,6 +59,54 @@ public class Source : INotifyPropertyChanged
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;
@@ -65,6 +120,7 @@ public class Source : INotifyPropertyChanged
{
_imageSource = ImageCache.Get(value ?? string.Empty);
Raise(nameof(ImageSource));
+ Raise(nameof(DisplaySource));
}
}
}
diff --git a/Models/index.md b/Models/index.md
index 8b67af9..06a38bc 100644
--- a/Models/index.md
+++ b/Models/index.md
@@ -6,7 +6,7 @@ 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`, asset identity |
+| `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` |
| `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/CameraDeviceInfo.cs b/Services/CameraDeviceInfo.cs
new file mode 100644
index 0000000..45c6255
--- /dev/null
+++ b/Services/CameraDeviceInfo.cs
@@ -0,0 +1,13 @@
+namespace ytLive.Services;
+
+public sealed class CameraDeviceInfo
+{
+ public string Id { get; }
+ public string DisplayName { get; }
+
+ public CameraDeviceInfo(string id, string displayName)
+ {
+ Id = id;
+ DisplayName = displayName;
+ }
+}
diff --git a/Services/CameraManager.cs b/Services/CameraManager.cs
new file mode 100644
index 0000000..4c89384
--- /dev/null
+++ b/Services/CameraManager.cs
@@ -0,0 +1,218 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Media;
+using System.Windows.Media.Imaging;
+using System.Windows.Threading;
+using ytLive.Helpers;
+
+namespace ytLive.Services;
+
+///
+/// Owns webcam capture sessions app-wide, refcounted by DeviceId. One device =
+/// at most one capture, one shared WriteableBitmap; the last release stops and
+/// disposes the source. Frames arrive on a worker thread and are coalesced onto
+/// the UI dispatcher (at most one pending copy per session, using the latest
+/// frame) so a 60fps device doesn't drown the render thread.
+///
+public sealed class CameraManager : IDisposable
+{
+ private sealed class CameraSession
+ {
+ public string DeviceId { get; }
+ public ICameraFrameSource Source { get; }
+ public Action? FrameHandler;
+ public int RefCount;
+ public bool Started;
+ public WriteableBitmap? PreviewBitmap;
+ public VideoFrame? LatestFrame;
+ public bool FramePending;
+
+ public CameraSession(string deviceId, ICameraFrameSource source)
+ {
+ DeviceId = deviceId;
+ Source = source;
+ RefCount = 1;
+ }
+ }
+
+ private readonly ICameraEnumerator _enumerator;
+ private readonly Func _frameSourceFactory;
+ private readonly Dispatcher? _uiDispatcher;
+ private readonly Dictionary _sessions = new();
+ private readonly object _gate = new();
+
+ /// Raised on the UI thread when a camera's shared preview bitmap is first created.
+ public event Action? PreviewBitmapChanged;
+
+ /// Raised when a capture fails to start (device in use, access denied, no preview source).
+ public event Action? CameraFailed;
+
+ public CameraManager(ICameraEnumerator enumerator, Func frameSourceFactory,
+ Dispatcher? uiDispatcher = null)
+ {
+ _enumerator = enumerator;
+ _frameSourceFactory = frameSourceFactory;
+ _uiDispatcher = uiDispatcher;
+ }
+
+ public ICameraEnumerator Enumerator => _enumerator;
+
+ /// Increments the refcount for a device, starting capture the first time.
+ public async Task AcquireAsync(string deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId)) return false;
+
+ CameraSession session;
+ bool shouldStart;
+ lock (_gate)
+ {
+ if (_sessions.TryGetValue(deviceId, out var existing))
+ {
+ existing.RefCount++;
+ shouldStart = false;
+ session = existing;
+ }
+ else
+ {
+ session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
+ session.FrameHandler = frame => OnFrameAvailable(session, frame);
+ session.Source.FrameAvailable += session.FrameHandler;
+ _sessions[deviceId] = session;
+ shouldStart = true;
+ }
+ }
+
+ if (!shouldStart) return session.Started;
+
+ try
+ {
+ await session.Source.StartAsync();
+ session.Started = true;
+ return true;
+ }
+ catch (Exception ex)
+ {
+ lock (_gate)
+ _sessions.Remove(deviceId);
+ session.Source.FrameAvailable -= session.FrameHandler;
+ AppLog.Write($"CameraManager: failed to start camera '{deviceId}': {ex.Message}");
+ CameraFailed?.Invoke(deviceId, ex.Message);
+ await SafeStopAsync(session.Source);
+ return false;
+ }
+ }
+
+ /// Decrements the refcount; stops and disposes the source at zero.
+ public async Task ReleaseAsync(string deviceId)
+ {
+ CameraSession? toStop = null;
+ lock (_gate)
+ {
+ if (!_sessions.TryGetValue(deviceId, out var session)) return;
+ if (--session.RefCount > 0) return;
+ _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)
+ return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
+ }
+
+ public void Dispose()
+ {
+ List sessions;
+ lock (_gate)
+ {
+ sessions = new List(_sessions.Values);
+ _sessions.Clear();
+ }
+
+ foreach (var session in sessions)
+ {
+ session.Source.FrameAvailable -= session.FrameHandler;
+ _ = SafeStopAsync(session.Source);
+ }
+ }
+
+ private static async Task SafeStopAsync(ICameraFrameSource source)
+ {
+ try
+ {
+ await source.StopAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
+ }
+ }
+
+ private bool TryGetActiveSession(CameraSession session)
+ {
+ lock (_gate)
+ return _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
+ }
+
+ private void OnFrameAvailable(CameraSession session, VideoFrame frame)
+ {
+ if (!TryGetActiveSession(session)) return;
+ session.LatestFrame = frame;
+
+ if (session.PreviewBitmap == null)
+ {
+ if (_uiDispatcher == null) return;
+ if (_uiDispatcher.CheckAccess())
+ EnsurePreviewBitmap(session);
+ else
+ _uiDispatcher.BeginInvoke(() =>
+ {
+ if (TryGetActiveSession(session))
+ EnsurePreviewBitmap(session);
+ }, DispatcherPriority.Render);
+ return;
+ }
+
+ if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
+ {
+ CopyFrame(session);
+ return;
+ }
+
+ if (session.FramePending) return;
+ session.FramePending = true;
+ _uiDispatcher.BeginInvoke(() =>
+ {
+ session.FramePending = false;
+ if (TryGetActiveSession(session) && session.PreviewBitmap != null)
+ CopyFrame(session);
+ }, DispatcherPriority.Render);
+ }
+
+ private void EnsurePreviewBitmap(CameraSession session)
+ {
+ if (session.PreviewBitmap != null || session.LatestFrame == null) return;
+ var frame = session.LatestFrame;
+ var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
+ bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
+ session.PreviewBitmap = bitmap;
+ PreviewBitmapChanged?.Invoke(session.DeviceId, bitmap);
+ }
+
+ private void CopyFrame(CameraSession session)
+ {
+ var bitmap = session.PreviewBitmap;
+ var frame = session.LatestFrame;
+ if (bitmap == null || frame == null) return;
+ if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
+ bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
+ }
+}
diff --git a/Services/ICameraEnumerator.cs b/Services/ICameraEnumerator.cs
new file mode 100644
index 0000000..7f39ba2
--- /dev/null
+++ b/Services/ICameraEnumerator.cs
@@ -0,0 +1,10 @@
+namespace ytLive.Services;
+
+///
+/// Enumerates physical capture devices. Seam so the picker and CameraManager
+/// never touch WinRT directly (tests inject fakes).
+///
+public interface ICameraEnumerator
+{
+ Task> GetCamerasAsync();
+}
diff --git a/Services/ICameraFrameSource.cs b/Services/ICameraFrameSource.cs
new file mode 100644
index 0000000..3960d66
--- /dev/null
+++ b/Services/ICameraFrameSource.cs
@@ -0,0 +1,14 @@
+namespace ytLive.Services;
+
+///
+/// A running capture source for one device. Raises normalized BGRA frames from
+/// a worker thread; callers must marshal to the UI thread. Seam so CameraManager
+/// is testable without WinRT.
+///
+public interface ICameraFrameSource
+{
+ string DeviceId { get; }
+ event Action? FrameAvailable;
+ Task StartAsync();
+ Task StopAsync();
+}
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index cc07d75..08bddf1 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -31,7 +31,7 @@ public class LayoutStore : IDisposable
{
string[] statements =
{
- "PRAGMA user_version = 1;",
+ "PRAGMA user_version = 2;",
"""
CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY,
@@ -65,6 +65,8 @@ public class LayoutStore : IDisposable
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
+ ClipShape TEXT NOT NULL DEFAULT 'Traditional',
+ IsMirrored INTEGER NOT NULL DEFAULT 0,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
@@ -75,6 +77,36 @@ public class LayoutStore : IDisposable
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
+ MigrateSourceTable();
+ }
+
+ // v1 β v2: Source gains ClipShape + IsMirrored (webcam clip/mirror prefs).
+ // CREATE TABLE IF NOT EXISTS doesn't touch existing tables, so pre-v2 DBs
+ // get the columns here instead.
+ private void MigrateSourceTable()
+ {
+ var columns = new HashSet(StringComparer.OrdinalIgnoreCase);
+ using (var cmd = _connection.CreateCommand())
+ {
+ cmd.CommandText = "PRAGMA table_info(Source);";
+ using var reader = cmd.ExecuteReader();
+ while (reader.Read())
+ columns.Add(reader.GetString(1));
+ }
+
+ if (!columns.Contains("ClipShape"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN ClipShape TEXT NOT NULL DEFAULT 'Traditional';";
+ cmd.ExecuteNonQuery();
+ }
+
+ if (!columns.Contains("IsMirrored"))
+ {
+ using var cmd = _connection.CreateCommand();
+ cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
+ cmd.ExecuteNonQuery();
+ }
}
public List Load()
@@ -102,7 +134,7 @@ public class LayoutStore : IDisposable
{
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex, DeviceId
+ X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -123,6 +155,8 @@ public class LayoutStore : IDisposable
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,
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List();
@@ -185,9 +219,11 @@ public class LayoutStore : IDisposable
{
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
- X, Y, Width, Height, Opacity, MonitorIndex, DeviceId, SortOrder)
+ X, Y, Width, Height, Opacity, MonitorIndex, DeviceId,
+ ClipShape, IsMirrored, SortOrder)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
- $x, $y, $w, $h, $opacity, $monitor, $device, $sort)
+ $x, $y, $w, $h, $opacity, $monitor, $device,
+ $clip, $mirrored, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -203,6 +239,8 @@ public class LayoutStore : IDisposable
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);
foreach (var scene in scenes)
@@ -223,6 +261,8 @@ public class LayoutStore : IDisposable
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++;
cmd.ExecuteNonQuery();
}
diff --git a/Services/MediaCaptureCameraEnumerator.cs b/Services/MediaCaptureCameraEnumerator.cs
new file mode 100644
index 0000000..16f4fce
--- /dev/null
+++ b/Services/MediaCaptureCameraEnumerator.cs
@@ -0,0 +1,15 @@
+using Windows.Devices.Enumeration;
+
+namespace ytLive.Services;
+
+public sealed class MediaCaptureCameraEnumerator : ICameraEnumerator
+{
+ public async Task> GetCamerasAsync()
+ {
+ var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
+ var result = new List(devices.Count);
+ foreach (var device in devices)
+ result.Add(new CameraDeviceInfo(device.Id, device.Name));
+ return result;
+ }
+}
diff --git a/Services/MediaCaptureFrameSource.cs b/Services/MediaCaptureFrameSource.cs
new file mode 100644
index 0000000..7c3c479
--- /dev/null
+++ b/Services/MediaCaptureFrameSource.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Runtime.InteropServices.WindowsRuntime;
+using System.Threading.Tasks;
+using Windows.Graphics.Imaging;
+using Windows.Media.Capture;
+using Windows.Media.Capture.Frames;
+using Windows.Media.MediaProperties;
+using ytLive.Helpers;
+
+namespace ytLive.Services;
+
+///
+/// CPU-first MediaCapture source. Requests BGRA8 frames from the camera's video
+/// preview source; the capture pipeline does any format conversion, so every
+/// frame arrives as a normalized . Frames arrive on a
+/// worker thread β marshal before touching WPF.
+///
+public sealed class MediaCaptureFrameSource : ICameraFrameSource
+{
+ private readonly string _deviceId;
+ private MediaCapture? _capture;
+ private MediaFrameReader? _frameReader;
+
+ public string DeviceId => _deviceId;
+
+ public event Action? FrameAvailable;
+
+ public MediaCaptureFrameSource(string deviceId)
+ {
+ _deviceId = deviceId;
+ }
+
+ public async Task StartAsync()
+ {
+ var capture = new MediaCapture();
+ MediaFrameReader? reader = null;
+ try
+ {
+ var settings = new MediaCaptureInitializationSettings
+ {
+ VideoDeviceId = _deviceId,
+ StreamingCaptureMode = StreamingCaptureMode.Video,
+ MemoryPreference = MediaCaptureMemoryPreference.Cpu,
+ };
+ await capture.InitializeAsync(settings);
+
+ var colorSource = capture.FrameSources
+ .FirstOrDefault(pair => pair.Value.Info.MediaStreamType == MediaStreamType.VideoPreview)
+ .Value;
+ if (colorSource == null)
+ throw new InvalidOperationException($"No video preview source on camera '{_deviceId}'.");
+
+ reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8);
+ reader.FrameArrived += OnFrameArrived;
+ await reader.StartAsync();
+
+ _capture = capture;
+ _frameReader = reader;
+ }
+ catch
+ {
+ if (reader != null)
+ {
+ reader.FrameArrived -= OnFrameArrived;
+ reader.Dispose();
+ }
+ capture.Dispose();
+ throw;
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ var reader = _frameReader;
+ _frameReader = null;
+ if (reader != null)
+ {
+ reader.FrameArrived -= OnFrameArrived;
+ try
+ {
+ await reader.StopAsync();
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"MediaCaptureFrameSource: stop frame reader failed: {ex.Message}");
+ }
+ reader.Dispose();
+ }
+
+ var capture = _capture;
+ _capture = null;
+ capture?.Dispose();
+ }
+
+ private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
+ {
+ using var frame = sender.TryAcquireLatestFrame();
+ var videoFrame = frame?.VideoMediaFrame?.SoftwareBitmap;
+ if (videoFrame == null) return;
+
+ var bitmap = videoFrame.BitmapPixelFormat == BitmapPixelFormat.Bgra8
+ ? videoFrame
+ : SoftwareBitmap.Convert(videoFrame, BitmapPixelFormat.Bgra8);
+
+ try
+ {
+ using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
+ using var reference = buffer.CreateReference();
+ if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity))
+ {
+ var pixels = new byte[capacity];
+ Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
+ FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels));
+ }
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"MediaCaptureFrameSource: frame copy failed: {ex.Message}");
+ }
+ finally
+ {
+ if (!ReferenceEquals(bitmap, videoFrame))
+ bitmap.Dispose();
+ }
+ }
+}
diff --git a/Services/VideoFrame.cs b/Services/VideoFrame.cs
new file mode 100644
index 0000000..4d21546
--- /dev/null
+++ b/Services/VideoFrame.cs
@@ -0,0 +1,22 @@
+namespace ytLive.Services;
+
+///
+/// A normalized CPU frame (32bpp BGRA, tightly packed). The capture path hands
+/// these to the UI thread, which copies them into the shared WriteableBitmap.
+/// Deliberately the only pixel type the rest of the app knows about β every
+/// future capture source (screen, background-removed webcam) feeds the same seam.
+///
+public sealed class VideoFrame
+{
+ public int Width { get; }
+ public int Height { get; }
+ public byte[] BgraPixels { get; }
+ public int Stride => Width * 4;
+
+ public VideoFrame(int width, int height, byte[] bgraPixels)
+ {
+ Width = width;
+ Height = height;
+ BgraPixels = bgraPixels;
+ }
+}
diff --git a/Services/index.md b/Services/index.md
index bbce188..73caafb 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -8,7 +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 |
+| `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) |
+| `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 |
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 72bfaf9..3f992e0 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -136,7 +136,15 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
### Requirements:
1. **Screen** β Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker
-2. **Webcam** β MediaCapture (WinRT) with device enumeration
+2. **Webcam** β MediaCapture (WinRT SDK projection) with device enumeration β β
**milestone 1 done**:
+ - TFM bumped to `net8.0-windows10.0.19041.0` (app **and** tests) so the WinRT projection resolves from the SDK reference packs β no NuGet package, no capability manifest (unpackaged desktop app)
+ - `MediaCaptureFrameSource` (CPU-first: `MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`), `MediaCaptureCameraEnumerator` (`DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)`)
+ - `CameraManager`: refcounted by `DeviceId`, one shared `WriteableBitmap` app-wide, dispatcher-coalesced UI updates (~render rate, latest-frame drop), placeholder/`AppLog` + warning on failure
+ - `CameraPickerDialog` (mirror of `ReuseImageDialog`) β "Searching for camerasβ¦" / list / "No cameras found" states
+ - One webcam app-wide: Add β Webcam greyed out once one exists ("it's already in your stream" tooltip); persisted `DeviceId` re-acquires after layout load
+ - Default placement 16:9 **480Γ270**, bottom-right, 32px margin; drag/resize/selection shared with Image sources
+ - **Clip shapes: Traditional + Round** (phone view dropped β the 9:16 phone output is the vertical output-crop tier); **mirror**; both persisted in the layout DB (schema v2) and toggled from the source chip
+ - **Background removal = milestone 2** (ONNX Runtime + DirectML, MediaPipe Selfie Segmentation) β not in this build
3. **Background / Image / Text** β static sources positioned/scaled/opacity
4. **Chat box** β rendered from the live chat poll (right panel is the same feed, raw)
5. **Scene compositing** β per-scene source layering (z-order = sources list order, top-to-bottom
@@ -151,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 β scenes/sources UI built (add/reorder/rename, image + background overlays with move/resize/opacity/reuse); real capture/encoding pending
+### 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
---
@@ -231,8 +239,9 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
add/remove/reorder, and any source transform change; flush on window close.
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, SortOrder). `WindowHandle` stays in-memory
- (per-session). Save = transactional rewrite; orphaned assets pruned.
+ 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.
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/ViewModels/CameraPickerViewModel.cs b/ViewModels/CameraPickerViewModel.cs
new file mode 100644
index 0000000..32deccc
--- /dev/null
+++ b/ViewModels/CameraPickerViewModel.cs
@@ -0,0 +1,86 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using ytLive.Helpers;
+using ytLive.Services;
+
+namespace ytLive.ViewModels;
+
+public class CameraPickerCandidate
+{
+ public CameraDeviceInfo Device { get; }
+ public string Name => Device.DisplayName;
+
+ public CameraPickerCandidate(CameraDeviceInfo device)
+ {
+ Device = device;
+ }
+}
+
+public class CameraPickerViewModel : ViewModelBase
+{
+ private readonly ICameraEnumerator _enumerator;
+ private CameraPickerCandidate? _selectedCamera;
+ private bool _isLoading = true;
+
+ public ObservableCollection Cameras { get; } = new();
+
+ public bool IsLoading
+ {
+ get => _isLoading;
+ private set => SetProperty(ref _isLoading, value);
+ }
+
+ public bool HasCameras => !IsLoading && Cameras.Count > 0;
+ public bool NoCameras => !IsLoading && Cameras.Count == 0;
+
+ public CameraPickerCandidate? SelectedCamera
+ {
+ get => _selectedCamera;
+ set
+ {
+ if (SetProperty(ref _selectedCamera, value))
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+
+ public ICommand UseSelectedCommand { get; }
+ public ICommand CancelCommand { get; }
+
+ public event Action? UseRequested;
+ public event Action? CancelRequested;
+
+ public CameraPickerViewModel(ICameraEnumerator enumerator)
+ {
+ _enumerator = enumerator;
+ UseSelectedCommand = new RelayCommand(
+ _ => { if (SelectedCamera != null) UseRequested?.Invoke(SelectedCamera.Device); },
+ _ => SelectedCamera != null);
+ CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
+ _ = LoadAsync();
+ }
+
+ private async Task LoadAsync()
+ {
+ IsLoading = true;
+ try
+ {
+ var devices = await _enumerator.GetCamerasAsync();
+ Cameras.Clear();
+ foreach (var device in devices)
+ Cameras.Add(new CameraPickerCandidate(device));
+ if (Cameras.Count > 0)
+ SelectedCamera = Cameras[0];
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"CameraPickerViewModel: enumerating cameras failed: {ex.Message}");
+ }
+ finally
+ {
+ IsLoading = false;
+ OnPropertyChanged(nameof(HasCameras));
+ OnPropertyChanged(nameof(NoCameras));
+ CommandManager.InvalidateRequerySuggested();
+ }
+ }
+}
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index c792b92..d4be41d 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -6,6 +6,7 @@ using System.IO;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media;
+using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Win32;
using ytLive.Helpers;
@@ -56,6 +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.
+ private readonly ICameraEnumerator _cameraEnumerator;
+ private readonly CameraManager _cameraManager;
+ private Source? _webcamSource;
+
// Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization".
@@ -99,9 +106,19 @@ public class MainViewModel : ViewModelBase
public Source? SelectedSource
{
get => _selectedSource;
- set => SetProperty(ref _selectedSource, value);
+ set
+ {
+ if (SetProperty(ref _selectedSource, 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;
+
+ /// Shows the mirror / clip-shape row in the source chip when a webcam is selected.
+ public bool IsWebcamSelected => SelectedSource?.Type == SourceType.Webcam;
+
public StreamStatus StreamStatus
{
get => _streamStatus;
@@ -466,6 +483,14 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath;
+
+ _cameraEnumerator = new MediaCaptureCameraEnumerator();
+ _cameraManager = new CameraManager(
+ _cameraEnumerator,
+ id => new MediaCaptureFrameSource(id),
+ System.Windows.Application.Current?.Dispatcher);
+ _cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
+
LoadLayout();
_ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end");
@@ -533,14 +558,41 @@ public class MainViewModel : ViewModelBase
}
ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground();
+ ReacquireWebcam();
ScheduleSave();
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.
+ private void ReacquireWebcam()
+ {
+ var webcam = Scenes.SelectMany(s => s.Sources).FirstOrDefault(s => s.Type == SourceType.Webcam);
+ if (ReferenceEquals(_webcamSource, webcam)) return;
+
+ if (_webcamSource != null && !string.IsNullOrWhiteSpace(_webcamSource.DeviceId))
+ _ = _cameraManager.ReleaseAsync(_webcamSource.DeviceId);
+
+ _webcamSource = webcam;
+ OnPropertyChanged(nameof(CanAddWebcam));
+ if (webcam != null && !string.IsNullOrWhiteSpace(webcam.DeviceId))
+ _ = _cameraManager.AcquireAsync(webcam.DeviceId);
+ }
+
+ // 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.
+ private void OnCameraPreviewBitmapChanged(string deviceId, WriteableBitmap bitmap)
+ {
+ if (_webcamSource?.DeviceId == deviceId)
+ _webcamSource.VideoImageSource = bitmap;
+ }
+
public void Shutdown()
{
_saveDebounce?.Stop();
SaveLayoutNow();
+ _cameraManager.Dispose();
_layoutStore.Dispose();
}
@@ -691,6 +743,12 @@ public class MainViewModel : ViewModelBase
_ => "Source",
};
+ if (sourceType == SourceType.Webcam)
+ {
+ _ = AddWebcamSourceAsync();
+ return;
+ }
+
if (sourceType == SourceType.Background)
{
var bytes = PickImageBytes("Choose a backdrop image");
@@ -722,6 +780,46 @@ public class MainViewModel : ViewModelBase
UpdateActiveBackground();
}
+ private async Task AddWebcamSourceAsync()
+ {
+ var scene = ActiveScene;
+ if (scene == null || _webcamSource != null) return;
+
+ var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
+ {
+ Owner = Application.Current.MainWindow
+ };
+ 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,
+ };
+
+ _webcamSource = source;
+ OnPropertyChanged(nameof(CanAddWebcam));
+ scene.Sources.Add(source);
+ SelectedSource = source;
+ OnPropertyChanged(nameof(ShowEmptySceneHint));
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
+ UpdateActiveBackground();
+
+ var started = await _cameraManager.AcquireAsync(device.Id);
+ 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);
+ }
+ }
+
private void AddImage()
{
var scene = ActiveScene;
@@ -842,6 +940,13 @@ public class MainViewModel : ViewModelBase
{
var scene = ActiveScene;
if (scene == null || source == null) return;
+ if (source.Type == SourceType.Webcam && ReferenceEquals(source, _webcamSource))
+ {
+ _webcamSource = null;
+ OnPropertyChanged(nameof(CanAddWebcam));
+ if (!string.IsNullOrWhiteSpace(source.DeviceId))
+ _ = _cameraManager.ReleaseAsync(source.DeviceId);
+ }
scene.Sources.Remove(source);
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
diff --git a/ViewModels/index.md b/ViewModels/index.md
index a445804..6bf82b1 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -4,9 +4,10 @@ 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) |
+| `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` |
| `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` |
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
for what the ViewModels drive; base class in [`Helpers/ViewModelBase.cs`](../Helpers/ViewModelBase.cs).
diff --git a/ai.md b/ai.md
index fdda1d4..30d0b28 100644
--- a/ai.md
+++ b/ai.md
@@ -36,7 +36,16 @@ Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project ca
## Tests
-No test framework set up yet. When added: `dotnet test`.
+xUnit in `ytLive.Tests` (net8.0-windows10.0.19041.0, matches the app TFM). Run on Windows β
+WSL can't run net8.0-windows tests:
+
+```bash
+dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
+```
+
+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) β 11 passing.
## Architecture
@@ -44,9 +53,9 @@ C# / WPF (.NET 8) following MVVM:
| Path | Role |
|------|------|
-| `Models/` | Plain data types β Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
-| `ViewModels/` | MainViewModel β exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
-| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
+| `Models/` | Plain data types β Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
+| `ViewModels/` | MainViewModel β exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel |
+| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
| `Themes/` | `Controls.xaml` β the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
@@ -65,11 +74,41 @@ 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); the OAuth session persists (DPAPI); the paid-unlock state does not (yet β itch.io key verification pending)
+- 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)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams β must switch to the v3 `variable` reusable stream
-- No capture/encoding/RTMP yet
+- 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
+### Webcam capture (TASK 3 milestone 1)
+
+- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
+ BGRA8) + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces. Tests inject fakes;
+ screen capture and background removal later feed the same seam.
+- **CPU-first:** `MediaCaptureInitializationSettings { MemoryPreference = Cpu, StreamingCaptureMode = Video }`,
+ frames pulled via `CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` β the pipeline does
+ any format conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via
+ `WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies).
+- **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.
+- **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
+ `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. Round hit-testing is the
+ same rectangle as Traditional (selection overlay is rectangular) β acceptable for now.
+- **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.
+- **Background removal = milestone 2** β ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
+ Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
+
## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it.
diff --git a/ytLive.Tests/CameraManagerTests.cs b/ytLive.Tests/CameraManagerTests.cs
new file mode 100644
index 0000000..67f2f11
--- /dev/null
+++ b/ytLive.Tests/CameraManagerTests.cs
@@ -0,0 +1,122 @@
+using Xunit;
+using ytLive.Services;
+
+namespace ytLive.Tests;
+
+public class CameraManagerTests
+{
+ private sealed class FakeFrameSource : ICameraFrameSource
+ {
+ private readonly List _started;
+ private readonly List _stopped;
+
+ public string DeviceId { get; }
+ public event Action? FrameAvailable;
+
+ public FakeFrameSource(string deviceId, List started, List stopped)
+ {
+ DeviceId = deviceId;
+ _started = started;
+ _stopped = stopped;
+ }
+
+ public Task StartAsync()
+ {
+ _started.Add(DeviceId);
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync()
+ {
+ _stopped.Add(DeviceId);
+ return Task.CompletedTask;
+ }
+
+ public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
+ }
+
+ private sealed class FailingFrameSource : ICameraFrameSource
+ {
+ public string DeviceId { get; }
+ public event Action? FrameAvailable;
+ public bool Stopped;
+
+ public FailingFrameSource(string deviceId) => DeviceId = deviceId;
+
+ public Task StartAsync() => throw new InvalidOperationException("camera in use");
+
+ public Task StopAsync()
+ {
+ Stopped = true;
+ return Task.CompletedTask;
+ }
+
+ public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
+ }
+
+ private static CameraManager CreateManager(List started, List stopped)
+ => new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped));
+
+ private sealed class FakeEnumerator : ICameraEnumerator
+ {
+ public Task> GetCamerasAsync()
+ => Task.FromResult>(new[]
+ {
+ new CameraDeviceInfo("dev1", "Logitech C920"),
+ });
+ }
+
+ [Fact]
+ public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
+ {
+ var started = new List();
+ var stopped = new List();
+ var manager = CreateManager(started, stopped);
+
+ Assert.True(await manager.AcquireAsync("dev1"));
+ Assert.True(await manager.AcquireAsync("dev1"));
+ Assert.Single(started);
+
+ await manager.ReleaseAsync("dev1");
+ Assert.Empty(stopped);
+
+ await manager.ReleaseAsync("dev1");
+ Assert.Single(stopped);
+ Assert.Null(manager.GetLatestFrame("dev1"));
+ }
+
+ [Fact]
+ public async Task FramePump_UpdatesLatestFrame()
+ {
+ var started = new List();
+ var stopped = new List();
+ FakeFrameSource? captured = null;
+ var manager = new CameraManager(
+ new FakeEnumerator(),
+ id => captured = new FakeFrameSource(id, started, stopped));
+
+ await manager.AcquireAsync("dev1");
+ var frame = new VideoFrame(2, 2, new byte[16]);
+ captured!.Pump(frame);
+
+ Assert.Same(frame, manager.GetLatestFrame("dev1"));
+ }
+
+ [Fact]
+ public async Task Acquire_FailingCamera_ReturnsFalseAndStops()
+ {
+ var manager = new CameraManager(new FakeEnumerator(), id => new FailingFrameSource(id));
+ string? failedDevice = null;
+ manager.CameraFailed += (device, _) => failedDevice = device;
+
+ Assert.False(await manager.AcquireAsync("dev1"));
+ Assert.Equal("dev1", failedDevice);
+ }
+
+ [Fact]
+ public async Task Acquire_EmptyDeviceId_ReturnsFalse()
+ {
+ var manager = CreateManager(new List(), new List());
+ Assert.False(await manager.AcquireAsync(" "));
+ }
+}
diff --git a/ytLive.Tests/ytLive.Tests.csproj b/ytLive.Tests/ytLive.Tests.csproj
index cdab722..dc7b95b 100644
--- a/ytLive.Tests/ytLive.Tests.csproj
+++ b/ytLive.Tests/ytLive.Tests.csproj
@@ -1,7 +1,7 @@
- net8.0-windows
+ net8.0-windows10.0.19041.0
enable
enable
false
diff --git a/ytLive.csproj b/ytLive.csproj
index b238702..6949ebd 100644
--- a/ytLive.csproj
+++ b/ytLive.csproj
@@ -2,7 +2,7 @@
WinExe
- net8.0-windows
+ net8.0-windows10.0.19041.0
enable
enable
true