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
+2 -1
View File
@@ -27,7 +27,8 @@ public partial class App : Application
AppLog.Write("App OnStartup begin"); AppLog.Write("App OnStartup begin");
// Attribution (2026-09-04): every take must be traceable to the exact binary // 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})"); AppLog.Write($"Build {ytLive.Helpers.BuildStamp.Id} (compiled {ytLive.Helpers.BuildStamp.BuiltLocal})");
// Velopack auto-update bootstrap — checks for updates on startup and // Velopack auto-update bootstrap — checks for updates on startup and
+2 -2
View File
@@ -22,9 +22,9 @@
</Button.Template> </Button.Template>
<TextBlock FontSize="22" FontWeight="Bold"> <TextBlock FontSize="22" FontWeight="Bold">
<Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run <Run Text="LlamaCas" Foreground="#e0e0e0"/><Run Text="ty" Foreground="#e94560"/><Run
x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Id}" FontSize="9" x:Name="BuildIdStamp" Text="{x:Static helpers:BuildStamp.Label}" FontSize="9"
BaselineAlignment="Superscript" Foreground="#8888aa" BaselineAlignment="Superscript" Foreground="#8888aa"
ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Id}"/><Run Text=" " FontSize="4"/> ToolTipService.ToolTip="{x:Static helpers:BuildStamp.Label}"/><Run Text=" " FontSize="4"/>
</TextBlock> </TextBlock>
</Button> </Button>
+7
View File
@@ -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: 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. 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 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 genuinely await it (`Task.Delay(d, ct)`) — a fake that returns
`Task.CompletedTask` synchronously makes the whole pump loop run on `StartAsync`'s `Task.CompletedTask` synchronously makes the whole pump loop run on `StartAsync`'s
+32 -2
View File
@@ -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) private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
{ {
using var frame = sender.TryAcquireLatestFrame(); using var frame = sender.TryAcquireLatestFrame();
@@ -225,10 +254,11 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
using var reference = buffer.CreateReference(); using var reference = buffer.CreateReference();
if (WindowsRuntimeMarshal.TryGetDataUnsafe(reference, out var pixelsPtr, out var capacity)) 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); Marshal.Copy(pixelsPtr, pixels, 0, (int)capacity);
// MediaCapture video carries no alpha plane — the OS fills 255. // 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) catch (Exception ex)
+4 -1
View File
@@ -36,7 +36,10 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource
// itself was fast. A 4-deep ring rotated round-robin is never lapped by a // 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 // ≤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. // 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 int _ringNext;
private long _epoch; private long _epoch;
private byte[]? _row0; private byte[]? _row0;
+50 -13
View File
@@ -29,6 +29,38 @@ public sealed class WebView2Manager : IDisposable
public VideoFrame? LatestFrame; public VideoFrame? LatestFrame;
public bool Disposed; public bool Disposed;
public bool Initialized; 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) public WebView2Manager(Panel hostPanel, Dispatcher dispatcher)
@@ -212,31 +244,36 @@ public sealed class WebView2Manager : IDisposable
if (pixW < 1 || pixH < 1) return; 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); formatted.CopyPixels(pixels, pixW * 4, 0);
var stride = pixW * 4; var stride = pixW * 4;
var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride); var (cropX, cropY, cropW, cropH) = FindContentBounds(pixels, pixW, pixH, stride);
var outPixels = pixels; var outPixels = session.RentOutBuffer(cropW * cropH * 4);
if (cropW != pixW || cropH != pixH || cropX != 0 || cropY != 0) if (cropW == pixW && cropH == pixH && cropX == 0 && cropY == 0)
{ Buffer.BlockCopy(pixels, 0, outPixels, 0, outPixels.Length);
var dstStride = cropW * 4; else
outPixels = new byte[dstStride * cropH];
for (var r = 0; r < cropH; r++) 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); if (session.PreviewBitmap == null
wb.WritePixels(new Int32Rect(0, 0, cropW, cropH), outPixels, cropW * 4, 0); || session.PreviewBitmap.PixelWidth != cropW || session.PreviewBitmap.PixelHeight != cropH)
wb.Freeze(); 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(() => _ = _dispatcher.BeginInvoke(() =>
{ {
PreviewBitmapChanged?.Invoke(sourceId, wb); PreviewBitmapChanged?.Invoke(sourceId, preview);
}); });
} }
catch (ObjectDisposedException) { } catch (ObjectDisposedException) { }
+3 -1
View File
File diff suppressed because one or more lines are too long
+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** | | `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** | | `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")** | | `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`) | | `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) | | `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) 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 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 `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 ("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 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>`, 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 (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 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 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 ~8.3MB `byte[]` per DWM frame (~500MB/s LOH). It now rotates a shared-frame ring (4-deep at slice 8,
never be lapped at 60Hz), size-matched per slot, with reused downscale row scratch. Recycled deepened to 8-deep after the "flash" finding — consumer holds must never outlive depth × source
arrays would poison the paste cache (it keys on array IDENTITY), so `VideoFrame.Epoch` — period), size-matched per slot, with reused downscale row scratch. Recycled
monotonic per producer frame, 0 for fresh-array producers — joins the key. Regression test arrays would poison the paste cache (it keys on array IDENTITY), so `VideoFrame.Epoch` —
`PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` fails on the old key by monotonic per producer frame, 0 for fresh-array producers — joins the key. Regression test
construction. Next suspect if gen2 stays hot: the 10Hz WebView2 capture loop (full-canvas PNG `PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` fails on the old key by
decode + fresh arrays on the UI thread) — recorded as follow-up, untouched this slice. 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) - **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 **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 reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
+10 -2
View File
@@ -9,7 +9,9 @@ using ytLive.Helpers;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary> /// <summary>
/// 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 /// 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 /// 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 /// RealApp test proves the wordmark ACTUALLY displays it (XAML wiring, live visual
@@ -20,6 +22,12 @@ public sealed class BuildStampTests
[Fact] [Fact]
public void Stamp_IsAFreshHexIdAndParsableTimestamp() 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.Matches(new Regex("^[0-9a-f]{8}$"), BuildStamp.Id);
Assert.True(DateTime.TryParseExact(BuildStamp.BuiltLocal, "yyyy-MM-dd HH:mm:ss", Assert.True(DateTime.TryParseExact(BuildStamp.BuiltLocal, "yyyy-MM-dd HH:mm:ss",
null, System.Globalization.DateTimeStyles.None, out var built)); null, System.Globalization.DateTimeStyles.None, out var built));
@@ -47,7 +55,7 @@ public sealed class BuildStampDisplayTests
// control, never from the window (MyMistakes: namescoped FindName). // control, never from the window (MyMistakes: namescoped FindName).
var topBar = (UserControl)window.FindName("TopBar")!; var topBar = (UserControl)window.FindName("TopBar")!;
var stamp = (Run)topBar.FindName("BuildIdStamp")!; var stamp = (Run)topBar.FindName("BuildIdStamp")!;
Assert.Equal(BuildStamp.Id, stamp.Text); Assert.Equal(BuildStamp.Label, stamp.Text);
Assert.Equal(BaselineAlignment.Superscript, stamp.BaselineAlignment); Assert.Equal(BaselineAlignment.Superscript, stamp.BaselineAlignment);
} }
finally finally
+20 -3
View File
@@ -29,13 +29,30 @@
<BuildStampFile>$(IntermediateOutputPath)BuildStamp.g.cs</BuildStampFile> <BuildStampFile>$(IntermediateOutputPath)BuildStamp.g.cs</BuildStampFile>
<BuildStampId>$([System.Guid]::NewGuid().ToString('N').Substring(0,8))</BuildStampId> <BuildStampId>$([System.Guid]::NewGuid().ToString('N').Substring(0,8))</BuildStampId>
<BuildStampTime>$([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))</BuildStampTime> <BuildStampTime>$([System.DateTime]::Now.ToString('yyyy-MM-dd HH:mm:ss'))</BuildStampTime>
<!-- RELEASE COUNTER (creator ruling 2026-09-04): the UI shows a sequential number that
ticks +1 per COMMIT, starting at 13 — a per-BUILD GUID changed on every launch and read
as noise (rightly). Derived from git rev-list count (stable per commit, no bookkeeping).
Baseline 241 = count at introduction, first display 13. Missing git or unparsed output
falls back to the start (build never breaks). GUID + compile time stay in startup.log,
where build-identity forensics actually live. -->
<BuildNumberBaseline>241</BuildNumberBaseline>
<BuildNumberStart>13</BuildNumberStart>
</PropertyGroup>
<Exec Command="&quot;C:\Program Files\Git\cmd\git.exe&quot; rev-list --count HEAD"
WorkingDirectory="$(ProjectDir)" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="CommitCountRaw" />
</Exec>
<PropertyGroup>
<CommitCount>$(CommitCountRaw.Trim())</CommitCount>
<BuildNumber>$([MSBuild]::Add($(BuildNumberStart), $([MSBuild]::Subtract($(CommitCount), $(BuildNumberBaseline)))))</BuildNumber>
<BuildNumber Condition="! $([System.Text.RegularExpressions.Regex]::IsMatch('$(BuildNumber)', '^[0-9]+$'))">$(BuildNumberStart)</BuildNumber>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<BuildStampLines Include="// &lt;auto-generated&gt; Per-build identity (ytLive.csproj GenerateBuildStamp)."/> <BuildStampLines Include="// &lt;auto-generated&gt; Per-build identity (ytLive.csproj GenerateBuildStamp)."/>
<BuildStampLines Include="// Shown as the wordmark superscript and logged at startup: proof of which"/> <BuildStampLines Include="// Release/Label = the sequential UI stamp (+1 per commit, started at 13)."/>
<BuildStampLines Include="// binary produced a test recording (2026-09-04). Never edit by hand."/> <BuildStampLines Include="// Id/BuiltLocal = per-build forensic identity, logged to startup.log only. Never edit by hand."/>
<BuildStampLines Include="namespace ytLive.Helpers%3B"/> <BuildStampLines Include="namespace ytLive.Helpers%3B"/>
<BuildStampLines Include="public static class BuildStamp { public const string Id = &quot;$(BuildStampId)&quot;%3B public const string BuiltLocal = &quot;$(BuildStampTime)&quot;%3B }"/> <BuildStampLines Include="public static class BuildStamp { public const int Release = $(BuildNumber)%3B public const string Label = &quot;#$(BuildNumber)&quot;%3B public const string Id = &quot;$(BuildStampId)&quot;%3B public const string BuiltLocal = &quot;$(BuildStampTime)&quot;%3B }"/>
</ItemGroup> </ItemGroup>
<WriteLinesToFile File="$(BuildStampFile)" Lines="@(BuildStampLines)" Overwrite="true" WriteOnlyWhenDifferent="false" /> <WriteLinesToFile File="$(BuildStampFile)" Lines="@(BuildStampLines)" Overwrite="true" WriteOnlyWhenDifferent="false" />
<ItemGroup> <ItemGroup>