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.
17 KiB
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
Assettable 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
Sourceprops (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 startupMainViewModel.StampDefaultAlertVideo()upserts its bytes into theAssettable and records the row id in the settings keyAlertDefaultVideoAssetId(AlertDefaultVideoKey). The prune step exempts that key via a UNION so the default never gets cleaned. - Per-alert resolver (
ResolveAlertClipPathinMainViewModel.Chat.cs): custom path wins ONLY whenAlertUseDefaultVideo == falseAND the file exists; otherwise the thumbnailed built-in is materialized once to%TEMP%\ytLive-alert-{id}.mp4; failure → the sixAlertRendereranimations play (fallback, not an error). Services/AlertClipDecoder.cs—IAlertClipDecoder(Start/Stop/Dispose +FrameAvailable(Action<VideoFrame>)/AudioReady(Action<AudioSample>)/Completed(Action)) +AlertClipDecoderimpl: ffmpeg-loglevel errorchild emittingrawvideo bgra(-vf scale=W:H) on one pipe andf32le -ar 48000 -ac 2on 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. PreferStart-syncedTrackFrameRate— 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),Advanceadvances_elapsed(fade-in frame copies scale alpha 0→255 straight-source over the box), events forward to the audio sink scaled byvolume × fade, EOF → freeze-frame + fade-out over the sameFadeDurationSeconds = 0.30envelope → drain (StopClip + dispose, idle null again). Ticker:AlertTickerFramecomposes "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 (scrollpos = 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+CompositeLayerstake a trailingtickerFrameparam (blitted at 0,0 after the social bar), threaded byFramePump._alertTicker(Func<VideoFrame?>seam, ctor param) throughRenderScene/RenderFull/BuildFullRenderSignature(mixed into the cache signature so a ticker change invalidates — ticker is distinct from static-cache-eligible content). - Audio path —
AudioMixer.EnqueueAlertAudiouses a dedicated ring (AlertBufferSeconds = 8at 48k stereo): resample/upmix to stereo 48k, drained pre-limiter inFillAndMixand added at unity (never ducked);StartLiveclears it. The layer appliesvolume × fadeper 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 onSource { Type: AlertBox }); browse = OpenFileDialog (mp4/mov/webm), result writesAlertVideoPath+ 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
Advancefailed to clear_currentbeforeAdvanceToNext, so after the fade-out the layer stayedIsPlayingand re-rendered the finished alert via the animation fallback. The clean frame literally forced theAssert.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, NEWServices/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 realAlertClipDecoder+ realAlertOverlayLayer+ realSceneCompositor, 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— realMainWindow+ realPreviewPane, an AlertBox handed an opaqueWriteableBitmap, assert the boundImageisVisible. Red withExpected: Visible / Actual: Collapsedbefore the fix, green after.
Full suite 324/324.