Files
LlamaCasty/Services/WebView2Manager.cs
T
gramps 0f72c53aa5 fix(web): wipe background-image too — the widget's black void is a CSS backdrop gradient, not a color
The 15:34 take's box interior is the widget's OWN full-canvas opaque paint — a
black void with wide content strips at the top/bottom and a right-edge bar —
arriving AFTER the first-second blank-transparent dumps. A background-color-only
!important wipe (b4bba4b) can't touch it because CSS gradients/backdrops are
background-IMAGE, not background-color.

OBS's fix for this exact symptom is background-image:none (obsproject/obs-studio#6659:
"set the CSS for html and body to background: none !important"). Injection now wipes
background-image:none!important on html,body,html * alongside the color wipe; HTML
overlay art (<img>/DOM/CSS shapes) survives — that is browser-source semantics.

Also re-arms WidgetDumpRemaining=5 ~30s after nav so the next take dumps the real
document INSIDE the recording window (the previous dumps proved blank at +1s and
missed the later backdrop paint).

9/9 WebView2Manager tests, 0 warnings.
2026-09-10 15:42:26 -07:00

473 lines
20 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.Diagnostics;
using System.Drawing;
using System.IO;
using System.Text.Json;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.Wpf;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
public sealed class WebView2Manager : IDisposable
{
private readonly Panel _hostPanel;
private readonly Dispatcher _dispatcher;
private readonly Dictionary<string, WebSourceSession> _sessions = new();
private Task<CoreWebView2Environment>? _environmentTask;
private int _captureIntervalMs = 200; // idle pace; recording drops it to ~30Hz via SetCaptureInterval
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
private sealed class WebSourceSession
{
public required WebView2 Control { get; init; }
public required CaptureScheduler Scheduler { get; init; }
public VideoFrame? LatestFrame;
public bool Disposed;
public bool Initialized;
// Buffer reuse (2026-09-04): every capture tick used to mint a fresh
// 8.3MB canvas array + a fresh crop array (~83MB/s of LOH at 10Hz →
// gen2 pauses surfaced as the pump's 35-40ms "worst render" spikes).
// CanvasScratch is reused while the size holds. The frame handed to the
// compositor is always the FULL canvas (transparent margins reveal layers
// beneath); the crop is only for the preview WriteableBitmap. Epoch is
// monotonic per canvas generation (identity-keyed consumers see each frame).
public byte[]? CanvasScratch;
public readonly byte[]?[] OutRing = new byte[8][];
public int OutRingNext;
public long Epoch;
public WriteableBitmap? PreviewBitmap;
public bool DebugPngWritten;
public int WidgetDumpRemaining;
public int TelemetryCaptureCount;
public byte[] RentOutBuffer(int size)
{
for (var tries = 0; tries < OutRing.Length; tries++)
{
var idx = (OutRingNext + tries) % OutRing.Length;
var buf = OutRing[idx];
if (buf != null && buf.Length == size)
{
OutRingNext = (idx + 1) % OutRing.Length;
return buf;
}
}
var slot = OutRingNext;
OutRingNext = (slot + 1) % OutRing.Length;
var fresh = new byte[size];
OutRing[slot] = fresh;
return fresh;
}
}
public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
{
_hostPanel = hostPanel;
_dispatcher = dispatcher;
}
public void Register(Source source)
{
if (_sessions.ContainsKey(source.Id)) return;
// OBS model: the page renders at the MASTER CANVAS size (1920×1080),
// stable, never tracked. Full-bleed widgets (designed for the canvas)
// render fully instead of being clipped to a small box-sized viewport;
// dragging the box never reflows the page. The capture is tightly
// cropped to the widget's ALPHA bounding box (FindContentBounds) and
// the display (Stretch=Fill) maps it flush under the box, anchored at
// (0,0) — no DOM query, so no timing fragility; worst case is the
// full frame, which is the confirmed-good rendering.
var webView = new WebView2
{
Width = 1920,
Height = 1080,
DefaultBackgroundColor = System.Drawing.Color.Transparent,
Visibility = System.Windows.Visibility.Visible,
};
Canvas.SetLeft(webView, -5000);
Canvas.SetTop(webView, -5000);
_hostPanel.Children.Add(webView);
var scheduler = new CaptureScheduler(
_dispatcher,
_captureIntervalMs,
() => CaptureFrame(source.Id));
var session = new WebSourceSession
{
Control = webView,
Scheduler = scheduler,
};
_sessions[source.Id] = session;
_ = InitializeAsync(source, session);
}
/// <summary>Changes the capture cadence for every session. Recording runs at
/// ~30Hz (33ms) — the practical PNG-capture ceiling — idle drops back to 200ms so
/// the preview alone doesn't burn a core decoding full-HD captures.</summary>
public void SetCaptureInterval(int milliseconds)
{
_captureIntervalMs = milliseconds;
foreach (var session in _sessions.Values)
if (!session.Disposed)
session.Scheduler.SetInterval(milliseconds);
}
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.Control.CoreWebView2?.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). CapturePreviewAsync "will always honor a webpage's
// background content" (MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md), so a
// transparent capture REQUIRES the transparency to be in place before the page paints.
//
// 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
{
await session.Control.EnsureCoreWebView2Async(await GetEnvironmentAsync());
var cws = session.Control.CoreWebView2!;
// 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 cws.AddScriptToExecuteOnDocumentCreatedAsync(TransparentBackgroundScript);
cws.NavigationStarting += (_, _) =>
{
_ = cws.ExecuteScriptAsync(TransparentBackgroundScript);
};
cws.NavigationCompleted += (_, e) =>
{
_ = cws.ExecuteScriptAsync(TransparentBackgroundScript);
if (e.IsSuccess && session.Control.CoreWebView2?.Source is { } src && src != "about:blank")
{
// Arm a one-shot diagnostic dump of the REAL widget document
// (a few post-paint captures so styles/backgrounds settle) —
// the old first-capture dump only ever saw the about:blank
// document, so whether this widget's capture actually HAS the
// transparent margins the injection promises has never been
// verified (MyMistakes: "? VERIFY").
session.WidgetDumpRemaining = 5;
// The first five dumps fire within ~1s of completion (usually BEFORE the
// recording starts) — re-arm ~30s in so the same real document gets dumped
// INSIDE a recording's window too. Next take therefore nukes the gap that
// hid what the page actually painted while recording.
var rearm = session;
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(30));
rearm.WidgetDumpRemaining = 5;
});
}
_ = CaptureFrame(source.Id);
};
session.Initialized = true;
session.Control.CoreWebView2.NavigateToString(
"<html><body style='background:transparent;margin:0'></body></html>");
if (!string.IsNullOrEmpty(source.WebUri))
Navigate(source.WebUri, session);
session.Scheduler.Start();
}
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: init failed for '{source.Name}': {ex.Message}");
RemoveSession(source.Id);
}
}
// The widget page is rendered off-screen; Chromium throttles hidden pages
// (rAF parked, timers clamped to ~1s) the moment the app window is unfocused or
// covered — WebView2Feedback#1172/#3070, Chrome 88 timer throttling. These flags
// (the Electron-class embedder answer) 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.
// The environment MUST exist before EnsureCoreWebView2Async — it's created once
// and shared by every session (one browser process).
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);
}
private void Navigate(string url, WebSourceSession session)
{
try { session.Control.CoreWebView2?.Navigate(url); }
catch (Exception ex) { AppLog.Write($"WebView2Manager: navigate failed: {ex.Message}"); }
}
// 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). This is immune to
// DOM timing (fonts/images/animations), needs no JS round-trip, and
// CANNOT break the render: if the widget fills the canvas the box equals
// the full frame; if it floats in transparent margin the box is the
// measured content extent — used ONLY 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 to cover the whole 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);
}
// 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.
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 async Task CaptureFrame(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
var sw = Stopwatch.StartNew();
try
{
var webView = session.Control;
if (webView.CoreWebView2 == null) return;
using var ms = new MemoryStream();
await webView.CoreWebView2.CapturePreviewAsync(
CoreWebView2CapturePreviewImageFormat.Png, ms);
if (!session.DebugPngWritten)
{
session.DebugPngWritten = true;
try
{
ms.Position = 0;
var dbg = Path.Combine(Path.GetTempPath(), $"ytLive-web-{sourceId}.png");
File.WriteAllBytes(dbg, ms.ToArray());
AppLog.Write($"WebView2Manager: debug capture dump -> {dbg}");
}
catch (Exception dex) { AppLog.Write($"WebView2Manager: debug dump failed: {dex.Message}"); }
}
ms.Position = 0;
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.StreamSource = ms;
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze();
var formatted = new FormatConvertedBitmap(bitmap,
PixelFormats.Bgra32, null, 0);
var pixW = formatted.PixelWidth;
var pixH = formatted.PixelHeight;
if (pixW < 1 || pixH < 1) return;
var need = pixW * pixH * 4;
var pixels = session.CanvasScratch is { } scratch && scratch.Length >= need
? scratch
: (session.CanvasScratch = new byte[need]);
formatted.CopyPixels(pixels, pixW * 4, 0);
var stride = pixW * 4;
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
if (session.Epoch == 0)
{
AppLog.Write(
$"WebView2Manager: first capture for '{sourceId}' {pixW}x{pixH} " +
$"{AlphaStats(pixels, pixW, pixH, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
if (session.WidgetDumpRemaining > 0)
{
var dumpNo = 5 - session.WidgetDumpRemaining + 1;
session.WidgetDumpRemaining--;
try
{
var dbg = Path.Combine(Path.GetTempPath(), $"ytLive-web-{sourceId}-w{dumpNo}.png");
File.WriteAllBytes(dbg, ms.ToArray());
AppLog.Write(
$"WebView2Manager: widget capture [{dumpNo}/5] for '{sourceId}' {pixW}x{pixH} " +
$"{AlphaStats(pixels, pixW, pixH, stride)} " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH}) -> {dbg}");
}
catch (Exception dex)
{
AppLog.Write($"WebView2Manager: widget dump failed: {dex.Message}");
}
}
var outPixels = session.RentOutBuffer(cropW * cropH * 4);
if (cropW == pixW && cropH == pixH && cropX == 0 && cropY == 0)
Buffer.BlockCopy(pixels, 0, outPixels, 0, outPixels.Length);
else
for (var r = 0; r < cropH; r++)
Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * cropW * 4, cropW * 4);
if (session.PreviewBitmap == null
|| session.PreviewBitmap.PixelWidth != cropW || session.PreviewBitmap.PixelHeight != cropH)
session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null);
session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0);
session.LatestFrame = new VideoFrame(pixW, pixH, pixels)
{
Epoch = ++session.Epoch,
CropBounds = (cropX, cropY, cropW, cropH),
};
// The preview bitmap is now MUTABLE and reused (CameraManager precedent):
// WPF re-renders it after WritePixels; handlers must not Freeze it.
var preview = session.PreviewBitmap;
_ = _dispatcher.BeginInvoke(() =>
{
PreviewBitmapChanged?.Invoke(sourceId, preview);
});
// Capture-cost telemetry for the first ~30 captures of a session: PNG encode
// (CapturePreviewAsync) + decode cost decide whether ~30Hz recording cadence
// is affordable or must drop to ~20Hz. The FramePump drops frames (never
// time-lapses) if the UI thread's GC churn starves it, so startup.log shows
// the real budget before anything is tuned further.
if (session.TelemetryCaptureCount < 30)
{
session.TelemetryCaptureCount++;
AppLog.Write(
$"WebView2Manager: capture #{session.TelemetryCaptureCount} for '{sourceId}' " +
$"{pixW}x{pixH} took {sw.ElapsedMilliseconds}ms");
}
}
catch (ObjectDisposedException) { }
catch (InvalidOperationException) { }
catch (Exception ex)
{
AppLog.Write($"WebView2Manager: capture failed for '{sourceId}': {ex.Message}");
}
}
private void RemoveSession(string sourceId)
{
if (!_sessions.TryGetValue(sourceId, out var session)) return;
session.Disposed = true;
session.Scheduler.Stop();
_hostPanel.Children.Remove(session.Control);
try { session.Control.Dispose(); } catch { }
_sessions.Remove(sourceId);
}
public void Dispose()
{
foreach (var id in _sessions.Keys.ToList())
RemoveSession(id);
}
}