Skip to content

Commit

Permalink
Improve readable colors algorithm and make it optional (#1072)
Browse files Browse the repository at this point in the history
* Use better algorithm for adjusting color visibility

* Make readable usernames configurable

* Update translations

* Use alternate background color when enabled

* Fix readable colors preference not being saved to settings

* More adjustments

* Reduce javascript overhead when computing contrast ratio in HTML chats
  • Loading branch information
ScrubN authored May 25, 2024
1 parent a26fccd commit 4fc0572
Show file tree
Hide file tree
Showing 24 changed files with 241 additions and 47 deletions.
3 changes: 3 additions & 0 deletions TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }

Expand Down
3 changes: 2 additions & 1 deletion TwitchDownloaderCLI/Modes/RenderChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
3 changes: 3 additions & 0 deletions TwitchDownloaderCLI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
131 changes: 96 additions & 35 deletions TwitchDownloaderCore/ChatRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -588,22 +588,22 @@ 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;

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;
Expand All @@ -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
Expand Down Expand Up @@ -652,7 +653,7 @@ private static string GetKeyName(IEnumerable<Codepoint> 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)
{
Expand All @@ -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)
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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:
Expand All @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1417,11 +1418,15 @@ private static float MeasureRtlText(ReadOnlySpan<char> 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()
Expand All @@ -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)
Expand Down
55 changes: 55 additions & 0 deletions TwitchDownloaderCore/Extensions/SKColorExtensions.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
}

1 change: 1 addition & 0 deletions TwitchDownloaderCore/Options/ChatRenderOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
Loading

0 comments on commit 4fc0572

Please sign in to comment.