diff --git a/TwitchDownloaderCLI/Modes/RenderChat.cs b/TwitchDownloaderCLI/Modes/RenderChat.cs index d9d47d78..085b0a96 100644 --- a/TwitchDownloaderCLI/Modes/RenderChat.cs +++ b/TwitchDownloaderCLI/Modes/RenderChat.cs @@ -20,7 +20,7 @@ internal static void Render(ChatRenderArgs inputOptions) progress.ProgressChanged += ProgressHandler.Progress_ProgressChanged; var renderOptions = GetRenderOptions(inputOptions); - ChatRenderer chatRenderer = new(renderOptions, progress); + using ChatRenderer chatRenderer = new(renderOptions, progress); chatRenderer.ParseJsonAsync().Wait(); chatRenderer.RenderVideoAsync(new CancellationToken()).Wait(); } @@ -115,7 +115,7 @@ private static ChatRenderOptions GetRenderOptions(ChatRenderArgs inputOptions) if (inputOptions.IgnoreUsersString != "") { - renderOptions.IgnoreUsersArray = inputOptions.IgnoreUsersString.ToLower().Split(',', + renderOptions.IgnoreUsersArray = inputOptions.IgnoreUsersString.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); } diff --git a/TwitchDownloaderCore/Chat/ChatHtml.cs b/TwitchDownloaderCore/Chat/ChatHtml.cs index c47dca9c..c68915f3 100644 --- a/TwitchDownloaderCore/Chat/ChatHtml.cs +++ b/TwitchDownloaderCore/Chat/ChatHtml.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using System.Web; +using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects; namespace TwitchDownloaderCore.Chat @@ -30,7 +31,8 @@ public static class ChatHtml cancellationToken.ThrowIfCancellationRequested(); - string[] templateStrings = Properties.Resources.template.Split('\n'); + using var templateStream = new MemoryStream(Properties.Resources.chat_template); + using var templateReader = new StreamReader(templateStream); var outputDirectory = Directory.GetParent(Path.GetFullPath(filePath))!; if (!outputDirectory.Exists) @@ -41,9 +43,10 @@ public static class ChatHtml await using var fs = File.Create(filePath); await using var sw = new StreamWriter(fs, Encoding.Unicode); - for (int i = 0; i < templateStrings.Length; i++) + while (!templateReader.EndOfStream) { - switch (templateStrings[i].TrimEnd('\r', '\n')) + var line = await templateReader.ReadLineAsync(); + switch (line.AsSpan().TrimEnd("\r\n")) { case "": await sw.WriteLineAsync(HttpUtility.HtmlEncode(Path.GetFileNameWithoutExtension(filePath))); @@ -71,13 +74,13 @@ public static class ChatHtml case "": foreach (var comment in chatRoot.comments) { - var relativeTime = new TimeSpan(0, 0, (int)comment.content_offset_seconds); - string timestamp = relativeTime.ToString(@"h\:mm\:ss"); - await sw.WriteLineAsync($"
[{timestamp}] {GetChatBadgesHtml(embedData, chatBadgeData, comment)} {(comment.commenter.display_name.Any(x => x > 127) ? $"{comment.commenter.display_name} ({comment.commenter.name})" : comment.commenter.display_name)}: {GetMessageHtml(embedData, thirdEmoteData, chatRoot, comment)}
"); + var relativeTime = TimeSpan.FromSeconds(comment.content_offset_seconds); + var timestamp = TimeSpanHFormat.ReusableInstance.Format(@"H\:mm\:ss", relativeTime); + await sw.WriteLineAsync($"
[{timestamp}] {GetChatBadgesHtml(embedData, chatBadgeData, comment)}{(comment.commenter.display_name.Any(x => x > 127) ? $"{comment.commenter.display_name} ({comment.commenter.name})" : comment.commenter.display_name)}: {GetMessageHtml(embedData, thirdEmoteData, chatRoot, comment)}
"); } break; default: - await sw.WriteLineAsync(templateStrings[i].TrimEnd('\r', '\n')); + await sw.WriteLineAsync(line.AsMemory().TrimEnd("\r\n"), cancellationToken); break; } } @@ -150,6 +153,7 @@ private static string GetChatBadgesHtml(bool embedData, IReadOnlyDictionary { new Fragment() { text = comment.message.body } }; + comment.message.fragments ??= new List { new() { text = comment.message.body } }; foreach (var fragment in comment.message.fragments) { @@ -178,7 +182,8 @@ private static string GetMessageHtml(bool embedEmotes, IReadOnlyDictionary ConvertComments(CommentVideo video, ChatFormat form message.body = bodyStringBuilder.ToString(); - var bitMatch = _bitsRegex.Match(message.body); + var bitMatch = BitsRegex.Match(message.body); if (bitMatch.Success && int.TryParse(bitMatch.ValueSpan, out var result)) { message.bits_spent = result; @@ -256,10 +256,10 @@ public async Task DownloadAsync(IProgress progress, Cancellation ChatRoot chatRoot = new() { - FileInfo = new() { Version = ChatRootVersion.CurrentVersion, CreatedAt = DateTime.Now }, + FileInfo = new ChatRootInfo { Version = ChatRootVersion.CurrentVersion, CreatedAt = DateTime.Now }, streamer = new(), video = new(), - comments = new() + comments = new List() }; string videoId = downloadOptions.Id; @@ -290,7 +290,7 @@ public async Task DownloadAsync(IProgress progress, Cancellation GqlVideoChapterResponse videoChapterResponse = await TwitchHelper.GetVideoChapters(int.Parse(videoId)); foreach (var responseChapter in videoChapterResponse.data.video.moments.edges) { - VideoChapter chapter = new() + chatRoot.video.chapters.Add(new VideoChapter { id = responseChapter.node.id, startMilliseconds = responseChapter.node.positionMilliseconds, @@ -299,11 +299,10 @@ public async Task DownloadAsync(IProgress progress, Cancellation description = responseChapter.node.description, subDescription = responseChapter.node.subDescription, thumbnailUrl = responseChapter.node.thumbnailURL, - gameId = responseChapter.node.details.game?.id ?? null, - gameDisplayName = responseChapter.node.details.game?.displayName ?? null, - gameBoxArtUrl = responseChapter.node.details.game?.boxArtURL ?? null - }; - chatRoot.video.chapters.Add(chapter); + gameId = responseChapter.node.details.game?.id, + gameDisplayName = responseChapter.node.details.game?.displayName, + gameBoxArtUrl = responseChapter.node.details.game?.boxArtURL + }); } } else @@ -508,7 +507,7 @@ public async Task DownloadAsync(IProgress progress, Cancellation await ChatText.SerializeAsync(downloadOptions.Filename, chatRoot, downloadOptions.TimeFormat); break; default: - throw new NotImplementedException("Requested output chat format is not implemented"); + throw new NotSupportedException($"{downloadOptions.DownloadFormat} is not a supported output format."); } } } diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs index 59dc2212..934b8ae2 100644 --- a/TwitchDownloaderCore/ChatRenderer.cs +++ b/TwitchDownloaderCore/ChatRenderer.cs @@ -14,30 +14,32 @@ using System.Threading; using System.Threading.Tasks; using TwitchDownloaderCore.Chat; +using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Options; using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects; namespace TwitchDownloaderCore { - public sealed class ChatRenderer + 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 const string HIGHLIGHT_BACKGROUND = "#1A6B6B6E"; - private static readonly string[] defaultColors = { "#FF0000", "#0000FF", "#00FF00", "#B22222", "#FF7F50", "#9ACD32", "#FF4500", "#2E8B57", "#DAA520", "#D2691E", "#5F9EA0", "#1E90FF", "#FF69B4", "#8A2BE2", "#00FF7F" }; + private const string PURPLE = "#7B2CF2"; + private static readonly SKColor Purple = SKColor.Parse(PURPLE); + private static readonly SKColor HighlightBackground = SKColor.Parse("#1A6B6B6E"); + 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); - private static readonly Regex blockArtRegex = new("[\u2500-\u257F\u2580-\u259F\u2800-\u28FF]", RegexOptions.Compiled); - private static readonly Regex emojiRegex = new(@"(?:[#*0-9]\uFE0F?\u20E3|©\uFE0F?|[®\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDD-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF6](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7C\uDE80-\uDE86\uDE90-\uDEAC\uDEB0-\uDEBA\uDEC0-\uDEC2\uDED0-\uDED9\uDEE0-\uDEE7]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?))", + private static readonly Regex RtlRegex = new("[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]", RegexOptions.Compiled); + private static readonly Regex BlockArtRegex = new("[\u2500-\u257F\u2580-\u259F\u2800-\u28FF]", RegexOptions.Compiled); + private static readonly Regex EmojiRegex = new(@"(?:[#*0-9]\uFE0F?\u20E3|©\uFE0F?|[®\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26D3\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC3\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDD-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF6](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE74\uDE78-\uDE7C\uDE80-\uDE86\uDE90-\uDEAC\uDEB0-\uDEBA\uDEC0-\uDEC2\uDED0-\uDED9\uDEE0-\uDEE7]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF-\uDDB3\uDDBC\uDDBD]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?))", RegexOptions.Compiled); - private Regex[] BannedWordRegexes; // TODO: Use FrozenDictionary when .NET 8 private static readonly IReadOnlyDictionary AllEmojiSequences = Emoji.All.ToDictionary(e => e.SortOrder, e => e.Sequence.AsString); - private readonly IProgress _progress = null; + private readonly IProgress _progress; private readonly ChatRenderOptions renderOptions; private List badgeList = new List(); private List emoteList = new List(); @@ -46,12 +48,13 @@ public sealed class ChatRenderer private Dictionary emojiCache = new Dictionary(); private Dictionary fallbackFontCache = new Dictionary(); private bool noFallbackFontFound = false; - private SKFontManager fontManager = SKFontManager.CreateDefault(); - private SKPaint messageFont = new SKPaint(); - private SKPaint nameFont = new SKPaint(); - private SKPaint outlinePaint = new SKPaint(); + private readonly SKFontManager fontManager = SKFontManager.CreateDefault(); + private SKPaint messageFont; + private SKPaint nameFont; + private SKPaint outlinePaint; + private readonly HighlightIcons highlightIcons; - public ChatRenderer(ChatRenderOptions chatRenderOptions, IProgress progress) + public ChatRenderer(ChatRenderOptions chatRenderOptions, IProgress progress = null) { renderOptions = chatRenderOptions; renderOptions.TempFolder = Path.Combine( @@ -60,6 +63,7 @@ public ChatRenderer(ChatRenderOptions chatRenderOptions, IProgress renderOptions.BlockArtPreWrapWidth; _progress = progress; + highlightIcons = new HighlightIcons(renderOptions.TempFolder, Purple, renderOptions.Offline); } public async Task RenderVideoAsync(CancellationToken cancellationToken) @@ -88,15 +92,18 @@ public async Task RenderVideoAsync(CancellationToken cancellationToken) messageFont.Typeface = SKTypeface.FromFamilyName(renderOptions.Font, renderOptions.MessageFontStyle); } + // Cache the rendered timestamp widths + renderOptions.TimestampWidths = !renderOptions.Timestamp ? Array.Empty() : new[] + { + (int)messageFont.MeasureText("0:00"), + (int)messageFont.MeasureText("00:00"), + (int)messageFont.MeasureText("0:00:00"), + (int)messageFont.MeasureText("00:00:00") + }; + // Rough estimation of the width of a single block art character - renderOptions.BlockArtCharWidth = GetFallbackFont('█', renderOptions).MeasureText("█"); + renderOptions.BlockArtCharWidth = GetFallbackFont('█').MeasureText("█"); - BannedWordRegexes = new Regex[renderOptions.BannedWordsArray.Length]; - for (int i = 0; i < renderOptions.BannedWordsArray.Length; i++) - { - BannedWordRegexes[i] = new Regex(@$"(?<=^|[\s\d\p{{P}}\p{{S}}]){Regex.Escape(renderOptions.BannedWordsArray[i])}(?=$|[\s\d\p{{P}}\p{{S}}])", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } RemoveRestrictedComments(chatRoot.comments); @@ -124,8 +131,8 @@ public async Task RenderVideoAsync(CancellationToken cancellationToken) } catch { - ffmpegProcess.Process.Dispose(); - maskProcess?.Process.Dispose(); + ffmpegProcess.Dispose(); + maskProcess?.Dispose(); GC.Collect(); throw; } @@ -211,24 +218,42 @@ private void FloorCommentOffsets(List comments) private void RemoveRestrictedComments(List comments) { - for (int i = 0; i < comments.Count; i++) + if (renderOptions.IgnoreUsersArray.Length == 0 && renderOptions.BannedWordsArray.Length == 0) { - if (renderOptions.IgnoreUsersArray.Contains(comments[i].commenter.name.ToLower())) + return; + } + + var bannedWordRegexes = new Regex[renderOptions.BannedWordsArray.Length]; + for (var i = 0; i < renderOptions.BannedWordsArray.Length; i++) + { + bannedWordRegexes[i] = new Regex(@$"(?<=^|[\s\d\p{{P}}\p{{S}}]){Regex.Escape(renderOptions.BannedWordsArray[i])}(?=$|[\s\d\p{{P}}\p{{S}}])", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } + + for (var i = 0; i < comments.Count; i++) + { + foreach (var username in renderOptions.IgnoreUsersArray) { - comments.RemoveAt(i); - i--; - continue; + if (username.Equals(comments[i].commenter.name, StringComparison.OrdinalIgnoreCase)) + { + comments.RemoveAt(i); + i--; + goto NextComment; + } } - foreach (var bannedWordRegex in BannedWordRegexes) + foreach (var bannedWordRegex in bannedWordRegexes) { if (bannedWordRegex.IsMatch(comments[i].message.body)) { comments.RemoveAt(i); i--; - break; + goto NextComment; } } + + // goto is cheaper and more readable than using a boolean + branch check after each operation + NextComment: ; } } @@ -249,12 +274,12 @@ private static SKTypeface GetInterTypeface(SKFontStyle fontStyle) private void RenderVideoSection(int startTick, int endTick, FfmpegProcess ffmpegProcess, FfmpegProcess maskProcess = null, CancellationToken cancellationToken = new()) { UpdateFrame latestUpdate = null; - BinaryWriter ffmpegStream = new BinaryWriter(ffmpegProcess.Process.StandardInput.BaseStream); + BinaryWriter ffmpegStream = new BinaryWriter(ffmpegProcess.StandardInput.BaseStream); BinaryWriter maskStream = null; if (maskProcess != null) - maskStream = new BinaryWriter(maskProcess.Process.StandardInput.BaseStream); + maskStream = new BinaryWriter(maskProcess.StandardInput.BaseStream); - DriveInfo outputDrive = DriveHelper.GetOutputDrive(ffmpegProcess); + DriveInfo outputDrive = DriveHelper.GetOutputDrive(ffmpegProcess.SavePath); Stopwatch stopwatch = Stopwatch.StartNew(); @@ -263,22 +288,20 @@ private static SKTypeface GetInterTypeface(SKFontStyle fontStyle) messageFont.MeasureText("ABC123", ref sampleTextBounds); int sectionDefaultYPos = (int)(((renderOptions.SectionHeight - sampleTextBounds.Height) / 2.0) + sampleTextBounds.Height); - using var highlightIcons = new HighlightMessage(renderOptions.TempFolder); - for (int currentTick = startTick; currentTick < endTick; currentTick++) { cancellationToken.ThrowIfCancellationRequested(); if (currentTick % renderOptions.UpdateFrame == 0) { - latestUpdate = GenerateUpdateFrame(currentTick, sectionDefaultYPos, highlightIcons, latestUpdate); + latestUpdate = GenerateUpdateFrame(currentTick, sectionDefaultYPos, latestUpdate); } SKBitmap frame = null; bool isCopyFrame = false; try { - (frame, isCopyFrame) = GetFrameFromTick(currentTick, sectionDefaultYPos, highlightIcons, latestUpdate); + (frame, isCopyFrame) = GetFrameFromTick(currentTick, sectionDefaultYPos, latestUpdate); if (!renderOptions.SkipDriveWaiting) DriveHelper.WaitForDrive(outputDrive, _progress, cancellationToken).Wait(cancellationToken); @@ -296,13 +319,13 @@ private static SKTypeface GetInterTypeface(SKFontStyle fontStyle) } finally { - if (isCopyFrame && frame is not null) + if (isCopyFrame) { - frame.Dispose(); + frame?.Dispose(); } } - if (_progress != null) + if (_progress != null && currentTick % 3 == 0) { double percentDouble = (currentTick - startTick) / (double)(endTick - startTick) * 100.0; int percentInt = (int)percentDouble; @@ -311,28 +334,30 @@ private static SKTypeface GetInterTypeface(SKFontStyle fontStyle) int timeLeftInt = (int)(100.0 / percentDouble * stopwatch.Elapsed.TotalSeconds) - (int)stopwatch.Elapsed.TotalSeconds; TimeSpan timeLeft = new TimeSpan(0, 0, timeLeftInt); TimeSpan timeElapsed = new TimeSpan(0, 0, (int)stopwatch.Elapsed.TotalSeconds); - _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Rendering Video: {percentInt}% ({timeElapsed.ToString(@"h\hm\ms\s")} Elapsed | {timeLeft.ToString(@"h\hm\ms\s")} Remaining)")); + _progress.Report(new ProgressReport(ReportType.SameLineStatus, $"Rendering Video: {percentInt}% ({timeElapsed:h\\hm\\ms\\s} Elapsed | {timeLeft:h\\hm\\ms\\s} Remaining)")); } } stopwatch.Stop(); + _progress?.Report(new ProgressReport(100)); _progress?.Report(new ProgressReport(ReportType.SameLineStatus, "Rendering Video: 100%")); - _progress?.Report(new ProgressReport(ReportType.Log, $"FINISHED. RENDER TIME: {(int)stopwatch.Elapsed.TotalSeconds}s SPEED: {((endTick - startTick) / renderOptions.Framerate / stopwatch.Elapsed.TotalSeconds).ToString("0.##")}x")); + _progress?.Report(new ProgressReport(ReportType.Log, $"FINISHED. RENDER TIME: {stopwatch.Elapsed.TotalSeconds:F1}s SPEED: {(endTick - startTick) / (double)renderOptions.Framerate / stopwatch.Elapsed.TotalSeconds:F2}x")); latestUpdate?.Image.Dispose(); ffmpegStream.Dispose(); maskStream?.Dispose(); - ffmpegProcess.Process.WaitForExit(100_000); - maskProcess?.Process.WaitForExit(100_000); + ffmpegProcess.WaitForExit(100_000); + maskProcess?.WaitForExit(100_000); } - private void SetFrameMask(SKBitmap frame) + private static void SetFrameMask(SKBitmap frame) { IntPtr pixelsAddr = frame.GetPixels(); - int height = frame.Height; - int width = frame.Width; + SKImageInfo frameInfo = frame.Info; + int height = frameInfo.Height; + int width = frameInfo.Width; unsafe { byte* ptr = (byte*)pixelsAddr.ToPointer(); @@ -365,15 +390,25 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) savePath = Path.Combine(renderOptions.TempFolder, Path.GetRandomFileName() + (isMask ? "_mask" : "") + Path.GetExtension(renderOptions.OutputFile)); } - string inputArgs = renderOptions.InputArgs.Replace("{fps}", renderOptions.Framerate.ToString()) - .Replace("{height}", renderOptions.ChatHeight.ToString()).Replace("{width}", renderOptions.ChatWidth.ToString()) - .Replace("{save_path}", savePath).Replace("{max_int}", int.MaxValue.ToString()) - .Replace("{pix_fmt}", SKImageInfo.PlatformColorType == SKColorType.Bgra8888 ? "bgra" : "rgba"); - string outputArgs = renderOptions.OutputArgs.Replace("{fps}", renderOptions.Framerate.ToString()) - .Replace("{height}", renderOptions.ChatHeight.ToString()).Replace("{width}", renderOptions.ChatWidth.ToString()) - .Replace("{save_path}", savePath).Replace("{max_int}", int.MaxValue.ToString()); + savePath = Path.GetFullPath(savePath); - var process = new Process + string inputArgs = new StringBuilder(renderOptions.InputArgs) + .Replace("{fps}", renderOptions.Framerate.ToString()) + .Replace("{height}", renderOptions.ChatHeight.ToString()) + .Replace("{width}", renderOptions.ChatWidth.ToString()) + .Replace("{save_path}", savePath) + .Replace("{max_int}", int.MaxValue.ToString()) + .Replace("{pix_fmt}", SKImageInfo.PlatformColorType == SKColorType.Bgra8888 ? "bgra" : "rgba") + .ToString(); + string outputArgs = new StringBuilder(renderOptions.OutputArgs) + .Replace("{fps}", renderOptions.Framerate.ToString()) + .Replace("{height}", renderOptions.ChatHeight.ToString()) + .Replace("{width}", renderOptions.ChatWidth.ToString()) + .Replace("{save_path}", savePath) + .Replace("{max_int}", int.MaxValue.ToString()) + .ToString(); + + var process = new FfmpegProcess { StartInfo = { @@ -384,7 +419,8 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, - } + }, + SavePath = savePath }; if (renderOptions.LogFfmpegOutput && _progress != null) @@ -402,12 +438,12 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) process.BeginErrorReadLine(); process.BeginOutputReadLine(); - return new FfmpegProcess(process, savePath); + return process; } - private (SKBitmap frame, bool isCopyFrame) GetFrameFromTick(int currentTick, int sectionDefaultYPos, HighlightMessage highlightIcons, UpdateFrame currentFrame = null) + private (SKBitmap frame, bool isCopyFrame) GetFrameFromTick(int currentTick, int sectionDefaultYPos, UpdateFrame currentFrame = null) { - currentFrame ??= GenerateUpdateFrame(currentTick, sectionDefaultYPos, highlightIcons); + currentFrame ??= GenerateUpdateFrame(currentTick, sectionDefaultYPos); var (frame, isCopyFrame) = DrawAnimatedEmotes(currentFrame.Image, currentFrame.Comments, currentTick); return (frame, isCopyFrame); } @@ -438,15 +474,16 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) long currentTickMs = (long)(currentTick / (double)renderOptions.Framerate * 1000); using (SKCanvas frameCanvas = new SKCanvas(newFrame)) { - foreach (var comment in comments.Reverse()) + for (int c = comments.Count - 1; c >= 0; c--) { + var comment = comments[c]; frameHeight -= comment.Image.Height + renderOptions.VerticalPadding; foreach ((Point drawPoint, TwitchEmote emote) in comment.Emotes) { if (emote.FrameCount > 1) { int frameIndex = emote.EmoteFrameDurations.Count - 1; - long imageFrame = currentTickMs % (emote.EmoteFrameDurations.Sum() * 10); + long imageFrame = currentTickMs % (emote.TotalDuration * 10); for (int i = 0; i < emote.EmoteFrameDurations.Count; i++) { if (imageFrame - emote.EmoteFrameDurations[i] * 10 <= 0) @@ -465,7 +502,7 @@ private FfmpegProcess GetFfmpegProcess(int partNumber, bool isMask) return (newFrame, true); } - private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, HighlightMessage highlightIcons, UpdateFrame lastUpdate = null) + private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, UpdateFrame lastUpdate = null) { SKBitmap newFrame = new SKBitmap(renderOptions.ChatWidth, renderOptions.ChatHeight); double currentTimeSeconds = currentTick / (double)renderOptions.Framerate; @@ -484,6 +521,12 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, { oldCommentIndex = commentList.Last().CommentIndex; } + else if (newestCommentIndex > 100) + { + // If we are starting partially through the comment list, we don't want to needlessly render *every* comment before our starting comment. + // Skipping to 100 comments before our starting index should be more than enough to fill the frame with previous comments + oldCommentIndex = newestCommentIndex - 100; + } if (newestCommentIndex > oldCommentIndex) { @@ -491,7 +534,7 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, do { - CommentSection comment = GenerateCommentSection(currentIndex, sectionDefaultYPos, highlightIcons); + CommentSection comment = GenerateCommentSection(currentIndex, sectionDefaultYPos); if (comment != null) { commentList.Add(comment); @@ -520,10 +563,8 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, frameCanvas.DrawBitmap(comment.Image, 0, frameHeight); - for (int i = 0; i < comment.Emotes.Count; i++) + foreach (var (drawPoint, emote) in comment.Emotes) { - (Point drawPoint, TwitchEmote emote) = comment.Emotes[i]; - //Only draw static emotes if (emote.FrameCount == 1) { @@ -545,15 +586,15 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, int sectionDefaultYPos, return new UpdateFrame() { Image = newFrame, Comments = commentList, CommentIndex = newestCommentIndex }; } - private CommentSection GenerateCommentSection(int commentIndex, int sectionDefaultYPos, HighlightMessage highlightIcons) + private CommentSection GenerateCommentSection(int commentIndex, int sectionDefaultYPos) { CommentSection newSection = new CommentSection(); List<(Point, TwitchEmote)> emoteSectionList = new List<(Point, TwitchEmote)>(); Comment comment = chatRoot.comments[commentIndex]; - List sectionImages = new List(); + List<(SKImageInfo info, SKBitmap bitmap)> sectionImages = new List<(SKImageInfo info, SKBitmap bitmap)>(); Point drawPos = new Point(); Point defaultPos = new Point(); - var highlightType = HighlightType.None; + var highlightType = HighlightType.Unknown; defaultPos.X = renderOptions.SidePadding; if (comment.message.user_notice_params?.msg_id != null) @@ -578,9 +619,9 @@ private CommentSection GenerateCommentSection(int commentIndex, int sectionDefau defaultPos.Y = sectionDefaultYPos; drawPos.Y = defaultPos.Y; - if (highlightType is HighlightType.None) + if (highlightType is HighlightType.Unknown) { - highlightType = HighlightMessage.GetHighlightType(comment); + highlightType = HighlightIcons.GetHighlightType(comment); } if (highlightType is not HighlightType.None) @@ -590,7 +631,7 @@ private CommentSection GenerateCommentSection(int commentIndex, int sectionDefau return null; } - DrawAccentedMessage(comment, sectionImages, emoteSectionList, highlightType, highlightIcons, ref drawPos, defaultPos); + DrawAccentedMessage(comment, sectionImages, emoteSectionList, highlightType, ref drawPos, defaultPos); } else { @@ -605,30 +646,35 @@ private CommentSection GenerateCommentSection(int commentIndex, int sectionDefau return newSection; } - private SKBitmap CombineImages(List sectionImages, HighlightType highlightType) + private SKBitmap CombineImages(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, HighlightType highlightType) { - SKBitmap finalBitmap = new SKBitmap(renderOptions.ChatWidth, sectionImages.Sum(x => x.Height)); + SKBitmap finalBitmap = new SKBitmap(renderOptions.ChatWidth, sectionImages.Sum(x => x.info.Height)); + var finalBitmapInfo = finalBitmap.Info; using (SKCanvas finalCanvas = new SKCanvas(finalBitmap)) { if (highlightType is HighlightType.PayingForward or HighlightType.ChannelPointHighlight) { - var colorString = highlightType is HighlightType.PayingForward ? "#26262c" : "#80808c"; - finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmap.Height, new SKPaint() { Color = SKColor.Parse(colorString) }); + var colorString = highlightType is HighlightType.PayingForward ? "#26262C" : "#80808C"; + using var paint = new SKPaint { Color = SKColor.Parse(colorString) }; + finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmapInfo.Height, paint); } else if (highlightType is not HighlightType.None) { - finalCanvas.DrawRect(renderOptions.SidePadding, 0, finalBitmap.Width - renderOptions.SidePadding * 2, finalBitmap.Height, new SKPaint() { Color = SKColor.Parse(HIGHLIGHT_BACKGROUND) }); - finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmap.Height, new SKPaint() { Color = SKColor.Parse(PURPLE) }); + using var backgroundPaint = new SKPaint { Color = HighlightBackground }; + using var accentPaint = new SKPaint { Color = Purple }; + finalCanvas.DrawRect(renderOptions.SidePadding, 0, finalBitmapInfo.Width - renderOptions.SidePadding * 2, finalBitmapInfo.Height, backgroundPaint); + finalCanvas.DrawRect(renderOptions.SidePadding, 0, renderOptions.AccentStrokeWidth, finalBitmapInfo.Height, accentPaint); } for (int i = 0; i < sectionImages.Count; i++) { - finalCanvas.DrawBitmap(sectionImages[i], 0, i * renderOptions.SectionHeight); - sectionImages[i].Dispose(); + finalCanvas.DrawBitmap(sectionImages[i].bitmap, 0, i * renderOptions.SectionHeight); + sectionImages[i].bitmap.Dispose(); } } sectionImages.Clear(); + finalBitmap.SetImmutable(); return finalBitmap; } @@ -647,7 +693,7 @@ private static string GetKeyName(IEnumerable codepoints) return emojiKey; } - private void DrawNonAccentedMessage(Comment comment, List 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, ref Point drawPos, ref Point defaultPos) { if (renderOptions.Timestamp) { @@ -659,19 +705,24 @@ private void DrawNonAccentedMessage(Comment comment, List sectionImage } DrawUsername(comment, sectionImages, ref drawPos, defaultPos); DrawMessage(comment, sectionImages, emotePositionList, highlightWords, ref drawPos, defaultPos); + + foreach (var (_, bitmap) in sectionImages) + { + bitmap.SetImmutable(); + } } - private void DrawAccentedMessage(Comment comment, List sectionImages, List<(Point, TwitchEmote)> emotePositionList, HighlightType highlightType, HighlightMessage highlightIcons, ref Point drawPos, Point defaultPos) + private void DrawAccentedMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, HighlightType highlightType, ref Point drawPos, Point defaultPos) { drawPos.X += renderOptions.AccentIndentWidth; defaultPos.X = drawPos.X; - using var highlightIcon = highlightIcons.GetHighlightIcon(highlightType, PURPLE, messageFont.Color, renderOptions.FontSize); + var highlightIcon = highlightIcons.GetHighlightIcon(highlightType, messageFont.Color, renderOptions.FontSize); Point iconPoint = new() { X = drawPos.X, - Y = (int)((renderOptions.SectionHeight - (highlightIcon?.Height ?? 0)) / 2.0) + Y = (int)((renderOptions.SectionHeight - highlightIcon?.Height) / 2.0 ?? 0) }; switch (highlightType) @@ -694,12 +745,17 @@ private void DrawAccentedMessage(Comment comment, List sectionImages, DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos); break; } + + foreach (var (_, bitmap) in sectionImages) + { + bitmap.SetImmutable(); + } } - private void DrawSubscribeMessage(Comment comment, List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKBitmap highlightIcon, Point iconPoint) + private void DrawSubscribeMessage(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()); - canvas.DrawBitmap(highlightIcon, iconPoint.X, iconPoint.Y); + using SKCanvas canvas = new(sectionImages.Last().bitmap); + canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y); Point customMessagePos = drawPos; drawPos.X += highlightIcon.Width + renderOptions.WordSpacing; @@ -720,7 +776,7 @@ private void DrawSubscribeMessage(Comment comment, List sectionImages, comment.message.fragments[0].text = comment.message.fragments[0].text[(comment.commenter.display_name.Length + 1)..]; } - var (resubMessage, customResubMessage) = HighlightMessage.SplitSubComment(comment); + var (resubMessage, customResubMessage) = HighlightIcons.SplitSubComment(comment); DrawMessage(resubMessage, sectionImages, emotePositionList, false, ref drawPos, defaultPos); // Return if there is no custom resub message to draw @@ -735,17 +791,17 @@ private void DrawSubscribeMessage(Comment comment, List sectionImages, DrawNonAccentedMessage(customResubMessage, sectionImages, emotePositionList, false, ref drawPos, ref defaultPos); } - private void DrawGiftMessage(Comment comment, List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, SKBitmap highlightIcon, Point iconPoint) + 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()); + using SKCanvas canvas = new(sectionImages.Last().bitmap); - canvas.DrawBitmap(highlightIcon, iconPoint.X, iconPoint.Y); + canvas.DrawImage(highlightIcon, iconPoint.X, iconPoint.Y); drawPos.X += highlightIcon.Width + renderOptions.AccentIndentWidth - renderOptions.AccentStrokeWidth; defaultPos.X = drawPos.X; DrawMessage(comment, sectionImages, emotePositionList, false, ref drawPos, defaultPos); } - private void DrawMessage(Comment comment, List sectionImages, List<(Point, TwitchEmote)> emotePositionList, bool highlightWords, ref Point drawPos, Point defaultPos) + private void DrawMessage(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, bool highlightWords, ref Point drawPos, Point defaultPos) { int bitsCount = comment.message.bits_spent; foreach (var fragment in comment.message.fragments) @@ -766,13 +822,13 @@ private void DrawMessage(Comment comment, List sectionImages, List<(Po } } - private void DrawFragmentPart(List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentPart, bool highlightWords, bool skipThird = false, bool skipEmoji = false, bool skipNonFont = false) + private void DrawFragmentPart(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentPart, bool highlightWords, bool skipThird = false, bool skipEmoji = false, bool skipNonFont = false) { - if (!skipThird && emoteThirdList.Any(x => x.Name == fragmentPart)) + if (!skipThird && TryGetTwitchEmote(emoteThirdList, fragmentPart, out var emote)) { - DrawThirdPartyEmote(sectionImages, emotePositionList, ref drawPos, defaultPos, fragmentPart, highlightWords); + DrawThirdPartyEmote(sectionImages, emotePositionList, ref drawPos, defaultPos, emote, highlightWords); } - else if (!skipEmoji && emojiRegex.IsMatch(fragmentPart)) + else if (!skipEmoji && EmojiRegex.IsMatch(fragmentPart)) { DrawEmojiMessage(sectionImages, emotePositionList, ref drawPos, defaultPos, bitsCount, fragmentPart, highlightWords); } @@ -784,37 +840,55 @@ private void DrawFragmentPart(List sectionImages, List<(Point, TwitchE { DrawRegularMessage(sectionImages, emotePositionList, ref drawPos, defaultPos, bitsCount, fragmentPart, highlightWords); } + + static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteName, out TwitchEmote twitchEmote) + { + // Enumerating over a span is faster than a list + var emoteListSpan = CollectionsMarshal.AsSpan(twitchEmoteList); + foreach (var emote1 in emoteListSpan) + { + if (emote1.Name.AsSpan().SequenceEqual(emoteName)) + { + twitchEmote = emote1; + return true; + } + } + + twitchEmote = default; + return false; + } } - private void DrawThirdPartyEmote(List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, string fragmentString, bool highlightWords) + private void DrawThirdPartyEmote(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, TwitchEmote twitchEmote, bool highlightWords) { - TwitchEmote twitchEmote = emoteThirdList.First(x => x.Name == fragmentString); + SKImageInfo emoteInfo = twitchEmote.Info; Point emotePoint = new Point(); if (!twitchEmote.IsZeroWidth) { - if (drawPos.X + twitchEmote.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) + if (drawPos.X + emoteInfo.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) { AddImageSection(sectionImages, ref drawPos, defaultPos); } if (highlightWords) { - using var canvas = new SKCanvas(sectionImages.Last()); - canvas.DrawRect(drawPos.X, 0, twitchEmote.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, new SKPaint() { Color = SKColor.Parse(PURPLE) });; + using var canvas = new SKCanvas(sectionImages.Last().bitmap); + using var paint = new SKPaint { Color = Purple }; + canvas.DrawRect(drawPos.X, 0, emoteInfo.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, paint); } emotePoint.X = drawPos.X; - drawPos.X += twitchEmote.Width + renderOptions.EmoteSpacing; + drawPos.X += emoteInfo.Width + renderOptions.EmoteSpacing; } else { - emotePoint.X = drawPos.X - renderOptions.EmoteSpacing - twitchEmote.Width; + emotePoint.X = drawPos.X - renderOptions.EmoteSpacing - emoteInfo.Width; } - emotePoint.Y = (int)(sectionImages.Sum(x => x.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - twitchEmote.Height) / 2.0)); + emotePoint.Y = (int)(sectionImages.Sum(x => x.info.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - emoteInfo.Height) / 2.0)); emotePositionList.Add((emotePoint, twitchEmote)); } - private void DrawEmojiMessage(List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentString, bool highlightWords) + private void DrawEmojiMessage(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentString, bool highlightWords) { if (renderOptions.EmojiVendor == EmojiVendor.None) { @@ -882,8 +956,9 @@ private void DrawEmojiMessage(List sectionImages, List<(Point, TwitchE SingleEmoji selectedEmoji = emojiMatches.MaxBy(x => x.SortOrder); SKBitmap emojiImage = emojiCache[GetKeyName(selectedEmoji.Sequence.Codepoints)]; + SKImageInfo emojiImageInfo = emojiImage.Info; - if (drawPos.X + emojiImage.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) + if (drawPos.X + emojiImageInfo.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) { AddImageSection(sectionImages, ref drawPos, defaultPos); } @@ -891,21 +966,21 @@ private void DrawEmojiMessage(List sectionImages, List<(Point, TwitchE Point emotePoint = new Point { X = drawPos.X + (int)Math.Ceiling(renderOptions.EmoteSpacing / 2d), // emotePoint.X halfway through emote padding - Y = (int)((renderOptions.SectionHeight - emojiImage.Height) / 2.0) + Y = (int)((renderOptions.SectionHeight - emojiImageInfo.Height) / 2.0) }; - using (SKCanvas canvas = new SKCanvas(sectionImages.Last())) + using (SKCanvas canvas = new SKCanvas(sectionImages.Last().bitmap)) { if (highlightWords) { - canvas.DrawRect((int)(emotePoint.X - renderOptions.EmoteSpacing / 2d), 0, emojiImage.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, - new SKPaint() { Color = SKColor.Parse(PURPLE) }); + using var paint = new SKPaint { Color = Purple }; + canvas.DrawRect((int)(emotePoint.X - renderOptions.EmoteSpacing / 2d), 0, emojiImageInfo.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, paint); } canvas.DrawBitmap(emojiImage, emotePoint.X, emotePoint.Y); } - drawPos.X += emojiImage.Width + renderOptions.EmoteSpacing; + drawPos.X += emojiImageInfo.Width + renderOptions.EmoteSpacing; } if (nonEmojiBuffer.Length > 0) { @@ -914,12 +989,12 @@ private void DrawEmojiMessage(List sectionImages, List<(Point, TwitchE } } - private void DrawNonFontMessage(List sectionImages, ref Point drawPos, Point defaultPos, string fragmentString, bool highlightWords) + private void DrawNonFontMessage(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, string fragmentString, bool highlightWords) { - ReadOnlySpan fragmentSpan = fragmentString.Trim('\uFE0F').AsSpan(); + ReadOnlySpan fragmentSpan = fragmentString.AsSpan().Trim('\uFE0F'); // TODO: use fragmentSpan instead of fragmentString once upgraded to .NET 7 - if (blockArtRegex.IsMatch(fragmentString)) + if (BlockArtRegex.IsMatch(fragmentString)) { // Very rough estimation of width of block art int textWidth = (int)(fragmentSpan.Length * renderOptions.BlockArtCharWidth); @@ -944,7 +1019,7 @@ private void DrawNonFontMessage(List sectionImages, ref Point drawPos, } if (nonFontBuffer.Length > 0) { - using SKPaint nonFontFallbackFont = GetFallbackFont(nonFontBuffer[0], renderOptions).Clone(); + using SKPaint nonFontFallbackFont = GetFallbackFont(nonFontBuffer[0]).Clone(); nonFontFallbackFont.Color = renderOptions.MessageColor; DrawText(nonFontBuffer.ToString(), nonFontFallbackFont, false, sectionImages, ref drawPos, defaultPos, highlightWords); nonFontBuffer.Clear(); @@ -953,7 +1028,7 @@ private void DrawNonFontMessage(List sectionImages, ref Point drawPos, //Don't attempt to draw U+E0000 if (utf32Char != 0xE0000) { - using SKPaint highSurrogateFallbackFont = GetFallbackFont(utf32Char, renderOptions).Clone(); + using SKPaint highSurrogateFallbackFont = GetFallbackFont(utf32Char).Clone(); highSurrogateFallbackFont.Color = renderOptions.MessageColor; DrawText(fragmentSpan.Slice(j, 2).ToString(), highSurrogateFallbackFont, false, sectionImages, ref drawPos, defaultPos, highlightWords); } @@ -973,7 +1048,7 @@ private void DrawNonFontMessage(List sectionImages, ref Point drawPos, { if (nonFontBuffer.Length > 0) { - using SKPaint fallbackFont = GetFallbackFont(nonFontBuffer[0], renderOptions).Clone(); + using SKPaint fallbackFont = GetFallbackFont(nonFontBuffer[0]).Clone(); fallbackFont.Color = renderOptions.MessageColor; DrawText(nonFontBuffer.ToString(), fallbackFont, false, sectionImages, ref drawPos, defaultPos, highlightWords); nonFontBuffer.Clear(); @@ -985,7 +1060,7 @@ private void DrawNonFontMessage(List sectionImages, ref Point drawPos, // Only one or the other should occur if (nonFontBuffer.Length > 0) { - using SKPaint fallbackFont = GetFallbackFont(nonFontBuffer[0], renderOptions).Clone(); + using SKPaint fallbackFont = GetFallbackFont(nonFontBuffer[0]).Clone(); fallbackFont.Color = renderOptions.MessageColor; DrawText(nonFontBuffer.ToString(), fallbackFont, true, sectionImages, ref drawPos, defaultPos, highlightWords); nonFontBuffer.Clear(); @@ -997,79 +1072,105 @@ private void DrawNonFontMessage(List sectionImages, ref Point drawPos, } } - private void DrawRegularMessage(List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentString, bool highlightWords) + private void DrawRegularMessage(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, int bitsCount, string fragmentString, bool highlightWords) { bool bitsPrinted = false; - try + if (bitsCount > 0 && fragmentString.Any(char.IsDigit) && fragmentString.Any(char.IsLetter)) { - if (bitsCount > 0 && fragmentString.Any(char.IsDigit) && fragmentString.Any(char.IsLetter)) + int bitsIndex = fragmentString.AsSpan().IndexOfAny("0123456789"); + if (int.TryParse(fragmentString.AsSpan(bitsIndex), out var bitsAmount) && TryGetCheerEmote(cheermotesList, fragmentString.AsSpan(0, bitsIndex), out var currentCheerEmote)) { - int bitsIndex = fragmentString.IndexOfAny("0123456789".ToCharArray()); - string outputPrefix = fragmentString.Substring(0, bitsIndex).ToLower(); - var currentCheerEmote = cheermotesList.FirstOrDefault(x => x.prefix.ToLower() == outputPrefix, null); - if (currentCheerEmote is not null) + KeyValuePair tierList = currentCheerEmote.getTier(bitsAmount); + TwitchEmote cheerEmote = tierList.Value; + SKImageInfo cheerEmoteInfo = cheerEmote.Info; + if (drawPos.X + cheerEmoteInfo.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) { - int bitsAmount = int.Parse(fragmentString.AsSpan()[bitsIndex..]); - bitsCount -= bitsAmount; - KeyValuePair tierList = currentCheerEmote.getTier(bitsAmount); - TwitchEmote twitchEmote = tierList.Value; - if (drawPos.X + twitchEmote.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) - { - AddImageSection(sectionImages, ref drawPos, defaultPos); - } - - Point emotePoint = new Point - { - X = drawPos.X, - Y = (int)(sectionImages.Sum(x => x.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - twitchEmote.Height) / 2.0)) - }; - emotePositionList.Add((emotePoint, twitchEmote)); - drawPos.X += twitchEmote.Width + renderOptions.EmoteSpacing; - bitsPrinted = true; + AddImageSection(sectionImages, ref drawPos, defaultPos); } + + Point emotePoint = new Point + { + X = drawPos.X, + Y = (int)(sectionImages.Sum(x => x.info.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - cheerEmoteInfo.Height) / 2.0)) + }; + emotePositionList.Add((emotePoint, cheerEmote)); + drawPos.X += cheerEmoteInfo.Width + renderOptions.EmoteSpacing; + bitsPrinted = true; } } - catch { } if (!bitsPrinted) { DrawText(fragmentString, messageFont, true, sectionImages, ref drawPos, defaultPos, highlightWords); } + + static bool TryGetCheerEmote(List cheerEmoteList, ReadOnlySpan prefix, out CheerEmote cheerEmote) + { + // Enumerating over a span is faster than a list + var cheerEmoteListSpan = CollectionsMarshal.AsSpan(cheerEmoteList); + foreach (var emote1 in cheerEmoteListSpan) + { + if (emote1.prefix.AsSpan().Equals(prefix, StringComparison.OrdinalIgnoreCase)) + { + cheerEmote = emote1; + return true; + } + } + + cheerEmote = default; + return false; + } } - private void DrawFirstPartyEmote(List sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, Fragment fragment, bool highlightWords) + private void DrawFirstPartyEmote(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, List<(Point, TwitchEmote)> emotePositionList, ref Point drawPos, Point defaultPos, Fragment fragment, bool highlightWords) { // First party emote - string emoteId = fragment.emoticon.emoticon_id; - if (emoteList.Any(x => x.Id == emoteId)) + if (TryGetTwitchEmote(emoteList, fragment.emoticon.emoticon_id, out var emote)) { - TwitchEmote twitchEmote = emoteList.First(x => x.Id == emoteId); - if (drawPos.X + twitchEmote.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) + SKImageInfo emoteInfo = emote.Info; + if (drawPos.X + emoteInfo.Width > renderOptions.ChatWidth - renderOptions.SidePadding * 2) { AddImageSection(sectionImages, ref drawPos, defaultPos); } Point emotePoint = new Point { X = drawPos.X, - Y = (int)(sectionImages.Sum(x => x.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - twitchEmote.Height) / 2.0)) + Y = (int)(sectionImages.Sum(x => x.info.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - emoteInfo.Height) / 2.0)) }; if (highlightWords) { - using var canvas = new SKCanvas(sectionImages.Last()); - canvas.DrawRect(drawPos.X, 0, twitchEmote.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, new SKPaint() { Color = SKColor.Parse(PURPLE) }); + using var canvas = new SKCanvas(sectionImages.Last().bitmap); + canvas.DrawRect(drawPos.X, 0, emoteInfo.Width + renderOptions.EmoteSpacing, renderOptions.SectionHeight, new SKPaint() { Color = Purple }); } - emotePositionList.Add((emotePoint, twitchEmote)); - drawPos.X += twitchEmote.Width + renderOptions.EmoteSpacing; + emotePositionList.Add((emotePoint, emote)); + drawPos.X += emoteInfo.Width + renderOptions.EmoteSpacing; } else { // Probably an old emote that was removed DrawText(fragment.text, messageFont, true, sectionImages, ref drawPos, defaultPos, highlightWords); } + + static bool TryGetTwitchEmote(List twitchEmoteList, ReadOnlySpan emoteId, out TwitchEmote twitchEmote) + { + // Enumerating over a span is faster than a list + var emoteListSpan = CollectionsMarshal.AsSpan(twitchEmoteList); + foreach (var emote1 in emoteListSpan) + { + if (emote1.Id.AsSpan().SequenceEqual(emoteId)) + { + twitchEmote = emote1; + return true; + } + } + + twitchEmote = default; + return false; + } } - private void DrawText(string drawText, SKPaint textFont, bool padding, List sectionImages, ref Point drawPos, Point defaultPos, bool highlightWords, bool noWrap = false) + private void DrawText(string drawText, SKPaint textFont, bool padding, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos, bool highlightWords, bool noWrap = false) { bool isRtl = IsRightToLeft(drawText); float textWidth = MeasureText(drawText, textFont, isRtl); @@ -1077,12 +1178,12 @@ private void DrawText(string drawText, SKPaint textFont, bool padding, List effectiveChatWidth) { - string newDrawText = SubstringToTextWidth(drawText, textFont, effectiveChatWidth, isRtl, new char[] { '?', '-' }); + string newDrawText = SubstringToTextWidth(drawText, textFont, effectiveChatWidth, isRtl, "?-").ToString(); var overrideWrap = false; if (newDrawText.Length == 0) { - // When chat width and font size are small enough, 1 character can be wider than effectiveChatWidth. + // When chat width is small enough and font size is big enough, 1 character can be wider than effectiveChatWidth. overrideWrap = true; newDrawText = drawText[..1]; } @@ -1097,23 +1198,24 @@ private void DrawText(string drawText, SKPaint textFont, bool padding, List less than or equal to when drawn with OR substringed to the last index of any character in . /// /// A shortened in visual width or delimited , whichever comes first. - private static string SubstringToTextWidth(string text, SKPaint textFont, int maxWidth, bool isRtl, char[] delimiters) + private static ReadOnlySpan SubstringToTextWidth(ReadOnlySpan text, SKPaint textFont, int maxWidth, bool isRtl, ReadOnlySpan delimiters) { - ReadOnlySpan inputText = text.AsSpan(); + // If we are dealing with non-RTL and don't have any delimiters then SKPaint.BreakText is over 9x faster + if (!isRtl && text.IndexOfAny(delimiters) == -1) + { + return SubstringToTextWidth(text, textFont, maxWidth); + } + + using var shaper = isRtl + ? new SKShaper(textFont.Typeface) + : null; - // input text was already less than max width - if (MeasureText(inputText, textFont, isRtl) <= maxWidth) + // Input text was already less than max width + if (MeasureText(text, textFont, isRtl, shaper) <= maxWidth) { return text; } // Cut in half until <= width - int length = inputText.Length; + var length = text.Length; do { length /= 2; } - while (MeasureText(inputText.Slice(0, length), textFont, isRtl) > maxWidth); + while (MeasureText(text[..length], textFont, isRtl, shaper) > maxWidth); // Add chars until greater than width, then remove the last do { length++; - } while (MeasureText(inputText.Slice(0, length), textFont, isRtl) < maxWidth); - inputText = inputText.Slice(0, length - 1); + } while (MeasureText(text[..length], textFont, isRtl, shaper) < maxWidth); + text = text[..(length - 1)]; // Cut at the last delimiter character if applicable - int delimiterIndex = inputText.LastIndexOfAny(delimiters); + var delimiterIndex = text.LastIndexOfAny(delimiters); if (delimiterIndex != -1) { - return inputText.Slice(0, delimiterIndex + 1).ToString(); + return text[..(delimiterIndex + 1)]; } - return inputText.ToString(); + return text; } - private static float MeasureText(ReadOnlySpan text, SKPaint textFont, bool? isRtl = null) + /// + /// Produces a less than or equal to when drawn with + /// + /// A shortened in visual width . + /// This is not compatible with text that needs to be shaped. + private static ReadOnlySpan SubstringToTextWidth(ReadOnlySpan text, SKPaint textFont, int maxWidth) { - isRtl ??= IsRightToLeft(text[0].ToString()); + var length = (int)textFont.BreakText(text, maxWidth); + return text[..length]; + } + + private static float MeasureText(ReadOnlySpan text, SKPaint textFont, bool? isRtl = null, SKShaper shaper = null) + { + isRtl ??= IsRightToLeft(text); if (isRtl == false) { return textFont.MeasureText(text); } - else + + if (shaper == null) { return MeasureRtlText(text, textFont); } + + return MeasureRtlText(text, textFont, shaper); } private static float MeasureRtlText(ReadOnlySpan rtlText, SKPaint textFont) - => MeasureRtlText(rtlText.ToString(), textFont); - - private static float MeasureRtlText(string rtlText, SKPaint textFont) { - using SKShaper messageShape = new SKShaper(textFont.Typeface); - SKShaper.Result measure = messageShape.Shape(rtlText, textFont); - return measure.Width; + using var shaper = new SKShaper(textFont.Typeface); + return MeasureRtlText(rtlText, textFont, shaper); } - // Heavily modified from SkiaSharp.HarfBuzz.CanvasExtensions.DrawShapedText - private static SKPath GetShapedTextPath(SKPaint paint, string text, float x, float y) + private static float MeasureRtlText(ReadOnlySpan rtlText, SKPaint textFont, SKShaper shaper) { - if (string.IsNullOrWhiteSpace(text)) - return new SKPath(); - if (paint == null) - throw new ArgumentNullException(nameof(paint)); - - using var font = paint.ToFont(); - using var shaper = new SKShaper(paint.Typeface); - var result = shaper.Shape(text, x, y, paint); - - var glyphSpan = result.Codepoints.AsSpan(); - var pointSpan = result.Points.AsSpan(); - - var xOffset = 0.0f; - if (paint.TextAlign != SKTextAlign.Left) - { - var width = result.Width; - if (paint.TextAlign == SKTextAlign.Center) - width *= 0.5f; - xOffset -= width; - } - - var returnPath = new SKPath(); - for (var i = 0; i < pointSpan.Length; i++) - { - using var glyphPath = font.GetGlyphPath((ushort)glyphSpan[i]); - if (glyphPath.IsEmpty) - continue; - - var point = pointSpan[i]; - glyphPath.Transform(new SKMatrix( - 1, 0, point.X + xOffset, - 0, 1, point.Y, - 0, 0, 1 - )); - returnPath.AddPath(glyphPath); - } - - return returnPath; + using var buffer = new HarfBuzzSharp.Buffer(); + buffer.Add(rtlText, textFont.TextEncoding); + SKShaper.Result measure = shaper.Shape(buffer, textFont); + return measure.Width; } - private void DrawUsername(Comment comment, List 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, string colorOverride = null) { - SKColor userColor = SKColor.Parse(colorOverride ?? comment.message.user_color ?? defaultColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % defaultColors.Length]); + SKColor userColor = SKColor.Parse(colorOverride ?? comment.message.user_color ?? DefaultUsernameColors[Math.Abs(comment.commenter.display_name.GetHashCode()) % DefaultUsernameColors.Length]); if (colorOverride is null) userColor = GenerateUserColor(userColor, renderOptions.BackgroundColor, renderOptions); - SKPaint userPaint = comment.commenter.display_name.Any(IsNotAscii) - ? GetFallbackFont(comment.commenter.display_name.Where(IsNotAscii).First(), renderOptions).Clone() + 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 ? ":" : ""); DrawText(userName, userPaint, true, sectionImages, ref drawPos, defaultPos, false); - userPaint.Dispose(); } private static SKColor GenerateUserColor(SKColor userColor, SKColor background_color, ChatRenderOptions renderOptions) @@ -1274,20 +1358,9 @@ private static SKColor GenerateUserColor(SKColor userColor, SKColor background_c return userColor; } -#if DEBUG - //For debugging, works on Windows only - private static void OpenImage(SKBitmap newBitmap) - { - string tempFile = Path.GetFileNameWithoutExtension(Path.GetTempFileName()) + ".png"; - using (FileStream fs = new FileStream(tempFile, FileMode.Create)) - newBitmap.Encode(SKEncodedImageFormat.Png, 100).SaveTo(fs); - - Process.Start(new ProcessStartInfo(tempFile) { UseShellExecute = true }); - } -#endif - private void DrawBadges(Comment comment, List sectionImages, ref Point drawPos) + private void DrawBadges(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos) { - using SKCanvas sectionImageCanvas = new SKCanvas(sectionImages.Last()); + using SKCanvas sectionImageCanvas = new SKCanvas(sectionImages.Last().bitmap); List<(SKBitmap, ChatBadgeType)> badgeImages = ParseCommentBadges(comment); foreach (var (badgeImage, badgeType) in badgeImages) { @@ -1305,64 +1378,91 @@ private void DrawBadges(Comment comment, List sectionImages, ref Point { List<(SKBitmap, ChatBadgeType)> returnList = new List<(SKBitmap, ChatBadgeType)>(); - if (comment.message.user_badges != null) + if (comment.message.user_badges == null) + return returnList; + + foreach (var badge in comment.message.user_badges) { - foreach (var badge in comment.message.user_badges) + string id = badge._id; + string version = badge.version; + + foreach (var cachedBadge in badgeList) { - bool foundBadge = false; - string id = badge._id.ToString(); - string version = badge.version.ToString(); + if (cachedBadge.Name != id) + continue; - foreach (var cachedBadge in badgeList) + foreach (var cachedVersion in cachedBadge.Versions) { - if (cachedBadge.Name != id) - continue; - - foreach (var cachedVersion in cachedBadge.Versions) + if (cachedVersion.Key == version) { - if (cachedVersion.Key == version) - { - returnList.Add((cachedVersion.Value, cachedBadge.Type)); - foundBadge = true; - break; - } + returnList.Add((cachedVersion.Value, cachedBadge.Type)); + goto NextUserBadge; } - - if (foundBadge) - break; } } + + // goto is cheaper and more readable than using a boolean + branch check after each operation + NextUserBadge: ; } return returnList; } - private void DrawTimestamp(Comment comment, List sectionImages, ref Point drawPos, ref Point defaultPos) + private void DrawTimestamp(Comment comment, List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, ref Point defaultPos) { - using SKCanvas sectionImageCanvas = new SKCanvas(sectionImages.Last()); - TimeSpan timestamp = new TimeSpan(0, 0, (int)comment.content_offset_seconds); - string timeString = ""; + using var sectionImageCanvas = new SKCanvas(sectionImages.Last().bitmap); + var timestamp = new TimeSpan(0, 0, (int)comment.content_offset_seconds); + + const int MAX_TIMESTAMP_LENGTH = 8; // 48:00:00 + var formattedTimestamp = FormatTimestamp(stackalloc char[MAX_TIMESTAMP_LENGTH], timestamp); - if (timestamp.Hours >= 1) - timeString = timestamp.ToString(@"h\:mm\:ss"); - else - timeString = timestamp.ToString(@"m\:ss"); - int textWidth = (int)messageFont.MeasureText(Regex.Replace(timeString, "[0-9]", "0")); if (renderOptions.Outline) { - SKPath outlinePath = messageFont.GetTextPath(timeString, drawPos.X, drawPos.Y); + using var outlinePath = messageFont.GetTextPath(formattedTimestamp, drawPos.X, drawPos.Y); sectionImageCanvas.DrawPath(outlinePath, outlinePaint); } - sectionImageCanvas.DrawText(timeString, drawPos.X, drawPos.Y, messageFont); - drawPos.X += textWidth + (renderOptions.WordSpacing * 2); + + sectionImageCanvas.DrawText(formattedTimestamp, drawPos.X, drawPos.Y, messageFont); + var textWidth = + timestamp.TotalHours >= 1 + ? timestamp.TotalHours >= 10 + ? renderOptions.TimestampWidths[3] + : renderOptions.TimestampWidths[2] + : timestamp.Minutes >= 10 + ? renderOptions.TimestampWidths[1] + : renderOptions.TimestampWidths[0]; + drawPos.X += textWidth + renderOptions.WordSpacing * 2; defaultPos.X = drawPos.X; + + static ReadOnlySpan FormatTimestamp(Span stackSpace, TimeSpan timespan) + { + if (timespan.TotalHours >= 1) + { + if (timespan.TotalHours >= 24) + { + return TimeSpanHFormat.ReusableInstance.Format(@"HH\:mm\:ss", timespan); + } + + return timespan.TryFormat(stackSpace, out var charsWritten, @"h\:mm\:ss") + ? stackSpace[..charsWritten] + : timespan.ToString(@"h\:mm\:ss"); + } + else + { + return timespan.TryFormat(stackSpace, out var charsWritten, @"m\:ss") + ? stackSpace[..charsWritten] + : timespan.ToString(@"m\:ss"); + } + } } - private void AddImageSection(List sectionImages, ref Point drawPos, Point defaultPos) + private void AddImageSection(List<(SKImageInfo info, SKBitmap bitmap)> sectionImages, ref Point drawPos, Point defaultPos) { drawPos.X = defaultPos.X; drawPos.Y = defaultPos.Y; - sectionImages.Add(new SKBitmap(renderOptions.ChatWidth, renderOptions.SectionHeight)); + SKBitmap newBitmap = new SKBitmap(renderOptions.ChatWidth, renderOptions.SectionHeight); + SKImageInfo newInfo = newBitmap.Info; + sectionImages.Add((newInfo, newBitmap)); } /// @@ -1509,7 +1609,7 @@ private async Task> GetScaledEmojis(CancellationTok } } - public SKPaint GetFallbackFont(int input, ChatRenderOptions renderOptions) + private SKPaint GetFallbackFont(int input) { ref var fallbackPaint = ref CollectionsMarshal.GetValueRefOrAddDefault(fallbackFontCache, input, out bool alreadyExists); if (alreadyExists) @@ -1537,9 +1637,9 @@ private static bool IsNotAscii(char input) return input > 127; } - private static IEnumerable SwapRightToLeft(string[] words) + private static List SwapRightToLeft(string[] words) { - List finalWords = new List(); + List finalWords = new List(words.Length); Stack rtlStack = new Stack(); foreach (var word in words) { @@ -1560,10 +1660,10 @@ private static IEnumerable SwapRightToLeft(string[] words) { finalWords.Add(rtlStack.Pop()); } - return finalWords.AsEnumerable(); + return finalWords; } - private static bool IsRightToLeft(string message) + private static bool IsRightToLeft(ReadOnlySpan message) { if (message.Length > 0) { @@ -1584,19 +1684,66 @@ private static bool IsRightToLeft(string message) return chatRoot; } - ~ChatRenderer() + +#region ImplementIDisposable + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool isDisposing) { - chatRoot = null; - badgeList = null; - emoteList = null; - emoteThirdList = null; - cheermotesList = null; - emojiCache = null; - fallbackFontCache = null; - fontManager.Dispose(); - outlinePaint.Dispose(); - nameFont.Dispose(); - messageFont.Dispose(); + try + { + if (Disposed) + { + return; + } + + if (isDisposing) + { + foreach (var badge in badgeList) + badge?.Dispose(); + foreach (var emote in emoteList) + emote?.Dispose(); + foreach (var emote in emoteThirdList) + emote?.Dispose(); + foreach (var cheerEmote in cheermotesList) + cheerEmote?.Dispose(); + foreach (var (_, bitmap) in emojiCache) + bitmap?.Dispose(); + foreach (var (_, paint) in fallbackFontCache) + paint?.Dispose(); + fontManager?.Dispose(); + nameFont?.Dispose(); + messageFont?.Dispose(); + outlinePaint?.Dispose(); + highlightIcons?.Dispose(); + + badgeList.Clear(); + emoteList.Clear(); + emoteThirdList.Clear(); + cheermotesList.Clear(); + emojiCache.Clear(); + fallbackFontCache.Clear(); + + // Set the root references to null to explicitly tell the garbage collector that the resources have been disposed + chatRoot = null; + badgeList = null; + emoteList = null; + emoteThirdList = null; + cheermotesList = null; + emojiCache = null; + fallbackFontCache = null; + } + } + finally + { + Disposed = true; + } } + +#endregion } } \ No newline at end of file diff --git a/TwitchDownloaderCore/ChatUpdater.cs b/TwitchDownloaderCore/ChatUpdater.cs index d5136d6c..9ac3c06a 100644 --- a/TwitchDownloaderCore/ChatUpdater.cs +++ b/TwitchDownloaderCore/ChatUpdater.cs @@ -33,7 +33,7 @@ private static class SharedObjects public async Task UpdateAsync(IProgress progress, CancellationToken cancellationToken) { chatRoot.FileInfo = new() { Version = ChatRootVersion.CurrentVersion, CreatedAt = chatRoot.FileInfo.CreatedAt, UpdatedAt = DateTime.Now }; - if (Path.GetExtension(_updateOptions.InputFile.Replace(".gz", ""))!.ToLower() != ".json") + if (!Path.GetExtension(_updateOptions.InputFile.Replace(".gz", ""))!.Equals(".json", StringComparison.OrdinalIgnoreCase)) { throw new NotSupportedException("Only JSON chat files can be used as update input. HTML support may come in the future."); } @@ -74,7 +74,7 @@ public async Task UpdateAsync(IProgress progress, CancellationTo await ChatText.SerializeAsync(_updateOptions.OutputFile, chatRoot, _updateOptions.TextTimestampFormat); break; default: - throw new NotSupportedException("Requested output chat format is not implemented"); + throw new NotSupportedException($"{_updateOptions.OutputFormat} is not a supported output format."); } } diff --git a/TwitchDownloaderCore/Extensions/BufferExtensions.cs b/TwitchDownloaderCore/Extensions/BufferExtensions.cs new file mode 100644 index 00000000..c62d1847 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/BufferExtensions.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Text; +using SkiaSharp; +using Buffer = HarfBuzzSharp.Buffer; + +namespace TwitchDownloaderCore.Extensions +{ + public static class BufferExtensions + { + public static void Add(this Buffer buffer, ReadOnlySpan text, SKTextEncoding textEncoding) + { + switch (textEncoding) + { + // Encoding.GetBytes(ReadOnlySpan, Span) internally allocates arrays, so we may as well use ArrayPools to reduce the GC footprint + case SKTextEncoding.Utf8: + { + var byteCount = Encoding.UTF8.GetByteCount(text); + var encodedBytes = ArrayPool.Shared.Rent(byteCount); + + var textChars = ArrayPool.Shared.Rent(text.Length); + text.CopyTo(textChars); + + Encoding.UTF8.GetBytes(textChars, 0, text.Length, encodedBytes, 0); + buffer.AddUtf8(encodedBytes.AsSpan(0, byteCount)); + + ArrayPool.Shared.Return(encodedBytes); + ArrayPool.Shared.Return(textChars); + break; + } + case SKTextEncoding.Utf16: + buffer.AddUtf16(text); + break; + case SKTextEncoding.Utf32: + { + var byteCount = Encoding.UTF32.GetByteCount(text); + var encodedBytes = ArrayPool.Shared.Rent(byteCount); + + var textChars = ArrayPool.Shared.Rent(text.Length); + text.CopyTo(textChars); + + Encoding.UTF32.GetBytes(textChars, 0, text.Length, encodedBytes, 0); + buffer.AddUtf32(encodedBytes.AsSpan(0, byteCount)); + + ArrayPool.Shared.Return(encodedBytes); + ArrayPool.Shared.Return(textChars); + break; + } + default: + throw new NotSupportedException("TextEncoding of type GlyphId is not supported."); + } + + buffer.GuessSegmentProperties(); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs b/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs new file mode 100644 index 00000000..5a3fcaf3 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/ReadOnlySpanExtensions.cs @@ -0,0 +1,47 @@ +using System; + +namespace TwitchDownloaderCore.Extensions +{ + public static class ReadOnlySpanExtensions + { + /// Replaces all occurrences of not prepended by a backslash with . + public static bool TryReplaceNonEscaped(this ReadOnlySpan str, Span destination, out int charsWritten, char oldChar, char newChar) + { + if (destination.Length < str.Length) + { + charsWritten = 0; + return false; + } + + str.CopyTo(destination); + charsWritten = str.Length; + + var firstIndex = destination.IndexOf(oldChar); + + if (firstIndex == -1) + { + return true; + } + + firstIndex = Math.Min(firstIndex, destination.IndexOf('\\')); + + for (var i = firstIndex; i < str.Length; i++) + { + var readChar = destination[i]; + + if (readChar == '\\' && i + 1 < str.Length) + { + i++; + continue; + } + + if (readChar == oldChar) + { + destination[i] = newChar; + } + } + + return true; + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/SKCanvasExtensions.cs b/TwitchDownloaderCore/Extensions/SKCanvasExtensions.cs new file mode 100644 index 00000000..63cbd817 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/SKCanvasExtensions.cs @@ -0,0 +1,26 @@ +using System; +using SkiaSharp; + +namespace TwitchDownloaderCore.Extensions +{ + // ReSharper disable once InconsistentNaming + public static class SKCanvasExtensions + { + public static void DrawText(this SKCanvas canvas, ReadOnlySpan text, float x, float y, SKPaint paint) + { + if (paint.TextAlign != SKTextAlign.Left) + { + var num = paint.MeasureText(text); + if (paint.TextAlign == SKTextAlign.Center) + num *= 0.5f; + x -= num; + } + + using var text1 = SKTextBlob.Create(text, paint.AsFont()); + if (text1 == null) + return; + + canvas.DrawText(text1, x, y, paint); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Extensions/SKPaintExtensions.cs b/TwitchDownloaderCore/Extensions/SKPaintExtensions.cs new file mode 100644 index 00000000..94f9a286 --- /dev/null +++ b/TwitchDownloaderCore/Extensions/SKPaintExtensions.cs @@ -0,0 +1,66 @@ +using System; +using System.Reflection; +using SkiaSharp; +using SkiaSharp.HarfBuzz; + +namespace TwitchDownloaderCore.Extensions +{ + // ReSharper disable once InconsistentNaming + public static class SKPaintExtensions + { + private static readonly MethodInfo GetFontMethodInfo = typeof(SKPaint).GetMethod("GetFont", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly Func GetFontDelegate = (Func)Delegate.CreateDelegate(typeof(Func), GetFontMethodInfo); + + /// A reference to the held internally by the . + /// The returned should NOT be disposed of. + public static SKFont AsFont(this SKPaint paint) + { + return GetFontDelegate.Invoke(paint); + } + + // Heavily modified from SkiaSharp.HarfBuzz.CanvasExtensions.DrawShapedText + public static SKPath GetShapedTextPath(this SKPaint paint, ReadOnlySpan text, float x, float y) + { + var returnPath = new SKPath(); + + if (text.IsEmpty || text.IsWhiteSpace()) + return returnPath; + + using var shaper = new SKShaper(paint.Typeface); + using var buffer = new HarfBuzzSharp.Buffer(); + buffer.Add(text, paint.TextEncoding); + var result = shaper.Shape(buffer, x, y, paint); + + var glyphSpan = result.Codepoints.AsSpan(); + var pointSpan = result.Points.AsSpan(); + + var xOffset = 0.0f; + if (paint.TextAlign != SKTextAlign.Left) + { + var width = result.Width; + if (paint.TextAlign == SKTextAlign.Center) + width *= 0.5f; + xOffset -= width; + } + + // We cannot dispose because it is a reference, not a clone. + var font = paint.AsFont(); + for (var i = 0; i < pointSpan.Length; i++) + { + using var glyphPath = font.GetGlyphPath((ushort)glyphSpan[i]); + if (glyphPath.IsEmpty) + continue; + + var point = pointSpan[i]; + glyphPath.Transform(new SKMatrix( + 1, 0, point.X + xOffset, + 0, 1, point.Y, + 0, 0, 1 + )); + returnPath.AddPath(glyphPath); + } + + return returnPath; + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/TimeSpanExtensions.cs b/TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs similarity index 73% rename from TwitchDownloaderCore/Tools/TimeSpanExtensions.cs rename to TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs index e0a1742a..508ff4d0 100644 --- a/TwitchDownloaderCore/Tools/TimeSpanExtensions.cs +++ b/TwitchDownloaderCore/Extensions/TimeSpanExtensions.cs @@ -1,6 +1,6 @@ using System; -namespace TwitchDownloaderCore.Tools +namespace TwitchDownloaderCore.Extensions { public static class TimeSpanExtensions { @@ -17,30 +17,30 @@ public static TimeSpan ParseTimeCode(ReadOnlySpan input) var secondIndex = input.IndexOf('s'); var returnTimespan = TimeSpan.Zero; - if (dayIndex != -1) + if (dayIndex != -1 && int.TryParse(input[..dayIndex], out var days)) { - returnTimespan = returnTimespan.Add(TimeSpan.FromDays(int.Parse(input[..dayIndex]))); + returnTimespan = returnTimespan.Add(TimeSpan.FromDays(days)); } dayIndex++; - if (hourIndex != -1) + if (hourIndex != -1 && int.TryParse(input[dayIndex..hourIndex], out var hours)) { - returnTimespan = returnTimespan.Add(TimeSpan.FromHours(int.Parse(input[dayIndex..hourIndex]))); + returnTimespan = returnTimespan.Add(TimeSpan.FromHours(hours)); } hourIndex++; - if (minuteIndex != -1) + if (minuteIndex != -1 && int.TryParse(input[hourIndex..minuteIndex], out var minutes)) { - returnTimespan = returnTimespan.Add(TimeSpan.FromMinutes(int.Parse(input[hourIndex..minuteIndex]))); + returnTimespan = returnTimespan.Add(TimeSpan.FromMinutes(minutes)); } minuteIndex++; - if (secondIndex != -1) + if (secondIndex != -1 && int.TryParse(input[minuteIndex..secondIndex], out var seconds)) { - returnTimespan = returnTimespan.Add(TimeSpan.FromSeconds(int.Parse(input[minuteIndex..secondIndex]))); + returnTimespan = returnTimespan.Add(TimeSpan.FromSeconds(seconds)); } return returnTimespan; diff --git a/TwitchDownloaderCore/Options/ChatRenderOptions.cs b/TwitchDownloaderCore/Options/ChatRenderOptions.cs index ef00fdd7..80a42ba1 100644 --- a/TwitchDownloaderCore/Options/ChatRenderOptions.cs +++ b/TwitchDownloaderCore/Options/ChatRenderOptions.cs @@ -91,5 +91,6 @@ public string MaskFile public bool DisperseCommentOffsets { get; set; } = true; public bool SkipDriveWaiting { get; set; } = false; public EmojiVendor EmojiVendor { get; set; } = EmojiVendor.GoogleNotoColor; + public int[] TimestampWidths { get; set; } } } diff --git a/TwitchDownloaderCore/Properties/Resources.Designer.cs b/TwitchDownloaderCore/Properties/Resources.Designer.cs index 8977e966..9462ff4d 100644 --- a/TwitchDownloaderCore/Properties/Resources.Designer.cs +++ b/TwitchDownloaderCore/Properties/Resources.Designer.cs @@ -62,9 +62,9 @@ internal Resources() { /// /// Looks up a localized resource of type System.Byte[]. /// - internal static byte[] Inter { + internal static byte[] chat_template { get { - object obj = ResourceManager.GetObject("Inter", resourceCulture); + object obj = ResourceManager.GetObject("chat_template", resourceCulture); return ((byte[])(obj)); } } @@ -72,9 +72,9 @@ internal static byte[] Inter { /// /// Looks up a localized resource of type System.Byte[]. /// - internal static byte[] InterBold { + internal static byte[] Inter { get { - object obj = ResourceManager.GetObject("InterBold", resourceCulture); + object obj = ResourceManager.GetObject("Inter", resourceCulture); return ((byte[])(obj)); } } @@ -82,31 +82,20 @@ internal static byte[] InterBold { /// /// Looks up a localized resource of type System.Byte[]. /// - internal static byte[] noto_emoji_2_038 { + internal static byte[] InterBold { get { - object obj = ResourceManager.GetObject("noto_emoji_2_038", resourceCulture); + object obj = ResourceManager.GetObject("InterBold", resourceCulture); return ((byte[])(obj)); } } /// - /// Looks up a localized string similar to <html> - ///<head> - ///<link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'> - ///<title> - ///<!-- TITLE --> - ///</title> - ///<style> - /// /*! - /// * Bootstrap v4.0.0 (https://getbootstrap.com) - /// * Copyright 2011-2018 The Bootstrap Authors - /// * Copyright 2011-2018 Twitter, Inc. - /// * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) - /// */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan [rest of string was truncated]";. + /// Looks up a localized resource of type System.Byte[]. /// - internal static string template { + internal static byte[] noto_emoji_2_038 { get { - return ResourceManager.GetString("template", resourceCulture); + object obj = ResourceManager.GetObject("noto_emoji_2_038", resourceCulture); + return ((byte[])(obj)); } } diff --git a/TwitchDownloaderCore/Properties/Resources.resx b/TwitchDownloaderCore/Properties/Resources.resx index 4f34780b..50ea04e4 100644 --- a/TwitchDownloaderCore/Properties/Resources.resx +++ b/TwitchDownloaderCore/Properties/Resources.resx @@ -124,8 +124,8 @@ ..\Resources\interbold.otf;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - ..\Resources\template.html;System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Windows-1252 + + ..\Resources\chat-template.html;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089;Windows-1252 ..\Resources\twemoji-14.0.0.zip;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 diff --git a/TwitchDownloaderCore/Resources/template.html b/TwitchDownloaderCore/Resources/chat-template.html similarity index 99% rename from TwitchDownloaderCore/Resources/template.html rename to TwitchDownloaderCore/Resources/chat-template.html index 8cf6cded..3e28b869 100644 --- a/TwitchDownloaderCore/Resources/template.html +++ b/TwitchDownloaderCore/Resources/chat-template.html @@ -127,7 +127,7 @@ pagingControlsContainer: "#pagingControls", pagingContainer: "#content", itemSelector: ".comment-root:visible", - itemsPerPageSelector: ".itemPerPageDropDown", //Paragraphs Per Page + itemsPerPageSelector: ".itemPerPageDropDown", /* Paragraphs Per Page */ searchBoxSelector: '.searchBox', itemsPerPage: 100, currentPage: 1, diff --git a/TwitchDownloaderCore/Tools/DriveHelper.cs b/TwitchDownloaderCore/Tools/DriveHelper.cs index a079986e..886d2275 100644 --- a/TwitchDownloaderCore/Tools/DriveHelper.cs +++ b/TwitchDownloaderCore/Tools/DriveHelper.cs @@ -7,9 +7,6 @@ namespace TwitchDownloaderCore.Tools { public static class DriveHelper { - public static DriveInfo GetOutputDrive(FfmpegProcess ffmpegProcess) - => GetOutputDrive(Path.GetFullPath(ffmpegProcess.SavePath)); - public static DriveInfo GetOutputDrive(string outputPath) { // Cannot instantiate a null DriveInfo @@ -37,11 +34,6 @@ public static DriveInfo GetOutputDrive(string outputPath) public static async Task WaitForDrive(DriveInfo drive, IProgress progress, CancellationToken cancellationToken) { - if (drive.IsReady) - { - return; - } - int driveNotReadyCount = 0; while (!drive.IsReady) { @@ -52,7 +44,6 @@ public static async Task WaitForDrive(DriveInfo drive, IProgress { throw new DriveNotFoundException("The output drive disconnected for 10 or more consecutive seconds."); } - } } } diff --git a/TwitchDownloaderCore/Tools/FfmpegProcess.cs b/TwitchDownloaderCore/Tools/FfmpegProcess.cs index 78d6668c..c81a1bc0 100644 --- a/TwitchDownloaderCore/Tools/FfmpegProcess.cs +++ b/TwitchDownloaderCore/Tools/FfmpegProcess.cs @@ -2,21 +2,10 @@ namespace TwitchDownloaderCore.Tools { - public class FfmpegProcess + public sealed class FfmpegProcess : Process { - public Process Process { get; private set; } - public string SavePath { get; private set; } + public string SavePath { get; init; } - public FfmpegProcess(Process process, string savePath) - { - Process = process; - SavePath = savePath; - } - - ~FfmpegProcess() - { - SavePath = null; - Process.Dispose(); - } + public FfmpegProcess() { } } -} +} \ No newline at end of file diff --git a/TwitchDownloaderCore/Tools/HighlightMessage.cs b/TwitchDownloaderCore/Tools/HighlightIcons.cs similarity index 65% rename from TwitchDownloaderCore/Tools/HighlightMessage.cs rename to TwitchDownloaderCore/Tools/HighlightIcons.cs index 48f85907..430b35c5 100644 --- a/TwitchDownloaderCore/Tools/HighlightMessage.cs +++ b/TwitchDownloaderCore/Tools/HighlightIcons.cs @@ -20,9 +20,9 @@ public enum HighlightType Unknown } - public class HighlightMessage : IDisposable + public sealed class HighlightIcons : IDisposable { - public bool Disposed = false; + public bool Disposed { get; private set; } = false; 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"; @@ -33,17 +33,21 @@ public class HighlightMessage : IDisposable 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 SKBitmap _subscribedTierIcon = null; - private SKBitmap _subscribedPrimeIcon = null; - private SKBitmap _giftSingleIcon = null; - private SKBitmap _giftManyIcon = null; - private SKBitmap _giftAnonymousIcon = null; + private SKImage _subscribedTierIcon; + private SKImage _subscribedPrimeIcon; + private SKImage _giftSingleIcon; + private SKImage _giftManyIcon; + private SKImage _giftAnonymousIcon; private readonly string _cachePath; + private readonly SKColor _purple; + private readonly bool _offline; - public HighlightMessage(string cachePath) + public HighlightIcons(string cachePath, SKColor iconPurple, bool offline) { _cachePath = Path.Combine(cachePath, "icons"); + _purple = iconPurple; + _offline = offline; } // If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck @@ -51,130 +55,146 @@ 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.StartsWith(comment.commenter.display_name + " subscribed at Tier")) + if (comment.message.body.Length == 0) { - return HighlightType.SubscribedTier; + // This likely happens due to the 7TV extension letting users bypass the IRC message trimmer + return HighlightType.None; } - if (comment.message.body.StartsWith(comment.commenter.display_name + " subscribed with Prime")) - { - return HighlightType.SubscribedPrime; - } - if (comment.message.body.StartsWith(comment.commenter.display_name + " is gifting")) - { - return HighlightType.GiftedMany; - } - if (comment.message.body.StartsWith(comment.commenter.display_name + " gifted a Tier")) - { - return HighlightType.GiftedSingle; - } - if (comment.message.body.StartsWith(comment.commenter.display_name + " is continuing the Gift Sub")) - { - return HighlightType.ContinuingGift; - } - if (comment.message.body.StartsWith(comment.commenter.display_name + " converted from a")) + + var bodySpan = comment.message.body.AsSpan(); + var displayName = comment.commenter.display_name.AsSpan(); + if (bodySpan.StartsWith(displayName)) { - var convertedToMatch = Regex.Match(comment.message.body, @$"(?<=^{comment.commenter.display_name} converted from a (?:Prime|Tier \d) sub to a )(?:Prime|Tier \d)"); - if (!convertedToMatch.Success) - { - return HighlightType.None; - } + var bodyWithoutName = bodySpan[displayName.Length..]; + if (bodyWithoutName.StartsWith(" subscribed at Tier")) + return HighlightType.SubscribedTier; + + if (bodyWithoutName.StartsWith(" subscribed with Prime")) + return HighlightType.SubscribedPrime; + + if (bodyWithoutName.StartsWith(" is gifting")) + return HighlightType.GiftedMany; + + if (bodyWithoutName.StartsWith(" gifted a Tier")) + return HighlightType.GiftedSingle; + + if (bodyWithoutName.StartsWith(" is continuing the Gift Sub")) + return HighlightType.ContinuingGift; - // TODO: Use ValueSpan once on NET7 - return convertedToMatch.Value switch + if (bodyWithoutName.StartsWith(" is paying forward the Gift they got from")) + return HighlightType.PayingForward; + + if (bodyWithoutName.StartsWith(" converted from a")) { - "Prime" => HighlightType.SubscribedPrime, - "Tier 1" => HighlightType.SubscribedTier, - "Tier 2" => HighlightType.SubscribedTier, - "Tier 3" => HighlightType.SubscribedTier, - _ => HighlightType.Unknown - }; - } - if (comment.message.body.StartsWith(comment.commenter.display_name + " is paying forward the Gift they got from")) - { - return HighlightType.PayingForward; + // TODO: use bodyWithoutName when .NET 7 + var convertedToMatch = Regex.Match(comment.message.body, $@"(?<=^{comment.commenter.display_name} converted from a (?:Prime|Tier \d) sub to a )(?:Prime|Tier \d)"); + if (!convertedToMatch.Success) + return HighlightType.None; + + return convertedToMatch.ValueSpan switch + { + "Prime" => HighlightType.SubscribedPrime, + "Tier 1" => HighlightType.SubscribedTier, + "Tier 2" => HighlightType.SubscribedTier, + "Tier 3" => HighlightType.SubscribedTier, + _ => HighlightType.None + }; + } } + if (comment.commenter._id is ANONYMOUS_GIFT_ACCOUNT_ID && GiftAnonymousRegex.IsMatch(comment.message.body)) - { return HighlightType.GiftedAnonymous; - } - // There are more re-sub messages but I don't know the exact wordings and they tend to be very rare + return HighlightType.None; } - /// A copy of the requested icon or null if no icon exists for the highlight type - public SKBitmap GetHighlightIcon(HighlightType highlightType, string purple, SKColor textColor, double fontSize) + /// 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. + public SKImage GetHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize) { - // Return a copy of the needed icon from cache or generate if null + // Return the needed icon from cache or generate if null return highlightType switch { - HighlightType.SubscribedTier => _subscribedTierIcon?.Copy() ?? GenerateHighlightIcon(highlightType, purple, textColor, fontSize), - HighlightType.SubscribedPrime => _subscribedPrimeIcon?.Copy() ?? GenerateHighlightIcon(highlightType, purple, textColor, fontSize), - HighlightType.GiftedSingle => _giftSingleIcon?.Copy() ?? GenerateHighlightIcon(highlightType, purple, textColor, fontSize), - HighlightType.GiftedMany => _giftManyIcon?.Copy() ?? GenerateHighlightIcon(highlightType, purple, textColor, fontSize), - HighlightType.GiftedAnonymous => _giftAnonymousIcon?.Copy() ?? GenerateHighlightIcon(highlightType, purple, textColor, fontSize), + 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), _ => null }; } - private SKBitmap GenerateHighlightIcon(HighlightType highlightType, string purple, SKColor textColor, double fontSize) + private SKImage GenerateHighlightIcon(HighlightType highlightType, SKColor textColor, double fontSize) { // Generate the needed icon - var returnBitmap = highlightType is HighlightType.GiftedMany - ? GenerateGiftedManyIcon(fontSize, _cachePath) - : GenerateSvgIcon(highlightType, purple, textColor, fontSize); + var returnIcon = highlightType is HighlightType.GiftedMany + ? GenerateGiftedManyIcon(fontSize, _cachePath, _offline) + : GenerateSvgIcon(highlightType, _purple, textColor, fontSize); - // Cache a copy of the icon + // Cache the icon switch (highlightType) { case HighlightType.SubscribedTier: - _subscribedTierIcon = returnBitmap.Copy(); + _subscribedTierIcon = returnIcon; break; case HighlightType.SubscribedPrime: - _subscribedPrimeIcon = returnBitmap.Copy(); + _subscribedPrimeIcon = returnIcon; break; case HighlightType.GiftedSingle: - _giftSingleIcon = returnBitmap.Copy(); + _giftSingleIcon = returnIcon; break; case HighlightType.GiftedMany: - _giftManyIcon = returnBitmap.Copy(); + _giftManyIcon = returnIcon; break; case HighlightType.GiftedAnonymous: - _giftAnonymousIcon = returnBitmap.Copy(); + _giftAnonymousIcon = returnIcon; break; default: - throw new NotSupportedException("This should not be possible."); + throw new NotSupportedException("The requested highlight icon does not exist."); } // Return the generated icon - return returnBitmap; + return returnIcon; } - private static SKBitmap GenerateGiftedManyIcon(double fontSize, string cachePath) + 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 + var finalIconSize = (int)(fontSize / 0.6); // 20x20px @ 12pt font + + if (offline) + { + using var offlineBitmap = new SKBitmap(finalIconSize, finalIconSize); + using (var offlineCanvas = new SKCanvas(offlineBitmap)) + offlineCanvas.Clear(); + offlineBitmap.SetImmutable(); + return SKImage.FromBitmap(offlineBitmap); + } + 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 - var codec = SKCodec.Create(ms); + using var codec = SKCodec.Create(ms); using var tempBitmap = SKBitmap.Decode(codec); - //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 newSize = (int)(fontSize / 0.6); // 20x20px @ 12pt font - SKImageInfo imageInfo = new(newSize, newSize); - return tempBitmap.Resize(imageInfo, SKFilterQuality.High); + var imageInfo = new SKImageInfo(finalIconSize, finalIconSize); + using var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High); + resizedBitmap.SetImmutable(); + return SKImage.FromBitmap(resizedBitmap); } - private static SKBitmap GenerateSvgIcon(HighlightType highlightType, string purple, SKColor textColor, double fontSize) + private static SKImage GenerateSvgIcon(HighlightType highlightType, SKColor purple, SKColor textColor, double fontSize) { using var tempBitmap = new SKBitmap(72, 72); // Icon SVG strings are scaled for 72x72 using var tempCanvas = new SKCanvas(tempBitmap); - var iconPath = SKPath.ParseSvgPathData(highlightType switch + 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("This should not be possible.") + _ => throw new NotSupportedException("The requested icon svg path does not exist.") }); iconPath.FillType = SKPathFillType.EvenOdd; @@ -183,10 +203,10 @@ private static SKBitmap GenerateSvgIcon(HighlightType highlightType, string purp Color = highlightType switch { HighlightType.SubscribedTier => textColor, - HighlightType.SubscribedPrime => SKColor.Parse(purple), + HighlightType.SubscribedPrime => purple, HighlightType.GiftedSingle => textColor, HighlightType.GiftedAnonymous => textColor, - _ => throw new NotSupportedException("This should not be possible.") + _ => throw new NotSupportedException("The requested icon color does not exist.") }, IsAntialias = true, LcdRenderText = true @@ -195,7 +215,9 @@ private static SKBitmap GenerateSvgIcon(HighlightType highlightType, string purp tempCanvas.DrawPath(iconPath, iconColor); var newSize = (int)(fontSize / 0.6); // 20*20px @ 12pt font var imageInfo = new SKImageInfo(newSize, newSize); - return tempBitmap.Resize(imageInfo, SKFilterQuality.High); + var resizedBitmap = tempBitmap.Resize(imageInfo, SKFilterQuality.High); + resizedBitmap.SetImmutable(); + return SKImage.FromBitmap(resizedBitmap); } /// @@ -235,7 +257,7 @@ public static (Comment subMessage, Comment customMessage) SplitSubComment(Commen // i.e. Foobar subscribed with Prime. They've subscribed for 45 months! Hey PogChamp if (!customMessage.StartsWith(comment.message.fragments[1].text)) // If yes { - customMessageComment.message.fragments[0].text = customMessage[..(customMessage.IndexOf(comment.message.fragments[1].text) - 1)]; + customMessageComment.message.fragments[0].text = customMessage[..(customMessage.IndexOf(comment.message.fragments[1].text, StringComparison.Ordinal) - 1)]; return (subMessageComment, customMessageComment); } @@ -260,11 +282,9 @@ public static (string subMessage, string customMessage) SplitSubMessage(string c public void Dispose() { Dispose(true); - - GC.SuppressFinalize(this); } - protected virtual void Dispose(bool isDisposing) + private void Dispose(bool isDisposing) { try { diff --git a/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs b/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs index 8790cc9d..f9fbb120 100644 --- a/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs +++ b/TwitchDownloaderCore/Tools/TimeSpanHFormat.cs @@ -1,18 +1,18 @@ using System; -using System.Globalization; -using System.IO; using System.Text; +using TwitchDownloaderCore.Extensions; namespace TwitchDownloaderCore.Tools { - /// - /// Adds an 'H' parameter to TimeSpan string formatting. The 'H' parameter is equivalent to flooring .TotalHours. - /// + /// Adds an 'H' parameter to string formatting. The 'H' parameter is equivalent to flooring .. /// - /// The fact that this is not part of .NET is stupid. + /// This formatter only supports escaping 'H's via '\'. + /// For optimal memory performance, resulting strings split about any 'H' parameters should be less than 256. /// public class TimeSpanHFormat : IFormatProvider, ICustomFormatter { + public static readonly TimeSpanHFormat ReusableInstance = new(); + public object GetFormat(Type formatType) { if (formatType == typeof(ICustomFormatter)) @@ -21,38 +21,64 @@ public object GetFormat(Type formatType) return null; } - public string Format(string format, object arg, IFormatProvider formatProvider) + public string Format(string format, object arg, IFormatProvider formatProvider = null) { - if (!(arg is TimeSpan timeSpan)) + if (arg is TimeSpan timeSpan) { - return HandleOtherFormats(format, arg); + return Format(format, timeSpan); + } + + return HandleOtherFormats(format, arg, formatProvider); + } + + /// Provides an identical output to but without boxing the . + /// This method is not part of the interface. + public string Format(string format, TimeSpan timeSpan, IFormatProvider formatProvider = null) + { + if (string.IsNullOrEmpty(format)) + { + return ""; } if (!format.Contains('H')) { - return HandleOtherFormats(format, arg); + return HandleOtherFormats(format, timeSpan, formatProvider); } - var reader = new StringReader(format); - var builder = new StringBuilder(format.Length); + // If the timespan is less than 24 hours, HandleOtherFormats can be up to 3x faster and half the allocations + if (timeSpan.Days == 0) + { + var newFormat = format.Length <= 256 ? stackalloc char[format.Length] : new char[format.Length]; + if (!format.AsSpan().TryReplaceNonEscaped(newFormat, out var charsWritten, 'H', 'h')) + { + throw new Exception("Failed to generate ToString() compatible format. This should not have been possible."); + } + + // If the format contains more than 2 sequential unescaped h's, it will throw a format exception. If so, we can fallback to our parser. + if (newFormat.IndexOf("hhh") == -1) + { + return HandleOtherFormats(newFormat[..charsWritten].ToString(), timeSpan, formatProvider); + } + } + + var sb = new StringBuilder(format.Length); var regularFormatCharStart = -1; var bigHStart = -1; - var position = -1; - do + var formatSpan = format.AsSpan(); + for (var i = 0; i < formatSpan.Length; i++) { - var readChar = reader.Read(); - position++; + var readChar = formatSpan[i]; if (readChar == 'H') { if (bigHStart == -1) { - bigHStart = position; + bigHStart = i; } if (regularFormatCharStart != -1) { - builder.Append(timeSpan.ToString(format.Substring(regularFormatCharStart, position - regularFormatCharStart))); + AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, i - regularFormatCharStart); regularFormatCharStart = -1; } } @@ -60,50 +86,76 @@ public string Format(string format, object arg, IFormatProvider formatProvider) { if (regularFormatCharStart == -1) { - regularFormatCharStart = position; + regularFormatCharStart = i; } if (bigHStart != -1) { - var formatString = ""; - for (var i = 0; i < position - bigHStart; i++) - { - formatString += "0"; - } - - builder.Append(((int)timeSpan.TotalHours).ToString(formatString)); + AppendBigHFormat(sb, timeSpan, i - bigHStart); bigHStart = -1; } + + // If the current char is an escape we can skip the next char + if (readChar == '\\' && i + 1 < formatSpan.Length) + { + i++; + } } - } while (reader.Peek() != -1); + } - position++; if (regularFormatCharStart != -1) { - builder.Append(timeSpan.ToString(format.Substring(regularFormatCharStart, position - regularFormatCharStart))); + AppendRegularFormat(sb, timeSpan, format, regularFormatCharStart, formatSpan.Length - regularFormatCharStart); } else if (bigHStart != -1) { - var formatString = ""; - for (var i = 0; i < position - bigHStart; i++) - { - formatString += "0"; - } + AppendBigHFormat(sb, timeSpan, formatSpan.Length - bigHStart); + } + + return sb.ToString(); + } - builder.Append(((int)timeSpan.TotalHours).ToString(formatString)); + private static void AppendRegularFormat(StringBuilder sb, TimeSpan timeSpan, string formatString, int start, int length) + { + Span destination = stackalloc char[256]; + var format = formatString.AsSpan(start, length); + + if (timeSpan.TryFormat(destination, out var charsWritten, format)) + { + sb.Append(destination[..charsWritten]); + } + else + { + sb.Append(timeSpan.ToString(format.ToString())); } + } - return builder.ToString(); + private static void AppendBigHFormat(StringBuilder sb, TimeSpan timeSpan, int count) + { + Span destination = stackalloc char[8]; + Span format = stackalloc char[count]; + format.Fill('0'); + + if (((int)timeSpan.TotalHours).TryFormat(destination, out var charsWritten, format)) + { + sb.Append(destination[..charsWritten]); + } + else + { + sb.Append(((int)timeSpan.TotalHours).ToString(format.ToString())); + } } - private string HandleOtherFormats(string format, object arg) + private static string HandleOtherFormats(string format, object arg, IFormatProvider formatProvider) { - if (arg is IFormattable) - return ((IFormattable)arg).ToString(format, CultureInfo.CurrentCulture); + if (arg is IFormattable formattable) + return formattable.ToString(format, formatProvider); else if (arg != null) return arg.ToString(); else return ""; } + + private static string HandleOtherFormats(string format, TimeSpan arg, IFormatProvider formatProvider) => arg.ToString(format, formatProvider); } } \ No newline at end of file diff --git a/TwitchDownloaderCore/TwitchDownloaderCore.csproj b/TwitchDownloaderCore/TwitchDownloaderCore.csproj index d518d340..6f2858d3 100644 --- a/TwitchDownloaderCore/TwitchDownloaderCore.csproj +++ b/TwitchDownloaderCore/TwitchDownloaderCore.csproj @@ -15,7 +15,7 @@ - + diff --git a/TwitchDownloaderCore/TwitchHelper.cs b/TwitchDownloaderCore/TwitchHelper.cs index 36100bcd..5b97c69b 100644 --- a/TwitchDownloaderCore/TwitchHelper.cs +++ b/TwitchDownloaderCore/TwitchHelper.cs @@ -48,8 +48,8 @@ public static async Task GetVideoToken(int videoId, strin Content = new StringContent("{\"operationName\":\"PlaybackAccessToken_Template\",\"query\":\"query PlaybackAccessToken_Template($login: String!, $isLive: Boolean!, $vodID: ID!, $isVod: Boolean!, $playerType: String!) { streamPlaybackAccessToken(channelName: $login, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isLive) { value signature __typename } videoPlaybackAccessToken(id: $vodID, params: {platform: \\\"web\\\", playerBackend: \\\"mediaplayer\\\", playerType: $playerType}) @include(if: $isVod) { value signature __typename }}\",\"variables\":{\"isLive\":false,\"login\":\"\",\"isVod\":true,\"vodID\":\"" + videoId + "\",\"playerType\":\"embed\"}}", Encoding.UTF8, "application/json") }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); - if (authToken != null && authToken != "") - request.Headers.Add("Authorization", "OAuth " + authToken); + if (!string.IsNullOrWhiteSpace(authToken)) + request.Headers.Add("Authorization", $"OAuth {authToken}"); using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); response.EnsureSuccessStatusCode(); return await response.Content.ReadFromJsonAsync(); @@ -59,7 +59,7 @@ public static async Task GetVideoPlaylist(int videoId, string token, s { var request = new HttpRequestMessage() { - RequestUri = new Uri(String.Format("http://usher.ttvnw.net/vod/{0}?nauth={1}&nauthsig={2}&allow_source=true&player=twitchweb", videoId, token, sig)), + RequestUri = new Uri($"http://usher.ttvnw.net/vod/{videoId}?nauth={token}&nauthsig={sig}&allow_source=true&player=twitchweb"), Method = HttpMethod.Get }; request.Headers.Add("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko"); @@ -249,7 +249,7 @@ private static async Task GetStvEmoteData(int streamerId, List GetUserInfo(List idList) { try { - using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + await using FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); byte[] bytes = new byte[stream.Length]; stream.Seek(0, SeekOrigin.Begin); - await stream.ReadAsync(bytes, cancellationToken); + _ = await stream.ReadAsync(bytes, cancellationToken); //Check if image file is not corrupt if (bytes.Length > 0) diff --git a/TwitchDownloaderCore/TwitchObjects/ChatBadge.cs b/TwitchDownloaderCore/TwitchObjects/ChatBadge.cs index 7f105bae..0b0cbd80 100644 --- a/TwitchDownloaderCore/TwitchObjects/ChatBadge.cs +++ b/TwitchDownloaderCore/TwitchObjects/ChatBadge.cs @@ -1,8 +1,8 @@ using SkiaSharp; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; -using System.Linq; using System.Text.Json.Serialization; namespace TwitchDownloaderCore.TwitchObjects @@ -26,15 +26,17 @@ public class ChatBadgeData public string description { get; set; } public byte[] bytes { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - public string url { get; set;} + public string url { get; set; } } - public class ChatBadge + [DebuggerDisplay("{Name}")] + public sealed class ChatBadge : IDisposable { + public bool Disposed { get; private set; } = false; public string Name; - public Dictionary Versions; - public Dictionary VersionsData; - public ChatBadgeType Type; + public readonly Dictionary Versions; + public readonly Dictionary VersionsData; + public readonly ChatBadgeType Type; public ChatBadge(string name, Dictionary versions) { @@ -42,13 +44,14 @@ public ChatBadge(string name, Dictionary versions) Versions = new Dictionary(); VersionsData = versions; - foreach (var version in versions) + foreach (var (versionName, versionData) in versions) { - using MemoryStream ms = new MemoryStream(version.Value.bytes); + using MemoryStream ms = new MemoryStream(versionData.bytes); //For some reason, twitch has corrupted images sometimes :) for example //https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/1 SKBitmap badgeImage = SKBitmap.Decode(ms); - Versions.Add(version.Key, badgeImage); + badgeImage.SetImmutable(); + Versions.Add(versionName, badgeImage); } Type = name switch @@ -66,16 +69,47 @@ public ChatBadge(string name, Dictionary versions) public void Resize(double newScale) { - List keyList = new List(Versions.Keys.ToList()); - - for (int i = 0; i < keyList.Count; i++) + foreach (var (versionName, bitmap) in Versions) { - SKImageInfo imageInfo = new SKImageInfo((int)(Versions[keyList[i]].Width * newScale), (int)(Versions[keyList[i]].Height * newScale)); + SKImageInfo imageInfo = new SKImageInfo((int)(bitmap.Width * newScale), (int)(bitmap.Height * newScale)); SKBitmap newBitmap = new SKBitmap(imageInfo); - Versions[keyList[i]].ScalePixels(newBitmap, SKFilterQuality.High); - Versions[keyList[i]].Dispose(); - Versions[keyList[i]] = newBitmap; + bitmap.ScalePixels(newBitmap, SKFilterQuality.High); + bitmap.Dispose(); + newBitmap.SetImmutable(); + Versions[versionName] = newBitmap; + } + } + +#region ImplementIDisposable + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool isDisposing) + { + try + { + if (Disposed) + { + return; + } + + if (isDisposing) + { + foreach (var (_, bitmap) in Versions) + { + bitmap?.Dispose(); + } + } + } + finally + { + Disposed = true; } } + +#endregion } } diff --git a/TwitchDownloaderCore/TwitchObjects/ChatRoot.cs b/TwitchDownloaderCore/TwitchObjects/ChatRoot.cs index 1b65dcae..58cc4d45 100644 --- a/TwitchDownloaderCore/TwitchObjects/ChatRoot.cs +++ b/TwitchDownloaderCore/TwitchObjects/ChatRoot.cs @@ -11,7 +11,7 @@ public class Streamer public int id { get; set; } } - [DebuggerDisplay("display_name: {display_name}")] + [DebuggerDisplay("{display_name}")] public class Commenter { public string display_name { get; set; } @@ -37,11 +37,13 @@ public Commenter Clone() } } + [DebuggerDisplay("{emoticon_id}")] public class Emoticon { public string emoticon_id { get; set; } } + [DebuggerDisplay("{text}")] public class Fragment { public string text { get; set; } @@ -57,6 +59,7 @@ public Fragment Clone() } } + [DebuggerDisplay("{_id}")] public class UserBadge { public string _id { get; set; } @@ -72,6 +75,7 @@ public UserBadge Clone() } } + [DebuggerDisplay("{_id}")] public class Emoticon2 { public string _id { get; set; } @@ -89,7 +93,7 @@ public Emoticon2 Clone() } } - [DebuggerDisplay("body: {body}")] + [DebuggerDisplay("{body}")] public class Message { public string body { get; set; } @@ -129,6 +133,7 @@ public Message Clone() } } + [DebuggerDisplay("{msg_id}")] public class UserNoticeParams { public string msg_id { get; set; } @@ -142,6 +147,7 @@ public UserNoticeParams Clone() } } + [DebuggerDisplay("{commenter} {message}")] public class Comment { public string _id { get; set; } @@ -207,6 +213,7 @@ public class Video #endregion } + [DebuggerDisplay("{name}")] public class EmbedEmoteData { public string id { get; set; } @@ -218,12 +225,14 @@ public class EmbedEmoteData public int height { get; set; } } + [DebuggerDisplay("{name}")] public class EmbedChatBadge { public string name { get; set; } public Dictionary versions { get; set; } } + [DebuggerDisplay("{name}")] public class LegacyEmbedChatBadge { public string name { get; set; } @@ -232,6 +241,7 @@ public class LegacyEmbedChatBadge public Dictionary urls { get; set; } } + [DebuggerDisplay("{prefix}")] public class EmbedCheerEmote { public string prefix { get; set; } diff --git a/TwitchDownloaderCore/TwitchObjects/CheerEmote.cs b/TwitchDownloaderCore/TwitchObjects/CheerEmote.cs index a4352e18..9547bb22 100644 --- a/TwitchDownloaderCore/TwitchObjects/CheerEmote.cs +++ b/TwitchDownloaderCore/TwitchObjects/CheerEmote.cs @@ -1,12 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using System.Text; namespace TwitchDownloaderCore.TwitchObjects { - public class CheerEmote + [DebuggerDisplay("{prefix}")] + public sealed class CheerEmote : IDisposable { + public bool Disposed { get; private set; } = false; public string prefix { get; set; } public List> tierList { get; set; } = new List>(); @@ -30,5 +32,37 @@ public void Resize(double newScale) tierList[i].Value.Resize(newScale); } } + +#region ImplementIDisposable + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool isDisposing) + { + try + { + if (Disposed) + { + return; + } + + if (isDisposing) + { + foreach (var (_, emote) in tierList) + { + emote?.Dispose(); + } + } + } + finally + { + Disposed = true; + } + } + +#endregion } } diff --git a/TwitchDownloaderCore/TwitchObjects/TwitchEmote.cs b/TwitchDownloaderCore/TwitchObjects/TwitchEmote.cs index 6dc5430a..f8d922df 100644 --- a/TwitchDownloaderCore/TwitchObjects/TwitchEmote.cs +++ b/TwitchDownloaderCore/TwitchObjects/TwitchEmote.cs @@ -1,52 +1,49 @@ using SkiaSharp; using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; -using System.Linq; -using System.Text; namespace TwitchDownloaderCore.TwitchObjects { - public enum EmoteProvider { FirstParty, ThirdParty } - public class TwitchEmote + + [DebuggerDisplay("{Name}")] + public sealed class TwitchEmote : IDisposable { - public SKCodec Codec { get; set; } + public bool Disposed { get; private set; } = false; + public SKCodec Codec { get; } public byte[] ImageData { get; set; } public EmoteProvider EmoteProvider { get; set; } - public List EmoteFrames { get; set; } = new List(); - public List EmoteFrameDurations { get; set; } = new List(); + public List EmoteFrames { get; } = new List(); + public List EmoteFrameDurations { get; private set; } = new List(); public int TotalDuration { get; set; } - public string Name { get; set; } - public string Id { get; set; } - public int ImageScale { get; set; } + public string Name { get; } + public string Id { get; } + public int ImageScale { get; } public bool IsZeroWidth { get; set; } = false; - public int FrameCount - { - get - { - if (Codec.FrameCount == 0) - return 1; - else - return Codec.FrameCount; - } - } - public int Height { get { return EmoteFrames[0].Height; } } - public int Width { get { return EmoteFrames[0].Width; } } + public int FrameCount { get; } + public int Height => EmoteFrames[0].Height; + public int Width => EmoteFrames[0].Width; + public SKImageInfo Info => EmoteFrames[0].Info; public TwitchEmote(byte[] imageData, EmoteProvider emoteProvider, int imageScale, string imageId, string imageName) { using MemoryStream ms = new MemoryStream(imageData); - Codec = SKCodec.Create(ms); + Codec = SKCodec.Create(ms, out var result); + if (Codec is null) + throw new BadImageFormatException($"Skia was unable to decode {imageName} ({imageId}). Returned: {result}"); + EmoteProvider = emoteProvider; Id = imageId; Name = imageName; ImageScale = imageScale; ImageData = imageData; + FrameCount = Math.Max(1, Codec.FrameCount); ExtractFrames(); CalculateDurations(); @@ -54,15 +51,20 @@ public TwitchEmote(byte[] imageData, EmoteProvider emoteProvider, int imageScale private void CalculateDurations() { - EmoteFrameDurations = new List(); - for (int i = 0; i < Codec.FrameCount; i++) + EmoteFrameDurations = new List(FrameCount); + + if (FrameCount == 1) + return; + + var frameInfos = Codec.FrameInfo; + for (int i = 0; i < FrameCount; i++) { - var duration = Codec.FrameInfo[i].Duration / 10; + var duration = frameInfos[i].Duration / 10; EmoteFrameDurations.Add(duration); TotalDuration += duration; } - if (TotalDuration == 0 || TotalDuration == Codec.FrameCount) + if (TotalDuration == 0 || TotalDuration == FrameCount) { for (int i = 0; i < EmoteFrameDurations.Count; i++) { @@ -84,27 +86,65 @@ private void CalculateDurations() private void ExtractFrames() { + var codecInfo = Codec.Info; for (int i = 0; i < FrameCount; i++) { - SKImageInfo imageInfo = new SKImageInfo(Codec.Info.Width, Codec.Info.Height); + SKImageInfo imageInfo = new SKImageInfo(codecInfo.Width, codecInfo.Height); SKBitmap newBitmap = new SKBitmap(imageInfo); IntPtr pointer = newBitmap.GetPixels(); SKCodecOptions codecOptions = new SKCodecOptions(i); Codec.GetPixels(imageInfo, pointer, codecOptions); + newBitmap.SetImmutable(); EmoteFrames.Add(newBitmap); } } public void Resize(double newScale) { + var codecInfo = Codec.Info; for (int i = 0; i < FrameCount; i++) { - SKImageInfo imageInfo = new SKImageInfo((int)(Codec.Info.Width * newScale), (int)(Codec.Info.Height * newScale)); + SKImageInfo imageInfo = new SKImageInfo((int)(codecInfo.Width * newScale), (int)(codecInfo.Height * newScale)); SKBitmap newBitmap = new SKBitmap(imageInfo); EmoteFrames[i].ScalePixels(newBitmap, SKFilterQuality.High); EmoteFrames[i].Dispose(); + newBitmap.SetImmutable(); EmoteFrames[i] = newBitmap; } } + +#region ImplementIDisposable + + public void Dispose() + { + Dispose(true); + } + + private void Dispose(bool isDisposing) + { + try + { + if (Disposed) + { + return; + } + + if (isDisposing) + { + foreach (var bitmap in EmoteFrames) + { + bitmap?.Dispose(); + } + + Codec?.Dispose(); + } + } + finally + { + Disposed = true; + } + } + +#endregion } } diff --git a/TwitchDownloaderWPF/PageChatDownload.xaml.cs b/TwitchDownloaderWPF/PageChatDownload.xaml.cs index f4f33050..ebfa2175 100644 --- a/TwitchDownloaderWPF/PageChatDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageChatDownload.xaml.cs @@ -11,8 +11,8 @@ using System.Windows.Media.Imaging; using TwitchDownloaderCore; using TwitchDownloaderCore.Chat; +using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Options; -using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects.Gql; using TwitchDownloaderWPF.Properties; using TwitchDownloaderWPF.Services; diff --git a/TwitchDownloaderWPF/PageChatRender.xaml.cs b/TwitchDownloaderWPF/PageChatRender.xaml.cs index e180e12f..1077e42c 100644 --- a/TwitchDownloaderWPF/PageChatRender.xaml.cs +++ b/TwitchDownloaderWPF/PageChatRender.xaml.cs @@ -116,7 +116,7 @@ public ChatRenderOptions GetOptions(string filename) AccentIndentScale = double.Parse(textAccentIndentScale.Text, CultureInfo.CurrentCulture), AccentStrokeScale = double.Parse(textAccentStrokeScale.Text, CultureInfo.CurrentCulture), VerticalSpacingScale = double.Parse(textVerticalScale.Text, CultureInfo.CurrentCulture), - IgnoreUsersArray = textIgnoreUsersList.Text.ToLower().Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), + IgnoreUsersArray = textIgnoreUsersList.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), BannedWordsArray = textBannedWordsList.Text.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), Timestamp = (bool)checkTimestamp.IsChecked, MessageColor = messageColor, @@ -318,9 +318,9 @@ public void SaveSettings() { Settings.Default.VideoCodec = ((Codec)comboCodec.SelectedItem).Name; } - Settings.Default.IgnoreUsersList = string.Join(",", textIgnoreUsersList.Text.ToLower() + Settings.Default.IgnoreUsersList = string.Join(",", textIgnoreUsersList.Text .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); - Settings.Default.BannedWordsList = string.Join(",", textBannedWordsList.Text.ToLower() + Settings.Default.BannedWordsList = string.Join(",", textBannedWordsList.Text .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); if (RadioEmojiNotoColor.IsChecked == true) Settings.Default.RenderEmojiVendor = (int)EmojiVendor.GoogleNotoColor; @@ -672,9 +672,8 @@ private async void SplitBtnRender_Click(object sender, RoutedEventArgs e) _cancellationTokenSource.Dispose(); UpdateActionButtons(false); - currentRender = null; - GC.Collect(); - GC.WaitForPendingFinalizers(); + currentRender.Dispose(); + GC.Collect(2, GCCollectionMode.Default, false); } } diff --git a/TwitchDownloaderWPF/PageClipDownload.xaml.cs b/TwitchDownloaderWPF/PageClipDownload.xaml.cs index 8c68a640..0b557d7d 100644 --- a/TwitchDownloaderWPF/PageClipDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageClipDownload.xaml.cs @@ -266,6 +266,6 @@ public TwitchClip(string Quality, string Framerate, string Url) public string ToString() { //Only show framerate if it's not 30fps - return String.Format("{0}p{1}", quality, framerate == "30" ? "" : framerate); + return $"{quality}p{(framerate == "30" ? "" : framerate)}"; } } \ No newline at end of file diff --git a/TwitchDownloaderWPF/PageVodDownload.xaml.cs b/TwitchDownloaderWPF/PageVodDownload.xaml.cs index 124dd66f..a4d098a0 100644 --- a/TwitchDownloaderWPF/PageVodDownload.xaml.cs +++ b/TwitchDownloaderWPF/PageVodDownload.xaml.cs @@ -14,8 +14,8 @@ using System.Windows.Media.Imaging; using System.Windows.Navigation; using TwitchDownloaderCore; +using TwitchDownloaderCore.Extensions; using TwitchDownloaderCore.Options; -using TwitchDownloaderCore.Tools; using TwitchDownloaderCore.TwitchObjects.Gql; using TwitchDownloaderWPF.Properties; using TwitchDownloaderWPF.Services; diff --git a/TwitchDownloaderWPF/Services/FilenameService.cs b/TwitchDownloaderWPF/Services/FilenameService.cs index 560454b8..17f51785 100644 --- a/TwitchDownloaderWPF/Services/FilenameService.cs +++ b/TwitchDownloaderWPF/Services/FilenameService.cs @@ -30,8 +30,8 @@ internal static string GetFilename(string template, string title, string id, Dat .Replace("{channel}", RemoveInvalidFilenameChars(channel)) .Replace("{date}", date.ToString("Mdyy")) .Replace("{random_string}", Path.GetFileNameWithoutExtension(Path.GetRandomFileName())) - .Replace("{crop_start}", string.Format(new TimeSpanHFormat(), @"{0:HH\-mm\-ss}", cropStart)) - .Replace("{crop_end}", string.Format(new TimeSpanHFormat(), @"{0:HH\-mm\-ss}", cropEnd)); + .Replace("{crop_start}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropStart)) + .Replace("{crop_end}", TimeSpanHFormat.ReusableInstance.Format(@"HH\-mm\-ss", cropEnd)); if (template.Contains("{date_custom=")) { diff --git a/TwitchDownloaderWPF/Services/ThemeService.cs b/TwitchDownloaderWPF/Services/ThemeService.cs index 4882282b..9b97d19f 100644 --- a/TwitchDownloaderWPF/Services/ThemeService.cs +++ b/TwitchDownloaderWPF/Services/ThemeService.cs @@ -5,7 +5,6 @@ using System.Windows; using System.Windows.Media; using System.Xml.Serialization; -using HandyControl.Tools; using TwitchDownloaderWPF.Models; using TwitchDownloaderWPF.Properties; diff --git a/TwitchDownloaderWPF/TwitchTasks/ChatRenderTask.cs b/TwitchDownloaderWPF/TwitchTasks/ChatRenderTask.cs index 90e2ecbe..42b375b2 100644 --- a/TwitchDownloaderWPF/TwitchTasks/ChatRenderTask.cs +++ b/TwitchDownloaderWPF/TwitchTasks/ChatRenderTask.cs @@ -104,10 +104,9 @@ public async Task RunAsync() Exception = new TwitchTaskException(ex); OnPropertyChanged(nameof(Exception)); } - renderer = null; + renderer.Dispose(); TokenSource.Dispose(); - GC.Collect(); - GC.WaitForPendingFinalizers(); + GC.Collect(2, GCCollectionMode.Default, false); } private void Progress_ProgressChanged(object sender, ProgressReport e) diff --git a/TwitchDownloaderWPF/TwitchTasks/TaskData.cs b/TwitchDownloaderWPF/TwitchTasks/TaskData.cs index c064af5f..2fe618b7 100644 --- a/TwitchDownloaderWPF/TwitchTasks/TaskData.cs +++ b/TwitchDownloaderWPF/TwitchTasks/TaskData.cs @@ -19,15 +19,15 @@ public string LengthFormatted TimeSpan time = TimeSpan.FromSeconds(Length); if ((int)time.TotalHours > 0) { - return (int)time.TotalHours + ":" + time.Minutes.ToString("D2") + ":" + time.Seconds.ToString("D2"); + return $"{(int)time.TotalHours}:{time.Minutes:D2}:{time.Seconds:D2}"; } if ((int)time.TotalMinutes > 0) { - return time.Minutes.ToString("D2") + ":" + time.Seconds.ToString("D2"); + return $"{time.Minutes:D2}:{time.Seconds:D2}"; } - return time.Seconds.ToString("D2") + "s"; + return $"{time.Seconds:D2}s"; } } }