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
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>Per-session web capture pace: a DispatcherTimer that drops ticks while a
|
||||
/// capture is in flight (latest-wins). Raising the naive 10Hz cadence to ~30Hz would
|
||||
/// stack concurrent CapturePreviewAsync calls (a full 1920×1080 PNG encode costs
|
||||
/// ~10-30ms), so overlapping ticks are dropped, never queued; the effective cadence
|
||||
/// is max(interval, capture duration). The interval and the in-flight drop live here,
|
||||
/// not in the manager, so the scheduler is unit-testable without a WebView2 runtime.</summary>
|
||||
internal sealed class CaptureScheduler
|
||||
{
|
||||
private readonly Dispatcher _dispatcher;
|
||||
private readonly Func<Task> _capture;
|
||||
private DispatcherTimer? _timer;
|
||||
private int _intervalMs;
|
||||
private bool _inFlight;
|
||||
private Task _pending = Task.CompletedTask;
|
||||
|
||||
public CaptureScheduler(Dispatcher dispatcher, int intervalMs, Func<Task> capture)
|
||||
{
|
||||
_dispatcher = dispatcher;
|
||||
_intervalMs = Math.Max(16, intervalMs);
|
||||
_capture = capture;
|
||||
}
|
||||
|
||||
/// <summary>The task of the capture currently in flight, or a completed task when
|
||||
/// idle. Test seam: lets the caller await exactly the running capture.</summary>
|
||||
internal Task Pending => _pending;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_timer != null) return;
|
||||
_timer = new DispatcherTimer(
|
||||
TimeSpan.FromMilliseconds(_intervalMs),
|
||||
DispatcherPriority.Background,
|
||||
(_, _) => Tick(),
|
||||
_dispatcher);
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
public void Stop() => _timer?.Stop();
|
||||
|
||||
public void SetInterval(int milliseconds)
|
||||
{
|
||||
_intervalMs = Math.Max(16, milliseconds);
|
||||
if (_timer != null) _timer.Interval = TimeSpan.FromMilliseconds(_intervalMs);
|
||||
}
|
||||
|
||||
/// <summary>Runs the capture unless one is already in flight — the dropped tick is
|
||||
/// the latest-wins policy. Tick runs on the dispatcher thread (timer + test), so
|
||||
/// _inFlight needs no locking.</summary>
|
||||
internal void Tick()
|
||||
{
|
||||
if (_inFlight) return;
|
||||
_inFlight = true;
|
||||
_pending = RunCapture();
|
||||
}
|
||||
|
||||
private async Task RunCapture()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _capture();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_inFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-15
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
@@ -19,13 +20,15 @@ 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 DispatcherTimer CaptureTimer { get; init; }
|
||||
public required CaptureScheduler Scheduler { get; init; }
|
||||
public VideoFrame? LatestFrame;
|
||||
public bool Disposed;
|
||||
public bool Initialized;
|
||||
@@ -43,6 +46,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
public long Epoch;
|
||||
public WriteableBitmap? PreviewBitmap;
|
||||
public bool DebugPngWritten;
|
||||
public int TelemetryCaptureCount;
|
||||
|
||||
public byte[] RentOutBuffer(int size)
|
||||
{
|
||||
@@ -94,28 +98,33 @@ public sealed class WebView2Manager : IDisposable
|
||||
|
||||
_hostPanel.Children.Add(webView);
|
||||
|
||||
var timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
};
|
||||
var scheduler = new CaptureScheduler(
|
||||
_dispatcher,
|
||||
_captureIntervalMs,
|
||||
() => CaptureFrame(source.Id));
|
||||
|
||||
var session = new WebSourceSession
|
||||
{
|
||||
Control = webView,
|
||||
CaptureTimer = timer,
|
||||
Scheduler = scheduler,
|
||||
};
|
||||
|
||||
_sessions[source.Id] = session;
|
||||
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
if (!session.Disposed && session.Initialized)
|
||||
_ = CaptureFrame(source.Id);
|
||||
};
|
||||
|
||||
_ = 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)
|
||||
@@ -156,7 +165,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
await session.Control.EnsureCoreWebView2Async();
|
||||
await session.Control.EnsureCoreWebView2Async(await GetEnvironmentAsync());
|
||||
|
||||
var cws = session.Control.CoreWebView2!;
|
||||
|
||||
@@ -184,7 +193,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
if (!string.IsNullOrEmpty(source.WebUri))
|
||||
Navigate(source.WebUri, session);
|
||||
|
||||
session.CaptureTimer.Start();
|
||||
session.Scheduler.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -193,6 +202,25 @@ public sealed class WebView2Manager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// 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); }
|
||||
@@ -233,6 +261,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
{
|
||||
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var webView = session.Control;
|
||||
@@ -330,6 +359,19 @@ public sealed class WebView2Manager : IDisposable
|
||||
{
|
||||
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) { }
|
||||
@@ -343,7 +385,7 @@ public sealed class WebView2Manager : IDisposable
|
||||
{
|
||||
if (!_sessions.TryGetValue(sourceId, out var session)) return;
|
||||
session.Disposed = true;
|
||||
session.CaptureTimer.Stop();
|
||||
session.Scheduler.Stop();
|
||||
_hostPanel.Children.Remove(session.Control);
|
||||
try { session.Control.Dispose(); } catch { }
|
||||
_sessions.Remove(sourceId);
|
||||
|
||||
Reference in New Issue
Block a user