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:
2026-09-10 10:25:41 -07:00
parent ec7c734bdd
commit 5e78065c2d
7 changed files with 292 additions and 66 deletions
+70 -51
View File
@@ -1,66 +1,85 @@
# HANDOFF — 2026-09-10
# HANDOFF — 2026-09-10 (evening)
## Branch / Commit State
`main` HEAD = `c45cbc9` (slice 10 committed), **ahead of origin by 15, NOT pushing** —
**user ruling (2026-09-10): do not push until the web-overlay transparency AND audio
silence issues are addressed.** Both are open (see Still Open). Working tree clean.
`main` HEAD currently = `c45cbc9` (slice 10). **Slice 11 is uncommitted** in the working tree
(WebView2 capture cadence + de-throttle; see below). **ahead of origin by 16, NOT pushing** —
user ruling (2026-09-10): do not push until the web-overlay **transparency AND audio-silence**
issues are addressed. Both are open (see Still Open). Milestone tag `milestone-recording-timing`
(annotated) on `c45cbc9` — rollback point: `git reset --hard milestone-recording-timing`.
## The timing saga — where it stands
## Timing saga — CLOSED (verified)
- **slice 9 (committed `bd396e4`)** fixed the DURATION (count-based CFR, deadline never
rebased, `-re` removed): file length == wall time by frame-count construction.
- **But the CONTENT still hiccuped** — the creator read "1...23...4...56..." in the
recording, and the aggregates (301/300, uniform PTS, 15.6s wall vs 15.74s file) could
NOT see it. Root cause finally measured in `FfmpegEncoder.SubmitFrameAsync`: it BLOCKED
on `WriteAsync(8.3MB)+FlushAsync` when ffmpeg lagged the pipe, and slice 9's burst
`while` loop then re-wrote that SAME composite for every crossed slot — frozen runs.
- **slice 10 (current, uncommitted)** — the OBS `obs-encoder.c` shape:
1. `FfmpegEncoder.SubmitFrameAsync` is an ENQUEUE into a bounded `Channel<byte[]>`
(cap 120) drained by its own task; the pump NEVER blocks on the pipe.
2. Queue full → drop the NEWEST frame + count (`IFfmpegEncoder.DroppedFrames`).
Stop flushes the whole queue, then EOF.
3. Pump: ONE fresh composite per iteration (burst loop deleted) — no stale re-write.
4. **Burned-in frame counter** (the new judge, replaces the WSL ticker): 6-digit
dot-matrix strip, white box, bottom-right of every composite. Decoding the file
reads +1/frame; jumps = counted drops. Clock-independent.
5. Stats: `worst submit`, `dropped N`, `stalls K`; stall log names iterations > 2× interval.
- slice 9 `bd396e4`: duration == wall time (count-based CFR, deadline never rebased, `-re` removed).
- slice 10 `c45cbc9`: bounded `Channel` encoder queue + drop-newest policy + burned-in dot-matrix
frame counter (bottom-right). Verified on two takes (`ty-20260910-0949…-2.mp4`, 2383 frames 39.92s;
`ty-20260910-0957…-2.mp4`, 2270 frames 38.05s): **every frame decodes, counter advances +1/frame,
zero gaps/dups**. Content cadence both: median 2 frames, max gap 9, longest frozen run 8 (133ms).
User: "I think we're good on this issue" → **timing CLOSED.**
## NEXT STEP (ONE user run required — the take that closes timing)
## The web-overlay threads (the active push-gate work) — Slice 11 in tree
Record ~20s (WSL ticker visible in the preview is optional now — the burned counter is
the judge), then check:
- **decode the recording and read the bottom-right frame counter** (probe a few frames
widely spaced + the same region across a densely-sampled range): the number advances
**exactly +1 per frame**, jumping only where drops are countable
- `%APPDATA%\ytLlive\startup.log`: `FramePump stats:` ≈ n/n per 5s (n=300 @60fps),
`dropped 0`, `stalls 0`, `worst submit ≈ 1-3ms`
- NO "FramePump stall:" lines, NO frozen-content runs in the playback
- Extract a few frames around any suspicious moment and correlation-check the strip.
Two user-reframed symptoms converged on ONE root cause and one change is in the tree (uncommitted):
## Committed so far this session (before slice 10)
1. "The widget is an animated resource — the animation looks **too slow**."
2. (earlier) "image renders but the **transparent pixels are black**."
- `d212d5a` — tools: WSL ticker (`tools/ticker.c` + binary)
- `bd396e4` — fix(rec): CFR emission + `-re` removal + docs (ai.md slice 9, MyMistakes,
HANDOFF). Cites libobs video-io.c.
- Do NOT push until the user says (multi-commit local only).
Root cause of #1 (code-proven): the recording is 60fps, but the WebView2 capture loop was a blind
100ms `DispatcherTimer` = 10Hz → the widget's motion is sampled 6× under rate → repeated-frame
slow-mo. PLUS Chromium hidden-page throttling (rAF parked, timers→1s; WebView2Feedback#1172/#3070)
when the off-screen page's host window is unfocused/covered. #2 remains UNVERIFIED (the diagnostic
PNG+alpha log is bound to the FIRST capture ever = the initial about:blank doc, so it can never see
the widget — 5/5 sessions logged alpha=0 blank; instrument is blind, not the capture).
**Slice 11 (uncommitted, files below):**
- `Services/CaptureScheduler.cs` (new): per-session dispatcher timer that DROPS ticks while a
capture is in flight (latest-wins, never queues). Interval owner-configurable: 33ms recording /
200ms idle.
- `Services/WebView2Manager.cs`: scheduler replaces the raw timer; shared `CoreWebView2Environment`
created BEFORE `EnsureCoreWebView2Async` with `--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding --disable-features=CalculateNativeWinOcclusion`
(`GetEnvironmentAsync`); `SetCaptureInterval(int)`; capture-cost telemetry (first 30 captures/
session → startup.log).
- `ViewModels/MainViewModel.Streaming.Operations.cs`: `SetCaptureInterval(33)` on record/stream
start, `(200)` on stop.
- `ytLive.Tests/WebView2ManagerTests.cs`: ONE test
`CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes` (deterministic TCS-driven, no
WebView2 runtime).
- Docs same commit: `ai.md` slice 11, `MyMistakes.md` recipe "web widget captured at 10Hz…".
Suite: 290/291 pass — sole failure the PRE-EXISTING `Composite_FullScene_MasterPixels` pixel
(1380,700) cyan-vs-magenta (out of scope; fails with any fix stashed).
## NEXT STEP (the take that closes the web animation speed)
1. Commit slice 11 (scope-check first; commit cites WebView2Feedback#1172/#3070/#20 + Chrome-88 blog).
2. Run a normal recording with the animated widget on screen for ~20s. Expected:
- startup.log: `capture #1..#30 … took Nms` lines (tell us the per-capture cost) — **this decides
whether 30Hz stays or drops to ~20Hz**; also `FramePump stats:` should show no new dropped-frame
growth vs before (GC-churn check).
- Widget animation ~2-3× closer to real-time (10→30Hz).
- If it only became jittery instead of fast: check the FramePump dropped-frame counters.
3. Then, separately, the TRANSPARENCY verification (still open): re-point the first-capture dump/
alpha-log at a POST-PAINT capture of the real widget document (currently bound to about:blank) —
the change that makes "are transparent margins real?" answerable. Intended next change.
## Still Open
- Web overlay: pre-parse transparency injection NOT yet proven — recorded diagnostic PNGs
(`%TEMP%\ytLive-web-<id>.png`) were 100% alpha=0 AND RGB=0 (an EMPTY capture). Frozen
pending the timing verification.
- Audio silence: silent audio (full-length, -91dB) in the take-15 file — the named-pipe
audio delivers ~nothing. Separate from timing; queued follow-up, NOT this change.
- WSL-ticker-under-heavy-load reliability check: informational (burned counter is judge).
- Pre-existing: `Composite_FullScene_MasterPixels` line 109 pixel (1380,700) — verbose
cyan-vs-magenta; fails with slice-9 stashed; untouched by both slices. Separate
compositor investigation. DO NOT fix in the timing work.
- **Web transparency UNVERIFIED** — the diagnostic instrument is blind (fires on about:blank); last
known visual state take-25 black box; user's "transparent pixels black" may be the opacity bug OR
the preview surface. Needs the post-paint instrument fix (above) then a take. Push gate reason #1.
- **Audio silence** — silent audio (−91dB full-length) in take-15; named-pipe audio delivers ~nothing.
Queued follow-up. Push gate reason #2.
- Pre-existing compositor pixel test failure (never in scope).
## Landmines
- testhost shares startup.log with app — filter by time when triaging
- testhost/exe lock DLLs: `taskkill /F /IM testhost.exe /IM ytLive.exe` before rebuild
- Build via the Windows dotnet host: `/mnt/c/Program Files/dotnet/dotnet.exe build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"`
- No Linux ffmpeg / no sudo on this box — probing uses `/mnt/c/Program Files/Krita (x64)/bin/ffmpeg.exe`
- `tools/ticker`: constant ~+982ms offset on the FIRST line is cosmetic (t0 a beat late)
- testhost shares startup.log with app — filter by time when triaging.
- Locked DLLs: `taskkill //F //IM testhost.exe //IM ytLive.exe` before rebuild.
- Build/tests via `/mnt/c/Program Files/dotnet/dotnet.exe build …` / `… vstest
"C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"`.
- Probing recordings: `/mnt/c/Program Files/Krita (x64)/bin/ffmpeg.exe` / `ffprobe.exe` (Windows-form paths).
- `tools/ticker`: constant ~+982ms offset on the FIRST line is cosmetic.
- WebView2Manager Ran into LOH churn worry: each capture allocs a BitmapImage raster (~8.3MB) that
is garbage per capture; at 30Hz that's ~250MB/s LOH on the UI thread → potential gen2 pauses
showing as FramePump dropped frames. Slice 12 candidate if the take's stats show it.
+32
View File
@@ -214,9 +214,41 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
that HOLDS submitted frames now must snapshot them (`Clone`) once the producer
legitimately recycles buffers — mirror the real consumer's copy semantics in the fake.
---
### A web widget captured at 10Hz inside a 60fps recording plays at ~1/6 speed
Derived 2026-09-10 (the "widget animation too slow" report). The recording is 60fps and the
WebView2 capture loop was a blind 100ms `DispatcherTimer` = 10Hz — each captured web frame gets
repeated ~6× in the file, so whatever the page animates at, the OUTPUT is capped at the *capture*
cadence, not the page's. Two stacked throttles, both real:
1. **The capture rate is the hard ceiling.** web-layer motion in the recording can never beat
`CapturePreviewAsync` frequency. But you can't just raise the timer: full-HD PNG capture costs
~10-30ms (WebView2Feedback#20: "CapturePreviewAsync … produces PNG/JPG and is very slow"), so
concurrent captures stack CPU AND can publish stale-after-fresh. The mandatory shape is a
**latest-wins drop**: at most one capture in flight per session; a tick during the in-flight
window is DROPPED, never queued; effective cadence = max(interval, capture duration). Do this
before anyone touches the interval constant.
2. **Chromium throttles hidden pages.** An off-screen WebView2 (we place it at (-5000,-5000)) is a
hidden page the moment the host window is unfocused or covered: `requestAnimationFrame` parks and
JS timers clamp to ~1s (WebView2Feedback#1172 — background-throttled rAF; #3070 — a WebView2 with
`Visibility.Collapsed` slows timers to 1s; Chrome-88 blog — heavy timer throttling of hidden tabs).
There is NO supported per-page opt-out (#5250 still open). The embedder answer is browser args on
the `CoreWebView2EnvironmentOptions` created BEFORE `EnsureCoreWebView2Async`:
`--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
--disable-features=CalculateNativeWinOcclusion` (the Electron/Streamlabs-class fix for
occluded-window animation throttling). Share ONE environment across sessions (one browser process).
3. **Measure before picking the cadence.** Log the first ~30 captures' elapsed ms on the first
recording run; PNG encode + WPF decode of 1920×1080 is the per-capture cost that decides whether
~30Hz is affordable or it must drop to ~20Hz. The FramePump drops frames (never time-lapses) if
UI-thread GC churn starves it, so cost shows up as dropped-frame stats — read them.
---
## Splitting a large file into partials — NEVER `awk … > SRC` while awking SRC
(2026-08-31, Commit D) Tried to split `SocialsDialogViewModel.cs` in one line:
+73
View File
@@ -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
View File
@@ -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);
@@ -56,6 +56,7 @@ public partial class MainViewModel : ViewModelBase
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
_webView2Manager?.SetCaptureInterval(33); // web layer ~30Hz while recording
await _framePump.StartAsync();
}
@@ -175,6 +176,7 @@ public partial class MainViewModel : ViewModelBase
return;
}
_webView2Manager?.SetCaptureInterval(33); // web layer ~30Hz while live
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
@@ -251,6 +253,7 @@ public partial class MainViewModel : ViewModelBase
// monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive();
await _framePump.StopAsync();
_webView2Manager?.SetCaptureInterval(200); // idle pace — preview-only doesn't need 30Hz
// Proper close-out (TASK 9 decision 6, built 2026-09-01): transition(complete)
// AFTER the RTMP push is closed so no frames post-date the end — YouTube then
+29
View File
@@ -870,6 +870,35 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
submitted − dropped bytes). Full suite 289/290 (the pre-existing compositor pixel failure unchanged).
Audio untouched (follow-up). WSL ticker-under-load reliability check still to run (informational — the
burned counter is the judge).
- **Slice 11 — web-layer animation ran at ~1/6 speed; capture cadence 10Hz→~30Hz + de-throttle
(2026-09-10, the "widget animates too slow" take):** the recording is 60fps but the WebView2
capture loop was a blind 100ms `DispatcherTimer` = 10Hz — a 60fps-designed widget was sampled 6×
under its native rate (repeated-footage slow-mo). Two stacked throttles:
(1) **the 10Hz cap** (unconditional, code-proven) and
(2) **Chromium hidden-page throttling** (`requestAnimationFrame` parked, JS timers clamped to ~1s
per WebView2Feedback#1172/#3070 + Chrome-88 timer throttling) whenever the app window is unfocused
or covered — the page is off-screen at (-5000,-5000), so it is a hidden page the moment the host
window loses occlusion.
Fixed:
(1) **`CaptureScheduler` (new, `Services/CaptureScheduler.cs`)** replaces the per-session
`DispatcherTimer`: a dispatcher timer that DROPS ticks while a capture is in flight (latest-wins,
never queues) — the in-flight drop is what made raising the cadence safe (concurrent full-HD PNG
`CapturePreviewAsync` calls would stack ~10-30ms encodes and publish stale-after-fresh). Interval is
owner-configurable: recording 33ms (~30Hz), idle 200ms. Unit-tested without a WebView2 runtime
(the ONE test: `CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes` — overlapping
ticks dropped, capture resumes when idle, driven by a TCS so it is deterministic).
(2) **de-throttle browser args**: the shared `CoreWebView2Environment` is created with
`--disable-backgrounding-occluded-windows --disable-renderer-backgrounding
--disable-features=CalculateNativeWinOcclusion` (the Electron/Streamlabs-class embedder answer for
occluded-window animation throttling) BEFORE `EnsureCoreWebView2Async`.
(3) **cadence hook**: `MainViewModel` sets `SetCaptureInterval(33)` on record/stream start and
`(200)` on stop (`MainViewModel.Streaming.Operations.cs`).
(4) **capture-cost telemetry**: first 30 captures per session log elapsed ms to startup.log —
PNG-encode+decode cost decides whether ~30Hz stays or drops to ~20Hz; the FramePump drops frames
(never time-lapses, slice 10) if UI-thread GC churn starves it, so the cost is observable.
Full suite 290/291 passing, the sole failure the pre-existing compositor pixel test. The web-overlay
transparency verification (the first-capture diagnostic bound to about:blank) and the audio-silence
item remain open push-gate items — both untouched by this slice.
- **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending
queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits;
an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was
+28
View File
@@ -154,4 +154,32 @@ public sealed class WebView2ManagerTests
Assert.Equal((0, 0, w, h), (x, y, bw, bh));
}
[Fact]
public async Task CaptureScheduler_Drops_Ticks_While_Capture_InFlight_And_Resumes()
{
// The ~30Hz recording cadence would stack concurrent CapturePreviewAsync calls
// (full-HD PNG encode is ~10-30ms) without the latest-wins drop: ticks that
// arrive while a capture is in flight must be skipped, never queued, and a
// capture can always start once the previous one has finished.
var calls = 0;
var signal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var scheduler = new CaptureScheduler(Dispatcher.CurrentDispatcher, 200, async () =>
{
calls++;
await signal.Task;
});
scheduler.Tick();
scheduler.Tick();
scheduler.Tick();
Assert.Equal(1, calls); // overlapping ticks dropped, never queued
signal.SetResult();
await scheduler.Pending; // durable: Pending completes only after the drop resets
scheduler.Tick();
Assert.Equal(2, calls); // once idle, the next tick captures again
await scheduler.Pending;
}
}