7f14ffb11e
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).
170 lines
7.5 KiB
C#
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);
|
|
}
|
|
} |