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
192 lines
8.7 KiB
C#
192 lines
8.7 KiB
C#
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive.Models;
|
|
|
|
namespace ytLive.Services.Compositor;
|
|
|
|
/// <summary>
|
|
/// Rasterizes the stream-alert ticker into a transparent BGRA strip that the
|
|
/// output compositor overlays along the very top edge of the master frame
|
|
/// (TASK 47). The strip is text ("Viewer — Super Chat · $12.34") inside a dark
|
|
/// pill with the LlamaCasty accent bar, rendered ONCE per unique text on the UI
|
|
/// thread (WPF <c>RenderTargetBitmap</c>, Pbgra32→straight-alpha like
|
|
/// <see cref="SocialBarRenderer"/>), then cached. Each <see cref="Render"/> call
|
|
/// is pure byte-math on that cached raster producing a scrolling marquee frame,
|
|
/// so the frame pump can call it from any thread for every output frame at
|
|
/// 30fps without a Dispatcher round-trip.
|
|
/// </summary>
|
|
public static class AlertTickerRenderer
|
|
{
|
|
/// <summary>Master-frame width — the ticker spans the full 1920px output.</summary>
|
|
public const int Width = 1920;
|
|
|
|
/// <summary>Strip height (pill + vertical padding).</summary>
|
|
public const int Height = 48;
|
|
|
|
private const int RepeatGap = 120;
|
|
private const int CacheLimit = 48;
|
|
|
|
/// <summary>How long each blink half lasts in <see cref="AlertDisplayMethod.Flash"/>
|
|
/// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert).</summary>
|
|
public const double FlashHalfSeconds = 0.5;
|
|
|
|
public static VideoFrame? Render(Ticker ticker, string? text, double elapsedSeconds,
|
|
double alertSeconds, int readsPerAlert, int width, int height)
|
|
{
|
|
if (ticker == null) throw new ArgumentNullException(nameof(ticker));
|
|
return ticker.IsEnabled
|
|
? Render(text, elapsedSeconds, ticker.DisplayMethod, alertSeconds, readsPerAlert, width, height)
|
|
: null;
|
|
}
|
|
|
|
private static readonly object Gate = new();
|
|
private static readonly Dictionary<string, VideoFrame> StripCache = new();
|
|
|
|
/// <summary>A frame of <paramref name="text"/> presented per
|
|
/// <paramref name="method"/> at <paramref name="elapsedSeconds"/> into the alert,
|
|
/// or null when there is nothing to show (no text, or a Flash in its off half).
|
|
/// Thread-safe: reads the cached raster and does row copies only.
|
|
/// <para><see cref="AlertDisplayMethod.TickerScroll"/> paces itself from
|
|
/// <paramref name="readsPerAlert"/> over <paramref name="alertSeconds"/> rather
|
|
/// than a fixed pixels-per-second: the old constant (140px/s ≈ a 17s pass) meant
|
|
/// a 10s alert showed the text barely once, entering from the right and never
|
|
/// crossing. The creator's target is three readable passes per alert.</para>
|
|
/// <para><paramref name="width"/>/<paramref name="height"/> default to the
|
|
/// full-master strip. They are parameters because the ticker now renders INTO the
|
|
/// Stream Alerts box (creator ruling 2026-09-26) and therefore has to be the box's
|
|
/// size: a 1920-wide strip clipped to an 800px box would hide the announcement
|
|
/// off the right edge for most of its pass, which is the opposite of readable.</para></summary>
|
|
public static VideoFrame? Render(string? text, double elapsedSeconds,
|
|
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
|
|
double alertSeconds = 0.0, int readsPerAlert = 3,
|
|
int width = Width, int height = Height)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
|
if (width <= 0 || height <= 0) return null;
|
|
var strip = GetStrip(text!);
|
|
if (strip == null) return null;
|
|
|
|
if (method == AlertDisplayMethod.Flash
|
|
&& elapsedSeconds % (FlashHalfSeconds * 2) >= FlashHalfSeconds)
|
|
return null; // off half of the pulse
|
|
|
|
var x = method == AlertDisplayMethod.TickerScroll
|
|
? ScrollX(strip.Width, width, elapsedSeconds, alertSeconds, readsPerAlert)
|
|
: (width - strip.Width) / 2; // Solid + Flash: centred, still
|
|
|
|
var pixels = new byte[width * height * 4];
|
|
CopyStrip(pixels, width, height, strip, x);
|
|
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
|
|
CopyStrip(pixels, width, height, strip, x + width + RepeatGap); // wrapped tail enters
|
|
return new VideoFrame(width, height, pixels);
|
|
}
|
|
|
|
/// <summary>Left edge of the pill for a marquee that must complete
|
|
/// <paramref name="readsPerAlert"/> passes in <paramref name="alertSeconds"/>:
|
|
/// one pass (off the right edge, across, out of sight) takes that long.
|
|
/// <para>The run is phase-started half a frame in, so the very first frame of an
|
|
/// alert already shows the announcement instead of a blank strip sitting exactly
|
|
/// off the right edge.</para></summary>
|
|
private static int ScrollX(int stripWidth, int width, double elapsedSeconds,
|
|
double alertSeconds, int readsPerAlert)
|
|
{
|
|
var cycle = width + stripWidth + RepeatGap;
|
|
var pass = Math.Max(0.5, alertSeconds) / Math.Max(1, readsPerAlert);
|
|
var speed = cycle / pass;
|
|
var pos = (width / 2 + (int)(elapsedSeconds * speed)) % cycle;
|
|
return width - pos;
|
|
}
|
|
|
|
|
|
private static void CopyStrip(byte[] dst, int dstW, int dstH, VideoFrame strip, int dstX)
|
|
{
|
|
if (dstX >= dstW || dstX + strip.Width <= 0) return;
|
|
var x0 = Math.Max(0, dstX);
|
|
var x1 = Math.Min(dstW, dstX + strip.Width);
|
|
var sx0 = x0 - dstX;
|
|
var copyBytes = (x1 - x0) * 4;
|
|
var sw = strip.Width;
|
|
// The strip is rasterised at its natural 48px; the target box can be shorter,
|
|
// and copying strip.Height rows into a shorter dst would run off the end of the
|
|
// array. Clip the rows, not the buffer.
|
|
var rows = Math.Min(strip.Height, dstH);
|
|
for (var y = 0; y < rows; y++)
|
|
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * dstW + x0) * 4, copyBytes);
|
|
}
|
|
|
|
private static VideoFrame? GetStrip(string text)
|
|
{
|
|
lock (Gate)
|
|
{
|
|
if (StripCache.TryGetValue(text, out var cached)) return cached;
|
|
}
|
|
var strip = RenderStrip(text);
|
|
if (strip == null) return null;
|
|
lock (Gate)
|
|
{
|
|
if (StripCache.Count >= CacheLimit) StripCache.Clear();
|
|
if (!StripCache.TryGetValue(text, out var existing))
|
|
StripCache[text] = strip;
|
|
return StripCache[text];
|
|
}
|
|
}
|
|
|
|
private static VideoFrame? RenderStrip(string text)
|
|
{
|
|
// WPF rendering is single-threaded; the pump thread resolves to the UI
|
|
// thread at most once per unique text (then hits the cache).
|
|
var app = Application.Current;
|
|
if (app != null && !app.Dispatcher.CheckAccess())
|
|
{
|
|
VideoFrame? result = null;
|
|
app.Dispatcher.Invoke(() => result = RenderStrip(text));
|
|
return result;
|
|
}
|
|
|
|
const double pillHeight = 34;
|
|
const double accentW = 6;
|
|
const double padX = 16;
|
|
const double gap = 8;
|
|
|
|
var font = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
|
|
var ft = new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
|
|
font, 22, Brushes.White, 1.0);
|
|
|
|
var pillW = (int)Math.Ceiling(padX + accentW + gap + ft.Width + padX);
|
|
var top = (Height - pillHeight) / 2.0;
|
|
var textTop = top + (pillHeight - ft.Height) / 2.0;
|
|
|
|
var visual = new DrawingVisual();
|
|
using (var dc = visual.RenderOpen())
|
|
{
|
|
var pill = new Rect(0, top, pillW, pillHeight);
|
|
dc.DrawRoundedRectangle(new SolidColorBrush(Color.FromArgb(0xE6, 0x10, 0x13, 0x24)), null, pill, pillHeight / 2, pillHeight / 2);
|
|
dc.DrawRoundedRectangle(new SolidColorBrush(OutputPalette.Live), null,
|
|
new Rect(padX, top + (pillHeight - 12) / 2.0, accentW, 12), 2, 2);
|
|
dc.DrawText(ft, new Point(padX + accentW + gap, textTop));
|
|
}
|
|
|
|
var bitmap = new RenderTargetBitmap(pillW, Height, 96, 96, PixelFormats.Pbgra32);
|
|
bitmap.Render(visual);
|
|
|
|
var pixels = new byte[pillW * Height * 4];
|
|
bitmap.CopyPixels(pixels, pillW * 4, 0);
|
|
|
|
// Pbgra32 is premultiplied — unpremultiply so the compositor's
|
|
// straight-alpha source-over doesn't halo the text edges.
|
|
for (var i = 0; i < pixels.Length; i += 4)
|
|
{
|
|
var a = pixels[i + 3];
|
|
if (a == 0 || a == 255) continue;
|
|
pixels[i] = (byte)(pixels[i] * 255 / a);
|
|
pixels[i + 1] = (byte)(pixels[i + 1] * 255 / a);
|
|
pixels[i + 2] = (byte)(pixels[i + 2] * 255 / a);
|
|
}
|
|
return new VideoFrame(pillW, Height, pixels);
|
|
}
|
|
}
|