TASK 3 milestone 1: webcam capture (MediaCapture CPU-first, CameraManager refcount, picker, clip/mirror, schema v2, tests)
This commit is contained in:
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user