feat(chat-overlay): add ChatBox config UI + schema persistence

- Elements panel config UI for ChatBox source (font size slider,
  font/member/super-chat/background color inputs, max messages slider,
  timestamp/badge toggles)
- Schema migration: 8 new ChatBox columns on Source table (ChatFontSize,
  ChatFontColor, ChatBackgroundColor, ChatMaxMessages, ChatShowTimestamps,
  ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
- Load/save: Source SELECT and INSERT now include all ChatBox fields
- IsElementsPanelEmpty excludes ChatBox so hint doesn't cover config
- ChatBoxRenderer.cs created: WPF FormattedText + RenderTargetBitmap
  renderer for on-stream chat overlay
- Slider bindings set to TwoWay for live propagation
This commit is contained in:
2026-08-19 12:42:32 -07:00
parent 93987506a7
commit 65641d84e5
4 changed files with 346 additions and 4 deletions
+173
View File
@@ -0,0 +1,173 @@
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Renders chat messages into a VideoFrame for the chat overlay on stream.
/// Uses WPF's FormattedText + RenderTargetBitmap to produce BGRA8 output.
/// </summary>
public sealed class ChatBoxRenderer
{
private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture;
/// <summary>
/// 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.
/// </summary>
public VideoFrame Render(
IReadOnlyList<ChatMessage> messages,
int width,
int height,
double fontSize,
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("Segoe UI"), 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<string>();
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);
}
}
+83 -3
View File
@@ -211,6 +211,56 @@ public class LayoutStore : IDisposable
cmd.CommandText = "ALTER TABLE Source ADD COLUMN CaptureKey TEXT;";
cmd.ExecuteNonQuery();
}
// TASK 3.18: ChatBox overlay source
if (!columns.Contains("ChatFontSize"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontSize REAL NOT NULL DEFAULT 16;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatFontColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatFontColor TEXT NOT NULL DEFAULT '#FFFFFF';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatBackgroundColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatBackgroundColor TEXT NOT NULL DEFAULT '#80000000';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMaxMessages"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMaxMessages INTEGER NOT NULL DEFAULT 5;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowTimestamps"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowTimestamps INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatShowBadges"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatShowBadges INTEGER NOT NULL DEFAULT 1;";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatMemberColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatMemberColor TEXT NOT NULL DEFAULT '#e94560';";
cmd.ExecuteNonQuery();
}
if (!columns.Contains("ChatSuperChatColor"))
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "ALTER TABLE Source ADD COLUMN ChatSuperChatColor TEXT NOT NULL DEFAULT '#eab308';";
cmd.ExecuteNonQuery();
}
}
// v3 → v4: WebcamSceneConfig gains RectWidth/RectHeight (the pre-Round rect,
@@ -510,7 +560,9 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
SELECT Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex, ClipShape, IsMirrored,
IsBackdrop, CaptureKey
IsBackdrop, CaptureKey,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor
FROM Source ORDER BY SortOrder
""";
using var reader = cmd.ExecuteReader();
@@ -534,6 +586,14 @@ public class LayoutStore : IDisposable
IsMirrored = reader.GetInt32(13) != 0,
IsBackdrop = reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ChatFontSize = reader.IsDBNull(16) ? 16 : reader.GetDouble(16),
ChatFontColor = reader.IsDBNull(17) ? "#FFFFFF" : reader.GetString(17),
ChatBackgroundColor = reader.IsDBNull(18) ? "#80000000" : reader.GetString(18),
ChatMaxMessages = reader.IsDBNull(19) ? 5 : reader.GetInt32(19),
ChatShowTimestamps = reader.IsDBNull(20) ? true : reader.GetInt32(20) != 0,
ChatShowBadges = reader.IsDBNull(21) ? true : reader.GetInt32(21) != 0,
ChatMemberColor = reader.IsDBNull(22) ? "#e94560" : reader.GetString(22),
ChatSuperChatColor = reader.IsDBNull(23) ? "#eab308" : reader.GetString(23),
};
if (!sourcesByScene.TryGetValue(sceneId, out var list))
sourcesByScene[sceneId] = list = new List<Source>();
@@ -724,10 +784,14 @@ public class LayoutStore : IDisposable
cmd.CommandText = """
INSERT INTO Source (Id, SceneId, AssetId, Type, Name, IsEnabled,
X, Y, Width, Height, Opacity, MonitorIndex,
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder)
ClipShape, IsMirrored, IsBackdrop, CaptureKey, SortOrder,
ChatFontSize, ChatFontColor, ChatBackgroundColor, ChatMaxMessages,
ChatShowTimestamps, ChatShowBadges, ChatMemberColor, ChatSuperChatColor)
VALUES ($id, $sceneId, $assetId, $type, $name, $isEnabled,
$x, $y, $w, $h, $opacity, $monitor,
$clip, $mirrored, $isBackdrop, $captureKey, $sort)
$clip, $mirrored, $isBackdrop, $captureKey, $sort,
$chatFontSize, $chatFontColor, $chatBgColor, $chatMaxMsgs,
$chatTimestamps, $chatBadges, $chatMemberColor, $chatSuperChatColor)
""";
cmd.Transaction = tx;
var idP = cmd.Parameters.Add("$id", SqliteType.Text);
@@ -747,6 +811,14 @@ public class LayoutStore : IDisposable
var isBackdropP = cmd.Parameters.Add("$isBackdrop", SqliteType.Integer);
var captureKeyP = cmd.Parameters.Add("$captureKey", SqliteType.Text);
var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer);
var chatFontSizeP = cmd.Parameters.Add("$chatFontSize", SqliteType.Real);
var chatFontColorP = cmd.Parameters.Add("$chatFontColor", SqliteType.Text);
var chatBgColorP = cmd.Parameters.Add("$chatBgColor", SqliteType.Text);
var chatMaxMsgsP = cmd.Parameters.Add("$chatMaxMsgs", SqliteType.Integer);
var chatTimestampsP = cmd.Parameters.Add("$chatTimestamps", SqliteType.Integer);
var chatBadgesP = cmd.Parameters.Add("$chatBadges", SqliteType.Integer);
var chatMemberColorP = cmd.Parameters.Add("$chatMemberColor", SqliteType.Text);
var chatSuperChatColorP = cmd.Parameters.Add("$chatSuperChatColor", SqliteType.Text);
foreach (var scene in scenes)
{
@@ -771,6 +843,14 @@ public class LayoutStore : IDisposable
isBackdropP.Value = source.IsBackdrop ? 1 : 0;
captureKeyP.Value = (object?)source.CaptureKey ?? DBNull.Value;
sortP.Value = sort++;
chatFontSizeP.Value = source.ChatFontSize;
chatFontColorP.Value = source.ChatFontColor;
chatBgColorP.Value = source.ChatBackgroundColor;
chatMaxMsgsP.Value = source.ChatMaxMessages;
chatTimestampsP.Value = source.ChatShowTimestamps ? 1 : 0;
chatBadgesP.Value = source.ChatShowBadges ? 1 : 0;
chatMemberColorP.Value = source.ChatMemberColor;
chatSuperChatColorP.Value = source.ChatSuperChatColor;
cmd.ExecuteNonQuery();
}
}