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:
@@ -139,6 +139,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
public ICommand RemoveSourceCommand { get; }
|
||||
public ICommand EditElementCommand { get; }
|
||||
public ICommand ToggleElementVisibilityCommand { get; }
|
||||
public ICommand BrowseAlertVideoCommand { get; }
|
||||
public ICommand ResetAlertVideoCommand { get; }
|
||||
public ICommand ActivateLicenseCommand { get; }
|
||||
public ICommand OpenLicenseEntryCommand { get; }
|
||||
public ICommand OpenHotkeyConfigCommand { get; }
|
||||
@@ -187,6 +189,8 @@ public partial class MainViewModel : ViewModelBase
|
||||
RemoveSourceCommand = new RelayCommand(element => RemoveElement(element as SceneElement));
|
||||
EditElementCommand = new RelayCommand(element => BeginEditElement(element as SceneElement));
|
||||
ToggleElementVisibilityCommand = new RelayCommand(element => ToggleElementVisibility(element as SceneElement));
|
||||
BrowseAlertVideoCommand = new RelayCommand(element => BrowseAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
|
||||
ResetAlertVideoCommand = new RelayCommand(element => ResetAlertVideo(element as Source), element => element is Source { Type: SourceType.AlertBox });
|
||||
ChangeWebcamCommand = new RelayCommand(_ => _ = ChangeWebcamAsync(), _ => CanChangeWebcam);
|
||||
ShowWebcamCommand = new RelayCommand(_ => ShowWebcamInStagedScene(), _ => CanShowWebcamInStagedScene);
|
||||
OpenSocialDialogCommand = new RelayCommand(_ => OpenSocialDialog());
|
||||
@@ -308,7 +312,11 @@ public partial class MainViewModel : ViewModelBase
|
||||
_mediaManager.MediaFailed += OnMediaFailed;
|
||||
|
||||
_chatLayer = new ChatOverlayLayer(new ChatBoxRenderer());
|
||||
_alertLayer = new AlertOverlayLayer(new AlertRenderer());
|
||||
_alertLayer = new AlertOverlayLayer(
|
||||
new AlertRenderer(),
|
||||
clipDecoderFactory: AlertClipDecoderFor,
|
||||
clipPathResolver: ResolveAlertClipPath,
|
||||
audioSink: _audioMixer.EnqueueAlertAudio);
|
||||
|
||||
_rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL
|
||||
_framePump = new FramePump(
|
||||
@@ -319,12 +327,14 @@ public partial class MainViewModel : ViewModelBase
|
||||
encoderFactory: () => new FfmpegEncoder(new FfmpegLocator()),
|
||||
log: message => AppLog.Write(message),
|
||||
socialBar: () => (_socialBarFrame, _socials?.BarPosition ?? SocialBarPosition.Bottom),
|
||||
alertTicker: () => _alertLayer.AlertTickerFrame,
|
||||
transition: _transition,
|
||||
sceneGraph: _sceneGraph);
|
||||
_framePump.Failed += OnFramePumpFailed;
|
||||
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
|
||||
|
||||
LoadLayout();
|
||||
StampDefaultAlertVideo();
|
||||
// Resource validation runs as soon as the layout is up: enumerate the
|
||||
// OS once, and if exactly one webcam exists, lock it for the session
|
||||
// (the saved identity's scene configs may already hold it — refcounted).
|
||||
|
||||
Reference in New Issue
Block a user