feat(alerts): TASK 47 — alert box plays a video (built-in/custom clip) + read-time fade + message ticker

TASK 43's alert box grows a real video celebration. Per-alert IAlertClipDecoder
(ffmpeg bgra + f32le pipes, real-time paced, disposed at drain) plays the shipped
Assets/alert-default.mp4 (stamped into the Asset table at startup) unless the
creator picks their own file — path reference only, never stored in the DB; the
six AlertRenderer animations stay the fallback. ~0.3s fade rides the alpha
envelope on straight-source copies (EOF freeze-frames then fades out); audio
forwards to a new AudioMixer alert ring (8s, 48k stereo) drained at unity — no
duck, creator ruling — scaled by volume × fade. An auto-composed marquee ticker
('Funder — Super Chat · $10.00', 140px/s) scrolls top-of-frame via a
FramePump._alertTicker seam through Render/CompositeLayers, mixed into the cache
signature (dynamic overlay, never baked). New Stream Alerts section in LeftPanel.

Derivative-work references (how OBS/Streamlabs alert boxes do per-alert video):
- https://support.streamlabs.com/hc/en-us/articles/217741147-Setting-Up-Your-Streamlabs-Alerts (custom image/video per alert type + variations)
- https://obsproject.com/kb/stream-tutorial-2-alerts (alert overlay as an on-screen zone)
- https://streamlabs.com/content-hub/widgets/alert-box (per-event alert playback)

Good Dog: AlertLayerVideoTests drives a fake IAlertClipDecoder through the whole
lifecycle in one pass (custom path wins, decoder spawns/disposes, fade envelope
0→127→255, audio volume×fade, ticker scrolls, EOF fade-drain to idle). It caught
the clip branch of Advance not clearing _current before AdvanceToNext — the layer
stayed IsPlaying after drain (MyMistakes post-mortem).

Full vstest 319/319; clean build 0 warnings; scope check green.
This commit is contained in:
2026-09-26 11:15:17 -07:00
parent 2c9af9426b
commit a11b15e444
23 changed files with 1399 additions and 87 deletions
+45 -3
View File
@@ -808,10 +808,52 @@ identity (`RuntimeHelpers.GetHashCode(element)`). Two test-setup habits silently
that asserts per-frame content or cache behavior must pass `scene: () => scene` with one Scene
instance; the fresh-scene default is only for pacing/diagnostic tests.**
2. **A call-count resolver flip (`flip++ % 2`) double-advances under a cache-aware pump** — the
tick resolves TWICE (signature pass + compositor pass). Key per-tick content on the burned
`OutputIndex` (stable until submit, which happens AFTER render) or on `encoder.Frames.Count`
instead. A stable-per-tick token, not a call counter, is the deterministic alternation.
tick resolves TWICE (signature pass + compositor pass). Key per-tick content on the burned
`OutputIndex` (stable until submit, which happens AFTER render) or on `encoder.Frames.Count`
instead. A stable-per-tick token, not a call counter, is the deterministic alternation.
3. New frames that must invalidate the cache need BOTH a fresh array AND a fresh Epoch (array
identity alone is unchanged for a mutated-in-place array; `Epoch` is the monotonic generation
marker the compositor's paste key and the C4 signature share).
---
### ALERT-CLIP DECODE PIPE (RECIPE — TASK 47, 2026-09-26) — read a per-play mp4 into loose frames + stereo 48k audio
Worked out for the alert-box video: a short mp4 played **per alert** (one-shot) and dropped at
drain, NOT the refcounted media-source manager. Recipe:
- **Two ffmpeg pipe outputs, one child:** `ffmpeg -loglevel error -i "{file}" -f rawvideo -pix_fmt
bgra -vf scale={W}:{H} - -f f32le -ac 2 -ar 48000 -` — `-loglevel error` keeps stderr quiet (the
other pipes' ffmpeg rivals read the whole FD so a chatty stderr deadlocks); do **not** drain
stderr, mirror the `MediaVideoSource` split. Video pipe read backs onto `RawVideoFrameReader`
(TASK 21) which already pace-throttles to rtc-frame-rate… earlier live pacing is VFR-driven.
- **Audio = a carry-buffer loop, never a per-read slice.** PCM16 bytes → float32 chunks changes the
per-frame byte count; the naive `ReadFullyAsync(count)` per chunk **drops samples that straddle a
pipe read boundary** (the first draft's bug). The fix: an in-loop buffer that carries leftovers —
read `count - pending` bytes, emit whole float chunks from `pending + fresh`, keep the remainder.
Pacer: 50ms audio chunks → `Thread.Sleep` the (chunk length − read wall time) remainder.
- **bgra from ffmpeg is OPAQUE** (alpha=255). Whoever fades must copy the frame and scale alpha
across the copy (straight-alpha); the decoder never touches alpha. That keeps the decoder pure
and the fade a layer concern.
- **Fake-decoder convention:** the integration test implements `IAlertClipDecoder` inline with
`EmitFrame/EmitAudio/End` helpers — Start/Stop/Dispose are recorded as int counters, frames and
audio are pushed from the test thread, EOF is a test-raised event. No ffmpeg, no threads, still
the full real-time path through the layer.
- Re-grep here before building a second "play a file into the mix" path — snippet above is the
whole non-trivial part.
### A component's drain must reset its OWN anchor state (TASK 47 post-mortem — the test caught what a no-frame path hid)
`AlertOverlayLayer.Advance` gained a clip branch for video (TASK 47). In the ANIMATION branch the
old code did `_current = null; _elapsed = 0;` before `AdvanceToNext()`; the new clip branch called
only `StopClip(); AdvanceToNext();` — so after the EOF fade-out the layer kept `_current` pointing
at the finished message: `IsPlaying` stayed true, `RenderFrame` re-rendered the alert through the
animation fallback, and the layer never returned to idle. The six-animation tests couldn't see it
(they only ever exercise the animation branch); the video test's explicit
`Assert.False(layer.IsPlaying)` after drain nailed it on the first run. Rule: **when a second
execution path is added to a state machine, the drain/precondition contract of the ORIGINAL path is
part of the spec** — assert the component's end-state on the new path, don't assume the old path's
cleanup. The forest for the trip: the fallback path (animation) MASKED the broken path because its
output still looked like "an alert rendering", exactly the flick I'd have shipped if the test
hadn't forced the null-frame idle state.