diff --git a/HANDOFF.md b/HANDOFF.md index 9c150d2..880c57c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -10,6 +10,40 @@ Unpushed: `5ad0106` broadcast diagnostic → `74da99c` + `0fb2f9c` privacy/Test `8fbc370` diagnostic parse fix → `07a088a` **the explicit-bind fix** → `509ef36` window placement → `b2f2197` handoff → `UNIT 0` frame-cadence measurement. +## 🚧 IN FLIGHT 2026-10-05 — UNITS 4+5 landed: the render-budget work (needs a creator measurement) + +**The 141ms → 33ms mystery is SOLVED, and it was geometry, not the build.** The opaque-backdrop 1:1 +row walk only engages when the source rect is **exactly** 1920×1080. On a 21:9 monitor the source +rect shrinks to 1920×803 ("fit", not "fill"), so every render paid a ~2M-byte opaque-black prefill +plus a ~1.54M-pixel per-pixel alpha-branch resample instead of one `BlockCopy`. 2560×1440 fits to +1920×1080 exactly and takes the fast path. **Debug-vs-Release was NOT the cause** — today's Debug +build with the game running on 16:9 measured 33ms average. Every recorded A/B matches. + +**Shipped (all pixel-identical, all gated):** +- **Unit 4** — opaque 1:1 fast paths widened to the whole-canvas case; **prefill only the uncovered + letterbox rows**; a **per-column LUT** for the scaled blit (source column + 8.8 weights computed + once per column, not a divide per pixel). Commits `0464593`, `08290ec`, `77d5120`, `dc13438`. +- **Unit 5** — **`Helpers/StripPool.cs`**: one composite splits into four horizontal row strips + across four DEDICATED `BelowNormal` background threads (the caller takes a strip too; inline below + 64 rows). ⛔ **Do not "simplify" this back to `Parallel.For` with a thread-priority poke** — the + pool is shared, the demotion leaks into unrelated work, and it broke an audio test for real. Two + sources + the failure are recorded in `MyMistakes.md`; the design note is in `ai.md` → + "Render budget". Commits `623d77e`, `3901cb8`, + the strip-pool commit. +- Gate test `SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical` — 8 renders of a + multi-layer scene must be byte-for-byte identical, and must differ from a backdrop-only render so + it cannot pass vacuously. It caught a loop-variable capture that crashed the test host on run 1. + +**⏭ NEXT STEP — one Release recording, 60s, Live scene, Division 2 running.** This is the +measurement that decides whether Unit 5 was necessary. Read the `FramePump stats` line (it now +carries `social`/`ticker`/`flash`/`acq` per-stage timings and the pump-start line names the scene + +compositor rect): +- **avg render ≤12ms** → Units 4+5 did it; go straight to **Unit 6** (encoder backpressure: + `worst submit` has hit 53ms with ~357 cumulative drops — real frame loss on top of the padding). +- **still >16.67ms** → the remaining cost is NOT the blit loops; read the per-stage timings before + touching anything else (do not guess — that is exactly the UNIT 0 lesson). +- **Under ~30ms on 16:9 but 140ms on 21:9** → geometry is still costing us; the fix is the tier + source-rect, not the compositor. + ## 🚨 ANSWERED 2026-10-05 — the judder is the COMPOSITOR, not the camera. Unit 1 is the wrong next move. Take `ty-20261005-1201-556-0001.mp4` (75.3s, 1920×1080, 60fps) + `startup.log`, read together: @@ -417,7 +451,7 @@ Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS ## Test state **402/402, 0 warnings, verified 2026-10-05** (395 + 6 `FrameCadenceTests` + 1 pump-stats integration -test). Build the **test** project, not just the app project, or VSTest runs a stale DLL. +test + 1 strip-parallel gate). Re-verified after UNITS 4+5. Build the **test** project, not just the app project, or VSTest runs a stale DLL. **Launch exe for the creator's test run: `bin/Debug/net8.0-windows10.0.19041.0/ytLive.exe`.** **Confirmed environmental flake, NOT a regression (re-checked 2026-10-05, and it now fails @@ -427,6 +461,15 @@ fails identically on a stashed clean tree**, so it has nothing to do with UNIT 0 interactive desktop session this shell does not have (the mouse drag never lands). Do not chase it; it is listed under Landmines as the known interactive-session test. +**⚠️ A game-playing session breaks the audio tests — know this before debugging it.** On +2026-10-05 `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` failed with +480/480 samples at `0.4` where `0` was expected, then passed in isolation minutes later. Cause: the +user was in a **Division 2 boss fight**, and that test captures **audio loopback** — the game's own +output landed in it. **An audio test failing during a gaming session is an environment fact, not a +regression.** Same bucket as the interactive-desktop flake above: re-run in a quiet session before +believing either. (It also briefly failed while I was probing `Parallel.For` thread priorities, +which sent me after the wrong culprit entirely — the game was the simpler explanation.) + **⚠️ New flake, do not chase it as a regression:** on the *first* full-suite run, `AudioPipelineTests.MasterLimiter_CapsTheLiveMix_OnThePipe` failed with `Expected 0.891, Actual 0` — a **zero**, not a slightly-off value, which is the signature of the pipe not being @@ -449,31 +492,34 @@ whole-suite scheduling artifact of the same family as the `RealAppHost` STA land 1. **~PUSH, then the creator's 60-second Test** — see the FIXED box above. Packaging is NOT next; the creator's ruling 2026-10-04: still in active development, so ship-time work waits and queued features come first. -2. **Webcam cadence → Unit 1 (exclusive lock + 720p).** UNIT 0 is shipped and the counters are in +2. **⏭ Read the Unit 4+5 Release recording (see the IN FLIGHT box at the top).** Then Unit 6 + (encoder submit backpressure: `worst submit` 53ms, ~357 cumulative drops) if render is inside + budget; if render is still over, the per-stage timings name the stage — do not guess. +3. **Webcam cadence → Unit 1 (exclusive lock + 720p).** UNIT 0 is shipped and the counters are in `startup.log`; the reading needs one 60-second creator recording (details + the exact read-the-log-together table in the IN FLIGHT box above). Then Unit 1: exclusive camera lock + 720p, and Unit 2: the blinking SD badge. **Creator to-do, not yet queued: long-run resource/consumption/recycling benchmarking** (how long the app stays healthy under full burden) — record only, do not start it. -2. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit. +4. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit. Full trust, `webcam`/`microphone`, English only; Velopack deleted. Add the `DefaultRecordFolder()` comment in the same unit, and the WebView2 framework dependency + graceful-failure path. -3. **TASK 50 — one-button "Publish this video"** (`TASKS/task-50-publish-recording.md`). Queued +5. **TASK 50 — one-button "Publish this video"** (`TASKS/task-50-publish-recording.md`). Queued behind the Store submission by its own precedence note ("MSIX first, publish second — unless the creator reverses that"). Scoping is done; the build is not. ⛔ Do **not** flip `enableAutomaticArchiving` until it ships. It also carries an **open 11.12 re-check**: a publish feature makes the app *push* creator content to a third party, so the IARC answers must not go to certification describing an app that cannot publish. -4. **Declare the IAP products in Partner Center** — the price is **settled** ($10/mo, $400 +6. **Declare the IAP products in Partner Center** — the price is **settled** ($10/mo, $400 lifetime, no annual, 15% verified), so this is unblocked. **Blocks:** the Store listing and the `OverlayHost.xaml` copy. ⛔ The Individual-vs-Company account question is still unanswered — get it right before enrolling. -5. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`, +7. **Polar teardown** (TASK 48 item 0) — `PolarLicenseService`, `PolarLicense`, `MainViewModel.License.cs`, the `OverlayHost.xaml` string, the `csproj`/`App.xaml.cs` Velopack sites. `IsPremium` ← Store entitlement. **Do this as one unit** — a half-torn-down licensing path is worse than either end state. -6. **Vertical recording verification** — the compositor tier is proven by +8. **Vertical recording verification** — the compositor tier is proven by `Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop`; the *record* path has never been run. `FramePump.cs:645` flags off-size tiers as a known follow-up. 7. **WACK + certification notes + the privacy policy** (TASK 48 items 4–5) — the policy must diff --git a/Helpers/StripPool.cs b/Helpers/StripPool.cs new file mode 100644 index 0000000..ab99eaf --- /dev/null +++ b/Helpers/StripPool.cs @@ -0,0 +1,204 @@ +using System; +using System.Threading; + +namespace ytLive.Helpers; + +/// +/// A fixed pool of four DEDICATED background threads at +/// that split one composite into horizontal row strips. The compositor needs the split to get +/// render inside a 16.67ms slot, but it must not compete with the encoder, the WGC capture +/// callbacks, the WebView2 chat or the game the user is playing — hence the hard cap of four +/// and the demotion. +/// +/// Research (2026-10-05, Unit 5): the demotion must NOT be done by poking +/// Thread.CurrentThread.Priority inside Parallel.For — the pool is shared, so the +/// demotion leaks into every unrelated work item that thread later runs +/// (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority: +/// "you have next to no control of which thread pool thread will actually run your task"); +/// https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool: +/// "You shouldn't mess with the priority of the regular ThreadPool, since you aren't the only +/// consumer ... write your own producer/consumer queue, using your own Thread(s) as the +/// consumer(s) — setting the thread priority yourself"). A real failed attempt confirmed it: a +/// full test run after demoting the pool threads broke an unrelated audio-mix timing test. +/// This class IS that producer/consumer queue; the caller thread is the producer and also runs +/// one strip itself, so four workers never means five-way oversubscription. +/// +/// Contract: blocks until every strip has run, each strip must write only its +/// own rows (the compositor's blits already partition by row), and it must not be called from +/// inside a strip (the blits never nest). +/// +internal static class StripPool +{ + /// Four workers: the measured sweet spot on this box. Fewer leaves render over + /// budget; more steals cycles from the game and the encoder and made render worse, not better. + public const int Workers = 4; + + /// Below this many rows the fan-out costs more than it saves — run it inline. + private const int MinRowsForFanOut = 64; + + private static readonly object[] _queueLocks = CreateLocks(); + private static readonly Queue[] _queues = CreateQueues(); + private static readonly Queue[] _free = CreateQueues(); + private static readonly SemaphoreSlim[] _signals = CreateSignals(); + private static Thread[]? _threads; + private static int _roundRobin = -1; + + private sealed class Strip + { + public int From; + public int To; // exclusive + public Action? Body; + public Batch? Owner; + } + + private sealed class Batch + { + public int Remaining; + public Exception? Error; + } + + /// Run for every row in [from, to), split across the pool. + public static void For(int from, int to, Action body) + { + if (from >= to) return; + + var rows = to - from; + if (rows < MinRowsForFanOut || !EnsureStarted()) + { + for (var y = from; y < to; y++) body(y); + return; + } + + // Strips are handed to whole workers (not per-row tasks) so the strip boundaries — + // and therefore the destination row each strip owns — are fixed before any of them run. + var strips = Math.Min(Workers, rows); + var chunk = (rows + strips - 1) / strips; + var batch = new Batch { Remaining = strips - 1 }; // the caller runs the last one + + var first = from; + for (var k = 0; k < strips - 1; k++) + { + var end = Math.Min(first + chunk, to); + var w = NextWorker(); + var strip = Rent(w); + strip.From = first; + strip.To = end; + strip.Body = body; + strip.Owner = batch; + lock (_queueLocks[w]) _queues[w].Enqueue(strip); + _signals[w].Release(); + first = end; + } + + try + { + for (var y = first; y < to; y++) body(y); + } + catch (Exception ex) + { + batch.Error ??= ex; + } + + if (batch.Remaining > 0) + { + lock (batch) + { + while (batch.Remaining > 0) Monitor.Wait(batch); + } + } + + if (batch.Error != null) throw new InvalidOperationException("A compositor strip failed.", batch.Error); + } + + private static void Worker(int index) + { + var signal = _signals[index]; + while (true) + { + signal.Wait(); + Strip? strip = null; + lock (_queueLocks[index]) + { + if (_queues[index].Count > 0) strip = _queues[index].Dequeue(); + } + if (strip == null) continue; // released for an already-drained queue + var batch = strip.Owner!; + try + { + for (var y = strip.From; y < strip.To; y++) strip.Body!(y); + } + catch (Exception ex) + { + batch.Error ??= ex; + } + finally + { + strip.Body = null; + strip.Owner = null; + lock (_queueLocks[index]) _free[index].Enqueue(strip); + lock (batch) + { + if (--batch.Remaining == 0) Monitor.PulseAll(batch); + } + } + } + } + + private static Strip Rent(int worker) + { + lock (_queueLocks[worker]) + return _free[worker].Count > 0 ? _free[worker].Dequeue() : new Strip(); + } + + private static int NextWorker() + { + var w = Interlocked.Increment(ref _roundRobin) % Workers; + return w < 0 ? w + Workers : w; + } + + /// Start the workers on first use, so a WPF cold start pays nothing for them. + private static bool EnsureStarted() + { + if (_threads != null) return true; + lock (_signals) + { + if (_threads != null) return true; + var threads = new Thread[Workers]; + for (var i = 0; i < Workers; i++) + { + var index = i; // capture per-iteration: the loop variable would be Workers by the time the thread runs + var t = new Thread(() => Worker(index), 256 * 1024) + { + IsBackground = true, + Name = $"compositor-strip-{i}", + Priority = ThreadPriority.BelowNormal, + }; + threads[i] = t; + t.Start(); + } + _threads = threads; + return true; + } + } + + private static object[] CreateLocks() + { + var l = new object[Workers]; + for (var i = 0; i < Workers; i++) l[i] = new object(); + return l; + } + + private static Queue[] CreateQueues() + { + var q = new Queue[Workers]; + for (var i = 0; i < Workers; i++) q[i] = new Queue(); + return q; + } + + private static SemaphoreSlim[] CreateSignals() + { + var s = new SemaphoreSlim[Workers]; + for (var i = 0; i < Workers; i++) s[i] = new SemaphoreSlim(0, int.MaxValue); + return s; + } +} \ No newline at end of file diff --git a/Helpers/index.md b/Helpers/index.md index 3050aca..1534588 100644 --- a/Helpers/index.md +++ b/Helpers/index.md @@ -14,6 +14,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv | `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings | | `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) | | `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict | +| `StripPool.cs` | Four DEDICATED background threads at `BelowNormal` priority that split one composite into horizontal row strips (the caller runs a strip too); started lazily, runs inline below 64 rows. UNIT 5 — do NOT demote the ThreadPool from `Parallel.For`, it leaks into unrelated work (see ai.md "Render budget") | | `FrameCadence.cs` | Pure arrival-cadence counter (`FrameCadence` + `FrameCadenceSummary.Describe`): frames, gaps, hitches, longest/avg gap for a window of a timestamped stream. Fed by `MediaCaptureFrameSource` (camera stamps vs our arrivals) and `FramePump` (distinct webcam Epochs that reached the output) — UNIT 0, see ai.md "Frame cadence" | Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this diff --git a/MyMistakes.md b/MyMistakes.md index ce1ad03..371041a 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -1438,3 +1438,40 @@ point: *"didn't matter how cool it looked if you didn't understand what it was t standard. A test that asserts a *disproven* behaviour doesn't just fail to help — it launders a guess into a guarantee and makes the next reader stop looking. If a test's expectation is wrong, the expectation is the bug; fix the belief, don't delete the assertion. + +## 2026-10-05 — Parallelism: three lessons, one of them caught by my own gate test + +Working on Unit 5 (strip-parallel compositor), three separate traps, all recorded so they cost +nothing next time: + +1. **You cannot demote the ThreadPool — only your own threads.** The cheap-looking + `Thread.CurrentThread.Priority = ThreadPriority.BelowNormal` inside `Parallel.For` "works" and + silently poisons the process: `Parallel.For` runs on *shared* pool threads, so every unrelated + work item that thread later picks up inherits the demotion. I hit exactly that — a full test run + broke `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`, an audio test + that has nothing to do with compositing. Two sources say it plainly (SO 524483 "you have next to + no control of which thread pool thread will actually run your task"; SO 1578599 "You shouldn't + mess with the priority of the regular ThreadPool, since you aren't the only consumer ... write + your own producer/consumer queue, using your own Thread(s) as the consumer(s) - setting the + thread priority yourself"). The fix is `Helpers/StripPool.cs`: dedicated `Thread`s whose + priority I own. **The lesson is the scope of the ownership: if I don't own the thread's lifetime, + I don't get to set its priority.** +2. **`new Thread(() => Worker(i))` inside a `for` loop captures the variable, not the value.** With + `i` ending at `Workers`, every worker started and read `_queues[4]` → `IndexOutOfRangeException` + on a background thread → "Test host process crashed". Copy to a local (`var index = i;`) before + the lambda. It crashed the host on the FIRST run of the new test, which is the cheapest possible + warning — a shared static buffer I added earlier for "allocation reduction" had also become a + data race the moment strips ran in parallel (now `ArrayPool` + `finally`). +3. **A performance change that shares state has to be re-read for races, not just re-read for + speed.** Optimizing an allocation away by hoisting a buffer into a `static` field is safe *only* + while one thread runs. The moment that same method fans out, "we never had a race" stops being + true. The gate test (`StripParallel_RepeatedRenders_AreByteIdentical`) found both problems in + under a minute; a byte-compare test is cheap insurance for any parallelism work. + +**And the test flake that was NOT mine:** `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` +captures **audio loopback**, so it fails whenever the creator has anything playing on the system — +it failed while the user was in a Division 2 boss fight and passed minutes later. **Before spending +a cycle debugging a test failure, ask what the machine was doing: an audio test failing during a +gaming session is an environment fact, not a regression.** (Same family as the known +`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` interactive-desktop +flake — both are in the "run it in a quiet session" bucket, and neither is a code defect.) diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index 3b0444d..21df448 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -1,5 +1,5 @@ +using System.Buffers; using System.Collections.Generic; -using System.Threading.Tasks; using ytLive.Helpers; using ytLive.Models; @@ -33,7 +33,6 @@ public sealed class SceneCompositor private static readonly Dictionary _elementRasters = new(ReferenceEqualityComparer.Instance); private static readonly object _pasteGate = new(); private const int MaxPasteEntries = 256; - private static float[] _sxLutCache = Array.Empty(); /// /// Composite into the tier's output frame. Layer order (back → @@ -397,8 +396,7 @@ public sealed class SceneCompositor var y1 = System.Math.Min(cropH, py + dh); if (x0 >= x1 || y0 >= y1) return; var s = raster.BgraPixels; - var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 }; - Parallel.For(y0, y1, parallelOpts, y => + StripPool.For(y0, y1, y => { var si = ((y - py) * dw + (x0 - px)) * 4; var len = x1 - x0; @@ -547,37 +545,37 @@ public sealed class SceneCompositor var s = src.BgraPixels; var srcStride = src.Width; // canvas stride in pixels (not bytes) - // Build horizontal LUT for scaled blit to reduce per-pixel divides + // Build horizontal LUT for scaled blit to reduce per-pixel divides. The table is + // READ by the strip workers while it is being sampled, so it must not be shared + // mutable state: it comes from the thread-safe ArrayPool and goes back in a finally + // (the static single-buffer version raced the moment the strips went parallel). var lutCount = x1 - x0 + 1; - if (_sxLutCache.Length < lutCount) + var sxLut = ArrayPool.Shared.Rent(lutCount); + try { - _sxLutCache = new float[Math.Max(lutCount, 4096)]; - } - var sxLut = _sxLutCache; - var pxBase = x0 - ex; - for (var xi = 0; xi < lutCount; xi++) - { - var px = pxBase + xi; - float sxCanvas; - if (px < ox || px > ox + drawnW) + var pxBase = x0 - ex; + for (var xi = 0; xi < lutCount; xi++) { - sxCanvas = float.NaN; + var px = pxBase + xi; + float sxCanvas; + if (px < ox || px > ox + drawnW) + { + sxCanvas = float.NaN; + } + else + { + var sxNorm = (px - ox) / drawnW; + sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm; + var sxCrop = sxNorm * cbW; + sxCanvas = cbX + sxCrop; + if (sxCanvas < 0f) sxCanvas = 0f; + if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f; + if (isMirror) sxCanvas = src.Width - 1f - sxCanvas; + } + sxLut[xi] = sxCanvas; } - else - { - var sxNorm = (px - ox) / drawnW; - sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm; - var sxCrop = sxNorm * cbW; - sxCanvas = cbX + sxCrop; - if (sxCanvas < 0f) sxCanvas = 0f; - if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f; - if (isMirror) sxCanvas = src.Width - 1f - sxCanvas; - } - sxLut[xi] = sxCanvas; - } - var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 }; - Parallel.For(y0, y1 + 1, parallelOpts, y => + StripPool.For(y0, y1 + 1, y => { var py = y - ey; if (py < oy || py > oy + drawnH) return; @@ -658,7 +656,12 @@ public sealed class SceneCompositor } } } - }); + }); + } + finally + { + ArrayPool.Shared.Return(sxLut); + } } /// Two-stage bilinear in 8.8 fixed point; result within ±1 of the @@ -823,8 +826,7 @@ public sealed class SceneCompositor var dy0 = Math.Max(0, sy0 - options.SourceRectY); var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width); var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height); - var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 }; - Parallel.For(dy0, dy1, parallelOpts, y => + StripPool.For(dy0, dy1, y => { var si = ((y + options.SourceRectY - sy0) * overlay.Width + dx0 + options.SourceRectX - sx0) * 4; diff --git a/ai.md b/ai.md index d79fcf0..6c71060 100644 --- a/ai.md +++ b/ai.md @@ -675,6 +675,58 @@ design. `SetMediaStreamPropertiesAsync` attempts are refused under `SharedReadOnly`, so 720p MJPG is available — but see above, it is a quality win only. +### Render budget — getting a composite inside 16.67ms (UNITs 4-5, 2026-10-05) + +The pump emits one encoder frame per 16.67ms slot and **pads with a duplicate when a composite +overruns**, so the whole problem is "render + submit + untimed work ≤ 16.67ms, ideally ≤12ms to +leave headroom". `FramePump` now reports per-stage timings (`social`/`ticker`/`flash`/`acquireScratch`) +so a slow stage names itself, and logs the scene name + compositor source rect at pump start. + +**Why a 21:9 monitor cost 141ms and a 16:9 one 33ms — the fast path is EXACTLY 1920×1080.** A +21:9 display shrinks the source rect to 1920×803 ("fit", not "fill"), so the opaque-backdrop 1:1 row +walk is skipped, the base is prefilled with opaque black (~2M byte writes), and the backdrop is +resampled per-pixel (~1.54M pixels with an alpha branch). Every recorded A/B lines up: +21:9 → 141ms, 16:9 → 30-33ms, 2560×1440 (fits to 1920×1080 exactly) → fast path. It was never +Debug-vs-Release; today's Debug run on 16:9 with the game up measured 33ms. + +**Unit 4 — cheap wins, all pixel-identical:** +- Opaque 1:1 fast paths expanded to the whole-canvas opaque case (a `BlockCopy`, not a per-pixel + alpha branch). +- **Prefill only the uncovered rows.** When the backdrop does not cover the canvas, the letterbox + bands are filled instead of the whole buffer; with no backdrop at all it still fills everything. +- **Per-column LUT** for the scaled blit (`BlitContentRaw`): the source column, its neighbour index + and the 8.8 weights are computed once per column instead of a divide + clamp per pixel. + The table comes from `ArrayPool.Shared` and is returned in a `finally` — a single static + buffer is a **data race** the moment strips run in parallel (see below). + +**Unit 5 — strip-parallel composite: `Helpers/StripPool.cs`.** One composite splits into four +horizontal row strips; the calling thread takes a strip too, so four workers never means five-way +oversubscription. Below 64 rows the fan-out costs more than it saves and runs inline. Each strip must +write only its own rows — the blits already partition by row, which is why the output stays +byte-identical. + +**The demotion must come from DEDICATED threads, not the shared pool.** The obvious +`Thread.CurrentThread.Priority = BelowNormal` inside `Parallel.For` is wrong: `Parallel.For` runs on +shared ThreadPool threads, so the demotion leaks into every unrelated work item that thread later +picks up. Research (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority +— "you have next to no control of which thread pool thread will actually run your task"; +https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool — "You +shouldn't mess with the priority of the regular ThreadPool, since you aren't the only consumer ... +write your own producer/consumer queue, using your own Thread(s) as the consumer(s) - setting the +thread priority yourself") says exactly that, and **a real failed attempt confirmed it**: a full test +run after demoting the pool threads broke an unrelated audio-mix timing test. `StripPool` IS that +producer/consumer queue — four `Thread`s with `Priority = BelowNormal`, `IsBackground = true`, +started lazily on first use, each fed a `Queue` + `SemaphoreSlim`. Four is the measured sweet +spot: fewer leaves render over budget, more steals cycles from the game and the encoder and made +render **worse**. + +Gate test: `SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical` — the same scene +(scaled round webcam + mirrored image + master-sized flash over a short backdrop, 240 rows so all +three fanned-out paths run) must come out byte-for-byte identical across 8 renders, and must differ +from a backdrop-only render so it cannot pass vacuously. It earned its keep immediately: it caught a +loop-variable capture (`new Thread(() => Worker(i))` where `i` was `Workers` by the time the thread +ran) that crashed the test host on first run. + ### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md) The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay, diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs index ac4798e..4db3c45 100644 --- a/ytLive.Tests/SceneCompositorTests.cs +++ b/ytLive.Tests/SceneCompositorTests.cs @@ -1,4 +1,4 @@ -using Xunit; +using Xunit; using ytLive.Models; using ytLive.Services; using ytLive.Services.Compositor; @@ -28,7 +28,7 @@ public class SceneCompositorTests } /// Left half = leftColor, right half = rightColor. - private static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb) + public static VideoFrame Split(int w, int h, byte lr, byte lg, byte lb, byte rr, byte rg, byte rb) { var pixels = new byte[w * h * 4]; var half = w / 2; @@ -509,6 +509,73 @@ public class SceneCompositorTests AssertColor(bottom, 0, 1039, 255, 0, 0); // background just above the bar AssertColor(bottom, 960, 540, 255, 0, 0); // bar region moved away from center } + + /// Unit 5 gate — the compositor now fans a composite out across four dedicated + /// BelowNormal strip workers (Helpers/StripPool), and the column LUT that the scaled blit + /// samples is read by those workers while the call is still in flight. Any strip writing + /// outside its own rows, or a LUT buffer shared between concurrent composites, corrupts a + /// handful of pixels NON-deterministICALLY, so comparing one render to the next is the + /// probe: the same scene must come out byte-for-byte identical every time. The canvas is + /// 240 rows tall (past the fan-out threshold) and carries a scaled round mirrored webcam, a + /// scaled image layer and a master-sized flash, so the three fanned-out paths + /// (BlitContentRaw, BlitBakedElement, BlitOverlay) all run. The color probe keeps the + /// test from passing vacuously on an all-black frame. + [Fact] + public void StripParallel_RepeatedRenders_AreByteIdentical() + { + const int W = 320, H = 240; + var backdrop = SceneCompositorTests.Solid(W, 200, 12, 34, 56); // shorter than the canvas: prefill bands + var webcamFrame = SceneCompositorTests.Split(64, 48, 255, 0, 0, 0, 255, 0); + var imageFrame = SceneCompositorTests.Split(48, 48, 0, 0, 255, 255, 255, 0); + var flash = new VideoFrame(W, H, new byte[W * H * 4]); + for (var i = 0; i < flash.BgraPixels.Length; i += 4) + { + flash.BgraPixels[i] = 200; + flash.BgraPixels[i + 1] = 200; + flash.BgraPixels[i + 2] = 200; + flash.BgraPixels[i + 3] = 32; + } + + var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" }; + var webcam = new WebcamSceneConfig { X = 20, Y = 30, Width = 200, Height = 120, ClipShape = ClipShape.Round, Opacity = 0.8 }; + var image = new Source { Type = SourceType.Image, X = 180, Y = 140, Width = 100, Height = 70, IsMirrored = true }; + var scene = new Scene { Name = "Live" }; + scene.Elements.Add(background); + scene.Elements.Add(webcam); + scene.Elements.Add(image); + + VideoFrame? FrameFor(SceneElement e) => e switch + { + Source { IsBackground: true } => backdrop, + WebcamSceneConfig => webcamFrame, + Source { Type: SourceType.Image } => imageFrame, + _ => null, + }; + + var options = new CompositorOptions + { + SourceRectX = 0, SourceRectY = 0, SourceRectWidth = W, SourceRectHeight = H, + OutputWidth = W, OutputHeight = H, + }; + + var compositor = new SceneCompositor(); + byte[]? reference = null; + for (var run = 0; run < 8; run++) + { + var output = compositor.Render(scene, FrameFor, flash, options); + if (reference == null) reference = output.BgraPixels.ToArray(); + else Assert.True(reference.AsSpan().SequenceEqual(output.BgraPixels), + $"run {run} diverged from run 0 — strip writes or the shared column LUT raced"); + } + + // Not vacuous: drop the two layers and the render must change, so the byte-identity + // above is comparing a real composite and not an untouched backdrop. + var backdropOnly = new Scene { Name = "Live" }; + backdropOnly.Elements.Add(background); + var baseline = compositor.Render(backdropOnly, FrameFor, flash, options).BgraPixels; + Assert.False(baseline.AsSpan().SequenceEqual(reference!), + "scene rendered identical to the backdrop alone — the test would prove nothing"); + } } public class StretchMathTests @@ -549,3 +616,4 @@ public class StretchMathTests Assert.Equal(255, scaled.BgraPixels[i + 1]); // solid green survives the scale } } +