From d35823a4a9408eb1c730335786ec5d092b863675 Mon Sep 17 00:00:00 2001 From: gramps Date: Sat, 5 Sep 2026 14:31:03 -0700 Subject: [PATCH] feat(ux)+fix(rings): release counter #N in the wordmark; all shared-frame rings 4->8 (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- App.xaml.cs | 3 +- Controls/TopBar.xaml | 4 +- MyMistakes.md | 7 ++++ Services/MediaCaptureFrameSource.cs | 34 ++++++++++++++- Services/ScreenCaptureFrameSource.cs | 5 ++- Services/WebView2Manager.cs | 63 ++++++++++++++++++++++------ TASKS.md | 4 +- ai.md | 22 ++++++---- ytLive.Tests/BuildStampTests.cs | 12 +++++- ytLive.csproj | 23 ++++++++-- 10 files changed, 143 insertions(+), 34 deletions(-) diff --git a/App.xaml.cs b/App.xaml.cs index 8dceaa4..ab1345a 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -27,7 +27,8 @@ public partial class App : Application AppLog.Write("App OnStartup begin"); // Attribution (2026-09-04): every take must be traceable to the exact binary - // that produced it — the wordmark superscript shows this same id. + // that produced it — the wordmark shows the release counter (BuildStamp.Release); + // the per-build GUID is forensic only and lands here. AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})"); // Velopack auto-update bootstrap — checks for updates on startup and diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index 232f24f..c873523 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -22,9 +22,9 @@ + ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/> diff --git a/MyMistakes.md b/MyMistakes.md index d6c605e..4d1f467 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -78,6 +78,13 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive: remainder, SPIN the last ~2ms across the deadline. Diagnostic before touching the compositor again: period ≈ work + 15.6 → the SLEEP is the bug, not the work. + Related (take 14, 2026-09-04): **recycled ring buffers are a race you must SIZE, not just own.** + Deepening shared frames to kill GC churn (a fresh 8.3MB/tick array) hands out REUSED memory — the + ring's depth × source period must EXCEED the worst consumer hold (compositor read + lagged UI + preview copy), not just "a few frames". 4 slots at 144Hz capture laps in ~27ms vs a ≤50ms read: half + a new screen frame flashed over an old one in the recording ("bits flashing over other bits"). + Depth 8 everywhere (screen/camera/web output rings); the paste-cache Epoch still guards identity. + 4. **Hermetic pacing test:** inject the delay seam to RECORD the requested TimeSpan and genuinely await it (`Task.Delay(d, ct)`) — a fake that returns `Task.CompletedTask` synchronously makes the whole pump loop run on `StartAsync`'s diff --git a/Services/MediaCaptureFrameSource.cs b/Services/MediaCaptureFrameSource.cs index bd3eada..67a057c 100644 --- a/Services/MediaCaptureFrameSource.cs +++ b/Services/MediaCaptureFrameSource.cs @@ -209,6 +209,35 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource } } + // Buffer recycling (same lesson as the screen capture): a fresh ~3.7MB byte[] + // per device frame at 30-60fps is 110-220MB/s of LOH churn — gen2 pauses land + // on the frame pump as 35-40ms render spikes. An 8-deep ring (take-14 rule: + // depth × source period must exceed the worst consumer hold), rotated on the + // single frame-reader thread, with per-frame Epoch so the identity-keyed paste + // cache can never serve a recycled array as unchanged. + private readonly byte[]?[] _frameRing = new byte[8][]; + private int _ringNext; + private long _epoch; + + private byte[] RentRingBuffer(int size) + { + for (var tries = 0; tries < _frameRing.Length; tries++) + { + var idx = (_ringNext + tries) % _frameRing.Length; + var buf = _frameRing[idx]; + if (buf != null && buf.Length == size) + { + _ringNext = (idx + 1) % _frameRing.Length; + return buf; + } + } + var slot = _ringNext; + _ringNext = (slot + 1) % _frameRing.Length; + var fresh = new byte[size]; + _frameRing[slot] = fresh; + return fresh; + } + private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args) { using var frame = sender.TryAcquireLatestFrame(); @@ -225,10 +254,11 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource using var reference = buffer.CreateReference(); if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity)) { - var pixels = new byte[capacity]; + var pixels = RentRingBuffer((int)capacity); Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity); // MediaCapture video carries no alpha plane — the OS fills 255. - FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels) { IsOpaque = true }); + FrameAvailable?.Invoke(new VideoFrame(bitmap.PixelWidth, bitmap.PixelHeight, pixels) + { IsOpaque = true, Epoch = ++_epoch }); } } catch (Exception ex) diff --git a/Services/ScreenCaptureFrameSource.cs b/Services/ScreenCaptureFrameSource.cs index 8cd65d6..699e453 100644 --- a/Services/ScreenCaptureFrameSource.cs +++ b/Services/ScreenCaptureFrameSource.cs @@ -36,7 +36,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource // itself was fast. A 4-deep ring rotated round-robin is never lapped by a // ≤17ms consumer at 60Hz; each hand-out carries an Epoch so identity-keyed // consumers (the compositor's paste cache) cannot false-hit a recycled array. - private readonly byte[]?[] _frameRing = new byte[4][]; + // Depth 8 (take-14 rule): depth × source period must exceed the worst consumer + // hold — 4 slots at 144Hz laps in ~27ms while a compositor read + lagged UI + // preview copy can hold a frame ~50ms; the flash was half a new frame over old. + private readonly byte[]?[] _frameRing = new byte[8][]; private int _ringNext; private long _epoch; private byte[]? _row0; diff --git a/Services/WebView2Manager.cs b/Services/WebView2Manager.cs index 6c0eb95..409670f 100644 --- a/Services/WebView2Manager.cs +++ b/Services/WebView2Manager.cs @@ -29,6 +29,38 @@ public sealed class WebView2Manager : IDisposable public VideoFrame? LatestFrame; public bool Disposed; public bool Initialized; + + // Buffer reuse (2026-09-04, same lesson as the capture/camera rings): every + // capture tick used to mint a fresh 8.3MB canvas array + a fresh crop array + // (~83MB/s of LOH at 10Hz → gen2 pauses surfaced as the pump's 35-40ms + // "worst render" spikes). Canvas scratch is reused while the size holds; + // the frame handed to the compositor rotates an 8-deep ring with a monotonic + // Epoch (identity-keyed consumers must see each generation). The preview + // WriteableBitmap is recreated only when the cropped size changes. + public byte[]? CanvasScratch; + public readonly byte[]?[] OutRing = new byte[8][]; + public int OutRingNext; + public long Epoch; + public WriteableBitmap? PreviewBitmap; + + public byte[] RentOutBuffer(int size) + { + for (var tries = 0; tries < OutRing.Length; tries++) + { + var idx = (OutRingNext + tries) % OutRing.Length; + var buf = OutRing[idx]; + if (buf != null && buf.Length == size) + { + OutRingNext = (idx + 1) % OutRing.Length; + return buf; + } + } + var slot = OutRingNext; + OutRingNext = (slot + 1) % OutRing.Length; + var fresh = new byte[size]; + OutRing[slot] = fresh; + return fresh; + } } public WebView2Manager(Panel hostPanel, Dispatcher dispatcher) @@ -212,31 +244,36 @@ public sealed class WebView2Manager : IDisposable if (pixW < 1 || pixH < 1) return; - var pixels = new byte[pixW * pixH * 4]; + var need = pixW * pixH * 4; + var pixels = session.CanvasScratch is { } scratch && scratch.Length >= need + ? scratch + : (session.CanvasScratch = new byte[need]); formatted.CopyPixels(pixels, pixW * 4, 0); var stride = pixW * 4; var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride); - var outPixels = pixels; - if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0) - { - var dstStride = cropW * 4; - outPixels = new byte[dstStride * cropH]; + var outPixels = session.RentOutBuffer(cropW * cropH * 4); + if (cropW == pixW && cropH == pixH && cropX == 0 && cropY == 0) + Buffer.BlockCopy(pixels, 0, outPixels, 0, outPixels.Length); + else for (var r = 0; r < cropH; r++) - Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * dstStride, dstStride); - } + Buffer.BlockCopy(pixels, (cropY + r) * stride + cropX * 4, outPixels, r * cropW * 4, cropW * 4); - var wb = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null); - wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); - wb.Freeze(); + if (session.PreviewBitmap == null + || session.PreviewBitmap.PixelWidth != cropW || session.PreviewBitmap.PixelHeight != cropH) + session.PreviewBitmap = new WriteableBitmap(cropW, cropH, 96, 96, PixelFormats.Bgra32, null); + session.PreviewBitmap.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); - session.LatestFrame = new VideoFrame(cropW, cropH, outPixels); + session.LatestFrame = new VideoFrame(cropW, cropH, outPixels) { Epoch = ++session.Epoch }; + // The preview bitmap is now MUTABLE and reused (CameraManager precedent): + // WPF re-renders it after WritePixels; handlers must not Freeze it. + var preview = session.PreviewBitmap; _ = _dispatcher.BeginInvoke(() => { - PreviewBitmapChanged?.Invoke(sourceId, wb); + PreviewBitmapChanged?.Invoke(sourceId, preview); }); } catch (ObjectDisposedException) { } diff --git a/TASKS.md b/TASKS.md index 8d191ac..18d8d91 100644 --- a/TASKS.md +++ b/TASKS.md @@ -973,7 +973,9 @@ click (volume sliders keep their manual `SetSliderValueFromClick`, harmless dupl **Goal:** record the stream output to a local file, with or without simultaneously streaming. -### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10 ran (59a02a5b): the new `wait` stat exposed the LAST structural bug — `render 22 + wait 10` against a 16.7ms deadline is impossible for a rebasing pacer: the wait was the producer QUEUED BEHIND THE LIVE PREVIEW — the pump's await-continuations inherit the UI SynchronizationContext (StartAsync fires from a command handler), so the loop had been rendering on the dispatcher all along. Slice 7: Task.Run the loop (OBS keeps media threads off-UI for this exact reason), StaticPixelCache locked + chat raster marshalled to the dispatcher on cache-miss (RTB/DrawingVisual are UI-thread objects), SustainedLowLatency GC, `worst render` stat, webcam routed through the paste cache; test `Pump_Produces_OffTheStartingContext`, 70/70 green. **take 11 ran (c10ce06c): off-UI loop WORKED — typical frames land exactly on the 16.7ms deadline (work ~10 + wait ~6.8; 212/300 best yet); the remaining gap is periodic 35-65ms spikes worsening across a take = gen2 GC pauses, fed by the capture path's fresh ~8.3MB array per DWM frame (~500MB/s). Slice 8: 4-deep capture buffer ring + `VideoFrame.Epoch` in the identity-keyed paste cache + `gen2 +N` printed per stats window; test `PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` (fails on the old key), 37/37. Also this session (creator ask): post-mix master gain +40% (before the −1dBFS limiter, so no new clipping). **take 12: `gen2` ~0-1/window, `worst render` → ~16-20ms, n/300 → 300, audio ~40% louder in the file.**** Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF +### Status: ✅ Shipped `a9eb360` (2026-08-29) — code done (incl. manual-rename modal), build 0 warnings, 244/246 tests; **running-app verification (takes 1–5 done)**: files land (ffmpeg re-pinned month-end), **webcam-in-output + social bar visually CONFIRMED from take 3's extracted frame**, rename modal used for real (take 3 was named via it); take 3 exposed the ~2fps producer starvation and the pump stage-timing (`97ffc42`) named it in one line — `avg render 258.1ms` — **slice 1 fixed 2026-09-03** (deadline pacing per OBS video-io.c + libyuv-style row blits in `SceneCompositor`, test `Pump_Paces_To_The_Deadline_Compensating_Render_Cost`); **take 4: pacing held but render stayed 58.9ms** (2M-iteration row walk + 8.3MB/tick LOH) — **slice 2 shipped 2026-09-04**: `VideoFrame.IsOpaque` producer-contract flag → full-cover backdrop is one `Buffer.BlockCopy`; integer fixed-point bilinear general path; pump scratch pool (release strictly post-submit, owned-by-reference so cache frames are untouchable); dead per-tick `fromScene` render + the `fromSceneProvider` seam removed (BlendFrame uses `TransitionService.FromFrame` — the old render fed nothing). Tests `Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels` (the ONE) + `Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch`; 59/59 per-class green, clean build 0 warnings. **take 5 ran: render 58.9→25.5ms (`138/300` ≈ 2.2x still) — cause: the per-tick chat raster (`RenderFrame` hit `RenderTargetBitmap` every tick whenever the message buffer was non-empty — the buffer survives sessions); slice 3 shipped 2026-09-04: `ChatOverlayLayer` rasters on message/config change and blits a cached frame every tick (OBS text-source pattern; test `ChatOverlayLayerCacheTests`).** **take 6 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10 ran (59a02a5b): the new `wait` stat exposed the LAST structural bug — `render 22 + wait 10` against a 16.7ms deadline is impossible for a rebasing pacer: the wait was the producer QUEUED BEHIND THE LIVE PREVIEW — the pump's await-continuations inherit the UI SynchronizationContext (StartAsync fires from a command handler), so the loop had been rendering on the dispatcher all along. Slice 7: Task.Run the loop (OBS keeps media threads off-UI for this exact reason), StaticPixelCache locked + chat raster marshalled to the dispatcher on cache-miss (RTB/DrawingVisual are UI-thread objects), SustainedLowLatency GC, `worst render` stat, webcam routed through the paste cache; test `Pump_Produces_OffTheStartingContext`, 70/70 green. **take 11 ran (c10ce06c): off-UI loop WORKED — typical frames land exactly on the 16.7ms deadline (work ~10 + wait ~6.8; 212/300 best yet); the remaining gap is periodic 35-65ms spikes worsening across a take = gen2 GC pauses, fed by the capture path's fresh ~8.3MB array per DWM frame (~500MB/s). Slice 8: 4-deep capture buffer ring + `VideoFrame.Epoch` in the identity-keyed paste cache + `gen2 +N` printed per stats window; test `PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` (fails on the old key), 37/37. Also this session (creator ask): post-mix master gain +40% (before the −1dBFS limiter, so no new clipping). **take 12: `gen2` ~0-1/window, `worst render` → ~16-20ms, n/300 → 300, audio ~40% louder in the file.** + **ROLL-FORWARD (2026-09-04): the two-loop producer (the OBS duplicate-instead-of-starve redesign) and the +40% pre-limiter gain were NOT kept — the two-loop introduced recorded-output TEARING on real hardware and was rolled back with it (full record in HANDOFF). Main sits on the slice-8 serial loop plus this unit: all shared-frame rings 4→8 (screen/camera/web — depth × source period must exceed the worst consumer hold), fresh camera + WebView2 capture pooling, and the wordmark release counter #N replacing the UI GUID (per-build GUID now logs to startup.log only). The tear-proof pacing fix — duplicate-instead-of-starve ON the serial loop — is the next in-flight unit.** + Known remaining churn (follow-ups, not silently done): vertical tier's final `BilinearScale` still allocates per frame; one slow tick (~15-25ms) per arriving chat message — debounced off-tick re-render if take 6 shows burst loss. **User UX spec (2026-09-04) queued behind this**: two-line top bar (LIVE rename, radio pills, Login/Logout button + avatar right-click Change Account, no account light; line 2 centered Start↔Stop grayed-until-armed) + up-front SaveFileDialog for REC (native overwrite prompt; retires the stop-time rename modal) + Go-Live dialog KEPT as preflight confirmation prefilled from the Text drawer — decisions captured in HANDOFF 1. ✅ `EncoderOptions` extended with `StreamEnabled` / `RecordEnabled` / `RecordPath` (independent intent flags) 2. ✅ `FfmpegArgs.Build` reworked into per-output blocks (stream `-f flv`, record `-f mp4`) via `AddVideoTags` diff --git a/ai.md b/ai.md index cd81404..1c5d539 100644 --- a/ai.md +++ b/ai.md @@ -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`, `Func` resolver, `Func`, @@ -799,13 +799,17 @@ seam:** `Func`, `Func` resolver, `Func -/// Build attribution (2026-09-04, after takes 3–6 could not be pinned to a binary): +/// Build attribution (2026-09-04): the UI carries the release counter (#N, +1 per +/// commit, from git rev-list — stable across rebuilds of one commit); the per-build +/// GUID stays in startup.log for forensics. /// every build stamps a fresh GUID (ytLive.csproj GenerateBuildStamp), shown as the /// wordmark superscript and logged at startup. The unit pins the stamp shape; the /// RealApp test proves the wordmark ACTUALLY displays it (XAML wiring, live visual @@ -20,6 +22,12 @@ public sealed class BuildStampTests [Fact] public void Stamp_IsAFreshHexIdAndParsableTimestamp() { + // The UI shows the RELEASE COUNTER (creator ruling 2026-09-04: a per-build + // GUID changed on every launch and read as noise; #N ticks +1 per commit, + // started at 13 — derived from git rev-list, stable within a commit). + Assert.True(BuildStamp.Release >= 13, $"release counter regressed: {BuildStamp.Release}"); + Assert.Equal(BuildStamp.Label, $"#{BuildStamp.Release}"); + // The GUID survives as the forensic per-build id (startup.log only). Assert.Matches(new Regex("^[0-9a-f]{8}$"), BuildStamp.Id); Assert.True(DateTime.TryParseExact(BuildStamp.BuiltLocal, "yyyy-MM-dd HH:mm:ss", null, System.Globalization.DateTimeStyles.None, out var built)); @@ -47,7 +55,7 @@ public sealed class BuildStampDisplayTests // control, never from the window (MyMistakes: namescoped FindName). var topBar = (UserControl)window.FindName("TopBar")!; var stamp = (Run)topBar.FindName("BuildIdStamp")!; - Assert.Equal(BuildStamp.Id, stamp.Text); + Assert.Equal(BuildStamp.Label, stamp.Text); Assert.Equal(BaselineAlignment.Superscript, stamp.BaselineAlignment); } finally diff --git a/ytLive.csproj b/ytLive.csproj index 85c810b..2c61248 100644 --- a/ytLive.csproj +++ b/ytLive.csproj @@ -29,13 +29,30 @@ $(IntermediateOutputPath)BuildStamp.g.cs $([System.Guid]::NewGuid().ToString('N').Substring(0,8)) $([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss')) + + 241 + 13 + + + + + + $(CommitCountRaw.Trim()) + $([MSBuild]::Add($(BuildNumberStart), $([MSBuild]::Subtract($(CommitCount), $(BuildNumberBaseline))))) + $(BuildNumberStart) - - + + - +