The broadcast picture is part of the app, so it stops carrying its own colours. Alert cards, the ticker and the branding flash now paint from OutputPalette, which Mirrors DESIGN.md for the brand tokens and owns the alert box's six-event accent language (gold/violet/pink — content, not chrome; the standard has no purple and should not grow one, but they live in ONE file instead of 57 scattered literals). It is a separate type because the renderers run on the frame-pump thread and must not touch application resources. The duplication is made safe by OutputPaletteTests: it parses Themes/Tokens.xaml and fails if any mirrored token drifts, and fails if a renderer reintroduces a colour literal or "Segoe UI". Ticker and flash glyphs now use the bundled Inter instead of Segoe UI. Neon red is the standard live red — one red in the product, including the credit. Fixed on the way: the palette's font fields were static pack-URI initialisers, which throw TypeInitializationException in any host without a WPF Application (the test process caught it); they are lazy now with a name fallback.
182 lines
8.3 KiB
C#
182 lines
8.3 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>
|
|
/// <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);
|
|
}
|
|
} |