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:
+70
-51
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user