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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user