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:
@@ -1616,3 +1616,31 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
|
||||
**never enqueue** — per the creator ruling, a subscriber mention is sufficient; there is
|
||||
no free-sub alert (YouTube emits none) and no viewer count (vetoed — demotivating,
|
||||
weaponizable).
|
||||
- **Alert box VIDEO (TASK 47, 2026-09-26):** since TASK 47 the box plays a clip on every
|
||||
alert instead of (only) an animation — `Enqueue`→`BeginClip` spawns a **per-play**
|
||||
`IAlertClipDecoder` (ffmpeg `rawvideo bgra -vf scale=W:H` frame pipe + `f32le` audio
|
||||
pipe, both real-time paced via `RawVideoFrameReader`; **not** the refcounted
|
||||
`MediaVideoSourceManager` — alerts are one-shot plays that must be disposed at drain).
|
||||
Custom clip wins per-source over the built-in ONLY when `AlertUseDefaultVideo==false`
|
||||
AND the file exists; the shipped `Assets/alert-default.mp4` is stamped into the `Asset`
|
||||
table at startup (`AlertDefaultVideoKey` setting; the prune keeps it via UNION) and
|
||||
read-at-play; any decode failure falls back to the six `AlertRenderer` animations
|
||||
(deliberate, not an error). Fades in/out via **`FadeDurationSeconds = 0.30`**
|
||||
(`AlertOverlayLayer` const): fade-in by `elapsed` on straight-alpha frame copies;
|
||||
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
|
||||
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
|
||||
forwards to the mixer sink scaled by `volume × fade`.
|
||||
- **Alert ticker:** `AlertTickerFrame` (in the layer) composes "Author — Kind · amount"
|
||||
and hands it to `Services/Compositor/AlertTickerRenderer.cs` — a 1920×48 marquee strip
|
||||
rasterized **on the UI thread once per text** (cached, unpremultiplied straight-alpha
|
||||
pill) then scrolled by pure byte-math per call (`140px/s`, repeat-gap wrap), so the
|
||||
pump calls it from its own thread with no locking. **It is a dynamic overlay, NEVER
|
||||
baked** (the social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
|
||||
`CompositeLayers` take a trailing `tickerFrame` (blitted last at 0,0) threaded from
|
||||
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
|
||||
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
|
||||
changes the cache key. Toggled by `AlertShowTicker`.
|
||||
- **Alert audio under the master limiter:** `AudioMixer.EnqueueAlertAudio` writes a
|
||||
dedicated 8s stereo-48k ring (`AlertBufferSeconds`), drained in `FillAndMix` before the
|
||||
limiter and added at **unity — never ducked** (creator ruling: "don't lower my game
|
||||
audio for a tip"); `StartLive` clears it. Per-alert loudness = the Volume slider.
|
||||
|
||||
Reference in New Issue
Block a user