From fbcccea0f794ac6fc4175b5e4f1d33d6d10eadb7 Mon Sep 17 00:00:00 2001 From: gramps Date: Fri, 30 Oct 2026 14:37:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(scoreboard):=20unit=204=20=E2=80=94=20the?= =?UTF-8?q?=20strip=20renderer=20(plate,=20text=20colour,=20fit,=20wrap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Services/Compositor/ScoreboardRenderer.cs | 139 ++++++++++++++++++++++ ytLive.Tests/ScoreboardRendererTests.cs | 67 +++++++++++ 2 files changed, 206 insertions(+) create mode 100644 Services/Compositor/ScoreboardRenderer.cs create mode 100644 ytLive.Tests/ScoreboardRendererTests.cs diff --git a/Services/Compositor/ScoreboardRenderer.cs b/Services/Compositor/ScoreboardRenderer.cs new file mode 100644 index 0000000..9c138d8 --- /dev/null +++ b/Services/Compositor/ScoreboardRenderer.cs @@ -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; + +/// 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). +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); + +/// Rasterises the scoreboard strip (one line, shrinking/wrapping via +/// ) into a straight-alpha BGRA frame the compositor blits. +/// +/// 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. +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? 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); + } + + /// Greedy line packing at the fitted size — mirrors + /// so the break points agree with the fit the caller was told about. + private static List> BuildLines(IReadOnlyList tiles, IReadOnlyList widths, double available) + { + var lines = new List> { 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()); + 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; + } +} diff --git a/ytLive.Tests/ScoreboardRendererTests.cs b/ytLive.Tests/ScoreboardRendererTests.cs new file mode 100644 index 0000000..5c63460 --- /dev/null +++ b/ytLive.Tests/ScoreboardRendererTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Xunit; +using ytLive.Services; +using ytLive.Services.Compositor; + +namespace ytLive.Tests; + +/// The strip is state, not events: everything enabled is always on screen. These checks +/// paint the real frame, because a renderer that silently draws nothing would still pass a +/// structural test. +public class ScoreboardRendererTests +{ + private static readonly List Tiles = new() { "Members 12", "Milestones 3", "SC $47.00" }; + + [Fact] + public void The_strip_paints_the_plate_and_the_text_and_never_needs_a_scroll() + { + Exception? failure = null; + + var thread = new Thread(() => + { + try + { + // Plate on: half-transparent blue behind white text. + var plated = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FFFFFF", true, "#0000FF", 0.5)); + Assert.NotNull(plated); + Assert.Equal(ScoreboardRenderer.Width, plated!.Width); + Assert.True(plated.Height > 0); + + Assert.True(Count(plated, (r, g, b, a) => b > 150 && r < 60 && g < 60 && a is > 100 and < 170) > 500, + "the plate must fill at the chosen colour and opacity"); + Assert.True(Count(plated, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200) > 20, + "the text must be painted in the chosen colour"); + + // Plate off: the same area is transparent, the text survives. + var bare = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FFFFFF", false, "#0000FF", 0.5)); + Assert.NotNull(bare); + Assert.Equal(0, Count(bare!, (r, g, b, a) => b > 150 && r < 60 && g < 60 && a > 100)); + Assert.True(Count(bare!, (r, g, b, a) => r > 200 && g > 200 && b > 200 && a > 200) > 20); + + // Text colour reaches the glyphs. + var red = ScoreboardRenderer.Render(Tiles, new ScoreboardStyle("#FF0000", false, "#000000", 0.5)); + Assert.True(Count(red!, (r, g, b, a) => r > 180 && g < 80 && b < 80 && a > 200) > 20); + + // Nothing enabled: no frame at all (the layer draws nothing). + Assert.Null(ScoreboardRenderer.Render(Array.Empty(), new ScoreboardStyle())); + } + catch (Exception ex) { failure = ex; } + }); + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + Assert.Null(failure); + } + + private static int Count(VideoFrame frame, Func predicate) + { + var hits = 0; + for (var i = 0; i < frame.BgraPixels.Length; i += 4) + if (predicate(frame.BgraPixels[i + 2], frame.BgraPixels[i + 1], frame.BgraPixels[i], frame.BgraPixels[i + 3])) + hits++; + return hits; + } +}