using System.Globalization; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using ytLive.Models; namespace ytLive.Services; /// /// Renders chat messages into a VideoFrame for the chat overlay on stream. /// Uses WPF's FormattedText + RenderTargetBitmap to produce BGRA8 output. /// public sealed class ChatBoxRenderer { private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; /// /// Render the last N messages into a VideoFrame at the specified dimensions. /// Messages are drawn bottom-up (newest at bottom, oldest at top) with /// configurable font, colors, and background. /// public VideoFrame Render( IReadOnlyList messages, int width, int height, double fontSize, string fontFamily, string fontColorHex, string backgroundColorHex, int maxMessages, bool showTimestamps, bool showBadges, string memberColorHex, string superChatColorHex) { var dpi = 96.0; var visual = new DrawingVisual(); using (var ctx = visual.RenderOpen()) { // Background var bgColor = ParseColor(backgroundColorHex); ctx.DrawRectangle( new SolidColorBrush(bgColor), null, new Rect(0, 0, width, height)); if (messages.Count == 0) return CreateFrame(visual, width, height, dpi); // Layout parameters var lineHeight = fontSize * 1.4; var padding = 8.0; var maxVisibleLines = (int)((height - padding * 2) / lineHeight); if (maxVisibleLines < 1) maxVisibleLines = 1; // Take the last N messages that fit var visibleMessages = new List<(ChatMessage msg, string text, Brush brush)>(); var totalLines = 0; for (var i = messages.Count - 1; i >= 0 && visibleMessages.Count < maxMessages; i--) { var msg = messages[i]; var brush = GetMessageBrush(msg, fontColorHex, memberColorHex, superChatColorHex); var text = FormatMessage(msg, showTimestamps, showBadges); var lineCount = EstimateLineCount(text, fontSize, width - padding * 2); if (totalLines + lineCount > maxVisibleLines) break; totalLines += lineCount; visibleMessages.Add((msg, text, brush)); } visibleMessages.Reverse(); // Draw messages bottom-up var y = height - padding; for (var i = visibleMessages.Count - 1; i >= 0; i--) { var (_, text, brush) = visibleMessages[i]; var formatted = new FormattedText( text, InvariantCulture, FlowDirection.LeftToRight, new Typeface(new FontFamily(fontFamily), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal), fontSize, brush, dpi); formatted.MaxTextWidth = width - padding * 2; formatted.Trimming = TextTrimming.CharacterEllipsis; y -= formatted.Height; ctx.DrawText(formatted, new Point(padding, y)); } } return CreateFrame(visual, width, height, dpi); } private static string FormatMessage(ChatMessage msg, bool showTimestamps, bool showBadges) { var parts = new List(); if (showTimestamps) parts.Add($"[{msg.Timestamp:HH:mm}]"); if (showBadges) { if (msg.IsSuperChat) parts.Add("💰"); else if (msg.IsMember) parts.Add("⭐"); } parts.Add($"{msg.AuthorName}: {msg.Message}"); return string.Join(" ", parts); } private static int EstimateLineCount(string text, double fontSize, double maxWidth) { // Rough estimate: ~12 chars per 100px at font size 16 var charsPerLine = (int)(maxWidth / (fontSize * 0.6)); if (charsPerLine < 1) charsPerLine = 1; return Math.Max(1, (int)Math.Ceiling((double)text.Length / charsPerLine)); } private static Brush GetMessageBrush(ChatMessage msg, string fontColorHex, string memberColorHex, string superChatColorHex) { if (msg.IsSuperChat) return new SolidColorBrush(ParseColor(superChatColorHex)); if (msg.IsMember) return new SolidColorBrush(ParseColor(memberColorHex)); return new SolidColorBrush(ParseColor(fontColorHex)); } private static Color ParseColor(string hex) { hex = hex.Trim().TrimStart('#'); if (hex.Length == 6) { var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber); var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber); var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber); return Color.FromRgb(r, g, b); } if (hex.Length == 8) { var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber); var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber); var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber); var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber); return Color.FromArgb(a, r, g, b); } return Colors.White; } private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi) { var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32); rtb.Render(visual); var stride = width * 4; var pixels = new byte[stride * height]; rtb.CopyPixels(pixels, stride, 0); // Pbgra32 → Bgra8: swap R and B channels (Pbgra premultiplies alpha but // for opaque backgrounds the swap is all we need) for (var i = 0; i < pixels.Length; i += 4) { (pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]); } return new VideoFrame(width, height, pixels); } }