feat(ux)+fix(rings): release counter #N in the wordmark; all shared-frame rings 4->8 (#11)

Roll-forward of today-slices 6fd1d9c onto the slice-8 base, two-loop hunk dropped.

Release counter (per-build GUID read as noise; +1 per commit from git rev-list,
baseline 241 -> #13, generated by GenerateBuildStamp; GUID demotes to startup.log).
Tests pinned to Label/#N >= 13; wordmark display test asserts the Label.

Flash fix (take 14 finding): consumer holds must never outlive depth x source
period — 4 slots at high refresh lap ~27ms vs a <=50ms compositor read, so a
recycled slot flashed its new frame over the lagged old one. All shared rings 4->8
(OBS/overlay precedent for ring discipline).

Camera producer now rotates an 8-deep ring + Epoch instead of a fresh ~3.7MB
array per device frame (110-220MB/s LOH churn); WebView2 capture reuses a canvas
scratch + 8-deep output ring + a reused WriteableBitmap instead of two fresh
arrays + a fresh bitmap per 10Hz tick. Paste cache stays identity-keyed (Epoch).
This commit is contained in:
2026-09-05 14:31:03 -07:00
parent 99aeef7994
commit d35823a4a9
10 changed files with 143 additions and 34 deletions
+13 -9
View File
@@ -171,7 +171,7 @@ C# / WPF (.NET 8) following MVVM:
| `Models/` | Plain data types — Scene, Source (incl. `ClipShape`, `IsMirrored`, `VideoImageSource`), QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage, **Socials (`SocialService` enum + `SocialEntry`/`SocialsConfig` + `SocialServiceIcons`) — the social bar** |
| `ViewModels/` | MainViewModel — `public partial class`, one file per functional area (Scenes, Background, Webcam, Audio, Trax, Socials, Streaming, Chat, Overlays, Account, License, Recording — split complete, see `ViewModels/index.md`); **Chat.cs is a thin delegating facade over `Services/ChatOverlayLayer.cs` (Commit G, first true decomposition)**; GoLiveViewModel, ReuseImageViewModel, CameraPickerViewModel, **SocialsDialogViewModel** |
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite), **SocialValidator (`ISocialValidator` seam + `HttpSocialValidator` default)**, **webcam: `VideoFrame` seam + `CameraDeviceInfo`/`ICameraEnumerator`/`ICameraFrameSource` interfaces + `MediaCaptureCameraEnumerator`/`MediaCaptureFrameSource` (WinRT) + `CameraManager`**, **screen capture: `IFullScreenDetector`/`Win32FullScreenDetector` + `IScreenCaptureSource`/`ScreenCaptureFrameSource` (WinRT GraphicsCapture) + `ScreenCaptureManager` + `ScreenCaptureSourceFactory` + `Direct3D11Helper`/`CaptureInterop` (COM bridges)**, **media source: `IMediaFrameSource` + `MediaVideoSource` (spawns ffmpeg `rawvideo` BGRA decode) + `MediaVideoSourceManager` (refcount-by-path session owner) + pure `RawVideoFrameReader` + `IDecodeProcess`/`FfmpegDecodeProcess` process seam (binary-stdout mirror of `IEncoderProcess`; see "Media source")**, **compositor: `SceneCompositor` + `CompositorOptions` + pure `StretchMath` + `StaticPixelCache` (see "Scene compositor")**, **audio: `IAudioSource` seam + `WasapiLoopbackAudioSource`/`WasapiMicAudioSource` (NAudio WASAPI) + `AudioMixer` + pure `AudioLevelMeter`/`WaveToFloat`/`VoiceFilterChain`/`LowShelfFilter`/`HighShelfFilter`/`NoiseGate`/`Compressor`/`AutoDucker`/`AudioRingBuffer`/`TinyResampler`/`AudioSyncDelay` + `MusicPlayer` + `IAudioPipeWriter`/`NamedPipeAudioWriter` (see "Live audio capture")**, **encoder: `IFfmpegEncoder`/`FfmpegEncoder` + `IEncoderProcess`/`FfmpegEncoderProcess` + `IFfmpegLocator`/`FfmpegLocator` + pure `FfmpegArgs`/`FfmpegProgressParser`/`FfmpegEncoderPicker` + the `FramePump` frame producer (see "Live encoder" + "Live frame pipeline")**, **notifications: `INotificationService` seam (`AppNotificationSeverity` Info/Success/Warning/Error) + `NotificationService` (Notification.Wpf toasts, see "Toast notifications")** |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (per-build GUID generated by `GenerateBuildStamp` in ytLive.csproj — wordmark superscript + startup.log line, 2026-09-04)**, visibility converters |
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, **BuildStamp (wordmark release counter `#N`, +1 per commit, generated by `GenerateBuildStamp` in ytLive.csproj via git rev-list; the per-build GUID now logs to startup.log only — 2026-09-04)**, visibility converters |
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview + live controls below), left (scenes/sources), right (chat), bottom (gear + stream stats + resolution) |
@@ -680,7 +680,7 @@ and paces frames into the encoder at the tier's FPS. **rawvideo is stamped by AR
pts from frame order at the declared fps — supply rate = output speed. A starved producer (pump < fps)
ships a time-lapse, truncated file with NO error (take-2 lesson, 2026-09-01); the pump therefore logs
`FramePump stats: n/target frames per 5s, avg render Xms (resolve R), avg submit Yms` so a slow stage names itself — and since 2026-09-04 so does the BUILD: every build gets a GUID stamped
into `Helpers/BuildStamp` (csproj `GenerateBuildStamp` target, fresh per compile — no lying incremental build), shown as the wordmark superscript and logged at startup
into `Helpers/BuildStamp` (csproj `GenerateBuildStamp` target, fresh per compile — no lying incremental build), logged at startup
("Build xxxxxxxx (compiled ...)"). **Take 6 (stamp-less, ambiguous): render 35-41ms with slice 3 built or not unknown — attribution is why the stamp exists; takes 7+ are
readable.** If the breakdown says resolve dominates, the suspects are pre-cached chat/config thrash or the web frame; if blit dominates, the general-path element sizes. **Pattern — everything is a constructor-injected
seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<CompositorOptions>`,
@@ -799,13 +799,17 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
(212/300). The whole remaining gap is PERIODIC 35-65ms worst-render spikes that worsened across
the take (189→147 frames/5s) — the signature of gen2 GC pauses, now provable via `gen2 +N` in
every stats window. Biggest churn was structural: `ScreenCaptureFrameSource` minted a fresh
~8.3MB `byte[]` per DWM frame (~500MB/s LOH). It now rotates a 4-deep ring (a ≤17ms consumer can
never be lapped at 60Hz), size-matched per slot, with reused downscale row scratch. Recycled
arrays would poison the paste cache (it keys on array IDENTITY), so `VideoFrame.Epoch` —
monotonic per producer frame, 0 for fresh-array producers — joins the key. Regression test
`PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` fails on the old key by
construction. Next suspect if gen2 stays hot: the 10Hz WebView2 capture loop (full-canvas PNG
decode + fresh arrays on the UI thread) — recorded as follow-up, untouched this slice.
~8.3MB `byte[]` per DWM frame (~500MB/s LOH). It now rotates a shared-frame ring (4-deep at slice 8,
deepened to 8-deep after the "flash" finding — consumer holds must never outlive depth × source
period), size-matched per slot, with reused downscale row scratch. Recycled
arrays would poison the paste cache (it keys on array IDENTITY), so `VideoFrame.Epoch` —
monotonic per producer frame, 0 for fresh-array producers — joins the key. Regression test
`PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` fails on the old key by
construction. The camera producer carried the same fresh-array churn (110-220MB/s at 30-60fps):
it now rotates its own 8-deep ring + Epoch (same identity rule), and the WebView2 capture
reuses a canvas scratch + 8-deep output ring + a reused WriteableBitmap instead of minting two
fresh arrays + a new bitmap per 10Hz tick. Next suspect if gen2 stays hot: the WPF preview load
itself driving gen2 — recorded as follow-up, untouched this slice.
- **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits)
**before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips