a11b15e444
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.
434 lines
15 KiB
C#
434 lines
15 KiB
C#
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive.Models;
|
|
using ytLive.Services.Audio;
|
|
using ytLive.Services.Compositor;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
/// <summary>
|
|
/// The native alert box (OBS-style celebration zone, no third party). Owns the
|
|
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
|
|
/// the chat feed are enqueued one-at-a-time and played through a celebration
|
|
/// animation (<see cref="AlertRenderer"/>) OR — when the alert-box video is
|
|
/// configured and resolves — through a decoded clip. Frames are served to the
|
|
/// stream compositor via <see cref="RenderFrame"/> and mirrored to the preview
|
|
/// via <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
|
|
/// advanced by a 33ms dispatch ticker. The clock is exposed as
|
|
/// <see cref="Advance"/> so tests drive playback deterministically without the
|
|
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
|
|
/// a subscriber mention is a chat row, not an alert.
|
|
///
|
|
/// TASK 47 (Stream Alerts video): a video-backed alert replaces the animation
|
|
/// while a clip decodes (<see cref="IAlertClipDecoder"/>, one per play). The
|
|
/// clip plays at its own media pace; the layer applies a ~300ms fade-in/out
|
|
/// alpha envelope to the frames AND the audio samples (freeze-frame fade-out on
|
|
/// the tail), clips the alert to the clip length, and forwards the scaled audio
|
|
/// to the mixer's alert ring via <paramref name="audioSink"/> (no duck). The
|
|
/// per-source config (path/default/ticker/volume) is resolved through
|
|
/// <paramref name="clipPathResolver"/>; the ticker strip is served via
|
|
/// <see cref="AlertTickerFrame"/>.
|
|
/// </summary>
|
|
public sealed class AlertOverlayLayer : IDisposable
|
|
{
|
|
/// <summary>The fade-in/out ramp length (video alpha AND audio gain). Measured
|
|
/// against a real clip in the AlertLayerVideoTests unit; keep the ramp short
|
|
/// enough to read as a "pop-in" on stream (see ai.md).</summary>
|
|
internal const double FadeDurationSeconds = 0.30;
|
|
|
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
|
|
|
|
private readonly AlertRenderer _renderer;
|
|
private readonly Func<string, int, int, IAlertClipDecoder>? _clipDecoderFactory;
|
|
private readonly Func<Source, string?>? _clipPathResolver;
|
|
private readonly Action<AudioSample>? _audioSink;
|
|
private readonly Queue<ChatMessage> _queue = new();
|
|
private readonly DispatcherTimer? _ticker;
|
|
|
|
private ChatMessage? _current;
|
|
private Source? _currentBox;
|
|
private IAlertClipDecoder? _clip;
|
|
private VideoFrame? _latestClipFrame;
|
|
private bool _clipEof;
|
|
private double _clipEofElapsed;
|
|
private int _clipWidth;
|
|
private int _clipHeight;
|
|
private string? _tickerText;
|
|
private double _elapsed;
|
|
private IEnumerable<Scene>? _scenes;
|
|
|
|
private int _contentVersion;
|
|
private int _frameVersion = -1;
|
|
private string? _frameKey;
|
|
private VideoFrame? _cachedFrame;
|
|
|
|
public AlertOverlayLayer(
|
|
AlertRenderer renderer,
|
|
Func<string, int, int, IAlertClipDecoder>? clipDecoderFactory = null,
|
|
Func<Source, string?>? clipPathResolver = null,
|
|
Action<AudioSample>? audioSink = null)
|
|
{
|
|
_renderer = renderer;
|
|
_clipDecoderFactory = clipDecoderFactory;
|
|
_clipPathResolver = clipPathResolver;
|
|
_audioSink = audioSink;
|
|
if (Application.Current != null)
|
|
{
|
|
_ticker = new DispatcherTimer { Interval = TickInterval };
|
|
_ticker.Tick += (_, _) =>
|
|
{
|
|
Advance(TickInterval.TotalSeconds);
|
|
RefreshAlertPreviews();
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>Production entry from the chat feed: enqueue one event. The queue
|
|
/// lives on the UI thread (the poller marshals here), so the ticker's own
|
|
/// thread expectations hold for preview writes.</summary>
|
|
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
|
{
|
|
_scenes = scenes;
|
|
Enqueue(message);
|
|
_ticker?.Start();
|
|
RefreshAlertPreviews();
|
|
}
|
|
|
|
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
|
|
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
|
|
public void Enqueue(ChatMessage message)
|
|
{
|
|
if (message.Kind == ChatEventKind.None) return;
|
|
if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback
|
|
_queue.Enqueue(message);
|
|
if (_current == null)
|
|
{
|
|
_current = _queue.Dequeue();
|
|
_elapsed = 0;
|
|
BeginClip();
|
|
}
|
|
_contentVersion++;
|
|
}
|
|
|
|
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
|
|
/// tests call this directly and never start the timer). Draining the queue to
|
|
/// nothing stops the ticker and leaves the box transparent.</summary>
|
|
public void Advance(double deltaSeconds)
|
|
{
|
|
// Video-backed alert: duration is clip-driven. The clip plays at media
|
|
// pace; once it EOFs the last frame sweeps through the fade-out before
|
|
// the alert moves on (a hard cut at EOF would pop, not fade).
|
|
if (_clip != null)
|
|
{
|
|
_elapsed += deltaSeconds;
|
|
if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds)
|
|
{
|
|
_clipEof = false;
|
|
StopClip();
|
|
_current = null;
|
|
_elapsed = 0;
|
|
AdvanceToNext();
|
|
}
|
|
_contentVersion++;
|
|
return;
|
|
}
|
|
|
|
if (_current == null)
|
|
{
|
|
if (_queue.Count == 0) return;
|
|
_current = _queue.Dequeue();
|
|
_elapsed = 0;
|
|
BeginClip();
|
|
_contentVersion++;
|
|
return;
|
|
}
|
|
|
|
_elapsed += deltaSeconds;
|
|
if (_elapsed >= DurationOf(_current))
|
|
{
|
|
_current = null;
|
|
_elapsed = 0;
|
|
AdvanceToNext();
|
|
}
|
|
_contentVersion++;
|
|
}
|
|
|
|
/// <summary>Whether an alert is currently playing or queued.</summary>
|
|
public bool IsPlaying => _current != null || _queue.Count > 0;
|
|
|
|
/// <summary>The live-output frame for one AlertBox element: the decoded clip
|
|
/// frame (faded) while a clip is active, else the animation frame, null
|
|
/// (transparent) when idle.</summary>
|
|
public VideoFrame? RenderFrame(Source alertBox)
|
|
{
|
|
if (_current == null)
|
|
{
|
|
_frameKey = null;
|
|
return null;
|
|
}
|
|
|
|
if (_clip != null)
|
|
return RenderClipFrame(alertBox);
|
|
|
|
var width = (int)Math.Max(1, alertBox.Width);
|
|
var height = (int)Math.Max(1, alertBox.Height);
|
|
var key = $"{width}x{height}|{_current.Id}|{Progress()}";
|
|
|
|
if (_frameKey == key && _frameVersion == _contentVersion)
|
|
return _cachedFrame;
|
|
|
|
var app = Application.Current;
|
|
if (app != null && !app.Dispatcher.CheckAccess())
|
|
{
|
|
var capturedKey = key;
|
|
var capturedVersion = _contentVersion;
|
|
return app.Dispatcher.Invoke(() =>
|
|
{
|
|
if (_frameKey == capturedKey && _frameVersion == capturedVersion)
|
|
return _cachedFrame;
|
|
return RenderCore(alertBox, width, height, capturedKey, capturedVersion);
|
|
});
|
|
}
|
|
|
|
return RenderCore(alertBox, width, height, key, _contentVersion);
|
|
}
|
|
|
|
/// <summary>The global ticker strip for the stream output (top edge, full
|
|
/// master width): a marquee of "viewer — event" while an alert is playing
|
|
/// and the config has the ticker on, else null. Pure byte-math per call so
|
|
/// the frame pump can read it on any thread (the text strip is rasterized
|
|
/// once on the UI thread and cached).</summary>
|
|
public VideoFrame? AlertTickerFrame
|
|
{
|
|
get
|
|
{
|
|
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
|
|
return AlertTickerRenderer.Render(_tickerText!, _elapsed);
|
|
}
|
|
}
|
|
|
|
private VideoFrame? RenderClipFrame(Source alertBox)
|
|
{
|
|
var frame = _latestClipFrame;
|
|
if (frame == null) return null;
|
|
|
|
var w = (int)Math.Max(1, alertBox.Width);
|
|
var h = (int)Math.Max(1, alertBox.Height);
|
|
if (frame.Width != w || frame.Height != h)
|
|
frame = StretchMath.BilinearScale(frame, w, h);
|
|
|
|
var fade = FadeAt();
|
|
if (fade >= 1.0) return frame;
|
|
|
|
// Straight-alpha source-over on the compositor side: scale the source
|
|
// alpha by the envelope so the clip blends out (a 680x200 copy per fade
|
|
// tick is trivial; outside the ramps fade==1 returns the raw frame).
|
|
var copy = new byte[frame.BgraPixels.Length];
|
|
Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length);
|
|
var a = (byte)(fade * 255 + 0.5);
|
|
for (var i = 3; i < copy.Length; i += 4)
|
|
copy[i] = (byte)((copy[i] * a) >> 8);
|
|
return new VideoFrame(frame.Width, frame.Height, copy);
|
|
}
|
|
|
|
private double FadeAt()
|
|
{
|
|
if (_clip == null) return 1.0;
|
|
if (_clipEof)
|
|
return Math.Clamp(1.0 - (_elapsed - _clipEofElapsed) / FadeDurationSeconds, 0.0, 1.0);
|
|
return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0);
|
|
}
|
|
|
|
private void BeginClip()
|
|
{
|
|
StopClip();
|
|
_tickerText = null;
|
|
if (_current == null) return;
|
|
|
|
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
|
|
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
|
|
_currentBox = box;
|
|
if (box != null && box.AlertShowTicker)
|
|
_tickerText = ComposeTicker(_current);
|
|
|
|
if (box == null || _clipDecoderFactory == null) return;
|
|
var path = _clipPathResolver?.Invoke(box);
|
|
if (string.IsNullOrWhiteSpace(path)) return;
|
|
|
|
_clipWidth = (int)Math.Max(1, box.Width);
|
|
_clipHeight = (int)Math.Max(1, box.Height);
|
|
try
|
|
{
|
|
_clip = _clipDecoderFactory(path, _clipWidth, _clipHeight);
|
|
_clip.FrameAvailable += OnClipFrame;
|
|
_clip.AudioReady += OnClipAudio;
|
|
_clip.Completed += OnClipCompleted;
|
|
_clip.Start();
|
|
}
|
|
catch
|
|
{
|
|
StopClip(); // decode setup failed (bad path) — fall back to animations
|
|
}
|
|
}
|
|
|
|
private void StopClip()
|
|
{
|
|
var clip = _clip;
|
|
_clip = null;
|
|
_latestClipFrame = null;
|
|
_clipEof = false;
|
|
if (clip == null) return;
|
|
clip.FrameAvailable -= OnClipFrame;
|
|
clip.AudioReady -= OnClipAudio;
|
|
clip.Completed -= OnClipCompleted;
|
|
try { clip.Stop(); } catch { /* already stopped */ }
|
|
clip.Dispose();
|
|
}
|
|
|
|
private void OnClipFrame(VideoFrame frame) => _latestClipFrame = frame;
|
|
|
|
private void OnClipCompleted()
|
|
{
|
|
_clipEof = true;
|
|
_clipEofElapsed = _elapsed;
|
|
}
|
|
|
|
private void OnClipAudio(AudioSample sample)
|
|
{
|
|
if (_audioSink == null) return;
|
|
var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0);
|
|
if (volume <= 0f) return;
|
|
var fade = FadeAt();
|
|
if (fade <= 0.0) return;
|
|
|
|
var data = sample.Samples;
|
|
if (fade < 1.0 || Math.Abs(volume - 1f) > 0.001f)
|
|
{
|
|
data = new float[data.Length];
|
|
var gain = volume * (float)fade;
|
|
for (var i = 0; i < sample.Samples.Length; i++)
|
|
data[i] = sample.Samples[i] * gain;
|
|
}
|
|
_audioSink(new AudioSample(data, sample.SampleRate, sample.Channels));
|
|
}
|
|
|
|
private void AdvanceToNext()
|
|
{
|
|
if (_queue.Count > 0)
|
|
{
|
|
_current = _queue.Dequeue();
|
|
_elapsed = 0;
|
|
BeginClip();
|
|
}
|
|
else
|
|
{
|
|
_ticker?.Stop();
|
|
}
|
|
}
|
|
|
|
private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version)
|
|
{
|
|
_frameKey = key;
|
|
_frameVersion = version;
|
|
if (_current == null) return _cachedFrame = null;
|
|
return _cachedFrame = _renderer.Render(_current, Progress(), width, height);
|
|
}
|
|
|
|
private double Progress()
|
|
{
|
|
if (_current == null) return 0;
|
|
return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0);
|
|
}
|
|
|
|
private static double DurationOf(ChatMessage m) => m.Kind switch
|
|
{
|
|
ChatEventKind.SuperChat => 4.6,
|
|
ChatEventKind.SuperSticker => 4.2,
|
|
ChatEventKind.NewMember => 4.0,
|
|
ChatEventKind.MemberGift => 5.0,
|
|
ChatEventKind.GiftReceived => 4.4,
|
|
ChatEventKind.MemberMilestone => 4.8,
|
|
_ => 4.0,
|
|
};
|
|
|
|
private static string ComposeTicker(ChatMessage m)
|
|
{
|
|
var label = m.Kind switch
|
|
{
|
|
ChatEventKind.SuperChat => "Super Chat",
|
|
ChatEventKind.SuperSticker => "Super Sticker",
|
|
ChatEventKind.NewMember => "New Member",
|
|
ChatEventKind.MemberGift => "Membership Gift",
|
|
ChatEventKind.GiftReceived => "Gifted Membership",
|
|
ChatEventKind.MemberMilestone => "Member Milestone",
|
|
_ => "Stream Alert",
|
|
};
|
|
var amount = m.Kind is ChatEventKind.SuperChat or ChatEventKind.SuperSticker
|
|
&& m.SuperChatDisplayString is { Length: > 0 } s
|
|
? $" · {s}"
|
|
: "";
|
|
return $"{m.AuthorName} — {label}{amount}";
|
|
}
|
|
|
|
/// <summary>Render the current state onto one AlertBox preview (mirrors chat):
|
|
/// the playing frame, or null when idle so the source shows nothing until an
|
|
/// event actually fires.</summary>
|
|
public void UpdatePreview(Source alertBox)
|
|
{
|
|
try
|
|
{
|
|
var frame = RenderFrame(alertBox);
|
|
if (frame == null)
|
|
{
|
|
alertBox.VideoImageSource = null;
|
|
return;
|
|
}
|
|
var width = (int)Math.Max(1, alertBox.Width);
|
|
var height = (int)Math.Max(1, alertBox.Height);
|
|
var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null);
|
|
bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0);
|
|
alertBox.VideoImageSource = bitmap;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Wire property-change subscriptions for all AlertBox sources across
|
|
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
|
|
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
|
{
|
|
_scenes = scenes;
|
|
foreach (var scene in scenes)
|
|
foreach (var alertBox in scene.Elements.OfType<Source>().Where(s => s.Type == SourceType.AlertBox))
|
|
{
|
|
alertBox.PropertyChanged += (_, e) =>
|
|
{
|
|
if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height))
|
|
UpdatePreview(alertBox);
|
|
};
|
|
UpdatePreview(alertBox);
|
|
}
|
|
}
|
|
|
|
private void RefreshAlertPreviews()
|
|
{
|
|
if (_scenes == null) return;
|
|
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
|
|
.Where(s => s.Type == SourceType.AlertBox))
|
|
UpdatePreview(alertBox);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ticker?.Stop();
|
|
_queue.Clear();
|
|
_current = null;
|
|
StopClip();
|
|
}
|
|
} |