Files
LlamaCasty/Services/Compositor/ScoreboardRenderer.cs
T
gramps 460b36204d feat(scoreboard): it ticks — the marquee is the design, not an option
Creator, plainly: "the entire point of a ticker is to tick… so, tick." The static strip was my
reading of an earlier exchange; it is gone.

How it ticks without becoming the compositor's most expensive layer:
- `ScoreboardRenderer.RenderRun` rasterises the run ONCE per value change at its natural width plus
  a gap.
- `ScoreboardPresenter` crops a 1920-wide window at a moving offset each tick — a row-wise byte
  copy, no text layout, no re-raster.
- `TickScoreboard` runs on the existing 30 Hz meter clock and only while the strip is shown, so a
  hidden or disabled scoreboard costs nothing.

The dev simulation uses the same path, so what is previewed is what ships.

Test updated to the real contract: no tick → identical pixels; a tick → different pixels; a value
change → re-rasterised run. (The old test asserted frame-instance identity, which a cropped window
can never satisfy.)
2026-10-30 15:17:55 -07:00

214 lines
9.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services.Compositor;
/// <summary>How the scoreboard strip is painted. Same knobs as the social bar, because the creator
/// asked for the same controls in the same place (2026-10-30).</summary>
public readonly record struct ScoreboardStyle(
string TextColor = "#FFFFFF",
bool BackgroundEnabled = true,
string BackgroundColor = "#000000",
double BackgroundOpacity = 0.55,
double FontSize = ScoreboardLayout.MaxFontSize,
double SeparatorOpacity = 0.45);
/// <summary>Rasterises the scoreboard strip (one line, shrinking/wrapping via
/// <see cref="ScoreboardLayout.FitLine"/>) into a straight-alpha BGRA frame the compositor blits.
///
/// <para>Static by design: the strip holds state, not events — everything enabled is always
/// visible, nothing waits its turn. It therefore rasterises only when a value changes, which is why
/// the renderer takes plain strings and the caller keeps the cache.</para></summary>
public static class ScoreboardRenderer
{
public const int Width = 1920;
private const double PaddingX = 24;
private const double PaddingY = 8;
private const double SeparatorGap = 26;
/// <summary>Builds the SCROLLING run: the strip's line plus a gap, at its natural width, so the
/// publisher can crop a 1920-wide window that moves. One raster per value change, never per
/// tick — the tick is a row-wise byte copy.</summary>
public static VideoFrame? RenderRun(IReadOnlyList<string>? tiles, ScoreboardStyle style, double gapPx = 240)
{
var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList();
if (texts is not { Count: > 0 }) return null;
var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
var size = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize);
var textBrush = Brush(style.TextColor, 1.0, Colors.White);
var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray);
var measured = texts.Select(t => Text(t, typeface, size, textBrush)).ToList();
var lineWidth = measured.Sum(t => t.WidthIncludingTrailingWhitespace) + SeparatorGap * (texts.Count - 1);
var height = (int)Math.Ceiling(size * 1.45 + PaddingY * 2);
var width = (int)Math.Ceiling(lineWidth + gapPx);
var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black);
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
if (style.BackgroundEnabled)
dc.DrawRectangle(plate, null, new Rect(0, 0, width, height));
var x = 0.0;
for (var i = 0; i < measured.Count; i++)
{
dc.DrawText(measured[i], new Point(x, (height - size) / 2));
x += measured[i].WidthIncludingTrailingWhitespace;
if (i == measured.Count - 1) continue;
var dot = Text("·", typeface, size, separator);
dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, (height - size) / 2));
x += SeparatorGap;
}
}
return Rasterise(visual, width, height);
}
/// <summary>Crops a <paramref name="width"/>-wide window out of the run at
/// <paramref name="offsetPx"/>, wrapping. Straight-alpha rows, no scaling.</summary>
public static VideoFrame Crop(VideoFrame run, int offsetPx, int width)
{
var height = run.Height;
var pixels = new byte[width * height * 4];
var runStride = run.Width * 4;
var start = ((offsetPx % run.Width) + run.Width) % run.Width;
for (var y = 0; y < height; y++)
{
var srcRow = y * runStride;
var dstRow = y * width * 4;
for (var x = 0; x < width; x++)
{
var sx = (start + x) % run.Width;
var src = srcRow + sx * 4;
var dst = dstRow + x * 4;
pixels[dst] = run.BgraPixels[src];
pixels[dst + 1] = run.BgraPixels[src + 1];
pixels[dst + 2] = run.BgraPixels[src + 2];
pixels[dst + 3] = run.BgraPixels[src + 3];
}
}
return new VideoFrame(width, height, pixels);
}
public static VideoFrame? Render(IReadOnlyList<string>? tiles, ScoreboardStyle style)
{
var texts = tiles?.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).ToList();
if (texts is not { Count: > 0 }) return null;
var typeface = new Typeface(OutputPalette.Sans, FontStyles.Normal, FontWeights.SemiBold, FontStretches.Normal);
var requested = Math.Clamp(style.FontSize, ScoreboardLayout.FloorFontSize, ScoreboardLayout.MaxFontSize);
double Measure(string text, double size) =>
new FormattedText(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
typeface, size, Brushes.White, pixelsPerDip: 1.0).WidthIncludingTrailingWhitespace;
var widths = texts.Select(t => Measure(t, requested)).ToList();
var fit = ScoreboardLayout.FitLine(widths, SeparatorGap, Width - PaddingX * 2, requested);
// Re-measure at the fitted size so line breaking and drawing use the same numbers.
var size = fit.FontSize;
var lines = BuildLines(texts, texts.Select(t => Measure(t, size)).ToList(), Width - PaddingX * 2);
var lineHeight = size * 1.45;
var height = (int)Math.Ceiling(lines.Count * lineHeight + PaddingY * 2);
var textBrush = Brush(style.TextColor, 1.0, Colors.White);
var plate = Brush(style.BackgroundColor, Math.Clamp(style.BackgroundOpacity, 0, 1), Colors.Black);
var separator = Brush(style.TextColor, Math.Clamp(style.SeparatorOpacity, 0, 1), Colors.Gray);
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
if (style.BackgroundEnabled)
dc.DrawRoundedRectangle(plate, null, new Rect(0, 0, Width, height), height / 2.0, height / 2.0);
for (var line = 0; line < lines.Count; line++)
{
var lineTiles = lines[line];
var totalWidth = lineTiles.Sum(t => Measure(t, size)) + SeparatorGap * (lineTiles.Count - 1);
var x = (Width - totalWidth) / 2;
var y = PaddingY + line * lineHeight + (lineHeight - size) / 2;
for (var i = 0; i < lineTiles.Count; i++)
{
var text = Text(lineTiles[i], typeface, size, textBrush);
dc.DrawText(text, new Point(x, y));
x += text.WidthIncludingTrailingWhitespace;
if (i == lineTiles.Count - 1) continue;
var dot = Text("·", typeface, size, separator);
dc.DrawText(dot, new Point(x + (SeparatorGap - dot.WidthIncludingTrailingWhitespace) / 2, y));
x += SeparatorGap;
}
}
}
return Rasterise(visual, Width, height);
}
/// <summary>Draws the visual into a straight-alpha BGRA frame (unpremultiplied, because the
/// compositor blends straight alpha).</summary>
private static VideoFrame Rasterise(DrawingVisual visual, int width, int height)
{
var bitmap = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var pixels = new byte[width * height * 4];
bitmap.CopyPixels(pixels, width * 4, 0);
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(width, height, pixels);
}
/// <summary>Greedy line packing at the fitted size — mirrors <see cref="ScoreboardLayout.FitLine"/>
/// so the break points agree with the fit the caller was told about.</summary>
private static List<List<string>> BuildLines(IReadOnlyList<string> tiles, IReadOnlyList<double> widths, double available)
{
var lines = new List<List<string>> { new() };
var lineWidth = 0.0;
for (var i = 0; i < tiles.Count; i++)
{
var w = widths[i];
var needed = lineWidth == 0 ? w : lineWidth + SeparatorGap + w;
if (needed > available && lineWidth > 0)
{
lines.Add(new List<string>());
lineWidth = w;
}
else
{
lineWidth = needed;
}
lines[^1].Add(tiles[i]);
}
return lines;
}
private static FormattedText Text(string text, Typeface face, double size, Brush brush) =>
new(text, CultureInfo.InvariantCulture, FlowDirection.LeftToRight, face, size, brush, pixelsPerDip: 1.0);
private static SolidColorBrush Brush(string hex, double opacity, Color fallback)
{
Color colour;
try { colour = (Color)ColorConverter.ConvertFromString(hex); }
catch (Exception) { colour = fallback; }
var brush = new SolidColorBrush(Color.FromArgb((byte)Math.Round(opacity * 255), colour.R, colour.G, colour.B));
brush.Freeze();
return brush;
}
}