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; /// /// 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 RenderTargetBitmap, Pbgra32→straight-alpha like /// ), then cached. Each 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. /// public static class AlertTickerRenderer { /// Master-frame width — the ticker spans the full 1920px output. public const int Width = 1920; /// Strip height (pill + vertical padding). public const int Height = 48; private const int RepeatGap = 120; private const int CacheLimit = 48; /// How long each blink half lasts in /// (0.5s on, 0.5s off — a 1Hz pulse for the whole alert). public const double FlashHalfSeconds = 0.5; private static readonly object Gate = new(); private static readonly Dictionary StripCache = new(); /// A frame of presented per /// at 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. /// paces itself from /// over 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. /// / 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. 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); } /// Left edge of the pill for a marquee that must complete /// passes in : /// one pass (off the right edge, across, out of sight) takes that long. /// 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. 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(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); } }