feat(alerts): add scene-local recent ticker

Model the AlertBox and standalone ticker with shared settings; scene tickers render the in-memory active and queued events without saving history. Keep AlertBox content active-event-only.

Ref: https://obsproject.com/kb/obs-studio-overview
This commit is contained in:
2026-11-02 10:47:48 -08:00
parent 6208cb9409
commit 20f059422b
20 changed files with 339 additions and 90 deletions
+21 -11
View File
@@ -98,16 +98,24 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
EOF → freeze-frame → fade-out → drain (the clip branch of `Advance` **clears
`_current` before `AdvanceToNext`** — the miss that the TASK 47 test caught). Audio
forwards to the mixer sink scaled by `volume × fade`.
- **Alert ticker (announcement strip):** `AlertTickerFrame` (in the layer) composes
- **Ticker settings and alert feed:** `Models/Ticker.cs` is shared by the
AlertBox and standalone `SourceType.Ticker` layers. Each source owns a distinct
settings instance (`IsEnabled` + `DisplayMethod`); the existing `AlertShowTicker`
and `AlertDisplayMethod` columns persist those settings on their own Source row,
so this is additive without a schema migration. The standalone ticker can be
added once in each scene and shows the in-memory active event plus queued events.
Completed events are not retained as history. The AlertBox ticker uses the same
renderer/settings shape but shows only the active event.
- **AlertBox ticker (announcement strip):** `AlertBoxTickerFrame` composes
"Author — Kind · amount" and hands it to `Services/Compositor/AlertTickerRenderer.cs` —
a strip rasterized **on the UI thread once per text** (cached, unpremultiplied
straight-alpha pill) then positioned by pure byte-math per call, so the pump calls it
from its own thread with no locking. **It is a dynamic overlay, NEVER baked** (the
social bar IS baked into `BakeStaticBase`): `SceneCompositor.Render` +
`CompositeLayers` take a trailing `tickerFrame` threaded from
`FramePump._alertTicker` (`Func<VideoFrame?>` ctor seam) through
`FramePump._alertTicker` (`Func<Scene, VideoFrame?>` ctor seam) through
`RenderScene`/`RenderFull` and **mixed into `BuildFullRenderSignature`** so a scroll
changes the cache key. Toggled by `AlertShowTicker`.
changes the cache key. It reads the active scene's AlertBox settings.
- **It draws INSIDE the Stream Alerts box (creator ruling 2026-09-26).** Was a global
1920px bar pinned to the top edge: *"the ticker should appear over the stream alerts
video, not over the entire preview window"*. The strip is now rendered at the
@@ -135,14 +143,16 @@ published decisions (recorded in `TASKS/task-43-native-alerts.md`):
old fixed `140px/s` took ~17s per pass, so a 10s alert showed the message once.
Same convention as the incumbent (Streamlabs: "Alert Duration" + "Text Delay", not a
scroll-speed slider). Flash's off half returns null.
- **A ticker still needs its own preview element, not a `Source`.** It is a frame-pump
producer, so it never rides a per-element `Image`; `AlertOverlayLayer` takes a
`tickerPreviewSink` and publishes from `RefreshAlertPreviews()` (UI thread only —
the pump thread must never touch a `WriteableBitmap`); the VM writes it into one
reused `WriteableBitmap` (`AlertTickerImageSource`/`AlertTickerVisible`) bound to
`AlertTickerElement` in `PreviewPane.xaml`, mirroring `SocialBarElement`. The rect
is re-raised on **every** published frame, not just on a bitmap resize, so a box
moved or resized mid-alert tracks live.
- **AlertBox preview:** `tickerPreviewSink` publishes from
`RefreshAlertPreviews()` (UI thread only — the pump thread must never touch a
`WriteableBitmap`); the VM writes the box-sized frame into the reused bitmap bound
to `AlertTickerElement` in `PreviewPane.xaml`. The rect is re-raised on every
published frame so a box moved or resized mid-alert tracks live.
- **Standalone ticker preview/output:** it is an ordinary dynamic Source with a
per-element frame. `AlertOverlayLayer` updates each scene ticker's reusable
`VideoImageSource` on its UI timer for preview; the output resolver renders the
same source with that source's Ticker settings. It is not the AlertBox global
ticker callback, so each scene's layer and settings remain independent.
- **Two measurement traps here:** a seamless marquee never goes blank (a wrapped copy
enters as the pill clears), so count a pass by the pill's leading edge resetting, not
by an empty frame; and the run is phase-started half a frame in, or the first