diff --git a/HANDOFF.md b/HANDOFF.md index e3c71d5..d7162b6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -44,17 +44,18 @@ RealApp boot-smoke. Scope-check passed. 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. **BUILD STAMP shipped (slice 4, same day):** take 6 came back 35-41ms — WORSE than take 5 — - and attribution was impossible (exe timestamp ≠ binary contents; incremental builds served - unverified exes). Every build now stamps a fresh GUID (`ytLive.csproj GenerateBuildStamp` → - `Helpers/BuildStamp`), shown as the wordmark superscript (build id) + `Build xxxxxxxx (compiled - ...)` in startup.log; stats report `avg render Xms (resolve Y)` so the hot half of the tick is - named. Tests `BuildStampTests` + `BuildStampDisplayTests` (real window, namescoped FindName). -3. **Take 7 (user, ~30s record-only):** FIRST read the superscript + startup.log Build line — the - take is meaningless without it. Expect slice 3 in any post-stamp build: `avg render ≤ ~10ms - (resolve ≪ render), ≈300/300`. If resolve dominates → resolver-side surprise (chat config thrash - / web frame); if blit dominates → measure the general-path element sizes. If chat-burst sags - `n/300`: debounced off-tick re-render slice. If clean → recording saga CLOSED, Unit B. +2. **Takes 7/8 + slice 5 (2026-09-04):** the stamp (f190587b) settled attribution — chat cache REAL + (`resolve ≈ 0`) but render stayed 26-27ms: the compositor re-rasterized every non-opaque layer + per tick (his Live scene: chat+web+image+cam ≈ 680k samples @ ~38ns). Slice 5: `BlitCachedLayer` + — one raster per (source-array, rect, round/mirror), paste at integer pos with opacity; static + layers now cost row-blends, only content changes resample; drag/opacity changes are paste params, + not cache keys. Tests: `PasteCache_...` + 85/85 across compositor/pump/chat/capture/session + classes; clean build 0 warnings. (Cam bypasses the cache via IsOpaque; revisit if take 9 is + borderline. Follow-ups unchanged: vertical-tier alloc, debounced chat re-render on bursts.) +3. **Take 9 (user, ~30s record-only):** confirm the wordmark superscript matches the startup.log + Build line, then stats must show `≈300/300, avg render ≤ ~8ms` and playback 1x. If yes → + **recording saga CLOSED**, Unit B starts. If render ~10-15 → cam-resample cache (drop the + IsOpaque bypass); if n/300 sags only during chat bursts → debounced off-tick raster slice. 4. **Unit B — the top bar + session logic (user spec 2026-09-04 re-sent twice + decisions settled in Q&A):** - 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 diff --git a/MyMistakes.md b/MyMistakes.md index 5911e24..b817564 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -83,6 +83,14 @@ Both halves were solved by OBS/libyuv long ago; do not re-derive: (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!). +5. **Prove the stage, then the fix — and re-prove after every slice (2026-09-04, takes 6-8).** + The chat raster fix was REAL but the composer blamed it for the residual slowness it did not + own; two takes burned before the render/resolve split showed `resolve ≈ 0` and pointed at the + compositor pasting static layers per tick (`BlitCachedLayer` finished the job OBS-style). Before + shipping a perf fix: name the stage with a measurement, not a story; after shipping one, the + NEXT number must move — a fix that doesn't change the stat wasn't the bottleneck. + + **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 + integer bilinear) followed the same libyuv research diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 17e1bad..adc25ac 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -14,6 +14,19 @@ namespace ytLive.Services.Compositor; /// public sealed class SceneCompositor { + // Paste cache (take-8 fix, 2026-09-04): the Live scene re-sampled ~680k bilinear + // pixels EVERY tick even though only the camera actually changes at that rate — + // chat/web/image layers are identical frame after frame. Same shape as OBS (the + // source surface is cached, the compositor blits): a scaled/round-clipped/mirrored + // layer rasterizes once and is pasted (row-copy/blend) on every later tick. + // Keyed by the SOURCE FRAME IDENTITY — every producer (capture, webcam, chat + // renderer, web capture, static cache) hands out immutable byte[], so "same pixels + // array + same target rect" ⇒ same raster. Bounds-checked eviction clears wholesale. + private sealed record PasteKey(byte[] Pixels, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror); + private static readonly Dictionary _pasteCache = new(); + private static readonly object _pasteGate = new(); + private const int MaxPasteEntries = 48; + /// /// Composite into the tier's output frame. Layer order (back → /// front): live background (the scene's IsBackground source) → background image → @@ -240,12 +253,84 @@ public sealed class SceneCompositor var ew = (float)element.Width; var eh = (float)element.Height; var isRound = element.ClipShape == ClipShape.Round; - BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame, - (float)element.Opacity, isRound, element.IsMirrored); + if (frame.IsOpaque && element.Opacity >= 1f) + { + // Opaque full-strength layer (the live backdrop): raster it straight, no alpha path. + BlitContent(buffer, cropW, cropH, ex, ey, ew, eh, frame, 1f, isRound, element.IsMirrored); + } + else + { + BlitCachedLayer(buffer, cropW, cropH, ex, ey, ew, eh, frame, element, isRound); + } if (element.HasBorder) DrawBorder(buffer, cropW, cropH, ex, ey, ew, eh, element, isRound); } + /// Non-opaque layer blit through the paste cache: the element-space raster + /// (scaled to the element rect, round-clipped, mirrored) depends only on the source + /// frame + rect + shape/mirror — NOT on position or opacity. Static content (chat, + /// web overlays, images, the media frame between updates) is sampled ONCE and pasted + /// at integer position with per-tixel blend; only layers whose source frame actually + /// changed (webcam at device fps) pay resampling. + private static void BlitCachedLayer( + byte[] buffer, int cropW, int cropH, + float ex, float ey, float ew, float eh, + VideoFrame frame, SceneElement element, bool isRound) + { + if (ew <= 0 || eh <= 0) return; + var dw = (int)System.Math.Ceiling(ew); + var dh = (int)System.Math.Ceiling(eh); + if (dw <= 0 || dh <= 0) return; + var mirror = element.IsMirrored; + + PasteKey? key = null; + VideoFrame? raster = null; + lock (_pasteGate) + { + key = new PasteKey(frame.BgraPixels, frame.Width, frame.Height, dw, dh, isRound, mirror); + _pasteCache.TryGetValue(key, out raster); + } + + if (raster == null) + { + // Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255 + // alpha only where it actually draws, so untouched margins (outside the + // round clip, past the UniformToFill edges) stay alpha 0 and the paste + // reveals the master beneath instead of black. + var pixels = new byte[dw * dh * 4]; + BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror); + raster = new VideoFrame(dw, dh, pixels); + lock (_pasteGate) + { + if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear(); + _pasteCache[key!] = raster; + } + } + + // Paste at integer position; opacity blends. The raster is never flagged + // IsOpaque — round clips/margins carry alpha 0 — so the row blend always runs. + var op = (int)(System.Math.Clamp(element.Opacity, 0f, 1f) * 256); + if (op <= 0) return; + var px = (int)System.Math.Floor(ex); + var py = (int)System.Math.Floor(ey); + var x0 = System.Math.Max(0, px); + var y0 = System.Math.Max(0, py); + var x1 = System.Math.Min(cropW, px + dw); + var y1 = System.Math.Min(cropH, py + dh); + if (x0 >= x1 || y0 >= y1) return; + var d = buffer.AsSpan(); + var s = raster.BgraPixels; + for (var y = y0; y < y1; y++) + { + var si = ((y - py) * dw + (x0 - px)) * 4; + var len = x1 - x0; + if (op == 256) + BlendRowOpaque(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len); + else + BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op); + } + } + private static VideoFrame Overlay(VideoFrame frame, CompositorOptions options, VideoFrame overlay, int sx0, int sy0) { var cropW = options.SourceRectWidth; @@ -311,6 +396,20 @@ public sealed class SceneCompositor return; } + BlitContentRaw(dst, dstW, dstH, ex, ey, ew, eh, src, opacity, isRound, isMirror); + } + + /// The general scaled/clipped/mirrored sampler (integer fixed-point + /// bilinear + source-over). Also rasterizes paste-cache entries in ELEMENT space + /// onto a TRANSPARENT base (alpha 0) so pasting over the master keeps the round + /// clip and transparency of the untouched margins. + private static void BlitContentRaw( + byte[] dst, int dstW, int dstH, + float ex, float ey, float ew, float eh, + VideoFrame src, float opacity, bool isRound, bool isMirror) + { + if (ew <= 0 || eh <= 0) return; + var (scale, ox, oy) = StretchMath.UniformToFill(ew, eh, src.Width, src.Height); var drawnW = src.Width * scale; var drawnH = src.Height * scale; var radius = Math.Min(ew, eh) / 2f; diff --git a/TASKS.md b/TASKS.md index ee5853b..3ca7418 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–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 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **take 7 pending**: read stamp → confirm slice 3 build → stats must show `avg render ≤ ~10ms (resolve ≪), ≈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 +### 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 ran WITHOUT attribution (35-41ms — build provenance unknown; slice 3 effectiveness UNCONFIRMED) → build-stamp shipped instead of guessing again: `Helpers/BuildStamp` GUID per build (csproj GenerateBuildStamp; incremental builds can no longer lie), wordmark superscript + startup.log line; stats split `render (resolve)` so the next take names the stage. **takes 7–8 (stamped f190587b): chat fix CONFIRMED (`resolve ≈0`) but render stayed 26-27ms — compositor re-rasterizing STATIC layers every tick; slice 5 shipped: `BlitCachedLayer` pastes once-rasterized element-space layers (OBS surface-cache pattern; test `PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates`). **take 9 pending**: read stamp → stats must show `≈300/300, avg render ≤ ~8ms` → saga closes.** 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 72237a0..5dc7db8 100644 --- a/ai.md +++ b/ai.md @@ -707,8 +707,8 @@ seam:** `Func`, `Func` resolver, `Func`, `Func` resolver, `Func +/// Build attribution (2026-09-04, after takes 3–6 could not be pinned to a binary): +/// 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 +/// RealApp test proves the wordmark ACTUALLY displays it (XAML wiring, live visual +/// tree) — a stamp nobody can see would not have ended the guessing. +/// +public sealed class BuildStampTests +{ + [Fact] + public void Stamp_IsAFreshHexIdAndParsableTimestamp() + { + Assert.Matches(new Regex("^[0-9a-f]{8}$"), BuildStamp.Id); + Assert.True(DateTime.TryParseExact(BuildStamp.BuiltLocal, "yyyy-MM-dd HH:mm:ss", + null, System.Globalization.DateTimeStyles.None, out var built)); + Assert.True(built <= DateTime.Now); + Assert.True(built.Date >= new DateTime(2026, 9, 4)); + } +} + +[Collection("RealApp")] +public sealed class BuildStampDisplayTests +{ + private readonly RealAppHost _app; + + public BuildStampDisplayTests(RealAppHost app) => _app = app; + + [Fact] + public void Wordmark_Shows_The_Build_Stamp_As_Superscript() + { + _app.Run(() => + { + var window = new MainWindow(); + try + { + // TopBar is its own namescope (UserControl) — FindName from the + // control, never from the window (MyMistakes: namescoped FindName). + var topBar = (UserControl)window.FindName("TopBar")!; + var stamp = (Run)topBar.FindName("BuildIdStamp")!; + Assert.Equal(BuildStamp.Id, stamp.Text); + Assert.Equal(BaselineAlignment.Superscript, stamp.BaselineAlignment); + } + finally + { + window.Close(); + } + }); + } +} diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs index 51f187f..7a379a8 100644 --- a/ytLive.Tests/SceneCompositorTests.cs +++ b/ytLive.Tests/SceneCompositorTests.cs @@ -154,6 +154,63 @@ public class SceneCompositorTests Assert.DoesNotContain((byte)0xFD, output.BgraPixels); // full cover — nothing stale } + /// The ONE integration test for the paste cache (take-8): a second render of + /// the SAME frames must produce byte-identical output (the hit path pastes a cached + /// element-space raster — position/opacity/margins/round-clip must survive it), and a + /// NEW source array must propagate (the cache keys on frame identity, so a stale + /// raster can never outlive its content). Covers the margins the FullScene probe + /// pixels don't: outside-the-circle alpha and the black pre-fill interaction. + [Fact] + public void PasteCache_RepeatRender_IsByteIdentical_And_ContentChangePropagates() + { + var red = Solid(1920, 1080, 255, 0, 0); + var green = Solid(1280, 720, 0, 255, 0); + var blue = Solid(1280, 720, 0, 0, 255); + + var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" }; + var roundWebcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 300, Height = 300, ClipShape = ClipShape.Round }; + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(background); + scene.Elements.Add(roundWebcam); + + VideoFrame? FrameFor(SceneElement e) => e switch + { + WebcamSceneConfig => green, + Source { IsBackground: true } => red, + _ => null, + }; + + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1920, SourceRectHeight = 1080, + OutputWidth = 1920, OutputHeight = 1080, + }; + + var compositor = new SceneCompositor(); + var first = compositor.Render(scene, FrameFor, null, options); + var second = compositor.Render(scene, FrameFor, null, options); // paste-cache hit + Assert.Equal(first.BgraPixels, second.BgraPixels); + + // margins: inside the circle = webcam, outside = backdrop (both renders) + foreach (var f in new[] { first, second }) + { + AssertColor(f, 250, 250, 0, 255, 0); + AssertColor(f, 101, 101, 255, 0, 0); + Assert.Equal((byte)255, f.BgraPixels[(250 * 1920 + 250) * 4 + 3]); // alpha stays opaque + } + + // Content change (new array, same geometry) must show through the cache. + VideoFrame? FrameForBlue(SceneElement e) => e switch + { + WebcamSceneConfig => blue, + Source { IsBackground: true } => red, + _ => null, + }; + var third = compositor.Render(scene, FrameForBlue, null, options); + AssertColor(third, 250, 250, 0, 0, 255); + AssertColor(third, 101, 101, 255, 0, 0); + } + [Fact] public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop() {