TASK 3 milestone 1: webcam capture (MediaCapture CPU-first, CameraManager refcount, picker, clip/mirror, schema v2, tests)

This commit is contained in:
2026-08-06 14:35:34 -07:00
parent d97b5d375c
commit 98cdc4b3f4
23 changed files with 1099 additions and 40 deletions
+90
View File
@@ -0,0 +1,90 @@
<Window x:Class="ytLive.CameraPickerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Add Webcam" Height="340" Width="460"
Icon="/Assets/llama-logo-icon.png"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False" Background="#1a1a2e">
<Window.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<Style x:Key="CandidateItem" TargetType="ListBoxItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" Background="Transparent" CornerRadius="4" Padding="6,5" Margin="0,2">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#1c2a52"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="#2a2450"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Window.Resources>
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Add Webcam" FontSize="18" FontWeight="Bold"
Foreground="#e0e0e0" Margin="0,0,0,14"/>
<TextBlock Grid.Row="1" Text="Pick the camera to add to your stream."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" Margin="0,0,0,12"/>
<Grid Grid.Row="2">
<ListBox Background="Transparent" BorderThickness="0"
ItemContainerStyle="{StaticResource CandidateItem}"
ItemsSource="{Binding Cameras}"
SelectedItem="{Binding SelectedCamera, Mode=TwoWay}"
Visibility="{Binding HasCameras, Converter={StaticResource BoolToVis}}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<TextBlock Text="📷" FontSize="15" VerticalAlignment="Center" Margin="0,0,10,0"/>
<TextBlock Text="{Binding Name}" Foreground="#e0e0e0" FontSize="13"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center"
Visibility="{Binding IsLoading, Converter={StaticResource BoolToVis}}">
<TextBlock Text="Searching for cameras…" Foreground="#a0a0b0" FontSize="13"/>
</StackPanel>
<StackPanel VerticalAlignment="Center"
Visibility="{Binding NoCameras, Converter={StaticResource BoolToVis}}">
<TextBlock Text="No cameras found" Foreground="#e0e0e0" FontSize="15"
FontWeight="SemiBold" HorizontalAlignment="Center"/>
<TextBlock Text="Check that a webcam is connected and that Windows camera access is enabled."
Foreground="#a0a0b0" FontSize="13" TextWrapping="Wrap" MaxWidth="360"
TextAlignment="Center" HorizontalAlignment="Center" Margin="0,8,0,0"/>
</StackPanel>
</Grid>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="Cancel" Command="{Binding CancelCommand}"
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
<Button Content="Use Selected" Command="{Binding UseSelectedCommand}"
Style="{StaticResource YtButton}" MinWidth="110"/>
</StackPanel>
</Grid>
</Window>
+25
View File
@@ -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;
}
}
+47 -4
View File
@@ -229,7 +229,9 @@
Click="AddSourceButton_Click">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"/>
<MenuItem Header="Webcam" Command="{Binding AddSourceCommand}" CommandParameter="webcam"
IsEnabled="{Binding CanAddWebcam}"
ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
@@ -319,9 +321,14 @@
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Image Width="{Binding Width}" Height="{Binding Height}"
Opacity="{Binding Opacity}" Stretch="UniformToFill"
Source="{Binding ImageSource}"
<Grid Width="{Binding Width}" Height="{Binding Height}"
Opacity="{Binding Opacity}"
RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
</Grid.RenderTransform>
<Image Stretch="UniformToFill"
Source="{Binding DisplaySource}"
RenderOptions.BitmapScalingMode="HighQuality">
<Image.Style>
<Style TargetType="Image">
@@ -330,10 +337,37 @@
<DataTrigger Binding="{Binding Type}" Value="Image">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="Webcam"/>
<Condition Binding="{Binding ClipShape}" Value="Traditional"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Image.Style>
</Image>
<Ellipse>
<Ellipse.Style>
<Style TargetType="Ellipse">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<MultiDataTrigger>
<MultiDataTrigger.Conditions>
<Condition Binding="{Binding Type}" Value="Webcam"/>
<Condition Binding="{Binding ClipShape}" Value="Round"/>
</MultiDataTrigger.Conditions>
<Setter Property="Visibility" Value="Visible"/>
</MultiDataTrigger>
</Style.Triggers>
</Style>
</Ellipse.Style>
<Ellipse.Fill>
<ImageBrush ImageSource="{Binding DisplaySource}" Stretch="UniformToFill"/>
</Ellipse.Fill>
</Ellipse>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -441,6 +475,15 @@
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0,10,0,0"
Visibility="{Binding IsWebcamSelected, Converter={StaticResource BoolToVis}}">
<Button Content="{Binding SelectedSource.MirrorButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Click="MirrorButton_Click"/>
<Button Content="{Binding SelectedSource.ShapeButtonText}"
Style="{StaticResource YtButtonSecondary}" Padding="10,4"
Margin="8,0,0,0" Click="ShapeButton_Click"/>
</StackPanel>
<TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
</StackPanel>
+20 -4
View File
@@ -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<double> 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;
+56
View File
@@ -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;
/// <summary>Mirror toggle label (mirrored → "Unmirror").</summary>
public string MirrorButtonText => IsMirrored ? "Unmirror" : "Mirror";
/// <summary>Clip-shape toggle label (round → "Rect").</summary>
public string ShapeButtonText => ClipShape == ClipShape.Round ? "Rect" : "Round";
/// <summary>What the preview shows: static image for image/background, live frames for webcam.</summary>
public ImageSource? DisplaySource => Type == SourceType.Webcam ? _videoImageSource : _imageSource;
// Image (asset stored in the layout database)
private string? _assetId;
private ImageSource? _imageSource;
@@ -65,6 +120,7 @@ public class Source : INotifyPropertyChanged
{
_imageSource = ImageCache.Get(value ?? string.Empty);
Raise(nameof(ImageSource));
Raise(nameof(DisplaySource));
}
}
}
+1 -1
View File
@@ -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) |
+13
View File
@@ -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;
}
}
+218
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
public sealed class CameraManager : IDisposable
{
private sealed class CameraSession
{
public string DeviceId { get; }
public ICameraFrameSource Source { get; }
public Action<VideoFrame>? 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<string, ICameraFrameSource> _frameSourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Dictionary<string, CameraSession> _sessions = new();
private readonly object _gate = new();
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
/// <summary>Raised when a capture fails to start (device in use, access denied, no preview source).</summary>
public event Action<string, string>? CameraFailed;
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
Dispatcher? uiDispatcher = null)
{
_enumerator = enumerator;
_frameSourceFactory = frameSourceFactory;
_uiDispatcher = uiDispatcher;
}
public ICameraEnumerator Enumerator => _enumerator;
/// <summary>Increments the refcount for a device, starting capture the first time.</summary>
public async Task<bool> 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;
}
}
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
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<CameraSession> sessions;
lock (_gate)
{
sessions = new List<CameraSession>(_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);
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace ytLive.Services;
/// <summary>
/// Enumerates physical capture devices. Seam so the picker and CameraManager
/// never touch WinRT directly (tests inject fakes).
/// </summary>
public interface ICameraEnumerator
{
Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync();
}
+14
View File
@@ -0,0 +1,14 @@
namespace ytLive.Services;
/// <summary>
/// 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.
/// </summary>
public interface ICameraFrameSource
{
string DeviceId { get; }
event Action<VideoFrame>? FrameAvailable;
Task StartAsync();
Task StopAsync();
}
+44 -4
View File
@@ -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<string>(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<Scene> 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<ClipShape>(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<Source>();
@@ -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();
}
+15
View File
@@ -0,0 +1,15 @@
using Windows.Devices.Enumeration;
namespace ytLive.Services;
public sealed class MediaCaptureCameraEnumerator : ICameraEnumerator
{
public async Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
{
var devices = await DeviceInformation.FindAllAsync(DeviceClass.VideoCapture);
var result = new List<CameraDeviceInfo>(devices.Count);
foreach (var device in devices)
result.Add(new CameraDeviceInfo(device.Id, device.Name));
return result;
}
}
+128
View File
@@ -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;
/// <summary>
/// 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 <see cref="VideoFrame"/>. Frames arrive on a
/// worker thread — marshal before touching WPF.
/// </summary>
public sealed class MediaCaptureFrameSource : ICameraFrameSource
{
private readonly string _deviceId;
private MediaCapture? _capture;
private MediaFrameReader? _frameReader;
public string DeviceId => _deviceId;
public event Action<VideoFrame>? 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();
}
}
}
+22
View File
@@ -0,0 +1,22 @@
namespace ytLive.Services;
/// <summary>
/// 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.
/// </summary>
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;
}
}
+8 -1
View File
@@ -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).
+13 -4
View File
@@ -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.
+86
View File
@@ -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<CameraPickerCandidate> 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<CameraDeviceInfo>? 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();
}
}
}
+106 -1
View File
@@ -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,8 +106,18 @@ public class MainViewModel : ViewModelBase
public Source? SelectedSource
{
get => _selectedSource;
set => SetProperty(ref _selectedSource, value);
set
{
if (SetProperty(ref _selectedSource, value))
OnPropertyChanged(nameof(IsWebcamSelected));
}
}
/// <summary>The Add → Webcam menu item. One webcam app-wide — once one exists it's greyed out.</summary>
public bool CanAddWebcam => _webcamSource == null;
/// <summary>Shows the mirror / clip-shape row in the source chip when a webcam is selected.</summary>
public bool IsWebcamSelected => SelectedSource?.Type == SourceType.Webcam;
public StreamStatus 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));
+2 -1
View File
@@ -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).
+45 -6
View File
@@ -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.
+122
View File
@@ -0,0 +1,122 @@
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
public class CameraManagerTests
{
private sealed class FakeFrameSource : ICameraFrameSource
{
private readonly List<string> _started;
private readonly List<string> _stopped;
public string DeviceId { get; }
public event Action<VideoFrame>? FrameAvailable;
public FakeFrameSource(string deviceId, List<string> started, List<string> 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<VideoFrame>? 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<string> started, List<string> stopped)
=> new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped));
private sealed class FakeEnumerator : ICameraEnumerator
{
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
{
new CameraDeviceInfo("dev1", "Logitech C920"),
});
}
[Fact]
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
{
var started = new List<string>();
var stopped = new List<string>();
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<string>();
var stopped = new List<string>();
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<string>(), new List<string>());
Assert.False(await manager.AcquireAsync(" "));
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>