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
+12
View File
@@ -150,6 +150,18 @@ public sealed class CameraManager : IDisposable
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()
{
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,
IsHidden INTEGER NOT NULL DEFAULT 0,
IsChatScene INTEGER NOT NULL DEFAULT 0,
HasBackdrop INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0
);
""",
@@ -69,6 +70,8 @@ public class LayoutStore : IDisposable
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
);
""",
@@ -110,11 +113,12 @@ public class LayoutStore : IDisposable
}
MigrateSourceTable();
MigrateWebcamConfigTable();
MigrateSceneTable();
if (GetUserVersion() < 3)
MigrateToV3();
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = "PRAGMA user_version = 4;";
cmd.CommandText = "PRAGMA user_version = 6;";
cmd.ExecuteNonQuery();
}
}
@@ -153,6 +157,20 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN IsMirrored INTEGER NOT NULL DEFAULT 0;";
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,
@@ -260,6 +278,47 @@ public class LayoutStore : IDisposable
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()
{
Webcam = null;
@@ -269,7 +328,7 @@ public class LayoutStore : IDisposable
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();
while (reader.Read())
{
@@ -279,6 +338,7 @@ public class LayoutStore : IDisposable
Name = reader.GetString(1),
IsHidden = reader.GetInt32(2) != 0,
IsChatScene = reader.GetInt32(3) != 0,
HasBackdrop = reader.GetInt32(4) != 0,
});
}
}
@@ -302,7 +362,8 @@ public class LayoutStore : IDisposable
{
cmd.CommandText = """
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
""";
using var reader = cmd.ExecuteReader();
@@ -324,6 +385,8 @@ public class LayoutStore : IDisposable
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
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))
sourcesByScene[sceneId] = list = new List<Source>();
@@ -412,14 +475,15 @@ public class LayoutStore : IDisposable
using (var cmd = _connection.CreateCommand())
{
cmd.CommandText = """
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $sort)
INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackdrop, SortOrder)
VALUES ($id, $name, $isHidden, $isChat, $hasBackdrop, $sort)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
var nameP = cmd.Parameters.Add("$name", SqliteType.Text);
var hiddenP = cmd.Parameters.Add("$isHidden", 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 sort = 0;
@@ -429,6 +493,7 @@ public class LayoutStore : IDisposable
nameP.Value = scene.Name;
hiddenP.Value = scene.IsHidden ? 1 : 0;
chatP.Value = scene.IsChatScene ? 1 : 0;
backdropP.Value = scene.HasBackdrop ? 1 : 0;
sortP.Value = sort++;
cmd.ExecuteNonQuery();
}
@@ -439,10 +504,10 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, SortOrder)
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $sort)
$clip, $mirrored, $isBackdrop, $captureKey, $sort)
""";
cmd.Transaction = tx;
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 clipP = cmd.Parameters.Add("$clip", SqliteType.Text);
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);
foreach (var scene in scenes)
@@ -481,6 +548,8 @@ public class LayoutStore : IDisposable
monitorP.Value = (object?)source.MonitorIndex ?? DBNull.Value;
clipP.Value = source.ClipShape.ToString();
mirroredP.Value = source.IsMirrored ? 1 : 0;
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
sortP.Value = sort++;
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) |
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; 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 |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `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)
(no DI container yet). Models in [`Models/index.md`](../Models/index.md).