Files
LlamaCasty/Services/WebView2Manager.cs
T
gramps 5e78065c2d fix(web): web-layer capture cadence 10Hz → ~30Hz while recording — widget animations no longer play ~1/6 speed
The recording is 60fps but WebView2 capture was a blind 100ms DispatcherTimer = 10Hz;
each captured web frame repeated ~6x into the file caps web animation at the capture
rate, not the page's (user: 'the animation appears to be too slow').

- New CaptureScheduler (Services/CaptureScheduler.cs): per-session dispatcher timer
  that DROPS a tick while a capture is in flight (latest-wins, never queues) — the
  guard that makes a higher cadence safe: concurrent full-HD PNG CapturePreviewAsync
  calls (~10-30ms each, slow per WebView2Feedback#20) would stack CPU and publish
  stale-after-fresh. Effective cadence = max(interval, capture duration).
- Cadence: SetCaptureInterval(33) on record/stream start, (200) idle — applied via
  MainViewModel.Streaming.Operations.cs.
- De-throttle the hidden page: shared CoreWebView2Environment created BEFORE
  EnsureCoreWebView2Async with --disable-backgrounding-occluded-windows
  --disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion.
  Off-screen WebView2 is a hidden page when the host window is unfocused/covered and
  Chromium then parks rAF and clamps timers to ~1s (WebView2Feedback#1172/#3070,
  Chrome-88 timer-throttling blog).
- Telemetry: first 30 captures per session log elapsed ms (PNG encode + decode) to
  startup.log — that decides whether ~30Hz stays or drops to ~20Hz; the FramePump
  drops frames (never time-lapses, slice 10) if UI-thread GC churn starves it.
- ONE test: CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes
  (deterministic TCS-driven, no WebView2 runtime). Suite 290/291 — sole failure the
  pre-existing compositor pixel test.
- Docs same-commit: ai.md slice 11, MyMistakes.md, HANDOFF.

References: https://github.com/MicrosoftEdge/WebView2Feedback/issues/1172
https://github.com/MicrosoftEdge/WebView2Feedback/issues/3070
https://github.com/MicrosoftEdge/WebView2Feedback/issues/20
https://developer.chrome.com/blog/timer-throttling-in-chrome-88
2026-09-10 10:25:41 -07:00

400 lines
16 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 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.
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 += (_, _) =>
{
_ = cws.ExecuteScriptAsync(TransparentBackgroundScript);
_ = 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);
}
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)
{
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++;
}
}
AppLog.Write(
$"WebView2Manager: first capture for '{sourceId}' {pixW}x{pixH} " +
$"alpha[min={minA},max={maxA},mean={sumA / (double)nA:F1},zero={100.0 * zeroA / nA:F1}%] " +
$"contentBounds=({cropX},{cropY},{cropW},{cropH})");
}
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);
}
}