feat(scoreboard): unit 4 — the strip renderer (plate, text colour, fit, wrap)
Rasterises enabled tiles into one straight-alpha BGRA frame: measured widths -> FitLine (shrink, then wrap, never scroll) -> centred lines with dim separators, over an optional rounded plate whose colour and opacity are the creator's knobs. Uses OutputPalette.Sans (bundled Inter) because on-stream text wears the app's typefaces. Static by design: it takes plain strings, so the caller caches the frame and only re-rasterises when a value changes — a strip that re-rendered every tick would be the compositor's most expensive layer for no benefit. Guard: ScoreboardRendererTests paints real frames and inspects pixels — plate colour AND opacity, text colour reaching glyphs, plate-off leaves the area transparent, and an empty tile set returns no frame at all.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user