From d6dbe78febc28342c35f21417ac3cf0dbf3ef92f Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Sat, 21 Oct 2023 13:10:48 -0400 Subject: [PATCH] Support highlighting new bit badge notifications (#869) * Simplify icon generation * Cleanup * Add support for bit badge tier notifications * More simplification * Even more simplification --- TwitchDownloaderCore/ChatRenderer.cs | 56 ++++++++++-- TwitchDownloaderCore/Tools/HighlightIcons.cs | 90 ++++++-------------- 2 files changed, 73 insertions(+), 73 deletions(-) diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs index 4059f8b4..1516082e 100644 --- a/TwitchDownloaderCore/ChatRenderer.cs +++ b/TwitchDownloaderCore/ChatRenderer.cs @@ -27,8 +27,7 @@ public sealed class ChatRenderer : IDisposable public bool Disposed { get; private set; } = false; public ChatRoot chatRoot { get; private set; } = new ChatRoot(); - private const string PURPLE = "#7B2CF2"; - private static readonly SKColor Purple = SKColor.Parse(PURPLE); + private static readonly SKColor Purple = SKColor.Parse("#7B2CF2"); private static readonly string[] DefaultUsernameColors = { "#FF0000", "#0000FF", "#00FF00", "#B22222", "#FF7F50", "#9ACD32", "#FF4500", "#2E8B57", "#DAA520", "#D2691E", "#5F9EA0", "#1E90FF", "#FF69B4", "#8A2BE2", "#00FF7F" }; private static readonly Regex RtlRegex = new("[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]", RegexOptions.Compiled); @@ -704,6 +703,9 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm case HighlightType.SubscribedPrime: DrawSubscribeMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); break; + case HighlightType.BitBadgeTierNotification: + DrawBitsBadgeTierMessage(comment, sectionImages, emotePositionList, ref drawPos, defaultPos, highlightIcon, iconPoint); + break; case HighlightType.GiftedMany: case HighlightType.GiftedSingle: case HighlightType.GiftedAnonymous: @@ -735,7 +737,7 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit drawPos.X += highlightIcon.Width + renderOptions.WordSpacing; defaultPos.X = drawPos.X; - DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, PURPLE); + DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, Purple); AddImageSection(sectionImages, ref drawPos, defaultPos); // Remove the commenter's name from the resub message @@ -765,6 +767,41 @@ private void DrawSubscribeMessage(Comment comment, List<(SKImageInfo info, SKBit DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, 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) + { + using SKCanvas canvas = new(sectionImages.Last().bitmap); + + canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y); + drawPos.X += highlightIcon.Width + renderOptions.WordSpacing; + defaultPos.X = drawPos.X; + + if (comment.message.fragments.Count == 1) + { + DrawUsername(comment, sectionImages, ref drawPos, defaultPos, false, messageFont.Color); + + var bitsBadgeVersion = comment.message.user_badges.FirstOrDefault(x => x._id == "bits")?.version; + if (bitsBadgeVersion is not null) + { + comment.message.body = bitsBadgeVersion.Length > 3 + ? $"just earned a new {bitsBadgeVersion.AsSpan(0, bitsBadgeVersion.Length - 3)}K Bits badge!" + : $"just earned a new {bitsBadgeVersion} Bits badge!"; + } + else + { + comment.message.body = "just earned a new Bits badge!"; + } + + comment.message.fragments[0].text = comment.message.body; + } + else + { + // This should never be possible, but just in case. + DrawUsername(comment, sectionImages, ref drawPos, defaultPos, true, messageFont.Color); + } + + DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos); + } + private void DrawGiftMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKImage highlightIcon, Point iconPoint) { using SKCanvas canvas = new(sectionImages.Last().bitmap); @@ -1291,22 +1328,25 @@ 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, string colorOverride = null) + private void DrawUsername(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool appendColon = true, SKColor? colorOverride = null) { - SKColor userColor = SKColor.Parse(colorOverride ?? comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]); + var userColor = colorOverride ?? SKColor.Parse(comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]); if (colorOverride is null) - userColor = GenerateUserColor(userColor, renderOptions.BackgroundColor, renderOptions); + userColor = AdjustColorVisibility(userColor, renderOptions.BackgroundColor, renderOptions); using SKPaint userPaint = comment.commenter.display_name.Any(IsNotAscii) ? GetFallbackFont(comment.commenter.display_name.First(IsNotAscii)).Clone() : nameFont.Clone(); userPaint.Color = userColor; - string userName = comment.commenter.display_name + (appendColon ? ":" : ""); + var userName = appendColon + ? comment.commenter.display_name + ":" + : comment.commenter.display_name; + DrawText(userName, userPaint, true, sectionImages, ref drawPos, defaultPos, false); } - private static SKColor GenerateUserColor(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions) + private static SKColor AdjustColorVisibility(SKColor userColor, SKColor backgroundColor, ChatRenderOptions renderOptions) { backgroundColor.ToHsl(out _, out _, out float backgroundBrightness); userColor.ToHsl(out float userHue, out float userSaturation, out float userBrightness); diff --git a/TwitchDownloaderCore/Tools/HighlightIcons.cs b/TwitchDownloaderCore/Tools/HighlightIcons.cs index ee1bc761..71100dfe 100644 --- a/TwitchDownloaderCore/Tools/HighlightIcons.cs +++ b/TwitchDownloaderCore/Tools/HighlightIcons.cs @@ -18,27 +18,30 @@ public enum HighlightType PayingForward, ChannelPointHighlight, Raid, + BitBadgeTierNotification, Unknown } public sealed class HighlightIcons : IDisposable { - public bool Disposed { get; private set; } = false; + public bool Disposed { get; private set; } private const string SUBSCRIBED_TIER_ICON_SVG = "m 32.599229,13.144498 c 1.307494,-2.80819 5.494049,-2.80819 6.80154,0 l 5.648628,12.140919 13.52579,1.877494 c 3.00144,0.418654 4.244522,3.893468 2.138363,5.967405 -3.357829,3.309501 -6.715662,6.618992 -10.073491,9.928491 L 53.07148,56.81637 c 0.524928,2.962772 -2.821092,5.162303 -5.545572,3.645496 L 36,54.043603 24.474093,60.461866 C 21.749613,61.975455 18.403591,59.779142 18.92852,56.81637 L 21.359942,43.058807 11.286449,33.130316 c -2.1061588,-2.073937 -0.863074,-5.548751 2.138363,-5.967405 l 13.52579,-1.877494 z"; private const string SUBSCRIBED_PRIME_ICON_SVG = "m 61.894653,21.663055 v 25.89488 c 0,3.575336 -2.898361,6.47372 -6.473664,6.47372 H 16.57901 c -3.573827,-0.0036 -6.470094,-2.89986 -6.473663,-6.47372 V 21.663055 L 23.052674,31.373635 36,18.426194 c 4.315772,4.315816 8.631553,8.631629 12.947323,12.947441 z"; private const string GIFTED_SINGLE_ICON_SVG = "m 55.187956,23.24523 h 6.395987 V 42.433089 H 58.38595 V 61.620947 H 13.614042 V 42.433089 H 10.416049 V 23.24523 h 6.395987 v -3.859957 c 0,-8.017328 9.689919,-12.0307888 15.359963,-6.363975 0.418936,0.418935 0.796298,0.879444 1.125692,1.371934 l 2.702305,4.055034 2.702305,-4.055034 a 8.9863623,8.9863139 0 0 1 1.125692,-1.371934 c 5.666845,-5.6668138 15.359963,-1.653353 15.359963,6.363975 z M 23.208023,19.385273 v 3.859957 h 8.301992 l -3.536982,-5.305444 a 2.6031666,2.6031528 0 0 0 -4.76501,1.445487 z m 25.583946,0 v 3.859957 h -8.301991 l 3.536983,-5.305444 a 2.6031666,2.6031528 0 0 1 4.765008,1.442286 z m 6.395987,10.255909 v 6.395951 H 39.19799 v -6.395951 z m -3.197992,25.58381 V 42.433089 H 39.19799 V 55.224992 Z M 32.802003,29.641182 v 6.395951 H 16.812036 v -6.395951 z m 0,12.791907 H 20.010028 v 12.791903 h 12.791975 z"; private const string GIFTED_MANY_ICON_URL = "https://static-cdn.jtvnw.net/subs-image-assets/gift-illus.png"; private const string GIFTED_ANONYMOUS_ICON_SVG = "m 54.571425,64.514958 a 4.3531428,4.2396967 0 0 1 -1.273998,-0.86096 l -1.203426,-1.172067 a 7.0051428,6.822584 0 0 0 -9.90229,0 c -3.417139,3.328092 -8.962569,3.328092 -12.383427,0 l -0.159707,-0.155553 a 7.1871427,6.9998405 0 0 0 -9.854005,-0.28216 l -1.894286,1.635103 a 4.9362858,4.8076423 0 0 1 -3.276,1.215474 H 10 V 32.337399 a 26.000001,25.322423 0 0 1 52,0 v 32.557396 h -5.627146 c -0.627714,0 -1.240569,-0.133847 -1.801429,-0.379837 z M 35.999996,14.249955 A 18.571428,18.087444 0 0 0 17.428572,32.337399 v 22.515245 a 14.619428,14.238435 0 0 1 17.471998,2.358609 l 0.163448,0.155554 c 0.516285,0.50645 1.355715,0.50645 1.875712,0 a 14.437428,14.061179 0 0 1 17.631712,-2.11623 V 32.337399 A 18.571428,18.087444 0 0 0 35.999996,14.249955 Z M 24.857142,35.954887 a 3.7142855,3.6174889 0 1 1 7.42857,0 3.7142855,3.6174889 0 0 1 -7.42857,0 z m 18.571432,-3.617488 a 3.7142859,3.6174892 0 1 0 0,7.234978 3.7142859,3.6174892 0 0 0 0,-7.234978 z"; + private const string BIT_BADGE_TIER_NOTIFICATION_ICON_SVG = "M 14.242705,42.37453 36,11.292679 57.757295,42.37453 36,61.023641 Z M 22.566425,41.323963 36,22.13092 49.433577,41.317747 46.79162,43.580506 36,39.266345 25.205273,43.586723 22.566425,41.320854 Z"; - private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d?\d?\d months(?:, currently on a \d?\d?\d month streak)?! )(.+)$", RegexOptions.Compiled); - private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d\d?\d?) Tier \d", RegexOptions.Compiled); + private static readonly Regex SubMessageRegex = new(@"^(subscribed (?:with Prime|at Tier \d)\. They've subscribed for \d{1,3} months(?:, currently on a \d{1,3} month streak)?! )(.+)$", RegexOptions.Compiled); + private static readonly Regex GiftAnonymousRegex = new(@"^An anonymous user (?:gifted a|is gifting \d{1,4}) Tier \d", RegexOptions.Compiled); private SKImage _subscribedTierIcon; private SKImage _subscribedPrimeIcon; private SKImage _giftSingleIcon; private SKImage _giftManyIcon; private SKImage _giftAnonymousIcon; + private SKImage _bitBadgeTierNotificationIcon; private readonly string _cachePath; private readonly SKColor _purple; @@ -54,8 +57,6 @@ public HighlightIcons(string cachePath, SKColor iconPurple, bool offline) // If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck public static HighlightType GetHighlightType(Comment comment) { - const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // '274598607' is the id of the anonymous gift message account, display name: 'AnAnonymousGifter' - if (comment.message.body.Length == 0) { // This likely happens due to the 7TV extension letting users bypass the IRC message trimmer @@ -104,6 +105,9 @@ public static HighlightType GetHighlightType(Comment comment) } } + if (bodySpan.Equals("bits badge tier notification ", StringComparison.Ordinal)) + return HighlightType.BitBadgeTierNotification; + if (char.IsDigit(bodySpan[0]) && bodySpan.Contains("have joined!", StringComparison.Ordinal)) { // TODO: use bodySpan when .NET 7 @@ -111,61 +115,29 @@ public static HighlightType GetHighlightType(Comment comment) return HighlightType.Raid; } + const string ANONYMOUS_GIFT_ACCOUNT_ID = "274598607"; // Display name is 'AnAnonymousGifter' if (comment.commenter._id is ANONYMOUS_GIFT_ACCOUNT_ID && GiftAnonymousRegex.IsMatch(comment.message.body)) return HighlightType.GiftedAnonymous; return HighlightType.None; } - /// A the requested icon or null if no icon exists for the highlight type - /// The icon returned is NOT a copy and should not be manually disposed. + /// The requested icon or if no icon exists for the highlight type + /// The returned is NOT a copy and should not be manually disposed. public SKImage GetHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize) { - // Return the needed icon from cache or generate if null return highlightType switch { - HighlightType.SubscribedTier => _subscribedTierIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize), - HighlightType.SubscribedPrime => _subscribedPrimeIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize), - HighlightType.GiftedSingle => _giftSingleIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize), - HighlightType.GiftedMany => _giftManyIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize), - HighlightType.GiftedAnonymous => _giftAnonymousIcon ?? GenerateHighlightIcon(highlightType, textColor, fontSize), + HighlightType.SubscribedTier => _subscribedTierIcon ??= GenerateSvgIcon(SUBSCRIBED_TIER_ICON_SVG, textColor, fontSize), + HighlightType.SubscribedPrime => _subscribedPrimeIcon ??= GenerateSvgIcon(SUBSCRIBED_PRIME_ICON_SVG, _purple, fontSize), + HighlightType.GiftedSingle => _giftSingleIcon ??= GenerateSvgIcon(GIFTED_SINGLE_ICON_SVG, textColor, fontSize), + HighlightType.GiftedMany => _giftManyIcon ??= GenerateGiftedManyIcon(fontSize, _cachePath, _offline), + HighlightType.GiftedAnonymous => _giftAnonymousIcon ??= GenerateSvgIcon(GIFTED_ANONYMOUS_ICON_SVG, textColor, fontSize), + HighlightType.BitBadgeTierNotification => _bitBadgeTierNotificationIcon ??= GenerateSvgIcon(BIT_BADGE_TIER_NOTIFICATION_ICON_SVG, textColor, fontSize), _ => null }; } - private SKImage GenerateHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize) - { - // Generate the needed icon - var returnIcon = highlightType is HighlightType.GiftedMany - ? GenerateGiftedManyIcon(fontSize, _cachePath, _offline) - : GenerateSvgIcon(highlightType, _purple, textColor, fontSize); - - // Cache the icon - switch (highlightType) - { - case HighlightType.SubscribedTier: - _subscribedTierIcon = returnIcon; - break; - case HighlightType.SubscribedPrime: - _subscribedPrimeIcon = returnIcon; - break; - case HighlightType.GiftedSingle: - _giftSingleIcon = returnIcon; - break; - case HighlightType.GiftedMany: - _giftManyIcon = returnIcon; - break; - case HighlightType.GiftedAnonymous: - _giftAnonymousIcon = returnIcon; - break; - default: - throw new NotSupportedException("The requested highlight icon does not exist."); - } - - // Return the generated icon - return returnIcon; - } - private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, bool offline) { //int newSize = (int)(fontSize / 0.2727); // 44*44px @ 12pt font // Doesn't work because our image sections aren't tall enough and I'm not rewriting that right now @@ -192,36 +164,22 @@ private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, return SKImage.FromBitmap(resizedBitmap); } - private static SKImage GenerateSvgIcon(HighlightType highlightType, SKColor purple, SKColor textColor, double fontSize) + private static SKImage GenerateSvgIcon(string iconSvgString, SKColor iconColor, double fontSize) { using var tempBitmap = new SKBitmap(72, 72); // Icon SVG strings are scaled for 72x72 using var tempCanvas = new SKCanvas(tempBitmap); - using var iconPath = SKPath.ParseSvgPathData(highlightType switch - { - HighlightType.SubscribedTier => SUBSCRIBED_TIER_ICON_SVG, - HighlightType.SubscribedPrime => SUBSCRIBED_PRIME_ICON_SVG, - HighlightType.GiftedSingle => GIFTED_SINGLE_ICON_SVG, - HighlightType.GiftedAnonymous => GIFTED_ANONYMOUS_ICON_SVG, - _ => throw new NotSupportedException("The requested icon svg path does not exist.") - }); + using var iconPath = SKPath.ParseSvgPathData(iconSvgString); iconPath.FillType = SKPathFillType.EvenOdd; - var iconColor = new SKPaint + var iconPaint = new SKPaint { - Color = highlightType switch - { - HighlightType.SubscribedTier => textColor, - HighlightType.SubscribedPrime => purple, - HighlightType.GiftedSingle => textColor, - HighlightType.GiftedAnonymous => textColor, - _ => throw new NotSupportedException("The requested icon color does not exist.") - }, + Color = iconColor, IsAntialias = true, LcdRenderText = true }; - tempCanvas.DrawPath(iconPath, iconColor); + tempCanvas.DrawPath(iconPath, iconPaint); var newSize = (int)(fontSize / 0.6); // 20*20px @ 12pt font var imageInfo = new SKImageInfo(newSize, newSize); var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High); @@ -309,6 +267,7 @@ private void Dispose(bool isDisposing) _giftSingleIcon?.Dispose(); _giftManyIcon?.Dispose(); _giftAnonymousIcon?.Dispose(); + _bitBadgeTierNotificationIcon?.Dispose(); // Set the root references to null to explicitly tell the garbage collector that the resources have been disposed _subscribedTierIcon = null; @@ -316,6 +275,7 @@ private void Dispose(bool isDisposing) _giftSingleIcon = null; _giftManyIcon = null; _giftAnonymousIcon = null; + _bitBadgeTierNotificationIcon = null; } } finally