Creator's defaults: white text, Background ON (black, 20%), speed capped at 200 px/s, text size shown in real pixels, and "plate" dropped from the label. The important find is underneath those: `ScoreboardStyle` is a record STRUCT, and `new ScoreboardStyle()` zero-initialises — it does NOT run the constructor's parameter defaults. So the shipped strip had null text colour, NO background and zero opacity, whatever the source said. That is why the Background checkbox and the opacity slider looked like they had no code behind them: the renderer was faithfully drawing the zeros it was handed. An explicit parameterless constructor now carries the real defaults, and the renderer test asserts them (white / on / black / 0.20 / max speed 200) so this class of silent-zero cannot return. Readouts fixed too: the opacity and text-size setters never raised their own property, so the numbers never moved while the render did. Text size now reads "24 px" instead of a bare slider value, and snaps to whole pixels.
223 lines
10 KiB
C#
223 lines
10 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.20,
|
|
double FontSize = ScoreboardLayout.MaxFontSize,
|
|
double SeparatorOpacity = 0.45)
|
|
{
|
|
/// <summary>A record STRUCT's implicit parameterless constructor zero-initialises — it does NOT
|
|
/// run the parameter defaults. Without this, `new ScoreboardStyle()` produced null text colour,
|
|
/// no background and zero opacity: every "default" in this file was a lie, and the plate/opacity
|
|
/// controls looked dead because the renderer was faithfully drawing what it was given.</summary>
|
|
public ScoreboardStyle() : this("#FFFFFF", true, "#000000", 0.20, ScoreboardLayout.MaxFontSize, 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;
|
|
}
|
|
}
|