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:
+67
-54
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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