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($"
");
+ var relativeTime = TimeSpan.FromSeconds(comment.content_offset_seconds);
+ var timestamp = TimeSpanHFormat.ReusableInstance.Format(@"H\:mm\:ss", relativeTime);
+ await sw.WriteLineAsync($"");
}
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";
}
}
}