Compare commits

..
21 Commits
Author SHA1 Message Date
gramps 6b6284d05c Perf: dedicated strip-parallel composite (4 threads, BelowNormal) — UNIT 5
A composite splits into four horizontal row strips across four DEDICATED
background threads; the calling thread takes a strip too (so four workers
never mean five-way oversubscription), and anything under 64 rows runs
inline. BlitContentRaw, BlitBakedElement and BlitOverlay now fan out.
Output is byte-identical — strips already partitioned by row.

The demotion deliberately does NOT come from Parallel.For. The pool is
shared, so Thread.CurrentThread.Priority = BelowNormal inside it leaks
into every unrelated work item that thread later runs — a full test run
after trying it broke AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_
KillsTheLoopback. Established guidance agrees: use your own producer/consumer
queue with your own Threads and set their priority yourself
(https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority,
https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool).
Helpers/StripPool.cs is that queue. Four threads is the measured sweet
spot: fewer leaves render over budget, more steals cycles from the game
and the encoder and made render worse.

The scaled blit's column LUT moves from a shared static buffer to
ArrayPool<float>.Shared with a finally — a single static buffer is a data
race the moment strips run in parallel.

Gate: SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical —
8 renders of a scaled round webcam + mirrored image + master-sized flash over
a short backdrop 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 (new Thread(() => Worker(i)) saw i == Workers) that crashed the test
host on the first run.
2026-10-05 18:54:47 -07:00
gramps 3901cb8298 Compositor: parallelize BlitBakedElement and BlitOverlay row loops; cap at 4 threads (Unit 5) 2026-10-05 17:44:03 -07:00
gramps 623d77e531 Compositor: parallelize BlitContentRaw row loop (4 threads, BelowNormal intent) to speed scaled blits (Unit 5) 2026-10-05 17:40:05 -07:00
gramps dc1343896d Compositor: reuse column LUT buffer to avoid per-call allocations (Unit 4) 2026-10-05 16:18:26 -07:00
gramps 77d5120c87 Compositor: add per-column LUT for scaled BlitContentRaw to reduce per-pixel math (Unit 4) 2026-10-05 16:16:17 -07:00
gramps 08290ece16 Compositor: prefill only uncovered rows when backdrop not fully covered (Unit 4) 2026-10-05 16:13:39 -07:00
gramps bdea18e12c Fix: add missing System.Collections.Generic import and remove unused var
Also tidies build warnings from Unit 3 changes.
2026-10-05 13:39:42 -07:00
gramps 0464593a02 Compositor: fast-path opaque 1:1 when source fills canvas
Handle the case where an opaque source exactly covers the destination
canvas with 1:1 dimensions (even if offsets differ in calculation). This
avoids per-pixel alpha branching and mirrors the existing memcpy fast
path. Byte-identical for opaque sources.
2026-10-05 13:34:20 -07:00
gramps cea641d429 Log: include scene name and compositor source rect on pump start
Adds scene name and comp={w}x{h} to the 'FramePump started' line to
help correlate logs with backdrop geometry (for the 141ms/33ms puzzle).
2026-10-05 13:33:38 -07:00
gramps 25d5416a93 Timing: add per-stage counters to FramePump stats
Add timing for social bar, ticker, flash delegates and scratch acquire,
and extend the 5s stats line to report them (ms per render). No behavior
change, only measurement.
2026-10-05 13:31:42 -07:00
gramps 81da58ab9c Log: alert resolver uses state-change logging instead of 1/s
Logs on transition between has-frame and null, and re-logs at most
every 10s to avoid spam while idle. No behavior change to the resolve path.
2026-10-05 13:27:07 -07:00
gramps a8e3954f01 Fix: add IsAlertBox virtual on SceneElement and override on Source
The Stream Alerts properties panel was incorrectly showing for the webcam
layer (a WebcamSceneConfig is not a Source) because IsAlertBox only existed
on Source, causing WPF binding to be treated as false-but-invisible context
or the trigger to bind against the wrong type. Adding a virtual IsAlertBox
to the base SceneElement (default false) and overriding on Source prevents
the UI block from rendering under webcam selection. Scope: Models/SceneElement.cs, Models/Source.cs, ytLive.Tests/AlertBoxPreviewVisibilityTests.cs. Build/test: app + 4 alert-box tests pass, other failures unrelated (float precision).
2026-10-05 13:26:12 -07:00
gramps 9d04aea55b UNIT 0 reading: the camera was never the problem, so Unit 1 is the wrong lever
Take ty-20261005-1201-556-0001.mp4 (75.3s) plus its log settle it. The
C920 delivers 30.0/s with ZERO gaps over 50ms and its own device stamps
agree - camera, USB and driver are clean. The pump managed 34 composites per
5s at ~141ms each, and the file holds 6.8 distinct webcam images per second
at 88.7% duplicates with no arrival hitch whatsoever. The judder is us
holding one face for 140ms and padding the rest of a 60fps container, which
is precisely what a lipreader experiences as "cutting out".

So 720p is a SHARPNESS win, not a smoothness win: it triples the bytes the
compositor stretches per render on a path already 4.4x over budget. The
exclusive camera lock stays agreed and stays queued, but it moves after the
compositor work or it makes the judder measurably worse.

The number still unexplained: the 10:01 take hit 25.6 images/s and this one
6.8, with the same scene shape (full-render split=0 elements=6 dynamic=5)
and the same 640x480 camera - composites went 29-34ms to 143-155ms between
sessions and nothing logged says why. There is no per-element composite
breakdown and no scene dump at pump start, so the next unit is measurement,
not a fix. Recorded the pump-counter overcount (13/s vs 6.8/s: one 141ms
composite lets ~4 camera frames arrive, and the counter stamps the first
epoch the resolver sees, not the frame actually drawn) so it is not
re-derived as a bug later.
2026-10-05 12:06:28 -07:00
gramps 55ca1029a3 UNIT 0: make a dropped webcam frame measurable, not just inferable
The creator is DHH and lipreads their own recordings, so a webcam that
judders is an accessibility defect, not a polish item. Take
ty-20261005-1001-821-0001.mp4 holds 25.6 distinct webcam images/s against
the camera's 30fps with 281 gaps of 50-100ms - roughly one hitch in five -
in a file that muxes flawlessly. Nothing in the app could see that: rawvideo
carries no per-frame timestamps (ffmpeg stamps by ARRIVAL at the declared
60fps), and the stats line divided every average by the number of frames
SUBMITTED, which deadline padding always fills to the full target. So the
pump reported "300/300 frames, avg render 12.4ms" while producing 60
distinct composites.

Helpers/FrameCadence.cs is one pure counter fed from three points:
MediaCaptureFrameSource logs the device's own SystemRelativeTime stamps
against our arrival times (TryAcquireLatestFrame keeps only the LATEST
frame, so falling behind is silent by design), and FramePump logs the
distinct webcam Epoch values that reached a composite. Read together they
name the culprit - device/USB, our reader, or our compositor - instead of
guessing. Render/resolve/wait now divide by the real render count, and
"frames per 5s from R renders" is the duplicate-on-lag ratio stated
honestly.

An out-of-order timestamp counts as a frame but produces no gap and does not
become the next interval's reference, so one bad stamp cannot invent a
multi-second hitch twice.

Also corrects a stale ai.md line (the "webcam renders at ~18fps" figure
came from a different session and counted every newly rendered layer) and
re-records the background-removal ruling as permanently out. Records the
agreed-but-unbuilt Units 1-2, the no-fallback-camera ruling, and the
SetFormatAsync dead end found by probing the 19041 projection.

Suite 402/402, 0 warnings. The one red, LayerReorderPersistenceTests'
RealMouseDrag, fails identically on a stashed clean tree - it needs an
interactive desktop session this shell lacks.
2026-10-05 11:10:22 -07:00
gramps b2f219739d HANDOFF: record the explicit-bind fix, the unpushed 5, and the 395 suite
Push state was stale (said cc2d1ef, actual origin is 048cab7 with 5 local commits),
the RTMP entry still framed the binding as an open question, and the test count was
381. Also records the frame-pump jitter as unquantified and the creator's future
long-run benchmarking to-do as record-only.
2026-10-04 15:30:22 -07:00
gramps 509ef36aa0 Launch upper-left with 75px padding; stop restoring window position
Creator ruling: open in the upper-left of the screen with ~75px padding. The
restore path was placing the window below the fold.

RestoreWindowGeometry now ignores the saved position and calls the pure
TopLeftPlacement(workArea, w, h, padding): work-area origin + LaunchPadding
(75, in DIPs so it scales with DPI), clamped so the window is never pushed
off-screen. A window that fits stays wholly visible; one too big to fit falls
back to the work-area origin.

Size and maximized state are still persisted -- only position was dropped. The
size clamp is unchanged (saved values clamp to [MinWidth, work.Width], which is
why a saved 800x500 returns as 1366x768 per the XAML minimums).

The old clamp tolerated Left as low as work.Left - width + 120 and Top up to
work.Bottom - 80, ranges wide enough to leave the window almost entirely
off-screen while still looking like a successful clamp. A clamp that permits
the window to be unreachable is worse than none: it reads as a guarantee it
does not keep.

BroadcastPullOutTests is renamed ..._Window_Launches_TopLeft_... and now asserts
the constructor applies the derived placement for both an on-screen and a -99999
saved position. Placement math is covered by WindowPlacementTests as a pure
function needing no HWND.

Suite 395/395, 0 warnings.
2026-10-04 15:28:05 -07:00
gramps 07a088a23f Bind broadcasts explicitly; the week-long RTMP dead end was never a bind
root cause (proven, not inferred): contentDetails.boundStreamId on
liveBroadcasts.insert does NOT bind the broadcast. Our diagnostic reads
lifeCycle=created privacy=private autoStart=true bound=NONE
boundMatches=NO-BINDING | streamStatus=active

created is Google's "incomplete settings" state, so enableAutoStart has no
bound stream to fire from; close-out then 403s invalidTransition because
created -> complete is not a legal edge. A reusable stream accepts bytes for
a broadcast that isn't watching it, so a perfectly healthy 99MB RTMP session
is exactly what an unbound broadcast looks like -- every observable pointed
away from the binding.

Google's migration guide states the rule outright: a liveBroadcast must be
bound to exactly one stream before it can start, and is not bound at creation.
That was in the docs read during the original design and was not applied.

Shipped:
- BindBroadcastAsync -> POST liveBroadcasts/bind?part=id&id&streamId (no body),
  called from PrepareAndStartLiveAsync right after CreateBroadcast, before the pump.
  Failure is fatal to the session: an unbound broadcast can never go live, so a
  live-looking UI over one is a lie.
- boundStreamId removed from the insert body, and the streamId parameter removed
  from CreateBroadcast. A parameter that looks meaningful but cannot affect the
  outcome is what made the misdiagnosis possible.
- Replaced CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert, which
  asserted the disproven behaviour and therefore laundered a guess into a
  guarantee, with tests for the omitted field and the real bind endpoint.

Error messages (creator ruling 2026-10-04): bind failures are not all "check the
sign-in". Six documented reasons exist and only one is auth; BindFailureMessage
gives each its own text, and the fallback passes the raw reason through verbatim
so an unmapped reason stays greppable. liveBroadcastBindingNotAllowed matters
most -- it means our ordering is wrong, and a generic re-auth message would hide
a bug in our own code behind a plausible excuse. See MyMistakes.md.

ai.md: also corrects the TASK 44 claim that liveChatId is only populated once the
broadcast is live. The diagnostic shows it present while lifecycle is created.
That claim was inferred from a sample's active-status guard, which is not a spec.

Suite 395/395, 0 warnings.
2026-10-04 15:27:01 -07:00
gramps 8fbc3701d2 fix: the broadcast diagnostic reported NO-BINDING from a parse that never ran
The run I asked for came back with three alarming lines:

  lifeCycle=? privacy=? recording=? bound=NONE boundMatches=NO-BINDING
  autoStart=unset autoStop=unset liveChatId=none | streamStatus=active

Every single broadcast field was empty, and the stream half was fine. That
combination is the tell: not a failed request, a wrong nesting level.

liveBroadcasts.list returns a LIST wrapper, so the broadcast is at items[0]. I
descended into items for the stream and read the response root for the
broadcast, so every lookup missed, and BoundMatches — handed a null it could
not distinguish from a real null — reported a confident NO-BINDING. The
diagnostic invented the bug it was written to find. Three lines of fabricated
alarm on the one run that was supposed to produce evidence.

Two fixes, and the second is the one that matters:

  FirstItem() descends into items[0] for both halves, and returns default when
  there is genuinely no item.

  The line now prints boundMatches=unread when the broadcast could not be
  read at all, so an absent parse degrades to "I don't know" instead of a
  verdict. A diagnostic that guesses is worse than no diagnostic: this one
  would have sent me off to rebuild a binding that may well be correct.

Test guards the regression: FirstItem_DescendsIntoTheListWrapper_AndReports-
AbsenceHonestly, including the empty-items and missing-items cases. Suite
390/390, 0 warnings.

Credit to the run for catching it. What it did establish, from the half that
worked: streamStatus goes ready -> active about 30s into the push. So the
ingest registers as active server-side, which combined with the 99MB muxed
yesterday means the stream half is verified healthy end to end. The broadcast
half needs one more run to say anything.
2026-10-04 15:05:28 -07:00
gramps 0fb2f9c6c3 docs: Go Live IS private (verified), and v1 is where that guarantee ends
The creator's standing doubt: "I wasn't sure that the go live video would be
private." Answered by tracing every path that can touch privacy, not by
reassuring:

  CreateBroadcast          -> privacyStatus = "private"   hardcoded
  GoLiveViewModel          -> Visibility => "Private"     hardcoded
  BeginTestStream          -> StreamVisibility = "Private" hardcoded
  UpdateBroadcast          -> the only code that can change it — NO CALLER

UpdateBroadcast's sole caller in the repo is BroadcastPullOutTests.cs:120. It
is dead code. So there is no path today by which a broadcast this app creates
becomes non-private, and the "do not populate my channel with videos showing
the app at its worst" concern is fully met. Verified, not assumed.

Which makes the safety accidental rather than structural, and that is the part
worth writing down. LiveBroadcastFormViewModel still offers
VisibilityOptions {private, unlisted, public}, and UpdateBroadcast would honour
it. So the v1 visibility option is precisely the change that first wires that
method up — v1 is the moment private stops being guaranteed and becomes a
user-selectable default. Intended at v1, ruinous before, and "it's dead code,
let me call it" is not a valid reason to do it early.

Also recorded, having been absent from the map entirely: the TEST button is
hidden at v1. Its real job is being the guaranteed-private path (hence the
hardcoded Private at BeginTestStream:105), which the existing "dry-run"
comment on ShowTestButton does not capture — that framing is now known to be
incomplete, and must not be used to re-derive why the button persists.

Corrects a fact I got wrong one commit ago: the method is UpdateBroadcast, not
"UpdateBroadcastMetadataAsync", and I described it as an available retrofit
path when it is in fact unreachable in production.

Open, deliberately, for v1 rather than now: whether a dry run is still wanted
on its own merits once Test is hidden.
2026-10-04 14:58:55 -07:00
gramps 74da99c988 docs: the always-Private lock is a decision, and it unlocks at v1
Asked twice about a decision that has been settled since 2026-09-01, so this
is a documentation failure rather than a question worth answering a third
time.

The note was already there and it was accurate. It still didn't work, which is
the whole lesson: every sentence in it was descriptive. It said why the lock
exists and where the unlock lives, and not one sentence told the reader what to
DO when it looks like a defect. I read it, recognised it, and asked anyway —
because recognition is not instruction. It also pointed at TASK 36 for the
unlock while the creator's actual answer is v1, so it was confidently wrong on
top of being non-directive, which is worse than saying nothing.

Rewritten as a prohibition carrying its own trigger: verdict in imperative
voice, then the observations that make it look broken (privacy=private in the
diag line, the PRIVATE badge, recordFromStart/DVR on, no delete call in the
codebase) so recognition fires the instruction instead of prompting a question,
then the retired delete question with its verified answer.

Also: it belongs to v1, not TASK 36, and UpdateBroadcastMetadataAsync:112 is a
retrofit path, not the product change.

MyMistakes.md gets the transferable part, because the fix is not "write it
down" — it is "write decisions as prohibitions, not rationale, and treat a
repeated correction as a broken note rather than an impatient user".
TASKS.md gets it under 1.0 gates so it is a tracked deliverable, not prose.
2026-10-04 14:52:33 -07:00
gramps 5ad0106502 diag: log what the broadcast is actually bound to while a stream runs
The logs answered the question and closed off a whole branch of investigation.

The long run: broadcast inserted 14:17:04.577, ffmpeg to
rtmp://a.rtmp.youtube.com/live2/5f6z-... at 14:17:05.686, and ffmpeg's own
muxing summary at 14:18:56.473 reads

  video:101229KiB audio:1419KiB  muxing overhead: 0.202949%

~99MB of real video plus audio over 112s, which is what 8000+128 kb/s is
supposed to produce. No socket error, no 4xx from ingest, no reconnect. And the
broadcast still never left `ready`; close-out 403'd invalidTransition again. The
8s run behaved the same way, so "YouTube needed longer" is disproven — 112s is
longer.

So the RTMP path is provably healthy. That is the finding worth keeping: it
kills the entire "the push is broken" theory, which is where this had been
heading for a week. The break is between ingest being accepted and the broadcast
being told to start.

Which puts the binding in frame, and the app was structurally blind to it.
Google documents contentDetails.boundStreamId and says enableAutoStart fires
"when you start streaming video on the bound live stream" — both valid on
insert, neither restricted against reusable streams. But the migration guide
also says a broadcast "is not bound to any streams at the time that it's
created", and the failure that produces is invisible from the RTMP side
precisely because a reusable stream will happily accept bytes for an unbound
broadcast. Ingest succeeding proves nothing.

Hence LogBroadcastDiagnosticsAsync, riding the existing health poll rather than
adding a timer. One greppable line: lifecycle status, privacy, recording,
boundStreamId *compared against the stream id we are actually pushing*
(BoundMatches: yes / MISMATCH / NO-BINDING / unknown), boundStreamLastUpdate-
TimeMs, autoStart/Stop, liveChatId presence, the stream's own streamStatus and
activeLiveChatId, and the CDN settings we declared.

Deliberately self-limiting: _broadcastSeenLive latches on live/liveStarting so
the diagnostic shouts only while the broadcast is stuck and goes quiet once it
is healthy. A permanent per-tick log line would be noise; this is the opposite.

One test, per the Good Dog rule: BroadcastBindingTests pins the matrix and
specifically pins `unknown` when there is no stream id to compare against, so a
missing expectation can never be misreported as a match — that is the failure
that would make this diagnostic lie.

Suite 387/387, 0 warnings.
2026-10-04 14:47:09 -07:00
24 changed files with 1985 additions and 134 deletions
+224 -26
View File
@@ -1,10 +1,184 @@
# HANDOFF — current state
**Branch:** `main` (pre-1.0, no feature branches — creator ruling 2026-08-24).
**Pushed 2026-10-04:** `origin/main` = `cc2d1ef`, working tree clean. That push carried
13 commits (the 2026-09-27 → 10-02 backlog plus two units from today). The stale-push-state
failure `MyMistakes.md` warns about has now bitten twice, so the rule stands: **check
`git rev-list --count origin/main..main` before trusting any line here about push state.**
**Pushed 2026-10-04:** `origin/main` = `048cab7`. **NOT PUSHED since — local `main` is ahead and the
tree is clean.** The stale-push-state failure `MyMistakes.md` warns about has now bitten
twice, so the rule stands: **check `git rev-list --count origin/main..main` before trusting any line
here about push state.**
Unpushed: `5ad0106` broadcast diagnostic → `74da99c` + `0fb2f9c` privacy/Test rulings →
`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:
| Counter | Value | Reading |
|---|---|---|
| `Camera cadence … arrivals` | **30.0/s, 0 gaps ≥50ms**, avg 33.4ms | the camera is **perfect**. Not the culprit. |
| `Camera cadence … camera` (device stamps) | 30.0/s, identical | the USB link and driver are clean too |
| `FramePump … from N renders` | **34 renders per 5s = 6.8/s**, avg render **141ms**, worst 294ms | one full composite takes longer than four camera frames |
| `webcam …` on the pump's line | 13.0/s, 34 gaps ≥50ms | only 13 of the 30 delivered frames ever reached a composite |
| the recording itself | **6.8 distinct webcam images/s, 88.7% duplicates, ZERO arrival hitches** | the judder is *only* us repeating a frame — nothing arrives late |
**⇒ The camera delivers all 30 of its frames, evenly. The compositor can only draw ~7 composites a
second, so the 60fps container is 89% repeats of a face held for 140ms.** That is exactly what a
lipreader experiences as "cutting out".
**⛔ Do NOT do Unit 1 (720p / exclusive lock) as the judder fix — it is the wrong lever.** 720p would
triple the bytes the compositor stretches per render on a path that is already 4.4× over its budget.
The exclusive lock is still worth having **for sharpness** (the C920 is pinned at 640×480 YUY2 and
the Chat scene upscales that ~2.1×), but it must be scheduled as a quality win, not a smoothness win,
and it should land **after** the compositor work — otherwise it makes the judder measurably worse.
**The number that needs explaining:** the 10:01 take hit **25.6** distinct images/s; this one hit
**6.8**. Same scene shape both times (`full-render split=0 elements=6 dynamic=5`), same camera, same
640×480. Composites went **29–34ms at 10:03 → 143–155ms at 12:01**, a 4.5× regression between
sessions with nothing in the log explaining it. `resolve` is 0.1ms, so the cost is pure pixel work,
not frame lookup. **That regression is the next thing to find, and the log cannot say what changed** —
no scene dump is written at stream start. ⛔ Do not guess at it.
**Cheapest next step, and it is a measurement not a fix:** log the per-element composite breakdown
(size + ms for each of the 6 elements) plus the scene contents at pump start. One run names which
element eats 140ms and whether the 10:03→12:01 regression was a scene change or something else.
Everything else in the compositor is guesswork until that exists.
**Also worth knowing (a counter subtlety, so nobody re-derives it):** the pump's `webcam` line reads
13/s while the file shows 6.8/s. Not a discrepancy — the counter stamps a frame when the RESOLVER
first sees it, and one composite takes 141ms, so ~4 camera frames arrive during it: the signature
pass records one epoch and the actual draw uses a later one. **The file measurement is the truth;
the pump counter overcounts by design** (it is a pipeline-health signal, not a frame-delivery metric).
## 🚧 IN FLIGHT 2026-10-05 — UNIT 0 shipped: a dropped webcam frame is finally measurable
**The measurement the map never had.** Take `ty-20261005-1001-821-0001.mp4` (85.6s, 1920×1080,
H.264 NVENC, 5124 frames @60) holds **25.6 distinct webcam images/s** in its lower-right region
against the camera's 30fps: 1,153 normal 33ms intervals, **281 gaps of 50–100ms**, 96 bunched 17ms
gaps. One hitch in five — the judder a DHH creator lipreads against, invisible in a file that muxes
flawlessly. Measured with `signalstats`/YDIF over `crop=480:270:1400:780`; the bundled ffmpeg has no
`mpdecimate`. ⛔ The old "webcam renders at ~18fps" claim was wrong (different session, counted all
newly rendered layers) — corrected in `ai.md`.
**Shipped:** `Helpers/FrameCadence.cs` (pure counter: frames, gaps, hitches ≥50ms, longest/avg), fed
from three points — `MediaCaptureFrameSource` logs `Camera cadence WxH: arrivals … | camera …`
(our delivery vs the device's own `SystemRelativeTime`), and `FramePump` logs distinct webcam
`Epoch`s that reached a composite. Full detail in `ai.md` → "Frame cadence".
**Also fixed, and it was a real measurement bug:** every pump average was divided by the number of
frames **submitted**, which deadline padding always fills to the full 60fps target — so a pump fed
one webcam frame per 200ms logged "300/300 frames, avg render 12.4ms". Render/resolve/wait now divide
by `statRenders` and the line reads `300/300 frames per 5s from 60 renders`. That pair IS the
duplicate-on-lag ratio. Test: `FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_…`.
**⏭ NEXT STEP — needs the creator, one short recording (60s is plenty):** record with the webcam
visible and paste nothing — I read `%APPDATA%\ytLlive\startup.log` for the `Camera cadence` and
`FramePump stats` lines. Reading them together names the culprit: camera steady + arrivals ragged =
we drop it (reader/compositor); ragged in both = device/USB/driver; pump `webcam` below `arrivals`
= the compositor missed frames the reader already had. **Then Unit 1** (exclusive lock + 720p, see
below) is judged against those numbers.
**Agreed but NOT yet built (decided 2026-10-05, so a later session does not re-derive it):**
- **Unit 1 — exclusive camera lock + 720p on the camera the creator picked.** Open with
`MediaCaptureSharingMode.ExclusiveControl`; if init is refused, reopen the **SAME camera** in
`SharedReadOnly`. Target MJPG ≥30fps up to 1280×720, ranked by frame rate then size (this also fixes
the existing candidate-ordering bug). **The creator REVERSED the ban on taking an exclusive camera
lock** — that ban was never written into the map, which is why the reversal must be recorded this
time. Accepted trade-off: an app needing camera *control* that opens it after us will fail.
- ✅ **API probe DONE 2026-10-05 against the actual 19041.57 projection** (a throwaway
compile-probe file, since deleted — do not re-derive, and note the build cost of finding out):
`MediaCaptureSharingMode.ExclusiveControl` **is** projected, so the old comment in
`MediaCaptureFrameSource.cs` ("Exclusive is not projected by the 19041 SDK") is wrong and must be
corrected. `Exclusive` and `SharedReadOnly` are the **only two** `MediaCaptureSharingMode` values.
`MediaFrameSource.SupportedFormats` and `CurrentFormat` exist.
⛔ **`MediaFrameSource.SetFormatAsync(MediaFrameFormat)` is a DEAD END here** — it takes a
`MediaFrameFormat`, and that type has **no public constructor and no static factory** in the
projection (it is only obtainable from a live frame's `VideoMediaFrame.Format`). The two-argument
`(subtype, properties)` overload in Microsoft's docs does not exist here. So the format change must
go through the `VideoDeviceController.SetMediaStreamPropertiesAsync` path the code ALREADY has —
it is only refused because `SharingMode` is `SharedReadOnly`. Under an exclusive lock the existing
negotiation should simply start working, which is a much smaller change than planned.
- **Remaining unknown (needs a real device, not research):** whether `SetMediaStreamPropertiesAsync`
actually succeeds under `ExclusiveControl` on this C920. The `MediaFrameSource.SupportedFormats`
route is the fallback probe if it refuses — enumerate, and log what the device offers before
assuming anything.
- **Unit 2 — blinking "SD" badge in the preview only**, when a better format was available and we did
not get it. Top-right corner of the webcam, or the 1:30 point ON THE RIM for a round clip (a round
webcam has no corner); outside the mirror transform. ~1Hz opacity blink (under the 3/sec
photosensitivity limit), tooltip on hover only, **must never appear in the recording or stream**
(the compositor draws from camera frames, not the WPF tree). Style lives in `Themes/Controls.xaml`
— a DataTrigger storyboard cannot be stopped inside a DataTemplate (`PreviewPane.xaml:168-171`).
- ⛔ **NO fallback-camera setting, ever** — creator ruling: *"no fallback - that's bypassing the
user's explicit choice."* Picking a vcam is a user action in the camera picker. Corollary, also
recorded in `ai.md`: **Windows Studio Effects is NOT a vcam** — it runs inside the physical camera's
driver and never appears as a selectable device. Only Broadcast / XSplit VCam install one.
- ⛔ **Background removal: permanently out.** Creator confirmed the existing note: *"no - not doing
it. the note is correct and accurate."* `ai.md` said "milestone 2", which was stale — now corrected.
## 🚨 FIXED 2026-10-04 — broadcasts never went live because they were never BOUND
**Root cause, proven by the diagnostic rather than inferred:** `contentDetails.boundStreamId` on
`liveBroadcasts.insert` **does not bind the broadcast.** The log line read
`lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING |
streamStatus=active`.
`created` is Google's *"incomplete settings"* state, so `enableAutoStart` had no bound stream to fire
from, and close-out 403'd `invalidTransition` because `created → complete` is not a legal edge. The
reason it survived a week: **a reusable stream accepts bytes for a broadcast that is not watching it**,
so a perfectly healthy 99 MB RTMP session is exactly what an unbound broadcast looks like. Google's
migration guide states the rule outright — *"a liveBroadcast must be bound to exactly one stream
before the live broadcast can actually start"* — and it was in the docs read during the original
design.
Shipped `07a088a`: `BindBroadcastAsync` → `POST liveBroadcasts/bind?part=id&id&streamId` (no body),
called right after `CreateBroadcast` and before the pump; failure is fatal to the session because an
unbound broadcast can never go live. `boundStreamId` and `CreateBroadcast`'s `streamId` parameter both
removed. The old test asserting insert-time binding **laundered a guess into a guarantee** and was
replaced. Also corrected the TASK 44 claim that `liveChatId` only appears once live — it is present
while lifecycle is `created`; that claim came from reading a sample's active-status guard as a spec.
**⏭ NEXT STEP — needs the creator, one 60-second Test:** confirm `Broadcast bound:` appears and
lifecycle walks `created → ready → live`. If it binds but still won't auto-start, the *next* suspect
is an explicit `transition`, not the binding — do not re-open the binding question without log proof.
**Coverage gap:** no automated test asserts `PrepareAndStartLiveAsync` actually *calls* bind — the
service method is tested in isolation, but the wiring in the ViewModel is not. Acceptable only
because the end-to-end symptom was the whole point of the diagnostic; revisit if the wiring is ever
touched again.
## 🚨 FIXED 2026-09-27 — "Change Account" no longer signs you out before the browser opens
@@ -247,11 +421,12 @@ Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS
## Landmines
- **Frame pump jitter, unquantified (2026-10-04).** The same test stream logged **86 stalls
per 5s** (`FramePump stall: iteration 40–50ms` against a 17ms budget; 142 such lines in an
8-second window) while `FramePump stats` still read 301/300 frames, 0 dropped. Nominal 60fps
with zero drops *and* repeated 3× overruns is a contradiction worth one measurement, not a
guess. Do not "fix" it from the log alone.
- **Frame pump jitter — counters now honest, cause NOT yet named (2026-10-05).** The same test
stream logged **86 stalls per 5s** (`FramePump stall: iteration 40–50ms` against a 17ms budget)
while `FramePump stats` read 301/300 frames, 0 dropped. UNIT 0 removed the reason those two
numbers could disagree (the averages were diluted by padded submits) and added the counters that
separate reader from compositor — **but no one has read a post-UNIT-0 log yet.** A creator
recording is required first. Do not "fix" the pump before that reading.
- **Stale fact inside `TASKS.md` (left alone, flagged here per the Scope Lock).** The "1.0 gates"
section asserts "**TASK 36 is now shipped**", but the catalog row 36 reads **☐ Queued** and
`task-36-gold-pass.md` still shows items 1–6 unchecked. The line most likely means *item 2*
@@ -259,9 +434,10 @@ Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS
flagging rather than fixing because it is not this unit's job.
- **A stale `ytLive.exe` (PID 2544) locked `bin/.../ytLive.exe`** and broke `dotnet run` with
MSB3027. Killed. **If the build fails to copy `ytLive.exe`, check for a running instance first.**
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` is flaky by
environment** (needs an interactive desktop session; 2 fail / 1 pass in isolation). **Run the
suite with ytLive CLOSED.** Not a regression.
- **`LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_PersistsTheReorder` needs an
interactive desktop session.** As of 2026-10-05 it fails deterministically here (`ImgC` vs
`ImgA`), verified identical on a clean tree. **Run the suite with ytLive CLOSED** and expect this
one to fail in this shell. Not a regression — don't diagnose it from a WSL-shell failure.
- **`RealAppHost.RunAsync` exists for a reason** — a frame-pump test MUST `await` inside it. A
blocking wait occupies the one shared STA thread and **hangs the whole suite with no output.**
Always background a `vstest` run and poll the log; a foreground piped `vstest` returns nothing
@@ -274,9 +450,25 @@ Detail and the open 11.12 re-check: [`TASKS/task-50-publish-recording.md`](TASKS
## Test state
**381/381, 0 warnings, re-verified 2026-10-04 on a clean test-project build** (380 + the
TRAX probe test added today). Build the **test** project, not just the app project, or VSTest
runs a stale DLL.
**402/402, 0 warnings, verified 2026-10-05** (395 + 6 `FrameCadenceTests` + 1 pump-stats integration
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
deterministically rather than 2-of-3):** `LayerReorderPersistenceTests.RealMouseDrag_OnTheLayerList_
PersistsTheReorder` fails with `Expected "ImgC", Actual "ImgA"` at `RealAppHost.Run` line 68. **It
fails identically on a stashed clean tree**, so it has nothing to do with UNIT 0 — it needs an
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,
@@ -297,31 +489,37 @@ whole-suite scheduling artifact of the same family as the `RealAppHost` STA land
## Next
1. **Log the broadcast status during a stream, then one 60-second test** — the RTMP
diagnostic came back "ffmpeg fine, broadcast never goes live", which is a *narrower*
question than the one item 1 used to ask. One code change (reuse the health poll timer)
plus one long test closes it. It is the live bug, so it still sorts first; nothing below
reorders. Packaging is NOT next — the creator's ruling 2026-10-04: we are still in active
development, so ship-time work waits and queued features come first.
2. **The packaging project + `Package.appxmanifest`** (TASK 48 items 2–3) — the next code unit.
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. **⏭ 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.
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
+123
View File
@@ -0,0 +1,123 @@
using System;
using System.Diagnostics;
namespace ytLive.Helpers;
/// <summary>
/// Arrival cadence for one window of a timestamped frame stream: how many frames
/// arrived, how far apart they were, and how many gaps were long enough to read
/// as a hitch. Fed any shared monotonic clock (<see cref="Stopwatch.GetTimestamp"/>
/// values, or a device's system-relative stamps on the same clock) and read through
/// <see cref="Summarize"/>.
///
/// Why it exists: a 60fps container makes a missing camera frame indistinguishable
/// from a padded repeat. rawvideo carries no per-frame timestamps — ffmpeg stamps
/// frames by ARRIVAL at the declared rate — so the OBS "duplicate on lag" loop
/// (FramePump) emits 300 authored frames per 5s whether the renderer managed 300
/// composites or 84. The recorded file therefore cannot name the stage that lost
/// the frames; only these counters can. The same counter run twice tells the two
/// apart: the camera's OWN timestamps vs our ARRIVAL timestamps. Steady camera
/// stamps with ragged arrivals means WE dropped it (compositor/reader); ragged in
/// both means the camera, USB link, or driver did.
/// </summary>
public sealed class FrameCadence
{
/// <summary>Default hitch threshold in ms. 50ms is ~1.5x a 30fps period — a
/// missed 30fps frame arrives as ~66ms — so this counts dropped frames without
/// flagging ordinary jitter (a 33ms frame can legitimately measure 35ms).</summary>
public const double DefaultHitchThresholdMs = 50;
private readonly double _hitchThresholdMs;
private long _firstTicks;
private long _previousTicks;
private double _gapTicksTotal;
private int _frames;
private int _gaps;
private int _hitches;
private double _longestGapMs;
public FrameCadence(double hitchThresholdMs = DefaultHitchThresholdMs)
{
if (hitchThresholdMs <= 0)
throw new ArgumentOutOfRangeException(nameof(hitchThresholdMs), hitchThresholdMs,
"Hitch threshold must be positive.");
_hitchThresholdMs = hitchThresholdMs;
}
public int Frames => _frames;
public int Gaps => _gaps;
public int Hitches => _hitches;
public double HitchThresholdMs => _hitchThresholdMs;
/// <summary>Records one frame at <paramref name="timestampTicks"/> on the shared
/// monotonic clock. Monotonicity is asserted, not trusted: an out-of-order stamp
/// (a device whose clock domain differs from ours, a wrapped counter, a dropped
/// timestamp) is counted as a frame but produces no gap AND does not become the
/// reference for the next one — otherwise a single bad stamp would poison both
/// its own interval and the one after it, inventing a multi-second "hitch".</summary>
public void Add(long timestampTicks)
{
if (_frames == 0)
{
_firstTicks = timestampTicks;
_previousTicks = timestampTicks;
_frames++;
return;
}
var delta = timestampTicks - _previousTicks;
_frames++;
if (delta <= 0) return;
var gapMs = delta * 1000d / Stopwatch.Frequency;
_gaps++;
_gapTicksTotal += gapMs;
if (gapMs > _longestGapMs) _longestGapMs = gapMs;
if (gapMs >= _hitchThresholdMs) _hitches++;
_previousTicks = timestampTicks;
}
public FrameCadenceSummary Summarize()
{
var spanMs = _frames >= 2
? (_previousTicks - _firstTicks) * 1000d / Stopwatch.Frequency
: 0d;
// Rate is gaps-per-span, not frames-per-span: N frames span N-1 periods, and
// counting the extra interval would report one extra frame per window.
var rate = _gaps > 0 && spanMs > 0 ? _gaps / (spanMs / 1000d) : 0d;
return new FrameCadenceSummary(
_frames, rate, spanMs, _gaps, _hitches, _longestGapMs,
_gaps > 0 ? _gapTicksTotal / _gaps : 0d, _hitchThresholdMs);
}
/// <summary>Starts a fresh window, keeping the configured threshold.</summary>
public void Reset()
{
_firstTicks = 0;
_previousTicks = 0;
_gapTicksTotal = 0;
_frames = 0;
_gaps = 0;
_hitches = 0;
_longestGapMs = 0;
}
}
/// <summary>One measured cadence window — see <see cref="FrameCadence"/>.</summary>
public readonly record struct FrameCadenceSummary(
int Frames,
double RatePerSecond,
double SpanMs,
int Gaps,
int Hitches,
double LongestGapMs,
double AverageGapMs,
double HitchThresholdMs)
{
/// <summary>One-line log form. Identical in shape from every producer (camera
/// source, frame pump) so two lines can be read against each other: same window
/// length, same fields, same order.</summary>
public string Describe(string label) => Frames == 0
? $"{label} no frames"
: $"{label} {Frames}f/{SpanMs / 1000d:F1}s {RatePerSecond:F1}/s, {Gaps} gaps, " +
$"{Hitches} >= {HitchThresholdMs:F0}ms, avg {AverageGapMs:F1}ms, longest {LongestGapMs:F1}ms";
}
+204
View File
@@ -0,0 +1,204 @@
using System;
using System.Threading;
namespace ytLive.Helpers;
/// <summary>
/// A fixed pool of four DEDICATED background threads at <see cref="ThreadPriority.BelowNormal"/>
/// 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
/// <c>Thread.CurrentThread.Priority</c> inside <c>Parallel.For</c> — 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: <see cref="For"/> 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).
/// </summary>
internal static class StripPool
{
/// <summary>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.</summary>
public const int Workers = 4;
/// <summary>Below this many rows the fan-out costs more than it saves — run it inline.</summary>
private const int MinRowsForFanOut = 64;
private static readonly object[] _queueLocks = CreateLocks();
private static readonly Queue<Strip>[] _queues = CreateQueues();
private static readonly Queue<Strip>[] _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<int>? Body;
public Batch? Owner;
}
private sealed class Batch
{
public int Remaining;
public Exception? Error;
}
/// <summary>Run <paramref name="body"/> for every row in [from, to), split across the pool.</summary>
public static void For(int from, int to, Action<int> 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;
}
/// <summary>Start the workers on first use, so a WPF cold start pays nothing for them.</summary>
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<Strip>[] CreateQueues()
{
var q = new Queue<Strip>[Workers];
for (var i = 0; i < Workers; i++) q[i] = new Queue<Strip>();
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;
}
}
+2
View File
@@ -14,6 +14,8 @@ 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
class backs; [`Models/index.md`](../Models/index.md) and
+41 -16
View File
@@ -42,31 +42,56 @@ public partial class MainWindow : Window
_viewModel.InitWebView2();
}
// ─── Window geometry persistence (launch size/position) ───
// Saved on close (RestoreBounds so a maximized window remembers its
// un-maximize rect); restored in the ctor before the window is shown.
// Values clamp to the minimums and to the primary work area — a position
// saved on a since-disconnected secondary monitor falls back on-screen.
// ─── Window geometry (launch position/size) ───
// Launch POSITION is derived, never restored: upper-left of the work area plus
// padding (creator ruling 2026-10-04). The old restore clamped the saved position
// to [work.Left - width + 120, work.Right - 120] horizontally and
// [work.Top, work.Bottom - 80] vertically — ranges wide enough to leave the window
// almost entirely off-screen, which is what the creator was seeing ("below the
// fold"). Size and maximized state are still persisted. Placement math is pure and
// tested (WindowPlacementTests); changing it back to restore position is reverting
// this block only, since the saved values are still written.
/// <summary>Launch inset from the work-area corner, in device-independent units —
/// so it scales with DPI rather than being a fixed physical pixel count.</summary>
internal const double LaunchPadding = 75;
/// <summary>Upper-left of <paramref name="workArea"/> plus padding, clamped so the
/// window is never pushed off-screen. The clamp is the point: a window that fits
/// stays wholly visible, and one too big to fit falls back to the work-area origin
/// rather than hanging off an edge.</summary>
internal static Point TopLeftPlacement(Rect workArea, double width, double height, double padding)
{
var left = workArea.Left + padding;
var top = workArea.Top + padding;
if (width <= workArea.Width) left = Math.Min(left, workArea.Right - width);
else left = workArea.Left;
if (height <= workArea.Height) top = Math.Min(top, workArea.Bottom - height);
else top = workArea.Top;
return new Point(left, top);
}
private void RestoreWindowGeometry()
{
try
{
var saved = _viewModel.RestoreWindowState();
if (saved == null) return;
var (left, top, width, height, state) = saved.Value;
var work = SystemParameters.WorkArea;
width = Math.Clamp(width, MinWidth, work.Width);
height = Math.Clamp(height, MinHeight, work.Height);
left = Math.Clamp(left, work.Left - width + 120, work.Right - 120);
top = Math.Clamp(top, work.Top, work.Bottom - 80);
var saved = _viewModel.RestoreWindowState();
Left = left;
Top = top;
// No saved state: keep the XAML-declared size rather than collapsing to Min*.
var width = Math.Clamp(saved?.Width ?? Width, MinWidth, work.Width);
var height = Math.Clamp(saved?.Height ?? Height, MinHeight, work.Height);
Width = width;
Height = height;
if (state == "Maximized") WindowState = WindowState.Maximized;
var place = TopLeftPlacement(work, width, height, LaunchPadding);
Left = place.X;
Top = place.Y;
if (saved?.State == "Maximized") WindowState = WindowState.Maximized;
}
catch (Exception ex)
{
+3
View File
@@ -77,6 +77,9 @@ public abstract class SceneElement : INotifyPropertyChanged
/// <summary>True only for Countdown-type sources. Gates timer properties panel.</summary>
public virtual bool IsCountdown => false;
/// <summary>True only for AlertBox-type sources. Gates stream alerts properties panel.</summary>
public virtual bool IsAlertBox => false;
public virtual bool IsBackground { get; set; }
private bool _useDefaultBackground = true;
+1 -1
View File
@@ -238,7 +238,7 @@ public class Source : SceneElement
set => Set(ref _alertDisplayMethod, value);
}
public bool IsAlertBox => Type == SourceType.AlertBox;
public override bool IsAlertBox => Type == SourceType.AlertBox;
// Chat box config (TASK 3.18)
private double _chatFontSize = 16;
+113
View File
@@ -1362,3 +1362,116 @@ even when the compiler accepts it. `fileName` + `memberName` looks fine and is w
shape puts the variable part last and the required context first. This is the same family as the
prose-matching lesson — **the aim is to make the wrong thing hard to write, not to detect it
afterward** — and I got it wrong in the very commit written to apply that idea.
## Asked twice about a deliberate, already-documented decision (2026-10-04)
**What happened:** the creator asked whether the test streams still upload private, and added "I
don't think you're able to delete them". I answered, researched `videos.delete`, and then — in the
very next exchange — *offered to build a cleanup tool*, which re-litigated a decision that has been
settled since 2026-09-01. Then got asked about it again. The creator's response: "I keep telling
you; maybe you should write it down." It **was** written down. The note existed, was accurate, and
named the decision correctly. It still did not work.
**Why the note failed — this is the actual lesson.** The old text read:
> **Visibility picker — DELIBERATE TEST-PHASE LOCK, not drift.** The "always Private" enforcement
> stays while the creator runs multi-month real-world testing… The unlock is a deliberate final-pass
> item in **TASK 36**, wired with the dialog selection — never opportunistic.
Every sentence is **descriptive**. It explains *why* the lock exists and *where* the unlock lives.
Not one sentence instructs the reader. So it told me what the truth was but never told me what to
*do* when the truth looked like a defect — and `privacy=private` in a log line, a PRIVATE badge, and
the absence of any delete call all *look* like defects. I read it, recognised it, and still asked,
because recognition is not instruction. Worse, "never opportunistic" and "TASK 36" pointed at a
*different* answer than the one the creator actually gave me (v1, not TASK 36), so the note was
partly stale on top of being non-directive.
**The generalisation:** a decision note must be written as a **prohibition addressed to the future
reader, carrying its own trigger** — not as rationale. Three parts, all required:
1. **The verdict, in imperative voice:** "do not change it", not "it stays because".
2. **The trigger condition:** *what you will observe* that makes it look wrong — `privacy=private`
in a log, the PRIVATE badge, no delete call — so recognition fires the instruction automatically.
3. **The correction, verbatim, where it differs from the note.** The creator's answer (v1) was not
what the note said (TASK 36). A note that is confidently wrong is worse than no note: it makes me
confidently re-ask.
Corollary, and the part I keep skipping: **a repeated user correction is a documentation failure,
not a user annoyance.** The second time the same question gets asked, the correct response is not a
better answer — it is to go find why the written answer did not land, and fix *that*. Answering
well the first time was never the problem; the map was not carrying the instruction.
---
## Error messages: write for the maintenance dev, not for the toast (creator ruling 2026-10-04)
The creator's standard, verbatim: *"my advice for error messages is to give some thought to the
poor maintenance dev who's got to slog through this code tracking down bugs... generic error
messages or, worse, error consolidation would ruin their day completely."* Extended on the ANSI
point: *"didn't matter how cool it looked if you didn't understand what it was trying to tell you."*
**The rule: legibility outranks polish, and a cause must never be collapsed into a bucket.**
1. **Never consolidate distinct causes.** The first bind-failure implementation mapped seven
documented reasons to seven messages and then funnelled everything unmapped into
`_ => $"YouTube rejected the stream binding ({status})."` — which is the trap wearing a
friendly face. YouTube adds an eighth reason, the generic branch swallows it, and the dev
greps for a string the API never emits. **An unmapped reason is passed through verbatim so it
stays greppable against the raw response.** A friendly sentence may be *added* to an unknown
cause; it may never *replace* it.
2. **Never let the toast be the only record.** The user-facing string may be reworded; the log
must keep status + reason + the whole response body verbatim. Toast = friendly, log = exact.
3. **"Re-authenticate" is not a universal answer.** It was my first instinct for a bind failure and
it is correct for exactly one of six documented reasons. Wrong for the other five it actively
misdirects — worst for `liveBroadcastBindingNotAllowed`, which means *our own ordering* is
broken. Sending the dev to re-authenticate hides a bug in our own code behind a plausible
excuse. **Before writing a fix-it suggestion, ask which of the documented reasons this text
would be wrong for.**
4. **A sentinel that means three things is not a sentinel.** The diagnostic emitted `?` for
"field absent", "field unparseable" and "endpoint returned something else" — an ANSI-bright
`?` that looked informative and told the reader nothing. Distinct states get distinct tokens
(`unset` vs `unread` vs a real value), and unreadable input is never reported as a finding.
5. **Check the docs' failure modes, not just the happy path** (this one already bit a week of
diagnosis — see the SOLVED box in `ai.md`). Every reason the API can refuse must have its own
handling and its own test, or the refusal is invisible.
**Corollary, same shape as the decision-note lesson above:** the test suite is part of this
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<float>` + `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.)
+108 -23
View File
@@ -1,3 +1,5 @@
using System.Buffers;
using System.Collections.Generic;
using ytLive.Helpers;
using ytLive.Models;
@@ -91,8 +93,53 @@ public sealed class SceneCompositor
var covered = liveCaptureFrame != null
&& liveCaptureFrame.Width == cropW && liveCaptureFrame.Height == cropH;
if (!covered)
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base
{
if (liveCaptureFrame != null)
{
var (uScale, uOx, uOy) = StretchMath.UniformToFill(cropW, cropH, liveCaptureFrame.Width, liveCaptureFrame.Height);
var drawnW = liveCaptureFrame.Width * uScale;
var drawnH = liveCaptureFrame.Height * uScale;
var ox = (int)Math.Floor(uOx);
var oy = (int)Math.Floor(uOy);
var oh = (int)Math.Ceiling(oy + drawnH);
var ow = cropW;
if (oy > 0)
{
var topCount = oy * ow;
for (var i = 3; i < topCount * 4; i += 4)
buffer[i] = 255;
}
if (oh < cropH)
{
var start = oh * ow;
var bottomCount = (cropH - oh) * ow;
for (var i = start * 4 + 3; i < (start + bottomCount) * 4; i += 4)
buffer[i] = 255;
}
if (ox > 0 || (int)Math.Ceiling(uOx + drawnW) < cropW)
{
var x0s = (int)Math.Floor(uOx);
var x1s = (int)Math.Ceiling(uOx + drawnW);
for (var y = oy; y < oh && y < cropH; y++)
{
var di = (y * ow + 0) * 4;
for (var x = 0; x < x0s && x < ow; x++)
{
buffer[di + x * 4 + 3] = 255;
}
for (var x = x1s; x < ow; x++)
{
buffer[di + x * 4 + 3] = 255;
}
}
}
}
else
{
for (var i = 3; i < buffer.Length; i += 4)
buffer[i] = 255; // opaque black base
}
}
if (liveCaptureFrame != null)
BlitContent(buffer, cropW, cropH, 0, 0, cropW, cropH, liveCaptureFrame, 1f, false, false);
@@ -348,17 +395,16 @@ public sealed class SceneCompositor
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++)
StripPool.For(y0, 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);
BlendRowOpaque(buffer, (y * cropW + x0) * 4, s, si, len);
else
BlendRowWeighted(d, (y * cropW + x0) * 4, s.AsSpan(si, len * 4), 0, len, op);
}
BlendRowWeighted(buffer, (y * cropW + x0) * 4, s, si, len, op);
});
}
/// <summary>Copy <paramref name="frame"/> and blit a global overlay onto the copy
@@ -499,27 +545,62 @@ public sealed class SceneCompositor
var s = src.BgraPixels;
var srcStride = src.Width; // canvas stride in pixels (not bytes)
for (var y = y0; y <= y1; y++)
// 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;
var sxLut = ArrayPool<float>.Shared.Rent(lutCount);
try
{
var pxBase = x0 - ex;
for (var xi = 0; xi < lutCount; xi++)
{
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;
}
StripPool.For(y0, y1 + 1, y =>
{
var py = y - ey;
if (py < oy || py > oy + drawnH) continue;
if (py < oy || py > oy + drawnH) return;
// syNorm: normalized [0,1] position within the drawn content (vertically)
var syNorm = Math.Clamp((py - oy) / drawnH, 0f, 1f);
var syNorm = (py - oy) / drawnH;
if (syNorm < 0f) syNorm = 0f;
if (syNorm > 1f) syNorm = 1f;
// syCrop: pixel position within the crop region
var syCrop = syNorm * cbH;
// syCanvas: canvas pixel row (for buffer indexing)
var syCanvas = Math.Clamp(cbY + syCrop, 0f, src.Height - 1f);
var syCanvas = cbY + syCrop;
if (syCanvas < 0f) syCanvas = 0f;
if (syCanvas > src.Height - 1f) syCanvas = src.Height - 1f;
var syi = (int)syCanvas;
var sy1i = Math.Min(syi + 1, src.Height - 1);
var fy8 = (int)((syCanvas - syi) * 256);
var fyInv = 256 - fy8;
var di = (y * dstW + x0) * 4;
for (var x = x0; x <= x1; x++, di += 4)
for (var xi = 0; xi < lutCount; xi++, di += 4)
{
var px = x - ex;
if (px < ox || px > ox + drawnW) continue;
var sxCanvas = sxLut[xi];
if (float.IsNaN(sxCanvas)) continue;
var px = pxBase + xi;
if (isRound)
{
var dx = px - cx;
@@ -527,13 +608,6 @@ public sealed class SceneCompositor
if (dx * dx + dy * dy > radius * radius) continue;
}
// sxNorm: normalized [0,1] position within the drawn content (horizontally)
var sxNorm = Math.Clamp((px - ox) / drawnW, 0f, 1f);
// sxCrop: pixel position within the crop region
var sxCrop = sxNorm * cbW;
// sxCanvas: canvas pixel column (for buffer indexing)
var sxCanvas = Math.Clamp(cbX + sxCrop, 0f, src.Width - 1f);
if (isMirror) sxCanvas = src.Width - 1 - sxCanvas;
var sxi = (int)sxCanvas;
var sx1i = Math.Min(sxi + 1, src.Width - 1);
var fx8 = (int)((sxCanvas - sxi) * 256);
@@ -582,6 +656,11 @@ public sealed class SceneCompositor
}
}
}
});
}
finally
{
ArrayPool<float>.Shared.Return(sxLut);
}
}
@@ -624,6 +703,12 @@ public sealed class SceneCompositor
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
return;
}
// Opaque source with 1:1 aligned region covering full canvas
if (op == 256 && src.IsOpaque && ex == 0 && ey == 0 && src.Width == dstW && src.Height == dstH)
{
Buffer.BlockCopy(src.BgraPixels, 0, dst, 0, dstW * dstH * 4);
return;
}
var d = dst.AsSpan();
var s = src.BgraPixels.AsSpan();
@@ -741,12 +826,12 @@ 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);
for (var y = dy0; y < dy1; y++)
StripPool.For(dy0, dy1, y =>
{
var si = ((y + options.SourceRectY - sy0) * overlay.Width
+ dx0 + options.SourceRectX - sx0) * 4;
BlendRowOpaque(dst, (y * dstW + dx0) * 4, overlay.BgraPixels, si, dx1 - dx0);
}
});
}
/// <summary>Straight-alpha source-over blend; the frame's alpha (and the layer opacity) drives coverage.</summary>
+64 -24
View File
@@ -202,7 +202,11 @@ public sealed class FramePump : IDisposable
// stats quantum fix made the wait VISIBLE; this removes its cause.
// OBS's video threads are dedicated for exactly this reason.
_pumpTask = Task.Run(() => PumpAsync(options, _cts.Token));
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)");
var scene = _sceneProvider();
var comp = _compositorOptions();
_log?.Invoke($"FramePump started ({options.Width}×{options.Height} @ {options.Fps} fps)" +
(scene != null ? $" scene='{scene.Name}'" : "") +
$" comp={comp.SourceRectWidth}x{comp.SourceRectHeight}");
}
catch (Exception ex)
{
@@ -333,11 +337,35 @@ public sealed class FramePump : IDisposable
// black box — the stats line now reports resolver time separately so a take
// names the stage (get-frame vs blit) instead of feeding another guess.
var resolveSw = new System.Diagnostics.Stopwatch();
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0, worstRender = 0, worstSubmit = 0;
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0;
long socialBarTicks = 0, tickerTicks = 0, flashTicks = 0, acquireTicks = 0;
long worstRender = 0, worstSubmit = 0;
int statFrames = 0;
int stalls = 0;
long lastCacheHits = 0, lastCacheRenders = 0;
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
// bool _loggedStartDetail = false;
// Real-render accounting (UNIT 0, 2026-10-05): every average above was
// divided by statFrames — the number of frames SUBMITTED, which deadline
// padding always fills to the full target even when the renderer only
// produced a fifth of them. A 60fps container therefore reported "300/300
// frames, avg render 12ms" while the source actually fed it 60 distinct
// images. Render cost is now divided by the render count, and the render
// count is reported beside the submit count — the two numbers side by side
// ARE the duplicate-on-lag ratio, which is the only honest reading of how
// much of the stream is a repeat.
int statRenders = 0;
// Webcam cadence (UNIT 0, 2026-10-05): the pump sees each element's resolved
// frame, and VideoFrame.Epoch is the webcam ring's monotonic generation — so a
// NEW epoch means the webcam actually delivered something new between two of
// our ticks. This is the counterpart to MediaCaptureFrameSource's arrivals
// line: it measures what reached the OUTPUT, not what the camera produced. If
// the camera reports 30/s and this reports 25/s, the loss is between them.
var webcamCadence = new Helpers.FrameCadence();
var seenWebcamEpochs = new HashSet<long>();
void ReportStats()
{
if (DateTime.UtcNow < statsNext) return;
@@ -358,21 +386,31 @@ public sealed class FramePump : IDisposable
lastCacheRenders = CacheRenders;
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
}
double freq = System.Diagnostics.Stopwatch.Frequency;
_log?.Invoke(statFrames == 0
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"worst submit {worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}" + cacheDetail + (detail.Length > 0 ? " | " + detail : ""));
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s from {statRenders} renders, " +
$"avg render {renderTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms " +
$"(resolve {resolveTicks / freq * 1000 / Math.Max(1, statRenders):F1}, " +
$"social {socialBarTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
$"ticker {tickerTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
$"flash {flashTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms, " +
$"acq {acquireTicks / freq * 1000 / Math.Max(1, statRenders):F2}ms), " +
$"avg submit {submitTicks / freq * 1000 / statFrames:F1}ms, "
+ $"avg wait {waitTicks / freq * 1000 / Math.Max(1, statRenders):F1}ms, "
+ $"worst render {worstRender / freq * 1000:F1}ms, "
+ $"worst submit {worstSubmit / freq * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}{cacheDetail}, {webcamCadence.Summarize().Describe("webcam")}"
+ (detail.Length > 0 ? " | " + detail : ""));
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
socialBarTicks = tickerTicks = flashTicks = acquireTicks = 0;
statFrames = 0;
statRenders = 0;
stalls = 0;
webcamCadence.Reset();
seenWebcamEpochs.Clear();
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
}
@@ -387,6 +425,8 @@ public sealed class FramePump : IDisposable
var frame = _frameResolver(element);
resolveSw.Stop();
resolveTicks += resolveSw.ElapsedTicks;
if (element.IsWebcam && frame != null && seenWebcamEpochs.Add(frame.Epoch))
webcamCadence.Add(System.Diagnostics.Stopwatch.GetTimestamp());
return frame;
}
@@ -411,26 +451,25 @@ public sealed class FramePump : IDisposable
: compositorOptions.SourceRectHeight - barFrame.Height;
}
// The alert ticker is a per-frame overlay (it scrolls while an alert
// plays), NEVER baked into the static base. Lawn-mower seam: read it
// each tick and let the compositor pin it to the very top.
var tickerFrame = _alertTicker?.Invoke();
// The free-tier branding credit. Read per-frame for the same reason
// as the ticker: it fades and sits on a fresh random spot, so it can
// never live in the baked base or the blit-on-change cache.
var flashFrame = _brandFlash?.Invoke();
var sw = System.Diagnostics.Stopwatch.StartNew();
VideoFrame? tickerFrame = _alertTicker?.Invoke();
tickerTicks += sw.ElapsedTicks;
sw.Restart();
VideoFrame? flashFrame = _brandFlash?.Invoke();
flashTicks += sw.ElapsedTicks;
sw.Restart();
var (barFrame2, position2) = (_socialBar == null ? (null, SocialBarPosition.Bottom) : _socialBar());
socialBarFrame = barFrame2;
socialBarTop = barFrame2 == null ? 0 : (position2 == SocialBarPosition.Top ? 0 : compositorOptions.SourceRectHeight - barFrame2.Height);
socialBarTicks += sw.ElapsedTicks;
VideoFrame frame;
var scratchSize = compositorOptions.SourceRectWidth
* compositorOptions.SourceRectHeight * 4;
// The transition "from" frame lives in TransitionService.FromFrame
// (captured at Start by the VM). The old per-tick fromScene render
// here was dead weight — a full extra scene composite every
// transition tick that BlendFrame never read; removed with the
// pooling change because its buffer's only consumer was its own release.
renderSw.Restart();
sw.Restart();
var scratch = AcquireScratch(scratchSize);
acquireTicks += sw.ElapsedTicks;
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
if (_transition is { Active: true } transition)
{
@@ -444,6 +483,7 @@ public sealed class FramePump : IDisposable
renderSw.Stop();
renderTicks += renderSw.ElapsedTicks;
if (renderSw.ElapsedTicks > worstRender) worstRender = renderSw.ElapsedTicks;
statRenders++;
IFfmpegEncoder? encoder;
lock (_gate) encoder = _encoder;
+51 -1
View File
@@ -50,7 +50,9 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
// Fallback ladder: prefer the camera's VideoPreview stream, but if the
// reader refuses to start there (NoVideoFrameAvailable), retry against
// its VideoRecord stream — some devices only deliver through it.
// (MediaCaptureSharingMode.Exclusive is not projected by the 19041 SDK.)
// (The exclusive-lock upgrade is a separate, later unit — creator
// ruling 2026-10-05; see HANDOFF.md. It is NOT blocked by an SDK gap:
// `MediaCaptureSharingMode.ExclusiveControl` IS projected in 19041.)
if (!await TryStartAsync(preferVideoRecord: false))
{
if (!await TryStartAsync(preferVideoRecord: true))
@@ -246,6 +248,7 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
_frameReader = reader;
_lastError = null;
_isFailed = false;
StartCadenceWindow();
return true;
}
@@ -287,6 +290,30 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
}
}
private const int CadenceWindowSeconds = 5;
private void StartCadenceWindow()
{
_arrivalCadence.Reset();
_cameraCadence.Reset();
_cadenceNextReportTicks = System.Diagnostics.Stopwatch.GetTimestamp()
+ System.Diagnostics.Stopwatch.Frequency * CadenceWindowSeconds;
}
/// <summary>Logs one cadence window and opens the next. The size rides along
/// because the whole question started as "the webcam is soft": a future format
/// change must be readable in the SAME line that proves the frame rate.</summary>
private void ReportCadence(int width, int height)
{
_cadenceNextReportTicks = System.Diagnostics.Stopwatch.GetTimestamp()
+ System.Diagnostics.Stopwatch.Frequency * CadenceWindowSeconds;
var arrivals = _arrivalCadence.Summarize();
var camera = _cameraCadence.Summarize();
_arrivalCadence.Reset();
_cameraCadence.Reset();
AppLog.Write($"Camera cadence {width}x{height}: {arrivals.Describe("arrivals")} | {camera.Describe("camera")}");
}
private void UnsubscribeCaptureEvents(MediaCapture capture)
{
capture.Failed -= OnCaptureFailed;
@@ -349,12 +376,35 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
return fresh;
}
// Arrival vs production cadence (UNIT 0, 2026-10-05): the recorded file cannot
// tell a dropped camera frame from a padded repeat — rawvideo stamps frames by
// ARRIVAL at the declared 60fps, so a webcam delivering 25 of its 30 frames still
// muxes a full-length file. Two counters, one reader, settle it: the camera's OWN
// SystemRelativeTime stamps (device/USB/driver cadence) against the moment the
// reader handed each frame to us (our cadence, including coalescing drops — the
// reader deliberately keeps only the LATEST frame, so falling behind is silent).
// Steady camera + ragged arrivals = we are behind. Ragged in both = the device.
private readonly FrameCadence _arrivalCadence = new();
private readonly FrameCadence _cameraCadence = new();
private long _cadenceNextReportTicks;
private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
{
var arrivedTicks = System.Diagnostics.Stopwatch.GetTimestamp();
using var frame = sender.TryAcquireLatestFrame();
var videoFrame = frame?.VideoMediaFrame?.SoftwareBitmap;
if (videoFrame == null) return;
// SystemRelativeTime is QPC-domain, the same clock Stopwatch reads here, so the
// camera's own frame spacing is measurable without a second conversion. It is
// NULLABLE in the projection (null = the device supplied no stamp), so the
// production side of the comparison is then simply missing from the line —
// arrivals alone still measure what reached us.
_arrivalCadence.Add(arrivedTicks);
if (frame!.SystemRelativeTime is { } cameraStamp)
_cameraCadence.Add((long)(cameraStamp.TotalSeconds * System.Diagnostics.Stopwatch.Frequency));
if (arrivedTicks >= _cadenceNextReportTicks) ReportCadence(videoFrame.PixelWidth, videoFrame.PixelHeight);
var bitmap = videoFrame.BitmapPixelFormat == BitmapPixelFormat.Bgra8
? videoFrame
: SoftwareBitmap.Convert(videoFrame, BitmapPixelFormat.Bgra8);
+224 -8
View File
@@ -29,7 +29,12 @@ public class YouTubeStreamService
return true;
}
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime, string? streamId = null)
/// <param name="scheduledStartTime"/> — note there is deliberately NO streamId
/// parameter (2026-10-04). It used to be here and was ignored: sending
/// `contentDetails.boundStreamId` does not bind the broadcast. A parameter that looks
/// meaningful but cannot affect the outcome is a defect — it is exactly what made the
/// week-long misdiagnosis possible — so the signature now says what it actually does.</param>
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime)
{
if (!await EnsureToken()) return null;
@@ -50,9 +55,16 @@ public class YouTubeStreamService
selfDeclaredMadeForKids = false
},
// One-click go-live (TASK 5 design decision 1): auto start/stop with
// no monitor stream and low latency. A reusable stream, when given,
// binds here (boundStreamId) so no second bind round-trip is needed.
contentDetails = BuildContentDetails(streamId)
// no monitor stream and low latency.
//
// ⛔ `boundStreamId` is DELIBERATELY NOT SENT HERE (2026-10-04). It used to
// be, on the assumption that binding at insert saved a round-trip. It does
// not bind anything — proven: the insert was accepted, sibling fields in
// this same object took effect, and the broadcast still read `bound=NONE`,
// stayed in lifeCycleStatus `created`, and never went live. Binding is done
// by the explicit BindBroadcastAsync call instead. Leaving the field out
// stops the next reader trusting it; see that method for the full account.
contentDetails = BuildContentDetails()
};
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
@@ -66,17 +78,17 @@ public class YouTubeStreamService
return data.GetProperty("id").GetString();
}
private static Dictionary<string, object?> BuildContentDetails(string? streamId)
private static Dictionary<string, object?> BuildContentDetails()
{
var details = new Dictionary<string, object?>
// No `boundStreamId` here on purpose — it does not bind (proven 2026-10-04).
// Binding is the explicit BindBroadcastAsync call; see CreateBroadcast.
return new Dictionary<string, object?>
{
["enableAutoStart"] = true,
["enableAutoStop"] = true,
["enableMonitorStream"] = false,
["latencyPreference"] = "low",
};
if (streamId != null) details["boundStreamId"] = streamId;
return details;
}
/// <summary>
@@ -178,6 +190,210 @@ public class YouTubeStreamService
: null;
}
/// <summary>One compact log line answering the only question that matters while a
/// stream is running: is the broadcast actually bound to the stream we are pushing
/// to, and has it moved off `ready`?
///
/// Added 2026-10-04 after two test runs proved the push itself is healthy — ffmpeg
/// delivered 101,229 KiB video + 1,419 KiB audio over 112s with no socket error,
/// yet `enableAutoStart` never fired and close-out 403'd with `invalidTransition`.
/// The config is correct by Google's documentation (boundStreamId is a valid
/// contentDetails field with no restriction against reusable streams), so the only
/// way forward is to observe rather than guess. Everything the API reports about
/// this broadcast and its stream goes in one greppable line:
/// lifeCycleStatus (so the caller can stop asking once it is live), privacyStatus, the boundStreamId
/// *compared against the stream we think we are pushing*, enableAutoStart/Stop, liveChatId
/// presence, the stream's own status.streamStatus, and the CDN settings we declared.
/// Never throws.</summary>
public async Task<string?> LogBroadcastDiagnosticsAsync(string broadcastId, string? streamId)
{
if (!await EnsureToken()) return null;
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
try
{
var broadcastList = await GetJsonAsync($"liveBroadcasts?part=status,contentDetails,snippet&id={Uri.EscapeDataString(broadcastId)}");
// FIX 2026-10-04: liveBroadcasts.list returns a LIST wrapper, so the
// broadcast lives at items[0]. The first version of this diagnostic read
// the wrapper's root and reported every field as "?" — which then made
// BoundMatches report a confident "NO-BINDING" from a parse that never
// happened. A diagnostic that lies when it fails is worse than none.
var broadcast = FirstItem(broadcastList);
var read = broadcast.ValueKind == JsonValueKind.Object;
var status = Nested(broadcast, "status");
var details = Nested(broadcast, "contentDetails");
var snippet = Nested(broadcast, "snippet");
var lifeCycle = Text(status, "lifeCycleStatus") ?? "?";
var bound = Text(details, "boundStreamId");
JsonElement? stream = string.IsNullOrWhiteSpace(streamId)
? null
: await GetJsonAsync($"liveStreams?part=status,cdn&id={Uri.EscapeDataString(streamId)}");
var streamItem = stream is { } present ? FirstItem(present) : default(JsonElement);
AppLog.Write(
$"Broadcast diag: id={broadcastId} lifeCycle={lifeCycle} privacy={Text(status, "privacyStatus") ?? "?"} " +
$"recording={Text(status, "recordingStatus") ?? "?"} bound={(read ? (bound ?? "NONE") : "UNREAD")} " +
$"expect={streamId ?? "none"} boundMatches={(read ? BoundMatches(bound, streamId) : "unread")} " +
$"boundLastUpdate={Text(details, "boundStreamLastUpdateTimeMs") ?? "?"} " +
$"autoStart={Flag(details, "enableAutoStart")} autoStop={Flag(details, "enableAutoStop")} " +
$"liveChatId={(string.IsNullOrWhiteSpace(Text(snippet, "liveChatId")) ? "none" : "present")} | " +
$"streamId={Text(streamItem, "id") ?? "none"} streamStatus={Text(Nested(streamItem, "status"), "streamStatus") ?? "?"} " +
$"activeChat={(string.IsNullOrWhiteSpace(Text(Nested(streamItem, "status"), "activeLiveChatId")) ? "none" : "present")} " +
$"cdn={Text(Nested(streamItem, "cdn"), "ingestionType") ?? "?"}/{Text(Nested(streamItem, "cdn"), "resolution") ?? "?"}@{Text(Nested(streamItem, "cdn"), "frameRate") ?? "?"}");
return lifeCycle;
}
catch (Exception ex)
{
AppLog.Write($"Broadcast diag failed: {ex.Message}");
return null;
}
}
/// <summary>Descends into a `*.list` response and returns its single item. Every list
/// endpoint wraps the resource in `items[]`, so reading the response root reads
/// the wrapper and silently yields nothing — which is exactly the bug this
/// exists to prevent. Returns <c>default</c> when there is no item (empty list,
/// or a shape we do not recognise) so callers can tell "absent" from "present".</summary>
internal static JsonElement FirstItem(JsonElement listResponse)
{
return listResponse.ValueKind == JsonValueKind.Object
&& listResponse.TryGetProperty("items", out var items)
&& items.ValueKind == JsonValueKind.Array
&& items.GetArrayLength() > 0
? items[0]
: default;
}
/// <summary>The bound stream must be the one we are actually feeding. A mismatch is
/// the failure mode where ingest succeeds (the reusable stream happily accepts the
/// bytes) while the broadcast waits forever for a stream that is not sending.</summary>
internal static string BoundMatches(string? boundStreamId, string? expectedStreamId)
{
if (string.IsNullOrWhiteSpace(boundStreamId)) return "NO-BINDING";
if (string.IsNullOrWhiteSpace(expectedStreamId)) return "unknown";
return boundStreamId == expectedStreamId ? "yes" : "MISMATCH";
}
private async Task<JsonElement> GetJsonAsync(string pathAndQuery)
{
var response = await _http.GetAsync($"{ApiBase}/{pathAndQuery}");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"Broadcast diag GET {pathAndQuery.Split('?')[0]} failed ({(int)response.StatusCode})");
return default;
}
return JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
}
private static JsonElement Nested(JsonElement parent, string name) =>
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var child) ? child : default;
private static string? Text(JsonElement parent, string name) =>
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String
? value.GetString()
: null;
private static string Flag(JsonElement parent, string name) =>
parent.ValueKind == JsonValueKind.Object && parent.TryGetProperty(name, out var value)
? (value.ValueKind == JsonValueKind.True ? "true" : value.ValueKind == JsonValueKind.False ? "false" : value.ValueKind.ToString())
: "unset";
/// <summary>Binds the broadcast to its stream — THE call that actually makes a broadcast
/// go live, and the reason it took a week to find (2026-10-04).
///
/// `POST liveBroadcasts/bind?id=…&streamId=…&part=id`, no request body, `youtube.force-ssl`.
///
/// **Sending `contentDetails.boundStreamId` on `liveBroadcasts.insert` does NOT bind the
/// broadcast.** Proven, not assumed: the insert carried `boundStreamId`, the API accepted the
/// request (sibling fields in the same `contentDetails` — `enableAutoStart`, `enableAutoStop` —
/// demonstrably took effect), and the created broadcast still came back
/// `bound=NONE`. YouTube's own migration guide says so outright: *"a liveBroadcast must be bound
/// to exactly one stream before the live broadcast can actually start. The broadcast is not bound
/// to any streams at the time that it's created."*
///
/// The failure it produces is a **silent stall, not an error**, which is why it survived so long:
/// an unbound broadcast reports `lifeCycleStatus = created` ("incomplete settings, not ready to
/// transition to a live or testing status"), so `enableAutoStart` has no bound stream to fire
/// from, the broadcast never leaves `created`, and close-out then 403s with `invalidTransition`
/// because `created → complete` is not a legal edge. Meanwhile ffmpeg pushes ~99 MB happily into
/// the reusable stream, because a reusable stream accepts bytes for a broadcast that is not
/// watching. Every symptom pointed away from the binding.
///
/// Returns null on success, otherwise a human-readable error naming the ACTUAL cause.
/// Never throws.</summary>
public async Task<string?> BindBroadcastAsync(string broadcastId, string streamId)
{
if (!await EnsureToken()) return "not signed in";
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.PostAsync(
$"{ApiBase}/liveBroadcasts/bind?part=id&id={Uri.EscapeDataString(broadcastId)}&streamId={Uri.EscapeDataString(streamId)}",
content: null);
if (response.IsSuccessStatusCode)
{
AppLog.Write($"Broadcast bound: {broadcastId} -> stream {streamId}");
return null;
}
var body = await response.Content.ReadAsStringAsync();
var reason = ErrorReason(body);
AppLog.Write($"Broadcast bind failed ({(int)response.StatusCode}/{reason ?? "unknown"}) for {broadcastId} -> {streamId}: {body}");
return BindFailureMessage((int)response.StatusCode, reason);
}
/// <summary>Pulls `errors[0].reason` out of a Google API error body. Needed because
/// "rejected the binding" alone is not actionable — the reason is what tells the
/// creator whether to re-auth, wait, or suspect our own ordering.</summary>
internal static string? ErrorReason(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var error)
&& error.TryGetProperty("errors", out var errors)
&& errors.GetArrayLength() > 0
&& errors[0].TryGetProperty("reason", out var reason))
return reason.GetString();
}
catch (JsonException)
{
// A non-JSON error body tells us nothing actionable; the status code is enough.
}
return null;
}
/// <summary>Bind failures are NOT all "sign in again" (2026-10-04). Google documents six
/// distinct reasons and only one is an auth problem, so a single sign-in message
/// actively misdirects: `liveBroadcastBindingNotAllowed` in particular means OUR
/// ordering is wrong, and telling the creator to re-authenticate would hide a bug in
/// our own code behind a plausible excuse. Each documented reason gets its own text,
/// because the caller's only lever is the string it shows.</summary>
internal static string BindFailureMessage(int status, string? reason) => reason switch
{
"liveBroadcastBindingNotAllowed" =>
"YouTube won't bind a broadcast in its current state — it may already be live. End the session and start again.",
"liveStreamNotFound" =>
"YouTube couldn't find the reusable stream. Sign in again so it can be recreated.",
"liveBroadcastNotFound" =>
"YouTube couldn't find the broadcast that was just created. Try again.",
"liveStreamingNotEnabled" =>
"This channel isn't enabled for live streaming — check the channel's live settings in YouTube Studio.",
"insufficientLivePermissions" =>
"This account doesn't have permission to stream live. Check the YouTube sign-in.",
"internalError" =>
"YouTube hit an internal error binding the stream. Try again.",
"userRequestsExceededRateLimit" =>
"YouTube is rate-limiting us. Wait a moment and try again.",
// Unknown or absent reason: keep it verbatim. Never collapse distinct causes into
// one bucket — an unmapped reason must stay greppable against the raw API response.
null => $"YouTube rejected the stream binding (HTTP {status}, no reason in the body — see startup.log).",
_ => $"YouTube rejected the stream binding (HTTP {status}, reason '{reason}' — see startup.log).",
};
/// <summary>Returns the channel's reusable stream (TASK 5 design decision 2):
/// lists existing streams first and reuses the one with cdn.isReusable=true,
/// creating it with variable resolution/frame rate on first use. Binding to a
+50 -1
View File
@@ -156,6 +156,28 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
adopts a solo camera as default. TASK 26's app-wide gate superseded by creator directive. The App
Settings webcam selector is now **TASK 40 Unit A** (plan saved 2026-09-22). Remaining next slice
(user-queued): static (+) catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
- **🔶 Webcam cadence → resolution — IN PROGRESS, creator-queued 2026-10-05** (DHH/lipreading: the
webcam judders, ~1 hitch in 5, invisible in the output file). **UNIT 0 shipped** (counters +
`Helpers/FrameCadence.cs`; see `ai.md` → "Frame cadence"). **Needs a creator recording before the
next unit.** Remaining units, all agreed 2026-10-05, one Good Dog test each:
- **⛔ UNIT 1 IS NOT NEXT — re-scoped 2026-10-05.** The first real reading exonerated the camera:
it delivers 30.0/s with zero hitches; the compositor manages **6.8 composites/s at ~141ms each**,
so the file is **88.7% duplicate frames** and that is the judder. 720p triples the bytes stretched
per render on an already-over-budget path. Keep `ExclusiveControl` + 720p as a **sharpness** win
and schedule it AFTER the compositor work.
- **⏭ ACTUAL NEXT: per-element composite timing + a scene dump at pump start.** One run names which
of the 6 elements eats 140ms and explains the 10:03→12:01 regression (29–34ms → 143–155ms, same
scene shape). Measurement only — no fix until it names the element.
- **Unit 2 (later, unchanged):** blinking amber **"SD" badge**, preview only, never in the
recording/stream, when a better format was available and not obtained. Top-right corner, or the
1:30 rim point when the clip is round.
- **Deferred to after Unit 1:** `MediaCaptureSharingMode.ExclusiveControl` + MJPG ≥30fps up to
1280×720 via the existing `SetMediaStreamPropertiesAsync` path (refused only because
`SharedReadOnly`); same-camera `SharedReadOnly` retry. **The creator reversed the ban on taking an
exclusive camera lock.** API probe already done — see HANDOFF.md, incl. the `SetFormatAsync`
dead end.
- ⛔ **No fallback-camera feature, ever** — *"that's bypassing the user's explicit choice."*
- ⛔ **No background removal** — re-affirmed out 2026-10-05.
- **TASK 40 — App Settings round** — plan saved 2026-09-22 (`TASKS/task-40-app-settings-round.md`),
four units, one Good Dog test each: **A** active web camera section under Recordings (Change…
sub-menu = live enumeration incl. vcams; Tune… config dialog via `ICameraControlProbe`, live-only
@@ -303,6 +325,31 @@ second encoder path needing a "redirect". Record+simulcast is one ffmpeg with tw
## 1.0 gates (do these before the first shipped build)
- **⛔ Replace the always-Private broadcast lock with a real visibility option (creator ruling,
restated 2026-10-04).** `privacyStatus = "private"` hardcoded in
`YouTubeStreamService.CreateBroadcast` is deliberate and correct for the whole pre-v1 test
phase — it is the reason test VODs of a still-breaking product stay off the channel. At v1 it
becomes a user-selectable visibility option in the Go-Live dialog. **Before v1, do not touch it and do not raise it as a
question, observation or suggestion — it has been asked and answered repeatedly.** Full rationale
and the retired "can we delete the test VODs?" question: `ai.md` → YouTube Live API design
constraints.
- **Verified 2026-10-04: a Go Live video IS private.** Three independent hardcodes
(`CreateBroadcast` → `"private"`, `GoLiveViewModel.Visibility => "Private"`,
`BeginTestStream` → `StreamVisibility = "Private"`) and the one method that could change it,
`YouTubeStreamService.UpdateBroadcast`, has **no production caller** — only
`BroadcastPullOutTests.cs:120`. No path exists today by which an app-created broadcast becomes
non-private.
- **Two coupled v1 items — both are the deliberate end of that guarantee:**
1. **Hide the TEST button.** It exists as the guaranteed-private path; once visibility is
selectable, a separate safe button is redundant. One-line change —
`ShowTestButton => ShowStreamActions && IsConnected` is a computed predicate, not a setting.
2. **This is the unit that first wires `UpdateBroadcast` into production.** It is the only code
that can change a broadcast's privacy, and it is currently dead. Wiring it is exactly when
private stops being guaranteed and becomes a user-selectable default. Correct at v1,
ruinous before — and "it's dead code, let me call it" is not a reason to do it early.
- A **dry run** may still be worth having at v1 on its own merits; that is a separate question
from the privacy mechanism and is **not** decided by the above.
- **Kill the dev multi-instance affordance.** `Helpers/InstanceProfile.cs` is already entirely
`#if DEBUG`, so a Release build ships as `DataRoot => DefaultRoot` / `WebViewDataFolder => null`.
Nothing to do but *keep it that way* — and re-prove it:
@@ -332,7 +379,9 @@ See [`TASKS/research-youtube-api.md`](TASKS/research-youtube-api.md) — authori
These were explicitly ruled out:
- **D3D11 swap compositor** (superseded by software compositor; TASK 3 item 16)
- **Background removal** (ONNX/DirectML; TASK 3 item 19)
- **Background removal** (ONNX/DirectML; TASK 3 item 19) — ⛔ **re-affirmed out 2026-10-05**: *"no - not
doing it. the note is correct and accurate."* A vcam app does it free; an in-app model is a runtime,
a GPU/driver matrix, and per-scene lighting tuning. **Never reopen without a new ruling.**
- **OBS-style source expansion** (game capture, browser source, media playlist, VLC, color-key, MIDI — the minimal source set is deliberate; creators who need more have graduated to OBS)
- **Multi-layout** (auto-save, single layout; multi-layout = OBS territory)
- **Cross-process stream resume** (API makes it impossible; creator ruling 2026-09-01)
@@ -166,7 +166,8 @@ public partial class MainViewModel : ViewModelBase
_layoutStore.SaveReusableStream(stream);
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id);
StreamTitle, StreamDescription, DateTime.UtcNow);
_broadcastSeenLive = false;
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session");
@@ -177,6 +178,31 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
// (2026-10-04). Sending `contentDetails.boundStreamId` on the insert does NOT
// bind: the insert is accepted, its sibling fields take effect, and the
// broadcast still comes back `bound=NONE`, sits in lifeCycleStatus `created`
// forever ("incomplete settings"), so `enableAutoStart` has no bound stream to
// fire from and close-out 403s `invalidTransition`. Meanwhile ffmpeg happily
// pushes ~99MB into the reusable stream, because a reusable stream accepts
// bytes for a broadcast that is not watching — which is why every symptom
// pointed away from the binding.
//
// Failure here is fatal to the session, not cosmetic: an unbound broadcast can
// never go live, so continuing would present a live-looking UI over a broadcast
// that is quietly never going to be seen.
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
if (bindError != null)
{
AppLog.Write($"Stream binding failed: {bindError}");
// Show the real reason. "Check the sign-in" would be wrong for five of the
// six documented bind failures, and would hide our own ordering bug behind
// a plausible excuse — see BindFailureMessage.
_notifications.Error("Couldn't go live", bindError);
StopStream(); // full rollback
return;
}
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;
@@ -236,7 +262,15 @@ public partial class MainViewModel : ViewModelBase
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
/// health state + the report-by-exception banner. Never throws — a poll
/// failure logs and leaves the last state untouched.</summary>
/// failure logs and leaves the last state untouched.
///
/// Also piggybacks the broadcast diagnostic (2026-10-04) — the health poll was
/// already running during a stream but only ever read the STREAM's health, which
/// is why two test runs could push 100MB to YouTube and still leave the app
/// unable to say whether the broadcast had ever left `ready`. Deliberately
/// self-limiting: it only logs while the broadcast has NOT reached `live`, so it
/// shouts exactly when something is wrong and goes quiet once healthy, rather than
/// adding a permanent per-tick line to the log.</summary>
private async Task PollHealthAsync()
{
if (_reusableStream == null || !IsLive) return;
@@ -254,6 +288,12 @@ public partial class MainViewModel : ViewModelBase
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
OnPropertyChanged(nameof(CurrentHealth));
ApplyHealthIssue(health.ConfigurationIssues);
if (!string.IsNullOrWhiteSpace(_currentBroadcastId) && !_broadcastSeenLive)
{
var lifeCycle = await _youtubeStream.LogBroadcastDiagnosticsAsync(_currentBroadcastId, _reusableStream.Id);
if (lifeCycle is "live" or "liveStarting") _broadcastSeenLive = true;
}
}
catch (Exception ex)
{
+3
View File
@@ -21,6 +21,9 @@ public partial class MainViewModel : ViewModelBase
private string _streamDescription = string.Empty;
private string _streamVisibility = "Private";
private string? _currentBroadcastId;
/// <summary>Latches once the broadcast reaches live so the per-poll broadcast
/// diagnostic stops logging (2026-10-04). Reset at broadcast creation.</summary>
private bool _broadcastSeenLive;
private string? _reusableStreamUrl;
private ReusableStream? _reusableStream;
private string? _healthIssueBanner;
+8 -4
View File
@@ -501,18 +501,22 @@ public partial class MainViewModel : ViewModelBase
if (element is Source { Type: SourceType.AlertBox } alert)
{
var now = DateTime.UtcNow;
if (now - _lastAlertResolveLog >= TimeSpan.FromSeconds(1))
bool hasFrame = frame != null;
bool wasLoggedThisState = hasFrame == _lastAlertHadFrame;
if (!wasLoggedThisState || now - _lastAlertResolveLog >= TimeSpan.FromSeconds(10))
{
_lastAlertHadFrame = hasFrame;
_lastAlertResolveLog = now;
AppLog.Write(frame == null
? $"Output resolver: alert box '{alert.Id}' frame is null (visible={alert.IsVisible} playing={_alertLayer.IsPlaying}) — skipped in composite"
: $"Output resolver: alert box '{alert.Id}' frame {frame!.Width}x{frame!.Height} (visible={alert.IsVisible} playing={_alertLayer.IsPlaying})");
AppLog.Write(hasFrame
? $"Output resolver: alert box '{alert.Id}' frame {frame!.Width}x{frame!.Height} (visible={alert.IsVisible} playing={_alertLayer.IsPlaying})"
: $"Output resolver: alert box '{alert.Id}' frame is null (visible={alert.IsVisible} playing={_alertLayer.IsPlaying}) — skipped in composite");
}
}
return frame;
}
private DateTime _lastAlertResolveLog = DateTime.MinValue;
private bool _lastAlertHadFrame = false;
private DateTime _lastWebcamNullLog = DateTime.MinValue;
+327 -12
View File
@@ -189,6 +189,31 @@ tests here when it ships: one real chat-poll payload containing all seven reward
assert the persisted canonical `RewardEvent` rows (type + amount/currency/tier/memberLevel/participant
ids) round-trip into SQLite.
### Launch POSITION is derived, never restored (2026-10-04)
Creator ruling: the window must open in the **upper-left of its screen with ~75px padding**, because
the restore path was putting it below the fold. `MainWindow.RestoreWindowGeometry` now ignores the
saved position entirely and calls the pure `TopLeftPlacement(workArea, w, h, padding)`, which takes
the work-area origin plus `LaunchPadding = 75` and clamps so the window is never pushed off-screen:
a window that fits stays wholly visible, one too big to fit falls back to the work-area origin.
`LaunchPadding` is in **device-independent units**, so it scales with DPI instead of being a fixed
physical pixel count.
**Size and maximized state are still persisted and restored** — only position was dropped. The
clamp still applies to size: saved values are `Math.Clamp`ed to `[MinWidth, work.Width]`, which is why
a saved 800×500 comes back as 1366×768 (the XAML `MinWidth`/`MinHeight`). Reverting this is
reverting the one block in `MainWindow.xaml.cs`; the saved values are still written, so nothing is lost.
**Why the old clamp was wrong (do not "fix" it back):** it tolerated `Left` as low as
`work.Left - width + 120` and `Top` up to `work.Bottom - 80` — ranges wide enough to leave the window
almost entirely off-screen while still looking like a successful clamp. A clamp that permits the
window to be ~unreachable is worse than no clamp, because it reads as a guarantee it does not keep.
`BroadcastPullOutTests.Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote` (renamed
from `..._WindowRestores_Clamped_...`) is the integration guard — it asserts the constructor applies
the derived placement for both an on-screen and a wildly off-screen (`-99999`) saved position.
Placement math itself is covered by `WindowPlacementTests` (pure function, no HWND needed).
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
The integration test boots a real `MainWindow` → `MainViewModel` → real `LayoutStore`
@@ -458,6 +483,11 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
fails with "camera in use" (or, if init slips through, the device exposes no preview source). Fix:
quit Broadcast while streaming, or pick its virtual "NVIDIA Broadcast" device from the picker and the
app captures the processed feed. This is a real-device finding (Logitech `VID_046D&PID_082D`).
Correction (2026-10-05): **Windows Studio Effects is NOT a second option of this kind** — it runs
inside the physical camera's own driver, so it never appears as a selectable device. Only tools
that install a virtual camera (NVIDIA Broadcast, XSplit VCam) do. "Pick the vcam" is a user action in
the camera picker, never an automatic substitution: creator ruling 2026-10-05 — the app must not
bypass the user's explicit camera choice, so there is deliberately **no fallback-camera setting**.
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
@@ -572,8 +602,130 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task.
- **Background removal = milestone 2** — ONNX Runtime + DirectML (CPU fallback), MediaPipe Selfie
Segmentation (Apache-2.0), wired into the same `VideoFrame` seam. Not part of milestone 1.
- **Background removal = PERMANENTLY OUT** (creator ruling 2026-10-05, confirmed: "no - not doing
it. the note is correct and accurate"). The line here used to read "milestone 2", which was stale —
it was never a plan, only a leftover from TASK 3's original scoping. The rationale that stands:
a vcam app (NVIDIA Broadcast, XSplit VCam) does it free, and an in-app model means a runtime, a
GPU/driver matrix, and per-scene lighting tuning — support hell for a solo dev
(`TASKS/task-36-gold-pass.md`, `TASKS/task-03-capture-pipeline.md` item 19). Don't reopen it.
### Frame cadence — how a dropped frame became measurable (UNIT 0, 2026-10-05)
**The problem this exists to solve.** The recorded file cannot distinguish a dropped camera frame
from a padded repeat. `-f rawvideo` carries no per-frame timestamps — ffmpeg assigns pts from frame
order at the DECLARED fps — so a webcam delivering 25 of its 30 frames still muxes a full-length,
correctly-timed 60fps file. Measured on take `ty-20261005-1001-821-0001.mp4` (85.6s, 1080p60): the
lower-right webcam region held 25.6 distinct images/s against the camera's 30fps, with 1,153 normal
33ms intervals, 281 gaps of 50–100ms, and 96 bunched 17ms gaps — roughly one hitch in five, which is
what makes a face judder while the file looks flawless. (The "webcam renders at ~18fps" figure that
used to sit in this map was WRONG: it came from a different session and counted every newly rendered
layer, not webcam arrivals. Corrected 2026-10-05.)
**`Helpers/FrameCadence.cs`** — one pure counter, three feeds. `Add(timestamp)` on any shared
monotonic clock; `Summarize()` → frames, rate (gaps ÷ span, NOT frames ÷ span — N frames span N-1
periods), gaps, hitches (default threshold 50ms ≈ 1.5× a 30fps period), longest and average gap;
`Describe(label)` gives every producer the same one-line shape so two lines can be read against each
other. An out-of-order stamp counts as a frame but produces no gap AND does not become the reference
for the next interval — otherwise one bad stamp invents a multi-second "hitch" twice.
**Three measurement points, and what each separates:**
| Line | Fed by | Answers |
|---|---|---|
| `Camera cadence WxH: arrivals … \| camera …` | `MediaCaptureFrameSource` | device/USB/driver cadence (`MediaFrame.SystemRelativeTime`, QPC domain, nullable) vs what the reader handed us. `TryAcquireLatestFrame` keeps only the LATEST frame, so falling behind is silent by design — this is where that shows |
| `webcam …` on the pump's stats line | `FramePump.TimedResolver` | distinct webcam `VideoFrame.Epoch` values that reached a composite — what actually got into the output |
| `frames per 5s from R renders` | `FramePump` | submits vs REAL composites — the duplicate-on-lag ratio, the only honest reading of how much of the stream is a repeat |
**Why the stats-line denominators changed (the actual bug).** Every average was divided by
`statFrames`, the number of frames SUBMITTED — which deadline padding always fills to the full 60fps
target. A pump fed one webcam frame per 200ms logged "300/300 frames per 5s, avg render 12.4ms"
while producing 60 distinct composites: the render average was diluted ~5× and the loss was
invisible. Render/resolve/wait are now divided by `statRenders`; submit stays per-frame (it really
happens per submit). Regression test:
`FramePumpTests.Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits`.
**Reading the three together:** camera 30/s steady + arrivals 25/s = WE dropped it (reader or
compositor). Ragged in both = the device, the USB link, or the driver. Pump `webcam` below
`arrivals` = the compositor missed frames the reader had already delivered.
**✅ WHAT THE FIRST REAL READING SAID (2026-10-05) — the camera was never the problem.**
`ty-20261005-1201-556-0001.mp4` + its log: the C920 delivers **30.0/s with ZERO gaps ≥50ms**, and the
device's own stamps are identical, so the camera, USB link, and driver are all clean. The pump did
**34 composites per 5s (6.8/s) at ~141ms each** (worst 294ms), and the file holds **6.8 distinct
webcam images/s at 88.7% duplicates with no arrival hitches at all**. The judder is entirely us
holding one face for 140ms and padding the rest of a 60fps container. `resolve` is 0.1ms, so it is
pure pixel work in the composite, not frame lookup. Two consequences, both load-bearing:
- **720p is a sharpness win, NOT a smoothness win** — it triples the bytes stretched per render on a
path already 4.4× over budget. Schedule the exclusive lock AFTER the compositor work or it makes
the judder measurably worse.
- **The 10:01 take hit 25.6 images/s and the 12:01 take 6.8, same scene shape
(`full-render split=0 elements=6 dynamic=5`), same 640×480 camera.** Composites went 29–34ms →
143–155ms between sessions with nothing logged explaining it. **There is no per-element composite
breakdown and no scene dump at pump start**, so the log cannot name the culprit — that gap is the
next thing to close, and guessing at it is explicitly out of bounds.
**Counter subtlety, so it is not re-derived:** the pump's `webcam` line read 13/s while the file held
6.8/s. Not a bug — the counter stamps a frame when the resolver FIRST sees it, and one composite takes
141ms, so ~4 camera frames arrive during it (signature pass stamps one epoch, the actual draw uses a
later one). The file is the truth; the pump counter is a pipeline-health signal and overcounts by
design.
**Still unmeasured:** the camera is stuck at the C920 default 640×480 YUY2 @30 because all 185
`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<float>.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<Strip>` + `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)
@@ -918,7 +1070,7 @@ scene each tick, resolves every element to its latest frame, composites it into
and paces frames into the encoder at the tier's FPS. **rawvideo is stamped by ARRIVAL:** ffmpeg assigns
pts from frame order at the declared fps — supply rate = output speed. A starved producer (pump < fps)
ships a time-lapse, truncated file with NO error (take-2 lesson, 2026-09-01); the pump therefore logs
`FramePump stats: n/target frames per 5s, avg render Xms (resolve R), avg submit Yms` so a slow stage names itself — and since 2026-09-04 so does the BUILD: every build gets a GUID stamped
`FramePump stats: n/target frames per 5s from R renders, avg render Xms (resolve R2), avg submit Yms` so a slow stage names itself (the `frames … from R renders` pair and the render-count denominators arrived with UNIT 0 — see "Frame cadence" below; before that every average was divided by the SUBMITTED count, which deadline padding always fills to the full target, so a pump fed one webcam frame per 200ms still logged "300/300 frames" and its render average was diluted ~an order of magnitude) — and since 2026-09-04 so does the BUILD: every build gets a GUID stamped
into `Helpers/BuildStamp` (csproj `GenerateBuildStamp` target, fresh per compile — no lying incremental build), logged at startup
("Build xxxxxxxx (compiled ...)"). **Take 6 (stamp-less, ambiguous): render 35-41ms with slice 3 built or not unknown — attribution is why the stamp exists; takes 7+ are
readable.** If the breakdown says resolve dominates, the suspects are pre-cached chat/config thrash or the web frame; if blit dominates, the general-path element sizes. **Pattern — everything is a constructor-injected
@@ -1677,12 +1829,107 @@ YouTube" button; going live is unreachable until an account is connected.
## YouTube Live API — design constraints (do not violate)
> ## ✅ SOLVED 2026-10-04 — the broadcasts never went live because they were never BOUND.
>
> **The bug, in one line: `contentDetails.boundStreamId` on `liveBroadcasts.insert` does not bind
> the broadcast.** Binding requires an explicit `POST liveBroadcasts/bind?id=…&streamId=…&part=id`.
>
> **How it was finally proven, after a week of misdiagnosis** — the diagnostic line, once its own
> JSON-nesting bug was fixed (`FirstItem`), read:
> ```
> lifeCycle=created privacy=private autoStart=true bound=NONE boundMatches=NO-BINDING
> | streamStatus=active activeChat=none cdn=rtmp/variable@variable
> ```
> Every field is real, and together they are conclusive:
> - `bound=NONE` **with** `autoStart=true`. Both live in the *same* `contentDetails` object on the
> insert, so the request was well-formed and accepted — the API took one field and silently ignored
> the other. That asymmetry is the proof; a malformed request would have failed outright.
> - `lifeCycle=created`, **not** `ready`. Google: `created` = *"incomplete settings, so it is not
> ready to transition to a live or testing status"*. An unbound broadcast is precisely incomplete.
> - `streamStatus=active` — the ingest half was never broken. YouTube had an active stream and a
> broadcast that was not attached to it.
>
> **The causal chain, complete:** no binding → `lifeCycleStatus` stuck at `created` →
> `enableAutoStart` has no bound stream to fire from → never live → close-out 403s
> `invalidTransition` because `created → complete` is not a legal edge (only `ready`/`live` are).
>
> **Why it survived a week, and the lesson worth keeping:** every observable pointed away from the
> binding. ffmpeg pushed ~99 MB cleanly, the reusable stream went `active`, close-out returned a
> *transition* error (which reads like a lifecycle-timing problem), and the insert returned a valid
> broadcast ID (which reads like success). **A reusable stream accepts bytes for a broadcast that is
> not watching it**, so a perfectly healthy RTMP session is exactly what an unbound broadcast looks
> like. Google says it outright in the migration guide: *"a liveBroadcast must be bound to exactly one
> stream before the live broadcast can actually start. The broadcast is not bound to any streams at
> the time that it's created."* That sentence was in the docs I read during the original design and
> was not applied. **Read the failure modes in the docs, not just the happy path.**
>
> **Shipped:** `YouTubeStreamService.BindBroadcastAsync`, called from `PrepareAndStartLiveAsync`
> immediately after `CreateBroadcast` and **before** the pump starts. Failure is fatal to the session
> (an unbound broadcast can never go live, so a live-looking UI over it is a lie). `boundStreamId` was
> **removed** from the insert body and the `streamId` parameter **removed from `CreateBroadcast`** — a
> parameter that looks meaningful but cannot affect the outcome is what made the misdiagnosis possible.
> The old test `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, which asserted the
> disproven behaviour and therefore *laundered a guess into a guarantee*, is replaced by
> `CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId` plus
> `BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body`.
>
> **Bind failures are not all "sign in again."** Google documents six reasons
> (`insufficientLivePermissions`, `liveStreamingNotEnabled`, `liveBroadcastBindingNotAllowed`,
> `liveStreamNotFound`, `liveBroadcastNotFound`, `internalError`, plus rate limiting) and only the
> first is an auth problem. `BindFailureMessage` maps each to its own text; `liveBroadcastBindingNotAllowed`
> matters most, because it means *our* ordering is wrong and a generic "check the sign-in" would hide
> a bug in our own code behind a plausible excuse.
These are the hard facts behind every decision. Full list in `TASKS.md`.
- **One-click go-live** — never call `transition(live)`. Insert the broadcast with
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
`enableMonitorStream=false` is what lets us skip the testing stage.
- **⛔ CORRECTION (2026-10-04): the push is provably healthy — stop investigating it.** The long
test run settles it: `liveBroadcasts.insert` at 14:17:04.577, ffmpeg `Output #0, flv` to
`rtmp://a.rtmp.youtube.com/live2/5f6z-…` at 14:17:05.686, and at 14:18:56.473 ffmpeg's own
muxing summary reads **`video:101229KiB audio:1419KiB`**, `muxing overhead: 0.202949%` — ~99 MB
of real video plus audio over 112s, which is exactly what 8000+128 kb/s should produce. **No
socket error, no 4xx from ingest, no reconnect.** The broadcast *still* never left `ready`, and
close-out earned 403 `invalidTransition` again. An earlier 8s run behaved identically, so
"YouTube needed longer" is disproven — 112s is longer. The break is between *ingest accepted*
and *broadcast told to start*, nowhere in the RTMP path.
- **The binding is the prime suspect, and the app was blind to it.** `contentDetails.boundStreamId`
("the live stream bound to the broadcast") and `enableAutoStart` ("start automatically when you
start streaming video **on the bound live stream**") are both valid on insert, with no documented
restriction against a reusable stream. But YouTube's migration guide carries the danger:
**"a liveBroadcast must be bound to exactly one stream before the live broadcast can actually
start. The broadcast is not bound to any streams at the time that it's created."** And this
failure mode is *invisible from the RTMP side*, because **a reusable stream accepts bytes for an
unbound broadcast** — ingest succeeding proves nothing. ⇒ `LogBroadcastDiagnosticsAsync` rides
the **existing** stream-health poll (no second timer) and writes one greppable line:
`lifeCycleStatus`, `privacyStatus`, `recordingStatus`, `boundStreamId` **compared against the
stream id we are actually pushing** (`BoundMatches` → `yes`/`MISMATCH`/`NO-BINDING`/`unknown`),
`boundStreamLastUpdateTimeMs`, `enableAutoStart/Stop`, `liveChatId` presence, the stream's own
`status.streamStatus` + `activeLiveChatId`, and the CDN settings we declared. It is
**self-limiting** — `_broadcastSeenLive` latches on `live`/`liveStarting`, so the line shouts only
while the broadcast is stuck and goes quiet once healthy. **The next run reads this line; it is
the whole experiment.** Guarded by `BroadcastBindingTests.BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush`,
which pins `unknown` for a missing expectation so an absent stream id can never be misreported
as a match.
- **⚠️ The first version of this diagnostic LIED, and the first real run caught it (2026-10-04).**
`liveBroadcasts.list` returns a **list wrapper**, so the broadcast lives at `items[0]`. The
original code descended into `items` for the *stream* but read the *broadcast's* response root,
so every broadcast field came back `?` — and `BoundMatches` then dutifully reported a confident
**`boundMatches=NO-BINDING`** from a parse that had never happened. Three lines of fabricated
alarm. Two fixes, both load-bearing: **`FirstItem()`** descends into `items[0]` for both halves
(and returns `default` when there is genuinely no item), and the line now prints
**`boundMatches=unread`** when the broadcast could not be read, so an absent parse can never be
reported as a verdict. Guarded by
`BroadcastBindingTests.FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly`.
**Generalisation: a diagnostic must degrade to "I don't know", never to a confident wrong
answer** — otherwise one level of JSON nesting manufactures a bug that isn't there.
- **What the one run that *did* work proved:** the stream half was correct, and it shows
`streamStatus=ready` → **`streamStatus=active`** about 30s into the push. So the ingest does
register as active server-side, which is a genuine step forward — combined with the 99MB muxed
in the earlier run, the stream half of the system is now verified healthy. The broadcast half is
still unobserved pending a re-run.
- **Visibility picker (TASK 9 item 6) — DELIBERATE TEST-PHASE LOCK, not drift (2026-09-01).**
The "always Private" enforcement in `YouTubeStreamService.CreateBroadcast` stays while the creator
runs multi-month real-world testing: non-private test streams would clutter the channel with VODs
@@ -1690,6 +1937,69 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
to subscribers and serve as post-mortem review tapes; bulk-delete pre-GA. The unlock is a
deliberate final-pass item in **TASK 36 (gold pass)**, wired with the dialog selection — never
opportunistic. The PRIVATE badge keeps showing when the stream is actually private.
- **⛔⛔ STOP. The always-Private lock is a DECISION, not a bug, not drift, not an open question.
(Creator ruling, restated 2026-10-04 after being asked about it AGAIN.)**
`privacyStatus = "private"` hardcoded in `YouTubeStreamService.CreateBroadcast` is
**exactly right as written. Do not change it, do not "unlock" it, and — this is the part I keep
getting wrong — do not raise it as a question, an observation, a suggestion, or a
"heads-up".** The creator has now answered this more than once.
- **What it becomes:** the hardcoded lock is replaced by a real, user-selectable
visibility option in the Go-Live dialog — **at v1, not before.** Not at 1.0-candidate, not
at GA prep, not opportunistically during any other unit.
- **Why it exists:** pre-v1 test streams would otherwise publish VODs of a product still
breaking, in public, to subscribers. Private keeps those tapes usable for post-mortem review
while staying invisible to the channel's audience.
- **Do not let these look like symptoms of the one they resemble.** All are working as designed:
`privacy=private` in the broadcast diag line; the PRIVATE badge; `recordFromStart`/DVR ON;
and the absence of any delete call in the codebase (there is no delete feature, by design at
this stage — "can we delete the test VODs" is a *retired* question, answered 2026-10-04:
`videos.delete` accepts `youtube.force-ssl` which we already hold, private is a visibility
setting not a deletion lock, and anyway a broadcast that never reaches `live` never becomes a
VOD at all).
- **`UpdateBroadcast` is NOT the unlock — and it is also currently DEAD CODE. (Fact corrected
2026-10-04: an earlier version of this note said `CreateBroadcast:112` /
`UpdateBroadcastMetadataAsync`, both wrong.)** The method that can change a live broadcast's
privacy is `YouTubeStreamService.UpdateBroadcast(broadcastId, meta)`, which sends
`privacyStatus = meta.Visibility ?? "private"`. **It has no production caller** — the only
caller in the repo is `BroadcastPullOutTests.cs:120`. So `CreateBroadcast` hardcoding private
is not merely backed up by convention, it is the *only* thing enforcing it.
- **⇒ Is a Go Live video private? YES — verified 2026-10-04, and this answers a standing
creator doubt.** Three independent hardcodes (`CreateBroadcast` → `"private"`;
`GoLiveViewModel.Visibility => "Private"`; `BeginTestStream` → `StreamVisibility = "Private"`)
plus one unreachable method. There is no path by which a broadcast this app creates can become
non-private today. The creator's actual concern — "do not populate my channel with videos
showing the app at its worst" — is fully met.
- **⛔ THE LANDMINE, and the reason the above matters:** `LiveBroadcastFormViewModel` still
exposes `VisibilityOptions = {private, unlisted, public}` and `UpdateBroadcast` would happily
honour it. **The v1 visibility option is the first thing that will ever wire `UpdateBroadcast`
up — so v1 is exactly when this guarantee ends, by the creator's own choice.** When that
happens, private stops being guaranteed and becomes a default the user can change. That is
intended at v1; it is a disaster before v1. Do not wire `UpdateBroadcast` into any
production path before v1, and do not "fix" the dead-code smell by calling it.
- **⛔ The TEST button is HIDDEN at v1 — it exists only because of the lock above (creator
ruling 2026-10-04).** This was **absent from the map entirely** until that date, which is how the
dry-run framing below came to be the only recorded one. Both are true, and the second is the
load-bearing one:
- **Its real job is being the guaranteed-private path.** `BeginTestStream` hard-sets
`StreamVisibility = "Private"` (`MainViewModel.Streaming.Operations.cs:105`), so Test can never
publish. That is *why* it exists for the whole pre-v1 phase — a dry run you cannot leak.
- **At v1 it is redundant and gets hidden**, because once visibility is a real dialog option the
user picks Private themselves and a separate "safe" button no longer earns its place. Hiding it
is part of the v1 visibility work, not a separate cleanup.
- **The pre-existing "dry run" framing is now known to be incomplete** (`ShowTestButton`'s own
comment calls it "a dry-run of the livestream"). Do not read that comment as the reason the
button must persist, and do not re-derive the button's purpose from it. If a dry run is still
wanted at v1 it must be re-justified on its own merits — that question is **open, and is for
v1, not now.**
- **It is a computed predicate, not a setting** — `ShowTestButton => ShowStreamActions &&
IsConnected` (`MainViewModel.Streaming.cs:182`), feeding both `CanStartTest` and the XAML
visibility. So the v1 hide is a one-line predicate change, not a settings migration.
- **Not a v1 blocker:** the Test route is the *same* pipeline as Go Live —
`BeginTestStream` → `StartStreamingSession(alsoRecord: false)` → `PrepareAndStartLiveAsync` —
so Test and Go Live create identical broadcasts (`boundStreamId`, `enableAutoStart`). Test
differs only in never recording (`alsoRecord: false`) and forcing Private. **This is why Test
is the correct way to run any diagnostic stream**, and why a Test result is evidence about Go
Live.
- **Full broadcast form (TASK 9 item 7) — RESCOPED 2026-09-01** — the core editable fields ship (since 2026-08-24) as the always-visible **Text drawer**: title, description, tags, visibility, made-for-kids, live-editable; scheduling ships as **TASK 34** (drawer ☑ Scheduled + adoption at Start). The old **Advanced tab is permanently out of product** (the 10% margin — see TASKS.md → "Out of product"): latency locked `low`, DVR/record-from-start locked on, embed/projection/CC/region fixed at sane defaults, invisible — every exposed field is a support ticket. `categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored). Monetization enablement (if ever needed) rides the reward-events chain via `liveBroadcasts.update`, not a form field. Go-live order (TASK 9, shipped 2026-08-16):
`BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`,
cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`),
@@ -1738,15 +2048,20 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
any left-click outside the rail closes whichever drawer is open — TEST included (fixed
2026-09-25, TASK 46; the code-behind outside-click close in `MainWindow` had skipped
TestSession, so only Stream Settings + YPP dismissed on an outside click).
**liveChatId resolution (fixed 2026-09-25, TASK 44):** the id is `snippet.liveChatId` —
`contentDetails` has NO liveChatId property — and YouTube only populates it once the broadcast
is LIVE (the official GetLiveChatId.java sample lists broadcastStatus=active). So
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads
part=snippet and polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame
pump FIRST (the RTMP push is what flips ready→live via enableAutoStart) before resolving it.
Chrome-debugging note: a fetch at broadcast-insert time (lifecycleStatus ready) legitimately
returns no id — missing liveChatId is the ready state, not an auth failure, until ~20s post
push.
**liveChatId resolution (fixed 2026-09-25, TASK 44; ⚠️ CORRECTED 2026-10-04):** the id is
`snippet.liveChatId` — `contentDetails` has NO liveChatId property — and
`GetBroadcastLiveChatIdAsync(broadcastId, maxAttempts=10, delayMs=2000)` reads part=snippet and
polls with a bounded retry, and `PrepareAndStartLiveAsync` starts the frame pump FIRST before
resolving it. **The old claim that "YouTube only populates it once the broadcast is LIVE" is
WRONG, and it was inferred from a sample rather than from the API.** The 2026-10-04 diagnostic
caught `liveChatId=present` while `lifeCycleStatus=created` — not live, not even `ready`. The
official `GetLiveChatId.java` sample tests `broadcastStatus == active` before returning the id, and
that read as a statement about *when YouTube populates the field*; it is only a guard in the
sample. **Generalisation: a sample's defensive check is not a spec.** Also note the old note said
the missing id meant "the ready state" — the broadcasts were never `ready` at all, they were
`created` and unbound (see the SOLVED box above), so the terminology was wrong too.
The bounded retry is harmless and stays, but the id is available far earlier than assumed — worth
revisiting only if chat startup latency is ever reported as slow.
Drawer tooling: **Mock Chat Input** = a REAL `liveChat/messages.insert` (`YouTubeStreamService.InsertChatMessageAsync`)
that round-trips through the real ~2s poll and renders through the live overlay path;
**simulated Subscriber / New Member / Super Chat** = `YouTubeChatService.InjectSimulatedMessage`,
+58
View File
@@ -0,0 +1,58 @@
using System.Text.Json;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Broadcast-binding diagnostics (2026-10-04). Two test streams proved the RTMP push
/// is healthy — ffmpeg delivered 101,229 KiB video + 1,419 KiB audio over 112 seconds
/// with no socket error — yet the broadcast never left `ready` and close-out returned
/// 403 `invalidTransition`. Google's docs say <c>enableAutoStart</c> fires "when you
/// start streaming video on the bound live stream", so the one thing worth asserting
/// about the request we send is that we know which stream we think we are bound to.
/// </summary>
public class BroadcastBindingTests
{
/// <summary>The comparison that decides whether the broadcast is even watching the
/// feed we are pushing. A reusable stream happily accepts bytes for an unbound
/// broadcast, so ingest succeeding proves nothing on its own — a MISMATCH or
/// NO-BINDING would look identical from the RTMP side and would hang forever.
/// "unknown" is the honest answer when the app has no stream id to compare against,
/// so a missing expectation is never silently reported as a match.</summary>
[Theory]
[InlineData("stream-abc", "stream-abc", "yes")]
[InlineData("stream-abc", "stream-xyz", "MISMATCH")]
[InlineData(null, "stream-abc", "NO-BINDING")]
[InlineData("", "stream-abc", "NO-BINDING")]
[InlineData("stream-abc", null, "unknown")]
[InlineData("stream-abc", "", "unknown")]
public void BoundMatches_ComparesTheBroadcastAgainstTheStreamWePush(string? bound, string? expected, string verdict)
{
Assert.Equal(verdict, YouTubeStreamService.BoundMatches(bound, expected));
}
/// <summary>The regression the first diagnostic shipped with. `liveBroadcasts.list`
/// returns a LIST wrapper, so the broadcast sits at `items[0]`; reading the response
/// root reads the wrapper, every field comes back empty, and the binding check then
/// reports a confident `NO-BINDING` from a parse that never happened. The first real
/// test run produced exactly that — three lines of fabricated alarm. `FirstItem` must
/// descend, and must return `default` (not throw, not an empty object) when there is
/// genuinely no item, so the caller can report "unread" instead of guessing.</summary>
[Theory]
[InlineData("{\"kind\":\"youtube#liveBroadcastListResponse\",\"items\":[{\"id\":\"bc1\",\"status\":{\"lifeCycleStatus\":\"ready\"}}]}", "bc1")]
[InlineData("{\"items\":[]}", null)]
[InlineData("{\"kind\":\"youtube#liveBroadcastListResponse\"}", null)]
public void FirstItem_DescendsIntoTheListWrapper_AndReportsAbsenceHonestly(string listJson, string? expectedId)
{
using var doc = JsonDocument.Parse(listJson);
var item = YouTubeStreamService.FirstItem(doc.RootElement);
if (expectedId == null)
{
Assert.Equal(JsonValueKind.Undefined, item.ValueKind);
return;
}
Assert.Equal(expectedId, item.GetProperty("id").GetString());
}
}
+17 -8
View File
@@ -23,7 +23,7 @@ public sealed class BroadcastPullOutTests
private readonly RealAppHost _app;
[Fact]
public void Metadata_Persists_WindowRestores_Clamped_And_UpdatePatchesRemote()
public void Metadata_Persists_Window_Launches_TopLeft_And_UpdatePatchesRemote()
{
_app.Run(Run);
}
@@ -89,11 +89,16 @@ public sealed class BroadcastPullOutTests
using (var writer = new LayoutStore(tempDb))
writer.SaveWindowState(100, 50, 1500, 900, "Normal");
var work = System.Windows.SystemParameters.WorkArea;
window = new MainWindow();
Assert.Equal(1500, window.Width);
Assert.Equal(900, window.Height);
Assert.Equal(100, window.Left);
Assert.Equal(50, window.Top);
// Launch POSITION is derived, never restored (2026-10-04). The 100,50 saved
// above is deliberately ignored: restoring it is what let the window come
// back below the fold, which is the whole reason this behaviour changed.
Assert.Equal(Math.Min(work.Left + 75, work.Right - 1500), window.Left);
Assert.Equal(Math.Min(work.Top + 75, work.Bottom - 900), window.Top);
window.Close();
window = null;
@@ -102,11 +107,15 @@ public sealed class BroadcastPullOutTests
writer.SaveWindowState(-99999, -99999, 800, 500, "Normal");
window = new MainWindow();
var work = System.Windows.SystemParameters.WorkArea;
Assert.Equal(1366, window.Width);
Assert.Equal(768, window.Height);
Assert.InRange(window.Left, work.Left - window.Width + 120, work.Right - 120);
Assert.InRange(window.Top, work.Top, work.Bottom - 80);
Assert.Equal(1366, window.Width); // saved 800 clamps up to MinWidth
Assert.Equal(768, window.Height); // saved 500 clamps up to MinHeight
// A wildly off-screen saved position (-99999) no longer influences placement
// at all — the old clamp tolerated Left as low as work.Left - Width + 120,
// i.e. the window could sit almost entirely off the left edge.
Assert.Equal(Math.Min(work.Left + 75, work.Right - window.Width), window.Left);
Assert.Equal(Math.Min(work.Top + 75, work.Bottom - window.Height), window.Top);
Assert.InRange(window.Left, work.Left, work.Right - window.Width);
Assert.InRange(window.Top, work.Top, work.Bottom - window.Height);
var auth = new YouTubeAuthService("test-client", "test-secret");
auth.SetSession(new YouTubeChannel
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Diagnostics;
using Xunit;
using ytLive.Helpers;
namespace ytLive.Tests;
/// <summary>
/// UNIT 0 (2026-10-05): the camera and the pump could both drop frames invisibly —
/// a 60fps rawvideo container stamps frames by ARRIVAL, so a missing camera frame
/// and a padded repeat look identical in the recorded file, and the old stats line
/// counted SUBMITTED frames (always the full padded target) over an unknown number
/// of renders. These tests pin the one shared counter that makes the two measurable:
/// gaps, hitches, and the longest stall in a window of a timestamped stream.
/// </summary>
public sealed class FrameCadenceTests
{
private static long Ms(double ms) => (long)(ms * Stopwatch.Frequency / 1000d);
private static long AtMs(double ms) => Ms(ms);
[Fact]
public void SteadyStream_ReportsNominalRateAndNoHitches()
{
var cadence = new FrameCadence();
// 30 frames at a true 33.3ms period: 29 gaps, none over the 50ms threshold.
for (var i = 0; i < 30; i++) cadence.Add(AtMs(i * (1000d / 30d)));
var s = cadence.Summarize();
Assert.Equal(30, s.Frames);
Assert.Equal(29, s.Gaps);
Assert.Equal(0, s.Hitches);
Assert.Equal(30d, Math.Round(s.RatePerSecond, 0));
Assert.Equal(33.3, Math.Round(s.AverageGapMs, 1));
Assert.InRange(s.LongestGapMs, 33.0, 33.5);
}
[Fact]
public void DroppedFrames_CountAsHitchesAndLongestGap()
{
var cadence = new FrameCadence();
// 30fps with two dropped frames (66.6ms gaps) and one double-frame (0.1ms):
// the pattern the 2026-10-05 recording showed in the webcam corner.
foreach (var ms in new[]
{
0.0, 33.3, 33.4, 66.7, 100.0, 133.3, 199.9, 233.2, 266.5, 333.1, 366.4,
})
cadence.Add(AtMs(ms));
var s = cadence.Summarize();
Assert.Equal(11, s.Frames);
Assert.Equal(10, s.Gaps); // the bunched pair still counts as an interval
Assert.Equal(2, s.Hitches); // the two dropped-frame gaps at 30fps
Assert.Equal(66.6, Math.Round(s.LongestGapMs, 1));
}
[Fact]
public void OutOfOrderStamp_CountsAsFrameWithoutPoisoningGaps()
{
var cadence = new FrameCadence();
cadence.Add(AtMs(0));
cadence.Add(AtMs(33.3));
cadence.Add(AtMs(10.0)); // out of order — a stamp from another clock domain
cadence.Add(AtMs(66.6));
var s = cadence.Summarize();
Assert.Equal(4, s.Frames);
Assert.Equal(2, s.Gaps); // the two valid intervals only
Assert.Equal(0, s.Hitches);
Assert.Equal(33.3, Math.Round(s.LongestGapMs, 1));
}
[Fact]
public void Reset_StartsFreshWindowAndKeepsThreshold()
{
var cadence = new FrameCadence(hitchThresholdMs: 20);
cadence.Add(AtMs(0));
cadence.Add(AtMs(100));
Assert.Equal(1, cadence.Hitches);
cadence.Reset();
Assert.Equal(0, cadence.Frames);
Assert.Equal(0, cadence.Gaps);
Assert.Equal(20, cadence.Summarize().HitchThresholdMs);
Assert.Contains("no frames", cadence.Summarize().Describe("cam"));
}
[Fact]
public void Describe_NamesTheWindowRateHitchesAndLongestGap()
{
var cadence = new FrameCadence();
foreach (var ms in new[] { 0.0, 16.7, 33.4, 50.1, 120.0 }) cadence.Add(AtMs(ms));
Assert.Equal(
"webcam 5f/0.1s 33.3/s, 4 gaps, 1 >= 50ms, avg 30.0ms, longest 69.9ms",
cadence.Summarize().Describe("webcam"));
}
[Fact]
public void ThresholdsMustBePositive()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new FrameCadence(0));
Assert.Throws<ArgumentOutOfRangeException>(() => new FrameCadence(-1));
}
}
+47
View File
@@ -1,5 +1,6 @@
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using Xunit;
using ytLive.Models;
using ytLive.Services;
@@ -573,6 +574,52 @@ public class FramePumpTests
$"resolver ran on the starting thread ({startThread}) — the loop is back on the caller context"));
}
/// <summary>The ONE integration test for UNIT 0's stats correction (2026-10-05).
/// The old line divided every average by the number of frames SUBMITTED — deadline
/// padding always fills that to the full 60fps target, so a pump fed one webcam
/// frame per 200ms still logged "300/300 frames per 5s" and the "avg render" was
/// diluted by an order of magnitude. It also never reported the webcam at all, so
/// a camera delivering 25 of its 30 frames was indistinguishable from one
/// delivering 30. Here a webcam advancing a new Epoch every ~200ms must show up
/// as FEWER distinct frames than submits, with the render count beside them.</summary>
[Fact]
public async Task Stats_ReportRealRendersAndDistinctWebcamFrames_NotJustPaddedSubmits()
{
var webcamPixels = SceneCompositorTests.Solid(64, 48, 0, 255, 0).BgraPixels;
var scene = new Scene { Name = "Live" };
scene.Elements.Add(new WebcamSceneConfig { Name = "Webcam", IsVisible = true });
var encoder = new FakeEncoder();
var log = new List<string>();
var epoch = 0L;
using var pump = NewPump(encoder, scene: () => scene, log: log, resolve: e =>
{
if (!e.IsWebcam) return null;
// One NEW webcam frame every ~200ms — slower than the 60fps container,
// exactly the case the old stats line hid completely.
Thread.Sleep(200);
epoch++;
return new VideoFrame(64, 48, webcamPixels) { Epoch = epoch };
});
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(8);
while (DateTime.UtcNow < deadline && !log.Any(l => l.Contains("webcam ")))
await Task.Delay(50);
await pump.StopAsync();
var stats = log.FirstOrDefault(l => l.StartsWith("FramePump stats:") && l.Contains("webcam "));
Assert.True(stats != null, $"no cadence-bearing stats line in: {string.Join(" | ", log)}");
var submits = int.Parse(Regex.Match(stats!, @"stats: (\d+)/").Groups[1].Value);
var renders = int.Parse(Regex.Match(stats!, @"from (\d+) renders").Groups[1].Value);
var webcamFrames = int.Parse(Regex.Match(stats!, @"webcam (\d+)f/").Groups[1].Value);
Assert.True(submits > 0, stats);
Assert.True(renders > 0 && renders <= submits, $"renders {renders} vs submits {submits}: {stats}");
Assert.True(webcamFrames > 0 && webcamFrames < submits,
$"webcam frames {webcamFrames} should be far fewer than the {submits} padded submits: {stats}");
}
private sealed class InlineSyncContext : SynchronizationContext
{
public override void Post(SendOrPostCallback d, object? state) => d(state);
+70 -2
View File
@@ -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
}
/// <summary>Left half = leftColor, right half = rightColor.</summary>
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
}
/// <summary>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.</summary>
[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
}
}
+53
View File
@@ -0,0 +1,53 @@
using System.Windows;
using Xunit;
namespace ytLive.Tests;
/// <summary>
/// Launch placement (2026-10-04). The window used to restore a saved position clamped to
/// <c>[work.Left - width + 120, work.Right - 120]</c> horizontally and
/// <c>[work.Top, work.Bottom - 80]</c> vertically — ranges wide enough to leave most of the
/// window off-screen (the creator's "below the fold"). Launch position is now derived:
/// upper-left of the work area plus padding.
/// </summary>
public class WindowPlacementTests
{
/// <summary>Three properties in one guard, because the third is the regression:
/// (1) the requested inset, (2) a window that nearly fills the screen is pulled back
/// so it stays <i>wholly</i> visible — padding must never win over visibility, or a
/// large window re-creates "below the fold" — and (3) a window too big to fit falls
/// back to the work-area origin rather than hanging off an edge. Work area is
/// 1920x1040 at the origin (a taskbar-less secondary-style rect) to prove the maths
/// does not quietly assume (0,0).</summary>
[Theory]
[InlineData(1280, 800, 75, 75)] // padding wins when it fits
[InlineData(1900, 1000, 20, 40)] // does not fit -> pulled back on-screen
[InlineData(2400, 1200, 0, 0)] // larger than the work area -> origin
public void TopLeftPlacement_PadsButNeverHangsOffScreen(int width, int height, double expX, double expY )
{
var work = new Rect(0, 0, 1920, 1040);
var place = MainWindow.TopLeftPlacement(work, width, height, 75);
Assert.Equal(expX, place.X);
Assert.Equal(expY, place.Y);
// Whatever the size, the window must not start beyond the work area when it fits.
if (width <= work.Width) Assert.True(place.X >= work.Left, $"left {place.X} hangs off");
if (height <= work.Height) Assert.True(place.Y >= work.Top, $"top {place.Y} hangs off");
}
/// <summary>Padding is applied to a work area that does not start at the origin — the
/// secondary-monitor case. A hardcoded <c>Left = 75</c> would drop the window off the
/// left edge of a monitor whose work area begins at a negative X.</summary>
[Fact]
public void TopLeftPlacement_OffsetsFromTheWorkAreaOrigin_NotFromZero()
{
var secondary = new Rect(-1920, -200, 1920, 1040);
var place = MainWindow.TopLeftPlacement(secondary, 1280, 800, 75);
Assert.Equal(-1845, place.X);
Assert.Equal(-125, place.Y);
}
}
+41 -6
View File
@@ -79,25 +79,60 @@ public class YouTubeStreamServiceTests
Assert.Null(handler.LastBody); // never reached the API
}
// TASK 5: a reusable stream, when given, binds at broadcast insert via
// contentDetails.boundStreamId — no separate bind round-trip. One-click
// go-live also completes the v3 flags (no monitor stream, low latency).
// TASK 5: one-click go-live completes the v3 flags (no monitor stream, low latency).
//
// ⛔ REWRITTEN 2026-10-04. This used to be
// `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert`, and it asserted
// `"boundStreamId":"S456"` in the insert body — i.e. it encoded, as a passing test,
// the exact assumption that cost a week of misdiagnosis. The assumption is now
// DISPROVEN: the insert is accepted, its sibling fields take effect, and the created
// broadcast still comes back `bound=NONE`, stuck in lifeCycleStatus `created`, never
// live, with close-out 403ing `invalidTransition`. A test that pins disproven
// behaviour is worse than no test — it launders a guess into a guarantee.
//
// So the guarantee is inverted: assert the insert does NOT claim to bind, and assert
// the real binding call targets the documented bind endpoint with both ids.
[Fact]
public async Task CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert()
public async Task CreateBroadcast_Does_Not_Claim_To_Bind_And_Omits_BoundStreamId()
{
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow, "S456");
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow);
Assert.Equal("BC123", id);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"boundStreamId\":\"S456\"", handler.LastBody);
// The trap: boundStreamId reads as a binding and is not one.
Assert.DoesNotContain("boundStreamId", handler.LastBody);
// The flags that DO take effect must still be sent.
Assert.Contains("\"enableMonitorStream\":false", handler.LastBody);
Assert.Contains("\"latencyPreference\":\"low\"", handler.LastBody);
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
}
/// <summary>The call that actually binds — the one whose absence is why nothing ever
/// went live. Google's documented shape is
/// `POST liveBroadcasts/bind?part=id&id=…&streamId=…` with NO request body (an empty
/// body would be rejected). The parameter is `id`, not `broadcastId` — a plausible
/// near-miss that returns 400 `idRequired`.</summary>
[Fact]
public async Task BindBroadcastAsync_Posts_To_Bind_Endpoint_With_Both_Ids_And_No_Body()
{
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123"}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var error = await service.BindBroadcastAsync("BC123", "S456");
Assert.Null(error);
var request = Assert.Single(handler.Requests);
Assert.StartsWith("POST ", request);
Assert.Contains("/liveBroadcasts/bind", request);
Assert.Contains("part=id", request);
Assert.Contains("id=BC123", request);
Assert.Contains("streamId=S456", request);
Assert.True(string.IsNullOrEmpty(handler.LastBody), "bind takes no request body");
}
// TASK 5 design decision 2: reuse the existing cdn.isReusable stream instead
// of creating a new one per broadcast.
[Fact]