Files
LlamaCasty/TASKS/task-47-alert-videos.md
T
gramps e2ecc248e0 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.
2026-09-26 14:44:57 -07:00

258 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TASK 47 — Alert box video (TASK 43 follow-up): creator-replaceable alert clip + read-time fade + auto ticker
> Catalog: [`TASKS.md`](../TASKS.md). Status: ✅ **SHIPPED 2026-09-26** — the TASK 43
> alert box grows a real **video** celebration: a built-in mp4 loops on every alert unless
> the creator picks their own clip (referenced from disk, never stored in the DB), the
> clip fades in/out in ~0.3s (alpha envelope on the video + volume on the audio, mixed
> into the live stream at unity — no duck), and an auto-composed **message ticker**
> ("Funder — Super Chat · $10.00") scrolls across the very top of the frame, toggleable.
> The six TASK 43 animations remain the intrinsic fallback when no asset is playable.
## Provenance (recorded per the feature-provenance rule)
- **2026-09-24, the creator asked (right after TASK 43 shipped)** — "make the alerts
animate like a video" / "I want to play my own video for alerts." The TASK 43 unit
closed with this as the explicit next step.
- **Lineage:** Research (websearch: how do OBS/StreamElements/Streamlabs alert boxes show
per-alert videos?) settled the shape, cited in the commit message:
- Alert videos play **per-alert** (a fresh decoder per event), never a resident media
player — the OBS "media source reset on activation" model, not a playlist.
- The clip **must not block**: decode is a background pipe (ffmpeg child, rawvideo
frames + f32le audio), paced real-time, so alerts never stall the 60fps frame pump.
- A **freeze-frame at clip EOF fades out** rather than hard-dropping (Streamlabs'
"end by fade" option) — the tail of a long clip dissolves without a new spawn.
- The custom file is served by **path, not bytes** (OBS media-source semantics; a
read-at-play clip lets the creator re-edit the file without a re-import). Decided
with the creator: "never store the video in the layout." The built-in default is the
one BLOB, stamped into the internal `Asset` table at startup.
- **Creator rulings (asked once, held):** custom video + six-animation fallback
(not either/or); ticker on top of the canvas (very top, full width); **no ducking** —
"don't lower my game audio for a tip" (the TASK 43 planner's auto-duck idea was
rejected on the floor); a per-alert **Volume** slider instead.
## Product model (as shipped)
- **Five new `Source` props** (schema v10, additive guarded ALTERs): `AlertVideoPath`
(custom file), `AlertUseDefaultVideo` (bool, default true — the built-in clip),
`AlertShowTicker` (bool, default true), `AlertVideoVolume` (0–1, default 1).
`AlertVideoAssetId` (internal reference to the stamped default asset row).
- **Built-in clip:** `Assets/alert-default.mp4` (~4.9MB, branded 2.5s loop, included as a
csproj Resource). At startup `MainViewModel.StampDefaultAlertVideo()` upserts its bytes
into the `Asset` table and records the row id in the settings key
`AlertDefaultVideoAssetId` (`AlertDefaultVideoKey`). The **prune** step exempts that
key via a UNION so the default never gets cleaned.
- **Per-alert resolver** (`ResolveAlertClipPath` in `MainViewModel.Chat.cs`): custom path
wins ONLY when `AlertUseDefaultVideo == false` AND the file exists; otherwise the
thumbnailed built-in is materialized once to `%TEMP%\ytLive-alert-{id}.mp4`; failure →
the six `AlertRenderer` animations play (fallback, not an error).
- **`Services/AlertClipDecoder.cs`** — `IAlertClipDecoder` (Start/Stop/Dispose +
`FrameAvailable(Action<VideoFrame>)` / `AudioReady(Action<AudioSample>)` /
`Completed(Action)`) + `AlertClipDecoder` impl: ffmpeg `-loglevel error` child
emitting `rawvideo bgra` (`-vf scale=W:H`) on one pipe and `f32le -ar 48000 -ac 2` on
another; `RawVideoFrameReader` (the TASK 21 media-source reader) on the video pipe,
a **carry-buffer** loop on the audio pipe (float-boundary straddles never drop a
sample — the 3-byte PCM16↔4-byte-f32 mismatch that broke the first draft), both
real-time paced. Per-play lifetime: the layer creates one per alert and disposes it at
drain. Prefer `Start`-synced `TrackFrameRate` — the frame pipe throttles to vfr
timestamps; audio paces itself. ffmpeg bgra is opaque (alpha=255) — the layer owns all
alpha.
- **`Services/AlertOverlayLayer.cs`** — clip branch beside the animation path:
`Enqueue` → `BeginClip` (factory + path + box dims seam), `Advance` advances `_elapsed`
(fade-in frame copies scale alpha 0→255 straight-source over the box), events forward
to the audio sink scaled by `volume × fade`, EOF → **freeze-frame** + fade-out over the
same `FadeDurationSeconds = 0.30` envelope → drain (StopClip + dispose, idle null
again). Ticker: `AlertTickerFrame` composes "Author — Kind · amount" from the live
message and scrolls it.
- **`Services/Compositor/AlertTickerRenderer.cs`** — marquee strip: 1920×48 transparent
BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied),
then pure byte-math per call (scroll `pos = elapsed × 140px/s % cycle`, pill drawn
straight-alpha over transparent with a repeat-gap copy), so the pump can call it from
its own thread every tick with no locking.
- **Ticker threading** — dynamic overlay like the social bar, but **never baked**
(the social bar IS baked into `BakeStaticBase`; the ticker is a per-frame marquee):
`SceneCompositor.Render` + `CompositeLayers` take a trailing `tickerFrame` param
(blitted at 0,0 after the social bar), threaded by `FramePump._alertTicker`
(`Func<VideoFrame?>` seam, ctor param) through `RenderScene`/`RenderFull`/
`BuildFullRenderSignature` (mixed into the cache signature so a ticker change
invalidates — ticker is distinct from static-cache-eligible content).
- **Audio path** — `AudioMixer.EnqueueAlertAudio` uses a dedicated ring
(`AlertBufferSeconds = 8` at 48k stereo): resample/upmix to stereo 48k, drained
pre-limiter in `FillAndMix` and added at **unity** (never ducked); `StartLive` clears
it. The layer applies `volume × fade` per sample before forwarding.
- **UI — Stream Alerts section** (LeftPanel, visible only on an `AlertBox`):
"Built-in video" pill; custom path box + Browse…; "Reset to built-in"; "Message
ticker" pill; "Volume" slider with % readout. Wiring: `BrowseAlertVideoCommand` +
`ResetAlertVideoCommand` (CanExecute gates on `Source { Type: AlertBox }`); browse =
OpenFileDialog (mp4/mov/webm), result writes `AlertVideoPath` + switches the pill off.
## Test (Good Dog — ONE integration test per change)
`ytLive.Tests/AlertLayerVideoTests.cs` (RealApp STA host, real WPF raster) —
`AlertVideo_PlaysCustomClip_FadesReadTime_TickerScrolls_ForwardsScaledAudio_DrainsIdle`
drives a **fake `IAlertClipDecoder`** (no ffmpeg in tests) through the whole lifecycle
in one pass: the custom path wins per-source (resolution seam asserts box W/H on the
factory + same source to the resolver); the decoder spawns on play (StartCount 1) and
dies at drain (DisposeCount 1); fade-in rides the 0.3s envelope (alpha 0 → 127 at 0.15s
→ 255, straight-alpha copy); pre-fade audio is dropped while ramp audio forwards at
`volume × fade` (0.8 × 0.5 → [0.2, −0.1]) and full-fade at volume only; the ticker
renders non-null with pixels and **scrolls** between two pump ticks while idle returns
null; EOF freeze → fade-out 127 → drain to `IsPlaying false` + null frame + disposed
decoder.
> Catches a REAL bug the animation path never had: the clip branch of `Advance` failed
> to clear `_current` before `AdvanceToNext`, so after the fade-out the layer stayed
> `IsPlaying` and re-rendered the finished alert via the animation fallback. The clean
> frame literally forced the `Assert.False(layer.IsPlaying)` to see it.
## Files (declared scope)
- `Models/Source.cs` (five alert props + `IsAlertBox`), `Services/LayoutStore.Migrations.cs`
(guarded ALTERs), `Services/LayoutStore.Save.cs` (INSERT + prune UNION),
`Services/LayoutStore.Load.cs` (reader indices),
`Services/LayoutStore.Settings.cs` (`AlertDefaultVideoKey` + asset stamps)
- `Assets/alert-default.mp4` + `ytLive.csproj` (Resource include)
- **NEW** `Services/AlertClipDecoder.cs`, **NEW** `Services/Compositor/AlertTickerRenderer.cs`
- `Services/AlertOverlayLayer.cs` (clip branch + fade + ticker + sink), `Services/Audio/AudioMixer.cs`
(alert ring + `EnqueueAlertAudio` + unity drain), `Services/Compositor/SceneCompositor.cs` +
`Services/Encoder/FramePump.cs` (ticker seam)
- `ViewModels/MainViewModel.cs` + `ViewModels/MainViewModel.Chat.cs` (seams, stamp,
resolver, materialize, browse/reset), `Controls/LeftPanel.xaml` (Stream Alerts section)
- **NEW** `ytLive.Tests/AlertLayerVideoTests.cs`
- Docs: `ai.md`, `TASKS.md`, this file, `Services/index.md`, `HANDOFF.md`, `MyMistakes.md`
## Gate
Clean build 0 warnings (Windows dotnet host); alert tests green (`AlertLayer*
FullyQualifiedName` filter = 2/2). Full suite 318/319 — the one failure
`LayerReorderPersistenceTests.RealMouseDrag…` is the HANDOFF-documented environmental
class (physical-mouse drag no-ops when the desktop/session isn't interactive; it passed
in isolation on the third re-run of this unit; the two sibling reorder tests — identical
persistence path — pass). `scripts/scope-check.sh` green.
## Post-ship crash fix (2026-09-26)
First live run found a crash the sims never could: test-tab alerts work (button commands run on
the UI thread — startup.log shows the alert clip's audio in the live mix, `peakMix 0.375 → 0.891`
with mic and loop at 0.000), but the first **real** message through the chat poller killed the app
at `11:22:01.301`:
System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject.
at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests()
`AlertOverlayLayer.OnMessageReceived` lacked the UI-thread marshal `ChatOverlayLayer.OnMessageReceived`
has: a polled message ran `RefreshAlertPreviews` → `UpdatePreview` on the MTA poller thread and
stamped `alertBox.VideoImageSource` (INPC-raised and WPF-bound) with a `WriteableBitmap` created
there — the binding engine's cross-thread re-bind crashed. Fix: marshal the whole ingest
(`Dispatcher.Invoke`, `Application.Current`-null guard) so enqueue, ticker, and preview writes stay
on the UI thread in lockstep. Good Dog test
`OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread` (red on the old seam: the
WriteableBitmap's `Dispatcher` was the poller's) — committed with the fix.
## Silent-decoder fallback + decode diagnostics (2026-09-26)
Creator repro after the crash fix: On-Air → test → remove and re-add the Stream Alerts
layer → TEST tab event → procs in the chat windows but NOTHING in the alert box.
Startup.log 11:44 (the re-add) shows `Test chat simulation 3 injected` but `peakMix 0.105`
(no alert audio), vs 11:21's 0.375–0.891 (alert audio in the mix) — the re-added box's
alert never reached the mix. No exception anywhere: every failure stage in the clip path
was silent by design. Hunted facts: `LiveScene` is never assigned (the encoder always
renders `StagedScene`, MainViewModel.cs:323) so the re-added box IS in the render graph;
the bake is invalidated per-element on add; the box's DB row (`c85b46ac-…`, X=602 Y=26
W=680 H=200) is healthy; ffmpeg tools incl. the pinned binary decode the default clip to
`scale=680:200` rawvideo correctly to a file. The silent void: a decoder whose `Start()`
succeeds but whose task yields no frames AND no audio leaves `_clip != null` with
`_latestClipFrame == null` → `RenderClipFrame` returns null forever → permanently blank
box, no animation fallback (the fallback only ran on setup-time throws) and no log trail.
Fix (commit `…)`: a no-frame grace in `AlertOverlayLayer.Advance`
(`NoFrameFallbackSeconds = 1.0`) — a started clip that has produced neither a frame nor
an audio chunk inside the grace is torn down (`StopClip`), `_elapsed` resets, and the SAME
alert continues as the six-animation render (never a blank box, never drained early).
Diagnostics stop the lying silence: `BeginClip` logs the box id + resolved path + size and
the setup failure that used to only `Debug.WriteLine`; `AlertClipDecoder.RunAsync` now logs
frame/chunk end-state and the exception it used to swallow. Good Dog test
`SilentDecoder_FallsBackToTheAnimationAfterTheNoFrameGrace` pins the whole contract —
transparent inside the grace, decoder disposed past it, animation rendering, drains to
idle. This lands the corpus at 321, with the one known-env flake (RealMouseDrag reorder,
passes with no fullscreen windows up) — unrelated to this change. Unit was the direct
lineage of bug surface: **a decoder booth production failure surfaces as dead air.**
## Video-frame pacing fix (2026-09-26)
Second creator repro after the fallback commit (12:35 session): 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) — yet
"no video plays in the web-alert box", "like you lost the video". The hole was never
decode: **the video pipe was unpaced.** `AlertClipDecoderFor` (MainViewModel.Chat.cs:113)
built the decoder with no `frameRateProbe`, so `RunAsync` computed
`frameDuration = TimeSpan.Zero` and `RunVideoAsync`'s pace (`if (frameDuration > 0)`)
was skipped — all 240 frames (a 10s, 680×200 clip, ~130MB) dumped through the pipe in
the first ~1-2s as fast as ffmpeg read them, then the box sat on the LAST frame frozen
for the remaining ~6-8s while the audio chunk-pacer (50ms/chunk × 156) ran out its
real-time cadence. To the eye: a blur then a dead frame = "no video / you lost the
video". The media path already wired exactly this seam (`MediaVideoSource` gets
`frameRateProbe: new FfmpegFrameRateProbe(…)`, MainViewModel.cs:308) — the alert
factory just never supplied one.
Fix (commit `…`): `AlertClipDecoderFor` now passes
`frameRateProbe: new FfmpegFrameRateProbe(new FfmpegLocator(), () => new FfmpegDecodeProcess())`
so the 240 frames pace at the clip's native ~24fps (10s real-time), matching the audio
cadence. Good Dog test `AlertClipDecoderTests.AlertClipDecoder_PacesVideoFramesToTheProbedFrameRate`
drives the REAL `AlertClipDecoder` through the same fake-process/fake-probe seam the
media test uses (3 frames, probe 100fps → one ~10ms pacing delay per frame) — red on
the old factory (no delay), green with a probe. Full suite 321/322, one known-env flake
(RealMouseDrag reorder). **Derivative lesson → `MyMistakes.md`: a decoder that reads
faster than wall-clock needs an explicit pace; "plays but you don't see it" is pacing,
not decode.**
## Open follow-ups (NOT this unit)
- TASK 3 item 20 (RewardEvent SQLite persistence half) and item 16 (Text source) remain
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.
## 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.