fix(alerts): wire the frame-rate probe so alert-clip VIDEO paces to real time

The 12:35 live session proved the decoder healthy (frames=240 audioChunks=156
failed=False both clips, alert audio in the mix at peakMix 0.277→0.733) yet the
creator still saw 'no video plays / you lost the video'. Root cause: pacing,
not decode. AlertClipDecoderFor built the decoder with no frameRateProbe, so
RunAsync computed frameDuration = TimeSpan.Zero and RunVideoAsync's pace step
was dead code — all 240 frames of the 10s clip dumped through the pipe in the
first ~1-2s (130MB as fast as ffmpeg read), then the box froze on the LAST
frame while audio chunk-paced its real-time ~7.8s. Reads exactly like a dead
decoder on screen. The media path already wired this seam
(MainViewModel.cs:308); the alert factory never supplied one.

Derivative fix (media/decoder plumbing, cited in broad consensus of players):
pass FfmpegFrameRateProbe into the per-play alert decoder exactly like
MediaVideoSource does. Good Dog:
AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate —
real AlertClipDecoder via the fake-process/fake-probe/delay-recorder seam;
red on the old factory (zero pacing delays), green with the probe (one ~10ms
delay per frame at 100fps). Full suite 321/322, one known-env flake
(RealMouseDrag reorder). Docs in this commit: task-47 addendum,
MyMistakes.md (a decoder that drops data faster than wall-clock looks
identical to a dead one), HANDOFF rewrite.
This commit is contained in:
2026-09-26 12:45:40 -07:00
parent 80038ff152
commit aea0670723
5 changed files with 182 additions and 43 deletions
+52 -41
View File
@@ -1,20 +1,21 @@
# HANDOFF — 2026-09-26 (TASK 47 shipping crash + the "nothing in the box" void both fixed; 3 local commits, NOT pushed)
# HANDOFF — 2026-09-26 (TASK 47: crash + silent-void + video-pacing all fixed; 4 local commits, NOT pushed)
## Branch / Commit State
`main` — three local commits ahead of `origin/main` (= `ecb329e`), **NOT pushed** (awaiting the
`main` — four 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, 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.
(regression test red pre-fix / green post-fix). Full suite 320/320.
3. `80038ff` — **silent-decoder fallback + diagnostics** (no-frame grace 1.0s → six-animation;
BeginClip/RunAsync logging). Full suite 321 with the **known env flake** (RealMouseDrag
reorder failing while windows are up).
4. *(uncommitted next — this session)* — **video pacing fix** (below). Scope:
`ViewModels/MainViewModel.Chat.cs`, `ytLive.Tests/AlertClipDecoderTests.cs` (new), docs
(task-47 addendum, `MyMistakes.md`, this file). Test green; build 0 warnings;
`scope-check.sh` pending, then commit.
## ✅ Crash fixed (`7b940b6`)
@@ -26,41 +27,53 @@ UI-thread marshal (`ChatOverlayLayer` has one); it ran `RefreshAlertPreviews`
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.
## ⚠️ The "nothing in the alert box" void — diagnosed + fixed (this unit)
## ✅ "Nothing in the alert box" void — fixed (`80038ff`)
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`.)
After the crash fix: On-Air → test → **remove and re-add** the Stream Alerts layer → TEST event →
procs in chat but **nothing in the alert box**. startup.log 11:44 `peakMix 0.105` (no alert audio)
vs 11:21's 0.375–0.891. Every stage of the clip path was silent by design; a decoder whose `Start()`
succeeds but yields **no frames AND no audio** left `_clip != null` with `_latestClipFrame == null`
→ blank box forever. Fix: no-frame grace (1.0s) in `AlertOverlayLayer.Advance` → tear down + same
alert as six-animation; BeginClip/RunAsync diagnostics stop the silence. Good Dog:
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`.
**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.
## ✅ "No video plays" — decoder HEALTHY, video UNPACED — fixed (this unit)
Creator repro after `80038ff` (12:35 session): still "no video plays in the web-alert box", "like
you lost the video" — but the new diagnostics PROVED the decoder healthy: `Alert clip start: box …`
→ `Alert clip end: … frames=240 audioChunks=156 failed=False` twice, alert audio in the live mix
(`peakMix 0.277 → 0.733`, mic/loop 0.000). Root cause was **pacing, never decode**:
`AlertClipDecoderFor` (MainViewModel.Chat.cs:113) built the decoder with **no frame-rate probe** →
`RunAsync` computed `frameDuration = TimeSpan.Zero` → `RunVideoAsync`'s pace step
(`if (frameDuration > 0)`) was dead code → all 240 frames of the 10s clip dumped through the pipe
in the first ~1-2s (130MB as fast as ffmpeg read), then the box sat frozen on the LAST frame while
the audio pipeline paced 156 chunks at real-time ~7.8s. Reading exactly like "no video / you lost
it". The media path already had the seam wired (`MediaVideoSource` gets
`frameRateProbe: new FfmpegFrameRateProbe(…)`, MainViewModel.cs:308) — the alert factory just
never passed one.
**Fix (uncommitted):** `AlertClipDecoderFor` passes
`frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())` so
video paces at the clip's native ~24fps (10s real-time) in lockstep with audio. Good Dog test
`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate` drives the REAL
`AlertClipDecoder` through the media-style fake process + fake probe + recorded delay seam
(3 frames at 100fps → one ~10ms delay per frame; red on the old factory, green now). Full suite
321/322 (one known-env flake). `MyMistakes.md`: **a decoder that drops data faster than wall-clock
looks identical to a dead decoder on screen — "plays but you don't see it" is a PACING bug before
it's a decode bug.** Rules: never ship a pipe whose `frameDuration` can be zero; check pacing
(bytes/s vs wall-clock) before re-auditing the codec.
## 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 — 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".
- If the alert box is STILL "no video" after this fix + relaunch: check the 12:35-style
`Alert clip end: … frames=240` line (now logged) — a healthy decode with no visual means the
painter/render path, not the decoder; the creator's Live scene also has a WebSource
"Web Resource-0" bottom-right — confirm which element they watch when describing the
"web-alert / stream-alerts" box.
- The known pump stall (`render=full-render … totalMs=147`, ~6fps worst) is recorded as a
pre-existing perf item, NOT part of this unit.
@@ -75,17 +88,15 @@ is a promise — degrade on dead-air, and a `catch` hiding WHY output vanished i
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 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.
`./scripts/scope-check.sh "ViewModels/MainViewModel.Chat.cs" "ytLive.Tests/AlertClipDecoderTests.cs"`.
2. Push the four local commits at the creator's go / milestone signal.
3. Creator to relaunch and re-run the repro — with the probe wired the clip plays real-time
(~10s of motion, not a blur + frozen frame); the `Alert clip end:` line confirms decode health.
## Critical working rules (unchanged, still binding)