Files
LlamaCasty/TASKS/task-47-alert-videos.md
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
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).
2026-09-26 15:10:26 -07:00

21 KiB
Raw Permalink 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 — 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 takes alertSeconds / TickerReadsPerAlert(3), and the speed is derived from that, so the 10s default clip carries the announcement across three times. The old fixed 140px/s needed ~17s per pass, i.e. barely one sighting in a 10s alert.
    • Flash — the static pill blinks 0.5s on / 0.5s off (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 + 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; "Display" selector (Scroll / Flash / Solid); "Volume" slider with % readout. The selector is inline <ComboBoxItem>s bound by SelectedValuePath="Tag", the same shape as the chat Font selector in this panel — NOT a bound ItemsSource (see RULE (8) in MyMistakes.md: a data-bound selection re-measures the panel and breaks the real-mouse layer-drag test). 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.

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.