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.
208 lines
7.9 KiB
C#
208 lines
7.9 KiB
C#
using System.Diagnostics;
|
|
using ytLive.Services.Audio;
|
|
using ytLive.Services.Encoder;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
/// <summary>
|
|
/// TASK 47: one alert clip's playback — video frames + on-stream audio from the
|
|
/// same file, both paced to real time. The alert box owns a decoder per event
|
|
/// (start at play, dispose at drain) — unlike MediaSource's app-wide refcounted
|
|
/// session, an alert REPLAYS the same clip each event, so a fresh decode per
|
|
/// play is required. Video is rawvideo BGRA scaled to the box rect (the
|
|
/// <see cref="MediaVideoSource"/> shape); audio is ffmpeg <c>f32le</c> at the
|
|
/// mixer's native 48 kHz stereo (so the mixer ring needs no resampler). The
|
|
/// fade is applied by the alert layer, not here.
|
|
/// </summary>
|
|
public interface IAlertClipDecoder : IDisposable
|
|
{
|
|
/// <summary>Starts the decode loop (video + audio); raises the events off the
|
|
/// calling thread. Paced to real time so playback matches wall clock.</summary>
|
|
void Start();
|
|
|
|
/// <summary>Stops and kills any running decode subprocesses.</summary>
|
|
void Stop();
|
|
|
|
/// <summary>One decoded video frame (box-sized, straight-alpha BGRA).</summary>
|
|
event Action<VideoFrame>? FrameAvailable;
|
|
|
|
/// <summary>One chunk of on-stream audio (48 kHz stereo float).</summary>
|
|
event Action<AudioSample>? AudioReady;
|
|
|
|
/// <summary>Raised once when both streams have fully played out.</summary>
|
|
event Action? Completed;
|
|
}
|
|
|
|
/// <summary>Default <see cref="IAlertClipDecoder"/>: two ffmpeg subprocesses on
|
|
/// the same file. Video via <see cref="RawVideoFrameReader"/> on a <c>rawvideo
|
|
/// bgra</c> pipe (-an), audio via an <c>f32le -ac 2 -ar 48000</c> pipe (-vn).
|
|
/// The seams (locator, process factory, frame-rate probe, delay) let tests drive
|
|
/// a fake decoder without a real codec or Windows.</summary>
|
|
public sealed class AlertClipDecoder : IAlertClipDecoder
|
|
{
|
|
public event Action<VideoFrame>? FrameAvailable;
|
|
public event Action<AudioSample>? AudioReady;
|
|
public event Action? Completed;
|
|
|
|
private const int AudioSampleRate = 48000;
|
|
private const int AudioChannels = 2;
|
|
private const int AudioChunkFloats = 4800; // 50ms of stereo
|
|
|
|
private readonly string _path;
|
|
private readonly int _width;
|
|
private readonly int _height;
|
|
private readonly IFfmpegLocator _locator;
|
|
private readonly Func<IDecodeProcess> _processFactory;
|
|
private readonly IFrameRateProbe? _probe;
|
|
private readonly Func<TimeSpan, CancellationToken, Task> _delay;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task? _run;
|
|
|
|
public AlertClipDecoder(
|
|
string path,
|
|
int width,
|
|
int height,
|
|
IFfmpegLocator locator,
|
|
Func<IDecodeProcess> processFactory,
|
|
IFrameRateProbe? frameRateProbe = null,
|
|
Func<TimeSpan, CancellationToken, Task>? delay = null)
|
|
{
|
|
_path = path;
|
|
_width = width;
|
|
_height = height;
|
|
_locator = locator;
|
|
_processFactory = processFactory;
|
|
_probe = frameRateProbe;
|
|
_delay = delay ?? ((t, ct) => Task.Delay(t, ct));
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
if (_run != null) return;
|
|
_run = Task.Run(RunAsync, CancellationToken.None);
|
|
}
|
|
|
|
public void Stop() => _cts.Cancel();
|
|
|
|
public void Dispose()
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
|
|
private async Task RunAsync()
|
|
{
|
|
var ct = _cts.Token;
|
|
try
|
|
{
|
|
double? frameRate = null;
|
|
if (_probe != null)
|
|
frameRate = await _probe.ProbeAsync(_path, ct).ConfigureAwait(false);
|
|
var frameDuration = frameRate is > 0 ? TimeSpan.FromSeconds(1.0 / frameRate.Value) : TimeSpan.Zero;
|
|
|
|
var ffmpegPath = await _locator.LocateAsync(ct).ConfigureAwait(false);
|
|
|
|
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
|
|
var audioTask = RunAudioAsync(ffmpegPath, ct);
|
|
await Task.WhenAll(videoTask, audioTask).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
return;
|
|
}
|
|
catch
|
|
{
|
|
// A failed decode is not fatal: the alert layer falls back to the six
|
|
// animations (or the frame it already holds) exactly as a missing file.
|
|
}
|
|
|
|
if (!ct.IsCancellationRequested)
|
|
Completed?.Invoke();
|
|
}
|
|
|
|
private async Task RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
|
|
{
|
|
var args =
|
|
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
|
$"-f rawvideo -pix_fmt bgra -vf scale={_width}:{_height} -an -";
|
|
using var process = _processFactory();
|
|
process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = ffmpegPath,
|
|
Arguments = args,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
});
|
|
|
|
var reader = new RawVideoFrameReader(_width, _height);
|
|
var buffer = new byte[65536];
|
|
var stream = process.StandardOutput;
|
|
while (!ct.IsCancellationRequested && !process.HasExited)
|
|
{
|
|
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
|
|
if (read <= 0) break;
|
|
var chunk = new byte[read];
|
|
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
|
|
foreach (var frame in reader.Feed(chunk))
|
|
{
|
|
FrameAvailable?.Invoke(frame);
|
|
if (frameDuration > TimeSpan.Zero)
|
|
await _delay(frameDuration, ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task RunAudioAsync(string ffmpegPath, CancellationToken ct)
|
|
{
|
|
var args =
|
|
$"-hide_banner -loglevel error -i \"{_path}\" " +
|
|
$"-f f32le -ac {AudioChannels} -ar {AudioSampleRate} -vn -";
|
|
using var process = _processFactory();
|
|
process.Start(new ProcessStartInfo
|
|
{
|
|
FileName = ffmpegPath,
|
|
Arguments = args,
|
|
UseShellExecute = false,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
});
|
|
|
|
var stream = process.StandardOutput;
|
|
var raw = new byte[65536];
|
|
var carry = new byte[0];
|
|
var floatScratch = new float[AudioChunkFloats];
|
|
var floatIndex = 0;
|
|
// The pipe is byte-delimited only: a read may split a float, so leftover
|
|
// bytes are carried into the next read instead of dropped (a dropped
|
|
// half-float would desync the entire stream).
|
|
while (!ct.IsCancellationRequested && !process.HasExited)
|
|
{
|
|
var read = await stream.ReadAsync(raw, 0, raw.Length, ct).ConfigureAwait(false);
|
|
if (read <= 0) break;
|
|
var total = carry.Length + read;
|
|
var bytes = total <= raw.Length ? raw : new byte[total];
|
|
if (carry.Length > 0)
|
|
{
|
|
Buffer.BlockCopy(carry, 0, bytes, 0, carry.Length);
|
|
Buffer.BlockCopy(raw, 0, bytes, carry.Length, read);
|
|
}
|
|
var floatsTo = total - (total % 4);
|
|
for (var i = 0; i < floatsTo && floatIndex < floatScratch.Length; i += 4)
|
|
floatScratch[floatIndex++] = BitConverter.ToSingle(bytes, i);
|
|
carry = total > floatsTo ? bytes[floatsTo..total] : new byte[0];
|
|
|
|
while (floatIndex == floatScratch.Length)
|
|
{
|
|
AudioReady?.Invoke(new AudioSample(floatScratch, AudioSampleRate, AudioChannels));
|
|
// Pace the NEXT chunk so the whole clip's audio lands at real-time
|
|
// cadence (the fade in the alert layer tracks the same wall clock).
|
|
await _delay(TimeSpan.FromSeconds(AudioChunkFloats / (double)(AudioSampleRate * AudioChannels)), ct).ConfigureAwait(false);
|
|
floatScratch = new float[AudioChunkFloats];
|
|
floatIndex = 0;
|
|
}
|
|
}
|
|
}
|
|
} |