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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user