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; /// /// The frame-driven web capture for one widget: a GraphicsCaptureItem backed by /// the widget's Windows.UI.Composition visual (see WebView2Manager). Owns the /// item, a free-threaded Direct3D11CaptureFramePool and the capture session; /// frames arrive in step with Chromium's own presentation (~60Hz on a real /// animation), replacing the old CapturePreviewAsync PNG poll whose ~35-165ms /// encode cost capped the recording at ~10Hz and the preview at ~5fps. /// /// The capture surface holds a REAL alpha channel (the controller renders onto /// a transparent composition visual), so the GPU→CPU copy uses /// and the frame is /// = false — the compositor's straight-alpha /// blend math ("web layer transparency", 2026-09-13) requires the readback to /// keep premultiplication OFF. Unlike the screen source there is no master-size /// downscale: the controller renders at the RAW 1920×1080 master canvas, so the /// CPU never holds a larger frame. /// /// Per-frame the alpha bounding box of the widget () /// is measured on this capture worker thread and stamped onto the frame as /// — same alpha-bbox contract as the old /// PNG path, but off the UI thread (a 2M-pixel scan per frame there is exactly /// the cost this redesign exists to remove). /// public sealed class WebCaptureFrameSource : 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: identical to the screen source's ring+Epoch contract // (see ScreenCaptureFrameSource) — the compositor's paste cache is keyed on // array identity, so recycled buffers must carry a monotonic Epoch or a // reused array false-hits with stale content. private readonly byte[]?[] _frameRing = new byte[8][]; private int _ringNext; private long _epoch; private static readonly TimeSpan ErrorLogThrottle = TimeSpan.FromSeconds(5); public string Key { get; } public event Action? FrameAvailable; internal WebCaptureFrameSource(string key, GraphicsCaptureItem item) { Key = key; _item = item; } /// Wraps an item created from a composition visual by the web manager. public static WebCaptureFrameSource CreateForVisual(string key, GraphicsCaptureItem item) => new(key, 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.Straight)) { FrameAvailable?.Invoke(CopyToVideoFrame(softwareBitmap)); } } catch (Exception ex) { var now = DateTime.UtcNow; if (now - _lastErrorLog >= ErrorLogThrottle) { _lastErrorLog = now; AppLog.Write($"WebCaptureFrameSource: 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)); var pixels = RentRingBuffer(count); Marshal.Copy(data, pixels, 0, pixels.Length); var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, sw, sh, srcStride); return new VideoFrame(sw, sh, pixels) { IsOpaque = false, Epoch = ++_epoch, CropBounds = (cropX, cropY, cropW, cropH), }; } // The widget's extent is measured from the captured frame itself: the // bounding box of non-transparent pixels (the background is injected // transparent, so the alpha channel IS the widget). Immune to DOM timing // (fonts/images/animations), needs no JS round-trip, and CANNOT break the // render: a widget that fills the canvas gets the full frame; a widget that // floats in transparent margin gets exactly its content box — used for the // preview bitmap / selection. The frame handed to the compositor is always // the FULL canvas, so the page's transparent margins keep revealing the // layers beneath (the "black square over webcam" was the crop fed to the // compositor: UniformToFill zoomed the opaque content box over the element). internal static (int X, int Y, int W, int H) FindContentBounds(byte[] pixels, int pixW, int pixH, int stride) { int minX = pixW, minY = pixH, maxX = -1, maxY = -1; for (var y = 0; y < pixH; y++) { var row = y * stride; for (var x = 0; x < pixW; x++) { if (pixels[row + x * 4 + 3] == 0) continue; if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } if (maxX < minX || maxY < minY) return (0, 0, pixW, pixH); return (minX, minY, maxX - minX + 1, maxY - minY + 1); } }