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