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"> Click="AddSourceButton_Click">
<Button.ContextMenu> <Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"> <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="Screen" Command="{Binding AddSourceCommand}" CommandParameter="screen"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/> <MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="background"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/> <MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
@@ -319,9 +321,14 @@
</ItemsControl.ItemContainerStyle> </ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Image Width="{Binding Width}" Height="{Binding Height}" <Grid Width="{Binding Width}" Height="{Binding Height}"
Opacity="{Binding Opacity}" Stretch="UniformToFill" Opacity="{Binding Opacity}"
Source="{Binding ImageSource}" RenderTransformOrigin="0.5,0.5">
<Grid.RenderTransform>
<ScaleTransform ScaleX="{Binding MirrorScale}"/>
</Grid.RenderTransform>
<Image Stretch="UniformToFill"
Source="{Binding DisplaySource}"
RenderOptions.BitmapScalingMode="HighQuality"> RenderOptions.BitmapScalingMode="HighQuality">
<Image.Style> <Image.Style>
<Style TargetType="Image"> <Style TargetType="Image">
@@ -330,10 +337,37 @@
<DataTrigger Binding="{Binding Type}" Value="Image"> <DataTrigger Binding="{Binding Type}" Value="Image">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </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.Triggers>
</Style> </Style>
</Image.Style> </Image.Style>
</Image> </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> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
@@ -441,6 +475,15 @@
<TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13" <TextBlock x:Name="OpacityValueText" Text="100%" Foreground="#e0e0e0" FontSize="13"
Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/> Width="40" Margin="8,0,0,0" VerticalAlignment="Center"/>
</StackPanel> </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" <TextBlock Text="Drag to move · drag the corner dot to resize"
Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/> Foreground="#a0a0b0" FontSize="11" Margin="0,4,0,0"/>
</StackPanel> </StackPanel>
+20 -4
View File
@@ -115,13 +115,29 @@ public partial class MainWindow : Window
private void UpdateSelectionOverlay() 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; SelectionOverlay.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed; OpacityChip.Visibility = selected ? Visibility.Visible : Visibility.Collapsed;
if (selected) if (selected)
OpacityValueText.Text = $"{Math.Round(_viewModel.SelectedSource!.Opacity * 100)}%"; 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) private void OpacitySlider_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e)
=> OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%"; => OpacityValueText.Text = $"{Math.Round(e.NewValue * 100)}%";
@@ -134,10 +150,10 @@ public partial class MainWindow : Window
var p = toCanvas.Transform(e.GetPosition(grid)); var p = toCanvas.Transform(e.GetPosition(grid));
var selected = _viewModel.SelectedSource; 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; _isResizing = true;
_resizeAspect = selected.Width / Math.Max(1, selected.Height); _resizeAspect = sel.Width / Math.Max(1, sel.Height);
grid.CaptureMouse(); grid.CaptureMouse();
e.Handled = true; e.Handled = true;
return; return;
@@ -218,7 +234,7 @@ public partial class MainWindow : Window
for (var i = scene.Sources.Count - 1; i >= 0; i--) for (var i = scene.Sources.Count - 1; i >= 0; i--)
{ {
var source = scene.Sources[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 && if (p.X >= source.X && p.X <= source.X + source.Width &&
p.Y >= source.Y && p.Y <= source.Y + source.Height) p.Y >= source.Y && p.Y <= source.Y + source.Height)
return source; return source;
+56
View File
@@ -1,6 +1,7 @@
using System.ComponentModel; using System.ComponentModel;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Helpers; using ytLive.Helpers;
namespace ytLive.Models; namespace ytLive.Models;
@@ -15,6 +16,12 @@ public enum SourceType
TextOverlay TextOverlay
} }
public enum ClipShape
{
Traditional,
Round
}
public class Source : INotifyPropertyChanged public class Source : INotifyPropertyChanged
{ {
public event PropertyChangedEventHandler? PropertyChanged; public event PropertyChangedEventHandler? PropertyChanged;
@@ -52,6 +59,54 @@ public class Source : INotifyPropertyChanged
private string? _deviceId; private string? _deviceId;
public string? DeviceId { get => _deviceId; set => Set(ref _deviceId, value); } 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) // Image (asset stored in the layout database)
private string? _assetId; private string? _assetId;
private ImageSource? _imageSource; private ImageSource? _imageSource;
@@ -65,6 +120,7 @@ public class Source : INotifyPropertyChanged
{ {
_imageSource = ImageCache.Get(value ?? string.Empty); _imageSource = ImageCache.Get(value ?? string.Empty);
Raise(nameof(ImageSource)); 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 | | File | Purpose |
|------|---------| |------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Sources` collection | | `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 | | `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 | | `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) | | `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 = string[] statements =
{ {
"PRAGMA user_version = 1;", "PRAGMA user_version = 2;",
""" """
CREATE TABLE IF NOT EXISTS Scene ( CREATE TABLE IF NOT EXISTS Scene (
Id TEXT PRIMARY KEY, Id TEXT PRIMARY KEY,
@@ -65,6 +65,8 @@ public class LayoutStore : IDisposable
Opacity REAL NOT NULL DEFAULT 1, Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER, MonitorIndex INTEGER,
DeviceId TEXT, DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
SortOrder INTEGER NOT NULL DEFAULT 0 SortOrder INTEGER NOT NULL DEFAULT 0
); );
""", """,
@@ -75,6 +77,36 @@ public class LayoutStore : IDisposable
cmd.CommandText = sql; cmd.CommandText = sql;
cmd.ExecuteNonQuery(); 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() public List<Scene> Load()
@@ -102,7 +134,7 @@ public class LayoutStore : IDisposable
{ {
cmd.CommandText = """ cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled, 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 FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -123,6 +155,8 @@ public class LayoutStore : IDisposable
Opacity = reader.GetDouble(10), Opacity = reader.GetDouble(10),
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11), MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
DeviceId = reader.IsDBNull(12) ? null : reader.GetString(12), 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)) if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>(); sourcesByScene[sceneId] = list = new List<Source>();
@@ -185,9 +219,11 @@ public class LayoutStore : IDisposable
{ {
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled, 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, 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; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); 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 opacityP = cmd.Parameters.Add("$opacity", SqliteType.Real);
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer); var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var deviceP = cmd.Parameters.Add("$device", SqliteType.Text); 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); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
foreach (var scene in scenes) foreach (var scene in scenes)
@@ -223,6 +261,8 @@ public class LayoutStore : IDisposable
opacityP.Value = source.Opacity; opacityP.Value = source.Opacity;
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value; monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
deviceP.Value = (object?)source.DeviceId ?? DBNull.Value; deviceP.Value = (object?)source.DeviceId ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
sortP.Value = sort++; sortP.Value = sort++;
cmd.ExecuteNonQuery(); 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) | | `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** | | `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` | | `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) Related: constructed in [`ViewModels/MainViewModel.cs`](../ViewModels/MainViewModel.cs)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md). (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: ### Requirements:
1. **Screen** — Windows.Graphics.Capture (WinRT), enumerate displays/windows, picker 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 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) 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 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 - **Scenes list:** drag rows to reorder scenes
- **Sources list:** drag rows to reorder sources (this *is* the z-order) — implemented - **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. add/remove/reorder, and any source transform change; flush on window close.
5. **Schema**`Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data, 5. **Schema**`Scene` (Id, Name, IsHidden, IsChatScene, SortOrder), `Asset` (Id, Hash, Data,
PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled, PixelWidth, PixelHeight), `Source` (Id, SceneId FK cascade, AssetId FK, Type, Name, IsEnabled,
X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, SortOrder). `WindowHandle` stays in-memory X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
(per-session). Save = transactional rewrite; orphaned assets pruned. `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) 6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending)
only when the DB is empty. 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;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
using Microsoft.Win32; using Microsoft.Win32;
using ytLive.Helpers; using ytLive.Helpers;
@@ -56,6 +57,12 @@ public class MainViewModel : ViewModelBase
private DispatcherTimer? _saveDebounce; private DispatcherTimer? _saveDebounce;
private bool _isLoading; 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 // Branding flash (monetization): a full-frame "made with ytLlive!" shown
// for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets // for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets
// BrandFlashEnabled = false. See ai.md "Monetization". // BrandFlashEnabled = false. See ai.md "Monetization".
@@ -99,8 +106,18 @@ public class MainViewModel : ViewModelBase
public Source? SelectedSource public Source? SelectedSource
{ {
get => _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 public StreamStatus StreamStatus
{ {
@@ -466,6 +483,14 @@ public class MainViewModel : ViewModelBase
_layoutStore = new LayoutStore(DefaultLayoutPath); _layoutStore = new LayoutStore(DefaultLayoutPath);
_activeLayoutPath = _layoutStore.ActivePath; _activeLayoutPath = _layoutStore.ActivePath;
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
id => new MediaCaptureFrameSource(id),
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
LoadLayout(); LoadLayout();
_ = LoadSavedSessionAsync(); _ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end"); AppLog.Write("MainViewModel ctor end");
@@ -533,14 +558,41 @@ public class MainViewModel : ViewModelBase
} }
ActiveScene = Scenes.FirstOrDefault(); ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground(); UpdateActiveBackground();
ReacquireWebcam();
ScheduleSave(); ScheduleSave();
AppLog.Write("LoadLayout end"); 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() public void Shutdown()
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
SaveLayoutNow(); SaveLayoutNow();
_cameraManager.Dispose();
_layoutStore.Dispose(); _layoutStore.Dispose();
} }
@@ -691,6 +743,12 @@ public class MainViewModel : ViewModelBase
_ => "Source", _ => "Source",
}; };
if (sourceType == SourceType.Webcam)
{
_ = AddWebcamSourceAsync();
return;
}
if (sourceType == SourceType.Background) if (sourceType == SourceType.Background)
{ {
var bytes = PickImageBytes("Choose a backdrop image"); var bytes = PickImageBytes("Choose a backdrop image");
@@ -722,6 +780,46 @@ public class MainViewModel : ViewModelBase
UpdateActiveBackground(); 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() private void AddImage()
{ {
var scene = ActiveScene; var scene = ActiveScene;
@@ -842,6 +940,13 @@ public class MainViewModel : ViewModelBase
{ {
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || source == null) return; 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); scene.Sources.Remove(source);
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); 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 | | 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 | | `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 | | `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) 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). 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 ## 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 ## Architecture
@@ -44,9 +53,9 @@ C# / WPF (.NET 8) following MVVM:
| Path | Role | | Path | Role |
|------|------| |------|------|
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage | | `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 | | `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) | | `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 | | `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`) | | `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) | | `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 ### 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`) - `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 - `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 - `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 ## Design Principle
> This software is so intuitive that even the most right-brained person can easily intuit and use it. > 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"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
+1 -1
View File
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>