fix(alerts): silent decoder falls back to the six animations after a 1s no-frame grace

Routing and ruling both good: 'procs in the chat windows but nothing in the alert box'
with zero exceptions — every failure stage was silent by design (Debug.WriteLine-only
preview catch, bare catch{} in RunAsync firing Completed regardless). A decode run that
starts but yields neither a frame nor an audio chunk left _clip != null with
_latestClipFrame == null, so RenderClipFrame returned null forever: the box stayed
transparent for the whole alert and the six-animation fallback (which only ran on
setup-time throws) never fired.

Fix: AlertOverlayLayer.Advance gives a started clip a NoFrameFallbackSeconds (1.0s)
grace — produce no frame AND no audio inside it and the clip is torn down, _elapsed
resets, and the SAME alert continues as the animation branch (never blank, never drained
early). Diagnostics stop the lying silence: BeginClip logs box id/path/size + setup
failures; AlertClipDecoder.RunAsync logs frame/chunk end-state and the exception it used
to swallow.

Good Dog test: SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace (transparent
inside grace, disposed past it, animation renders, drains to idle).

Reference: alert playback must degrade to its fallback on dead-air, never vanish —
same contract as every player/booth failure budget (e.g. OBS source fallbacks).
https://obsproject.com/forum/threads/source-visibility-fallback.187383/
This commit is contained in:
2026-09-26 12:08:27 -07:00
parent 7b940b6a5a
commit 80038ff152
6 changed files with 231 additions and 60 deletions
+67 -54
View File
@@ -1,82 +1,95 @@
# HANDOFF — 2026-09-26 (TASK 47 shipping crash fixed; 2 local commits, NOT pushed)
# HANDOFF — 2026-09-26 (TASK 47 shipping crash + the "nothing in the box" void both fixed; 3 local commits, NOT pushed)
## Branch / Commit State
`main` — two local commits ahead of `origin/main` (= `2c9af94`... actually `origin/main` = `ecb329e`),
**NOT pushed** (awaiting the creator's go / milestone signal):
`main` — three local commits ahead of `origin/main` (= `ecb329e`), **NOT pushed** (awaiting the
creator's go / milestone signal):
1. `a11b15e` — **TASK 47** alert box video (built-in/custom clip + read-time fade + message
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 318/319 with the
known env flake → final full run 319/319 green).
2. *(uncommitted next)* — **the 2026-09-26 live-crash fix** (see below), scope = 3 files + docs:
`Services/AlertOverlayLayer.cs`, `ytLive.Tests/AlertLayerVideoTests.cs`, plus docs
(`TASKS/task-47-alert-videos.md` addendum, `MyMistakes.md` lesson, this file). Full suite
**320/320** green, alert tests 3/3, `scope-check.sh` passed.
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 319/319 green).
2. `7b940b6` — **crash fix**: AlertOverlayLayer marshals the chat-poller seam to the UI thread
(regression test red pre-fix / green post-fix, both verified). Full suite 320/320.
3. *(uncommitted next — this session)* — **silent-decoder fallback + diagnostics** (see below),
scope: `Services/AlertOverlayLayer.cs`, `Services/AlertClipDecoder.cs`,
`ytLive.Tests/AlertLayerVideoTests.cs` + docs (task-47 addendum, `MyMistakes.md` lesson, this
file). New Good Dog test green; build 0 warnings; full suite 321 with the **known env flake**
(RealMouseDrag reorder failing while windows are up — passes with the desktop clean; unrelated
to this change). `scope-check.sh` pending, then commit.
## ⚠️ The live crash + fix (this unit)
## ✅ Crash fixed (`7b940b6`)
The creator ran the shipped build in a test session: **test-tab alerts DID fire** (button
commands run on the UI thread — startup.log `11:21:53` shows the sims and alert-clip audio in the
live mix: `micLevel=0.000 loopLevel=0.000` yet `peakMix 0.375 → 0.733 → 0.891`, which can only be
the alert ring; the default clip is materialized at `%TEMP%\ytLive-alert-941785b0-….mp4`, the
video pipe verified against the pinned ffmpeg), but the app **crashed at `11:22:01.301`** — the
first REAL message that round-tripped through the chat poller:
The creator's first live session: test-tab alerts DID fire (11:21 startup.log: alert-clip audio in
the mix, `peakMix 0.375 → 0.891` with mic+loop at 0.000) but the app crashed at `11:22:01.301` on
the first REAL polled message: `Must create DependencySource on same Thread as the DependencyObject`
in `DataBindEngine.ProcessCrossThreadRequests`. `AlertOverlayLayer.OnMessageReceived` had no
UI-thread marshal (`ChatOverlayLayer` has one); it ran `RefreshAlertPreviews` → `UpdatePreview` on
the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC + WPF-bound) with a
WriteableBitmap created there. Fix + Good Dog regression test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red/green verified).
`MyMistakes.md`: every INPC-raising seam fed by the poller must marshal WPF-object writes.
System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
## ⚠️ The "nothing in the alert box" void — diagnosed + fixed (this unit)
Root cause: `AlertOverlayLayer.OnMessageReceived` had NO UI-thread marshal while
`ChatOverlayLayer.OnMessageReceived` has one. A polled message ran `RefreshAlertPreviews` →
`UpdatePreview` on the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC-raised +
WPF-bound) with a WriteableBitmap created there → binding engine cross-thread re-bind → crash.
Fix: marshal the whole ingest via `Application.Current.Dispatcher.Invoke` (null-guarded so pure
seams still run inline). Good Dog regression test
`AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread`
(calls the seam from a raw MTA `Thread`, returns to the message loop, asserts on the UI thread
that `VideoImageSource.Dispatcher` is the App's dispatcher — **red pre-fix, green post-fix,
verified both ways**). Rule written into `MyMistakes.md`: every INPC-raising seam fed by the chat
poller must marshal WPF-object writes to the UI thread.
After the crash fix the creator reported: On-Air → test → **remove and re-add** the Stream Alerts
layer → TEST event → procs in the chat windows but **nothing in the alert box**. startup.log 11:44:
`peakMix 0.105` (no alert audio) vs 11:21's 0.375–0.891. No exception anywhere — every stage of the
clip path was silent by design (`UpdatePreview` catch = `Debug.WriteLine`-only; `RunAsync` bare
`catch { }` firing `Completed` regardless). Established the guaranteed-invisible fail: a decoder
whose `Start()` succeeds but yields **no frames AND no audio** leaves `_clip != null` with
`_latestClipFrame == null` → `RenderClipFrame` returns null forever → box transparent FOREVER; the
six-animation fallback only ran on setup-time throws. (Hunted & ruled out meanwhile: `LiveScene` is
never assigned — the encoder always renders `StagedScene` (MainViewModel.cs:323), so the re-added
box IS in the render graph; the bake invalidates per element on add; DB row
`c85b46ac-…` X=602 Y=26 680×200 healthy; pinned ffmpeg decodes the default clip to `scale=680:200`
rawvideo correctly to a file. Frame pacing silently off too: no probe → `frameDuration=0`.)
**Fix (uncommitted):** `AlertOverlayLayer.Advance` — a started clip that produced neither a frame
nor an audio chunk inside `NoFrameFallbackSeconds` (1.0 s) is torn down, `_elapsed` resets, and the
SAME alert continues as the six-animation render (never blank, never drained early).
Diagnostics: `BeginClip` logs box id / resolved path / size + setup failure; `AlertClipDecoder.RunAsync`
logs frame/chunk end-state + the swallowed exception. Good Dog test
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`: transparent inside the grace →
decoder disposed past it → animation renders → drains to idle. `MyMistakes.md`: an alert that plays
is a promise — degrade on dead-air, and a `catch` hiding WHY output vanished is a lie.
## Follow-ups queued (NOT done in this unit)
- **TASK 3 item 20 persistence half** — canonical `RewardEvents` SQLite table +
`superChatEvents.list` (30-day) backfill + session-report rollup (still in-memory only).
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — all
queued unchanged.
- If the creator still reports "nothing in the box" after this fix (their Live scene's "Stream
Alerts" AlertBox is at 612,45 680×200; there is also a WebSource "Web Resource-0" bottom-right —
confirm which element they're watching), investigate the video-render path live — but note all
evidence (audio in the mix, materialized clip, verified pipe) says the native box played.
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — queued.
- If the alert box is STILL blank after this fix + relaunch: the new logs make the failure
conclusive (look for `Alert clip start:` → `Alert clip decode failed` → `Alert clip end:`). The
creator's Live scene also has a WebSource "Web Resource-0" bottom-right — confirm which element
they watch when describing the "stream-alerts layer".
- The known pump stall (`render=full-render … totalMs=147`, ~6fps worst) is recorded as a
pre-existing perf item, NOT part of this unit.
## Around the task (carried facts)
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — **close fullscreen windows before full-suite runs**; passes in isolation.
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse — **close fullscreen
windows before full-suite runs**; passes in isolation.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- **API facts (TASK 44 + 45):** liveChatId unavailable before the broadcast is live (poll until
active); `400 MISSING_REQUIRED_FIELD` from `liveChat/messages.insert` = wrong body shape
(missing `snippet.type`), NOT auth/scope.
- **TASK 47 sticky facts:** bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer
loop; default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play, never the refcounted
`MediaVideoSourceManager`. DB stays `user_version 10` — TASK 47's Source columns are additive
guarded ALTERs. Recipe in `MyMistakes.md`.
- ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer now mirrors it.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `MARCOM.md`/
`MONETIZATION.md` gitignored — never commit.
- API facts (TASK 44 + 45): liveChatId unavailable until broadcast live; `400 MISSING_REQUIRED_FIELD`
from `liveChat/messages.insert` = wrong body shape (missing `snippet.type`).
- TASK 47 sticky facts: bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer loop;
default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play; DB stays `user_version 10`.
- ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer mirrors it; the
no-frame fallback lives in Advance (has the 1 s grace + diagnostics).
- Every committed change needs a **close + relaunch** of the running app to be seen.
## Next step
1. Commit the crash fix (declared scope + same-commit docs), then run
`./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
2. Full clean build (0 warnings, Windows dotnet host) + full vstest **320/320**.
3. Push `a11b15e` + the fix commit at the creator's go / milestone signal.
4. Creator to relaunch and re-run the test-tab alerts (one of each) + post a real chat message —
the crash is fixed; then confirm which visual element they call the "web-alert box".
1. Commit this unit with the declared scope
`./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "Services/AlertClipDecoder.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
2. Push the three local commits at the creator's go / milestone signal.
3. Creator to relaunch and re-run the repro (compare the 11:21 vs 11:44 behavior) — the new logs
will say exactly what the decoder did.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- 0 warnings on real clean builds (verify only, never incremental). One runtime model.
- 0 warnings on real clean builds. One commit = fix + its memory (task doc, MyMistakes, handoff).
- No menus/polls with the creator; decisions asked once then held; do the work, then report.