The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).
- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
is always the UI thread; MainViewModel.AlertTicker writes it into one reused
WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
showed the text once, entering from the right and never crossing. Paced in reads per
alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
Duration: choose how long your alert stays on your stream" and "Text Delay", never a
scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
table_info migration, appended LAST in the SELECT because the Source reader is
positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.
Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.
Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
21 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— announcement strip: 1920×48 transparent BGRA, text-pill rasterized once per text on the UI thread (cached, unpremultiplied), then pure byte-math per call, so the pump can call it from its own thread every tick with no locking. Three display methods (Source.AlertDisplayMethod, creator-set in the panel):TickerScroll— marquee. Paced in reads per alert, not px/s: one pass takesalertSeconds / TickerReadsPerAlert(3), and the speed is derived from that, so the 10s default clip carries the announcement across three times. The old fixed140px/sneeded ~17s per pass, i.e. barely one sighting in a 10s alert.Flash— the static pill blinks0.5son /0.5soff (1Hz) for the whole alert; the off half returns null so the preview collapses too.Solid— the static pill centred and still for the whole alert. The run is phase-started half a frame in so the first published frame isn't blank, and the wrap copies in from the right the instant the pill clears the left (a seamless loop — the frame never goes empty).
- 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; "Display" selector (Scroll / Flash / Solid); "Volume" slider with % readout. The selector is inline<ComboBoxItem>s bound bySelectedValuePath="Tag", the same shape as the chat Font selector in this panel — NOT a boundItemsSource(see RULE (8) inMyMistakes.md: a data-bound selection re-measures the panel and breaks the real-mouse layer-drag test). 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.
Take four (2026-09-26) — the ticker was output-only, and unreadably paced
The creator confirmed the clip fix ("the video plays now"), then asked two things: "where is the scrolling text? I do not see it" and whether ~3 reads inside the 10-second default alert would be readable. Answer: yes, three is right — and the text was invisible for a structural reason, not a rendering fault.
Bug 1 — the strip had no consumer in the preview. AlertTickerFrame existed only as
a frame-pump callback, blitted into the OUTPUT at (0,0). PreviewPane.xaml had no
element for it because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image (the trap RULE (5) records, one layer up — RULE (6)).
Fix: tickerPreviewSink on the ctor + AlertOverlayLayer (UI-thread DispatcherTimer
only, never the pump thread), the layer publishes from RefreshAlertPreviews(), and
MainViewModel writes it into one reused WriteableBitmap
(AlertTickerImageSource / AlertTickerVisible) bound to a new global
AlertTickerElement in PreviewPane.xaml, mirroring SocialBarElement.
Bug 2 — a fixed px/s cannot express "three reads". See the renderer note above. Incumbent convention agrees: Streamlabs exposes Alert Duration ("choose how long your alert stays on your stream") and Text Delay, never a scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts), so the unit is repeats-per-alert, not pixels-per-second.
Persistence — Source.AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the
idempotent PRAGMA table_info migration (no user_version bump: the guard is the
column's presence). The column is appended last in the SELECT on purpose — the
reader is POSITIONAL (GetInt32(32..34)), so inserting anywhere but the end would
silently shift a neighbour rather than fail to load.
Tests (ytLive.Tests/AlertTickerPreviewDisplayTests.cs, RealApp STA host):
the pane draws the strip and collapses when the alert ends; the layer publishes a real
1920x48 frame for all three methods (and nothing at all when the ticker is off); three
passes are counted in 10s by the pill's leading edge resetting (a seamless marquee never
blanks, so "blank frame" cannot count a pass); Solid is byte-identical at every moment;
Flash is on for half of each second; the panel shows and writes back the chosen method.
LayoutStorePersistenceTests.AlertDisplayMethod_RoundTrips guards the positional-reader
risk by asserting all three alert fields together.
Full suite 339/339.