Files
LlamaCasty/Services/Compositor/AlertTickerRenderer.cs
T
gramps 7f14ffb11e TASK 47 take four: alert ticker visible in the preview + 3 display methods
The creator confirmed the clip fix ("the video plays now"), then asked where the
scrolling text was — it was invisible, for a structural reason. AlertTickerFrame
existed only as a frame-pump callback blitted into the OUTPUT; PreviewPane.xaml had
no element for it, because the strip is master-width and global, not a Source, so it
cannot ride a per-element Image. Nothing was wrong in the renderer: there was no
consumer in the preview. Same class of defect as the missing IsAlertBox trigger, one
layer up (MyMistakes RULE 5/6).

- tickerPreviewSink on AlertOverlayLayer, published from RefreshAlertPreviews() so it
  is always the UI thread; MainViewModel.AlertTicker writes it into one reused
  WriteableBitmap bound to a new global AlertTickerElement, mirroring SocialBarElement.
- Source.AlertDisplayMethod + panel "Display" selector: TickerScroll / Flash / Solid.
  Flash pulses 0.5s on / 0.5s off for the whole alert; Solid is centred and still.
- The marquee was also unreadable: a fixed 140px/s took ~17s per pass, so a 10s alert
  showed the text once, entering from the right and never crossing. Paced in reads per
  alert instead (TickerReadsPerAlert = 3 inside the alert's own length, speed derived
  from it) — never px/s. Research (websearch: how do OBS/Streamlabs/StreamElements
  alert boxes present announcement timing?) settled the unit: Streamlabs exposes "Alert
  Duration: choose how long your alert stays on your stream" and "Text Delay", never a
  scroll-speed slider (https://support.streamlabs.com/hc/en-us/articles/52499995174299-Setting-up-Your-Streamlabs-Alerts).
  Run is phase-started half a frame in so the first frame isn't blank.
- Persistence: AlertDisplayMethod INTEGER NOT NULL DEFAULT 0 via the idempotent
  table_info migration, appended LAST in the SELECT because the Source reader is
  positional (GetInt32(32..34)) — a mid-list insert would silently shift a neighbour.

Tests: 15 new facts (suite 339/339). RealApp STA host: the pane draws the strip and
collapses at alert end; the layer publishes a real 1920x48 frame for all three methods
and nothing when the ticker is off; three passes counted in 10s by the pill's leading
edge resetting (a seamless marquee never blanks, so an empty frame cannot count a pass);
Solid byte-identical at every moment; Flash on for half of each second; the panel shows
and writes back the choice; the DB round-trips all three alert fields together.

Incidental finding: a bound ItemsSource ComboBox in LeftPanel.xaml broke
LayerReorderPersistenceTests.RealMouseDrag (that test injects PHYSICAL mouse input, so a
load-time re-measure moves the rows out from under the cursor). Rewritten as inline
ComboBoxItems, the shape the chat Font selector already uses in that panel. Recorded as
MyMistakes RULE (8).
2026-09-26 15:10:26 -07:00

170 lines
7.5 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;
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></summary>
public static VideoFrame? Render(string? text, double elapsedSeconds,
AlertDisplayMethod method = AlertDisplayMethod.TickerScroll,
double alertSeconds = 0.0, int readsPerAlert = 3)
{
if (string.IsNullOrWhiteSpace(text)) 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, elapsedSeconds, alertSeconds, readsPerAlert)
: (Width - strip.Width) / 2; // Solid + Flash: centred, still
var pixels = new byte[Width * Height * 4];
CopyStrip(pixels, strip, x);
if (method == AlertDisplayMethod.TickerScroll && x + strip.Width <= 0)
CopyStrip(pixels, strip, x + Width + RepeatGap); // wrapped tail enters from the right
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, 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, VideoFrame strip, int dstX)
{
if (dstX >= Width || dstX + strip.Width <= 0) return;
var x0 = Math.Max(0, dstX);
var x1 = Math.Min(Width, dstX + strip.Width);
var sx0 = x0 - dstX;
var copyBytes = (x1 - x0) * 4;
var sw = strip.Width;
for (var y = 0; y < strip.Height; y++)
Buffer.BlockCopy(strip.BgraPixels, (y * sw + sx0) * 4, dst, (y * Width + 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(new FontFamily("Segoe UI"), 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(Color.FromRgb(0xe9, 0x45, 0x60)), 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);
}
}