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
+79 -28
View File
@@ -58,6 +58,8 @@ public sealed class AlertOverlayLayer : IDisposable
private ChatMessage? _current;
private Source? _currentBox;
private sealed record TickerFeed(string Text, double Duration);
private volatile TickerFeed? _tickerFeed;
private IAlertClipDecoder? _clip;
private VideoFrame? _latestClipFrame;
private bool _clipEof;
@@ -66,8 +68,6 @@ public sealed class AlertOverlayLayer : IDisposable
private int _clipHeight;
private bool _clipSawFrame;
private bool _clipAudioHeard;
private string? _tickerText;
private AlertDisplayMethod _tickerMethod = AlertDisplayMethod.TickerScroll;
private double _elapsed;
private IEnumerable<Scene>? _scenes;
@@ -139,6 +139,7 @@ public sealed class AlertOverlayLayer : IDisposable
BeginClip();
}
_contentVersion++;
RefreshTickerFeed();
}
/// <summary>Advance the playback clock (the ticker's beat — and the test seam;
@@ -172,6 +173,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
return;
@@ -184,6 +186,7 @@ public sealed class AlertOverlayLayer : IDisposable
_elapsed = 0;
BeginClip();
_contentVersion++;
RefreshTickerFeed();
return;
}
@@ -193,6 +196,7 @@ public sealed class AlertOverlayLayer : IDisposable
_current = null;
_elapsed = 0;
AdvanceToNext();
RefreshTickerFeed();
}
_contentVersion++;
}
@@ -249,27 +253,55 @@ public sealed class AlertOverlayLayer : IDisposable
/// <see cref="VideoFrame.OriginX"/>/<see cref="VideoFrame.OriginY"/> instead of
/// a hardcoded 0. <b>No alert box in the scene means no ticker at all</b> — there
/// is no longer a global position for it to live in.</para></summary>
public VideoFrame? AlertTickerFrame
{
get
{
if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null;
var box = _currentBox;
if (box == null) return null;
public VideoFrame? AlertTickerFrame => AlertBoxTickerFrame(_currentBox);
var w = (int)Math.Max(1, Math.Round(box.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(box.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
_tickerText!, _elapsed, _tickerMethod, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(box.X), (int)Math.Round(box.Y)),
};
public VideoFrame? AlertBoxTickerFrame(Source? alertBox)
{
if (_current == null || alertBox is not { Type: SourceType.AlertBox }) return null;
var w = (int)Math.Max(1, Math.Round(alertBox.Width));
var h = (int)Math.Max(1, Math.Min(
Math.Round(alertBox.Height), AlertTickerRenderer.Height));
var frame = AlertTickerRenderer.Render(
alertBox.Ticker, ComposeTicker(_current), _elapsed, AlertSeconds(),
TickerReadsPerAlert, w, h);
return frame == null
? null
: new VideoFrame(frame.Width, frame.Height, frame.BgraPixels)
{
Placement = ((int)Math.Round(alertBox.X), (int)Math.Round(alertBox.Y)),
};
}
public VideoFrame? RenderSceneTicker(Source tickerSource)
{
if (tickerSource.Type != SourceType.Ticker || !tickerSource.Ticker.IsEnabled)
return null;
var feed = _tickerFeed;
if (feed == null) return null;
return AlertTickerRenderer.Render(
tickerSource.Ticker,
feed.Text,
_elapsed,
feed.Duration,
TickerReadsPerAlert,
(int)Math.Max(1, Math.Round(tickerSource.Width)),
(int)Math.Max(1, Math.Round(tickerSource.Height)));
}
private void RefreshTickerFeed()
{
if (_current == null)
{
_tickerFeed = null;
return;
}
var messages = new[] { _current }.Concat(_queue).ToArray();
_tickerFeed = new TickerFeed(
string.Join(" • ", messages.Select(ComposeTicker)),
messages.Sum(DurationOf));
}
/// <summary>How many times the marquee must cross the frame per alert, so the
@@ -354,17 +386,11 @@ public sealed class AlertOverlayLayer : IDisposable
private void BeginClip()
{
StopClip();
_tickerText = null;
if (_current == null) return;
var box = _scenes?.SelectMany(s => s.Elements).OfType<Source>()
.FirstOrDefault(s => s.Type == SourceType.AlertBox);
_currentBox = box;
if (box != null && box.AlertShowTicker)
{
_tickerText = ComposeTicker(_current);
_tickerMethod = box.AlertDisplayMethod;
}
if (box == null) return;
StartSoundByte(box);
@@ -552,6 +578,9 @@ public sealed class AlertOverlayLayer : IDisposable
};
UpdatePreview(alertBox);
}
foreach (var ticker in scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void RefreshAlertPreviews()
@@ -566,6 +595,27 @@ public sealed class AlertOverlayLayer : IDisposable
foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.AlertBox))
UpdatePreview(alertBox);
foreach (var ticker in _scenes.SelectMany(s => s.Elements).OfType<Source>()
.Where(s => s.Type == SourceType.Ticker))
UpdateTickerPreview(ticker);
}
private void UpdateTickerPreview(Source ticker)
{
var frame = RenderSceneTicker(ticker);
if (frame == null)
{
ticker.VideoImageSource = null;
return;
}
var bitmap = ticker.VideoImageSource;
if (bitmap == null || bitmap.PixelWidth != frame.Width || bitmap.PixelHeight != frame.Height)
{
bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
ticker.VideoImageSource = bitmap;
}
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
}
public void Dispose()
@@ -574,6 +624,7 @@ public sealed class AlertOverlayLayer : IDisposable
_ticker?.Stop();
_queue.Clear();
_current = null;
_tickerFeed = null;
StopClip();
}
}
}