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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user