0f441d825a
take-19-2/take-20 'webcam black square under the web widget': the capture was alpha-cropped (FindContentBounds) and that CROP was fed to the compositor, whose UniformToFill zoomed the opaque content box to cover the whole element rect the moment the widget drew any content (idle transparent page = full-frame crop = the correct viewport, hence take-1-good/take-2-bad). OBS model verified: the page is a fixed 1920x1080 canvas and the element rect is a viewport onto it — measure with the alpha crop (preview/selection), hand the composite the FULL canvas on an 8-deep ring + Epoch (same identity discipline as camera/screen). Regression test: Composite_FullCanvasWebSource_TransparentMarginsRevealWebcam (the reveal contract: margin pixel = webcam, badge pixel = widget).
327 lines
12 KiB
C#
327 lines
12 KiB
C#
using System;
|
||
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();
|
||
|
||
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||
|
||
private sealed class WebSourceSession
|
||
{
|
||
public required WebView2 Control { get; init; }
|
||
public required DispatcherTimer CaptureTimer { get; init; }
|
||
public VideoFrame? LatestFrame;
|
||
public bool Disposed;
|
||
public bool Initialized;
|
||
|
||
// Buffer reuse (2026-09-04, same lesson as the capture/camera rings): 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). The full-canvas slots rotate an 8-deep ring with a
|
||
// monotonic Epoch (identity-keyed consumers — the compositor's paste cache —
|
||
// must see each generation); the crop slots feed the preview WriteableBitmap,
|
||
// recreated only when the cropped size changes.
|
||
public readonly byte[]?[] CanvasRing = new byte[8][];
|
||
public int CanvasRingNext;
|
||
public long Epoch;
|
||
public readonly byte[]?[] OutRing = new byte[8][];
|
||
public int OutRingNext;
|
||
public WriteableBitmap? PreviewBitmap;
|
||
|
||
public byte[] RentCanvasBuffer(int size)
|
||
{
|
||
for (var tries = 0; tries < CanvasRing.Length; tries++)
|
||
{
|
||
var idx = (CanvasRingNext + tries) % CanvasRing.Length;
|
||
var buf = CanvasRing[idx];
|
||
if (buf != null && buf.Length == size)
|
||
{
|
||
CanvasRingNext = (idx + 1) % CanvasRing.Length;
|
||
return buf;
|
||
}
|
||
}
|
||
var slot = CanvasRingNext;
|
||
CanvasRingNext = (slot + 1) % CanvasRing.Length;
|
||
var fresh = new byte[size];
|
||
CanvasRing[slot] = fresh;
|
||
return fresh;
|
||
}
|
||
|
||
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 alpha bounding box
|
||
// (FindContentBounds) measures the widget's extent for the UI/selection;
|
||
// the COMPOSITE consumes the FULL canvas — the element rect is a viewport
|
||
// onto the page, whose transparent margins reveal the layers beneath.
|
||
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 timer = new DispatcherTimer
|
||
{
|
||
Interval = TimeSpan.FromMilliseconds(100),
|
||
};
|
||
|
||
var session = new WebSourceSession
|
||
{
|
||
Control = webView,
|
||
CaptureTimer = timer,
|
||
};
|
||
|
||
_sessions[source.Id] = session;
|
||
|
||
timer.Tick += (_, _) =>
|
||
{
|
||
if (!session.Disposed && session.Initialized)
|
||
_ = CaptureFrame(source.Id);
|
||
};
|
||
|
||
_ = InitializeAsync(source, session);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
private async Task InitializeAsync(Source source, WebSourceSession session)
|
||
{
|
||
try
|
||
{
|
||
await session.Control.EnsureCoreWebView2Async();
|
||
|
||
var cws = session.Control.CoreWebView2!;
|
||
|
||
cws.NavigationStarting += (_, _) =>
|
||
{
|
||
_ = cws.ExecuteScriptAsync(
|
||
"document.documentElement.style.background='transparent';" +
|
||
"document.documentElement.style.overflow='hidden';" +
|
||
"document.body.style.background='transparent';" +
|
||
"document.body.style.overflow='hidden';");
|
||
};
|
||
|
||
cws.NavigationCompleted += (_, _) =>
|
||
{
|
||
_ = cws.ExecuteScriptAsync(
|
||
"document.documentElement.style.background='transparent';" +
|
||
"document.documentElement.style.overflow='hidden';" +
|
||
"document.body.style.background='transparent';" +
|
||
"document.body.style.overflow='hidden';");
|
||
_ = 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.CaptureTimer.Start();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
AppLog.Write($"WebView2Manager: init failed for '{source.Name}': {ex.Message}");
|
||
RemoveSession(source.Id);
|
||
}
|
||
}
|
||
|
||
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). 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 take-19-2/take-20 "black square
|
||
// over the webcam" was the CROP fed to the compositor: UniformToFill then
|
||
// zoomed the opaque content box to cover the whole element rect).
|
||
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);
|
||
}
|
||
|
||
private async Task CaptureFrame(string sourceId)
|
||
{
|
||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||
|
||
try
|
||
{
|
||
var webView = session.Control;
|
||
if (webView.CoreWebView2 == null) return;
|
||
|
||
using var ms = new MemoryStream();
|
||
await webView.CoreWebView2.CapturePreviewAsync(
|
||
CoreWebView2CapturePreviewImageFormat.Png, ms);
|
||
|
||
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.RentCanvasBuffer(need);
|
||
formatted.CopyPixels(pixels, pixW * 4, 0);
|
||
|
||
var stride = pixW * 4;
|
||
|
||
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
|
||
|
||
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);
|
||
|
||
// The compositor consumes the FULL canvas (alpha intact): the element rect
|
||
// scales the page region onto itself and the transparent margins reveal what
|
||
// is beneath. The alpha crop above exists only for the preview/selection.
|
||
session.LatestFrame = new VideoFrame(pixW, pixH, pixels) { Epoch = ++session.Epoch };
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
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.CaptureTimer.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);
|
||
}
|
||
}
|