TASK 47: draw the alert clip — the preview Image was Collapsed for AlertBox

Four builds (89/90 + two) burned proving the alert video was PERFECT: real h264
1280x720, 240 frames decoded, alert audio in the live mix, and the shipped asset
byte-identical (md5 0ee1f496…) to the creator's llamacasty-dancingLlama-thankyou.mp4
with every sampled frame full bright content. Build 90's new diagnostics then showed
the frame reaching BOTH consumers every second for the whole clip —
`Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200
playing=True` — an opaque, correctly-sized frame, handed over and never seen.

Root cause: Controls/PreviewPane.xaml keeps the per-element Image Collapsed unless a
DataTrigger fires, and there was no IsAlertBox trigger (only IsImageSource / IsChatBox /
IsWebSource / IsWebcam). The alert box's Image was therefore Collapsed forever. Chat
boxes rendered because they HAVE a trigger — that asymmetry is the whole clue, and it
is why removing/re-adding the layer could never fix it.

Fix: an IsAlertBox DataTrigger beside the IsChatBox one.

Two Good Dog tests, because the existing fakes had been hiding this:
- AlertBoxPreviewVisibilityTests (red `Expected: Visible / Actual: Collapsed`, now
  green) drives the real MainWindow + PreviewPane and asserts the bound Image is
  Visible — the first alert test that crosses the XAML at all.
- AlertClipOutputTests is the first test in the repo to run a REAL codec: pinned ffmpeg
  generates a clip, the real AlertClipDecoder + AlertOverlayLayer + SceneCompositor
  composite it, and the box rect must be a colourful picture that DIFFERS from idle. It
  passed while the feature was broken in the app — which is exactly why it was needed:
  it exonerated decode+layer+compositor and pointed the hunt at the last hop.

Also keeps the two once-per-second alert diagnostics (resolver + preview) that settled
it, pending the creator's call on whether to keep them.

Notes: the pinned BtbN ffmpeg has no libx264, so the test clip is -c:v mpeg4.
SceneCompositor.Render fills its base with opaque black, so the test compares against an
idle render rather than counting non-zero bytes. MediaSource has the same missing trigger
in PreviewPane.xaml — left unfixed as out of scope, noted in HANDOFF.

Full suite 324/324. Lesson recorded in MyMistakes.md rule (5): a producer that hands over
a correct frame has still delivered nothing — when output arrives and the user still sees
nothing, stop auditing the producer and audit the consumer's VISIBILITY.
This commit is contained in:
2026-09-26 14:44:57 -07:00
parent aea0670723
commit e2ecc248e0
8 changed files with 456 additions and 99 deletions
+7
View File
@@ -113,6 +113,13 @@
<DataTrigger Binding="{Binding IsChatBox}" Value="True"> <DataTrigger Binding="{Binding IsChatBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
</DataTrigger> </DataTrigger>
<!-- TASK 47: the alert box's frame (the decoded clip) is written onto
VideoImageSource exactly like the chat box's, so it needs the same
trigger. Without it the Image stayed Collapsed and every alert was
invisible in the preview while the decode logged healthy (2026-09-26). -->
<DataTrigger Binding="{Binding IsAlertBox}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
<DataTrigger Binding="{Binding IsWebSource}" Value="True"> <DataTrigger Binding="{Binding IsWebSource}" Value="True">
<Setter Property="Visibility" Value="Visible"/> <Setter Property="Visibility" Value="Visible"/>
<Setter Property="Stretch" Value="Fill"/> <Setter Property="Stretch" Value="Fill"/>
+64 -98
View File
@@ -1,106 +1,72 @@
# HANDOFF — 2026-09-26 (TASK 47: crash + silent-void + video-pacing all fixed; 4 local commits, NOT pushed) # HANDOFF — 2026-09-26, end of session
## Branch / Commit State ## Where we are
`main`, **5 local commits ahead of `origin/main` (= `ecb329e`), NOTHING PUSHED.**
Last pushed commit is still `ecb329e`. Do not push without the creator saying so.
`main` — four local commits ahead of `origin/main` (= `ecb329e`), **NOT pushed** (awaiting the | commit | what |
creator's go / milestone signal): |---|---|
| `a11b15e` | TASK 47 alert video (first landing) |
| `7b940b6` | poller-thread marshal crash fix |
| `80038ff` | silent-decoder fallback + diagnostics |
| `aea0670` | video pacing via `FfmpegFrameRateProbe` |
| (last, unpushed) | **the real alert fix: `PreviewPane.xaml` `IsAlertBox` trigger** + 2 Good Dog tests + resolver/preview diagnostics |
1. `a11b15e` — **TASK 47** alert box video (built-in/custom clip + read-time fade + message ## THE ANSWER (do not re-derive this)
ticker + unity audio) + `AlertLayerVideoTests` (fake decoder, 0 warnings, 319/319 green). The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed
2. `7b940b6` — **crash fix**: AlertOverlayLayer marshals the chat-poller seam to the UI thread `Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200 playing=True`
(regression test red pre-fix / green post-fix). Full suite 320/320. every second for the whole clip — a real, opaque, correctly-sized frame reaching BOTH
3. `80038ff` — **silent-decoder fallback + diagnostics** (no-frame grace 1.0s → six-animation; consumers. `Controls/PreviewPane.xaml` keeps the per-element `Image` `Collapsed` unless a
BeginClip/RunAsync logging). Full suite 321 with the **known env flake** (RealMouseDrag DataTrigger fires, and there was **no `IsAlertBox` trigger** (only IsImageSource /
reorder failing while windows are up). IsChatBox / IsWebSource / IsWebcam). Chat boxes rendered; alert boxes never could.
4. *(uncommitted next — this session)* — **video pacing fix** (below). Scope: Removing/re-adding the layer could not have fixed it.
`ViewModels/MainViewModel.Chat.cs`, `ytLive.Tests/AlertClipDecoderTests.cs` (new), docs
(task-47 addendum, `MyMistakes.md`, this file). Test green; build 0 warnings;
`scope-check.sh` pending, then commit.
## ✅ Crash fixed (`7b940b6`) Clip itself is fine: DB asset `941785b0-d022-45fa-a8a2-2cd59ae2ba48` is byte-identical
(md5 `0ee1f4960dd3b23dee5930a2af79d410`) to the creator's
`C:\Users\gramp\Downloads\llamacasty-dancingLlama-thankyou.mp4` — h264 1280x720 10s,
every sampled frame full bright content.
The creator's first live session: test-tab alerts DID fire (11:21 startup.log: alert-clip audio in ## Working tree
the mix, `peakMix 0.375 → 0.891` with mic+loop at 0.000) but the app crashed at `11:22:01.301` on Clean apart from the uncommitted work unit above (it is committed; check `git status`).
the first REAL polled message: `Must create DependencySource on same Thread as the DependencyObject` If the app is running, `ytLive.dll`/`.exe` are LOCKED and any build fails with MSB3021/
in `DataBindEngine.ProcessCrossThreadRequests`. `AlertOverlayLayer.OnMessageReceived` had no MSB3027 — the creator must close ytLive first. That is normal, not a broken build.
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).
## ✅ "Nothing in the alert box" void — fixed (`80038ff`) ## Build / test commands (Windows host, always)
```bash
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj" --no-restore
"/mnt/c/Program Files/dotnet/dotnet.exe" vstest "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLive.Tests.dll"
```
Last full suite: **324/324 pass**. Run it with ytLive CLOSED or the RealMouseDrag env
flake fires.
After the crash fix: On-Air → test → **remove and re-add** the Stream Alerts layer → TEST event → ## Queued next (creator's words, 2026-09-26) — the Good Dog queue
procs in chat but **nothing in the alert box**. startup.log 11:44 `peakMix 0.105` (no alert audio) 1. **Test-console chat must not clear the chat window.** "when I add a chat message from
vs 11:21's 0.375–0.891. Every stage of the clip path was silent by design; a decoder whose `Start()` the test console, or from any console, that chat text should not clear the current
succeeds but yields **no frames AND no audio** left `_clip != null` with `_latestClipFrame == null` chat window."
→ blank box forever. Fix: no-frame grace (1.0s) in `AlertOverlayLayer.Advance` → tear down + same 2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right
alert as six-animation; BeginClip/RunAsync diagnostics stop the silence. Good Dog: edge of the div; needs 20px padding against the div's right side.
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`. 3. **Test pull-out chat input: Enter inserts a newline**, it must not send.
4. Verify the alert video live on the next build (the fix is in; nobody has SEEN it play
yet). This is the confirmation step for the commit above.
## ✅ "No video plays" — decoder HEALTHY, video UNPACED — fixed (this unit) ## Landmines / facts worth keeping
- The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`.
Creator repro after `80038ff` (12:35 session): still "no video plays in the web-alert box", "like - `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte"
you lost the video" — but the new diagnostics PROVED the decoder healthy: `Alert clip start: box …` is not proof a layer painted. Compare against an idle render, or count COLOURFUL px.
→ `Alert clip end: … frames=240 audioChunks=156 failed=False` twice, alert audio in the live mix - `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with
(`peakMix 0.277 → 0.733`, mic/loop 0.000). Root cause was **pacing, never decode**: `SELECT writefile('/tmp/opencode/x.bin', Data) FROM Asset WHERE Id='…'` (Linux paths
`AlertClipDecoderFor` (MainViewModel.Chat.cs:113) built the decoder with **no frame-rate probe** → only — a `C:/…` path creates a junk `C:` dir IN THE REPO; one was made and removed).
`RunAsync` computed `frameDuration = TimeSpan.Zero` → `RunVideoAsync`'s pace step - Layout DB: `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db` (tables: `Asset`
(`if (frameDuration > 0)`) was dead code → all 240 frames of the 10s clip dumped through the pipe `Id/Hash/Data`, `Settings`, `Source`, `Scene`, …). Alert default video setting key:
in the first ~1-2s (130MB as fast as ffmpeg read), then the box sat frozen on the LAST frame while `AlertDefaultVideoAssetId`. ffmpeg/ffprobe live in
the audio pipeline paced 156 chunks at real-time ~7.8s. Reading exactly like "no video / you lost `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/tools/` (run the `.exe` directly from WSL).
it". The media path already had the seam wired (`MediaVideoSource` gets - Two diagnostics were ADDED and are still in the tree (once-per-second, alert only):
`frameRateProbe: new FfmpegFrameRateProbe(…)`, MainViewModel.cs:308) — the alert factory just `Output resolver: alert box …` (MainViewModel.cs) and `Alert preview: box …`
never passed one. (AlertOverlayLayer.cs). Decide with the creator whether to keep them or strip them now
that the root cause is known.
**Fix (uncommitted):** `AlertClipDecoderFor` passes - `PreviewPane.xaml` has the same latent gap for `IsMediaSource` (no trigger either) —
`frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())` so NOT fixed (out of scope), noted as a follow-up.
video paces at the clip's native ~24fps (10s real-time) in lockstep with audio. Good Dog test - Known perf item, untouched: FramePump stalls ~140-180ms renders
`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate` drives the REAL (`render=full-render split=0 … totalMs=140`). Unrelated to the alert bug.
`AlertClipDecoder` through the media-style fake process + fake probe + recorded delay seam - YouTube rejects the `complete` transition (403) at end-of-stream; `enableAutoStop`
(3 frames at 100fps → one ~10ms delay per frame; red on the old factory, green now). Full suite finishes the broadcast. Harmless, already handled.
321/322 (one known-env flake). `MyMistakes.md`: **a decoder that drops data faster than wall-clock
looks identical to a dead decoder on screen — "plays but you don't see it" is a PACING bug before
it's a decode bug.** Rules: never ship a pipe whose `frameDuration` can be zero; check pacing
(bytes/s vs wall-clock) before re-auditing the codec.
## 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 — queued.
- If the alert box is STILL "no video" after this fix + relaunch: check the 12:35-style
`Alert clip end: … frames=240` line (now logged) — a healthy decode with no visual means the
painter/render path, not the decoder; the creator's Live scene also has a WebSource
"Web Resource-0" bottom-right — confirm which element they watch when describing the
"web-alert / stream-alerts" box.
- 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 — **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. `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`.
- Every committed change needs a **close + relaunch** of the running app to be seen.
## Next step
1. Commit this unit with the declared scope
`./scripts/scope-check.sh "ViewModels/MainViewModel.Chat.cs" "ytLive.Tests/AlertClipDecoderTests.cs"`.
2. Push the four local commits at the creator's go / milestone signal.
3. Creator to relaunch and re-run the repro — with the probe wired the clip plays real-time
(~10s of motion, not a blur + frozen frame); the `Alert clip end:` line confirms decode health.
## 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. 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.
+20
View File
@@ -923,3 +923,23 @@ fake-probe delay-recorder test now pins real-time video pacing
(`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate`). Rule added to the (`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate`). Rule added to the
list above: (4) if output arrives but the USER still reports no/motionless picture, check pacing list above: (4) if output arrives but the USER still reports no/motionless picture, check pacing
before decode — count the bytes/s against wall-clock, don't re-audit the codec. before decode — count the bytes/s against wall-clock, don't re-audit the codec.
**RULE (5) — the last hop is a hop. A producer that hands over a correct frame has still
delivered nothing.** The 2026-09-26 alert hunt burned four builds (89/90 + two more) proving
the video was PERFECT: real h264, 240 frames, bright pixels at every timestamp, byte-identical
to the creator's source file, opaque `a255` frames logged every second into both the preview
and the compositor resolver. The box was blank because `PreviewPane.xaml`'s per-element
`Image` is `Collapsed` unless a DataTrigger says otherwise, and nobody had ever written the
`IsAlertBox` one. Two lessons, both expensive:
- **"It decodes, it's handed over, it's still invisible" ⇒ stop auditing the producer and
audit the CONSUMER'S VISIBILITY.** Bytes existing is not pixels being drawn. Diff the two
consumers' behaviour: chat box rendered, alert box didn't — that asymmetry was the whole
clue, and every per-frame log said "healthy".
- **A test whose fakes replace the failing seam will pass forever.** `AlertLayerVideoTests`
drove `RenderFrame` with a fake decoder and was green through the entire outage; the decode
test used a fake process; nothing ever crossed the XAML. When a feature's output is
*displayed*, the test must build the real display path (`AlertBoxPreviewVisibilityTests`
drives the real `MainWindow`+`PreviewPane`). And when the symptom is "nothing appears",
the honest test asserts on the CONSUMER (is the Image Visible / are the canvas pixels
different), never on the producer's own counters.
- Corollary: never trust "the data arrived" as a root cause. Ask what draws it.
+14
View File
@@ -73,6 +73,7 @@ public sealed class AlertOverlayLayer : IDisposable
private int _frameVersion = -1; private int _frameVersion = -1;
private string? _frameKey; private string? _frameKey;
private VideoFrame? _cachedFrame; private VideoFrame? _cachedFrame;
private DateTime _lastPreviewLog = DateTime.MinValue;
public AlertOverlayLayer( public AlertOverlayLayer(
AlertRenderer renderer, AlertRenderer renderer,
@@ -428,6 +429,19 @@ public sealed class AlertOverlayLayer : IDisposable
try try
{ {
var frame = RenderFrame(alertBox); var frame = RenderFrame(alertBox);
// Preview eyes (TASK 47, 2026-09-26): a healthy decode painted nothing on
// the canvas. UpdatePreview swallows its failures to Debug, so say once a
// second whether the box is being handed a frame at all.
if (IsPlaying)
{
var now = DateTime.UtcNow;
if (now - _lastPreviewLog >= TimeSpan.FromSeconds(1))
{
_lastPreviewLog = now;
AppLog.Write($"Alert preview: box {alertBox.Id} frame="
+ (frame == null ? "null" : $"{frame.Width}x{frame.Height} a{frame.BgraPixels[3]}"));
}
}
if (frame == null) if (frame == null)
{ {
alertBox.VideoImageSource = null; alertBox.VideoImageSource = null;
+43
View File
@@ -212,3 +212,46 @@ not decode.**
- The alert-store folder (`Apps_Commands`-style per-clip assets) stays out of scope — - The alert-store folder (`Apps_Commands`-style per-clip assets) stays out of scope —
the current model is read-at-play from a path, per the creator's "never store the the current model is read-at-play from a path, per the creator's "never store the
video" ruling. video" ruling.
## Take three — the video was never DRAWN (2026-09-26, the real one)
Build 89 and build 90 both decoded the clip perfectly and showed nothing. The build-90
diagnostics settled it in one run, and the answer was NOT in the video pipeline at all:
```
Alert clip start: box ce2532a1… path '…ytLive-alert-941785b0….mp4' 680x200
Alert preview: box ce2532a1… frame=680x200 a255 ← opaque, right size, every second
Output resolver: alert box 'ce2532a1…' frame 680x200 (visible=True playing=True)
Alert clip end: … frames=240 audioChunks=156 failed=False
```
The frame reached BOTH consumers, opaque and correctly sized, for the whole 10s. The clip
itself is fine — the shipped asset (`AlertDefaultVideoAssetId` = 941785b0…, md5
`0ee1f4960dd3b23dee5930a2af79d410`) is byte-identical to the creator's original
`Downloads\llamacasty-dancingLlama-thankyou.mp4`, h264 1280x720 10s, and every sampled
frame is full bright content (mean ~470/255, 100% non-zero bytes).
**Root cause: `Controls/PreviewPane.xaml`.** The per-element `Image` is `Collapsed` by
default and only becomes Visible via DataTriggers for `IsImageSource` / `IsChatBox` /
`IsWebSource` / `IsWebcam`. There was no AlertBox trigger, so the alert box's Image stayed
Collapsed forever — the bound frame was never rendered. Chat boxes showed because they
HAVE a trigger; that asymmetry is why "the chat window works but the alert layer doesn't"
looked like a decode problem. Removing/re-adding the layer could never fix it.
Fix: an `IsAlertBox` DataTrigger next to the `IsChatBox` one.
Two Good Dog tests, because the fakes had been hiding this:
- `AlertClipOutputTests.AlertClip_RealDecode_PaintsPixelsInsideTheAlertBoxRect` — the
first test in the repo that runs a REAL codec: generates a clip with the pinned ffmpeg
(`mpeg4`, not libx264 — the pinned BtbN build has no x264), drives the real
`AlertClipDecoder` + real `AlertOverlayLayer` + real `SceneCompositor`, and asserts the
box rect is a COLOURFUL picture that DIFFERS from the idle canvas. It passed even while
the feature was broken in the app, which is exactly why it was needed: it exonerated
decode+layer+compositor and pointed the hunt at the last hop. Note the compositor fills
its base with opaque black, so "any non-zero byte" is NOT a pass — compare against idle.
- `AlertBoxPreviewVisibilityTests.AlertBox_HandedAFrame_IsDrawnInThePreviewPane` — real
`MainWindow` + real `PreviewPane`, an AlertBox handed an opaque `WriteableBitmap`, assert
the bound `Image` is `Visible`. Red with `Expected: Visible / Actual: Collapsed` before
the fix, green after.
Full suite 324/324.
+16
View File
@@ -508,9 +508,25 @@ public partial class MainViewModel : ViewModelBase
AppLog.Write($"Output resolver: webcam '{webcam.WebcamId}' frame is null — layer skipped in composite"); AppLog.Write($"Output resolver: webcam '{webcam.WebcamId}' frame is null — layer skipped in composite");
} }
} }
// Alerts (TASK 47, 2026-09-26): a healthy decode logged frames=240 while the
// box stayed blank, so name the two possibilities per tick — did the resolver
// ever get asked for the alert box, and did it have a frame to hand back?
if (element is Source { Type: SourceType.AlertBox } alert)
{
var now = DateTime.UtcNow;
if (now - _lastAlertResolveLog >= TimeSpan.FromSeconds(1))
{
_lastAlertResolveLog = now;
AppLog.Write(frame == null
? $"Output resolver: alert box '{alert.Id}' frame is null (visible={alert.IsVisible} playing={_alertLayer.IsPlaying}) — skipped in composite"
: $"Output resolver: alert box '{alert.Id}' frame {frame!.Width}x{frame!.Height} (visible={alert.IsVisible} playing={_alertLayer.IsPlaying})");
}
}
return frame; return frame;
} }
private DateTime _lastAlertResolveLog = DateTime.MinValue;
private DateTime _lastWebcamNullLog = DateTime.MinValue; private DateTime _lastWebcamNullLog = DateTime.MinValue;
/// <summary>Maps a config's identity GUID to the CameraManager session key (the /// <summary>Maps a config's identity GUID to the CameraManager session key (the
@@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Models;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// TASK 47 Good Dog (take three, 2026-09-26): the alert box's frame must be
/// DRAWN by the preview pane. The 14:26 and 14:28 live runs on build 90 proved
/// everything up to the last hop: <c>Alert preview: frame=680x200 a255</c> and
/// <c>Output resolver: frame 680x200 playing=True</c> every second for the whole
/// clip — a real, opaque, correctly-sized frame (the shipped llama asset decodes
/// bright, 1280x720 h264) handed to the canvas — and the box stayed blank.
/// Root cause: the preview's per-element <c>Image</c> only becomes Visible for
/// IsImageSource / IsChatBox / IsWebSource / IsWebcam, so an AlertBox's Image
/// stayed Collapsed and the bound frame was never rendered.
/// </summary>
[Collection("RealApp")]
public sealed class AlertBoxPreviewVisibilityTests
{
private readonly RealAppHost _app;
public AlertBoxPreviewVisibilityTests(RealAppHost app) => _app = app;
[Fact]
public void AlertBox_HandedAFrame_IsDrawnInThePreviewPane()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-test-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
var window = new MainWindow();
var vm = (MainViewModel)window.DataContext;
try
{
window.Show();
window.UpdateLayout();
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
X = 100,
Y = 100,
Width = 320,
Height = 180,
};
vm.StagedScene!.Elements.Add(box);
vm.SelectedElement = box;
// Exactly what the layer writes onto the source every tick while a clip
// plays: a real, opaque, box-sized frame.
box.VideoImageSource = SolidFrame(320, 180);
window.UpdateLayout();
var pane = (UserControl)window.FindName("PreviewPane")!;
var image = FindImageShowing(pane, box.VideoImageSource);
Assert.True(image != null,
"the preview pane never bound the alert box's frame to an Image");
Assert.Equal(Visibility.Visible, image!.Visibility);
Assert.True(image.IsVisible,
"the alert box's Image is Collapsed, so the frame is never rendered");
}
finally
{
window.Close();
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
private static WriteableBitmap SolidFrame(int w, int h)
{
var bitmap = new WriteableBitmap(w, h, 96, 96, PixelFormats.Bgra32, null);
var pixels = new byte[w * h * 4];
for (var i = 0; i < pixels.Length; i += 4)
{
pixels[i] = 40; // B
pixels[i + 1] = 90; // G
pixels[i + 2] = 200; // R
pixels[i + 3] = 255; // opaque
}
bitmap.WritePixels(new Int32Rect(0, 0, w, h), pixels, w * 4, 0);
return bitmap;
}
private static Image? FindImageShowing(DependencyObject parent, ImageSource? source)
=> Walk(parent, e => e is Image i && ReferenceEquals(i.Source, source)) as Image;
private static DependencyObject? Walk(DependencyObject parent, Func<DependencyObject, bool> predicate)
{
for (var i = 0; i < VisualTreeHelper.GetChildrenCount(parent); i++)
{
var child = VisualTreeHelper.GetChild(parent, i);
if (predicate(child)) return child;
var found = Walk(child, predicate);
if (found != null) return found;
}
return null;
}
}
+179
View File
@@ -0,0 +1,179 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using Xunit;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Compositor;
using ytLive.Services.Encoder;
namespace ytLive.Tests;
/// <summary>
/// TASK 47 Good Dog (take two, 2026-09-26): a decoded alert clip must actually
/// reach the OUTPUT canvas. Three live sessions on build 89 all logged a healthy
/// decode — <c>Alert clip start</c> … <c>frames=240 audioChunks=156 failed=False</c>,
/// alert audio in the live mix — and still painted NOTHING in the box. The layer
/// tests pass because they drive a FAKE decoder straight into <c>RenderFrame</c>:
/// nothing ever proves the decoded frame survives the trip through the real
/// compositor. This is that proof — a REAL ffmpeg clip, the REAL
/// <see cref="AlertClipDecoder"/>, the REAL <see cref="AlertOverlayLayer"/> and the
/// REAL <see cref="SceneCompositor"/>, end to end. It is the one test that runs a
/// real codec, and deliberately so: the gap it closes is exactly the one a fake
/// cannot see.
/// </summary>
[Collection("RealApp")]
public sealed class AlertClipOutputTests
{
private const int BoxW = 320;
private const int BoxH = 180;
private const int BoxX = 100;
private const int BoxY = 100;
private const int MasterW = 1920;
private const int MasterH = 1080;
private readonly RealAppHost _app;
public AlertClipOutputTests(RealAppHost app) => _app = app;
[Fact]
public void AlertClip_RealDecode_PaintsPixelsInsideTheAlertBoxRect()
{
_app.Run(Run);
}
private void Run()
{
var locator = new FfmpegLocator();
var ffmpeg = locator.LocateAsync().GetAwaiter().GetResult();
var clip = Path.Combine(Path.GetTempPath(), $"ytLive-test-alert-{Guid.NewGuid():N}.mp4");
try
{
MakeClip(ffmpeg, clip);
var box = new Source
{
Type = SourceType.AlertBox,
Name = "Stream Alerts",
X = BoxX,
Y = BoxY,
Width = BoxW,
Height = BoxH,
AlertVideoPath = clip,
AlertUseDefaultVideo = false,
};
var scene = new Scene { Name = "Live", Elements = { box } };
using var layer = new AlertOverlayLayer(
new AlertRenderer(),
clipDecoderFactory: (path, w, h) => new AlertClipDecoder(
path, w, h, locator, () => new FfmpegDecodeProcess(),
frameRateProbe: new FfmpegFrameRateProbe(locator, () => new FfmpegDecodeProcess())),
clipPathResolver: b => b.AlertVideoPath);
layer.UpdateConfiguredBoxes(new[] { scene });
layer.Enqueue(new ChatMessage
{
Kind = ChatEventKind.SuperChat,
AuthorName = "Funder",
SuperChatDisplayString = "$5.00",
});
Assert.True(layer.IsPlaying);
// The decoder is paced to the media clock, so the first frame arrives in
// wall-clock time — poll for it instead of racing it.
var deadline = DateTime.UtcNow.AddSeconds(20);
while (DateTime.UtcNow < deadline && layer.RenderFrame(box) == null)
Thread.Sleep(25);
layer.Advance(0.4); // past the 0.3s fade-in → opaque
var played = layer.RenderFrame(box);
Assert.True(played != null,
$"the real decoder produced no frame for {clip} — alpha0={AlphaStats(played)}");
Assert.Equal(255, played!.BgraPixels[3]);
var options = new CompositorOptions
{
SourceRectWidth = MasterW,
SourceRectHeight = MasterH,
OutputWidth = MasterW,
OutputHeight = MasterH,
};
var compositor = new SceneCompositor();
var withClip = compositor.Render(scene, e => ReferenceEquals(e, box) ? layer.RenderFrame(box) : null, null, options);
var idle = compositor.Render(scene, _ => null, null, options);
// The proof: the box rect is a COLORFUL picture that the idle canvas does
// not have. Opaque black in both (the compositor fills its base) is not a
// pass — the clip's own pixels have to be there.
var colorful = ColorfulPixels(withClip, BoxX, BoxY, BoxW, BoxH);
var changed = ChangedPixels(withClip, idle, BoxX, BoxY, BoxW, BoxH);
Assert.True(colorful > BoxW * BoxH / 2,
$"the decoded alert frame must paint the box rect in colour, only {colorful}/{BoxW * BoxH} px colourful");
Assert.True(changed > BoxW * BoxH / 2,
$"the alert box rect must differ from the idle canvas, only {changed}/{BoxW * BoxH} px changed");
}
finally
{
try { if (File.Exists(clip)) File.Delete(clip); } catch { /* temp file */ }
}
}
/// <summary>Pixels with actual colour in a rect — opaque black (the compositor's
/// base fill) does not count, a decoded frame does.</summary>
private static int ColorfulPixels(VideoFrame frame, int x, int y, int w, int h)
{
var lit = 0;
for (var row = y; row < y + h && row < frame.Height; row++)
for (var col = x; col < x + w && col < frame.Width; col++)
{
var i = (row * frame.Width + col) * 4;
if (frame.BgraPixels[i] != 0 || frame.BgraPixels[i + 1] != 0 || frame.BgraPixels[i + 2] != 0)
lit++;
}
return lit;
}
private static int ChangedPixels(VideoFrame a, VideoFrame b, int x, int y, int w, int h)
{
var changed = 0;
for (var row = y; row < y + h && row < a.Height; row++)
for (var col = x; col < x + w && col < a.Width; col++)
{
var i = (row * a.Width + col) * 4;
if (a.BgraPixels[i] != b.BgraPixels[i] || a.BgraPixels[i + 1] != b.BgraPixels[i + 1]
|| a.BgraPixels[i + 2] != b.BgraPixels[i + 2] || a.BgraPixels[i + 3] != b.BgraPixels[i + 3])
changed++;
}
return changed;
}
private static string AlphaStats(VideoFrame? frame)
=> frame == null ? "no frame" : $"alpha={frame.BgraPixels[3]}";
/// <summary>A 3s colourful clip with a tone — the same shape the alert box plays.</summary>
private static void MakeClip(string ffmpeg, string path)
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardError = true,
};
foreach (var a in new[]
{
"-y", "-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", $"testsrc2=size={BoxW}x{BoxH}:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "3", "-c:v", "mpeg4", "-q:v", "3", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest", path,
})
psi.ArgumentList.Add(a);
using var proc = Process.Start(psi)!;
var stderr = proc.StandardError.ReadToEnd();
Assert.True(proc.WaitForExit(60_000), "ffmpeg did not finish generating the test clip");
Assert.True(proc.ExitCode == 0, $"ffmpeg failed to generate the test clip: {stderr}");
Assert.True(File.Exists(path), $"ffmpeg produced no clip at {path}");
}
}