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 _sessions = new(); private Task? _environmentTask; private int _captureIntervalMs = 200; // idle pace; recording drops it to ~30Hz via SetCaptureInterval public event Action? 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); } /// 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. 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( ""); 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. private const string TransparentBackgroundScript = "document.documentElement.style.background='transparent';" + "document.documentElement.style.overflow='hidden';" + "document.documentElement.style.margin='0';" + "if(document.body){document.body.style.background='transparent';" + "document.body.style.overflow='hidden';document.body.style.margin='0';}"; 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( ""); 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 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); } }