d35823a4a9
Roll-forward of today-slices 6fd1d9c onto the slice-8 base, two-loop hunk dropped. Release counter (per-build GUID read as noise; +1 per commit from git rev-list, baseline 241 -> #13, generated by GenerateBuildStamp; GUID demotes to startup.log). Tests pinned to Label/#N >= 13; wordmark display test asserts the Label. Flash fix (take 14 finding): consumer holds must never outlive depth x source period — 4 slots at high refresh lap ~27ms vs a <=50ms compositor read, so a recycled slot flashed its new frame over the lagged old one. All shared rings 4->8 (OBS/overlay precedent for ring discipline). Camera producer now rotates an 8-deep ring + Epoch instead of a fresh ~3.7MB array per device frame (110-220MB/s LOH churn); WebView2 capture reuses a canvas scratch + 8-deep output ring + a reused WriteableBitmap instead of two fresh arrays + a fresh bitmap per 10Hz tick. Paste cache stays identity-keyed (Epoch).
262 lines
9.9 KiB
C#
262 lines
9.9 KiB
C#
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:<n>" or
|
||
/// "window:<hwnd>"). 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;
|
||
|
||
// Buffer recycling (take-11 spikes, 2026-09-04): a fresh ~8.3MB byte[] per DWM
|
||
// frame ≈ 500MB/s of LOH churn — the gen2 pauses it forces surfaced as the
|
||
// "worst render 35-65ms" spikes that capped fps at ~42 long after the compositor
|
||
// itself was fast. A 4-deep ring rotated round-robin is never lapped by a
|
||
// ≤17ms consumer at 60Hz; each hand-out carries an Epoch so identity-keyed
|
||
// consumers (the compositor's paste cache) cannot false-hit a recycled array.
|
||
// Depth 8 (take-14 rule): depth × source period must exceed the worst consumer
|
||
// hold — 4 slots at 144Hz laps in ~27ms while a compositor read + lagged UI
|
||
// preview copy can hold a frame ~50ms; the flash was half a new frame over old.
|
||
private readonly byte[]?[] _frameRing = new byte[8][];
|
||
private int _ringNext;
|
||
private long _epoch;
|
||
private byte[]? _row0;
|
||
private byte[]? _row1;
|
||
|
||
// The composition master frame (see ai.md "Resolution tiers"): the background
|
||
// is an input layer, so we never hold a CPU frame bigger than the master.
|
||
private const int MaxBackgroundWidth = 1920;
|
||
private const int MaxBackgroundHeight = 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 byte[] RentRingBuffer(int size)
|
||
{
|
||
for (var tries = 0; tries < _frameRing.Length; tries++)
|
||
{
|
||
var idx = (_ringNext + tries) % _frameRing.Length;
|
||
var buf = _frameRing[idx];
|
||
if (buf is { Length: var len } && len == size)
|
||
{
|
||
_ringNext = (idx + 1) % _frameRing.Length;
|
||
return buf;
|
||
}
|
||
}
|
||
var slot = _ringNext;
|
||
_ringNext = (slot + 1) % _frameRing.Length;
|
||
var fresh = new byte[size];
|
||
_frameRing[slot] = fresh;
|
||
return fresh;
|
||
}
|
||
|
||
private 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 > MaxBackgroundWidth || sh > MaxBackgroundHeight)
|
||
{
|
||
var scale = Math.Min(MaxBackgroundWidth / (double)sw, MaxBackgroundHeight / (double)sh);
|
||
var dw = Math.Max(1, (int)(sw * scale));
|
||
var dh = Math.Max(1, (int)(sh * scale));
|
||
// DWM delivers an opaque surface (alpha 255); bilinear keeps it 255.
|
||
var scaled = RentRingBuffer(dw * dh * 4);
|
||
return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh, scaled))
|
||
{ IsOpaque = true, Epoch = ++_epoch };
|
||
}
|
||
|
||
var pixels = RentRingBuffer(count);
|
||
Marshal.Copy(data, pixels, 0, pixels.Length);
|
||
return new VideoFrame(sw, sh, pixels) { IsOpaque = true, Epoch = ++_epoch };
|
||
}
|
||
|
||
// Bilinear downscale to the master frame. Reads each source row pair through
|
||
// Marshal.Copy (no unsafe), writing tightly packed BGRA output.
|
||
private byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh, byte[] dst)
|
||
{
|
||
// Row scratch is per-capture-thread and reused across frames (same churn lesson).
|
||
var row0 = _row0 != null && _row0.Length >= srcStride ? _row0 : (_row0 = new byte[srcStride]);
|
||
var row1 = _row1 != null && _row1.Length >= srcStride ? _row1 : (_row1 = new byte[srcStride]);
|
||
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;
|
||
}
|
||
}
|