From fbc8562cf43f8b15eee655614d85da930aa38671 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 4 Sep 2026 12:36:14 -0700 Subject: [PATCH] =?UTF-8?q?perf(capture):=20slice=208=20=E2=80=94=20buffer?= =?UTF-8?q?=20ring=20+=20paste-cache=20Epoch;=20gen2=20visibility=20(take-?= =?UTF-8?q?11=20spikes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Take 11 (c10ce06c) validated the off-UI architecture: typical frames land work ~10ms + wait ~6.8ms = 16.7 exactly on the deadline; 212/300 best yet. The ENTIRE remaining gap is periodic 35-65ms render spikes that WORSENED across the take (189 -> 147) — the signature of gen2 GC pauses. Biggest churner is structural: the screen capture minted a fresh ~8.3MB byte[] per DWM frame (~500MB/s of LOH), a producer OBS never does (it owns fixed surface pools). - ScreenCaptureFrameSource: 4-deep buffer ring with size-matched slots (a <=17ms consumer cannot be lapped at 60Hz) + reused downscale row scratch. - VideoFrame.Epoch: monotonic per producer frame. The paste cache keys on array IDENTITY, so recycled arrays MUST be distinguished — epoch joins the PasteKey. Producers handing fresh arrays leave it 0 (key unchanged effect). - Stats print 'gen2 +N' per 5s window: next take acquits or convicts GC without another guess (rule: prove the stage). - Test (the ONE): PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits — same array, new content, bumped epoch; fails on the old key by construction. 37/37 compositor/pump, clean build. - Next suspect if gen2 stays hot: the 10Hz WebView2 capture (full-canvas PNG decode + fresh arrays on the UI thread) — recorded, untouched. Creator audio ask queued in the same working session (+40% post-mix master gain before the -1dBFS limiter) lands as its own commit next. --- HANDOFF.md | 24 +++++++++---- Services/Compositor/SceneCompositor.cs | 11 +++--- Services/ScreenCaptureFrameSource.cs | 49 +++++++++++++++++++++----- Services/VideoFrame.cs | 7 ++++ TASKS.md | 2 +- ai.md | 12 +++++++ ytLive.Tests/SceneCompositorTests.cs | 43 ++++++++++++++++++++++ 7 files changed, 128 insertions(+), 20 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index e27aa57..19a9bc9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -60,12 +60,24 @@ RealApp boot-smoke. Scope-check passed. `avg wait` so render+submit+wait ≈ period (accounting closed — nothing can hide). Webcam now routes through the paste cache too (bypass re-sampled 156k px even between identical device frames). 52/52 per-class green, clean build 0 warnings, committed this slice. -4. **Take 11 (user, ~30s record-only):** read the superscript; expect `≈300/300 frames, wait ≈ the true remainder, worst render` now visible. If ~300: playback must be honest 1x — saga CLOSED, Unit B starts. If still ~250-280 with worst-render spikes: raster-miss spikes (web capture ~ every second) — next slice is pre-rasterizing on content change rather than on first-tick-after-change (cache the miss behind a swap-in). If wait is STILL large: the context theory was wrong and I have egg to eat — re-instrument, don't guess. - ~22, wait ~0-3` — honest 60fps IF render+submit ≤ ~16.7. If wait is near zero and n/300 sits at - ~200, the remaining gap is pure render 22ms → next slice = per-phase compositor timing (the stats - can split blit phases the same way they split resolve; that is the honest path, not a guess). - If take 10 lands at ~300/300 → recording saga CLOSED. -5. **Unit B — the top bar + session logic (user spec 2026-09-04 re-sent twice + decisions settled in Q&A):** +4. **Take 11 ran + slice 8 (2026-09-04, committed with the audio unit below):** off-UI loop + WORKED — typical frames land exactly on the deadline (work ~10 + wait ~6.8 = 16.7; 212/300). + Remaining gap = periodic 35-65ms spikes growing across the take = gen2 GC pauses; the capture + path minted a fresh ~8.3MB byte[] per DWM frame (~500MB/s LOH). Slice 8: 4-deep buffer ring + in ScreenCaptureFrameSource (size-matched slots, reused row scratch) + `VideoFrame.Epoch` + joins the paste-cache key so recycled arrays can never false-hit + `gen2 +N` per stats window + (suspect-or-acquit — never guess at the tail again). Test `PasteCache_RecycledArrayWithNewEpoch_ + ReRasterizes_NotStaleHits` fails on the old key by construction. 37/37 compositor/pump green. +4b. **AUDIO (creator ask 2026-09-04):** post-mix volume up ~40% — constant master gain applied + in AudioMixer.FillAndMix BEFORE the −1 dBFS limiter (limiter still owns the ceiling: the + gain can't add clipping, it just makes the limiter bind sooner on hot material). If the + creator reports pumping on loud game audio → the constant is the tuning knob (or per-input + defaults need raising instead — discuss with data). +5. **Take 12 (user, ~30s record + talk):** stats: `gen2 +N` should be ~0-1/window, `worst render` + → ~16-20ms, n/300 → 300; playback honest speed; file audio ~40% hotter than before. If gen2 + still >2/window: next suspect is the 10Hz WebView2 capture (full-canvas PNG decode + fresh + arrays on the UI thread) — throttle or move it, cited pattern first. +6. **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 (no account status light). Line 2: centered primary **Start** (grayed while NO pill armed — diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 05ca255..f3fb513 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -19,10 +19,11 @@ public sealed class SceneCompositor // 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); + // Keyed by the SOURCE FRAME IDENTITY (array + Epoch) — producers hand out fresh + // immutable byte[], or recycled-ring arrays whose monotonic Epoch distinguishes + // each generation (screen capture, take-11 fix), so "same array+epoch + same + // target rect" ⇒ same raster. Bounds-checked eviction clears wholesale. + private sealed record PasteKey(byte[] Pixels, long Epoch, 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; @@ -284,7 +285,7 @@ public sealed class SceneCompositor VideoFrame? raster = null; lock (_pasteGate) { - key = new PasteKey(frame.BgraPixels, frame.Width, frame.Height, dw, dh, isRound, mirror); + key = new PasteKey(frame.BgraPixels, frame.Epoch, frame.Width, frame.Height, dw, dh, isRound, mirror); _pasteCache.TryGetValue(key, out raster); } diff --git a/Services/ScreenCaptureFrameSource.cs b/Services/ScreenCaptureFrameSource.cs index 7fa76af..8cd65d6 100644 --- a/Services/ScreenCaptureFrameSource.cs +++ b/Services/ScreenCaptureFrameSource.cs @@ -30,6 +30,18 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource private bool _framePending; private DateTime _lastErrorLog = DateTime.MinValue; + // Buffer recycling (take-11 spikes, 2026-09-04): a fresh ~8.3MB byte[] per DWM + // frame ≈ 500MB/s of LOH churn — the gen2 pauses it forces surfaced as the + // "worst render 35-65ms" spikes that capped fps at ~42 long after the compositor + // 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 + // consumers (the compositor's paste cache) cannot false-hit a recycled array. + private readonly byte[]?[] _frameRing = new byte[4][]; + private int _ringNext; + private long _epoch; + private byte[]? _row0; + private byte[]? _row1; + // The composition master frame (see ai.md "Resolution tiers"): the background // is an input layer, so we never hold a CPU frame bigger than the master. private const int MaxBackgroundWidth = 1920; @@ -157,7 +169,26 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource } } - private static VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap) + 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 is { Length: var len } && len == 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 VideoFrame CopyToVideoFrame(SoftwareBitmap bitmap) { var sw = bitmap.PixelWidth; var sh = bitmap.PixelHeight; @@ -178,21 +209,23 @@ public sealed class ScreenCaptureFrameSource : IScreenCaptureSource var dw = Math.Max(1, (int)(sw * scale)); var dh = Math.Max(1, (int)(sh * scale)); // DWM delivers an opaque surface (alpha 255); bilinear keeps it 255. - return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh)) { IsOpaque = true }; + var scaled = RentRingBuffer(dw * dh * 4); + return new VideoFrame(dw, dh, DownscaleBgra(data, sw, sh, srcStride, dw, dh, scaled)) + { IsOpaque = true, Epoch = ++_epoch }; } - var pixels = new byte[count]; + var pixels = RentRingBuffer(count); Marshal.Copy(data, pixels, 0, pixels.Length); - return new VideoFrame(sw, sh, pixels) { IsOpaque = true }; + return new VideoFrame(sw, sh, pixels) { IsOpaque = true, Epoch = ++_epoch }; } // Bilinear downscale to the master frame. Reads each source row pair through // Marshal.Copy (no unsafe), writing tightly packed BGRA output. - private static byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh) + private byte[] DownscaleBgra(IntPtr src, int sw, int sh, int srcStride, int dw, int dh, byte[] dst) { - var row0 = new byte[srcStride]; - var row1 = new byte[srcStride]; - var dst = new byte[dw * dh * 4]; + // Row scratch is per-capture-thread and reused across frames (same churn lesson). + var row0 = _row0 != null && _row0.Length >= srcStride ? _row0 : (_row0 = new byte[srcStride]); + var row1 = _row1 != null && _row1.Length >= srcStride ? _row1 : (_row1 = new byte[srcStride]); var xs = sw / (double)dw; var ys = sh / (double)dh; diff --git a/Services/VideoFrame.cs b/Services/VideoFrame.cs index 71bc5fa..05ac1bb 100644 --- a/Services/VideoFrame.cs +++ b/Services/VideoFrame.cs @@ -13,6 +13,13 @@ public sealed class VideoFrame public byte[] BgraPixels { get; } public int Stride => Width * 4; + /// Producer frame epoch — a monotonically increasing number stamped by + /// sources that RECYCLE their pixel buffers (the screen-capture ring, take-11 spike + /// fix). Consumers that key caches on buffer identity (the compositor's paste cache) + /// MUST include it, or a recycled array false-hits with stale content. Producers + /// that hand out fresh arrays per frame leave it 0 — identity alone is then enough. + public long Epoch { get; init; } + /// Producer contract: every alpha byte in is 255 /// (DWM capture surfaces and MediaCapture video carry no alpha — the OS fills 255). /// Lets the compositor take a straight-copy fast path for a full-canvas opaque diff --git a/TASKS.md b/TASKS.md index 6ba3c8d..8d191ac 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. **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 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10 ran (59a02a5b): the new `wait` stat exposed the LAST structural bug — `render 22 + wait 10` against a 16.7ms deadline is impossible for a rebasing pacer: the wait was the producer QUEUED BEHIND THE LIVE PREVIEW — the pump's await-continuations inherit the UI SynchronizationContext (StartAsync fires from a command handler), so the loop had been rendering on the dispatcher all along. Slice 7: Task.Run the loop (OBS keeps media threads off-UI for this exact reason), StaticPixelCache locked + chat raster marshalled to the dispatcher on cache-miss (RTB/DrawingVisual are UI-thread objects), SustainedLowLatency GC, `worst render` stat, webcam routed through the paste cache; test `Pump_Produces_OffTheStartingContext`, 70/70 green. **take 11: `≈300/300 frames, wait ≈ the true remainder` → 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 +### 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 ran (c65a3cde, paste cache): render 26.5→22.4ms yet period stayed ~37ms — the gap was Task.Delay's 15.6ms sleep quantum padding every sub-tick wait: the true ceiling, hidden until then (why takes 7→9 looked like zero change). Slice 6 shipped: timeBeginPeriod(1) for the pump life + bulk-sleep + 2ms spin tail + `avg wait` stat (accounting closes) + webcam now routes through the paste cache. **take 10 ran (59a02a5b): the new `wait` stat exposed the LAST structural bug — `render 22 + wait 10` against a 16.7ms deadline is impossible for a rebasing pacer: the wait was the producer QUEUED BEHIND THE LIVE PREVIEW — the pump's await-continuations inherit the UI SynchronizationContext (StartAsync fires from a command handler), so the loop had been rendering on the dispatcher all along. Slice 7: Task.Run the loop (OBS keeps media threads off-UI for this exact reason), StaticPixelCache locked + chat raster marshalled to the dispatcher on cache-miss (RTB/DrawingVisual are UI-thread objects), SustainedLowLatency GC, `worst render` stat, webcam routed through the paste cache; test `Pump_Produces_OffTheStartingContext`, 70/70 green. **take 11 ran (c10ce06c): off-UI loop WORKED — typical frames land exactly on the 16.7ms deadline (work ~10 + wait ~6.8; 212/300 best yet); the remaining gap is periodic 35-65ms spikes worsening across a take = gen2 GC pauses, fed by the capture path's fresh ~8.3MB array per DWM frame (~500MB/s). Slice 8: 4-deep capture buffer ring + `VideoFrame.Epoch` in the identity-keyed paste cache + `gen2 +N` printed per stats window; test `PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits` (fails on the old key), 37/37. Also this session (creator ask): post-mix master gain +40% (before the −1dBFS limiter, so no new clipping). **take 12: `gen2` ~0-1/window, `worst render` → ~16-20ms, n/300 → 300, audio ~40% louder in the file.**** 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 1944323..cd81404 100644 --- a/ai.md +++ b/ai.md @@ -794,6 +794,18 @@ seam:** `Func`, `Func` resolver, `FuncThe ONE integration test for the capture buffer ring + paste-cache epoch + /// (take-11 spike fix): a recycled capture buffer hands out the SAME byte[] with + /// different content; only VideoFrame.Epoch can keep the paste cache honest. Without + /// the epoch in the key, frame 2 false-hits frame 1's raster and the recording shows + /// stale pixels for as long as the array keeps cycling — the exact bug this pins. + [Fact] + public void PasteCache_RecycledArrayWithNewEpoch_ReRasterizes_NotStaleHits() + { + var pixels = new byte[64 * 32 * 4]; + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = 0; pixels[i + 1] = 0; pixels[i + 2] = 255; pixels[i + 3] = 255; // red + } + var gen1 = new VideoFrame(64, 32, pixels) { IsOpaque = true, Epoch = 1 }; + + var webcam = new WebcamSceneConfig { X = 4, Y = 4, Width = 32, Height = 16 }; + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(webcam); + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 128, SourceRectHeight = 64, + OutputWidth = 128, OutputHeight = 64, + }; + + var compositor = new SceneCompositor(); + var out1 = compositor.Render(scene, _ => gen1, null, options); + AssertColor(out1, 20, 12, 255, 0, 0); + + // Generation 2: the SAME array, new content, bumped epoch (what the capture + // ring does 60 times a second). + for (var i = 0; i < pixels.Length; i += 4) + { + pixels[i] = 0; pixels[i + 1] = 255; pixels[i + 2] = 0; pixels[i + 3] = 255; // green + } + var gen2 = new VideoFrame(64, 32, pixels) { IsOpaque = true, Epoch = 2 }; + var out2 = compositor.Render(scene, _ => gen2, null, options); + AssertColor(out2, 20, 12, 0, 255, 0); // a stale hit would still be red + + // And the same epoch twice still hits the cache (idempotent paste). + var out3 = compositor.Render(scene, _ => gen2, null, options); + Assert.Equal(out2.BgraPixels, out3.BgraPixels); + } + [Fact] public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop() {