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
+67 -54
View File
@@ -1,82 +1,95 @@
# HANDOFF — 2026-09-26 (TASK 47 shipping crash fixed; 2 local commits, NOT pushed)
# HANDOFF — 2026-09-26 (TASK 47 shipping crash + the "nothing in the box" void both fixed; 3 local commits, NOT pushed)
## Branch / Commit State
`main` — two local commits ahead of `origin/main` (= `2c9af94`... actually `origin/main` = `ecb329e`),
**NOT pushed** (awaiting the creator's go / milestone signal):
`main` — three local commits ahead of `origin/main` (= `ecb329e`), **NOT pushed** (awaiting the
creator's go / milestone signal):
1. `a11b15e` — **TASK 47** alert box video (built-in/custom clip + read-time fade + message
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 318/319 with the
known env flake → final full run 319/319 green).
2. *(uncommitted next)* — **the 2026-09-26 live-crash fix** (see below), scope = 3 files + docs:
`Services/AlertOverlayLayer.cs`, `ytLive.Tests/AlertLayerVideoTests.cs`, plus docs
(`TASKS/task-47-alert-videos.md` addendum, `MyMistakes.md` lesson, this file). Full suite
**320/320** green, alert tests 3/3, `scope-check.sh` passed.
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 319/319 green).
2. `7b940b6` — **crash fix**: AlertOverlayLayer marshals the chat-poller seam to the UI thread
(regression test red pre-fix / green post-fix, both verified). Full suite 320/320.
3. *(uncommitted next — this session)* — **silent-decoder fallback + diagnostics** (see below),
scope: `Services/AlertOverlayLayer.cs`, `Services/AlertClipDecoder.cs`,
`ytLive.Tests/AlertLayerVideoTests.cs` + docs (task-47 addendum, `MyMistakes.md` lesson, this
file). New Good Dog test green; build 0 warnings; full suite 321 with the **known env flake**
(RealMouseDrag reorder failing while windows are up — passes with the desktop clean; unrelated
to this change). `scope-check.sh` pending, then commit.
## ⚠️ The live crash + fix (this unit)
## ✅ Crash fixed (`7b940b6`)
The creator ran the shipped build in a test session: **test-tab alerts DID fire** (button
commands run on the UI thread — startup.log `11:21:53` shows the sims and alert-clip audio in the
live mix: `micLevel=0.000 loopLevel=0.000` yet `peakMix 0.375 → 0.733 → 0.891`, which can only be
the alert ring; the default clip is materialized at `%TEMP%\ytLive-alert-941785b0-….mp4`, the
video pipe verified against the pinned ffmpeg), but the app **crashed at `11:22:01.301`** — the
first REAL message that round-tripped through the chat poller:
The creator's first live session: test-tab alerts DID fire (11:21 startup.log: alert-clip audio in
the mix, `peakMix 0.375 → 0.891` with mic+loop at 0.000) but the app crashed at `11:22:01.301` on
the first REAL polled message: `Must create DependencySource on same Thread as the DependencyObject`
in `DataBindEngine.ProcessCrossThreadRequests`. `AlertOverlayLayer.OnMessageReceived` had no
UI-thread marshal (`ChatOverlayLayer` has one); it ran `RefreshAlertPreviews` → `UpdatePreview` on
the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC + WPF-bound) with a
WriteableBitmap created there. Fix + Good Dog regression test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red/green verified).
`MyMistakes.md`: every INPC-raising seam fed by the poller must marshal WPF-object writes.
System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
## ⚠️ The "nothing in the alert box" void — diagnosed + fixed (this unit)
Root cause: `AlertOverlayLayer.OnMessageReceived` had NO UI-thread marshal while
`ChatOverlayLayer.OnMessageReceived` has one. A polled message ran `RefreshAlertPreviews` →
`UpdatePreview` on the MTA poller thread and stamped `alertBox.VideoImageSource` (INPC-raised +
WPF-bound) with a WriteableBitmap created there → binding engine cross-thread re-bind → crash.
Fix: marshal the whole ingest via `Application.Current.Dispatcher.Invoke` (null-guarded so pure
seams still run inline). Good Dog regression test
`AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread`
(calls the seam from a raw MTA `Thread`, returns to the message loop, asserts on the UI thread
that `VideoImageSource.Dispatcher` is the App's dispatcher — **red pre-fix, green post-fix,
verified both ways**). Rule written into `MyMistakes.md`: every INPC-raising seam fed by the chat
poller must marshal WPF-object writes to the UI thread.
After the crash fix the creator reported: On-Air → test → **remove and re-add** the Stream Alerts
layer → TEST event → procs in the chat windows but **nothing in the alert box**. startup.log 11:44:
`peakMix 0.105` (no alert audio) vs 11:21's 0.375–0.891. No exception anywhere — every stage of the
clip path was silent by design (`UpdatePreview` catch = `Debug.WriteLine`-only; `RunAsync` bare
`catch { }` firing `Completed` regardless). Established the guaranteed-invisible fail: a decoder
whose `Start()` succeeds but yields **no frames AND no audio** leaves `_clip != null` with
`_latestClipFrame == null` → `RenderClipFrame` returns null forever → box transparent FOREVER; the
six-animation fallback only ran on setup-time throws. (Hunted & ruled out meanwhile: `LiveScene` is
never assigned — the encoder always renders `StagedScene` (MainViewModel.cs:323), so the re-added
box IS in the render graph; the bake invalidates per element on add; DB row
`c85b46ac-…` X=602 Y=26 680×200 healthy; pinned ffmpeg decodes the default clip to `scale=680:200`
rawvideo correctly to a file. Frame pacing silently off too: no probe → `frameDuration=0`.)
**Fix (uncommitted):** `AlertOverlayLayer.Advance` — a started clip that produced neither a frame
nor an audio chunk inside `NoFrameFallbackSeconds` (1.0 s) is torn down, `_elapsed` resets, and the
SAME alert continues as the six-animation render (never blank, never drained early).
Diagnostics: `BeginClip` logs box id / resolved path / size + setup failure; `AlertClipDecoder.RunAsync`
logs frame/chunk end-state + the swallowed exception. Good Dog test
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`: transparent inside the grace →
decoder disposed past it → animation renders → drains to idle. `MyMistakes.md`: an alert that plays
is a promise — degrade on dead-air, and a `catch` hiding WHY output vanished is a lie.
## Follow-ups queued (NOT done in this unit)
- **TASK 3 item 20 persistence half** — canonical `RewardEvents` SQLite table +
`superChatEvents.list` (30-day) backfill + session-report rollup (still in-memory only).
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — all
queued unchanged.
- If the creator still reports "nothing in the box" after this fix (their Live scene's "Stream
Alerts" AlertBox is at 612,45 680×200; there is also a WebSource "Web Resource-0" bottom-right —
confirm which element they're watching), investigate the video-render path live — but note all
evidence (audio in the mix, materialized clip, verified pipe) says the native box played.
- TASK 3 item 16 (Text source); TASK 40 units A/C/D; TASK 32–36; TASK 12 master limiter — queued.
- If the alert box is STILL blank after this fix + relaunch: the new logs make the failure
conclusive (look for `Alert clip start:` → `Alert clip decode failed` → `Alert clip end:`). The
creator's Live scene also has a WebSource "Web Resource-0" bottom-right — confirm which element
they watch when describing the "stream-alerts layer".
- The known pump stall (`render=full-render … totalMs=147`, ~6fps worst) is recorded as a
pre-existing perf item, NOT part of this unit.
## Around the task (carried facts)
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse (POE 2 seen
2026-09-22) — **close fullscreen windows before full-suite runs**; passes in isolation.
- RealMouseDrag tests no-op while a game/fullscreen window steals the mouse — **close fullscreen
windows before full-suite runs**; passes in isolation.
- Test-env trap: saved OAuth session loads synchronously → force `vm.IsConnected` in signed-in tests.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `$99/yr` Polar
must become one-time before launch. `MARCOM.md`/`MONETIZATION.md` gitignored — never commit.
- **API facts (TASK 44 + 45):** liveChatId unavailable before the broadcast is live (poll until
active); `400 MISSING_REQUIRED_FIELD` from `liveChat/messages.insert` = wrong body shape
(missing `snippet.type`), NOT auth/scope.
- **TASK 47 sticky facts:** bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer
loop; default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play, never the refcounted
`MediaVideoSourceManager`. DB stays `user_version 10` — TASK 47's Source columns are additive
guarded ALTERs. Recipe in `MyMistakes.md`.
- ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer now mirrors it.
- `subscriberCount` YPP slice 2 needs re-consent — do not merge with other units. `MARCOM.md`/
`MONETIZATION.md` gitignored — never commit.
- API facts (TASK 44 + 45): liveChatId unavailable until broadcast live; `400 MISSING_REQUIRED_FIELD`
from `liveChat/messages.insert` = wrong body shape (missing `snippet.type`).
- TASK 47 sticky facts: bgra from ffmpeg is opaque (alpha=255); audio pipe = carry-buffer loop;
default clip = `%TEMP%\ytLive-alert-{id}.mp4`; decoder per-play; DB stays `user_version 10`.
- ChatOverlayLayer.OnMessageReceived marshals the poller seam; AlertOverlayLayer mirrors it; the
no-frame fallback lives in Advance (has the 1 s grace + diagnostics).
- Every committed change needs a **close + relaunch** of the running app to be seen.
## Next step
1. Commit the crash fix (declared scope + same-commit docs), then run
`./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
2. Full clean build (0 warnings, Windows dotnet host) + full vstest **320/320**.
3. Push `a11b15e` + the fix commit at the creator's go / milestone signal.
4. Creator to relaunch and re-run the test-tab alerts (one of each) + post a real chat message —
the crash is fixed; then confirm which visual element they call the "web-alert box".
1. Commit this unit with the declared scope
`./scripts/scope-check.sh "Services/AlertOverlayLayer.cs" "Services/AlertClipDecoder.cs" "ytLive.Tests/AlertLayerVideoTests.cs"`.
2. Push the three local commits at the creator's go / milestone signal.
3. Creator to relaunch and re-run the repro (compare the 11:21 vs 11:44 behavior) — the new logs
will say exactly what the decoder did.
## Critical working rules (unchanged, still binding)
- **Good Dog = ONE integration test per change.** Scope lock + `./scripts/scope-check.sh` before commit.
- Windows dotnet host for all WSL builds (`/mnt/c/Program Files/dotnet/dotnet.exe`, quoted paths).
- 0 warnings on real clean builds (verify only, never incremental). One runtime model.
- 0 warnings on real clean builds. One commit = fix + its memory (task doc, MyMistakes, handoff).
- No menus/polls with the creator; decisions asked once then held; do the work, then report.
+27
View File
@@ -881,3 +881,30 @@ Red pre-fix (it was the poller's), green post-fix.
Grep-ahead: a `DispatcherTimer` ctor guarded by `if (Application.Current != null)` marks a class
with UI-thread affinity — audit every production ingress point for the marshal when adding one.
---
## A decoder that "starts" but yields nothing is DEAD AIR — fall back on a no-frame grace, never trust silent catches (2026-09-26)
Second live alert failure of the day, a different void than the crash: creator's repro — On-Air →
test → remove and re-add the Stream Alerts layer → TEST event → procs in chat but the alert box
stayed transparent, and startup.log 11:44's `peakMix 0.105` proved the alert audio never reached the
mix (vs 11:21's 0.375–0.891). ZERO exceptions anywhere; every failure stage was silent BY DESIGN:
`UpdatePreview`'s catch was `Debug.WriteLine`-only (nothing in release), and `AlertClipDecoder.RunAsync`
had a bare `catch { }` around the whole run (fires `Completed` regardless). A decode run that fails
AFTER `BeginClip` succeeds leaves `_clip != null` with `_latestClipFrame == null` → `RenderClipFrame`
returns null forever → box transparent FOREVER, and the six-animation fallback only ever ran on
setup-time throws. Even frame pacing was silently off (no frame-rate probe → `frameDuration = 0` →
video dumps instantly while 50 ms audio chunks pace the whole clip).
The established answer (derivative: any video-player/booth failure budget has the same contract):
**an alert that plays is a promise — the moment data stops arriving, degrade, don't vanish.** Fixed
in `AlertOverlayLayer.Advance`: a started clip that produced neither a video frame NOR an audio chunk
inside a `NoFrameFallbackSeconds` (1.0 s) grace is torn down, `_elapsed` resets to 0, and the SAME
alert continues as the six-animation branch — never drained early, never blank. And the silence is
gone: `BeginClip` writes the box id / resolved path / size + setup failure; `RunAsync` now logs the
frame/chunk end-state AND the exception it swallowed. Rules for every future gamble:
(1) a `catch` that hides WHY the output vanished is a lie — log the exception and the counters;
(2) any producer that yields zero output inside a grace becomes a fallback trigger, not a wait;
(3) one integration test per rescue: `SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`
pins transparent→fell-back→renders→drains.
+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();
+29
View File
@@ -148,6 +148,35 @@ on the UI thread in lockstep. Good Dog test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red on the old seam: the
WriteableBitmap's `Dispatcher` was the poller's) — committed with the fix.
## Silent-decoder fallback + decode diagnostics (2026-09-26)
Creator repro after the crash fix: On-Air → test → remove and re-add the Stream Alerts
layer → TEST tab event → procs in the chat windows but NOTHING in the alert box.
Startup.log 11:44 (the re-add) shows `Test chat simulation 3 injected` but `peakMix 0.105`
(no alert audio), vs 11:21's 0.375–0.891 (alert audio in the mix) — the re-added box's
alert never reached the mix. No exception anywhere: every failure stage in the clip path
was silent by design. Hunted facts: `LiveScene` is never assigned (the encoder always
renders `StagedScene`, MainViewModel.cs:323) so the re-added box IS in the render graph;
the bake is invalidated per-element on add; the box's DB row (`c85b46ac-…`, X=602 Y=26
W=680 H=200) is healthy; ffmpeg tools incl. the pinned binary decode the default clip to
`scale=680:200` rawvideo correctly to a file. The silent void: a decoder whose `Start()`
succeeds but whose task yields no frames AND no audio leaves `_clip != null` with
`_latestClipFrame == null` → `RenderClipFrame` returns null forever → permanently blank
box, no animation fallback (the fallback only ran on setup-time throws) and no log trail.
Fix (commit `…)`: a no-frame grace in `AlertOverlayLayer.Advance`
(`NoFrameFallbackSeconds = 1.0`) — a started clip that has produced neither a frame nor
an audio chunk inside the grace is torn down (`StopClip`), `_elapsed` resets, and the SAME
alert continues as the six-animation render (never a blank box, never drained early).
Diagnostics stop the lying silence: `BeginClip` logs the box id + resolved path + size and
the setup failure that used to only `Debug.WriteLine`; `AlertClipDecoder.RunAsync` now logs
frame/chunk end-state and the exception it used to swallow. Good Dog test
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace` pins the whole contract —
transparent inside the grace, decoder disposed past it, animation rendering, drains to
idle. This lands the corpus at 321, with the one known-env flake (RealMouseDrag reorder,
passes with no fullscreen windows up) — unrelated to this change. Unit was the direct
lineage of bug surface: **a decoder booth production failure surfaces as dead air.**
## Open follow-ups (NOT this unit)
- TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain
+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);