Compare commits
21
Commits
048cab79f8
...
6b6284d05c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b6284d05c | ||
|
|
3901cb8298 | ||
|
|
623d77e531 | ||
|
|
dc1343896d | ||
|
|
77d5120c87 | ||
|
|
08290ece16 | ||
|
|
bdea18e12c | ||
|
|
0464593a02 | ||
|
|
cea641d429 | ||
|
|
25d5416a93 | ||
|
|
81da58ab9c | ||
|
|
a8e3954f01 | ||
|
|
9d04aea55b | ||
|
|
55ca1029a3 | ||
|
|
b2f219739d | ||
|
|
509ef36aa0 | ||
|
|
07a088a23f | ||
|
|
8fbc3701d2 | ||
|
|
0fb2f9c6c3 | ||
|
|
74da99c988 | ||
|
|
5ad0106502 |
+224
-26
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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.)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user