Files
LlamaCasty/Services/WebView2Manager.cs
T
gramps c01206fb8a feat: web sources frame-captured via composition (CoreWebView2CompositionController → Windows.Graphics.Capture); PNG poll + CaptureScheduler deleted
The ~30Hz CapturePreviewAsync PNG poll capped real cadence at ~20Hz
(35–165ms full-HD encode+decode), so a 60fps widget still juddered at ~1/6
speed. Replaced polling with frame-driven capture of the composition
controller's root visual — the mechanism WebView2CompositionControl and
Flutter's webview_windows use (graphics_context.cc captures the root
surface_ visual via CreateGraphicsCaptureItemFromVisual; reference:
github.com/microsoft/Windows.UI.Composition.WinUI / flutter-internal
webview_windows). Frames now arrive at the renderer's own pace; capture
memory is epoch'd ring reuse + one crop-sized shared WriteableBitmap.

New Services/WebCaptureFrameSource.cs owns GraphicsCaptureItem + free-
threaded Direct3D11CaptureFramePool + session (Straight alpha readback,
per-frame FindContentBounds → CropBounds). WebView2Manager reworked around
per-session composition controllers + one UI-thread Compositor created via
the CoreMessaging CreateDispatcherQueueController P/Invoke (the 19041
projection lacks CreateOnCurrentThread); internal seam ctor
(Dispatcher, Func<string,IScreenCaptureSource>?) for hermetic tests.
CaptureScheduler.cs deleted; the three SetCaptureInterval cadence hooks
removed; InitWebView2() moved from MainWindow ctor to Loaded (a parent
HWND must exist for the composition controller); the hidden WebViewHostPanel
overlay deleted. TransparentBackgroundScript unchanged.

Tests: WebView2ManagerTests reworked — 4 control-size + scheduler tests
dropped, FindContentBounds tests moved to WebCaptureFrameSource, ONE
integration test (Frames_PublishCroppedPreview_And_CoalesceToLatest_CarryingCropBounds)
drives the seam with a FakeWebSource + background-STA DispatcherPump.
Suite 293/293, 0 warnings.

NOTE: composition path NOT yet verified on a device — the take is the
next step. Web work committed locally only (no push per standing rule).

Docs same-commit: ai.md Slice 14 + supersede marker on Slice 11, HANDOFF,
MyMistakes (CoreMessaging DQ + namespace-landmine recipe), TASK 17,
Controls/ViewModels/Services indexes.
2026-09-14 16:14:00 -07:00

470 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Windows.Graphics.Capture;
using Windows.System;
using Windows.UI.Composition;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Owns the composition-based web sources (one per widget). A session renders
/// its widget through a <see cref="CoreWebView2CompositionController"/> whose
/// visual tree lives off-screen on the WPF UI thread (OBS web-source model:
/// the page renders at the MASTER CANVAS size 1920×1080, stable, never tracked);
/// the controller's root visual is captured by a <see cref="WebCaptureFrameSource"/>
/// at Chromium's own presentation cadence. The preview is crop-copied to a shared
/// WriteableBitmap on the UI thread (dispatcher-coalesced latest-wins, mirror of
/// ScreenCaptureManager); the FramePump reads <see cref="GetLatestFrame"/>.
///
/// The composition stack needs a DispatcherQueue on this thread (CsWinRT does not
/// project <c>CreateOnCurrentThread</c> on the 19041 SDK — only the dedicated
/// variant — so it is installed via a CoreMessaging P/Invoke, the documented
/// fallback the WebView2CompositionControl / webview_windows embedders use).
/// Everything composition-side therefore runs on the WPF UI thread; only the
/// frame conversion and alpha-bbox scan run on the capture worker.
///
/// Seam: an internal <c>Func&lt;string, IScreenCaptureSource&gt;</c> source
/// factory substitutes the whole WebView2+item creation in tests — the manager
/// then owns the session/coalesce/crop/preview plumbing exactly as in production.
/// </summary>
public sealed class WebView2Manager : IDisposable
{
private readonly Dispatcher _dispatcher;
private readonly Func<string, IScreenCaptureSource>? _sessionSourceFactory;
private readonly Dictionary<string, WebSourceSession> _sessions = new();
private Task<CoreWebView2Environment>? _environmentTask;
private DispatcherQueueController? _dispatcherQueueController;
private Windows.UI.Composition.Compositor? _compositor;
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
private sealed class WebSourceSession
{
public required string Key { get; init; }
public CoreWebView2? Web;
public CoreWebView2CompositionController? Controller;
public IScreenCaptureSource? Source;
public Action<VideoFrame>? FrameHandler;
public VideoFrame? LatestFrame;
public WriteableBitmap? PreviewBitmap;
// Buffer reuse (2026-09-04 lesson): the preview crop copy reuses one
// scratch array while the widget's extent holds — no per-frame LOH mint.
public byte[]? CropScratch;
public bool Disposed;
public bool Initialized;
public bool FramePending;
public bool FirstCaptureLogged;
public int WidgetLogsRemaining;
}
public WebView2Manager(Dispatcher dispatcher)
: this(dispatcher, null)
{
}
/// <summary>Test seam: the source factory replaces WebView2+composition
/// creation, so the session/coalesce/crop/preview plumbing is testable
/// without the runtime.</summary>
internal WebView2Manager(Dispatcher dispatcher, Func<string, IScreenCaptureSource>? sessionSourceFactory)
{
_dispatcher = dispatcher;
_sessionSourceFactory = sessionSourceFactory;
}
public void Register(Source source)
{
if (_sessions.ContainsKey(source.Id)) return;
_sessions[source.Id] = new WebSourceSession { Key = source.Id };
_ = InitializeAsync(source, _sessions[source.Id]);
}
public void Unregister(string sourceId) => RemoveSession(sourceId);
public void OnWebUriChanged(Source source)
{
if (!_sessions.TryGetValue(source.Id, out var session)) return;
if (session.Disposed || !session.Initialized) return;
if (string.IsNullOrEmpty(source.WebUri))
{
session.LatestFrame = null;
session.Web?.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
return;
}
Navigate(source.WebUri, session);
}
public VideoFrame? GetLatestFrame(string sourceId)
{
return _sessions.TryGetValue(sourceId, out var session) ? session.LatestFrame : null;
}
// Injected BEFORE the document is parsed (AddScriptToExecuteOnDocumentCreatedAsync) so
// the widget page's own CSS/scripts cannot repaint html/body opaque — the reason the
// late NavigationStarting/NavigationCompleted ExecuteScriptAsync injection lost the
// fight (page styles ran first). A transparent capture REQUIRES the transparency in
// place before the page paints, on the composition surface too.
//
// The OLD inline `element.style.background='transparent'` form lost to any page CSS:
// a widget that paints a background-COLOR on a container (or html/body with stronger
// specificity) after connect re-opaques the capture → the "black opaque box" in the
// recording while the early frames were transparent. This is a solved problem in the
// overlay ecosystem — OBS browser sources use a Custom CSS override and the decade-
// validated formula for arbitrary pages is a `!important` background-color wipe
// (OBS Forums 2016 "body { background-color: rgba(0,0,0,0) !important }",
// https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/,
// and the div-level variant for stubborn widgets, woahtech.com OBS custom-CSS guide).
// A `<style>` node injected before parse, with `!important`, outranks every page rule
// (only an author !important beats a later document-order !important; ours runs first).
// Only background-COLOR was wiped at first — the 15:34 take showed the widget's own
// full-canvas backdrop surviving as the box's black void: that paint is a CSS
// background-IMAGE (gradient/backdrop), and the OBS fix for the same symptom is
// `background: none !important` / background-image none, not color alone
// (obsproject/obs-studio#6659 — "set the CSS for html and body to background: none
// !important"). Wipe background-image too; HTML overlay art (the widget's content) is
// <img>/DOM/CSS shapes and survives — only container decorations are removed, which is
// OBS-browser-source semantics for a transparent overlay.
internal const string TransparentBackgroundScript =
"(function(){" +
"if(document.getElementById('ytl-transparent-bg'))return;" +
"var s=document.createElement('style');" +
"s.id='ytl-transparent-bg';" +
"s.appendChild(document.createTextNode(" +
"'html,body{background-color:transparent!important;background-image:none!important;margin:0!important;overflow:hidden!important;}'" +
"'html,body,html *{background-color:transparent!important;background-image:none!important;}'" +
"));" +
"(document.head||document.documentElement).appendChild(s);" +
"})();";
private async Task InitializeAsync(Source source, WebSourceSession session)
{
try
{
IScreenCaptureSource sourceImpl;
if (_sessionSourceFactory != null)
{
// Test path: the factory hands over a fully-configured source; the
// branch has no awaits, so Register() completes the session inline.
sourceImpl = _sessionSourceFactory(source.Id);
}
else
{
var controller = await CreateControllerAsync();
if (controller == null)
{
RemoveSession(source.Id);
return;
}
session.Controller = controller;
session.Web = controller.CoreWebView2;
if (session.Web == null)
{
RemoveSession(source.Id);
return;
}
// Pre-page hook: runs after the global object exists but BEFORE the document
// parses / any page script runs — must complete before navigating away from
// about:blank for it to apply to the widget document.
await session.Web.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
session.Web.NavigationStarting += (_, _) =>
{
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
};
session.Web.NavigationCompleted += (_, e) =>
{
_ = session.Web?.ExecuteScriptAsync(TransparentBackgroundScript);
if (e.IsSuccess && session.Web?.Source is { } src && src != "about:blank")
{
// Log-only diagnostic on the REAL widget document (a few post-paint
// frames so styles/backgrounds settle). The first-capture dump only
// ever saw about:blank; the old PNG widget dumps are gone with the
// PNG path — alpha stats now ride the frame-driven channel.
session.WidgetLogsRemaining = 3;
var rearm = session;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(30));
if (!rearm.Disposed) rearm.WidgetLogsRemaining = 3;
});
}
};
session.Web.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
if (!string.IsNullOrEmpty(source.WebUri))
Navigate(source.WebUri, session);
session.Initialized = true;
sourceImpl = new WebCaptureFrameSource(source.Id, CreateCaptureItem(controller));
}
session.Source = sourceImpl;
session.FrameHandler = frame => OnFrameAvailable(session, frame);
sourceImpl.FrameAvailable += session.FrameHandler;
await sourceImpl.StartAsync();
session.Initialized = true;
}
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: init failed for '{source.Name}': {ex.Message}");
RemoveSession(source.Id);
}
}
// The environment MUST exist before the controller is created — it's created once
// and shared by every session (one browser process). These flags (the
// Electron-class embedder answer to Chromium throttling hidden pages — the moment
// the app window is unfocused or covered, rAF parks and timers clamp to ~1s) keep
// the renderer at full rate: --disable-backgrounding-occluded-windows,
// --disable-renderer-backgrounding, and disable the native-window occlusion
// detector that marks the page hidden.
private Task<CoreWebView2Environment> GetEnvironmentAsync()
{
if (_environmentTask != null) return _environmentTask;
var options = new CoreWebView2EnvironmentOptions
{
AdditionalBrowserArguments =
"--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion",
};
return _environmentTask = CoreWebView2Environment.CreateAsync(null, null, options);
}
/// <summary>Creates the composition controller off-screen under the main
/// window (the parent HWND is required, the visual never mounts a window
/// target). Set up is copied from the WebView2CompositionControl /
/// webview_windows embedders: raw-pixel bounds at the master canvas,
/// fixed rasterization scale, transparent default background.</summary>
private async Task<CoreWebView2CompositionController?> CreateControllerAsync()
{
var mainWindow = Application.Current?.MainWindow;
var hwnd = mainWindow == null
? IntPtr.Zero
: new System.Windows.Interop.WindowInteropHelper(mainWindow).Handle;
if (hwnd == IntPtr.Zero)
{
AppLog.Write("WebView2Manager: main window handle unavailable — web source not created");
return null;
}
var env = await GetEnvironmentAsync();
if (_compositor == null) _compositor = CreateCompositorForCurrentThread();
var controller = await env.CreateCoreWebView2CompositionControllerAsync(hwnd);
controller.Bounds = new Rectangle(0, 0, 1920, 1080);
controller.BoundsMode = CoreWebView2BoundsMode.UseRawPixels;
controller.ShouldDetectMonitorScaleChanges = false;
controller.RasterizationScale = 1.0;
controller.IsVisible = true;
controller.DefaultBackgroundColor = System.Drawing.Color.Transparent;
return controller;
}
private void Navigate(string url, WebSourceSession session)
{
try { session.Web?.Navigate(url); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// The visual tree webview_windows wires (offscreen mode): a root container
// visual sized to the master canvas (the captured subject — the Flutter
// plugin calls GraphicsCaptureItem.CreateFromVisual on this root), with the
// controller's root visual target set to a child that fills the root via
// RelativeSizeAdjustment. BoundsMode raw pixels + RasterizationScale 1.0 keep
// the capture at exactly 1920×1080 regardless of monitor scale.
private GraphicsCaptureItem CreateCaptureItem(CoreWebView2CompositionController controller)
{
var compositor = _compositor!;
var root = compositor.CreateContainerVisual();
root.Size = new System.Numerics.Vector2(1920f, 1080f);
root.IsVisible = true;
var webViewVisual = compositor.CreateContainerVisual();
webViewVisual.RelativeSizeAdjustment = new System.Numerics.Vector2(1f, 1f);
root.Children.InsertAtTop(webViewVisual);
controller.RootVisualTarget = webViewVisual;
return GraphicsCaptureItem.CreateFromVisual(root);
}
// CsWinRT does not project DispatcherQueueController.CreateOnCurrentThread
// on the 19041 SDK (only CreateOnDedicatedThread [CS0117, verified by probe]);
// the documented fallback — "how to create a DispatcherQueue on the current
// thread" (learn.microsoft.com CoreMessaging docs) — is CreateDispatcherQueueController
// from CoreMessaging.dll, wrapped via FromAbi (the CaptureInterop pattern). One
// per UI thread, then new Compositor() (which requires the DQ) works here.
private Windows.UI.Composition.Compositor CreateCompositorForCurrentThread()
{
var options = new DispatcherQueueOptions
{
DwSize = Marshal.SizeOf<DispatcherQueueOptions>(),
ThreadType = 2, // DQTYPE_THREAD_CURRENT
ApartmentType = 2, // DQTAT_COM_STA — the WPF UI thread is STA
};
var hr = CreateDispatcherQueueController(options, out var controllerPtr);
if (hr != 0)
throw Marshal.GetExceptionForHR(hr)!;
_dispatcherQueueController = DispatcherQueueController.FromAbi(controllerPtr);
return new Windows.UI.Composition.Compositor();
}
[StructLayout(LayoutKind.Sequential)]
private struct DispatcherQueueOptions
{
public int DwSize;
public int ThreadType;
public int ApartmentType;
}
[DllImport("CoreMessaging.dll")]
private static extern int CreateDispatcherQueueController(
DispatcherQueueOptions options, out IntPtr dispatcherQueueController);
private void OnFrameAvailable(WebSourceSession session, VideoFrame frame)
{
if (session.Disposed) return;
session.LatestFrame = frame;
if (_dispatcher.CheckAccess())
{
UpdatePreview(session);
return;
}
if (session.FramePending) return;
session.FramePending = true;
_dispatcher.BeginInvoke(() =>
{
session.FramePending = false;
if (!session.Disposed)
UpdatePreview(session);
}, DispatcherPriority.Render);
}
private void UpdatePreview(WebSourceSession session)
{
var frame = session.LatestFrame;
if (frame == null) return;
var (cropX, cropY, cropW, cropH) = frame.CropBounds ?? (0, 0, frame.Width, frame.Height);
if (cropW < 1 || cropH < 1)
{
cropX = 0; cropY = 0;
cropW = frame.Width; cropH = frame.Height;
}
// The preview is sized to the widget's alpha bounding box (the compositor
// gets the FULL canvas + CropBounds; the crop is only for the preview).
// A fresh box (widget resizes / first content paints) mints a new bitmap
// once — WPF re-renders a reused mutable bitmap after WritePixels, so
// later frames only re-copy into the same one.
if (session.PreviewBitmap == null
|| session.PreviewBitmap.PixelWidth != cropW
|| session.PreviewBitmap.PixelHeight != cropH)
{
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
PreviewBitmapChanged?.Invoke(session.Key, session.PreviewBitmap);
}
var need = cropW * cropH * 4;
var scratch = session.CropScratch is { } s && s.Length >= need
? s
: (session.CropScratch = new byte[need]);
var stride = frame.Stride;
if (cropW == frame.Width && cropH == frame.Height && cropX == 0 && cropY == 0)
Buffer.BlockCopy(frame.BgraPixels, 0, scratch, 0, need);
else
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(frame.BgraPixels, (cropY + r) * stride + cropX * 4, scratch, r * cropW * 4, cropW * 4);
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), scratch, cropW * 4, 0);
// alpha[min/max/mean/zero%] — the one number that decides whether a widget's
// capture really has transparent margins (0% = opaque, ~100% if the page
// painted transparent-only). Sampled every 16th pixel like the original log.
if (!session.FirstCaptureLogged)
{
session.FirstCaptureLogged = true;
AppLog.Write(
$"WebView2Manager: first capture for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
if (session.WidgetLogsRemaining > 0)
{
var dumpNo = 3 - session.WidgetLogsRemaining + 1;
session.WidgetLogsRemaining--;
AppLog.Write(
$"WebView2Manager: widget capture [{dumpNo}/3] for '{session.Key}' {frame.Width}x{frame.Height} " +
$"{AlphaStats(frame.BgraPixels, frame.Width, frame.Height, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
}
private static string AlphaStats(byte[] pixels, int pixW, int pixH, int stride)
{
int minA = 255, maxA = 0;
long sumA = 0, nA = 0, zeroA = 0;
for (var y = 0; y < pixH; y += 16)
{
var row = y * stride;
for (var x = 0; x < pixW; x += 16)
{
var a = pixels[row + x * 4 + 3];
if (a < minA) minA = a;
if (a > maxA) maxA = a;
sumA += a;
if (a == 0) zeroA++;
nA++;
}
}
return $"alpha[min={minA},max={maxA},mean={sumA / (double)nA:F1},zero={100.0 * zeroA / nA:F1}%]";
}
private void RemoveSession(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session)) return;
session.Disposed = true;
var src = session.Source;
if (src != null)
{
if (session.FrameHandler != null)
src.FrameAvailable -= session.FrameHandler;
_ = SafeStopAsync(src);
}
try { session.Controller?.Close(); } catch { }
session.Web = null;
_sessions.Remove(sourceId);
}
private static async Task SafeStopAsync(IScreenCaptureSource source)
{
try { await source.StopAsync(); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: stopping web capture failed: {ex.Message}"); }
}
public void Dispose()
{
foreach (var id in _sessions.Keys.ToList())
RemoveSession(id);
}
}