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
|
## Branch / Commit State
|
||||||
|
|
||||||
`main` HEAD = `c45cbc9` (slice 10 committed), **ahead of origin by 15, NOT pushing** —
|
`main` HEAD currently = `c45cbc9` (slice 10). **Slice 11 is uncommitted** in the working tree
|
||||||
**user ruling (2026-09-10): do not push until the web-overlay transparency AND audio
|
(WebView2 capture cadence + de-throttle; see below). **ahead of origin by 16, NOT pushing** —
|
||||||
silence issues are addressed.** Both are open (see Still Open). Working tree clean.
|
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
|
- slice 9 `bd396e4`: duration == wall time (count-based CFR, deadline never rebased, `-re` removed).
|
||||||
rebased, `-re` removed): file length == wall time by frame-count construction.
|
- slice 10 `c45cbc9`: bounded `Channel` encoder queue + drop-newest policy + burned-in dot-matrix
|
||||||
- **But the CONTENT still hiccuped** — the creator read "1...23...4...56..." in the
|
frame counter (bottom-right). Verified on two takes (`ty-20260910-0949…-2.mp4`, 2383 frames 39.92s;
|
||||||
recording, and the aggregates (301/300, uniform PTS, 15.6s wall vs 15.74s file) could
|
`ty-20260910-0957…-2.mp4`, 2270 frames 38.05s): **every frame decodes, counter advances +1/frame,
|
||||||
NOT see it. Root cause finally measured in `FfmpegEncoder.SubmitFrameAsync`: it BLOCKED
|
zero gaps/dups**. Content cadence both: median 2 frames, max gap 9, longest frozen run 8 (133ms).
|
||||||
on `WriteAsync(8.3MB)+FlushAsync` when ffmpeg lagged the pipe, and slice 9's burst
|
User: "I think we're good on this issue" → **timing CLOSED.**
|
||||||
`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.
|
|
||||||
|
|
||||||
## 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
|
Two user-reframed symptoms converged on ONE root cause and one change is in the tree (uncommitted):
|
||||||
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.
|
|
||||||
|
|
||||||
## 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)
|
Root cause of #1 (code-proven): the recording is 60fps, but the WebView2 capture loop was a blind
|
||||||
- `bd396e4` — fix(rec): CFR emission + `-re` removal + docs (ai.md slice 9, MyMistakes,
|
100ms `DispatcherTimer` = 10Hz → the widget's motion is sampled 6× under rate → repeated-frame
|
||||||
HANDOFF). Cites libobs video-io.c.
|
slow-mo. PLUS Chromium hidden-page throttling (rAF parked, timers→1s; WebView2Feedback#1172/#3070)
|
||||||
- Do NOT push until the user says (multi-commit local only).
|
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
|
## Still Open
|
||||||
|
|
||||||
- Web overlay: pre-parse transparency injection NOT yet proven — recorded diagnostic PNGs
|
- **Web transparency UNVERIFIED** — the diagnostic instrument is blind (fires on about:blank); last
|
||||||
(`%TEMP%\ytLive-web-<id>.png`) were 100% alpha=0 AND RGB=0 (an EMPTY capture). Frozen
|
known visual state take-25 black box; user's "transparent pixels black" may be the opacity bug OR
|
||||||
pending the timing verification.
|
the preview surface. Needs the post-paint instrument fix (above) then a take. Push gate reason #1.
|
||||||
- Audio silence: silent audio (full-length, -91dB) in the take-15 file — the named-pipe
|
- **Audio silence** — silent audio (−91dB full-length) in take-15; named-pipe audio delivers ~nothing.
|
||||||
audio delivers ~nothing. Separate from timing; queued follow-up, NOT this change.
|
Queued follow-up. Push gate reason #2.
|
||||||
- WSL-ticker-under-heavy-load reliability check: informational (burned counter is judge).
|
- Pre-existing compositor pixel test failure (never in scope).
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Landmines
|
## Landmines
|
||||||
|
|
||||||
- testhost shares startup.log with app — filter by time when triaging
|
- 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
|
- Locked 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"`
|
- Build/tests via `/mnt/c/Program Files/dotnet/dotnet.exe build …` / `… vstest
|
||||||
- No Linux ffmpeg / no sudo on this box — probing uses `/mnt/c/Program Files/Krita (x64)/bin/ffmpeg.exe`
|
"C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"`.
|
||||||
- `tools/ticker`: constant ~+982ms offset on the FIRST line is cosmetic (t0 a beat late)
|
- 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
|
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.
|
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
|
## 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:
|
(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;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -19,13 +20,15 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
private readonly Panel _hostPanel;
|
private readonly Panel _hostPanel;
|
||||||
private readonly Dispatcher _dispatcher;
|
private readonly Dispatcher _dispatcher;
|
||||||
private readonly Dictionary<string, WebSourceSession> _sessions = new();
|
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;
|
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
||||||
|
|
||||||
private sealed class WebSourceSession
|
private sealed class WebSourceSession
|
||||||
{
|
{
|
||||||
public required WebView2 Control { get; init; }
|
public required WebView2 Control { get; init; }
|
||||||
public required DispatcherTimer CaptureTimer { get; init; }
|
public required CaptureScheduler Scheduler { get; init; }
|
||||||
public VideoFrame? LatestFrame;
|
public VideoFrame? LatestFrame;
|
||||||
public bool Disposed;
|
public bool Disposed;
|
||||||
public bool Initialized;
|
public bool Initialized;
|
||||||
@@ -43,6 +46,7 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
public long Epoch;
|
public long Epoch;
|
||||||
public WriteableBitmap? PreviewBitmap;
|
public WriteableBitmap? PreviewBitmap;
|
||||||
public bool DebugPngWritten;
|
public bool DebugPngWritten;
|
||||||
|
public int TelemetryCaptureCount;
|
||||||
|
|
||||||
public byte[] RentOutBuffer(int size)
|
public byte[] RentOutBuffer(int size)
|
||||||
{
|
{
|
||||||
@@ -94,28 +98,33 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
|
|
||||||
_hostPanel.Children.Add(webView);
|
_hostPanel.Children.Add(webView);
|
||||||
|
|
||||||
var timer = new DispatcherTimer
|
var scheduler = new CaptureScheduler(
|
||||||
{
|
_dispatcher,
|
||||||
Interval = TimeSpan.FromMilliseconds(100),
|
_captureIntervalMs,
|
||||||
};
|
() => CaptureFrame(source.Id));
|
||||||
|
|
||||||
var session = new WebSourceSession
|
var session = new WebSourceSession
|
||||||
{
|
{
|
||||||
Control = webView,
|
Control = webView,
|
||||||
CaptureTimer = timer,
|
Scheduler = scheduler,
|
||||||
};
|
};
|
||||||
|
|
||||||
_sessions[source.Id] = session;
|
_sessions[source.Id] = session;
|
||||||
|
|
||||||
timer.Tick += (_, _) =>
|
|
||||||
{
|
|
||||||
if (!session.Disposed && session.Initialized)
|
|
||||||
_ = CaptureFrame(source.Id);
|
|
||||||
};
|
|
||||||
|
|
||||||
_ = InitializeAsync(source, 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 Unregister(string sourceId) => RemoveSession(sourceId);
|
||||||
|
|
||||||
public void OnWebUriChanged(Source source)
|
public void OnWebUriChanged(Source source)
|
||||||
@@ -156,7 +165,7 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await session.Control.EnsureCoreWebView2Async();
|
await session.Control.EnsureCoreWebView2Async(await GetEnvironmentAsync());
|
||||||
|
|
||||||
var cws = session.Control.CoreWebView2!;
|
var cws = session.Control.CoreWebView2!;
|
||||||
|
|
||||||
@@ -184,7 +193,7 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
if (!string.IsNullOrEmpty(source.WebUri))
|
if (!string.IsNullOrEmpty(source.WebUri))
|
||||||
Navigate(source.WebUri, session);
|
Navigate(source.WebUri, session);
|
||||||
|
|
||||||
session.CaptureTimer.Start();
|
session.Scheduler.Start();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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)
|
private void Navigate(string url, WebSourceSession session)
|
||||||
{
|
{
|
||||||
try { session.Control.CoreWebView2?.Navigate(url); }
|
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;
|
if (!_sessions.TryGetValue(sourceId, out var session) || session.Disposed) return;
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var webView = session.Control;
|
var webView = session.Control;
|
||||||
@@ -330,6 +359,19 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
{
|
{
|
||||||
PreviewBitmapChanged?.Invoke(sourceId, preview);
|
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 (ObjectDisposedException) { }
|
||||||
catch (InvalidOperationException) { }
|
catch (InvalidOperationException) { }
|
||||||
@@ -343,7 +385,7 @@ public sealed class WebView2Manager : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_sessions.TryGetValue(sourceId, out var session)) return;
|
if (!_sessions.TryGetValue(sourceId, out var session)) return;
|
||||||
session.Disposed = true;
|
session.Disposed = true;
|
||||||
session.CaptureTimer.Stop();
|
session.Scheduler.Stop();
|
||||||
_hostPanel.Children.Remove(session.Control);
|
_hostPanel.Children.Remove(session.Control);
|
||||||
try { session.Control.Dispose(); } catch { }
|
try { session.Control.Dispose(); } catch { }
|
||||||
_sessions.Remove(sourceId);
|
_sessions.Remove(sourceId);
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
ResetHealth(StreamStatus.Offline);
|
ResetHealth(StreamStatus.Offline);
|
||||||
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
|
||||||
IsRecording = true;
|
IsRecording = true;
|
||||||
|
_webView2Manager?.SetCaptureInterval(33); // web layer ~30Hz while recording
|
||||||
await _framePump.StartAsync();
|
await _framePump.StartAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +176,7 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_webView2Manager?.SetCaptureInterval(33); // web layer ~30Hz while live
|
||||||
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
|
||||||
|
|
||||||
// Health polling (TASK 5 item 3): poll immediately, then every 30s while
|
// 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.
|
// monitoring); only the live/report pipe, the pump, and the session stop here.
|
||||||
_audioMixer.StopLive();
|
_audioMixer.StopLive();
|
||||||
await _framePump.StopAsync();
|
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)
|
// 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
|
// 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).
|
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
|
Audio untouched (follow-up). WSL ticker-under-load reliability check still to run (informational — the
|
||||||
burned counter is the judge).
|
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
|
- **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;
|
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
|
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));
|
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