fix(alerts): silent decoder falls back to the six animations after a 1s no-frame grace

Routing and ruling both good: 'procs in the chat windows but nothing in the alert box'
with zero exceptions — every failure stage was silent by design (Debug.WriteLine-only
preview catch, bare catch{} in RunAsync firing Completed regardless). A decode run that
starts but yields neither a frame nor an audio chunk left _clip != null with
_latestClipFrame == null, so RenderClipFrame returned null forever: the box stayed
transparent for the whole alert and the six-animation fallback (which only ran on
setup-time throws) never fired.

Fix: AlertOverlayLayer.Advance gives a started clip a NoFrameFallbackSeconds (1.0s)
grace — produce no frame AND no audio inside it and the clip is torn down, _elapsed
resets, and the SAME alert continues as the animation branch (never blank, never drained
early). Diagnostics stop the lying silence: BeginClip logs box id/path/size + setup
failures; AlertClipDecoder.RunAsync logs frame/chunk end-state and the exception it used
to swallow.

Good Dog test: SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace (transparent
inside grace, disposed past it, animation renders, drains to idle).

Reference: alert playback must degrade to its fallback on dead-air, never vanish —
same contract as every player/booth failure budget (e.g. OBS source fallbacks).
https://obsproject.com/forum/threads/source-visibility-fallback.187383/
This commit is contained in:
2026-09-26 12:08:27 -07:00
parent 7b940b6a5a
commit 80038ff152
6 changed files with 231 additions and 60 deletions
+23 -4
View File
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.IO;
using ytLive.Helpers;
using ytLive.Services.Audio;
using ytLive.Services.Encoder;
@@ -93,6 +95,9 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
private async Task RunAsync()
{
var ct = _cts.Token;
var videoFrames = 0;
var audioChunks = 0;
var failed = false;
try
{
double? frameRate = null;
@@ -104,23 +109,31 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
var videoTask = RunVideoAsync(ffmpegPath, frameDuration, ct);
var audioTask = RunAudioAsync(ffmpegPath, ct);
await Task.WhenAll(videoTask, audioTask).ConfigureAwait(false);
videoFrames = await videoTask.ConfigureAwait(false);
audioChunks = await audioTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
failed = true;
return;
}
catch
catch (Exception ex)
{
// 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.
// Logged so a silent blank box traces back to a decode failure.
failed = true;
AppLog.Write(ex, $"Alert clip decode failed ({Path.GetFileName(_path)})");
}
if (!ct.IsCancellationRequested)
{
AppLog.Write($"Alert clip end: {Path.GetFileName(_path)} frames={videoFrames} audioChunks={audioChunks} failed={failed}");
Completed?.Invoke();
}
}
private async Task RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
private async Task<int> RunVideoAsync(string ffmpegPath, TimeSpan frameDuration, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
@@ -139,6 +152,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
var reader = new RawVideoFrameReader(_width, _height);
var buffer = new byte[65536];
var stream = process.StandardOutput;
var frames = 0;
while (!ct.IsCancellationRequested && !process.HasExited)
{
var read = await stream.ReadAsync(buffer, 0, buffer.Length, ct).ConfigureAwait(false);
@@ -147,14 +161,16 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
Buffer.BlockCopy(buffer, 0, chunk, 0, read);
foreach (var frame in reader.Feed(chunk))
{
frames++;
FrameAvailable?.Invoke(frame);
if (frameDuration > TimeSpan.Zero)
await _delay(frameDuration, ct).ConfigureAwait(false);
}
}
return frames;
}
private async Task RunAudioAsync(string ffmpegPath, CancellationToken ct)
private async Task<int> RunAudioAsync(string ffmpegPath, CancellationToken ct)
{
var args =
$"-hide_banner -loglevel error -i \"{_path}\" " +
@@ -175,6 +191,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
var carry = new byte[0];
var floatScratch = new float[AudioChunkFloats];
var floatIndex = 0;
var chunks = 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).
@@ -196,6 +213,7 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
while (floatIndex == floatScratch.Length)
{
chunks++;
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).
@@ -204,5 +222,6 @@ public sealed class AlertClipDecoder : IAlertClipDecoder
floatIndex = 0;
}
}
return chunks;
}
}
+34 -2
View File
@@ -4,6 +4,7 @@ using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services.Audio;
using ytLive.Services.Compositor;
@@ -40,6 +41,11 @@ public sealed class AlertOverlayLayer : IDisposable
/// enough to read as a "pop-in" on stream (see ai.md).</summary>
internal const double FadeDurationSeconds = 0.30;
/// <summary>How long an alert waits for the first decoded frame or audio slab
/// before treating the clip as unplayable and falling back to the animations
/// (a silent decoder must never blank the alert box).</summary>
internal const double NoFrameFallbackSeconds = 1.0;
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33);
private readonly AlertRenderer _renderer;
@@ -57,6 +63,8 @@ public sealed class AlertOverlayLayer : IDisposable
private double _clipEofElapsed;
private int _clipWidth;
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
@@ -139,6 +147,19 @@ public sealed class AlertOverlayLayer : IDisposable
if (_clip != null)
{
_elapsed += deltaSeconds;
// A decoder that starts but produces not a single frame (or slab of
// audio) is dead weight — the box would sit transparent for the whole
// alert. Give it a short grace, then fall back to the animations so an
// alert ALWAYS renders (the live decode failure at 2026-09-26 11:44 was
// this exact void, invisible in the log).
if (!_clipSawFrame && !_clipAudioHeard && _elapsed >= NoFrameFallbackSeconds)
{
System.Diagnostics.Debug.WriteLine("Alert clip yielded no frames in 1s — falling back to the animation");
StopClip();
_elapsed = 0;
_contentVersion++;
return;
}
if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds)
{
_clipEof = false;
@@ -277,14 +298,18 @@ public sealed class AlertOverlayLayer : IDisposable
_clipHeight = (int)Math.Max(1, box.Height);
try
{
_clipSawFrame = false;
_clipAudioHeard = false;
_clip = _clipDecoderFactory(path, _clipWidth, _clipHeight);
_clip.FrameAvailable += OnClipFrame;
_clip.AudioReady += OnClipAudio;
_clip.Completed += OnClipCompleted;
_clip.Start();
AppLog.Write($"Alert clip start: box {box.Id} path '{path}' {_clipWidth}x{_clipHeight}");
}
catch
catch (Exception ex)
{
AppLog.Write(ex, "Alert clip setup failed");
StopClip(); // decode setup failed (bad path) — fall back to animations
}
}
@@ -295,6 +320,8 @@ public sealed class AlertOverlayLayer : IDisposable
_clip = null;
_latestClipFrame = null;
_clipEof = false;
_clipSawFrame = false;
_clipAudioHeard = false;
if (clip == null) return;
clip.FrameAvailable -= OnClipFrame;
clip.AudioReady -= OnClipAudio;
@@ -303,7 +330,11 @@ public sealed class AlertOverlayLayer : IDisposable
clip.Dispose();
}
private void OnClipFrame(VideoFrame frame) => _latestClipFrame = frame;
private void OnClipFrame(VideoFrame frame)
{
_clipSawFrame = true;
_latestClipFrame = frame;
}
private void OnClipCompleted()
{
@@ -314,6 +345,7 @@ public sealed class AlertOverlayLayer : IDisposable
private void OnClipAudio(AudioSample sample)
{
if (_audioSink == null) return;
_clipAudioHeard = true;
var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0);
if (volume <= 0f) return;
var fade = FadeAt();