diff --git a/HANDOFF.md b/HANDOFF.md index a946886..d491b55 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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. \ No newline at end of file diff --git a/MyMistakes.md b/MyMistakes.md index 473771f..56fd13f 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -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. diff --git a/Services/AlertClipDecoder.cs b/Services/AlertClipDecoder.cs index b2abd24..3cf837c 100644 --- a/Services/AlertClipDecoder.cs +++ b/Services/AlertClipDecoder.cs @@ -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 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 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; } } \ No newline at end of file diff --git a/Services/AlertOverlayLayer.cs b/Services/AlertOverlayLayer.cs index 9b2e0ca..bbfea72 100644 --- a/Services/AlertOverlayLayer.cs +++ b/Services/AlertOverlayLayer.cs @@ -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). internal const double FadeDurationSeconds = 0.30; + /// 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). + 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? _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(); diff --git a/TASKS/task-47-alert-videos.md b/TASKS/task-47-alert-videos.md index c1e8c87..2672c56 100644 --- a/TASKS/task-47-alert-videos.md +++ b/TASKS/task-47-alert-videos.md @@ -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 diff --git a/ytLive.Tests/AlertLayerVideoTests.cs b/ytLive.Tests/AlertLayerVideoTests.cs index dfa3a77..bb7a046 100644 --- a/ytLive.Tests/AlertLayerVideoTests.cs +++ b/ytLive.Tests/AlertLayerVideoTests.cs @@ -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 { 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);