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:
@@ -29,6 +29,7 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Action<string>? _log;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly Func<VideoFrame?>? _alertTicker;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = new();
|
||||
@@ -129,6 +130,7 @@ public sealed class FramePump : IDisposable
|
||||
Action<string>? log = null,
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
Func<VideoFrame?>? alertTicker = null,
|
||||
TransitionService? transition = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
@@ -140,6 +142,7 @@ public sealed class FramePump : IDisposable
|
||||
_log = log;
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_alertTicker = alertTicker;
|
||||
_transition = transition;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
@@ -405,6 +408,11 @@ public sealed class FramePump : IDisposable
|
||||
: compositorOptions.SourceRectHeight - barFrame.Height;
|
||||
}
|
||||
|
||||
// The alert ticker is a per-frame overlay (it scrolls while an alert
|
||||
// plays), NEVER baked into the static base. Lawn-mower seam: read it
|
||||
// each tick and let the compositor pin it to the very top.
|
||||
var tickerFrame = _alertTicker?.Invoke();
|
||||
|
||||
VideoFrame frame;
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
@@ -415,7 +423,7 @@ public sealed class FramePump : IDisposable
|
||||
// pooling change because its buffer's only consumer was its own release.
|
||||
renderSw.Restart();
|
||||
var scratch = AcquireScratch(scratchSize);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, scratch, TimedResolver);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, scratch, TimedResolver);
|
||||
if (_transition is { Active: true } transition)
|
||||
{
|
||||
frame = transition.BlendFrame(frame);
|
||||
@@ -555,6 +563,7 @@ public sealed class FramePump : IDisposable
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
byte[]? scratch = null,
|
||||
Func<SceneElement, VideoFrame?>? resolver = null)
|
||||
{
|
||||
@@ -562,7 +571,7 @@ public sealed class FramePump : IDisposable
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, scratch, resolver);
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, scratch, resolver);
|
||||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
@@ -586,14 +595,14 @@ public sealed class FramePump : IDisposable
|
||||
if (baseFrame != null)
|
||||
{
|
||||
var frame = SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, tickerFrame: tickerFrame, scratch: scratch);
|
||||
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, scratch, resolver);
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, scratch, resolver);
|
||||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||||
return frame2;
|
||||
}
|
||||
@@ -605,6 +614,7 @@ public sealed class FramePump : IDisposable
|
||||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||||
private VideoFrame RenderFull(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
@@ -615,7 +625,7 @@ public sealed class FramePump : IDisposable
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, resolver)
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, tickerFrame, resolver)
|
||||
: 0UL;
|
||||
|
||||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||||
@@ -626,7 +636,7 @@ public sealed class FramePump : IDisposable
|
||||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||||
}
|
||||
|
||||
var rendered = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
var rendered = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
|
||||
CacheRenders++;
|
||||
if (useCache)
|
||||
{
|
||||
@@ -659,6 +669,7 @@ public sealed class FramePump : IDisposable
|
||||
/// never trusted to key recycled content.</summary>
|
||||
private static ulong BuildFullRenderSignature(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
VideoFrame? tickerFrame,
|
||||
Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||||
@@ -671,6 +682,8 @@ public sealed class FramePump : IDisposable
|
||||
h = Mix(h, (ulong)socialBarTop);
|
||||
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
if (tickerFrame != null) h = MixFrame(h, tickerFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
|
||||
var backdropResolved = false;
|
||||
foreach (var element in scene.Elements)
|
||||
|
||||
Reference in New Issue
Block a user