Skip to content

Commit

Permalink
Memory and rendering improvements (#752)
Browse files Browse the repository at this point in the history
* Use more spans and references where possible, support reusing the same shaper for measuring text

* Don't hide the renderoptions in GetFallbackFont

* Minor cleanup

* Debug stuff

* Implement IDisposable in several types to properly release resources

* Cache values instead of making repeated external calls

* Misc cleanup

* Return highlight icon bitmap reference instead of copy

* Use precomputed emote total duration, use reverse for loop instead of reverse iterator

* Update FfmpegProcess, remove pointless couple to FfmpegProcess in DriveHelper

* Fix potential memory leaks, make less repeated external calls, cache SKColor.Parse(PURPLE)

* General cleanup

* Short circuit RemoveRestrictedComments if there are no comment restrictions, compare case insensitively instead of using string.ToLower

* More case insensitive checks instead of string.ToLower()

* Use StringBuilder.Replace instead of String.Replace

* Do not render every previous comment when starting at comment index 101 or later

* Use a StringPool to cache frequently recreated strings, short circuit early if a message is empty

* Move TimeSpanExtensions.cs to TwitchDownloaderCore.Extensions

* Forgot to stage

* Add success check to TwitchEmote image decoding

* More descriptive unsupported chat format exception messages

* Use spans instead of StringPool

* Fix oversight, only report progress every 3 ticks (less allocations), use interpolated string handler format provider instead of ToString

* Cache the SKImageInfo for each sectionImage for less external calls, use custom TryGetTwitchEmote() methods instead of .Any() then .First()

* Fix a few more minor memory leaks, refactor bits checking to be faster and allocate less, cleanup

* Reduce allocations made by TimeSpanHFormat.Format and TaskData.LengthFormatted, create ToFormattedString<T>() for custom string formatting without boxing

* Use string.Create instead of a custom extension method
Why is string.Create not mentioned more in the docs??

* Less allocations when serializing plain text chats

* Move GetShapedTextPath to SKPaintExtensions, cache SKPaint.GetFont reflection

* Use StringBuilder to build downloaded message bodies, store banned word regexes only in the needed scope, rename fields

* Fix ChatRender timestamps resetting after 24 hours, make ChatRender timestamp generation almost free, make TimeSpanHFormat.Format faster and allocate less, add non-boxing overload of TimeSpanHFormat.Format

* Explicitly set immutable bitmaps as immutable, obey offline when fetching gift-illus.png, return SKImages from HighlightIcons
Apparently calling SKCanvas.DrawBitmap with immutable bitmaps and using SKCanvas.DrawImage where possible increase performance

* Fix potential timecode parsing crash

* More efficient chat text Utc formatting

* Cleanup

* Reduce GC footprint of BufferExtensions.Add

* Make ChatBadge bitmaps immutable

* Explicitly make local functions static

* Fix message timestamp resetting after 24 hours in HTML chats and reduce memory allocated by HTML chat serializer

* Fix minor spacing issue

* Make ParseCommentBadges more readable
  • Loading branch information
ScrubN authored Jul 31, 2023
1 parent 3ea62f0 commit c7e6879
Show file tree
Hide file tree
Showing 34 changed files with 1,077 additions and 577 deletions.
4 changes: 2 additions & 2 deletions TwitchDownloaderCLI/Modes/RenderChat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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);
}

Expand Down
23 changes: 14 additions & 9 deletions TwitchDownloaderCore/Chat/ChatHtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.TwitchObjects;

namespace TwitchDownloaderCore.Chat
Expand All @@ -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)
Expand All @@ -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 "<!-- TITLE -->":

Check failure on line 51 in TwitchDownloaderCore/Chat/ChatHtml.cs

View workflow job for this annotation

GitHub Actions / build-gui

The feature 'pattern matching ReadOnly/Span<char> on constant string' is currently in Preview and *unsupported*. To use Preview features, use the 'preview' language version.
await sw.WriteLineAsync(HttpUtility.HtmlEncode(Path.GetFileNameWithoutExtension(filePath)));
Expand Down Expand Up @@ -71,13 +74,13 @@ public static class ChatHtml
case "<!-- CUSTOM HTML -->":

Check failure on line 74 in TwitchDownloaderCore/Chat/ChatHtml.cs

View workflow job for this annotation

GitHub Actions / build-gui

The feature 'pattern matching ReadOnly/Span<char> on constant string' is currently in Preview and *unsupported*. To use Preview features, use the 'preview' language version.
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($"<pre class=\"comment-root\">[{timestamp}] {GetChatBadgesHtml(embedData, chatBadgeData, comment)} <a href=\"https://twitch.tv/{comment.commenter.name}\"><span class=\"comment-author\" {(comment.message.user_color == null ? "" : $"style=\"color: {comment.message.user_color}\"")}>{(comment.commenter.display_name.Any(x => x > 127) ? $"{comment.commenter.display_name} ({comment.commenter.name})" : comment.commenter.display_name)}</span></a><span class=\"comment-message\">: {GetMessageHtml(embedData, thirdEmoteData, chatRoot, comment)}</span></pre>");
var relativeTime = TimeSpan.FromSeconds(comment.content_offset_seconds);
var timestamp = TimeSpanHFormat.ReusableInstance.Format(@"H\:mm\:ss", relativeTime);
await sw.WriteLineAsync($"<pre class=\"comment-root\">[{timestamp}] {GetChatBadgesHtml(embedData, chatBadgeData, comment)}<a href=\"https://twitch.tv/{comment.commenter.name}\"><span class=\"comment-author\" {(comment.message.user_color == null ? "" : $"style=\"color: {comment.message.user_color}\"")}>{(comment.commenter.display_name.Any(x => x > 127) ? $"{comment.commenter.display_name} ({comment.commenter.name})" : comment.commenter.display_name)}</span></a><span class=\"comment-message\">: {GetMessageHtml(embedData, thirdEmoteData, chatRoot, comment)}</span></pre>");
}
break;
default:
await sw.WriteLineAsync(templateStrings[i].TrimEnd('\r', '\n'));
await sw.WriteLineAsync(line.AsMemory().TrimEnd("\r\n"), cancellationToken);
break;
}
}
Expand Down Expand Up @@ -150,14 +153,15 @@ private static string GetChatBadgesHtml(bool embedData, IReadOnlyDictionary<stri
}
}

badgesHtml.Add(""); // Ensure the html string ends with a space
return string.Join(' ', badgesHtml);
}

private static string GetMessageHtml(bool embedEmotes, IReadOnlyDictionary<string, EmbedEmoteData> thirdEmoteData, ChatRoot chatRoot, Comment comment)
{
var message = new StringBuilder(comment.message.body.Length);

comment.message.fragments ??= new List<Fragment> { new Fragment() { text = comment.message.body } };
comment.message.fragments ??= new List<Fragment> { new() { text = comment.message.body } };

foreach (var fragment in comment.message.fragments)
{
Expand All @@ -178,7 +182,8 @@ private static string GetMessageHtml(bool embedEmotes, IReadOnlyDictionary<strin
}
else if (word != "")
{
message.Append(HttpUtility.HtmlEncode(word) + ' ');
message.Append(HttpUtility.HtmlEncode(word));
message.Append(' ');
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions TwitchDownloaderCore/Chat/ChatJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.Extensions;
using TwitchDownloaderCore.TwitchObjects;

namespace TwitchDownloaderCore.Chat
Expand Down Expand Up @@ -171,7 +171,7 @@ public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, Chat
}
break;
default:
throw new NotSupportedException("The requested chat format is not implemented");
throw new NotSupportedException($"{compression} is not a supported chat compression.");
}
}
}
Expand Down
13 changes: 5 additions & 8 deletions TwitchDownloaderCore/Chat/ChatText.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,24 @@ public static async Task SerializeAsync(string filePath, ChatRoot chatRoot, Time
var message = comment.message.body;
if (timeFormat == TimestampFormat.Utc)
{
var timestamp = comment.created_at;
await sw.WriteLineAsync($"[{timestamp:yyyy'-'MM'-'dd HH':'mm':'ss 'UTC'}] {username}: {message}");
var time = comment.created_at;
await sw.WriteLineAsync($"[{time:yyyy'-'MM'-'dd HH':'mm':'ss 'UTC'}] {username}: {message}");
}
else if (timeFormat == TimestampFormat.UtcFull)
{
var timestamp = comment.created_at;
await sw.WriteLineAsync($"[{timestamp:yyyy'-'MM'-'dd HH':'mm':'ss.fff 'UTC'}] {username}: {message}");
var time = comment.created_at;
await sw.WriteLineAsync($"[{time:yyyy'-'MM'-'dd HH':'mm':'ss.fff 'UTC'}] {username}: {message}");
}
else if (timeFormat == TimestampFormat.Relative)
{
var time = TimeSpan.FromSeconds(comment.content_offset_seconds);
await sw.WriteLineAsync(string.Format(new TimeSpanHFormat(), @"[{0:H\:mm\:ss}] {1}: {2}", time, username, message));
await sw.WriteLineAsync(string.Create(TimeSpanHFormat.ReusableInstance, @$"[{time:H\:mm\:ss}] {username}: {message}"));
}
else if (timeFormat == TimestampFormat.None)
{
await sw.WriteLineAsync($"{username}: {message}");
}
}

await sw.FlushAsync();
sw.Close();
}
}
}
25 changes: 12 additions & 13 deletions TwitchDownloaderCore/ChatDownloader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public sealed class ChatDownloader
BaseAddress = new Uri("https://gql.twitch.tv/gql"),
DefaultRequestHeaders = { { "Client-ID", "kd1unb4b3q4t58fwlpcbzcbnm76a8fp" } }
};
private static readonly Regex _bitsRegex = new(
private static readonly Regex BitsRegex = new(
@"(?<=(?:\s|^)(?:4Head|Anon|Bi(?:bleThumb|tBoss)|bday|C(?:h(?:eer|arity)|orgo)|cheerwal|D(?:ansGame|oodleCheer)|EleGiggle|F(?:rankerZ|ailFish)|Goal|H(?:eyGuys|olidayCheer)|K(?:appa|reygasm)|M(?:rDestructoid|uxy)|NotLikeThis|P(?:arty|ride|JSalt)|RIPCheer|S(?:coops|h(?:owLove|amrock)|eemsGood|wiftRage|treamlabs)|TriHard|uni|VoHiYo))[1-9]\d?\d?\d?\d?\d?\d?(?=\s|$)",
RegexOptions.Compiled);

Expand All @@ -35,9 +35,9 @@ private enum DownloadType
Video
}

public ChatDownloader(ChatDownloadOptions DownloadOptions)
public ChatDownloader(ChatDownloadOptions chatDownloadOptions)
{
downloadOptions = DownloadOptions;
downloadOptions = chatDownloadOptions;
downloadOptions.TempFolder = Path.Combine(
string.IsNullOrWhiteSpace(downloadOptions.TempFolder) ? Path.GetTempPath() : downloadOptions.TempFolder,
"TwitchDownloader");
Expand Down Expand Up @@ -231,7 +231,7 @@ private static List<Comment> 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;
Expand All @@ -256,10 +256,10 @@ public async Task DownloadAsync(IProgress<ProgressReport> 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<Comment>()
};

string videoId = downloadOptions.Id;
Expand Down Expand Up @@ -290,7 +290,7 @@ public async Task DownloadAsync(IProgress<ProgressReport> 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,
Expand All @@ -299,11 +299,10 @@ public async Task DownloadAsync(IProgress<ProgressReport> 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
Expand Down Expand Up @@ -508,7 +507,7 @@ public async Task DownloadAsync(IProgress<ProgressReport> 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.");
}
}
}
Expand Down
Loading

0 comments on commit c7e6879

Please sign in to comment.