From 5e78065c2d058dcc800fb7fabd92346d411f5a8e Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 10 Sep 2026 10:25:41 -0700 Subject: [PATCH] =?UTF-8?q?fix(web):=20web-layer=20capture=20cadence=2010H?= =?UTF-8?q?z=20=E2=86=92=20~30Hz=20while=20recording=20=E2=80=94=20widget?= =?UTF-8?q?=20animations=20no=20longer=20play=20~1/6=20speed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- HANDOFF.md | 121 ++++++++++-------- MyMistakes.md | 32 +++++ Services/CaptureScheduler.cs | 73 +++++++++++ Services/WebView2Manager.cs | 72 ++++++++--- .../MainViewModel.Streaming.Operations.cs | 3 + ai.md | 29 +++++ ytLive.Tests/WebView2ManagerTests.cs | 28 ++++ 7 files changed, 292 insertions(+), 66 deletions(-) create mode 100644 Services/CaptureScheduler.cs diff --git a/HANDOFF.md b/HANDOFF.md index ee7da52..9400461 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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` - (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-.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) \ No newline at end of file +- 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. \ No newline at end of file diff --git a/MyMistakes.md b/MyMistakes.md index 68a32e6..f0a131f 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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: diff --git a/Services/CaptureScheduler.cs b/Services/CaptureScheduler.cs new file mode 100644 index 0000000..c307100 --- /dev/null +++ b/Services/CaptureScheduler.cs @@ -0,0 +1,73 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Threading; + +namespace ytLive.Services; + +/// 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. +internal sealed class CaptureScheduler +{ + private readonly Dispatcher _dispatcher; + private readonly Func _capture; + private DispatcherTimer? _timer; + private int _intervalMs; + private bool _inFlight; + private Task _pending = Task.CompletedTask; + + public CaptureScheduler(Dispatcher dispatcher, int intervalMs, Func capture) + { + _dispatcher = dispatcher; + _intervalMs = Math.Max(16, intervalMs); + _capture = capture; + } + + /// The task of the capture currently in flight, or a completed task when + /// idle. Test seam: lets the caller await exactly the running capture. + 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); + } + + /// 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. + internal void Tick() + { + if (_inFlight) return; + _inFlight = true; + _pending = RunCapture(); + } + + private async Task RunCapture() + { + try + { + await _capture(); + } + finally + { + _inFlight = false; + } + } +} \ No newline at end of file diff --git a/Services/WebView2Manager.cs b/Services/WebView2Manager.cs index bf4f9df..c6d27bb 100644 --- a/Services/WebView2Manager.cs +++ b/Services/WebView2Manager.cs @@ -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 _sessions = new(); + private Task? _environmentTask; + private int _captureIntervalMs = 200; // idle pace; recording drops it to ~30Hz via SetCaptureInterval public event Action? 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); } + /// 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. + 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 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); diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 03e1e78..d9a274b 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -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 diff --git a/ai.md b/ai.md index 5f9820c..33b44dd 100644 --- a/ai.md +++ b/ai.md @@ -870,6 +870,35 @@ seam:** `Func`, `Func` resolver, `Func