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
+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)
{
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)
+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
// ≤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;
+50 -13
View File
@@ -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) { }