b4bba4bf68
The real-widget dumps (12:54/13:50) proved the OLD inline element.style.background='transparent' injection holds only while the page has nothing to paint: the capture was alpha-transparent, yet the recording showed a black opaque box over the whole element rect (1231,679 705x396) once the widget connected and repainted a container background-COLOR — CapturePreviewAsync always honors page CSS (MicrosoftEdge/WebView2Feedback specs/BackgroundColor.md), so any page-painted background wins over DefaultBackgroundColor. This is the OBS-solved class (all web-uri resources paint their own background): browser sources use a Custom CSS override, and the decade-validated formula for arbitrary pages is a pre-parse <style> with 'background-color: transparent !important' — https://obsproject.com/forum/threads/translucent-transparent-browser-source.59549/ ('body { background-color: rgba(0,0,0,0) !important }') plus the div-level variant for stubborn widgets (woahtech.com OBS custom-CSS guide). Injection is now an idempotent pre-parse style element wiping background-color on html,body,html * with !important (outranks every page rule, runs before page parse via AddScriptToExecuteOnDocumentCreatedAsync). Only background-COLOR is targeted — background images and widget art survive. Regression test asserts the element-wide !important form and that the losing inline form is gone. 9/9 WebView2Manager tests, 0 warnings.
456 lines
19 KiB
C#
456 lines
19 KiB
C#
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 is wiped — background images and the widget art survive.
|
||
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;margin:0!important;overflow:hidden!important;}'" +
|
||
"'html,body,html *{background-color:transparent!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;
|
||
}
|
||
_ = 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);
|
||
}
|
||
}
|