diff --git a/HANDOFF.md b/HANDOFF.md index 4642a98..5347d6b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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` override seam mirroring `RegistrarOverride` diff --git a/MyMistakes.md b/MyMistakes.md index 54ed023..b5b131c 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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 + diff --git a/Services/ChatOverlayLayer.cs b/Services/ChatOverlayLayer.cs index 912ca8b..3600d8c 100644 --- a/Services/ChatOverlayLayer.cs +++ b/Services/ChatOverlayLayer.cs @@ -37,7 +37,24 @@ public sealed class ChatOverlayLayer /// binds it to the panel) via ChatMessages => _chatLayer.Messages. public ObservableCollection 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++; + } /// 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. 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, diff --git a/TASKS.md b/TASKS.md index be58379..6448101 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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` diff --git a/ai.md b/ai.md index 712c254..7d85bc9 100644 --- a/ai.md +++ b/ai.md @@ -730,6 +730,18 @@ seam:** `Func`, `Func` resolver, `Func +/// 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. +/// +[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)); + } +}