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:
+13
-7
@@ -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`
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -37,7 +37,24 @@ public sealed class ChatOverlayLayer
|
||||
/// binds it to the panel) via <c>ChatMessages => _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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user