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
+17 -4
View File
@@ -37,7 +37,8 @@ public sealed class SceneCompositor
/// front): live background (the scene's <c>IsBackground</c> source) → background image →
/// visible elements (z-order = <c>Elements</c> order, mirroring the XAML DataTemplate) →
/// branding flash → social bar (a global overlay; spans the master width at
/// <paramref name="socialBarTop"/>). Transparent regions read opaque black.
/// <paramref name="socialBarTop"/>) → stream-alert ticker (a global overlay pinned to
/// the very top, TASK 47). Transparent regions read opaque black.
/// </summary>
public VideoFrame Render(
Scene scene,
@@ -48,7 +49,8 @@ public sealed class SceneCompositor
int socialBarTop = 0,
VideoFrame? staticBase = null,
int split = -1,
byte[]? scratch = null)
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
{
if (scene == null) throw new ArgumentNullException(nameof(scene));
if (frameFor == null) throw new ArgumentNullException(nameof(frameFor));
@@ -137,6 +139,9 @@ public sealed class SceneCompositor
if (socialBarFrame != null && socialBarFrame.Width > 0 && socialBarFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, socialBarFrame, 0, socialBarTop);
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, 0, 0);
return StretchMath.BilinearScale(
new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}
@@ -212,7 +217,10 @@ public sealed class SceneCompositor
/// <summary>The compositor's dynamic/above-split path: start from an existing frame
/// buffer and composite the z-layers from <paramref name="split"/> onward on top.
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.</summary>
/// When <paramref name="baseFrame"/> is null the caller must fall back to full render.
/// The social bar never reaches here (it IS baked into the static base) but the alert
/// ticker must: it is dynamic by construction — only ever present while an alert plays —
/// so it overlays LAST, pinned to the very top (TASK 47).</summary>
public static VideoFrame CompositeLayers(
VideoFrame baseFrame,
Scene scene,
@@ -222,7 +230,8 @@ public sealed class SceneCompositor
VideoFrame? socialBarFrame = null,
int socialBarTop = 0,
VideoFrame? flashFrame = null,
byte[]? scratch = null)
byte[]? scratch = null,
VideoFrame? tickerFrame = null)
{
var cropW = options.SourceRectWidth;
var cropH = options.SourceRectHeight;
@@ -247,6 +256,10 @@ public sealed class SceneCompositor
if (frame == null) continue;
BlitBakedElement(buffer, cropW, cropH, options, element, frame);
}
if (tickerFrame != null && tickerFrame.Width > 0 && tickerFrame.Height > 0)
BlitOverlay(buffer, cropW, cropH, options, tickerFrame, 0, 0);
return StretchMath.BilinearScale(new VideoFrame(cropW, cropH, buffer), options.OutputWidth, options.OutputHeight);
}