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

17 KiB
Raw Blame History

TASK 47 — Alert box video (TASK 43 follow-up): creator-replaceable alert clip + read-time fade + auto ticker

Catalog: 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.