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:
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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) { }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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=""C:\Program Files\Git\cmd\git.exe" 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="// <auto-generated> Per-build identity (ytLive.csproj GenerateBuildStamp)."/>
|
<BuildStampLines Include="// <auto-generated> 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 = "$(BuildStampId)"%3B public const string BuiltLocal = "$(BuildStampTime)"%3B }"/>
|
<BuildStampLines Include="public static class BuildStamp { public const int Release = $(BuildNumber)%3B public const string Label = "#$(BuildNumber)"%3B public const string Id = "$(BuildStampId)"%3B public const string BuiltLocal = "$(BuildStampTime)"%3B }"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<WriteLinesToFile File="$(BuildStampFile)" Lines="@(BuildStampLines)" Overwrite="true" WriteOnlyWhenDifferent="false" />
|
<WriteLinesToFile File="$(BuildStampFile)" Lines="@(BuildStampLines)" Overwrite="true" WriteOnlyWhenDifferent="false" />
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user