From 7b11b7409e9447fdaa614e56ff81ffa0730f88d3 Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Mon, 25 Mar 2024 20:28:21 -0400 Subject: [PATCH] Outline sub/highlight icons (#1012) * Fix highlight icons memory leak * Draw sub/highlight icons with outlines when outline is enabled * Cache icon paints by color --- TwitchDownloaderCore/ChatRenderer.cs | 11 ++- TwitchDownloaderCore/Tools/HighlightIcons.cs | 92 +++++++++++++------- 2 files changed, 65 insertions(+), 38 deletions(-) diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs index 1ed581e2..45c5546b 100644 --- a/TwitchDownloaderCore/ChatRenderer.cs +++ b/TwitchDownloaderCore/ChatRenderer.cs @@ -62,7 +62,10 @@ public ChatRenderer(ChatRenderOptions chatRenderOptions, IProgress renderOptions.BlockArtPreWrapWidth; _progress = progress; - highlightIcons = new HighlightIcons(renderOptions.TempFolder, Purple, renderOptions.Offline); + outlinePaint = new SKPaint { Style = SKPaintStyle.Stroke, StrokeWidth = (float)(renderOptions.OutlineSize * renderOptions.ReferenceScale), StrokeJoin = SKStrokeJoin.Round, Color = SKColors.Black, IsAntialias = true, IsAutohinted = true, LcdRenderText = true, SubpixelText = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High }; + nameFont = new SKPaint { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High }; + messageFont = new SKPaint { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High, Color = renderOptions.MessageColor }; + highlightIcons = new HighlightIcons(renderOptions, Purple, outlinePaint); } public async Task RenderVideoAsync(CancellationToken cancellationToken) @@ -76,10 +79,6 @@ public async Task RenderVideoAsync(CancellationToken cancellationToken) } FloorCommentOffsets(chatRoot.comments); - outlinePaint = new SKPaint() { Style = SKPaintStyle.Stroke, StrokeWidth = (float)(renderOptions.OutlineSize * renderOptions.ReferenceScale), StrokeJoin = SKStrokeJoin.Round, Color = SKColors.Black, IsAntialias = true, IsAutohinted = true, LcdRenderText = true, SubpixelText = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High }; - nameFont = new SKPaint() { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High }; - messageFont = new SKPaint() { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High, Color = renderOptions.MessageColor }; - if (renderOptions.Font == "Inter Embedded") { nameFont.Typeface = GetInterTypeface(renderOptions.UsernameFontStyle); @@ -681,7 +680,7 @@ private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitm drawPos.X += renderOptions.AccentIndentWidth; defaultPos.X = drawPos.X; - var highlightIcon = highlightIcons.GetHighlightIcon(highlightType, messageFont.Color, renderOptions.FontSize); + var highlightIcon = highlightIcons.GetHighlightIcon(highlightType, messageFont.Color); Point iconPoint = new() { diff --git a/TwitchDownloaderCore/Tools/HighlightIcons.cs b/TwitchDownloaderCore/Tools/HighlightIcons.cs index d55a94eb..aee70c39 100644 --- a/TwitchDownloaderCore/Tools/HighlightIcons.cs +++ b/TwitchDownloaderCore/Tools/HighlightIcons.cs @@ -1,7 +1,10 @@ using SkiaSharp; using System; +using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Text.RegularExpressions; +using TwitchDownloaderCore.Options; using TwitchDownloaderCore.TwitchObjects; namespace TwitchDownloaderCore.Tools @@ -38,6 +41,8 @@ public sealed class HighlightIcons : IDisposable private const string CHARITY_DONATION_ICON_SVG = "M 14.211579,29.774743 23.549474,11.09897 H 48.450526 L 57.788421,29.774743 47.345541,42.829108 60.901052,60.90103 H 39.112633 L 36,57.010242 32.887368,60.90103 h -21.78842 l 13.55551,-18.071922 z m 13.185107,-12.450515 -3.112631,6.225256 h 23.43189 l -3.112632,-6.225256 z m 2.378051,12.450515 2.334473,3.112628 -3.598202,4.796559 -6.32798,-7.909187 z m 10.20943,22.255295 2.119703,2.645734 h 6.346656 l -5.12028,-6.829109 -3.342966,4.180262 z M 23.549474,54.675772 42.225261,29.774743 h 7.59171 L 29.89613,54.675772 Z"; private const string CHANNEL_POINT_ICON_SVG = "m 34.074833,10.317667 a 25.759205,25.759174 0 0 0 -23.83413,25.686052 25.759298,25.759267 0 0 0 51.518594,0 25.759205,25.759174 0 0 0 -27.684464,-25.686052 z m 0.329458,6.432744 a 19.319404,19.319381 0 0 1 20.915597,19.253308 19.319888,19.319865 0 0 1 -38.639776,0 19.319404,19.319381 0 0 1 17.724179,-19.253308 z M 36,23.124918 v 6.439401 a 6.4398012,6.4397935 0 0 1 6.439407,6.4394 H 48.88048 A 12.879602,12.879587 0 0 0 36,23.124918 Z"; + private const int ICON_SIZE = 72; // Icon SVG strings are scaled for 72x72 + private static readonly Regex SubMessageRegex = new(@"^((?:\w+ )?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 static readonly Regex WatchStreakRegex = new(@"^((?:\w+ )?watched \d+ consecutive streams this month and sparked a watch streak! )(.+)$", RegexOptions.Compiled); @@ -54,12 +59,23 @@ public sealed class HighlightIcons : IDisposable private readonly string _cachePath; private readonly SKColor _purple; private readonly bool _offline; + private readonly double _fontSize; + private readonly bool _outline; + private readonly SKPaint _outlinePaint; + private readonly Dictionary _iconPaints = new(); - public HighlightIcons(string cachePath, SKColor iconPurple, bool offline) + public HighlightIcons(ChatRenderOptions renderOptions, SKColor iconPurple, SKPaint outlinePaint) { - _cachePath = Path.Combine(cachePath, "icons"); + _cachePath = Path.Combine(renderOptions.TempFolder, "icons"); _purple = iconPurple; - _offline = offline; + _offline = renderOptions.Offline; + _fontSize = renderOptions.FontSize; + _outline = renderOptions.Outline; + if (_outline) + { + _outlinePaint = outlinePaint.Clone(); + _outlinePaint.StrokeWidth *= (float)(ICON_SIZE / (_fontSize / 0.6)); + } } // If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck @@ -138,28 +154,28 @@ public static HighlightType GetHighlightType(Comment comment) /// 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) + public SKImage GetHighlightIcon(HighlightType highlightType, SKColor textColor) { return highlightType switch { - 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), - HighlightType.WatchStreak => _watchStreakIcon ??= GenerateSvgIcon(WATCH_STREAK_ICON_SVG, textColor, fontSize), - HighlightType.CharityDonation => _charityDonationIcon ??= GenerateSvgIcon(CHARITY_DONATION_ICON_SVG, textColor, fontSize), + HighlightType.SubscribedTier => _subscribedTierIcon ??= GenerateSvgIcon(SUBSCRIBED_TIER_ICON_SVG, textColor), + HighlightType.SubscribedPrime => _subscribedPrimeIcon ??= GenerateSvgIcon(SUBSCRIBED_PRIME_ICON_SVG, _purple), + HighlightType.GiftedSingle => _giftSingleIcon ??= GenerateSvgIcon(GIFTED_SINGLE_ICON_SVG, textColor), + HighlightType.GiftedMany => _giftManyIcon ??= GenerateGiftedManyIcon(), + HighlightType.GiftedAnonymous => _giftAnonymousIcon ??= GenerateSvgIcon(GIFTED_ANONYMOUS_ICON_SVG, textColor), + HighlightType.BitBadgeTierNotification => _bitBadgeTierNotificationIcon ??= GenerateSvgIcon(BIT_BADGE_TIER_NOTIFICATION_ICON_SVG, textColor), + HighlightType.WatchStreak => _watchStreakIcon ??= GenerateSvgIcon(WATCH_STREAK_ICON_SVG, textColor), + HighlightType.CharityDonation => _charityDonationIcon ??= GenerateSvgIcon(CHARITY_DONATION_ICON_SVG, textColor), _ => null }; } - private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, bool offline) + private SKImage GenerateGiftedManyIcon() { //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 - var finalIconSize = (int)(fontSize / 0.6); // 20x20px @ 12pt font + var finalIconSize = (int)(_fontSize / 0.6); // 20x20px @ 12pt font - if (offline) + if (_offline) { using var offlineBitmap = new SKBitmap(finalIconSize, finalIconSize); using (var offlineCanvas = new SKCanvas(offlineBitmap)) @@ -168,7 +184,7 @@ private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, return SKImage.FromBitmap(offlineBitmap); } - var taskIconBytes = TwitchHelper.GetImage(cachePath, GIFTED_MANY_ICON_URL, "gift-illus", "3", "png"); + var taskIconBytes = TwitchHelper.GetImage(_cachePath, GIFTED_MANY_ICON_URL, "gift-illus", "3", "png"); taskIconBytes.Wait(); using var ms = new MemoryStream(taskIconBytes.Result); // Illustration is 72x72 using var codec = SKCodec.Create(ms); @@ -176,33 +192,48 @@ private static SKImage GenerateGiftedManyIcon(double fontSize, string cachePath, var imageInfo = new SKImageInfo(finalIconSize, finalIconSize); using var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High); + resizedBitmap.SetImmutable(); return SKImage.FromBitmap(resizedBitmap); } - private static SKImage GenerateSvgIcon(string iconSvgString, SKColor iconColor, double fontSize) + private SKImage GenerateSvgIcon(string iconSvgString, SKColor iconColor) { - using var tempBitmap = new SKBitmap(72, 72); // Icon SVG strings are scaled for 72x72 + using var tempBitmap = new SKBitmap(ICON_SIZE, ICON_SIZE); using var tempCanvas = new SKCanvas(tempBitmap); + var iconPaint = GetSvgIconPaint(iconColor); using var iconPath = SKPath.ParseSvgPathData(iconSvgString); iconPath.FillType = SKPathFillType.EvenOdd; - var iconPaint = new SKPaint + if (_outline) { - Color = iconColor, - IsAntialias = true, - LcdRenderText = true - }; + tempCanvas.DrawPath(iconPath, _outlinePaint); + } tempCanvas.DrawPath(iconPath, iconPaint); - var newSize = (int)(fontSize / 0.6); // 20*20px @ 12pt font + var newSize = (int)(_fontSize / 0.6); // 20*20px @ 12pt font var imageInfo = new SKImageInfo(newSize, newSize); var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High); + resizedBitmap.SetImmutable(); return SKImage.FromBitmap(resizedBitmap); } + private SKPaint GetSvgIconPaint(SKColor iconColor) + { + ref var iconPaint = ref CollectionsMarshal.GetValueRefOrAddDefault(_iconPaints, iconColor, out var exists); + + if (!exists) + { + iconPaint = new SKPaint(); + iconPaint.Color = iconColor; + iconPaint.IsAntialias = true; + } + + return iconPaint; + } + /// /// Splits a comment into 2 comments based on the start index of a custom re-sub message /// @@ -322,14 +353,11 @@ private void Dispose(bool isDisposing) _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; - _subscribedPrimeIcon = null; - _giftSingleIcon = null; - _giftManyIcon = null; - _giftAnonymousIcon = null; - _bitBadgeTierNotificationIcon = null; + _outlinePaint?.Dispose(); + foreach (var (_, paint) in _iconPaints) + { + paint.Dispose(); + } } } finally