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
+51
View File
@@ -126,6 +126,57 @@ public sealed class AlertLayerVideoTests
Assert.True(fake.DisposeCount == 1, "the per-play decoder must be torn down at drain");
}
[Fact]
public void SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace()
{
_app.Run(() =>
{
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
Width = 600,
Height = 200,
AlertUseDefaultVideo = true,
};
var scenes = new List<Scene> { new Scene { Name = "Main", Elements = { box } } };
var fake = new FakeAlertClipDecoder();
var layer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: (_, _, _) => fake,
clipPathResolver: _ => @"C:\fake\silent.mp4");
layer.UpdateConfiguredBoxes(scenes);
layer.Enqueue(new ChatMessage { Kind = ChatEventKind.NewMember, AuthorName = "Funder" });
Assert.True(layer.IsPlaying);
Assert.Equal(1, fake.StartCount);
// Still inside the grace window: the decoder produced nothing, the box
// sits transparent — exactly the dead-air state the 2026-09-26 live
// failure showed.
layer.Advance(0.5);
Assert.Null(layer.RenderFrame(box));
Assert.Equal(0, fake.DisposeCount);
// Past the 1s grace: the silent decoder is torn down and the ALERT
// SWITCHES to the six-animation render — never a permanently blank box.
layer.Advance(0.6);
Assert.Equal(1, fake.DisposeCount);
Assert.True(layer.IsPlaying);
var animation = layer.RenderFrame(box);
Assert.NotNull(animation);
Assert.True(HasPixels(animation!), "the fallback must actually draw");
// The animation plays its full duration, then drains to idle.
layer.Advance(3.5);
Assert.True(layer.IsPlaying);
layer.Advance(1.0);
Assert.False(layer.IsPlaying);
Assert.Null(layer.RenderFrame(box));
});
}
private static byte AlphaAt(VideoFrame frame) => frame.BgraPixels[3];
private static bool HasPixels(VideoFrame frame) => frame.BgraPixels.Any(b => b != 0);