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:
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user