Screen backdrop (schema v5/v6) + five-scene catalog + webcam polish: live desktop/game capture as a permanent non-deletable bottom layer (Source.IsBackdrop), auto full-screen game detection (Win32FullScreenDetector) else primary display, GraphicsCapturePicker re-designation, refcounted shared ScreenCaptureManager, Live-only backdrop by policy (HasBackdrop + v5-v6 backfill + EnforceBackdropPolicy, checkbox gone), SceneCatalog (Starting/Live/BRB/Chat/Ending) with + button re-adding missing scenes, webcam mid-session transparent-container fix (GetPreviewBitmap propagation), Chat half-screen-area cap, 'Add Webcam' always opens the picker (SwapWebcamIdentityAsync, no silent resurrect), WindowsRuntimeMarshal frame-read + 5s-throttled errors, docs updated, tests (65 passing)

This commit is contained in:
2026-08-07 14:15:50 -07:00
parent e037ba027b
commit ad9b3e1e48
27 changed files with 2232 additions and 116 deletions
+77 -13
View File
@@ -13,6 +13,8 @@
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
Closing="MainWindow_Closing" Closing="MainWindow_Closing"
Loaded="MainWindow_Loaded" Loaded="MainWindow_Loaded"
Activated="MainWindow_Activated"
Deactivated="MainWindow_Deactivated"
PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown"> PreviewMouseLeftButtonDown="Window_PreviewMouseLeftButtonDown">
<Window.DataContext> <Window.DataContext>
@@ -130,11 +132,27 @@
<RowDefinition Height="180"/> <RowDefinition Height="180"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<!-- Scenes --> <!-- Scenes: the app has five canonical scenes. Work with less,
never more — the "+" only offers the missing ones, and is
hidden entirely once all five are back. -->
<StackPanel Grid.Row="0" Orientation="Horizontal"> <StackPanel Grid.Row="0" Orientation="Horizontal">
<TextBlock Text="SCENES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/> <TextBlock Text="SCENES" Style="{StaticResource SectionHeader}" VerticalAlignment="Center"/>
<Button ToolTip="Add Scene" Command="{Binding AddSceneCommand}" <Button ToolTip="Add Missing Scene" Click="AddSceneButton_Click"
Style="{StaticResource IconButton}" Margin="4,-4,0,0"> Style="{StaticResource IconButton}" Margin="4,-4,0,0"
Visibility="{Binding ShowAddScene, Converter={StaticResource BoolToVis}}">
<Button.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"
ItemsSource="{Binding MissingScenes}">
<ContextMenu.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding}"/>
<Setter Property="Command"
Value="{Binding DataContext.AddSceneCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</ContextMenu.ItemContainerStyle>
</ContextMenu>
</Button.ContextMenu>
<Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2" <Path Data="M12,4 L12,20 M4,12 L20,12" Stroke="#e94560" StrokeThickness="2"
Width="14" Height="14" Stretch="Uniform"/> Width="14" Height="14" Stretch="Uniform"/>
</Button> </Button>
@@ -265,12 +283,25 @@
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackdropMenu_ChangeCapture"
Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Capture" Click="BackdropMenu_RefreshCapture"
Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<Separator Visibility="{Binding IsBackdrop, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Remove Source" Click="SourceMenu_Remove"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
</Grid.ContextMenu>
<TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center" <TextBlock Grid.Column="0" Text="{Binding Name}" VerticalAlignment="Center"
Padding="4,2" TextTrimming="CharacterEllipsis"/> Padding="4,2" TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="1" ToolTip="Remove Source" <Button Grid.Column="1" ToolTip="Remove Source"
Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" Command="{Binding DataContext.RemoveSourceCommand, RelativeSource={RelativeSource AncestorType=ListBox}}"
CommandParameter="{Binding}" Style="{StaticResource IconButton}"> CommandParameter="{Binding}" Style="{StaticResource IconButton}"
Visibility="{Binding IsBackdrop, Converter={StaticResource InverseBoolToVis}}">
<Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z" <Path Data="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"
Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/> Fill="#d0d0d0" Width="13" Height="13" Stretch="Uniform"/>
</Button> </Button>
@@ -305,10 +336,31 @@
<Viewbox Stretch="Uniform"> <Viewbox Stretch="Uniform">
<Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent"> <Grid x:Name="CanvasGrid" Width="1920" Height="1080" ClipToBounds="True" Background="Transparent">
<Grid.ContextMenu> <Grid.ContextMenu>
<ContextMenu> <ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/> <MenuItem Header="Show Webcam" Command="{Binding ShowWebcamCommand}"/>
<MenuItem Header="Capture Display"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
ItemsSource="{Binding Displays}">
<MenuItem.ItemContainerStyle>
<Style TargetType="MenuItem">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command"
Value="{Binding DataContext.SetBackdropDisplayCommand, RelativeSource={RelativeSource AncestorType=ContextMenu}}"/>
<Setter Property="CommandParameter" Value="{Binding}"/>
</Style>
</MenuItem.ItemContainerStyle>
</MenuItem>
<MenuItem Header="Change Capture…" Command="{Binding ChangeCaptureCommand}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"
ToolTip="Pick any window with the OS picker"/>
<MenuItem Header="Refresh Capture" Command="{Binding RefreshCaptureCommand}"
Visibility="{Binding CanChangeBackdrop, Converter={StaticResource BoolToVis}}"/>
</ContextMenu> </ContextMenu>
</Grid.ContextMenu> </Grid.ContextMenu>
<!-- Live desktop/game backdrop (bottom layer; the static
background renders above it). -->
<Image Source="{Binding BackdropImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill" <Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"
IsHitTestVisible="False"/> IsHitTestVisible="False"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/> <Rectangle Stroke="#22c55e" StrokeThickness="6" IsHitTestVisible="False"/>
@@ -339,6 +391,12 @@
<DataTrigger Binding="{Binding IsVisible}" Value="True"> <DataTrigger Binding="{Binding IsVisible}" Value="True">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </DataTrigger>
<!-- The backdrop is a fixed full-frame layer (rendered separately,
below the static background): never hit-testable, so it can't be
selected, dragged, or bring up the webcam context menu. -->
<DataTrigger Binding="{Binding IsBackdrop}" Value="True">
<Setter Property="IsHitTestVisible" Value="False"/>
</DataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Grid.Style> </Grid.Style>
@@ -368,9 +426,15 @@
</Image> </Image>
<Viewbox Stretch="Uniform"> <Viewbox Stretch="Uniform">
<Grid Width="1" Height="1"> <Grid Width="1" Height="1">
<Ellipse> <!-- Round webcam: a plain Image (the same efficient WriteableBitmap
<Ellipse.Style> path as Traditional) clipped to a unit circle and scaled by the
<Style TargetType="Ellipse"> Viewbox — deliberately NOT an ImageBrush, whose per-frame
re-rasterization at full scale stalls the preview. -->
<Image Stretch="UniformToFill"
Source="{Binding DisplaySource}"
RenderOptions.BitmapScalingMode="HighQuality">
<Image.Style>
<Style TargetType="Image">
<Setter Property="Visibility" Value="Collapsed"/> <Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers> <Style.Triggers>
<MultiDataTrigger> <MultiDataTrigger>
@@ -382,11 +446,11 @@
</MultiDataTrigger> </MultiDataTrigger>
</Style.Triggers> </Style.Triggers>
</Style> </Style>
</Ellipse.Style> </Image.Style>
<Ellipse.Fill> <Image.Clip>
<ImageBrush ImageSource="{Binding DisplaySource}" Stretch="UniformToFill"/> <EllipseGeometry Center="0.5,0.5" RadiusX="0.5" RadiusY="0.5"/>
</Ellipse.Fill> </Image.Clip>
</Ellipse> </Image>
</Grid> </Grid>
</Viewbox> </Viewbox>
+38 -2
View File
@@ -46,6 +46,16 @@ public partial class MainWindow : Window
_viewModel.Shutdown(); _viewModel.Shutdown();
} }
private void MainWindow_Activated(object? sender, EventArgs e)
{
_viewModel.RefreshBackdropAutoCapture();
}
private void MainWindow_Deactivated(object? sender, EventArgs e)
{
_viewModel.NoteBackgroundWindow();
}
private void UpdateTaskbarOverlay() private void UpdateTaskbarOverlay()
{ {
TaskbarInfo.Overlay = _viewModel.IsLive TaskbarInfo.Overlay = _viewModel.IsLive
@@ -91,6 +101,16 @@ public partial class MainWindow : Window
} }
} }
private void AddSceneButton_Click(object sender, RoutedEventArgs e)
{
if (sender is Button { ContextMenu: { } menu } button)
{
menu.PlacementTarget = button;
menu.Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom;
menu.IsOpen = true;
}
}
// ─── Preview image selection / move / resize ─── // ─── Preview image selection / move / resize ───
private bool _isDraggingOverlay; private bool _isDraggingOverlay;
private bool _isResizing; private bool _isResizing;
@@ -166,6 +186,22 @@ public partial class MainWindow : Window
_viewModel.RemoveSourceCommand.Execute(element); _viewModel.RemoveSourceCommand.Execute(element);
} }
private void BackdropMenu_ChangeCapture(object sender, RoutedEventArgs e)
{
_ = _viewModel.ChangeBackdropCaptureAsync();
}
private void BackdropMenu_RefreshCapture(object sender, RoutedEventArgs e)
{
_viewModel.RefreshBackdropAutoCapture();
}
private void SourceMenu_Remove(object sender, RoutedEventArgs e)
{
if (sender is MenuItem { DataContext: SceneElement element })
_viewModel.RemoveSourceCommand.Execute(element);
}
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)}%";
@@ -227,8 +263,8 @@ public partial class MainWindow : Window
else if (_isResizing) else if (_isResizing)
{ {
var isWebcam = selected is WebcamSceneConfig; var isWebcam = selected is WebcamSceneConfig;
var maxW = isWebcam ? MainViewModel.WebcamMaxWidth : 1920; var maxW = isWebcam ? MainViewModel.MaxWebcamWidthFor(_viewModel.ActiveScene?.Name) : 1920;
var maxH = isWebcam ? MainViewModel.WebcamMaxHeight : 1080; var maxH = isWebcam ? MainViewModel.MaxWebcamHeightFor(_viewModel.ActiveScene?.Name) : 1080;
var newW = Math.Clamp(p.X - selected.X, 32, maxW); var newW = Math.Clamp(p.X - selected.X, 32, maxW);
var newH = newW / _resizeAspect; var newH = newW / _resizeAspect;
if (newH > maxH) if (newH > maxH)
+11
View File
@@ -11,6 +11,7 @@ public class Scene : INotifyPropertyChanged
private string _name = string.Empty; private string _name = string.Empty;
private bool _isEditing; private bool _isEditing;
private bool _isHidden; private bool _isHidden;
private bool _hasBackdrop;
public string Name public string Name
{ {
@@ -30,6 +31,16 @@ public class Scene : INotifyPropertyChanged
set => Set(ref _isHidden, value); set => Set(ref _isHidden, value);
} }
/// <summary>
/// Whether the scene has a live desktop/game backdrop layer. Enforced by
/// policy: only the canonical Live scene ever has one (see SceneCatalog).
/// </summary>
public bool HasBackdrop
{
get => _hasBackdrop;
set => Set(ref _hasBackdrop, value);
}
/// <summary> /// <summary>
/// The scene's rendered elements in z-order (back to front): multi-instance /// The scene's rendered elements in z-order (back to front): multi-instance
/// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any. /// Sources plus this scene's webcam usage (<see cref="WebcamConfig"/>), if any.
+32
View File
@@ -0,0 +1,32 @@
using System;
using System.Linq;
namespace ytLive.Models;
/// <summary>
/// The app's five canonical scenes. Scenes are worked on by name; you can delete
/// them ("work with less"), but the app only ever adds back one of these five —
/// anything beyond them is OBS territory. The live desktop/game backdrop exists
/// only in the Live scene.
/// </summary>
public static class SceneCatalog
{
public const string Starting = "Starting";
public const string Live = "Live";
public const string Brb = "BRB";
public const string Chat = "Chat";
public const string Ending = "Ending";
public static readonly string[] All = { Starting, Live, Brb, Chat, Ending };
public static bool IsCanonical(string? name)
=> name != null && All.Contains(name.Trim(), StringComparer.OrdinalIgnoreCase);
public static bool Is(string? name, string canonical)
=> name != null && string.Equals(name.Trim(), canonical, StringComparison.OrdinalIgnoreCase);
public static bool IsChat(string? name) => Is(name, Chat);
/// <summary>Only the Live scene carries the live desktop/game backdrop.</summary>
public static bool HasBackdrop(string? name) => Is(name, Live);
}
+26 -2
View File
@@ -27,7 +27,22 @@ public enum ClipShape
public class Source : SceneElement public class Source : SceneElement
{ {
private SourceType _type; private SourceType _type;
public SourceType Type { get => _type; set => Set(ref _type, value); } public SourceType Type
{
get => _type;
set
{
if (Set(ref _type, value))
{
Raise(nameof(IsLiveCapture));
Raise(nameof(DisplaySource));
}
}
}
/// <summary>The permanent scene backdrop (live desktop/game capture). Never removable/reorderable.</summary>
private bool _isBackdrop;
public bool IsBackdrop { get => _isBackdrop; set => Set(ref _isBackdrop, value); }
private bool _isEnabled = true; private bool _isEnabled = true;
public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); } public bool IsEnabled { get => _isEnabled; set => Set(ref _isEnabled, value); }
@@ -39,6 +54,15 @@ public class Source : SceneElement
private IntPtr? _windowHandle; private IntPtr? _windowHandle;
public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); } public IntPtr? WindowHandle { get => _windowHandle; set => Set(ref _windowHandle, value); }
/// <summary>
/// Identifies what this live-capture source points at: "monitor:&lt;index&gt;" or
/// "window:&lt;hwnd&gt;". Persisted so a re-designated backdrop survives a reload.
/// </summary>
private string? _captureKey;
public string? CaptureKey { get => _captureKey; set => Set(ref _captureKey, value); }
public bool IsLiveCapture => Type is SourceType.DisplayCapture or SourceType.WindowCapture;
// 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;
@@ -59,7 +83,7 @@ public class Source : SceneElement
public ImageSource? ImageSource => _imageSource; public ImageSource? ImageSource => _imageSource;
public override ImageSource? DisplaySource => _imageSource; public override ImageSource? DisplaySource => IsLiveCapture ? VideoImageSource : _imageSource;
public override bool IsImageSource => Type == SourceType.Image; public override bool IsImageSource => Type == SourceType.Image;
} }
+3 -2
View File
@@ -5,8 +5,9 @@ Plain data types. No logic beyond what a property can carry. See
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, `Elements` collection (images + webcam config), `WebcamConfig` accessor | | `Scene.cs` | A scene: `Name`, `IsHidden`, `IsChatScene`, `IsEditing`, **`HasBackdrop`** (Live-only policy flag, default off — only the canonical Live scene has one, see `SceneCatalog`), `Elements` collection (images + webcam config), `WebcamConfig` accessor |
| `Source.cs` | An image source: `SourceType` enum (Image/Screen/Background/Text — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource`, `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`) | | `SceneCatalog.cs` | The five canonical scenes (Starting/Live/BRB/Chat/Ending) — the product, by name: `All`, `IsCanonical`, `Is(name, canonical)` (case-insensitive trim), `IsChat`, `HasBackdrop(name)` (true only for Live). Drives the empty-DB seed, the "+" missing-scenes menu, and `MainViewModel.EnforceBackdropPolicy` |
| `Source.cs` | A source: `SourceType` enum (DisplayCapture/WindowCapture/Background/Image/TextOverlay — **Webcam removed**), `X`/`Y`/`Width`/`Height`, `Opacity`, `IsEnabled`, `ImageSource` (static) / `VideoImageSource` (live frames), `DisplaySource` (`IsLiveCapture` → video, else static), `ClipShape` enum (Traditional/Round), `IsMirrored` + `MirrorScale`, `MirrorButtonText`/`ShapeButtonText`, asset identity, `IsImageSource` (XAML binds this, never `Type`), **backdrop surface**: `IsBackdrop` (permanent bottom layer, never removable), `CaptureKey` (persisted `monitor:<n>`/`window:<hwnd>`/`picker:<name>`), `IsLiveCapture` (`Type` is Display/WindowCapture) |
| `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) | | `SceneElement.cs` | Base for anything placeable in a scene: shared layout/clip/mirror/border surface, `virtual IsWebcam`/`virtual IsImageSource`; `ToggleClipShape` + persisted `RectWidth`/`RectHeight` (pre-Round rect so round→rect restores after reload) |
| `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) | | `Webcam.cs` | Singleton webcam identity: `Id`, `DeviceId`, `Name` (one row app-wide) |
| `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` | | `WebcamSceneConfig.cs` | Per-scene webcam placement (subclass of `SceneElement`): geometry + `IsVisible` + border (`BorderColor`/`BorderOpacity`/`BorderWidth`/`BorderAnimation`) + `VideoImageSource`; `WebcamId` links to `Webcam` |
+12
View File
@@ -150,6 +150,18 @@ public sealed class CameraManager : IDisposable
return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null; return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
} }
/// <summary>
/// The shared WriteableBitmap for a running session, or null when the camera
/// hasn't produced its first frame yet. <see cref="PreviewBitmapChanged"/> only
/// fires once (first frame), so configs created after the session started must
/// pick the bitmap up from here.
/// </summary>
public WriteableBitmap? GetPreviewBitmap(string deviceId)
{
lock (_gate)
return _sessions.TryGetValue(deviceId, out var session) ? session.PreviewBitmap : null;
}
public void Dispose() public void Dispose()
{ {
List<CameraSession> sessions; List<CameraSession> sessions;
+52
View File
@@ -0,0 +1,52 @@
using System;
using System.Runtime.InteropServices;
using Windows.Graphics.Capture;
namespace ytLive.Services;
/// <summary>
/// Desktop-interop bridges for Graphics Capture that CsWinRT can't project:
/// creating a <see cref="GraphicsCaptureItem"/> from an HMONITOR/HWND via the
/// activation factory's IGraphicsCaptureItemInterop, and wiring the
/// GraphicsCapturePicker to an owner window via IInitializeWithWindow.
/// GUIDs and signatures come from the official Windows GraphicsCapture samples.
/// </summary>
internal static class CaptureInterop
{
private static readonly Guid GraphicsCaptureItemGuid = new("79C3F95B-31F7-4EC2-A464-632EF5D30760");
public static GraphicsCaptureItem CreateForMonitor(IntPtr hmonitor)
{
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
return GraphicsCaptureItem.FromAbi(interop.CreateForMonitor(hmonitor, GraphicsCaptureItemGuid));
}
public static GraphicsCaptureItem CreateForWindow(IntPtr hwnd)
{
var interop = GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>();
return GraphicsCaptureItem.FromAbi(interop.CreateForWindow(hwnd, GraphicsCaptureItemGuid));
}
public static void SetWindowOwner(GraphicsCapturePicker picker, IntPtr hwnd)
{
var interop = (IInitializeWithWindow)(object)picker;
interop.Initialize(hwnd);
}
[ComImport]
[Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IGraphicsCaptureItemInterop
{
IntPtr CreateForWindow([In] IntPtr window, [In] ref Guid iid);
IntPtr CreateForMonitor([In] IntPtr monitor, [In] ref Guid iid);
}
[ComImport]
[Guid("3E68D4BD-7135-4D10-8018-9FB6D9F33FA1")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IInitializeWithWindow
{
void Initialize(IntPtr hwnd);
}
}
+83
View File
@@ -0,0 +1,83 @@
using System;
using System.Runtime.InteropServices;
using Windows.Graphics.DirectX.Direct3D11;
using WinRT;
namespace ytLive.Services;
/// <summary>
/// Bridges a native D3D11 device into the WinRT <see cref="IDirect3DDevice"/>
/// the capture API needs. CsWinRT doesn't project the Direct3D11Helper static,
/// so this creates the device with D3D11CreateDevice and converts it via the
/// WinRT interop export <c>CreateDirect3D11DeviceFromDXGIDevice</c> (d3d11.dll).
///
/// This deliberately does NOT use the older QI-for-IDirect3DDxgiInterfaceAccess
/// trick: the raw D3D11 device stopped exposing that interface on newer Windows
/// (verified E_NOINTERFACE on build 26200, hardware and WARP alike), while
/// CreateDirect3D11DeviceFromDXGIDevice keeps working. One shared device per
/// process.
/// </summary>
internal static class Direct3D11Helper
{
private const uint D3D11CreateDeviceBgraSupport = 0x20;
private const uint D3D11SdkVersion = 7;
private const int DriverTypeHardware = 1;
// IDirect3DDxgiInterfaceAccess (the legacy bridge) is only exposed on an
// 11.1 device AND requires the 11.1 runtime to be in the requested set —
// with pFeatureLevels = NULL D3D11CreateDevice never creates 11.1, so the
// array must be explicit (11.1 first, then descending).
private static readonly int[] FeatureLevels = { 0xB100, 0xB000, 0xA100, 0xA000, 0x9300, 0x9200, 0x9100 };
private static IDirect3DDevice? _sharedDevice;
private static readonly object Gate = new();
public static IDirect3DDevice CreateDevice()
{
if (_sharedDevice != null) return _sharedDevice;
lock (Gate)
{
return _sharedDevice ??= CreateDeviceCore();
}
}
private static IDirect3DDevice CreateDeviceCore()
{
var hr = D3D11CreateDevice(IntPtr.Zero, DriverTypeHardware, IntPtr.Zero, D3D11CreateDeviceBgraSupport,
FeatureLevels, (uint)FeatureLevels.Length, D3D11SdkVersion, out var devicePtr, out _, out var contextPtr);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr)!;
try
{
var dxgiGuid = new Guid("54EC77FA-1377-44E6-8C32-88FD5F44C84C");
var qiHr = Marshal.QueryInterface(devicePtr, ref dxgiGuid, out var dxgiDevice);
if (qiHr != 0)
throw Marshal.GetExceptionForHR(qiHr)!;
try
{
hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice, out var winrtDevice);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr)!;
return MarshalInterface<IDirect3DDevice>.FromAbi(winrtDevice);
}
finally
{
Marshal.Release(dxgiDevice);
}
}
finally
{
Marshal.Release(devicePtr);
Marshal.Release(contextPtr);
}
}
[DllImport("d3d11.dll")]
private static extern int D3D11CreateDevice(IntPtr pAdapter, int driverType, IntPtr software,
uint flags, int[]? featureLevels, uint featureLevelsCount, uint sdkVersion,
out IntPtr device, out int featureLevel, out IntPtr immediateContext);
[DllImport("d3d11.dll", ExactSpelling = true)]
private static extern int CreateDirect3D11DeviceFromDXGIDevice(IntPtr dxgiDevice, out IntPtr graphicsDevice);
}
+21
View File
@@ -0,0 +1,21 @@
namespace ytLive.Services;
/// <summary>
/// Identifies which monitor holds a full-screen window (a game/app running
/// borderless or exclusive fullscreen). Seam so ScreenCaptureManager is
/// testable without Win32 interop.
/// </summary>
public interface IFullScreenDetector
{
/// <summary>
/// The index of the monitor a full-screen foreground window covers, or null
/// when the foreground window is windowed, missing, or one of our own.
/// </summary>
int? GetForegroundFullScreenMonitorIndex();
/// <summary>Index of the primary (taskbar-hosting) monitor.</summary>
int PrimaryMonitorIndex();
/// <summary>All monitors in capture-key order (enumeration order).</summary>
IReadOnlyList<DisplayInfo> GetDisplays();
}
+15
View File
@@ -0,0 +1,15 @@
namespace ytLive.Services;
/// <summary>
/// A running screen-capture source for one target key ("monitor:&lt;n&gt;" or
/// "window:&lt;hwnd&gt;"). Raises normalized BGRA frames from a worker thread;
/// callers must marshal to the UI thread. Seam so ScreenCaptureManager is
/// testable without WinRT.
/// </summary>
public interface IScreenCaptureSource
{
string Key { get; }
event Action<VideoFrame>? FrameAvailable;
Task StartAsync();
Task StopAsync();
}
+76 -7
View File
@@ -40,6 +40,7 @@ public class LayoutStore : IDisposable
Name TEXT NOT NULL, Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0, IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0, IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackdrop INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0 SortOrder INTEGER NOT NULL DEFAULT 0
); );
""", """,
@@ -69,6 +70,8 @@ public class LayoutStore : IDisposable
DeviceId TEXT, DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional', ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0, IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackdrop INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0 SortOrder INTEGER NOT NULL DEFAULT 0
); );
""", """,
@@ -110,11 +113,12 @@ public class LayoutStore : IDisposable
} }
MigrateSourceTable(); MigrateSourceTable();
MigrateWebcamConfigTable(); MigrateWebcamConfigTable();
MigrateSceneTable();
if (GetUserVersion() < 3) if (GetUserVersion() < 3)
MigrateToV3(); MigrateToV3();
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "PRAGMA user_version = 4;"; cmd.CommandText = "PRAGMA user_version = 6;";
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
} }
@@ -153,6 +157,20 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;"; cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
if (!columns.Contains("IsBackdrop"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsBackdrop INTEGER NOT NULL DEFAULT 0;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("CaptureKey"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
cmd.ExecuteNonQuery();
}
} }
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect, // v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
@@ -260,6 +278,47 @@ public class LayoutStore : IDisposable
tx.Commit(); tx.Commit();
} }
// v5 → v6: Scene gains HasBackdrop. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
// non-Live canonical scene off and drops their backdrop sources — the fix for
// DBs saved before the Live-only policy. MainViewModel.EnforceBackdropPolicy
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
var columns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA table_info(Scene);";
using var reader = cmd.ExecuteReader();
while (reader.Read())
columns.Add(reader.GetString(1));
}
if (columns.Contains("HasBackdrop")) return;
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "ALTER TABLE Scene ADD COLUMN HasBackdrop INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
const string noBackdrop = "('starting', 'brb', 'chat', 'ending')";
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"UPDATE Scene SET HasBackdrop = 0 WHERE LOWER(TRIM(Name)) IN {noBackdrop};";
cmd.ExecuteNonQuery();
}
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = $"""
DELETE FROM Source
WHERE IsBackdrop = 1 AND SceneId IN
(SELECT Id FROM Scene WHERE LOWER(TRIM(Name)) IN {noBackdrop});
""";
cmd.ExecuteNonQuery();
}
}
public List<Scene> Load() public List<Scene> Load()
{ {
Webcam = null; Webcam = null;
@@ -269,7 +328,7 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene FROM Scene ORDER BY SortOrder"; cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackdrop FROM Scene ORDER BY SortOrder";
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
while (reader.Read()) while (reader.Read())
{ {
@@ -279,6 +338,7 @@ public class LayoutStore : IDisposable
Name = reader.GetString(1), Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0, IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0, IsChatScene = reader.GetInt32(3) != 0,
HasBackdrop = reader.GetInt32(4) != 0,
}); });
} }
} }
@@ -302,7 +362,8 @@ 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, ClipShape, IsMirrored X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackdrop, CaptureKey
FROM Source ORDER BY SortOrder FROM Source ORDER BY SortOrder
"""; """;
using var reader = cmd.ExecuteReader(); using var reader = cmd.ExecuteReader();
@@ -324,6 +385,8 @@ public class LayoutStore : IDisposable
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11), MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional, ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0, IsMirrored = reader.GetInt32(13) != 0,
IsBackdrop = reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
}; };
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>();
@@ -412,14 +475,15 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand()) using (var cmd = _connection.CreateCommand())
{ {
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder) INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $sort) VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text); var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer); var hiddenP = cmd.Parameters.Add("$isHidden", SqliteType.Integer);
var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer); var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer);
var backdropP = cmd.Parameters.Add("$hasBackdrop", SqliteType.Integer);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var sort = 0; var sort = 0;
@@ -429,6 +493,7 @@ public class LayoutStore : IDisposable
nameP.Value = scene.Name; nameP.Value = scene.Name;
hiddenP.Value = scene.IsHidden ? 1 : 0; hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0; chatP.Value = scene.IsChatScene ? 1 : 0;
backdropP.Value = scene.HasBackdrop ? 1 : 0;
sortP.Value = sort++; sortP.Value = sort++;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
@@ -439,10 +504,10 @@ 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, X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, SortOrder) ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled, VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor, $x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $sort) $clip, $mirrored, $isBackdrop, $captureKey, $sort)
"""; """;
cmd.Transaction = tx; cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text); var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -459,6 +524,8 @@ public class LayoutStore : IDisposable
var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer); var monitorP = cmd.Parameters.Add("$monitor", SqliteType.Integer);
var clipP = cmd.Parameters.Add("$clip", SqliteType.Text); var clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer); var mirroredP = cmd.Parameters.Add("$mirrored", SqliteType.Integer);
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
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)
@@ -481,6 +548,8 @@ public class LayoutStore : IDisposable
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value; monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString(); clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0; mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
sortP.Value = sort++; sortP.Value = sort++;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
} }
+224
View File
@@ -0,0 +1,224 @@
using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Threading.Tasks;
using Windows.Graphics;
using Windows.Graphics.Capture;
using Windows.Graphics.DirectX;
using Windows.Graphics.DirectX.Direct3D11;
using Windows.Graphics.Imaging;
using ytLive.Helpers;
namespace ytLive.Services;
/// <summary>
/// A live screen capture for one target key ("monitor:&lt;n&gt;" or
/// "window:&lt;hwnd&gt;"). Owns the GraphicsCaptureItem, a free-threaded
/// Direct3D11CaptureFramePool and the capture session; frames are converted on
/// the capture worker thread from the GPU surface to a CPU BGRA VideoFrame.
/// Known OS limits: DRM content captures as black frames; capture pauses while
/// the app is minimized (the frame pool simply stops delivering frames).
/// </summary>
public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
{
private readonly object _gate = new();
private GraphicsCaptureItem _item;
private Direct3D11CaptureFramePool? _framePool;
private GraphicsCaptureSession? _session;
private SizeInt32 _poolSize;
private bool _started;
private bool _framePending;
private DateTime _lastErrorLog = DateTime.MinValue;
// The composition master frame (see ai.md "Resolution tiers"): the backdrop
// is an input layer, so we never hold a CPU frame bigger than the master.
private const int MaxBackdropWidth = 1920;
private const int MaxBackdropHeight = 1080;
// A failing conversion must not re-flood the log at frame rate.
private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5);
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
internal ScreenCaptureFrameSource(string key, GraphicsCaptureItem item)
{
Key = key;
_item = item;
}
/// <summary>Wraps an item chosen through the OS GraphicsCapturePicker.</summary>
public static ScreenCaptureFrameSource CreateForPicker(string key, GraphicsCaptureItem item)
=> new(key, item);
public static ScreenCaptureFrameSource CreateForMonitor(int monitorIndex)
{
var hmonitor = Win32FullScreenDetector.GetMonitorHandle(monitorIndex);
if (hmonitor == IntPtr.Zero)
throw new InvalidOperationException($"Monitor {monitorIndex} is not connected");
var item = CaptureInterop.CreateForMonitor(hmonitor);
return new ScreenCaptureFrameSource($"monitor:{monitorIndex}", item);
}
public static ScreenCaptureFrameSource CreateForWindow(IntPtr hwnd)
{
var item = CaptureInterop.CreateForWindow(hwnd);
return new ScreenCaptureFrameSource($"window:0x{hwnd.ToInt64():X}", item);
}
public Task StartAsync()
{
lock (_gate)
{
if (_started) return Task.CompletedTask;
var item = _item;
var device = Direct3D11Helper.CreateDevice();
var framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(
device, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size);
_poolSize = item.Size;
var session = framePool.CreateCaptureSession(item);
framePool.FrameArrived += OnFrameArrived;
session.StartCapture();
_framePool = framePool;
_session = session;
_started = true;
}
return Task.CompletedTask;
}
public Task StopAsync()
{
lock (_gate)
{
_started = false;
_framePending = false;
if (_framePool != null)
_framePool.FrameArrived -= OnFrameArrived;
_session?.Dispose();
_session = null;
_framePool?.Dispose();
_framePool = null;
}
return Task.CompletedTask;
}
private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args)
{
var frame = sender.TryGetNextFrame();
if (frame == null) return;
lock (_gate)
{
if (!_started)
{
frame.Dispose();
return;
}
}
if (frame.ContentSize.Width != _poolSize.Width || frame.ContentSize.Height != _poolSize.Height)
{
sender.Recreate(Direct3D11Helper.CreateDevice(),
DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, frame.ContentSize);
_poolSize = frame.ContentSize;
}
if (_framePending)
{
frame.Dispose();
return;
}
_framePending = true;
_ = ProcessFrameAsync(frame);
}
private async Task ProcessFrameAsync(Direct3D11CaptureFrame frame)
{
try
{
using (frame)
using (var softwareBitmap = await SoftwareBitmap.CreateCopyFromSurfaceAsync(
frame.Surface, BitmapAlphaMode.Ignore))
{
FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap));
}
}
catch (Exception ex)
{
var now = DateTime.UtcNow;
if (now - _lastErrorLog >= ErrorLogThrottle)
{
_lastErrorLog = now;
AppLog.Write($"ScreenCaptureFrameSource: frame conversion failed: {ex.Message}");
}
}
finally
{
_framePending = false;
}
}
private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap)
{
var sw = bitmap.PixelWidth;
var sh = bitmap.PixelHeight;
using var buffer = bitmap.LockBuffer(BitmapBufferAccessMode.Read);
using var reference = buffer.CreateReference();
if (!WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var data, out var capacity))
throw new InvalidOperationException("Could not access the frame buffer.");
var srcStride = sw * 4;
var count = (int)Math.Min(capacity, (uint)(sh * srcStride));
// The master frame is 1920×1080; a larger monitor is scaled down here so
// the CPU never holds a frame above the master (the rendered layer fills
// the frame with UniformToFill regardless).
if (sw > MaxBackdropWidth || sh > MaxBackdropHeight)
{
var scale = Math.Min(MaxBackdropWidth / (double)sw, MaxBackdropHeight / (double)sh);
var dw = Math.Max(1, (int)(sw * scale));
var dh = Math.Max(1, (int)(sh * scale));
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh));
}
var pixels = new byte[count];
Marshal.Copy(data, pixels, 0, pixels.Length);
return new VideoFrame(sw, sh, pixels);
}
// Bilinear downscale to the master frame. Reads each source row pair through
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh)
{
var row0 = new byte[srcStride];
var row1 = new byte[srcStride];
var dst = new byte[dw * dh * 4];
var xs = sw / (double)dw;
var ys = sh / (double)dh;
for (var y = 0; y < dh; y++)
{
var sy = Math.Min(sh - 1, (int)(y * ys));
var sy1 = Math.Min(sh - 1, sy + 1);
var fy = (y * ys) - sy;
Marshal.Copy(IntPtr.Add(src, sy * srcStride), row0, 0, srcStride);
Marshal.Copy(IntPtr.Add(src, sy1 * srcStride), row1, 0, srcStride);
var dRow = y * dw * 4;
for (var x = 0; x < dw; x++)
{
var sx = Math.Min(sw - 1, (int)(x * xs));
var sx1 = Math.Min(sw - 1, sx + 1);
var fx = (x * xs) - sx;
for (var c = 0; c < 4; c++)
{
var i0 = sx * 4 + c;
var i1 = sx1 * 4 + c;
var top = row0[i0] + (row0[i1] - row0[i0]) * fx;
var bottom = row1[i0] + (row1[i1] - row1[i0]) * fx;
dst[dRow + x * 4 + c] = (byte)(top + (bottom - top) * fy);
}
}
}
return dst;
}
}
+246
View File
@@ -0,0 +1,246 @@
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 screen-capture sessions app-wide, refcounted by target key
/// ("monitor:&lt;n&gt;", "window:&lt;hwnd&gt;", "picker:&lt;...&gt;"). One target =
/// 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). Mirrors CameraManager.
/// </summary>
public sealed class ScreenCaptureManager : IDisposable
{
private sealed class CaptureSession
{
public string Key { get; }
public IScreenCaptureSource Source { get; }
public Action<VideoFrame>? FrameHandler;
public int RefCount;
public bool Started;
public WriteableBitmap? PreviewBitmap;
public VideoFrame? LatestFrame;
public bool FramePending;
public CaptureSession(string key, IScreenCaptureSource source)
{
Key = key;
Source = source;
RefCount = 1;
}
}
private readonly Func<string, IScreenCaptureSource?> _sourceFactory;
private readonly Dispatcher? _uiDispatcher;
private readonly Dictionary<string, CaptureSession> _sessions = new();
private readonly object _gate = new();
/// <summary>Raised on the UI thread when a capture's shared preview bitmap is first created.</summary>
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
/// <summary>Raised when a capture cannot be created or started (monitor gone, access denied, no target).</summary>
public event Action<string, string>? CaptureFailed;
public ScreenCaptureManager(Func<string, IScreenCaptureSource?> sourceFactory, Dispatcher? uiDispatcher = null)
{
_sourceFactory = sourceFactory;
_uiDispatcher = uiDispatcher;
}
/// <summary>Increments the refcount for a target key, starting capture the first time.</summary>
public async Task<bool> AcquireAsync(string key)
{
if (string.IsNullOrWhiteSpace(key)) return false;
CaptureSession session;
bool shouldStart;
lock (_gate)
{
if (_sessions.TryGetValue(key, out var existing))
{
existing.RefCount++;
shouldStart = false;
session = existing;
}
else
{
IScreenCaptureSource? source;
try
{
source = _sourceFactory(key);
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: creating capture '{key}' failed: {ex.Message}");
CaptureFailed?.Invoke(key, ex.Message);
return false;
}
if (source == null)
{
CaptureFailed?.Invoke(key, "No capture target for this key");
return false;
}
session = new CaptureSession(key, source);
session.FrameHandler = frame => OnFrameAvailable(session, frame);
session.Source.FrameAvailable += session.FrameHandler;
_sessions[key] = 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(key);
session.Source.FrameAvailable -= session.FrameHandler;
AppLog.Write($"ScreenCaptureManager: failed to start capture '{key}': {ex.Message}");
CaptureFailed?.Invoke(key, 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 key)
{
CaptureSession? toStop = null;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out var session)) return;
if (--session.RefCount > 0) return;
_sessions.Remove(key);
toStop = session;
}
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
/// <summary>
/// Releases a target unconditionally (every ref), regardless of how many
/// scenes hold it. Used on capture re-designation and layout reloads where
/// the old key's refcount isn't known after the scenes are replaced.
/// </summary>
public async Task ReleaseAllAsync(string key)
{
CaptureSession? toStop = null;
lock (_gate)
{
if (!_sessions.TryGetValue(key, out var session)) return;
session.RefCount = 0;
_sessions.Remove(key);
toStop = session;
}
if (toStop == null) return;
toStop.Source.FrameAvailable -= toStop.FrameHandler;
await SafeStopAsync(toStop.Source);
toStop.PreviewBitmap = null;
}
public void Dispose()
{
List<CaptureSession> sessions;
lock (_gate)
{
sessions = new List<CaptureSession>(_sessions.Values);
_sessions.Clear();
}
foreach (var session in sessions)
{
session.Source.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(session.Source);
}
}
private static async Task SafeStopAsync(IScreenCaptureSource source)
{
try
{
await source.StopAsync();
}
catch (Exception ex)
{
AppLog.Write($"ScreenCaptureManager: stopping capture failed: {ex.Message}");
}
}
private bool TryGetActiveSession(CaptureSession session)
{
lock (_gate)
return _sessions.TryGetValue(session.Key, out var current) && ReferenceEquals(current, session);
}
private void OnFrameAvailable(CaptureSession 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(CaptureSession 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.Key, bitmap);
}
private void CopyFrame(CaptureSession 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);
}
}
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Windows.Graphics.Capture;
namespace ytLive.Services;
/// <summary>
/// Turns a capture key ("monitor:&lt;n&gt;", "window:&lt;hwnd&gt;", "picker:&lt;...&gt;")
/// into a live <see cref="IScreenCaptureSource"/>, and hosts the OS
/// GraphicsCapturePicker used by "Change Capture…". Picker picks are transient:
/// the picked GraphicsCaptureItem has no HWND/monitor identity, so a
/// "picker:" key resolves only within the current session and re-detection
/// takes over after a reload (documented v1 limit).
/// </summary>
public sealed class ScreenCaptureSourceFactory
{
private readonly Func<IntPtr> _ownerHwndProvider;
private readonly Dictionary<string, GraphicsCaptureItem> _picked = new();
private readonly object _gate = new();
public ScreenCaptureSourceFactory(Func<IntPtr> ownerHwndProvider)
{
_ownerHwndProvider = ownerHwndProvider;
}
/// <summary>Creates the source for a persisted key, or null when it can't be resolved.</summary>
public IScreenCaptureSource? Resolve(string key)
{
if (key.StartsWith("monitor:", StringComparison.OrdinalIgnoreCase))
{
var indexText = key.AsSpan("monitor:".Length);
if (int.TryParse(indexText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var index))
return ScreenCaptureFrameSource.CreateForMonitor(index);
return null;
}
if (key.StartsWith("window:", StringComparison.OrdinalIgnoreCase))
{
var hexText = key.AsSpan("window:".Length);
if (long.TryParse(hexText, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hwnd))
return ScreenCaptureFrameSource.CreateForWindow(new IntPtr(hwnd));
return null;
}
if (key.StartsWith("picker:", StringComparison.OrdinalIgnoreCase))
{
lock (_gate)
return _picked.TryGetValue(key, out var item)
? ScreenCaptureFrameSource.CreateForPicker(key, item)
: null;
}
return null;
}
/// <summary>
/// Shows the OS capture picker (must run on the UI thread, owner window set
/// via IInitializeWithWindow). Returns the session key, or null if cancelled.
/// </summary>
public async Task<string?> PickAsync()
{
var picker = new GraphicsCapturePicker();
CaptureInterop.SetWindowOwner(picker, _ownerHwndProvider());
var item = await picker.PickSingleItemAsync();
if (item == null) return null;
var key = $"picker:{item.DisplayName}";
lock (_gate)
_picked[key] = item;
return key;
}
}
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Windows.Interop;
namespace ytLive.Services;
/// <summary>A physical display: its capture-key index, geometry, and primary flag.</summary>
public sealed record DisplayInfo(int Index, string Name, int Width, int Height, int X, int Y, bool IsPrimary)
{
public string Label
=> IsPrimary ? $"{Name} — {Width}×{Height} (primary)" : $"{Name} — {Width}×{Height}";
}
/// <summary>
/// Win32 detection: <c>GetForegroundWindow</c> + <c>DwmGetWindowAttribute</c>
/// (DWMWA_EXTENDED_FRAME_BOUNDS) + <c>MonitorFromWindow</c> + <c>GetMonitorInfo</c>.
/// A foreground window whose extended frame bounds cover an entire monitor is
/// treated as a full-screen game/app on that monitor's index (monitor order =
/// <c>EnumDisplayMonitors</c> enumeration order, the same order the capture
/// factory uses to map index → HMONITOR). Windows of our own process are excluded.
/// DRM-protected content captures as black frames — a documented OS limit.
/// </summary>
public sealed class Win32FullScreenDetector : IFullScreenDetector
{
private const int DwmwaExtendedFrameBounds = 9;
private const uint MonitorDefaultToNearest = 2;
private const uint MonitorInfoFPrimary = 0x00000001;
public int? GetForegroundFullScreenMonitorIndex()
{
var hwnd = GetForegroundWindow();
if (hwnd == IntPtr.Zero || IsOwnWindow(hwnd)) return null;
if (!TryGetExtendedFrameBounds(hwnd, out var bounds)) return null;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
if (monitor == IntPtr.Zero || !TryGetMonitorInfo(monitor, out var info)) return null;
var r = info.RcMonitor;
var coversMonitor = bounds.Left <= r.Left && bounds.Top <= r.Top &&
bounds.Right >= r.Right && bounds.Bottom >= r.Bottom;
if (!coversMonitor) return null;
return MonitorIndex(monitor);
}
private static bool IsOwnWindow(IntPtr hwnd)
{
_ = GetWindowThreadProcessId(hwnd, out var pid);
return pid == Environment.ProcessId;
}
private static bool TryGetExtendedFrameBounds(IntPtr hwnd, out Win32Rect bounds)
{
bounds = default;
return DwmGetWindowAttribute(hwnd, DwmwaExtendedFrameBounds, ref bounds, Marshal.SizeOf<Win32Rect>()) == 0;
}
private static bool TryGetMonitorInfo(IntPtr monitor, out MonitorInfo info)
{
info = new MonitorInfo { CbSize = Marshal.SizeOf<MonitorInfo>() };
return GetMonitorInfo(monitor, ref info);
}
private static int? MonitorIndex(IntPtr monitor)
{
var handles = EnumerateMonitors();
var index = handles.IndexOf(monitor);
return index >= 0 ? index : null;
}
public int PrimaryMonitorIndex()
=> GetDisplays().FirstOrDefault(d => d.IsPrimary)?.Index ?? 0;
public IReadOnlyList<DisplayInfo> GetDisplays()
{
var result = new List<DisplayInfo>();
var handles = EnumerateMonitors();
for (var i = 0; i < handles.Count; i++)
{
if (!TryGetMonitorInfo(handles[i], out var info)) continue;
var name = GetDisplayName(handles[i]);
result.Add(new DisplayInfo(
i,
name,
info.RcMonitor.Right - info.RcMonitor.Left,
info.RcMonitor.Bottom - info.RcMonitor.Top,
info.RcMonitor.Left,
info.RcMonitor.Top,
(info.DwFlags & MonitorInfoFPrimary) != 0));
}
return result;
}
private static string GetDisplayName(IntPtr monitor)
{
var info = new MonitorInfoEx { CbSize = Marshal.SizeOf<MonitorInfoEx>() };
if (!GetMonitorInfo(monitor, ref info)) return $"Display {monitor}";
var deviceName = info.SzDevice.TrimEnd('\0');
var dev = new DisplayDevice { Cb = Marshal.SizeOf<DisplayDevice>() };
if (!EnumDisplayDevices(deviceName, 0, ref dev, 0)) return deviceName;
var friendly = dev.DeviceString.TrimEnd('\0');
return string.IsNullOrWhiteSpace(friendly) ? deviceName : friendly;
}
public static IntPtr GetMonitorHandle(int index)
{
var handles = EnumerateMonitors();
return index >= 0 && index < handles.Count ? handles[index] : IntPtr.Zero;
}
private static List<IntPtr> EnumerateMonitors()
{
var handles = new List<IntPtr>();
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero,
(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData) =>
{
handles.Add(hMonitor);
return true;
}, IntPtr.Zero);
return handles;
}
[StructLayout(LayoutKind.Sequential)]
private struct Win32Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int CbSize;
public Win32Rect RcMonitor;
public Win32Rect RcWork;
public uint DwFlags;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfoEx
{
public int CbSize;
public Win32Rect RcMonitor;
public Win32Rect RcWork;
public uint DwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string SzDevice;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DisplayDevice
{
public int Cb;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DeviceName;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceString;
public uint StateFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceId;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
public string DeviceKey;
}
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfo lpmi);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MonitorInfoEx lpmi);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DisplayDevice lpDisplayDevice, uint dwFlags);
[DllImport("user32.dll")]
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip,
MonitorEnumProc lpfnEnum, IntPtr dwData);
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
[DllImport("dwmapi.dll")]
private static extern int DwmGetWindowAttribute(IntPtr hwnd, int dwAttribute, ref Win32Rect pvAttribute, int cbAttribute);
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdcMonitor, ref Win32Rect lprcMonitor, IntPtr dwData);
}
+10 -2
View File
@@ -8,14 +8,22 @@ 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; schema `user_version` 4 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore) | | `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` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load) |
| `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 | | `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 | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | | `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 | | `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` | | `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 | | `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. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload) | | `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |
| `Direct3D11Helper.cs` | The one shared `IDirect3DDevice` per process: P/Invoke `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first feature array) → QI `IDXGIDevice` → the WinRT interop export `CreateDirect3D11DeviceFromDXGIDevice``MarshalInterface<IDirect3DDevice>.FromAbi`. CsWinRT does **not** project `Windows.Graphics.Direct3D11.Direct3D11Helper`, so this hand-rolls it. Do **not** revert to QI-for-`IDirect3DDxgiInterfaceAccess` — the device no longer exposes it on newer Windows (E_NOINTERFACE on build 26200) |
| `CaptureInterop.cs` | ComImport bridges the WinRT-projection gap: `IGraphicsCaptureItemInterop` (`CreateForMonitor`/`CreateForWindow`), `IInitializeWithWindow` (`GraphicsCapturePicker` owner window so the OS picker shows) |
| `ScreenCaptureFrameSource.cs` | One `Direct3D11CaptureFramePool` (free-threaded, 2 buffers) + session per target; frames → `SoftwareBitmap.CreateCopyFromSurfaceAsync` (BGRA, alpha ignored) → `VideoFrame`, bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe` (CsWinRT-safe — the `IMemoryBufferByteAccess` ComImport cast fails on every frame and is gone). Surfaces > 1920×1080 downscaled bilinearly to the master; conversion failures logged ≤ once/5 s. DRM content = black frames (OS limit). `CreateForMonitor`/`CreateForWindow`/`CreateForPicker` |
| `ScreenCaptureManager.cs` | Screen-capture ownership mirroring `CameraManager`: refcounted by target key, one shared `WriteableBitmap`, dispatcher-coalesced latest-frame copies; `PreviewBitmapChanged`/`CaptureFailed` events; `ReleaseAllAsync` used on re-designation |
| `ScreenCaptureSourceFactory.cs` | `Resolve(key)` parses `monitor:<n>` / `window:<hwnd>` / `picker:<name>` into a source; `PickAsync()` shows the OS `GraphicsCapturePicker` and returns the `picker:` key (transient — a reload falls back to auto-detection) |
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).
+14 -8
View File
@@ -159,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 — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; screen capture, compositing, encoding pending ### Status: 🔶 In progress — milestone 1 (webcam) shipped; **schema v3 (Ship Branch A) shipped**: multi-scene webcam (singleton `Webcam` + per-scene `WebcamSceneConfig`), right-click border/context menu, static OSB-style borders, 50%-per-dimension webcam size cap, device-swap (`ReleaseAllAsync`); **schema v4**: round→rect restore persisted (`WebcamSceneConfig.RectWidth`/`RectHeight`) + one-time legacy-square 16:9 heal on load — 25 tests passing; **screen backdrop (ship task #1) shipped**: live desktop/game capture as a permanent, non-deletable bottom layer (`Source.IsBackdrop`, schema v5), auto-detecting the full-screen game at launch/focus (else the **primary display** — never assumed monitor 0) via `Win32FullScreenDetector` (now with `GetDisplays()`/`PrimaryMonitorIndex()` for the in-app display picker), content re-designated via the OS `GraphicsCapturePicker` ("Change Capture…") or the in-app "Capture Display" submenu, refcounted/shared capture sessions in `ScreenCaptureManager` mirroring `CameraManager` — 45 tests passing; **schema v6**: `Scene.HasBackdrop`, now **Live-only by policy** — the backdrop is enforced by scene name on every load (`EnforceBackdropPolicy`: Starting/BRB/Chat/Ending never carry one; the one-time v5→v6 backfill covers all four), the scene context-menu "Backdrop" checkbox is gone (policy owns the flag), preview watermark hides when the backdrop renders, capture changed to `WindowsRuntimeMarshal.TryGetDataUnsafe` (the CsWinRT-safe frame-read) + downscale to the 1920×1080 master + 5s-throttled error logging (was flooding `startup.log` with 5 MB of cast errors and burning CPU), round webcam no longer re-rasterizes an `ImageBrush` every frame (Image + EllipseGeometry clip) — the live-mode stutter fix; **the five-scene catalog (`SceneCatalog`)**: Starting/Live/BRB/Chat/Ending is the product — work with less, never more; the (+) button only shows when a canonical scene is missing and re-adds it (its menu lists only the missing ones); **webcam-after-session-start fix**: a webcam added to a scene after the camera was already running (e.g. Chat) previously rendered a transparent container — `CameraManager.GetPreviewBitmap` + propagation in `AddWebcamToActiveSceneAsync`/`ReacquireWebcam` now hands the running shared frames to any newly added `WebcamSceneConfig` — 62 tests passing; the **Chat scene's webcam size cap** is raised from 50%-per-dimension (960×540) to half the screen AREA (~1358×764 @16:9, `MaxWebcamWidthFor`/`MaxWebcamHeightFor` keyed by canonical name) so the viewer sees the creator better — 65 tests passing; **"Add Webcam" always opens the camera picker** (deleting one scene's webcam then re-adding used to resurrect the old camera when another scene still used it — `SwapWebcamIdentityAsync` now swaps the app-wide identity if a different camera is chosen, same path as "Change Webcam…"); scenes/sources UI (add/reorder/rename, image + background overlays with move/resize/opacity/reuse) built; window capture, compositing, encoding pending
--- ---
@@ -178,10 +178,11 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
- **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming - **1080p60 @ 8 Mbps** (default — mainstream ceiling, GPU hardware-encoded so the gaming
machine never notices; upload headroom stays comfortable) machine never notices; upload headroom stays comfortable)
- Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier) - Vertical 1080×1920 @ 60fps @ 8 Mbps (9:16 phone tier)
- 1440p/4K = the paid unlock tiers (monetization), not the standard offering
The composition master is always 1920×1080; a tier is an output rect + target resolution The composition master is always 1920×1080; a tier is an output rect + target resolution
(see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master (see `ai.md` "Resolution tiers"). Vertical output = the centered 607×1080 crop of the master
scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect). scaled to 1080×1920 (semi-crop preview is already implemented; the encoder applies the same rect).
1080p60 is the ceiling by design — "if you want 1440 or 4K or 8K → OBS is your solution"; the app
targets the most mainstream creator, not power users.
Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required). Ladder is sculpted by a **cached probe** (IP-only TCP vs public ingest host; no auth required).
Quality is greyed out while live because the declared resolution can't change mid-stream — but Quality is greyed out while live because the declared resolution can't change mid-stream — but
with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls with `variable`, we can **auto step-down** bitrate/resolution on the fly with zero API calls
@@ -237,15 +238,20 @@ Preview shows the transition too (WYSIWYG). No wipes/slides/LUTs beyond the four
**Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active. **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active.
4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source 4. **Auto-save (invisible)** — ~1.5s debounce on scene add/remove/reorder/rename/hide, source
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, HasBackdrop, 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, ClipShape, IsMirrored, SortOrder) — X/Y/Width/Height/Opacity, MonitorIndex, DeviceId, ClipShape, IsMirrored, SortOrder) —
`user_version` **4** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton `user_version` **6** (v1 → v2 = `ALTER TABLE` adds the two webcam columns; v3 = singleton
`Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the `Webcam` + per-scene `WebcamSceneConfig`; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight` for the
round-to-rect restore). `WindowHandle` stays in-memory (per-session). Save = transactional round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey` for the live-capture
rewrite; orphaned assets pruned. backdrop; v6 = `Scene.HasBackdrop` — the backdrop is **Live-only by policy**
6. **Startup** — load the active file; seed the default five scenes (Starting/Live/BRB/Chat/Ending) (one-time backfill turns Starting/BRB/Chat/Ending off and drops their backdrop
only when the DB is empty. sources; `EnforceBackdropPolicy` re-normalizes every load). `WindowHandle` stays in-memory
(per-session). Save = transactional rewrite; orphaned assets pruned.
6. **Startup** — load the active file; seed the five canonical scenes
(Starting/Live/BRB/Chat/Ending, `SceneCatalog`) only when the DB is empty. The (+)
button re-adds a missing canonical scene and is hidden once all five are present;
adding beyond the five is rejected — work with less, never more.
### Status: ✅ Implemented ### Status: ✅ Implemented
+330 -45
View File
@@ -5,6 +5,7 @@ using System.Diagnostics;
using System.IO; using System.IO;
using System.Windows; using System.Windows;
using System.Windows.Input; using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media; using System.Windows.Media;
using System.Windows.Media.Imaging; using System.Windows.Media.Imaging;
using System.Windows.Threading; using System.Windows.Threading;
@@ -64,6 +65,17 @@ public class MainViewModel : ViewModelBase
private readonly CameraManager _cameraManager; private readonly CameraManager _cameraManager;
private Webcam? _webcam; private Webcam? _webcam;
// Screen backdrop: a permanent live capture (desktop/game) that every scene
// shows at the bottom layer. One shared capture session per key — the
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
private readonly IFullScreenDetector _fullScreenDetector;
private readonly ScreenCaptureManager _screenCaptureManager;
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
private int? _lastForegroundFullScreenMonitor;
private CancellationTokenSource? _deactivateCts;
private ImageSource? _backdropImage;
private HashSet<string> _liveCaptureKeys = new();
// 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".
@@ -78,6 +90,7 @@ public class MainViewModel : ViewModelBase
public ObservableCollection<Scene> Scenes { get; } = new(); public ObservableCollection<Scene> Scenes { get; } = new();
public ObservableCollection<ChatMessage> ChatMessages { get; } = new(); public ObservableCollection<ChatMessage> ChatMessages { get; } = new();
public ObservableCollection<DisplayInfo> Displays { get; } = new();
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" }; public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
public Scene? ActiveScene public Scene? ActiveScene
@@ -93,9 +106,11 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
OnPropertyChanged(nameof(CanChangeBackdrop));
OnPropertyChanged(nameof(CanAddWebcamToActiveScene)); OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
OnPropertyChanged(nameof(CanShowWebcamInActiveScene)); OnPropertyChanged(nameof(CanShowWebcamInActiveScene));
UpdateActiveBackground(); UpdateActiveBackground();
UpdateBackdropImage();
} }
} }
} }
@@ -106,6 +121,18 @@ public class MainViewModel : ViewModelBase
private set => SetProperty(ref _activeBackgroundImage, value); private set => SetProperty(ref _activeBackgroundImage, value);
} }
/// <summary>The active scene's live-capture backdrop frame (rendered below the
/// static background). Set from the shared capture bitmap as it arrives.</summary>
public ImageSource? BackdropImage
{
get => _backdropImage;
private set
{
if (SetProperty(ref _backdropImage, value))
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
}
}
public SceneElement? SelectedElement public SceneElement? SelectedElement
{ {
get => _selectedElement; get => _selectedElement;
@@ -166,9 +193,20 @@ public class MainViewModel : ViewModelBase
public bool ShowStartStream => IsOffline; public bool ShowStartStream => IsOffline;
public bool ShowChatInactiveMessage => !IsLive; public bool ShowChatInactiveMessage => !IsLive;
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0; public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Elements.Count == 0;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackdropImage == null;
public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0; public bool ShowSourcesEmptyHint => ActiveScene == null || ActiveScene.Elements.Count == 0;
/// <summary>The canonical scenes (SceneCatalog) not present right now — what the
/// "+" button may re-add. Work with less, never more.</summary>
public IEnumerable<string> MissingScenes
=> SceneCatalog.All.Where(canonical => Scenes.All(s => !SceneCatalog.Is(s.Name, canonical)));
/// <summary>Only show the "+" button when one of the five canonical scenes is missing.</summary>
public bool ShowAddScene => MissingScenes.Any();
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's backdrop.</summary>
public bool CanChangeBackdrop => ActiveScene?.HasBackdrop == true;
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the // Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately. // cadence timer is stopped and any active flash is hidden immediately.
public bool BrandFlashEnabled public bool BrandFlashEnabled
@@ -205,6 +243,12 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowPreviewPlaceholder)); OnPropertyChanged(nameof(ShowPreviewPlaceholder));
} }
private void UpdateBackdropImage()
{
var backdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
BackdropImage = backdrop?.DisplaySource;
}
public StreamHealth CurrentHealth public StreamHealth CurrentHealth
{ {
get => _currentHealth; get => _currentHealth;
@@ -330,15 +374,28 @@ public class MainViewModel : ViewModelBase
// Webcam size safeguard: no more than half the frame in any dimension // Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it // (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load. // (192x108). Enforced at resize and on layout load. The Chat scene is
// exempt from the per-dimension half — its webcam may take half the
// screen AREA (~1358x764 @16:9) so the viewer sees the creator better,
// matched by canonical name (SceneCatalog.IsChat).
public const double WebcamMaxWidth = 960; public const double WebcamMaxWidth = 960;
public const double WebcamMaxHeight = 540; public const double WebcamMaxHeight = 540;
public const double WebcamChatMaxWidth = 1358;
public const double WebcamChatMaxHeight = 764;
public const double WebcamMinWidth = MasterFrameWidth * 0.1; public const double WebcamMinWidth = MasterFrameWidth * 0.1;
public const double WebcamMinHeight = MasterFrameHeight * 0.1; public const double WebcamMinHeight = MasterFrameHeight * 0.1;
internal static void ClampWebcamToBounds(WebcamSceneConfig config) public static double MaxWebcamWidthFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxWidth : WebcamMaxWidth;
public static double MaxWebcamHeightFor(string? sceneName)
=> SceneCatalog.IsChat(sceneName) ? WebcamChatMaxHeight : WebcamMaxHeight;
internal static void ClampWebcamToBounds(WebcamSceneConfig config, string? sceneName)
{ {
var scale = Math.Min(WebcamMaxWidth / config.Width, WebcamMaxHeight / config.Height); var maxWidth = MaxWebcamWidthFor(sceneName);
var maxHeight = MaxWebcamHeightFor(sceneName);
var scale = Math.Min(maxWidth / config.Width, maxHeight / config.Height);
if (scale < 1) if (scale < 1)
{ {
config.Width = Math.Round(config.Width * scale); config.Width = Math.Round(config.Width * scale);
@@ -472,6 +529,9 @@ public class MainViewModel : ViewModelBase
public ICommand RemoveSourceCommand { get; } public ICommand RemoveSourceCommand { get; }
public ICommand ChangeWebcamCommand { get; } public ICommand ChangeWebcamCommand { get; }
public ICommand ShowWebcamCommand { get; } public ICommand ShowWebcamCommand { get; }
public ICommand ChangeCaptureCommand { get; }
public ICommand RefreshCaptureCommand { get; }
public ICommand SetBackdropDisplayCommand { get; }
public ICommand StartStreamCommand { get; } public ICommand StartStreamCommand { get; }
public ICommand EndStreamCommand { get; } public ICommand EndStreamCommand { get; }
public ICommand OpenSettingsCommand { get; } public ICommand OpenSettingsCommand { get; }
@@ -513,7 +573,7 @@ public class MainViewModel : ViewModelBase
Scenes.CollectionChanged += OnScenesChanged; Scenes.CollectionChanged += OnScenesChanged;
AddSceneCommand = new RelayCommand(_ => AddScene()); AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty));
EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene)); EditSceneCommand = new RelayCommand(scene => BeginEditScene(scene as Scene));
RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene)); RemoveSceneCommand = new RelayCommand(scene => RemoveScene(scene as Scene));
ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene)); ToggleSceneVisibilityCommand = new RelayCommand(scene => ToggleSceneVisibility(scene as Scene));
@@ -522,6 +582,9 @@ public class MainViewModel : ViewModelBase
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement)); RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam); ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene); ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInActiveScene(), _ => CanShowWebcamInActiveScene);
ChangeCaptureCommand = new RelayCommand(_ => _ = ChangeBackdropCaptureAsync());
RefreshCaptureCommand = new RelayCommand(_ => RefreshBackdropAutoCapture());
SetBackdropDisplayCommand = new RelayCommand(display => SetBackdropCapture(display as DisplayInfo));
OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings")); OpenSettingsCommand = new RelayCommand(_ => ShowOverlay(nameof(IsSettingsOpen), "App Settings"));
OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug")); OpenBugCommand = new RelayCommand(_ => ShowOverlay(nameof(IsBugOpen), "Report Bug"));
OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request")); OpenFeatureCommand = new RelayCommand(_ => ShowOverlay(nameof(IsFeatureOpen), "Feature Request"));
@@ -546,6 +609,18 @@ public class MainViewModel : ViewModelBase
System.Windows.Application.Current?.Dispatcher); System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged; _cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_fullScreenDetector = new Win32FullScreenDetector();
foreach (var display in _fullScreenDetector.GetDisplays())
Displays.Add(display);
_screenCaptureFactory = new ScreenCaptureSourceFactory(
() => new WindowInteropHelper(System.Windows.Application.Current?.MainWindow).Handle);
_screenCaptureManager = new ScreenCaptureManager(
_screenCaptureFactory.Resolve,
System.Windows.Application.Current?.Dispatcher);
_screenCaptureManager.PreviewBitmapChanged += OnScreenPreviewBitmapChanged;
_screenCaptureManager.CaptureFailed += (key, message) =>
AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}");
LoadLayout(); LoadLayout();
_ = LoadSavedSessionAsync(); _ = LoadSavedSessionAsync();
AppLog.Write("MainViewModel ctor end"); AppLog.Write("MainViewModel ctor end");
@@ -603,17 +678,18 @@ public class MainViewModel : ViewModelBase
Scenes.Add(scene); Scenes.Add(scene);
if (Scenes.Count == 0) if (Scenes.Count == 0)
{ foreach (var name in SceneCatalog.All)
AddScene("Starting"); AddScene(name);
AddScene("Live");
AddScene("BRB"); // The live backdrop belongs to Live only; a pre-policy DB may have
AddScene("Chat", isChatScene: true); // backdrops lingering in other scenes — drop them, then
AddScene("Ending"); // ReacquireScreenCaptures heals Live's.
} EnforceBackdropPolicy(Scenes);
foreach (var scene in Scenes) foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>()) foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
{ {
ClampWebcamToBounds(config); ClampWebcamToBounds(config, scene.Name);
HealLegacySquareRect(config); HealLegacySquareRect(config);
} }
AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded"); AppLog.Write($"LoadLayout: {Scenes.Count} scenes loaded");
@@ -624,7 +700,9 @@ public class MainViewModel : ViewModelBase
} }
ActiveScene = Scenes.FirstOrDefault(); ActiveScene = Scenes.FirstOrDefault();
UpdateActiveBackground(); UpdateActiveBackground();
UpdateBackdropImage();
ReacquireWebcam(); ReacquireWebcam();
ReacquireScreenCaptures();
ScheduleSave(); ScheduleSave();
AppLog.Write("LoadLayout end"); AppLog.Write("LoadLayout end");
} }
@@ -645,9 +723,16 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(CanAddWebcamToActiveScene)); OnPropertyChanged(nameof(CanAddWebcamToActiveScene));
if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return; if (_webcam == null || string.IsNullOrWhiteSpace(newDevice)) return;
if (previousDevice == newDevice) return; if (previousDevice != newDevice)
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>())) foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
_ = _cameraManager.AcquireAsync(newDevice); _ = _cameraManager.AcquireAsync(newDevice);
// A session already running (same device, or one that produced a first
// frame) has its shared bitmap; freshly loaded configs must adopt it here,
// because PreviewBitmapChanged never re-fires for an existing bitmap.
if (_cameraManager.GetPreviewBitmap(newDevice) is { } running)
foreach (var config in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
config.VideoImageSource = running;
} }
// CameraManager creates the shared WriteableBitmap on the UI thread at the // CameraManager creates the shared WriteableBitmap on the UI thread at the
@@ -660,11 +745,174 @@ public class MainViewModel : ViewModelBase
config.VideoImageSource = bitmap; config.VideoImageSource = bitmap;
} }
// ─── Screen backdrop capture (live desktop/game) ───
private const string MonitorKeyPrefix = "monitor:";
/// <summary>Every backdrop-enabled scene has exactly one backdrop, kept at index 0 (bottom layer).
/// Returns null for a scene with <see cref="Scene.HasBackdrop"/> = false.</summary>
internal static Source? EnsureBackdrop(Scene scene)
{
if (!scene.HasBackdrop) return null;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null) return backdrop;
backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
scene.Elements.Insert(0, backdrop);
return backdrop;
}
// The backdrop belongs to the Live scene alone. Runs after every layout
// load: the flag is normalized by scene name and any backdrop lingering in a
// non-Live scene (from a pre-policy DB) is removed. ReacquireScreenCaptures
// re-heals Live's backdrop right after.
internal static void EnforceBackdropPolicy(IEnumerable<Scene> scenes)
{
foreach (var scene in scenes)
{
scene.HasBackdrop = SceneCatalog.HasBackdrop(scene.Name);
if (scene.HasBackdrop) continue;
var backdrop = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (backdrop != null)
scene.Elements.Remove(backdrop);
}
}
private IEnumerable<Source> AllBackdrops()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackdrop));
// Full-screen game on its monitor, else the primary display. Called at launch
// (from ReacquireScreenCaptures, before the window steals focus) and on
// focus regain (RefreshBackdropAutoCapture).
private string ResolveAutoCaptureKey()
=> $"{MonitorKeyPrefix}{_fullScreenDetector.GetForegroundFullScreenMonitorIndex() ?? _fullScreenDetector.PrimaryMonitorIndex()}";
// After a layout load / file open: make sure every backdrop-enabled scene
// has a backdrop, give any backdrop without a persisted key the auto-detected
// one, then acquire one capture per unique backdrop key. ScreenCaptureManager
// refcounts by key, so every scene pointing at the same monitor shares one
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
foreach (var scene in Scenes)
EnsureBackdrop(scene);
var auto = ResolveAutoCaptureKey();
foreach (var backdrop in AllBackdrops())
if (string.IsNullOrWhiteSpace(backdrop.CaptureKey))
backdrop.CaptureKey = auto;
var keys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
// A layout reload replaces the scenes; stop captures that are no longer
// referenced (mirrors ReacquireWebcam's device swap).
foreach (var stale in _liveCaptureKeys.Except(keys))
_ = _screenCaptureManager.ReleaseAllAsync(stale);
_liveCaptureKeys = keys;
foreach (var key in keys)
_ = _screenCaptureManager.AcquireAsync(key);
}
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
// every backdrop pointing at that key picks it up.
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
{
var activeBackdrop = ActiveScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackdrop);
if (activeBackdrop?.CaptureKey == key)
BackdropImage = bitmap;
foreach (var backdrop in AllBackdrops().Where(b => b.CaptureKey == key))
backdrop.VideoImageSource = bitmap;
}
// One-shot foreground snapshot ~250ms after we lose focus, so the next
// Activated re-detect can see the full-screen game the user switched to.
// A single sample per deactivation — not a session listener or a poller.
public void NoteBackgroundWindow()
{
_deactivateCts?.Cancel();
_deactivateCts = new CancellationTokenSource();
var token = _deactivateCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(250, token);
var monitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
if (!token.IsCancellationRequested)
_lastForegroundFullScreenMonitor = monitor;
}
catch (OperationCanceledException) { }
});
}
// Re-runs full-screen detection at launch and when the app regains focus.
// A null detection (no full-screen foreground window — our app, the desktop,
// a normal window) leaves the current capture alone.
public void RefreshBackdropAutoCapture()
{
var detected = _lastForegroundFullScreenMonitor ?? _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
_lastForegroundFullScreenMonitor = null;
if (detected == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{detected}");
}
private async Task RedesignateBackdropAsync(string newKey)
{
var oldKeys = AllBackdrops()
.Select(b => b.CaptureKey!)
.Where(k => !string.IsNullOrWhiteSpace(k))
.ToHashSet();
foreach (var backdrop in AllBackdrops())
backdrop.CaptureKey = newKey;
foreach (var oldKey in oldKeys.Where(k => k != newKey))
await _screenCaptureManager.ReleaseAllAsync(oldKey);
if (!oldKeys.Contains(newKey))
await _screenCaptureManager.AcquireAsync(newKey);
_liveCaptureKeys = new HashSet<string> { newKey };
UpdateBackdropImage();
ScheduleSave();
}
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
// back to auto-detection.
public async Task ChangeBackdropCaptureAsync()
{
var key = await _screenCaptureFactory.PickAsync();
if (key == null) return;
await RedesignateBackdropAsync(key);
}
// In-app display picker: point every backdrop at a specific monitor.
private void SetBackdropCapture(DisplayInfo? display)
{
if (display == null) return;
_ = RedesignateBackdropAsync($"{MonitorKeyPrefix}{display.Index}");
}
public void Shutdown() public void Shutdown()
{ {
_saveDebounce?.Stop(); _saveDebounce?.Stop();
SaveLayoutNow(); SaveLayoutNow();
_cameraManager.Dispose(); _cameraManager.Dispose();
_screenCaptureManager.Dispose();
_layoutStore.Dispose(); _layoutStore.Dispose();
} }
@@ -722,6 +970,8 @@ public class MainViewModel : ViewModelBase
if (e.OldItems != null) if (e.OldItems != null)
foreach (Scene scene in e.OldItems) foreach (Scene scene in e.OldItems)
UnwireScene(scene); UnwireScene(scene);
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave(); ScheduleSave();
} }
@@ -738,7 +988,12 @@ public class MainViewModel : ViewModelBase
} }
private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnScenePropertyChanged(object? sender, PropertyChangedEventArgs e)
=> ScheduleSave(); {
// A rename can drop a canonical scene in/out of MissingScenes.
OnPropertyChanged(nameof(MissingScenes));
OnPropertyChanged(nameof(ShowAddScene));
ScheduleSave();
}
private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e) private void OnElementsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{ {
@@ -766,9 +1021,19 @@ public class MainViewModel : ViewModelBase
_saveDebounce.Start(); _saveDebounce.Start();
} }
private void AddScene(string? name = null, bool isChatScene = false) // Adds a canonical scene by name — only when it's actually missing. The
// backdrop flag follows the policy (Live yes, everyone else no).
private void AddScene(string name)
{ {
var scene = new Scene { Name = name ?? $"New Scene {Scenes.Count + 1}", IsChatScene = isChatScene }; if (!SceneCatalog.IsCanonical(name)) return;
if (Scenes.Any(s => SceneCatalog.Is(s.Name, name))) return;
var scene = new Scene
{
Name = name.Trim(),
IsChatScene = SceneCatalog.IsChat(name),
HasBackdrop = SceneCatalog.HasBackdrop(name),
};
EnsureBackdrop(scene);
Scenes.Add(scene); Scenes.Add(scene);
ActiveScene = scene; ActiveScene = scene;
} }
@@ -855,25 +1120,33 @@ public class MainViewModel : ViewModelBase
UpdateActiveBackground(); UpdateActiveBackground();
} }
// Adds the webcam to the active scene. The camera is picked once app-wide // Adds the webcam to the active scene. The creator ALWAYS picks from the
// (first add); afterwards "Add Webcam" just places the existing webcam here // cameras Windows has registered — never silently resurrects the previous
// at the default spot — each scene's config is independent (webcam.{scene}.config). // camera (which is what happened after deleting one scene's webcam while
// another scene still used it). Picking a different camera than the current
// app-wide one swaps it everywhere, so the single-identity model stays honest;
// each scene's placement config is independent (webcam.{scene}.config).
private async Task AddWebcamToActiveSceneAsync() private async Task AddWebcamToActiveSceneAsync()
{ {
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || scene.WebcamConfig != null) return; if (scene == null || scene.WebcamConfig != null) return;
if (_webcam == null)
{
var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator))
{ {
Owner = Application.Current.MainWindow Owner = Application.Current.MainWindow
}; };
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
var device = dialog.PickedDevice; var device = dialog.PickedDevice;
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanChangeWebcam));
} }
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
}
var config = new WebcamSceneConfig var config = new WebcamSceneConfig
{ {
@@ -891,6 +1164,12 @@ public class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowSourcesEmptyHint)); OnPropertyChanged(nameof(ShowSourcesEmptyHint));
UpdateActiveBackground(); UpdateActiveBackground();
// PreviewBitmapChanged fires only on the camera's first frame, so a config
// added while the session is already running must pick up the shared bitmap
// directly (it's written in place from then on).
if (_cameraManager.GetPreviewBitmap(_webcam.DeviceId) is { } running)
config.VideoImageSource = running;
var started = await _cameraManager.AcquireAsync(_webcam.DeviceId); var started = await _cameraManager.AcquireAsync(_webcam.DeviceId);
if (!started) if (!started)
{ {
@@ -902,7 +1181,32 @@ public class MainViewModel : ViewModelBase
// Swaps the device on the app-wide webcam identity. The old device is stopped // Swaps the device on the app-wide webcam identity. The old device is stopped
// unconditionally; each scene that uses the webcam re-acquires the new one so // unconditionally; each scene that uses the webcam re-acquires the new one so
// the per-config refcount stays honest. // the per-config refcount stays honest. Only called when the device differs.
private async Task SwapWebcamIdentityAsync(CameraDeviceInfo device)
{
var oldDevice = _webcam!.DeviceId;
_webcam.DeviceId = device.Id;
_webcam.Name = device.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice) && oldDevice != device.Id)
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
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);
break;
}
}
}
// "Change Webcam…" from the webcam's context menu: picker, then swap the
// app-wide identity if a different device was chosen.
private async Task ChangeWebcamAsync() private async Task ChangeWebcamAsync()
{ {
if (_webcam == null) return; if (_webcam == null) return;
@@ -912,29 +1216,9 @@ public class MainViewModel : ViewModelBase
Owner = Application.Current.MainWindow Owner = Application.Current.MainWindow
}; };
if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return;
if (dialog.PickedDevice.Id == _webcam.DeviceId) return;
var oldDevice = _webcam.DeviceId; await SwapWebcamIdentityAsync(dialog.PickedDevice);
var newDevice = dialog.PickedDevice.Id;
if (oldDevice == newDevice) return;
_webcam.DeviceId = newDevice;
_webcam.Name = dialog.PickedDevice.DisplayName;
ScheduleSave();
if (!string.IsNullOrWhiteSpace(oldDevice))
await _cameraManager.ReleaseAllAsync(oldDevice);
foreach (var unused in Scenes.SelectMany(s => s.Elements.OfType<WebcamSceneConfig>()))
{
var started = await _cameraManager.AcquireAsync(newDevice);
if (!started)
{
MessageBox.Show(
"Couldn't start that camera. It may be in use by another app, or Windows camera access may be turned off.",
"ytLlive", MessageBoxButton.OK, MessageBoxImage.Warning);
break;
}
}
} }
// "Show Webcam" from a right-click on the empty preview. Unhides this scene's // "Show Webcam" from a right-click on the empty preview. Unhides this scene's
@@ -1081,6 +1365,7 @@ public class MainViewModel : ViewModelBase
{ {
var scene = ActiveScene; var scene = ActiveScene;
if (scene == null || element == null) return; if (scene == null || element == null) return;
if (element is Source { IsBackdrop: true }) return;
if (element is WebcamSceneConfig && _webcam != null) if (element is WebcamSceneConfig && _webcam != null)
{ {
if (SelectedElement == element) if (SelectedElement == element)
+1 -1
View File
@@ -4,7 +4,7 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load, `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds` (50% cap seam) | | `MainViewModel.cs` | The app brain: scenes/elements collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog; **End Livestream signs out** (`StopStream` clears session + token — crash-safe). **Webcam:** owns `CameraManager` (MediaCapture), `AddWebcamToActiveSceneAsync` (picker → default 480×270 bottom-right placement → acquire; the Windows camera picker ALWAYS opens so the creator chooses — never silently reuses the previous camera; picking a different camera swaps the app-wide identity via `SwapWebcamIdentityAsync`, same path as `ChangeWebcamAsync`; propagates the running shared bitmap via `CameraManager.GetPreviewBitmap` first, so a webcam added mid-session never renders a transparent container; greys out when the active scene already has a config via `CanAddWebcamToActiveScene`), `ChangeWebcamAsync` (device swap — `ReleaseAllAsync` old + re-acquire), `ShowWebcamInActiveScene` (reveal hidden config / empty-canvas right-click), `ReacquireWebcam` after layout load (re-propagates the shared bitmap to every config), `OnCameraPreviewBitmapChanged` forwards the shared bitmap into every `WebcamSceneConfig.VideoImageSource`; `SelectedElement` drives the preview overlay + `internal ClampWebcamToBounds(config, sceneName)` (per-scene size cap seam: 50%-per-dimension everywhere, half-screen-AREA in Chat via `MaxWebcamWidthFor`/`MaxWebcamHeightFor`). **Scenes (five-scene catalog):** empty DB seeds Starting/Live/BRB/Chat/Ending (`SceneCatalog.All`); `AddScene(name)` accepts only canonical, missing names; `MissingScenes`/`ShowAddScene` drive the "+" button (hidden once all five exist; its menu lists only the missing scenes via `AddSceneCommand`). **Screen backdrop (Live-only by policy):** owns `ScreenCaptureManager` + `ScreenCaptureSourceFactory` (WinRT GraphicsCapture), `internal static EnsureBackdrop` heals one per backdrop-enabled scene (gated on `Scene.HasBackdrop`), `ReacquireScreenCaptures`/`RefreshBackdropAutoCapture`/`NoteBackgroundWindow` key by full-screen game monitor or the **primary display** via `Win32FullScreenDetector` (`GetDisplays()`/`PrimaryMonitorIndex()`), `ChangeBackdropCaptureAsync` (OS picker) + `SetBackdropCapture(DisplayInfo)` (in-app "Capture Display"), `RedesignateBackdropAsync` re-targets all backdrops and releases orphaned sessions, `internal static EnforceBackdropPolicy` normalizes `Scene.HasBackdrop` by name on every load + strips lingering non-Live backdrops, `CanChangeBackdrop` gates the capture menu to Live (the only scene with a backdrop), `BackdropImage` hides the preview watermark (`ShowPreviewPlaceholder`) |
| `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` | | `CameraPickerViewModel.cs` | Add Webcam dialog: async camera list (loading / has / none states), `UseRequested(CameraDeviceInfo)`/`CancelRequested` |
+109 -16
View File
@@ -53,8 +53,14 @@ dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLi
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI 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 + roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip,
25 passing. LayoutStore backdrop roundtrip, LayoutStore HasBackdrop roundtrip + v5→v6 non-Live backfill,
ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackdropTests (EnsureBackdrop
insert/idempotent/heal + HasBackdrop gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
(the five canonical scenes, Live-only backdrop policy, EnforceBackdropPolicy), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap) —
65 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug) ### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -81,7 +87,7 @@ C# / WPF (.NET 8) following MVVM:
|------|------| |------|------|
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), 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, CameraPickerViewModel | | `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`** | | `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)** |
| `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) |
@@ -93,6 +99,7 @@ C# / WPF (.NET 8) following MVVM:
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext) - ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet - Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available) - Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary) - Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Resolution tiers (bottom bar): 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6**Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height - Resolution tiers (bottom bar): 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6**Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
- Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug) - Crash diagnosis: `AppLog` writes startup checkpoints to `%APPDATA%\ytLlive\startup.log`; `App.xaml.cs` logs `DispatcherUnhandledException`/`AppDomain.UnhandledException`. When WPF won't run from WSL, this log is how you find the failure (it caught the `MenuItemRole.Separator` XAML crash and the ComboBox SelectionBoxItem bug)
@@ -100,11 +107,80 @@ 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, schema v4); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending) - Scene/source/asset layout persists (SQLite, schema v6); 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
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next** - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **window capture (non-backdrop), 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
### Screen backdrop capture (TASK 3 ship task #1)
The backdrop is the **live desktop/game capture as a permanent, non-deletable bottom layer**
rendered in every scene — the "Screen" source from the minimal set, done as content-swap
instead of a normal draggable source.
- **Model (schema v6):** `Source.IsBackdrop` (persisted) marks the one backdrop per scene; `Source.CaptureKey`
(persisted) names the target — `monitor:<n>`, `window:<hwnd>`, or `picker:<displayname>`. The backdrop is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackdrop(scene)`,
internal static — runs on layout load + every `AddScene`, healing any scene missing one), fixed at
X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
`IsDraggableElement` never matches live types; the element template sets `IsHitTestVisible=false` for
backdrops; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackdrop`
(persisted, default off) is the Live-only policy flag** — the backdrop belongs to the canonical Live
scene alone (see `SceneCatalog`). `EnsureBackdrop` returns null for a flag-less scene, so
Starting/BRB/Chat/Ending compose their own layers. The one-time v5→v6 backfill turns those four scenes
off and drops their backdrop sources, and **`EnforceBackdropPolicy` (internal static, runs after every
load)** re-normalizes the flag by scene name and strips any backdrop that lingers in a non-Live scene —
the flag is owned by policy, never the user. There is no scene-list "Backdrop" checkbox anymore
(the old `ToggleSceneBackdropCommand` is gone); "Change Capture…"/"Refresh Capture"/"Capture Display"
only show in the Live scene's preview menu (`CanChangeBackdrop`). The static Background, if any,
renders **above** the backdrop.
- **Detection (launch + focus only, no live session listener):** `Win32FullScreenDetector` =
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
`GetMonitorInfo`; a window is full-screen when its frame covers all four monitor edges; own process is
excluded; monitor **index = `EnumDisplayMonitors` enumeration order** (the same order
`ScreenCaptureSourceFactory` uses to map index→HMONITOR via `Win32FullScreenDetector.GetMonitorHandle`).
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu. At launch `ReacquireScreenCaptures` keys the backdrop to the full-screen game's monitor (or the
**primary** display — never assumed monitor 0).
On **Deactivated**, `NoteBackgroundWindow` samples the foreground ~250ms later (so an alt-tab to a game
lands before the app re-activates); on the next **Activated**, `RefreshBackdropAutoCapture` re-runs
detection against that sample and re-designates. Null detection (our app, the desktop, a normal window)
leaves the current capture alone.
- **Capture (WinRT GraphicsCapture):** `ScreenCaptureFrameSource` creates a free-threaded
`Direct3D11CaptureFramePool` (2 buffers, `B8G8R8A8UIntNormalized`) + `GraphicsCaptureSession`; frames →
`SoftwareBitmap.CreateCopyFromSurfaceAsync` (alpha ignored) → `VideoFrame` (BGRA8), bytes read via
`WindowsRuntimeMarshal.TryGetDataUnsafe` (the same CsWinRT-safe read the webcam path uses) — the
`IMemoryBufferByteAccess` ComImport cast threw `Invalid cast` on **every frame** under CsWinRT, which
flooded `startup.log` (~5 MB in a session) and burned CPU, so it is gone. Surfaces larger than the
1920×1080 master are downscaled bilinearly to the master (`DownscaleBgra`) before the copy, and
per-frame conversion failures are logged at most once per 5 s (`ErrorLogThrottle`). DRM-protected
content delivers black frames (OS limitation, documented). Frame pool
pauses while the app is minimized — capture keeps running, the pool just stops delivering.
- **Ownership:** `ScreenCaptureManager` mirrors `CameraManager` — refcounted by target key, one shared
`WriteableBitmap` per key, dispatcher-coalesced latest-frame copies, `PreviewBitmapChanged`/`CaptureFailed`
events, `ReleaseAllAsync` on re-designation. `ScreenCaptureSourceFactory.Resolve(key)` parses the key into
a source; `PickAsync()` shows the OS `GraphicsCapturePicker` ("Change Capture…", owner window set via the
`IInitializeWithWindow` ComImport) and returns a **transient** `picker:` key — a reload falls back to
auto-detection.
- **CsWinRT projection gaps hand-rolled:** `Windows.Graphics.Direct3D11.Direct3D11Helper` is not projected,
so `Direct3D11Helper` P/Invokes `d3d11.dll!D3D11CreateDevice` (hardware, BGRA_SUPPORT, explicit 11.1-first
feature array) → QI `IDXGIDevice` → the WinRT interop export
`CreateDirect3D11DeviceFromDXGIDevice``MarshalInterface<IDirect3DDevice>.FromAbi` (one shared device per
process). Do **not** switch back to the QI-for-`IDirect3DDxgiInterfaceAccess` trick: the raw D3D11 device
no longer exposes that interface on newer Windows (verified E_NOINTERFACE on build 26200, hardware and
WARP alike) while `CreateDirect3D11DeviceFromDXGIDevice` keeps working.
`IInitializeWithWindow` are ComImports in `CaptureInterop.cs`. All WinRT projections were verified by
reflection against the built `Microsoft.Windows.SDK.NET.dll` before writing the interop.
- **Known v1 limits:** full-desktop captures are CPU-copied at native resolution (GPU downscale = encoder
task); window capture (`window:<hwnd>`) and multi-monitor live re-targeting beyond the auto-detected
game are behind the picker; picker-based captures don't survive reload.
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
`ShowPreviewPlaceholder` now also checks `BackdropImage` (raised on backdrop change), so a scene with
live capture shows the feed instead of the "nothing here" label.
### Webcam capture (TASK 3 milestone 1) ### Webcam capture (TASK 3 milestone 1)
- **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed - **Seam-first:** everything above the WinRT layer speaks only `VideoFrame` (normalized tightly-packed
@@ -131,11 +207,16 @@ C# / WPF (.NET 8) following MVVM:
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
release stops + disposes). The Add Webcam menu greys out when the **active** scene already has a release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is
config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the existing config never silently handed the previous camera (which used to happen after deleting one scene's webcam
(`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam"). **Removing the last webcam while another scene still used it; that identity survived, so re-adding bypassed the choice).
config anywhere clears the identity** (`_webcam = null`), so re-adding opens the picker again Picking a different camera than the current app-wide one swaps it everywhere via
instead of resurrecting the old camera. `SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest;
picking the same one just places the config. The Add Webcam menu greys out when the **active**
scene already has a config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the
existing config (`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam" — which, on
a config-less canvas, delegates to Add Webcam and so also picks). **Removing the last webcam
config anywhere clears the identity** (`_webcam = null`), which also drops "Change Webcam…".
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()` - **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square restores them when switching back — otherwise the Round resize lock (square) would leave a square
@@ -152,6 +233,12 @@ C# / WPF (.NET 8) following MVVM:
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher `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 (at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread. device never drowns the render thread.
- **Webcam added mid-session must get the live frames:** `PreviewBitmapChanged` fires **once** (the first
frame creates the shared bitmap); later frames only mutate that bitmap in place, so a config that didn't
exist at first-frame time would never receive it — the empty/transparent container you'd see adding a
webcam to Chat while Live already had the camera. `CameraManager.GetPreviewBitmap(deviceId)` exposes the
current shared bitmap; `AddWebcamToActiveSceneAsync` assigns it to the new config right before
`AcquireAsync`, and `ReacquireWebcam` re-propagates it to every config after a reload.
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored` - **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity` (`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
01, `BorderWidth` 020, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow| 01, `BorderWidth` 020, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
@@ -160,13 +247,19 @@ C# / WPF (.NET 8) following MVVM:
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
preview canvas has its own Show Webcam entry. preview canvas has its own Show Webcam entry.
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders - The Round webcam is an `Image Stretch="UniformToFill"` with an `EllipseGeometry` clip
as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the (`Center=0.5,0.5` `RadiusX/Y=0.5`), inside a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it
element rect — and the traditional `Image` keeps `UniformToFill` over the full rect. The Round renders as a true circle (diameter = the shorter element dimension) instead of an oval stretched to
border is a centered `Ellipse` at `Width/Height = RoundBorderSize`. the element rect — the traditional `Image` keeps `UniformToFill` over the full rect. The clip is
geometry, **not an `ImageBrush`**: a brush re-rasterizes the frequently-updated `WriteableBitmap` per
frame on the render thread, which is what made the live webcam crawl while round. The Round border is
a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`. - Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **Webcam size clamp:** `ClampWebcamToBounds` (internal — test seam) enforces 50% of the 1920×1080 - **Webcam size clamp:** `ClampWebcamToBounds(config, sceneName)` (internal — test seam) enforces the
master per dimension (960×540 max) and no less than 10% (192×108) at resize + load; max per dimension at resize + load and no less than 10% of the master (192×108). The cap is picked
**by canonical scene name**: 50% per dimension (960×540) everywhere except the **Chat scene**, which
may reach half the screen **area** (~1358×764 @16:9) so the viewer sees the creator better
(`MaxWebcamWidthFor`/`MaxWebcamHeightFor`; a renamed Chat loses the bigger cap).
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp. `RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in - **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
+118
View File
@@ -0,0 +1,118 @@
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The backdrop is a permanent, non-deletable bottom layer holding live
/// desktop/game capture, and it exists only in the canonical Live scene (see
/// <see cref="SceneCatalog"/>). Every backdrop-enabled scene
/// (<see cref="Scene.HasBackdrop"/>) is healed to have exactly one, inserted
/// first; it is never reorderable or removable by the UI. Scenes without the
/// flag never get one.
/// </summary>
public class BackdropTests
{
[Fact]
public void EnsureBackdrop_OnEmptyScene_InsertsFullFrameBackdrop()
{
var scene = new Scene { Name = "S", HasBackdrop = true };
var backdrop = MainViewModel.EnsureBackdrop(scene);
Assert.NotNull(backdrop);
Assert.Single(scene.Elements);
Assert.Same(backdrop, scene.Elements[0]);
Assert.True(backdrop.IsBackdrop);
Assert.True(backdrop.IsEnabled);
Assert.Equal(SourceType.DisplayCapture, backdrop.Type);
Assert.Equal(0, backdrop.X);
Assert.Equal(0, backdrop.Y);
Assert.Equal(1920, backdrop.Width);
Assert.Equal(1080, backdrop.Height);
}
[Fact]
public void EnsureBackdrop_ExistingBackdrop_IsIdempotent()
{
var scene = new Scene { Name = "S", HasBackdrop = true };
var first = MainViewModel.EnsureBackdrop(scene);
var second = MainViewModel.EnsureBackdrop(scene);
Assert.Same(first, second);
Assert.Single(scene.Elements);
}
[Fact]
public void EnsureBackdrop_HealsMissingBackdropAtIndexZero()
{
var scene = new Scene { Name = "S", HasBackdrop = true };
var image = new Source { Name = "Logo", Type = SourceType.Image };
scene.Elements.Add(image);
MainViewModel.EnsureBackdrop(scene);
Assert.Equal(2, scene.Elements.Count);
Assert.True(((Source)scene.Elements[0]).IsBackdrop);
Assert.Same(image, scene.Elements[1]);
}
[Fact]
public void EnsureBackdrop_SceneWithoutHasBackdrop_ReturnsNullAndInsertsNothing()
{
var scene = new Scene { Name = "Ending", HasBackdrop = false };
var backdrop = MainViewModel.EnsureBackdrop(scene);
Assert.Null(backdrop);
Assert.Empty(scene.Elements);
}
[Theory]
[InlineData(SourceType.DisplayCapture, true)]
[InlineData(SourceType.WindowCapture, true)]
[InlineData(SourceType.Background, false)]
[InlineData(SourceType.Image, false)]
[InlineData(SourceType.TextOverlay, false)]
public void Source_IsLiveCapture_OnlyForDisplayAndWindow(SourceType type, bool expected)
{
Assert.Equal(expected, new Source { Type = type }.IsLiveCapture);
}
[Fact]
public void Source_DisplaySource_IsVideoImageSource_WhenLiveCapture()
{
var source = new Source { Type = SourceType.DisplayCapture };
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
source.VideoImageSource = bitmap;
Assert.Same(bitmap, source.DisplaySource);
}
[Fact]
public void Source_DisplaySource_IsImageSource_WhenNotLiveCapture()
{
var source = new Source { Type = SourceType.Background };
var bitmap = new System.Windows.Media.Imaging.WriteableBitmap(
2, 2, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null);
source.VideoImageSource = bitmap;
Assert.NotSame(bitmap, source.DisplaySource);
Assert.Null(source.DisplaySource);
}
[Fact]
public void Source_TypeChange_RaisesDisplaySource()
{
var source = new Source { Type = SourceType.Image };
var raised = new List<string>();
source.PropertyChanged += (_, e) => raised.Add(e.PropertyName ?? string.Empty);
source.Type = SourceType.DisplayCapture;
Assert.Contains(nameof(Source.IsLiveCapture), raised);
Assert.Contains(nameof(Source.DisplaySource), raised);
}
}
+145
View File
@@ -49,6 +49,151 @@ public class LayoutStorePersistenceTests
} }
} }
// The backdrop (schema v5): permanent bottom layer holding live desktop/game
// capture. IsBackdrop + CaptureKey must survive a save + reload so a
// re-designated target isn't lost on restart.
[Fact]
public void Backdrop_IsBackdrop_And_CaptureKey_Survive_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var backdrop = new Source
{
Name = "Backdrop",
Type = SourceType.DisplayCapture,
IsBackdrop = true,
IsEnabled = true,
X = 0,
Y = 0,
Width = 1920,
Height = 1080,
MonitorIndex = 1,
CaptureKey = "monitor:1",
};
var scene = new Scene { Name = "Starting" };
scene.Elements.Add(backdrop);
store.Save(new[] { scene }, null);
var reloaded = store.Load();
var restored = Assert.IsType<Source>(Assert.Single(reloaded[0].Elements));
Assert.True(restored.IsBackdrop);
Assert.Equal("monitor:1", restored.CaptureKey);
Assert.Equal(1, restored.MonitorIndex);
Assert.Equal(SourceType.DisplayCapture, restored.Type);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Per-scene backdrop switch (schema v6): a scene whose backdrop was turned
// off must stay off across a save + reload.
[Fact]
public void Scene_HasBackdrop_Survives_Save_And_Reload()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using var store = new LayoutStore(path);
var on = new Scene { Name = "Live", HasBackdrop = true };
var off = new Scene { Name = "Ending", HasBackdrop = false };
store.Save(new[] { on, off }, null);
var reloaded = store.Load();
Assert.True(reloaded.First(s => s.Name == "Live").HasBackdrop);
Assert.False(reloaded.First(s => s.Name == "Ending").HasBackdrop);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// v5 → v6 one-time backfill: pre-v6 DBs already have backdrops sitting in the
// non-Live canonical scenes (Starting/BRB/Chat/Ending). Adding the HasBackdrop
// column turns those scenes off and drops their backdrop sources; Live keeps
// its backdrop.
[Fact]
public void V5_Database_Backfill_Removes_NonLive_Backdrops()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var conn = new SqliteConnection($"Data Source={path}"))
{
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
CREATE TABLE Scene (
Id TEXT PRIMARY KEY,
Name TEXT NOT NULL,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
SortOrder INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE Source (
Id TEXT PRIMARY KEY,
SceneId TEXT NOT NULL REFERENCES Scene(Id) ON DELETE CASCADE,
AssetId TEXT,
Type TEXT NOT NULL,
Name TEXT NOT NULL,
IsEnabled INTEGER NOT NULL DEFAULT 1,
X REAL NOT NULL DEFAULT 0,
Y REAL NOT NULL DEFAULT 0,
Width REAL NOT NULL DEFAULT 0,
Height REAL NOT NULL DEFAULT 0,
Opacity REAL NOT NULL DEFAULT 1,
MonitorIndex INTEGER,
DeviceId TEXT,
ClipShape TEXT NOT NULL DEFAULT 'Traditional',
IsMirrored INTEGER NOT NULL DEFAULT 0,
IsBackdrop INTEGER NOT NULL DEFAULT 0,
CaptureKey TEXT,
SortOrder INTEGER NOT NULL DEFAULT 0
);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-starting', 'Starting', 0);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-live', 'Live', 1);
INSERT INTO Scene (Id, Name, SortOrder) VALUES ('s-chat', 'Chat', 2);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-starting', 's-starting', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-live', 's-live', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
INSERT INTO Source (Id, SceneId, Type, Name, IsBackdrop, CaptureKey, SortOrder)
VALUES ('b-chat', 's-chat', 'DisplayCapture', 'Backdrop', 1, 'monitor:0', 0);
PRAGMA user_version = 5;
""";
cmd.ExecuteNonQuery();
}
using var store = new LayoutStore(path);
var scenes = store.Load();
var starting = Assert.Single(scenes, s => s.Name == "Starting");
Assert.False(starting.HasBackdrop);
Assert.DoesNotContain(starting.Elements, e => e is Source { IsBackdrop: true });
var chat = Assert.Single(scenes, s => s.Name == "Chat");
Assert.False(chat.HasBackdrop);
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
var live = Assert.Single(scenes, s => s.Name == "Live");
Assert.True(live.HasBackdrop);
Assert.Single(live.Elements, e => e is Source { IsBackdrop: true });
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Round-to-rect restore is persisted (schema v4): a Round webcam resized to a // Round-to-rect restore is persisted (schema v4): a Round webcam resized to a
// square saves its pre-Round rect dims, and a reloaded config restores them on // square saves its pre-Round rect dims, and a reloaded config restores them on
// toggle-back instead of staying square. // toggle-back instead of staying square.
+10 -5
View File
@@ -101,9 +101,14 @@ public sealed class RoundClipInteractionTests
// The round clip must render as a circle (square bounding box), not an oval. // The round clip must render as a circle (square bounding box), not an oval.
webcam.ClipShape = ClipShape.Round; webcam.ClipShape = ClipShape.Round;
window.UpdateLayout(); window.UpdateLayout();
var ellipse = FindRoundEllipse(window); var clip = FindRoundClip(window);
Assert.NotNull(ellipse); Assert.NotNull(clip);
Assert.Equal(ellipse!.RenderSize.Width, ellipse.RenderSize.Height, 1.0); var geometry = Assert.IsType<EllipseGeometry>(clip!.Clip);
Assert.Equal(0.5, geometry.Center.X, 1.0);
Assert.Equal(0.5, geometry.Center.Y, 1.0);
Assert.Equal(0.5, geometry.RadiusX, 1.0);
Assert.Equal(0.5, geometry.RadiusY, 1.0);
Assert.Equal(clip.ActualWidth, clip.ActualHeight, 1.0);
} }
finally finally
{ {
@@ -114,9 +119,9 @@ public sealed class RoundClipInteractionTests
} }
} }
private static Ellipse? FindRoundEllipse(Window window) private static Image? FindRoundClip(Window window)
{ {
return Walk(window, element => element is Ellipse e && e.IsVisible) as Ellipse; return Walk(window, element => element is Image i && i.IsVisible && i.Clip is EllipseGeometry) as Image;
} }
private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate) private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate)
+77
View File
@@ -0,0 +1,77 @@
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// The app is a five-scene product: Starting/Live/BRB/Chat/Ending. Scenes are
/// worked on by name; you can delete them but never add beyond the set, and the
/// live backdrop belongs to Live alone.
/// </summary>
public class SceneCatalogTests
{
[Fact]
public void SceneCatalog_Defines_The_Five_Canonical_Scenes()
{
Assert.Equal(
new[] { "Starting", "Live", "BRB", "Chat", "Ending" },
SceneCatalog.All);
}
[Theory]
[InlineData("Starting", true)]
[InlineData("Live", true)]
[InlineData("BRB", true)]
[InlineData("Chat", true)]
[InlineData("Ending", true)]
[InlineData("live", true)]
[InlineData("My Scene", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void SceneCatalog_IsCanonical_Matches_By_Name(string? name, bool expected)
{
Assert.Equal(expected, SceneCatalog.IsCanonical(name));
}
[Theory]
[InlineData("Starting", false)]
[InlineData("Live", true)]
[InlineData("BRB", false)]
[InlineData("Chat", false)]
[InlineData("Ending", false)]
public void SceneCatalog_HasBackdrop_Only_For_Live(string name, bool expected)
{
Assert.Equal(expected, SceneCatalog.HasBackdrop(name));
}
[Fact]
public void EnforceBackdropPolicy_Removes_Backdrop_From_NonLive_Scenes()
{
var chat = new Scene { Name = "Chat", HasBackdrop = true };
var live = new Scene { Name = "Live", HasBackdrop = true };
var chatBackdrop = MainViewModel.EnsureBackdrop(chat)!;
var liveBackdrop = MainViewModel.EnsureBackdrop(live)!;
Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackdrop));
MainViewModel.EnforceBackdropPolicy(new[] { chat, live });
Assert.False(chat.HasBackdrop);
Assert.DoesNotContain(chat.Elements, e => e is Source { IsBackdrop: true });
Assert.True(live.HasBackdrop);
Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackdrop));
}
[Fact]
public void EnforceBackdropPolicy_Marks_Live_And_Clears_A_Pre_Policy_Database()
{
var starting = new Scene { Name = "Starting", HasBackdrop = true };
MainViewModel.EnsureBackdrop(starting);
var live = new Scene { Name = "Live", HasBackdrop = false };
MainViewModel.EnforceBackdropPolicy(new[] { starting, live });
Assert.False(starting.HasBackdrop);
Assert.True(live.HasBackdrop);
}
}
+189
View File
@@ -0,0 +1,189 @@
using System.Threading;
using System.Windows.Threading;
using System.Windows.Media.Imaging;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// ScreenCaptureManager is the app-wide owner of screen-capture sessions,
/// refcounted by target key and coalesced onto the UI dispatcher. These tests
/// pin that contract with a fake IScreenCaptureSource (the WinRT pool/session
/// layer is exercised only on Windows at runtime).
/// </summary>
public class ScreenCaptureManagerTests
{
private sealed class FakeScreenSource : IScreenCaptureSource
{
private readonly List<string>? _started;
private readonly List<string>? _stopped;
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
public FakeScreenSource(string key, List<string>? started = null, List<string>? stopped = null)
{
Key = key;
_started = started;
_stopped = stopped;
}
public Task StartAsync()
{
_started?.Add(Key);
return Task.CompletedTask;
}
public Task StopAsync()
{
_stopped?.Add(Key);
return Task.CompletedTask;
}
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
}
private sealed class FailingScreenSource : IScreenCaptureSource
{
public string Key { get; }
public event Action<VideoFrame>? FrameAvailable;
public FailingScreenSource(string key) => Key = key;
public Task StartAsync() => throw new InvalidOperationException("access denied");
public Task StopAsync() => Task.CompletedTask;
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
}
// A real Dispatcher pumping on a background STA thread, so the manager's
// BeginInvoke queue can be drained deterministically from the test thread.
private sealed class DispatcherPump : IDisposable
{
private readonly Thread _thread;
private readonly ManualResetEventSlim _ready = new(false);
private Dispatcher? _dispatcher;
public Dispatcher Dispatcher => _dispatcher!;
public DispatcherPump()
{
_thread = new Thread(() =>
{
_dispatcher = Dispatcher.CurrentDispatcher;
_ready.Set();
Dispatcher.Run();
});
_thread.IsBackground = true;
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
_ready.Wait();
}
// Queued behind anything already posted at Render/higher, so pending
// bitmap copies run first.
public void Drain() => Dispatcher.Invoke(() => { }, DispatcherPriority.ContextIdle);
public void Dispose()
{
Dispatcher.InvokeShutdown();
_thread.Join(3000);
}
}
private static byte[] Pixels(params byte[] raw) => raw;
[Fact]
public async Task Refcount_TwoAcquires_OneCaptureUntilLastRelease()
{
var started = new List<string>();
var stopped = new List<string>();
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key, started, stopped));
Assert.True(await manager.AcquireAsync("monitor:0"));
Assert.True(await manager.AcquireAsync("monitor:0"));
Assert.Single(started);
await manager.ReleaseAsync("monitor:0");
Assert.Empty(stopped);
await manager.ReleaseAsync("monitor:0");
Assert.Single(stopped);
}
[Fact]
public async Task ReleaseAll_StopsEvenWithMultipleRefs()
{
var started = new List<string>();
var stopped = new List<string>();
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key, started, stopped));
await manager.AcquireAsync("monitor:0");
await manager.AcquireAsync("monitor:0");
await manager.ReleaseAllAsync("monitor:0");
Assert.Single(stopped);
}
[Fact]
public async Task Acquire_FailingSource_ReturnsFalseAndRaisesCaptureFailed()
{
var manager = new ScreenCaptureManager(key => new FailingScreenSource(key));
string? failedKey = null;
manager.CaptureFailed += (key, _) => failedKey = key;
Assert.False(await manager.AcquireAsync("monitor:0"));
Assert.Equal("monitor:0", failedKey);
}
[Fact]
public async Task Acquire_EmptyKey_ReturnsFalse()
{
var manager = new ScreenCaptureManager(key => new FakeScreenSource(key));
Assert.False(await manager.AcquireAsync(" "));
}
// The one integration test for this branch: one target creates one shared
// WriteableBitmap, published once, and back-to-back frames coalesce to the
// latest (a single pending UI copy per session).
[Fact]
public async Task Frames_ShareOneBitmap_AndCoalesceToLatest()
{
using var pump = new DispatcherPump();
FakeScreenSource? captured = null;
var manager = new ScreenCaptureManager(key => captured = new FakeScreenSource(key), pump.Dispatcher);
WriteableBitmap? published = null;
var publishedCount = 0;
manager.PreviewBitmapChanged += (_, bitmap) => { published = bitmap; publishedCount++; };
Assert.True(await manager.AcquireAsync("monitor:0"));
var first = new VideoFrame(2, 2, Pixels(1, 0, 0, 255, 2, 0, 0, 255, 3, 0, 0, 255, 4, 0, 0, 255));
captured!.Pump(first);
pump.Drain();
Assert.NotNull(published);
Assert.Equal(1, publishedCount);
var dims = pump.Dispatcher.Invoke(() => new[] { published!.PixelWidth, published.PixelHeight });
Assert.Equal(new[] { 2, 2 }, dims);
var second = new VideoFrame(2, 2, Pixels(5, 0, 0, 255, 6, 0, 0, 255, 7, 0, 0, 255, 8, 0, 0, 255));
var third = new VideoFrame(2, 2, Pixels(9, 0, 0, 255, 10, 0, 0, 255, 11, 0, 0, 255, 12, 0, 0, 255));
captured.Pump(second);
captured.Pump(third);
pump.Drain();
// The bitmap is frozen to the dispatcher thread; copy its pixels there.
var result = pump.Dispatcher.Invoke(() =>
{
var bytes = new byte[16];
published!.CopyPixels(new System.Windows.Int32Rect(0, 0, 2, 2), bytes, 8, 0);
return bytes;
});
Assert.Equal(third.BgraPixels, result);
}
}
+39 -7
View File
@@ -6,16 +6,21 @@ namespace ytLive.Tests;
/// <summary> /// <summary>
/// The webcam size safeguard: no scene placement may exceed half the 1920×1080 /// The webcam size safeguard: no scene placement may exceed half the 1920×1080
/// master frame (960×540), nor drop below 10% of it (192×108). Enforced at /// master frame (960×540) — except the Chat scene, where it may reach half the
/// resize (MainWindow) and defensively again on every layout load. /// screen AREA (~1358×764 @16:9) — nor drop below 10% of the frame (192×108).
/// The cap is picked by canonical scene name. Enforced at resize (MainWindow)
/// and defensively again on every layout load.
/// </summary> /// </summary>
public class WebcamSafeguardTests public class WebcamSafeguardTests
{ {
private const string DefaultScene = "Starting";
private const string ChatScene = "Chat";
[Fact] [Fact]
public void Oversize_Webcam_Is_Capped_To_Half_The_Frame() public void Oversize_Webcam_Is_Capped_To_Half_The_Frame()
{ {
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 }; var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
MainViewModel.ClampWebcamToBounds(webcam); MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width); Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height); Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
} }
@@ -24,7 +29,7 @@ public class WebcamSafeguardTests
public void Wide_Webcam_Scales_To_Fit_Both_Dimensions() public void Wide_Webcam_Scales_To_Fit_Both_Dimensions()
{ {
var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 }; var webcam = new WebcamSceneConfig { Width = 1000, Height = 500 };
MainViewModel.ClampWebcamToBounds(webcam); MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
Assert.Equal(960, webcam.Width); Assert.Equal(960, webcam.Width);
Assert.Equal(480, webcam.Height); Assert.Equal(480, webcam.Height);
} }
@@ -33,7 +38,7 @@ public class WebcamSafeguardTests
public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum() public void Tiny_Webcam_Is_Brought_Up_To_The_Minimum()
{ {
var webcam = new WebcamSceneConfig { Width = 10, Height = 10 }; var webcam = new WebcamSceneConfig { Width = 10, Height = 10 };
MainViewModel.ClampWebcamToBounds(webcam); MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width); Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height); Assert.Equal(MainViewModel.WebcamMinWidth, webcam.Height);
} }
@@ -42,7 +47,7 @@ public class WebcamSafeguardTests
public void Short_Wide_Webcam_Meets_Both_Minimums() public void Short_Wide_Webcam_Meets_Both_Minimums()
{ {
var webcam = new WebcamSceneConfig { Width = 100, Height = 20 }; var webcam = new WebcamSceneConfig { Width = 100, Height = 20 };
MainViewModel.ClampWebcamToBounds(webcam); MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth); Assert.True(webcam.Width >= MainViewModel.WebcamMinWidth);
Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height); Assert.Equal(MainViewModel.WebcamMinHeight, webcam.Height);
} }
@@ -51,12 +56,39 @@ public class WebcamSafeguardTests
public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension() public void Round_Border_Size_Tracks_The_Shorter_Clamped_Dimension()
{ {
var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 }; var webcam = new WebcamSceneConfig { Width = 1920, Height = 500 };
MainViewModel.ClampWebcamToBounds(webcam); MainViewModel.ClampWebcamToBounds(webcam, DefaultScene);
Assert.Equal(960, webcam.Width); Assert.Equal(960, webcam.Width);
Assert.Equal(250, webcam.Height); Assert.Equal(250, webcam.Height);
Assert.Equal(250, webcam.RoundBorderSize); Assert.Equal(250, webcam.RoundBorderSize);
} }
[Fact]
public void Chat_Webcam_May_Reach_Half_The_Screen_Area()
{
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
MainViewModel.ClampWebcamToBounds(webcam, ChatScene);
Assert.Equal(MainViewModel.WebcamChatMaxWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamChatMaxHeight, webcam.Height);
}
[Fact]
public void Chat_Webcam_At_The_Default_Size_Is_Not_Grown_Or_Shrunk()
{
var webcam = new WebcamSceneConfig { Width = 480, Height = 270 };
MainViewModel.ClampWebcamToBounds(webcam, ChatScene);
Assert.Equal(480, webcam.Width);
Assert.Equal(270, webcam.Height);
}
[Fact]
public void Renamed_Chat_Scene_Loses_The_Larger_Cap()
{
var webcam = new WebcamSceneConfig { Width = 1920, Height = 1080 };
MainViewModel.ClampWebcamToBounds(webcam, "chit-chat");
Assert.Equal(MainViewModel.WebcamMaxWidth, webcam.Width);
Assert.Equal(MainViewModel.WebcamMaxHeight, webcam.Height);
}
[Fact] [Fact]
public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect() public void Round_Then_Back_To_Rect_Restores_The_Original_Aspect()
{ {