perf(chat): slice 3 — raster-on-change cache in ChatOverlayLayer (take-5 fix)

Take 5: render 58.9 -> 25.5ms (138/300, still ~2.2x). The blits were fixed; the
resolver was not: ResolveOutputFrame -> RenderChatBox ran a FULL WPF raster
(FormattedText + RenderTargetBitmap + CopyPixels + channel swap) EVERY tick
whenever the chat buffer was non-empty — and the buffer survives sessions, so
even a signed-out record-only take paid it. Established answer (OBS text
sources): re-render on change, blit the cache every tick.

ChatOverlayLayer: content version bumped from Messages.CollectionChanged
(covers adds, the 500-cap removal, the fade Clear from any caller) + a config
key (size + all Chat* appearance props); RenderFrame returns the cached
VideoFrame by identity until either changes (compositor only reads cached
frames). Conservative ordering (version latched BEFORE render) makes a
mid-render message re-render next tick, never serve stale.

ONE integration test: ChatOverlayLayerCacheTests (RealApp, real renderer):
Same() for unchanged inputs, NotSame() on message/config change, null on
empty. Full regression green (62 across touched classes), clean build 0
warnings. Accepted cost pending take 6: one ~15-25ms tick per arriving
message; if live-chat bursts sag n/300, next slice = debounced off-tick
re-render. Docs same commit: ai.md pipeline section, TASKS.md TASK 18,
MyMistakes recipe (raster-on-change + session-surviving-buffer trap),
HANDOFF (take 6 -> then Unit B, spec unchanged).
This commit is contained in:
2026-09-04 11:01:51 -07:00
parent 432adfdaef
commit 1c48849853
6 changed files with 121 additions and 12 deletions
+13 -7
View File
@@ -37,12 +37,18 @@ RealApp boot-smoke. Scope-check passed.
## OPEN — next, in order
1. **Take 5 (user, ~30s record-only):** read `%APPDATA%\ytLlive\startup.log` — expect
`FramePump stats: ≈300/300 frames per 5s, avg render ≤ ~10ms, avg submit ~1ms` + ffprobe frames ≈
60×duration. If render is under budget but `n/300` still short → suspect capture-side (the 1440p
`DownscaleBgra` per-frame alloc loop on the capture thread) or residual GC — next slice.
Also for the user to eyeball: webcam now at true 60fps, audio still in sync.
2. **Unit B — the top bar + session logic (user spec 2026-09-04, decisions settled):**
1. **Take 5 happened (2026-09-04 10:49):** `138/300 frames per 5s, avg render 25.5ms` — blits
fixed but the resolver's `RenderChatBox` full-rasterized the chat box EVERY tick whenever the
message buffer was non-empty (the buffer survives sessions — a signed-out record-only take paid
chat render cost!). **Slice 3 shipped same day:** `ChatOverlayLayer` content-versioned cache —
raster on message/config change, blit the cached frame every tick (OBS text-source pattern).
Tests: `ChatOverlayLayerCacheTests` (the ONE, RealApp) + full regression green (62 across
touched classes), clean build 0 warnings.
2. **Take 6 (user, ~30s record-only):** expect `≈300/300 frames per 5s, avg render ≤ ~10ms`. If
chat-burst drops one frame per message (avg fine but `n/300` sags during live chat): debounced
off-tick re-render is the next slice. If steady at ~300 with render <10 → recording saga
CLOSED, go Unit B (below).
3. **Unit B — the top bar + session logic (user spec 2026-09-04, decisions settled):**
- Two-line top bar. Line 1: center = REC + **LIVE** pills (text renamed from ON-AIR; pills become
mutually-exclusive RADIOS — record-OR-stream ruling), right = avatar + **Login/Logout** button
(no account status light). Line 2: centered primary **Start** (grayed while NO pill armed —
@@ -59,7 +65,7 @@ RealApp boot-smoke. Scope-check passed.
- LIVE+Start → **Go-Live dialog stays** as preflight: prefilled from Text-drawer `Broadcast.*`,
unfilled fields visibly prompted, explicit confirm → `PrepareAndStartLiveAsync` (user: "going
live is scary — confirmation allows back-out + testing up to go-live").
- Bottom-bar metrics init/maintain: `ResetHealth` + `HealthUpdated` exist — verify on take 5/6.
- Bottom-bar metrics init/maintain: `ResetHealth` + `HealthUpdated` exist — verify on take 6.
- F6 "start/end" hotkey routes through `HandleHotkey` — check it honors the new grayed-Start gate.
- ONE integration test (hermetic): pills↔button state machine + record-path seam (an
`internal static Func<SaveFileDialog-ish prompt>` override seam mirroring `RegistrarOverride`
+8
View File
@@ -74,6 +74,14 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive:
sync continuation and hang the test run (hit this 2026-09-03; the existing fakes all
yield for exactly this reason). Assert the REQUESTED wait (< interval with a
≥cost-ms fake render) — never wall-clock rate, which flakes on loaded machines.
4. **Expensive content: raster on change, never on read (take 5, 2026-09-04).** A source
that updates once a minute (chat text!) must not full-rasterize (`FormattedText` +
`RenderTargetBitmap` + `CopyPixels` ≈ 15-25ms) every compositor tick. OBS text sources
re-render on property/message change; the per-tick pass blits the cache. Implement as:
content version (collection-changed counter) + config key (size/appearance) → cached
immutable `VideoFrame` returned by identity. Gotcha: buffers that SURVIVE sessions
(the chat log) silently arm the per-tick cost even in flows that never touch the
feature (signed-out record-only takes paid chat rendering!).
**Take-4 follow-ups (2026-09-04) — the symptom needed a second pass, so cite again:**
render was still 58.9ms after slice 1. Slice 2 (buffer pool + opaque-row memcpy +
+30 -4
View File
@@ -37,7 +37,24 @@ public sealed class ChatOverlayLayer
/// binds it to the panel) via <c>ChatMessages =&gt; _chatLayer.Messages</c>.</summary>
public ObservableCollection<ChatMessage> Messages { get; } = new();
public ChatOverlayLayer(ChatBoxRenderer renderer) => _renderer = renderer;
// Output-frame cache (take-5 starvation fix): the pump called RenderFrame
// every tick and the renderer did a FULL WPF raster (FormattedText +
// RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a
// 25.5ms render. Established answer (OBS text sources): raster on change,
// blit the cache every tick. _contentVersion bumps on every buffer mutation
// (CollectionChanged — covers adds, the 500-cap removal, and the fade
// Clear from any caller); the config key covers the box geometry/appearance.
// The cached VideoFrame is treated as immutable: the compositor only reads.
private int _contentVersion;
private int _frameVersion = -1;
private string? _frameKey;
private VideoFrame? _cachedFrame;
public ChatOverlayLayer(ChatBoxRenderer renderer)
{
_renderer = renderer;
Messages.CollectionChanged += (_, _) => _contentVersion++;
}
/// <summary>Ingest a message from the poller. UI-thread marshalled: appended
/// to the buffer (capped at 500), mock preview stopped, swipe to real
@@ -90,12 +107,21 @@ public sealed class ChatOverlayLayer
/// preview render but without writing onto the source's VideoImageSource.</summary>
public VideoFrame? RenderFrame(Source chatBox)
{
if (Messages.Count == 0) return null;
var width = (int)Math.Max(1, chatBox.Width);
var height = (int)Math.Max(1, chatBox.Height);
var key =
$"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}"
+ $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}"
+ $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}";
return _renderer.Render(
if (_frameKey == key && _frameVersion == _contentVersion)
return _cachedFrame; // same content + same config — the cached raster stands
_frameKey = key;
_frameVersion = _contentVersion;
if (Messages.Count == 0) return _cachedFrame = null;
return _cachedFrame = _renderer.Render(
Messages,
width,
height,
+1 -1
View File
@@ -973,7 +973,7 @@ 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–4 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 pending**: stats must show `avg render ≤ ~10ms, ≈300/300`. Known remaining churn (follow-up, not silently done): the vertical tier's final `BilinearScale` still allocates per frame. **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 pending**: stats must show `avg render ≤ ~10ms, ≈300/300`. 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`
+12
View File
@@ -730,6 +730,18 @@ seam:** `Func<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
reference would race legitimate recycling. Take 5 must show `avg render ≤ ~10ms, ≈300/300`; the
vertical tier's final 1080×1920 `BilinearScale` still allocates fresh per frame (same GC lesson when
someone streams vertical — recorded as a follow-up, not silently "done").
- **Slice 3 — chat raster-on-change (2026-09-04, take 5):** take 5 measured `138/300, avg render
25.5ms` — the per-tick blits were cheap now, but `ResolveOutputFrame` → `RenderChatBox` ran a FULL
WPF raster (`ChatBoxRenderer`: FormattedText + `RenderTargetBitmap` + CopyPixels + channel swap)
EVERY tick whenever the message buffer was non-empty — and the buffer survives between sessions,
so even a signed-out record-only take paid it. Established answer, same as OBS text sources:
**raster on change, blit the cache every tick.** `ChatOverlayLayer` now keeps a content version
(`Messages.CollectionChanged` → `_contentVersion++`) plus a config key (box size + all Chat*
props); `RenderFrame` returns the cached `VideoFrame` by identity until either changes (the
compositor only ever reads a cached frame). Test: `ChatOverlayLayerCacheTests` (RealApp, real
renderer — `Same()` for unchanged inputs, `NotSame()` on message/config change, null on empty).
Accepted cost pending take 6: one slow tick (~15-25ms) per arriving message; if chat-burst frame
loss shows up, the next slice moves the re-render off-tick (debounced, dispatcher-side).
- **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
reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips
@@ -0,0 +1,57 @@
using System;
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Take-5 starvation fix (slice 3): the chat layer is rendered ON CHANGE, not on
/// every pump tick — the per-tick WPF raster (FormattedText + RenderTargetBitmap
/// + CopyPixels) was the 25ms render. OBS text sources raster-on-change; this
/// pins that contract with the real renderer on the RealApp STA host: identical
/// inputs reuse the cached frame by identity, and content or config changes force
/// a fresh raster.
/// </summary>
[Collection("RealApp")]
public sealed class ChatOverlayLayerCacheTests
{
private readonly RealAppHost _app;
public ChatOverlayLayerCacheTests(RealAppHost app) => _app = app;
[Fact]
public void RenderFrame_RastersOnContentOrConfigChange_ReusesCacheOtherwise()
{
_app.Run(Run);
}
private void Run()
{
var layer = new ChatOverlayLayer(new ChatBoxRenderer());
var box = new Source { Type = SourceType.ChatBox, Name = "YouTube Chat", Width = 220, Height = 160 };
Assert.Null(layer.RenderFrame(box)); // empty buffer — nothing to rasterize
layer.Messages.Add(new ChatMessage { AuthorName = "a", Message = "hello", Timestamp = DateTime.Now });
var first = layer.RenderFrame(box);
Assert.NotNull(first);
Assert.Same(first, layer.RenderFrame(box)); // unchanged content + config → the SAME frame
Assert.Same(first, layer.RenderFrame(box)); // every tick hits the cache, not the raster
var resized = new Source { Type = SourceType.ChatBox, Width = 300, Height = 160 };
var reRendered = layer.RenderFrame(resized);
Assert.NotNull(reRendered);
Assert.NotSame(first, reRendered); // config change → fresh raster
layer.Messages.Add(new ChatMessage { AuthorName = "b", Message = "second", Timestamp = DateTime.Now });
var grown = layer.RenderFrame(box);
Assert.NotNull(grown);
Assert.NotSame(reRendered, grown); // new message → fresh raster
layer.Messages.Clear(); // the fade path
Assert.Null(layer.RenderFrame(box));
Assert.Null(layer.RenderFrame(box));
}
}