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
+158
View File
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Audio;
namespace ytLive.Tests;
/// <summary>
/// TASK 47 Good Dog: the video-backed alert unit — the SAME good-dog single test
/// pinned on the RealApp STA host, driven through a FAKE <see cref="IAlertClipDecoder"/>
/// (the repo never runs a real codec in tests). It pins the whole lifecycle in one
/// pass: the custom clip path wins per-source over the built-in (resolution
/// seam), the decoder is created at play + torn down at drain, the ~300ms fade-in/out
/// alpha ENVELOPE rides the frames, the audio forwards at volume × fade (unity-add,
/// never ducked elsewhere), the EOF freeze-frame fades the tail out, the ticker strip
/// renders and scrolls during play and vanishes when idle — and the alert box always
/// returns to the transparent/idle state. The six-animation fallback stays covered by
/// AlertLayerTests.
/// </summary>
[Collection("RealApp")]
public sealed class AlertLayerVideoTests
{
private readonly RealAppHost _app;
public AlertLayerVideoTests(RealAppHost app) => _app = app;
[Fact]
public void AlertVideo_PlaysCustomClip_FadesReadTime_TickerScrolls_ForwardsScaledAudio_DrainsIdle()
{
_app.Run(Run);
}
private void Run()
{
const string clipPath = @"C:\fake\thank-you.mp4";
var fake = new FakeAlertClipDecoder();
var forwarded = new List<AudioSample>();
string? resolvedPath = null;
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
Width = 600,
Height = 200,
AlertVideoPath = clipPath,
AlertUseDefaultVideo = false,
AlertShowTicker = true,
AlertVideoVolume = 0.8,
};
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
var layer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: (path, width, height) =>
{
resolvedPath = path;
Assert.Equal((int)box.Width, width);
Assert.Equal((int)box.Height, height);
return fake;
},
clipPathResolver: source =>
{
Assert.Same(box, source);
return source.AlertVideoPath;
},
audioSink: sample => forwarded.Add(sample));
layer.UpdateConfiguredBoxes(scenes);
var msg = new ChatMessage
{
Kind = ChatEventKind.SuperChat,
AuthorName = "Funder",
SuperChatDisplayString = "$10.00",
};
// ── Play: the custom path feeds a per-play decoder ──
layer.Enqueue(msg);
Assert.True(layer.IsPlaying);
Assert.Equal(1, fake.StartCount);
Assert.Equal(clipPath, resolvedPath);
fake.EmitFrame(600, 200);
// Audio inside the fade-in ramp (gain 0) must not leak into the mix.
fake.EmitAudio(new[] { 0.5f, -0.25f });
Assert.Empty(forwarded);
// ── Fade-in: alpha rides the 0.3s envelope measured against a real clip ──
Assert.Equal(0, AlphaAt(layer.RenderFrame(box)!)); // t=0
layer.Advance(0.15); // mid-ramp
Assert.Equal(127, AlphaAt(layer.RenderFrame(box)!));
fake.EmitAudio(new[] { 0.5f, -0.25f });
var midSample = Assert.Single(forwarded).Samples;
Assert.Equal(0.2, midSample[0], 2); // 0.8 × 0.5 mid-ramp
Assert.Equal(-0.1, midSample[1], 2);
forwarded.Clear();
layer.Advance(0.20); // past the ramp → opaque
Assert.Equal(255, AlphaAt(layer.RenderFrame(box)!));
fake.EmitAudio(new[] { 1.0f, 0.0f });
Assert.Equal(0.8, forwarded[^1].Samples[0], 2); // full fade: volume only
Assert.Equal(0.0, forwarded[^1].Samples[1], 2);
// ── Ticker: on during play, scrolls between frames ──
layer.Advance(1.0);
var tickerA = layer.AlertTickerFrame;
layer.Advance(0.1);
var tickerB = layer.AlertTickerFrame;
Assert.False(tickerA is null);
Assert.True(HasPixels(tickerA!), "the ticker must be visible while an alert plays");
Assert.False(tickerA!.BgraPixels.SequenceEqual(tickerB!.BgraPixels), "the ticker must scroll");
// ── EOF → freeze-frame fade-out over the same envelope, then drain ──
fake.End();
layer.Advance(0.15);
Assert.Equal(127, AlphaAt(layer.RenderFrame(box)!)); // halfway out
layer.Advance(0.20); // past the 0.3s tail
Assert.False(layer.IsPlaying);
Assert.Null(layer.RenderFrame(box));
Assert.Null(layer.AlertTickerFrame);
Assert.True(fake.DisposeCount == 1, "the per-play decoder must be torn down at drain");
}
private static byte AlphaAt(VideoFrame frame) => frame.BgraPixels[3];
private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0);
/// <summary>A decoder seam the test drives synchronously (no ffmpeg, no threads):
/// the layer calls Start/Stop/Dispose and the test feeds frames/audio/EOF.</summary>
private sealed class FakeAlertClipDecoder : IAlertClipDecoder
{
public int StartCount;
public int StopCount;
public int DisposeCount;
public event Action<VideoFrame>? FrameAvailable;
public event Action<AudioSample>? AudioReady;
public event Action? Completed;
public void Start() => StartCount++;
public void Stop() => StopCount++;
public void Dispose() => DisposeCount++;
public void EmitFrame(int width, int height)
{
var pixels = new byte[width * height * 4];
for (var i = 3; i < pixels.Length; i += 4)
pixels[i] = 255; // opaque straight-alpha BGRA, blue body
FrameAvailable?.Invoke(new VideoFrame(width, height, pixels));
}
public void EmitAudio(float[] samples) => AudioReady?.Invoke(new AudioSample(samples, 48000, 2));
public void End() => Completed?.Invoke();
}
}