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:
@@ -1,8 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using Microsoft.Win32;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.Services.Encoder;
|
||||
|
||||
namespace ytLive.ViewModels;
|
||||
|
||||
@@ -60,4 +65,109 @@ public partial class MainViewModel : ViewModelBase
|
||||
|
||||
private void UpdateActiveAlertBoxPreviews()
|
||||
=> _alertLayer.UpdateConfiguredBoxes(Scenes);
|
||||
|
||||
// ─── TASK 47: the alert clip (video) ───
|
||||
|
||||
private string? _alertDefaultVideoId;
|
||||
private string? _defaultAlertClipScratch;
|
||||
|
||||
/// <summary>Runtime clip resolution for one alert box: the Creator's OWN video
|
||||
/// is a plain path on disk (never stored — the 2026-09-26 decision); the shipped
|
||||
/// llama placeholder lives as a BLOB in the internal Asset table and is
|
||||
/// materialized to a temp file once. Null → the six celebratory animations.
|
||||
/// Called from the output pump's alert frame path.</summary>
|
||||
private string? ResolveAlertClipPath(Source box)
|
||||
{
|
||||
if (!box.AlertUseDefaultVideo
|
||||
&& !string.IsNullOrWhiteSpace(box.AlertVideoPath)
|
||||
&& File.Exists(box.AlertVideoPath))
|
||||
return box.AlertVideoPath;
|
||||
return MaterializeDefaultAlertClip();
|
||||
}
|
||||
|
||||
private string? MaterializeDefaultAlertClip()
|
||||
{
|
||||
if (_defaultAlertClipScratch != null && File.Exists(_defaultAlertClipScratch))
|
||||
return _defaultAlertClipScratch;
|
||||
_alertDefaultVideoId ??= _layoutStore.GetAlertDefaultVideoId();
|
||||
if (_alertDefaultVideoId == null) return null;
|
||||
var bytes = _layoutStore.GetAssetBytes(_alertDefaultVideoId);
|
||||
if (bytes == null) return null;
|
||||
var scratch = Path.Combine(Path.GetTempPath(), $"ytLive-alert-{_alertDefaultVideoId}.mp4");
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(scratch, bytes);
|
||||
_defaultAlertClipScratch = scratch;
|
||||
return scratch;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert default materialization failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One decoder per play (the refcounted MediaVideoSourceManager sessions
|
||||
/// can't restart per replay). The clip is full-box in video + 48k stereo audio;
|
||||
/// both steams pace themselves to real time inside the decoder.</summary>
|
||||
private IAlertClipDecoder AlertClipDecoderFor(string path, int width, int height) =>
|
||||
new AlertClipDecoder(path, width, height, new FfmpegLocator(), () => new FfmpegDecodeProcess());
|
||||
|
||||
/// <summary>Stamp the shipped llama placeholder into the Asset table at startup
|
||||
/// (idempotent — hash-keyed UpsertAsset dedupes). Best-effort: if it fails the
|
||||
/// alert box simply falls back to the six animations.</summary>
|
||||
private void StampDefaultAlertVideo()
|
||||
{
|
||||
try
|
||||
{
|
||||
_alertDefaultVideoId = _layoutStore.GetAlertDefaultVideoId();
|
||||
if (!string.IsNullOrWhiteSpace(_alertDefaultVideoId)) return;
|
||||
var bytes = ReadAlertDefaultVideoResource();
|
||||
if (bytes is null or { Length: 0 }) return;
|
||||
_alertDefaultVideoId = _layoutStore.EnsureAlertDefaultVideo(bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert default video stamp failed");
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[]? ReadAlertDefaultVideoResource()
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri("pack://application:,,,/Assets/alert-default.mp4", UriKind.Absolute);
|
||||
using var stream = Application.GetResourceStream(uri)?.Stream;
|
||||
if (stream == null) return null;
|
||||
using var ms = new MemoryStream();
|
||||
stream.CopyTo(ms);
|
||||
return ms.ToArray();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write(ex, "Alert default video resource missing");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseAlertVideo(Source? box)
|
||||
{
|
||||
if (box == null || box.Type != SourceType.AlertBox) return;
|
||||
var dialog = new OpenFileDialog
|
||||
{
|
||||
Title = "Choose the alert clip (mp4)",
|
||||
Filter = "Video files (*.mp4;*.mov;*.webm;*.mkv)|*.mp4;*.mov;*.webm;*.mkv|All files (*.*)|*.*"
|
||||
};
|
||||
if (dialog.ShowDialog() != true) return;
|
||||
box.AlertVideoPath = dialog.FileName;
|
||||
box.AlertUseDefaultVideo = false;
|
||||
}
|
||||
|
||||
private void ResetAlertVideo(Source? box)
|
||||
{
|
||||
if (box == null || box.Type != SourceType.AlertBox) return;
|
||||
box.AlertVideoPath = null;
|
||||
box.AlertShowTicker = true;
|
||||
box.AlertUseDefaultVideo = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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