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">
<Setter Property="Visibility" Value="Visible"/>
</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">
<Setter Property="Visibility" Value="Visible"/>
<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
creator's go / milestone signal):
| commit | what |
|---|---|
| `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
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). Full suite 320/320.
3. `80038ff` — **silent-decoder fallback + diagnostics** (no-frame grace 1.0s → six-animation;
BeginClip/RunAsync logging). Full suite 321 with the **known env flake** (RealMouseDrag
reorder failing while windows are up).
4. *(uncommitted next — this session)* — **video pacing fix** (below). Scope:
`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.
## THE ANSWER (do not re-derive this)
The alert video was **never drawn**, never mis-decoded. Build 90's diagnostics showed
`Alert preview: frame=680x200 a255` and `Output resolver: frame 680x200 playing=True`
every second for the whole clip — a real, opaque, correctly-sized frame reaching BOTH
consumers. `Controls/PreviewPane.xaml` keeps the per-element `Image` `Collapsed` unless a
DataTrigger fires, and there was **no `IsAlertBox` trigger** (only IsImageSource /
IsChatBox / IsWebSource / IsWebcam). Chat boxes rendered; alert boxes never could.
Removing/re-adding the layer could not have fixed it.
## ✅ 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
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).
## Working tree
Clean apart from the uncommitted work unit above (it is committed; check `git status`).
If the app is running, `ytLive.dll`/`.exe` are LOCKED and any build fails with MSB3021/
MSB3027 — the creator must close ytLive first. That is normal, not a broken build.
## ✅ "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 →
procs in chat but **nothing in the alert box**. startup.log 11:44 `peakMix 0.105` (no alert audio)
vs 11:21's 0.375–0.891. Every stage of the clip path was silent by design; a decoder whose `Start()`
succeeds but yields **no frames AND no audio** left `_clip != null` with `_latestClipFrame == null`
→ blank box forever. Fix: no-frame grace (1.0s) in `AlertOverlayLayer.Advance` → tear down + same
alert as six-animation; BeginClip/RunAsync diagnostics stop the silence. Good Dog:
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace`.
## Queued next (creator's words, 2026-09-26) — the Good Dog queue
1. **Test-console chat must not clear the chat window.** "when I add a chat message from
the test console, or from any console, that chat text should not clear the current
chat window."
2. **Test pull-out chat input: 20px right padding** — the text box is clipped at the right
edge of the div; needs 20px padding against the div's right side.
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)
Creator repro after `80038ff` (12:35 session): still "no video plays in the web-alert box", "like
you lost the video" — but the new diagnostics PROVED the decoder healthy: `Alert clip start: box …`
→ `Alert clip end: … frames=240 audioChunks=156 failed=False` twice, alert audio in the live mix
(`peakMix 0.277 → 0.733`, mic/loop 0.000). Root cause was **pacing, never decode**:
`AlertClipDecoderFor` (MainViewModel.Chat.cs:113) built the decoder with **no frame-rate probe** →
`RunAsync` computed `frameDuration = TimeSpan.Zero` → `RunVideoAsync`'s pace step
(`if (frameDuration > 0)`) was dead code → all 240 frames of the 10s clip dumped through the pipe
in the first ~1-2s (130MB as fast as ffmpeg read), then the box sat frozen on the LAST frame while
the audio pipeline paced 156 chunks at real-time ~7.8s. Reading exactly like "no video / you lost
it". The media path already had the seam wired (`MediaVideoSource` gets
`frameRateProbe: new FfmpegFrameRateProbe(…)`, MainViewModel.cs:308) — the alert factory just
never passed one.
**Fix (uncommitted):** `AlertClipDecoderFor` passes
`frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())` so
video paces at the clip's native ~24fps (10s real-time) in lockstep with audio. Good Dog test
`AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate` drives the REAL
`AlertClipDecoder` through the media-style fake process + fake probe + recorded delay seam
(3 frames at 100fps → one ~10ms delay per frame; red on the old factory, green now). Full suite
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.
## Landmines / facts worth keeping
- The pinned BtbN ffmpeg has **no libx264** — generate test clips with `-c:v mpeg4`.
- `SceneCompositor.Render` fills its base with **opaque black**, so "any non-zero byte"
is not proof a layer painted. Compare against an idle render, or count COLOURFUL px.
- `sqlite3` on WSL: `/home/gramps/android-sdk/platform-tools/sqlite3`. Extract a BLOB with
`SELECT writefile('/tmp/opencode/x.bin', Data) FROM Asset WHERE Id='…'` (Linux paths
only — a `C:/…` path creates a junk `C:` dir IN THE REPO; one was made and removed).
- Layout DB: `/mnt/c/Users/gramp/AppData/Roaming/ytLlive/ytLlive.db` (tables: `Asset`
`Id/Hash/Data`, `Settings`, `Source`, `Scene`, …). Alert default video setting key:
`AlertDefaultVideoAssetId`. ffmpeg/ffprobe live in
`/mnt/c/Users/gramp/AppData/Roaming/ytLlive/tools/` (run the `.exe` directly from WSL).
- Two diagnostics were ADDED and are still in the tree (once-per-second, alert only):
`Output resolver: alert box …` (MainViewModel.cs) and `Alert preview: box …`
(AlertOverlayLayer.cs). Decide with the creator whether to keep them or strip them now
that the root cause is known.
- `PreviewPane.xaml` has the same latent gap for `IsMediaSource` (no trigger either) —
NOT fixed (out of scope), noted as a follow-up.
- Known perf item, untouched: FramePump stalls ~140-180ms renders
(`render=full-render split=0 … totalMs=140`). Unrelated to the alert bug.
- YouTube rejects the `complete` transition (403) at end-of-stream; `enableAutoStop`
finishes the broadcast. Harmless, already handled.
+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
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.
**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 string? _frameKey;
private VideoFrame? _cachedFrame;
private DateTime _lastPreviewLog = DateTime.MinValue;
public AlertOverlayLayer(
AlertRenderer renderer,
@@ -428,6 +429,19 @@ public sealed class AlertOverlayLayer : IDisposable
try
{
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)
{
alertBox.VideoImageSource = null;
+44 -1
View File
@@ -211,4 +211,47 @@ not decode.**
queued, unchanged.
- 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
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");
}
}
// 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;
}
private DateTime _lastAlertResolveLog = DateTime.MinValue;
private DateTime _lastWebcamNullLog = DateTime.MinValue;
/// <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}");
}
}