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)
-
-
+
+
-
+