Live test session proved the native alert box DID play (the alert ring was the only source in the mix: micLevel/loopLevel 0.000 while peakMix went 0.375->0.733->0.891 after the sim injections) but the app crashed at 11:22:01.301 the moment a REAL message round-tripped through the poller: System.ArgumentException: Must create DependencySource on same Thread as the DependencyObject at ...MS.Internal.Data.DataBindEngine.ProcessCrossThreadRequests() OnMessageReceived ran RefreshAlertPreviews on the MTA poller thread and stamped the WPF-bound VideoImageSource with a WriteableBitmap created there; the binding engine's cross-thread re-bind killed the process. ChatOverlayLayer already marshals this exact seam (Dispatcher.Invoke) - mirror it, guarded for Application.Current null so the pure test seams still run inline. Good Dog test: AlertLayerVideoTests.OnMessageReceived_FromPollerThread_MarshalsPreviewWritesToTheUiThread calls the seam from a raw MTA Thread while the RealApp loop runs, then asserts on the UI thread that the preview bitmap's Dispatcher is the App's (red on the old seam, green on the fix). WPF cross-thread rule recorded in MyMistakes.md. Full suite 320/320, clean build 0 warnings, scope-check green.
11 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.
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.