diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs index 29238b65..f8afe990 100644 --- a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs +++ b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs @@ -105,6 +105,9 @@ internal sealed class ChatRenderArgs : TwitchDownloaderArgs [Option("alternate-backgrounds", Default = false, HelpText = "Alternates the background color of every other chat message to help tell them apart.")] public bool AlternateMessageBackgrounds { get; set; } + [Option("readable-colors", Default = false, HelpText = "Increases the contrast of usernames against the background or outline color.")] + public bool AdjustUsernameVisibility { get; set; } + [Option("offline", Default = false, HelpText = "Render completely offline using only embedded emotes, badges, and bits from the input json.")] public bool Offline { get; set; } diff --git a/TwitchDownloaderCLI/Modes/RenderChat.cs b/TwitchDownloaderCLI/Modes/RenderChat.cs index cec24afe..75e8ba03 100644 --- a/TwitchDownloaderCLI/Modes/RenderChat.cs +++ b/TwitchDownloaderCLI/Modes/RenderChat.cs @@ -91,7 +91,8 @@ private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions, I AccentIndentScale = inputOptions.ScaleAccentIndent, AccentStrokeScale = inputOptions.ScaleAccentStroke, DisperseCommentOffsets = inputOptions.DisperseCommentOffsets, - AlternateMessageBackgrounds = inputOptions.AlternateMessageBackgrounds + AlternateMessageBackgrounds = inputOptions.AlternateMessageBackgrounds, + AdjustUsernameVisibility = inputOptions.AdjustUsernameVisibility, }; if (renderOptions.GenerateMask && renderOptions.BackgroundColor.Alpha == 255 && !(renderOptions.AlternateMessageBackgrounds! && renderOptions.AlternateBackgroundColor.Alpha != 255)) diff --git a/TwitchDownloaderCLI/README.md b/TwitchDownloaderCLI/README.md index f33335e1..71b6d7e2 100644 --- a/TwitchDownloaderCLI/README.md +++ b/TwitchDownloaderCLI/README.md @@ -272,6 +272,9 @@ Other = `1`, Broadcaster = `2`, Moderator = `4`, VIP = `8`, Subscriber = `16`, P **--alternate-backgrounds** (Default: `false`) Alternates the background color of every other chat message to help tell them apart. +**--readable-colors** +(Default: `false`) Increases the contrast of usernames against the background or outline color. + **--offline** (Default: `false`) Render completely offline using only embedded emotes, badges, and bits from the input json. diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs index 2d223570..538e2ad4 100644 --- a/TwitchDownloaderCore/ChatRenderer.cs +++ b/TwitchDownloaderCore/ChatRenderer.cs @@ -588,14 +588,14 @@ private CommentSection GenerateCommentSection(int commentIndex, int sectionDefau return null; } - DrawAccentedMessage(comment, sectionImages, emoteSectionList, highlightType, ref drawPos, defaultPos); + DrawAccentedMessage(comment, sectionImages, emoteSectionList, highlightType, commentIndex, ref drawPos, defaultPos); } else { - DrawNonAccentedMessage(comment, sectionImages, emoteSectionList, false, ref drawPos, ref defaultPos); + DrawNonAccentedMessage(comment, sectionImages, emoteSectionList, false, commentIndex, ref drawPos, ref defaultPos); } - SKBitmap finalBitmap = CombineImages(sectionImages, highlightType); + SKBitmap finalBitmap = CombineImages(sectionImages, highlightType, commentIndex); newSection.Image = finalBitmap; newSection.Emotes = emoteSectionList; newSection.CommentIndex = commentIndex; @@ -603,7 +603,7 @@ private CommentSection GenerateCommentSection(int commentIndex, int sectionDefau return newSection; } - private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, HighlightType highlightType) + private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, HighlightType highlightType, int commentIndex) { SKBitmap finalBitmap = new SKBitmap(renderOptions.ChatWidth, sectionImages.Sum(x => x.info.Height)); var finalBitmapInfo = finalBitmap.Info; @@ -621,8 +621,9 @@ private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> section else if (highlightType is not HighlightType.None) { const int OPAQUE_THRESHOLD = 245; - if (!(renderOptions.BackgroundColor.Alpha < OPAQUE_THRESHOLD || - (renderOptions.AlternateMessageBackgrounds && renderOptions.AlternateBackgroundColor.Alpha < OPAQUE_THRESHOLD))) + var useAlternateBackground = renderOptions.AlternateMessageBackgrounds && commentIndex % 2 == 1; + if (!((!useAlternateBackground && renderOptions.BackgroundColor.Alpha < OPAQUE_THRESHOLD) || + (useAlternateBackground && renderOptions.AlternateBackgroundColor.Alpha < OPAQUE_THRESHOLD))) { // Draw the highlight background only if the message background is opaque enough var backgroundColor = new SKColor(0x1A6B6B6E); // AARRGGBB @@ -652,7 +653,7 @@ private static string GetKeyName(IEnumerable codepoints) return string.Join(' ', codepointList); } - private void DrawNonAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, bool highlightWords, ref Point drawPos, ref Point defaultPos) + private void DrawNonAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, bool highlightWords, int commentIndex, ref Point drawPos, ref Point defaultPos) { if (renderOptions.Timestamp) { @@ -662,7 +663,7 @@ private void DrawNonAccentedMessage(Comment comment, List<(SKImageInfo info, SKB { DrawBadges(comment, sectionImages, ref drawPos); } - DrawUsername(comment, sectionImages, ref drawPos, defaultPos); + DrawUsername(comment, sectionImages, ref drawPos, defaultPos, commentIndex: commentIndex); DrawMessage(comment, sectionImages, emotePositionList, highlightWords, ref drawPos, defaultPos); foreach (var (_, bitmap) in sectionImages) @@ -671,7 +672,7 @@ private void DrawNonAccentedMessage(Comment comment, List<(SKImageInfo info, SKB } } - private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, HighlightType highlightType, ref Point drawPos, Point defaultPos) + private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, HighlightType highlightType, int commentIndex, ref Point drawPos, Point defaultPos) { drawPos.X += renderOptions.AccentIndentWidth; defaultPos.X = drawPos.X; @@ -688,13 +689,13 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm { case HighlightType.SubscribedTier: case HighlightType.SubscribedPrime: - DrawSubscribeMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); + DrawSubscribeMessage(comment, sectionImages, emotePositionList, commentIndex, ref drawPos, defaultPos, highlightIcon, iconPoint); break; case HighlightType.BitBadgeTierNotification: DrawBitsBadgeTierMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); break; case HighlightType.WatchStreak: - DrawWatchStreakMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); + DrawWatchStreakMessage(comment, sectionImages, emotePositionList, commentIndex, ref drawPos, defaultPos, highlightIcon, iconPoint); break; case HighlightType.CharityDonation: DrawCharityDonationMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); @@ -705,7 +706,7 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm DrawGiftMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); break; case HighlightType.ChannelPointHighlight: - DrawNonAccentedMessage(comment, sectionImages, emotePositionList, true, ref drawPos, ref defaultPos); + DrawNonAccentedMessage(comment, sectionImages, emotePositionList, true, commentIndex, ref drawPos, ref defaultPos); break; case HighlightType.ContinuingGift: case HighlightType.PayingForward: @@ -721,7 +722,7 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm } } - private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) + private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, int commentIndex, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) { using SKCanvas canvas = new(sectionImages.Last().bitmap); canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y); @@ -757,7 +758,7 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit AddImageSection(sectionImages, ref drawPos, defaultPos); drawPos = customMessagePos; defaultPos = customMessagePos; - DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, ref drawPos, ref defaultPos); + DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, commentIndex, ref drawPos, ref defaultPos); } private void DrawBitsBadgeTierMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) @@ -795,7 +796,7 @@ private void DrawBitsBadgeTierMessage(Comment comment, List<(SKImageInfo info, S DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos); } - private void DrawWatchStreakMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) + private void DrawWatchStreakMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, int commentIndex, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) { using SKCanvas canvas = new(sectionImages.Last().bitmap); canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y); @@ -831,7 +832,7 @@ private void DrawWatchStreakMessage(Comment comment, List<(SKImageInfo info, SKB AddImageSection(sectionImages, ref drawPos, defaultPos); drawPos = customMessagePos; defaultPos = customMessagePos; - DrawNonAccentedMessage(customMessage, sectionImages, emotePositionList, false, ref drawPos, ref defaultPos); + DrawNonAccentedMessage(customMessage, sectionImages, emotePositionList, false, commentIndex, ref drawPos, ref defaultPos); } private void DrawCharityDonationMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) @@ -1417,11 +1418,15 @@ private static float MeasureRtlText(ReadOnlySpan rtlText, SKPaint textFont return measure.Width; } - private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, SKColor? colorOverride = null) + private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, SKColor? colorOverride = null, int commentIndex = 0) { var userColor = colorOverride ?? SKColor.Parse(comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]); - if (colorOverride is null) - userColor = AdjustColorVisibility(userColor, renderOptions.BackgroundColor, renderOptions); + if (colorOverride is null && renderOptions.AdjustUsernameVisibility) + { + var useAlternateBackground = renderOptions.AlternateMessageBackgrounds && commentIndex % 2 == 1; + var backgroundColor = useAlternateBackground ? renderOptions.AlternateBackgroundColor : renderOptions.BackgroundColor; + userColor = AdjustUsernameVisibility(userColor, backgroundColor); + } using SKPaint userPaint = comment.commenter.display_name.Any(IsNotAscii) ? GetFallbackFont(comment.commenter.display_name.First(IsNotAscii)).Clone() @@ -1435,30 +1440,86 @@ private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitm DrawText(userName, userPaint, true, sectionImages, ref drawPos, defaultPos, false); } - private static SKColor AdjustColorVisibility(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions) + private SKColor AdjustUsernameVisibility(SKColor userColor, SKColor backgroundColor) + { + const int OPAQUE_THRESHOLD = 200; + if (!renderOptions.Outline && backgroundColor.Alpha < OPAQUE_THRESHOLD) + { + // Background lightness cannot be truly known. + return userColor; + } + + var newUserColor = AdjustColorVisibility(userColor, renderOptions.Outline ? outlinePaint.Color : backgroundColor); + + return renderOptions.Outline || backgroundColor.Alpha == byte.MaxValue + ? newUserColor + : userColor.Lerp(newUserColor, (float)backgroundColor.Alpha / byte.MaxValue); + } + + private static SKColor AdjustColorVisibility(SKColor foreground, SKColor background) { - backgroundColor.ToHsl(out _, out _, out float backgroundBrightness); - userColor.ToHsl(out float userHue, out float userSaturation, out float userBrightness); + background.ToHsl(out var bgHue, out var bgSat, out _); + foreground.ToHsl(out var fgHue, out var fgSat, out var fgLight); + + // Adjust lightness + if (background.RelativeLuminance() > 0.5) + { + // Bright background + if (fgLight > 60) + { + fgLight = 60; + } - if (backgroundBrightness < 25 || renderOptions.Outline) + if (bgSat <= 28) + { + fgHue = fgHue switch + { + > 48 and < 90 => AdjustHue(fgHue, 48, 90), // Yellow-Lime + > 164 and < 186 => AdjustHue(fgHue, 164, 186), // Turquoise + _ => fgHue + }; + } + } + else { - //Dark background or black outline - if (userBrightness < 45) - userBrightness = 45; - if (userSaturation > 80) - userSaturation = 80; - SKColor newColor = SKColor.FromHsl(userHue, userSaturation, userBrightness); - return newColor; + // Dark background + if (fgLight < 40) + { + fgLight = 40; + } + + if (bgSat <= 28) + { + fgHue = fgHue switch + { + > 224 and < 263 => AdjustHue(fgHue, 224, 264), // Blue-Purple + _ => fgHue + }; + } } - if (Math.Abs(backgroundBrightness - userBrightness) < 10 && backgroundBrightness > 50) + // Adjust hue on colored backgrounds + if (bgSat > 28 && fgSat > 28) { - userBrightness -= 20; - SKColor newColor = SKColor.FromHsl(userHue, userSaturation, userBrightness); - return newColor; + var hueDiff = fgHue - bgHue; + const int HUE_THRESHOLD = 25; + if (Math.Abs(hueDiff) < HUE_THRESHOLD) + { + var diffSign = hueDiff < 0 ? -1 : 1; // Math.Sign returns 1, -1, or 0. We only want 1 or -1. + fgHue = bgHue + HUE_THRESHOLD * diffSign; + + if (fgHue < 0) fgHue += 360; + fgHue %= 360; + } } - return userColor; + return SKColor.FromHsl(fgHue, Math.Min(fgSat, 90), fgLight); + + static float AdjustHue(float hue, float lowerClamp, float upperClamp) + { + var midpoint = (upperClamp + lowerClamp) / 2; + return hue >= midpoint ? upperClamp : lowerClamp; + } } private void DrawBadges(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos) diff --git a/TwitchDownloaderCore/Extensions/SKColorExtensions.cs b/TwitchDownloaderCore/Extensions/SKColorExtensions.cs new file mode 100644 index 00000000..bfe30cb8 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/SKColorExtensions.cs @@ -0,0 +1,55 @@ +using System; +using System.Numerics; +using SkiaSharp; + +namespace TwitchDownloaderCore.Extensions +{ + // ReSharper disable once InconsistentNaming + public static class SKColorExtensions + { + public static SKColor Lerp(this SKColor from, SKColor to, float factor) + { + var result = Vector4.Lerp(ToVector4(from), ToVector4(to), factor); + return FromVector4(result); + + static Vector4 ToVector4(SKColor color) + { + var colorF = color.ToSKColorF(); + return new Vector4(colorF.Red, colorF.Green, colorF.Blue, colorF.Alpha); + } + + static SKColor FromVector4(Vector4 color) + { + var colorF = new SKColorF(color.X, color.Y, color.Z, color.W); + return colorF.ToSKColor(); + } + } + + // ReSharper disable once InconsistentNaming + public static SKColorF ToSKColorF(this SKColor color) + { + return new SKColorF((float)color.Red / byte.MaxValue, (float)color.Green / byte.MaxValue, (float)color.Blue / byte.MaxValue, (float)color.Alpha / byte.MaxValue); + } + + // ReSharper disable once InconsistentNaming + public static SKColor ToSKColor(this SKColorF color) + { + return new SKColor((byte)(color.Red * byte.MaxValue), (byte)(color.Green * byte.MaxValue), (byte)(color.Blue * byte.MaxValue), (byte)(color.Alpha * byte.MaxValue)); + } + + // https://www.w3.org/TR/WCAG21/#dfn-relative-luminance + public static double RelativeLuminance(this SKColor color) + { + var colorF = color.ToSKColorF(); + return 0.2126 * ConvertColor(colorF.Red) + 0.7152 * ConvertColor(colorF.Green) + 0.0722 * ConvertColor(colorF.Blue); + + static double ConvertColor(float v) + { + return v <= 0.04045 + ? v / 12.92 + : Math.Pow((v + 0.055) / 1.055, 2.4); + } + } + } +} + diff --git a/TwitchDownloaderCore/Options/ChatRenderOptions.cs b/TwitchDownloaderCore/Options/ChatRenderOptions.cs index d105ce14..bdfc1883 100644 --- a/TwitchDownloaderCore/Options/ChatRenderOptions.cs +++ b/TwitchDownloaderCore/Options/ChatRenderOptions.cs @@ -91,5 +91,6 @@ public string MaskFile public bool SkipDriveWaiting { get; set; } = false; public EmojiVendor EmojiVendor { get; set; } = EmojiVendor.GoogleNotoColor; public int[] TimestampWidths { get; set; } + public bool AdjustUsernameVisibility { get; set; } } } diff --git a/TwitchDownloaderCore/Resources/chat-template.html b/TwitchDownloaderCore/Resources/chat-template.html index 3e28b869..72729626 100644 --- a/TwitchDownloaderCore/Resources/chat-template.html +++ b/TwitchDownloaderCore/Resources/chat-template.html @@ -99,26 +99,31 @@