From 2def0e91e5310cf641bd466131a8863d8bdee2ab Mon Sep 17 00:00:00 2001
From: Scrub <72096833+ScrubN@users.noreply.github.com>
Date: Sun, 11 Dec 2022 18:08:09 -0500
Subject: [PATCH] Chat Updater Rewrite + GUI frontend, QOL changes (#442)
* Move chatupdate functions to Core, prepare for additional update options, shorten chatupdate file/class names
* Make chatdownload obey custom temp folder
* Fixed all embeds being doubled with arg --embed-missing, fixed crash when embeddedData is null, made embed fetching async
* Continue before processing the new embedded item
* Implement chat crop moving
* Rename an arg, add input = output warning, typo
* Update readme
* Prepare for fetching missing comments (needs #440), cleanup
* Update readme
* Make ChatUpdater task based
* Add missing comment fetching, make default true bools nullable, add more progress reporters
* Move chat crop above embed fetching due to dependency, cleanup
* Fix comment appending not being fully thread-safe, add comment count report, ensure temp file is deleted, cleanup
* Update readmes
* Typo
* Clarify ReportType enum, fix chatupdate progress reports, clamp chat crops, cleanup
* Fix first party emotes not proprely checking for already added embedded emotes, fix emoji count check for Linux
* Rename 'DownloadFormat' to 'ChatFormat', add exception for catching unsupported chatupdate formats
* Fix future chat crop error when using non-json formats
* Simplify switch expressions with pattern matching
* Add GUI frontend for ChatUpdate, more UI consistency, cleanup
* Fix clip chat updating, add enqueuing support
* Add chat update to default settings
* Didn't mean to stage that
* Add output format conversion (input must still be json), cleanup
* Add step counter to progress chatupdate reports
* Add percent and universal constructor to ProgressReport class, add percent reports to chatupdate
* Use async methods with text chat writer, combine ParseChatJson and ParseChatJsonInfo, cleanup
* Update 'what can it do' section in readme
* Cleanup PreParseArgs, fix #448
* Fix NRE caused by 4867ddb8, fix catch block in TwitchHelper
* Better solution than 552ba3b, add additional icon sizes
* Add build instructions to README, remove last traces of .Net Framework, other README improvements
* Fix StatusInfo not fully overwriting the previous status,
Move json, html, txt chat operations to dedicated classes,
Fix sample text height being computed every render tick as it is expensive,
Rewrite SubstringToTextWidth() to use spans,
Cleanup
* Sealed core runmode classes, minor TwitchHelper optimizations, convert emoji check from LINQ to foreach (faster), use arrays instead of lists, stringbuilders instead of strings where possible, combine some Rtl and non-Rtl functions, cleanup
* Only use DrawShapedText() with messages containing RTL
---
README.md | 101 +-
.../Modes/Arguments/ChatDownloadArgs.cs | 10 +-
.../Arguments/ChatDownloadUpdaterArgs.cs | 33 -
.../Modes/Arguments/ChatRenderArgs.cs | 6 +-
.../Modes/Arguments/ChatUpdateArgs.cs | 43 +
TwitchDownloaderCLI/Modes/DownloadChat.cs | 13 +-
.../Modes/DownloadChatUpdater.cs | 170 ----
TwitchDownloaderCLI/Modes/DownloadVideo.cs | 4 +-
TwitchDownloaderCLI/Modes/RenderChat.cs | 12 +-
TwitchDownloaderCLI/Modes/UpdateChat.cs | 72 ++
TwitchDownloaderCLI/Program.cs | 7 +-
TwitchDownloaderCLI/README.md | 264 ++---
TwitchDownloaderCLI/Tools/PreParseArgs.cs | 69 +-
TwitchDownloaderCLI/Tools/ProgressHandler.cs | 45 +-
.../TwitchDownloaderCLI.csproj | 2 +-
TwitchDownloaderCore/ChatDownloader.cs | 282 ++----
TwitchDownloaderCore/ChatRenderer.cs | 407 ++++----
TwitchDownloaderCore/ChatUpdater.cs | 365 +++++++
TwitchDownloaderCore/ClipDownloader.cs | 7 +-
.../Options/ChatDownloadOptions.cs | 47 +-
.../Options/ChatRenderOptions.cs | 74 +-
.../Options/ChatUpdateOptions.cs | 33 +
TwitchDownloaderCore/ProgressReport.cs | 22 +-
TwitchDownloaderCore/Tools/ChatHtml.cs | 151 +++
TwitchDownloaderCore/Tools/ChatJson.cs | 93 ++
TwitchDownloaderCore/Tools/ChatText.cs | 54 +
TwitchDownloaderCore/Tools/CommentTools.cs | 24 +
.../TwitchDownloaderCore.csproj | 4 +-
TwitchDownloaderCore/TwitchHelper.cs | 82 +-
.../TwitchObjects/ChatRoot.cs | 10 +-
TwitchDownloaderCore/VideoDownloader.cs | 23 +-
TwitchDownloaderWPF/App.config | 6 +
TwitchDownloaderWPF/MainWindow.xaml | 22 +-
TwitchDownloaderWPF/MainWindow.xaml.cs | 6 +
TwitchDownloaderWPF/PageChatDownload.xaml | 37 +-
TwitchDownloaderWPF/PageChatDownload.xaml.cs | 931 +++++++++---------
TwitchDownloaderWPF/PageChatRender.xaml | 158 +--
TwitchDownloaderWPF/PageChatRender.xaml.cs | 22 +-
TwitchDownloaderWPF/PageChatUpdate.xaml | 147 +++
TwitchDownloaderWPF/PageChatUpdate.xaml.cs | 503 ++++++++++
TwitchDownloaderWPF/PageClipDownload.xaml | 43 +-
TwitchDownloaderWPF/PageQueue.xaml | 2 +-
TwitchDownloaderWPF/PageQueue.xaml.cs | 7 +
TwitchDownloaderWPF/PageVodDownload.xaml | 69 +-
TwitchDownloaderWPF/PageVodDownload.xaml.cs | 12 +-
.../Properties/Settings.Designer.cs | 24 +
.../Properties/Settings.settings | 6 +
TwitchDownloaderWPF/SettingsPage.xaml | 37 +-
TwitchDownloaderWPF/SettingsPage.xaml.cs | 162 +--
TwitchDownloaderWPF/TwitchDownloader.csproj | 20 +-
.../TwitchTasks/ChatDownloadTask.cs | 4 +-
.../TwitchTasks/ChatRenderTask.cs | 6 +-
.../TwitchTasks/ChatUpdateTask .cs | 84 ++
.../TwitchTasks/ClipDownloadTask.cs | 4 +-
.../TwitchTasks/VodDownloadTask.cs | 4 +-
.../WindowQueueOptions.xaml.cs | 67 +-
TwitchDownloaderWPF/icon.ico | Bin 4286 -> 38892 bytes
57 files changed, 3099 insertions(+), 1813 deletions(-)
delete mode 100644 TwitchDownloaderCLI/Modes/Arguments/ChatDownloadUpdaterArgs.cs
create mode 100644 TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
delete mode 100644 TwitchDownloaderCLI/Modes/DownloadChatUpdater.cs
create mode 100644 TwitchDownloaderCLI/Modes/UpdateChat.cs
create mode 100644 TwitchDownloaderCore/ChatUpdater.cs
create mode 100644 TwitchDownloaderCore/Options/ChatUpdateOptions.cs
create mode 100644 TwitchDownloaderCore/Tools/ChatHtml.cs
create mode 100644 TwitchDownloaderCore/Tools/ChatJson.cs
create mode 100644 TwitchDownloaderCore/Tools/ChatText.cs
create mode 100644 TwitchDownloaderCore/Tools/CommentTools.cs
create mode 100644 TwitchDownloaderWPF/PageChatUpdate.xaml
create mode 100644 TwitchDownloaderWPF/PageChatUpdate.xaml.cs
create mode 100644 TwitchDownloaderWPF/TwitchTasks/ChatUpdateTask .cs
diff --git a/README.md b/README.md
index 37a0ca6d..f6e917e7 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,8 @@ https://user-images.githubusercontent.com/1060681/197653099-c3fd12c2-f03a-4580-8
## What can it do?
- Download Twitch VODs
- Download Twitch Clips
-- Download chat for VODS and Clips, in either a [JSON with all the information](https://pastebin.com/raw/YDgRe6X4) or a [simple text file](https://pastebin.com/raw/016azeQX)
+- Download chat for VODS and Clips, in either a [JSON with all the original information](https://pastebin.com/raw/YDgRe6X4), a browser HTML file, or a [plain text file](https://pastebin.com/raw/016azeQX)
+- Update the contents of a previously generated JSON chat file with an option to save as another format
- Use a previously generated JSON chat file to render the chat with FFZ, BTTV and 7TV support (including GIFS)
# GUI
@@ -52,61 +53,73 @@ Check twitch-downloader-gui on [github](https://github.com/mohad12211/twitch-dow
## MacOS?
-No GUI is avaiable for MacOS yet :(
+No GUI is available for MacOS yet :(
# CLI
-The CLI is cross platform and performs the main functions of the program. It works on Windows, Linux, and MacOS.*
+### [See the full CLI documentation here](TwitchDownloaderCLI/README.md).
-*Only Intel Macs have been tested
+The CLI is cross-platform and implements the main functions of the program. It works on Windows, Linux, and MacOS*.
-### [CLI Documentation here](TwitchDownloaderCLI/README.md).
+*Only Intel Macs have been tested
-I've never really made a command line utility before so things may change in the future. If you're on Linux, make sure `fontconfig` and `libfontconfig1` are installed `(apt-get install fontconfig libfontconfig1)`.
-
-For example, you could copy/paste this into a `.bat` file on Windows, to download a VOD, chat, and then render in a single go.
-```
+With the CLI, it is possible to automate video processing using external scripts. For example, you could copy-paste the following code into a `.bat` file on Windows to download a VOD and its chat, and then render the chat, all from a single input.
+```bat
@echo off
set /p vodid="Enter VOD ID: "
TwitchDownloaderCLI.exe videodownload --id %vodid% --ffmpeg-path "ffmpeg.exe" -o %vodid%.mp4
-TwitchDownloaderCLI.exe chatdownload --id %vodid% -o %vodid%_chat.json
+TwitchDownloaderCLI.exe chatdownload --id %vodid% -o %vodid%_chat.json -E
TwitchDownloaderCLI.exe chatrender -i %vodid%_chat.json -h 1080 -w 422 --framerate 30 --update-rate 0 --font-size 18 -o %vodid%_chat.mp4
```
----
-### Linux – Getting started
-1. Go to [Releases](https://github.com/lay295/TwitchDownloader/releases/) and download the latest version for Linux.
-2. Extract `TwitchDownloaderCLI`
-3. Browse to where you extracted the file and give it executable rights in Terminal:
+## Windows - Getting started
+
+1. Go to [Releases](https://github.com/lay295/TwitchDownloader/releases/) and download the latest version for Windows or [build from source](#building-from-source).
+2. Extract `TwitchDownloaderCLI.exe`.
+3. Browse to where you extracted the file in the terminal.
+4. If you do not have ffmpeg, you can install it via [Chocolatey package manager](https://community.chocolatey.org/), or you can get it as a standalone file from [ffmpeg.org](https://ffmpeg.org/download.html) or by using TwitchDownloaderCLI:
+```
+TwitchDownloaderCLI.exe ffmpeg --download
+```
+5. You can now start using the downloader, for example:
+```
+TwitchDownloaderCLI.exe videodownload --id -o out.mp4
+```
+
+## Linux – Getting started
+
+1. Ensure both `fontconfig` and `libfontconfig1` are installed. `apt-get install fontconfig libfontconfig1` on Ubuntu.
+2. Go to [Releases](https://github.com/lay295/TwitchDownloader/releases/) and download the latest binary for Linux, grab the [AUR Package](https://aur.archlinux.org/packages/twitch-downloader-bin/) for Arch Linux, or [build from source](#building-from-source).
+3. Extract `TwitchDownloaderCLI`.
+4. Browse to where you extracted the file and give it executable rights in the terminal:
```
sudo chmod +x TwitchDownloaderCLI
```
-4. If you do not have ffmpeg, you should install it via your distro package manager, however you can also get it as a standalone file from [ffmpeg.org](https://ffmpeg.org/download.html) or by using TwitchDownloaderCLI:
+5. a) If you do not have ffmpeg, you should install it via your distro package manager, however you can also get it as a standalone file from [ffmpeg.org](https://ffmpeg.org/download.html) or by using TwitchDownloaderCLI:
```
./TwitchDownloaderCLI ffmpeg --download
```
-If downloaded as a standalone file, you must also give it executable rights with:
+5. b) If downloaded as a standalone file, you must also give it executable rights with:
```
sudo chmod +x ffmpeg
```
-5. You can now start using the downloader, for example:
+6. You can now start using the downloader, for example:
```
./TwitchDownloaderCLI videodownload --id -o out.mp4
```
-For Arch Linux, there's an [AUR Package](https://aur.archlinux.org/packages/twitch-downloader-bin/)
-### MacOS – Getting started
-1. Go to [Releases](https://github.com/lay295/TwitchDownloader/releases/) and download the latest version for MacOS.
-2. Extract `TwitchDownloaderCLI`
-3. Browse to where you extracted the file and give it executable rights in Terminal:
+## MacOS – Getting started
+1. Go to [Releases](https://github.com/lay295/TwitchDownloader/releases/) and download the latest binary for MacOS or [build from source](#building-from-source).
+2. Extract `TwitchDownloaderCLI`.
+3. Browse to where you extracted the file and give it executable rights in the terminal:
```
chmod +x TwitchDownloaderCLI
```
-4. If you do not have ffmpeg, you can install it via [Homebrew package manager](https://brew.sh/), or you can get it as a standalone file from [ffmpeg.org](https://ffmpeg.org/download.html) or by using TwitchDownloaderCLI:
+4. a) If you do not have ffmpeg, you can install it via [Homebrew package manager](https://brew.sh/), or you can get it as a standalone file from [ffmpeg.org](https://ffmpeg.org/download.html) or by using TwitchDownloaderCLI:
```
./TwitchDownloaderCLI ffmpeg --download
```
-If downloaded as a standalone file, you must also give it executable rights with:
+4. b) If downloaded as a standalone file, you must also give it executable rights with:
```
chmod +x ffmpeg
```
@@ -115,6 +128,44 @@ chmod +x ffmpeg
./TwitchDownloaderCLI videodownload --id -o out.mp4
```
+# Building from source
+
+## Requirements
+
+- [.NET 6.0.x SDK](https://dotnet.microsoft.com/en-us/download/dotnet/6.0)
+
+## Build Instructions
+
+1. Clone the repository:
+```
+git clone https://github.com/lay295/TwitchDownloader.git
+```
+2. Navigate to the solution folder:
+```
+cd TwitchDownloader
+```
+3. Restore the solution:
+```
+dotnet restore
+```
+4. a) Build the GUI:
+```
+dotnet publish TwitchDownloaderWPF -p:PublishProfile=Windows -p:DebugType=None -p:DebugSymbols=false
+```
+4. b) Build the CLI:
+```
+dotnet publish TwitchDownloaderCLI -p:PublishProfile= -p:DebugType=None -p:DebugSymbols=false
+```
+- Applicable Profiles: `Windows`, `Linux`, `LinuxAlpine`, `LinuxArm`, `MacOS`
+5. a) Navigate to the GUI build folder:
+```
+cd TwitchDownloaderWPF/bin/Release/net6.0-windows/publish/win-x64
+```
+5. b) Navigate to the CLI build folder:
+```
+cd TwitchDownloaderCLI/bin/Release/net6.0/publish
+```
+
# License
[MIT](./LICENSE.txt)
\ No newline at end of file
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
index 70cf84fc..daeeb335 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadArgs.cs
@@ -10,7 +10,7 @@ public class ChatDownloadArgs
[Option('u', "id", Required = true, HelpText = "The ID of the VOD or clip to download that chat of.")]
public string Id { get; set; }
- [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are json, html, and txt.")]
+ [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine download type. Valid extensions are: json, html, and txt.")]
public string OutputFile { get; set; }
[Option('b', "beginning", HelpText = "Time in seconds to crop beginning.")]
@@ -31,13 +31,13 @@ public class ChatDownloadArgs
[Option("stv", Default = true, HelpText = "Enable 7tv embedding in chat download. Requires -E / --embed-images!")]
public bool? StvEmotes { get; set; }
- [Option("timestamp", Default = false, HelpText = "Enable timestamps for .txt chat downloads.")]
- public bool Timestamp { get; set; }
-
- [Option("timestamp-format", Default = TimestampFormat.Relative, HelpText = "Sets the timestamp format for .txt chat logs. Valid values are Utc, Relative, and None")]
+ [Option("timestamp-format", Default = TimestampFormat.Relative, HelpText = "Sets the timestamp format for .txt chat logs. Valid values are: Utc, Relative, and None")]
public TimestampFormat TimeFormat { get; set; }
[Option("chat-connections", Default = 4, HelpText = "Number of downloading connections for chat")]
public int ChatConnections { get; set; }
+
+ [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")]
+ public string TempFolder { get; set; }
}
}
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadUpdaterArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadUpdaterArgs.cs
deleted file mode 100644
index b5466abf..00000000
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatDownloadUpdaterArgs.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using CommandLine;
-
-namespace TwitchDownloaderCLI.Modes.Arguments
-{
-
- [Verb("chatupdate", HelpText = "Updates the embeded emotes, badges, and bits of a chat download.")]
- public class ChatDownloadUpdaterArgs
- {
- [Option('i', "input", Required = true, HelpText = "Path to input file. Valid extensions are json")]
- public string InputFile { get; set; }
-
- [Option('o', "output", Required = true, HelpText = "Path to output file. Extension should match the input.")]
- public string OutputFile { get; set; }
-
- [Option('E', "embed-missing", Default = true, HelpText = "Embed missing emotes, badges, and bits. Already embedded images will be untouched")]
- public bool EmbedMissing { get; set; }
-
- [Option('U', "update-old", Default = false, HelpText = "Update old emotes, badges, and bits to the current. All embedded images will be overwritten")]
- public bool UpdateOldEmbeds { get; set; }
-
- [Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download.")]
- public bool BttvEmotes { get; set; }
-
- [Option("ffz", Default = true, HelpText = "Enable FFZ embedding in chat download.")]
- public bool FfzEmotes { get; set; }
-
- [Option("stv", Default = true, HelpText = "Enable 7tv embedding in chat download.")]
- public bool StvEmotes { get; set; }
-
- [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")]
- public string TempFolder { get; set; }
- }
-}
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
index b83694d8..f44e4229 100644
--- a/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatRenderArgs.cs
@@ -69,10 +69,10 @@ public class ChatRenderArgs
[Option("update-rate", Default = 0.2, HelpText = "Time in seconds to update chat render output.")]
public double UpdateRate { get; set; }
- [Option("input-args", Default = "-framerate {fps} -f rawvideo -analyzeduration {max_int} -probesize {max_int} -pix_fmt {pix_fmt} -video_size {width}x{height} -i -", HelpText = "Input (pass 1) arguments for ffmpeg chat render.")]
+ [Option("input-args", Default = "-framerate {fps} -f rawvideo -analyzeduration {max_int} -probesize {max_int} -pix_fmt {pix_fmt} -video_size {width}x{height} -i -", HelpText = "Input arguments for ffmpeg chat render.")]
public string InputArgs { get; set; }
- [Option("output-args", Default = "-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p \"{save_path}\"", HelpText = "Output (pass 2) arguments for ffmpeg chat render.")]
+ [Option("output-args", Default = "-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p \"{save_path}\"", HelpText = "Output arguments for ffmpeg chat render.")]
public string OutputArgs { get; set; }
[Option("ignore-users", Default = "", HelpText = "List of usernames to ignore when rendering, separated by commas.")]
@@ -81,7 +81,7 @@ public class ChatRenderArgs
[Option("badge-filter", Default = 0, HelpText = "Bitmask of types of Chat Badges to filter out. Add the numbers of the types of badges you want to filter. For example, 6 = no broadcaster or moderator badges.\r\nKey: Other = 1, Broadcaster = 2, Moderator = 4, VIP = 8, Subscriber = 16, Predictions = 32, NoAudio/NoVideo = 64, PrimeGaming = 128")]
public int BadgeFilterMask { get; set; }
- [Option("offline", Default = false, HelpText = "Render completely offline, using only resources embedded emotes, badges, and bits in the input json.")]
+ [Option("offline", Default = false, HelpText = "Render completely offline using only embedded emotes, badges, and bits from the input json.")]
public bool Offline { get; set; }
[Option("ffmpeg-path", HelpText = "Path to ffmpeg executable.")]
diff --git a/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
new file mode 100644
index 00000000..9b50cfdf
--- /dev/null
+++ b/TwitchDownloaderCLI/Modes/Arguments/ChatUpdateArgs.cs
@@ -0,0 +1,43 @@
+using CommandLine;
+using TwitchDownloaderCore.Options;
+
+namespace TwitchDownloaderCLI.Modes.Arguments
+{
+
+ [Verb("chatupdate", HelpText = "Updates the embeded emotes, badges, bits, and crops of a chat download and/or converts a JSON chat to another format.")]
+ public class ChatUpdateArgs
+ {
+ [Option('i', "input", Required = true, HelpText = "Path to input file. Valid extensions are: json.")]
+ public string InputFile { get; set; }
+
+ [Option('o', "output", Required = true, HelpText = "Path to output file. File extension will be used to determine new chat type. Valid extensions are: json, html, and txt.")]
+ public string OutputFile { get; set; }
+
+ [Option('E', "embed-missing", Default = false, HelpText = "Embed missing emotes, badges, and cheermotes. Already embedded images will be untouched.")]
+ public bool EmbedMissing { get; set; }
+
+ [Option('R', "replace-embeds", Default = false, HelpText = "Replace all embedded emotes, badges, and cheermotes in the file. All embedded images will be overwritten!")]
+ public bool ReplaceEmbeds { get; set; }
+
+ [Option('b', "beginning", Default = -1, HelpText = "New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop.")]
+ public int CropBeginningTime { get; set; }
+
+ [Option('e', "ending", Default = -1, HelpText = "New time in seconds for chat ending. Comments may be added but not removed. -1 = No crop.")]
+ public int CropEndingTime { get; set; }
+
+ [Option("bttv", Default = true, HelpText = "Enable BTTV embedding in chat download.")]
+ public bool? BttvEmotes { get; set; }
+
+ [Option("ffz", Default = true, HelpText = "Enable FFZ embedding in chat download.")]
+ public bool? FfzEmotes { get; set; }
+
+ [Option("stv", Default = true, HelpText = "Enable 7TV embedding in chat download.")]
+ public bool? StvEmotes { get; set; }
+
+ [Option("timestamp-format", Default = TimestampFormat.Relative, HelpText = "Sets the timestamp format for .txt chat logs. Valid values are: Utc, Relative, and None")]
+ public TimestampFormat TimeFormat { get; set; }
+
+ [Option("temp-path", Default = "", HelpText = "Path to temporary folder to use for cache.")]
+ public string TempFolder { get; set; }
+ }
+}
diff --git a/TwitchDownloaderCLI/Modes/DownloadChat.cs b/TwitchDownloaderCLI/Modes/DownloadChat.cs
index 47efc3f0..e6a0a720 100644
--- a/TwitchDownloaderCLI/Modes/DownloadChat.cs
+++ b/TwitchDownloaderCLI/Modes/DownloadChat.cs
@@ -12,7 +12,7 @@ internal class DownloadChat
{
internal static void Download(ChatDownloadArgs inputOptions)
{
- if (inputOptions.Id == string.Empty)
+ if (string.IsNullOrWhiteSpace(inputOptions.Id))
{
Console.WriteLine("[ERROR] - Invalid ID, unable to parse.");
Environment.Exit(1);
@@ -22,24 +22,23 @@ internal static void Download(ChatDownloadArgs inputOptions)
{
DownloadFormat = Path.GetExtension(inputOptions.OutputFile)!.ToLower() switch
{
- ".json" => DownloadFormat.Json,
- ".html" => DownloadFormat.Html,
- ".htm" => DownloadFormat.Html,
- _ => DownloadFormat.Text
+ ".html" or ".htm" => ChatFormat.Html,
+ ".json" => ChatFormat.Json,
+ _ => ChatFormat.Text
},
Id = inputOptions.Id,
CropBeginning = inputOptions.CropBeginningTime > 0.0,
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = inputOptions.CropEndingTime > 0.0,
CropEndingTime = inputOptions.CropEndingTime,
- Timestamp = inputOptions.Timestamp,
EmbedData = inputOptions.EmbedData,
Filename = inputOptions.OutputFile,
TimeFormat = inputOptions.TimeFormat,
ConnectionCount = inputOptions.ChatConnections,
BttvEmotes = (bool)inputOptions.BttvEmotes,
FfzEmotes = (bool)inputOptions.FfzEmotes,
- StvEmotes = (bool)inputOptions.StvEmotes
+ StvEmotes = (bool)inputOptions.StvEmotes,
+ TempFolder = inputOptions.TempFolder
};
ChatDownloader chatDownloader = new(downloadOptions);
diff --git a/TwitchDownloaderCLI/Modes/DownloadChatUpdater.cs b/TwitchDownloaderCLI/Modes/DownloadChatUpdater.cs
deleted file mode 100644
index fd459997..00000000
--- a/TwitchDownloaderCLI/Modes/DownloadChatUpdater.cs
+++ /dev/null
@@ -1,170 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.Json;
-using System.Threading.Tasks;
-using TwitchDownloaderCLI.Modes.Arguments;
-using TwitchDownloaderCore;
-using TwitchDownloaderCore.Options;
-using TwitchDownloaderCore.TwitchObjects;
-
-namespace TwitchDownloaderCLI.Modes
-{
- internal class DownloadChatUpdater
- {
- internal static void Update(ChatDownloadUpdaterArgs inputOptions)
- {
- DownloadFormat inFormat = Path.GetExtension(inputOptions.InputFile)!.ToLower() switch
- {
- ".json" => DownloadFormat.Json,
- ".html" => DownloadFormat.Html,
- ".htm" => DownloadFormat.Html,
- _ => DownloadFormat.Text
- };
- DownloadFormat outFormat = Path.GetExtension(inputOptions.OutputFile)!.ToLower() switch
- {
- ".json" => DownloadFormat.Json,
- ".html" => DownloadFormat.Html,
- ".htm" => DownloadFormat.Html,
- _ => DownloadFormat.Text
- };
- // Check that both input and output are json
- if (inFormat != DownloadFormat.Json || outFormat != DownloadFormat.Json)
- {
- Console.WriteLine("[ERROR] - {0} format must be be json!", inFormat != DownloadFormat.Json ? "Input" : "Output");
- Environment.Exit(1);
- }
- if (!File.Exists(inputOptions.InputFile))
- {
- Console.WriteLine("[ERROR] - Input file does not exist!");
- Environment.Exit(1);
- }
- if (!inputOptions.EmbedMissing && !inputOptions.UpdateOldEmbeds)
- {
- Console.WriteLine("[ERROR] - Please enable either EmbedMissingEmotes or UpdateOldEmotes");
- Environment.Exit(1);
- }
-
- // Read in the old input file
- ChatRoot chatRoot = Task.Run(() => ChatRenderer.ParseJsonStatic(inputOptions.InputFile)).Result;
- if (chatRoot.streamer == null)
- {
- chatRoot.streamer = new Streamer();
- chatRoot.streamer.id = int.Parse(chatRoot.comments.First().channel_id);
- chatRoot.streamer.name = Task.Run(() => TwitchHelper.GetStreamerName(chatRoot.streamer.id)).Result;
- }
- if (chatRoot.embeddedData == null)
- {
- chatRoot.embeddedData = new EmbeddedData();
- }
-
- string cacheFolder = Path.Combine(string.IsNullOrWhiteSpace(inputOptions.TempFolder) ? Path.GetTempPath() : inputOptions.TempFolder, "TwitchDownloader", "chatupdatecache");
-
- // Clear working directory if it already exists
- if (Directory.Exists(cacheFolder))
- Directory.Delete(cacheFolder, true);
-
- // Thirdparty emotes
- if (chatRoot.embeddedData.thirdParty == null || inputOptions.UpdateOldEmbeds)
- {
- chatRoot.embeddedData.thirdParty = new List();
- }
- Console.WriteLine("Input third party emote count: " + chatRoot.embeddedData.thirdParty.Count);
- List thirdPartyEmotes = new List();
- thirdPartyEmotes = Task.Run(() => TwitchHelper.GetThirdPartyEmotes(chatRoot.streamer.id, cacheFolder, bttv: inputOptions.BttvEmotes, ffz: inputOptions.FfzEmotes, stv: inputOptions.StvEmotes, embeddedData: chatRoot.embeddedData)).Result;
- foreach (TwitchEmote emote in thirdPartyEmotes)
- {
- EmbedEmoteData newEmote = new EmbedEmoteData();
- newEmote.id = emote.Id;
- newEmote.imageScale = emote.ImageScale;
- newEmote.data = emote.ImageData;
- newEmote.name = emote.Name;
- newEmote.width = emote.Width / emote.ImageScale;
- newEmote.height = emote.Height / emote.ImageScale;
- chatRoot.embeddedData.thirdParty.Add(newEmote);
- }
- Console.WriteLine("Output third party emote count: " + chatRoot.embeddedData.thirdParty.Count);
-
- // Firstparty emotes
- if (chatRoot.embeddedData.firstParty == null || inputOptions.UpdateOldEmbeds)
- {
- chatRoot.embeddedData.firstParty = new List();
- }
- Console.WriteLine("Input first party emote count: " + chatRoot.embeddedData.firstParty.Count);
- List firstPartyEmotes = new List();
- firstPartyEmotes = Task.Run(() => TwitchHelper.GetEmotes(chatRoot.comments, cacheFolder, embeddedData: chatRoot.embeddedData)).Result;
- foreach (TwitchEmote emote in firstPartyEmotes)
- {
- EmbedEmoteData newEmote = new EmbedEmoteData();
- newEmote.id = emote.Id;
- newEmote.imageScale = emote.ImageScale;
- newEmote.data = emote.ImageData;
- newEmote.width = emote.Width / emote.ImageScale;
- newEmote.height = emote.Height / emote.ImageScale;
- chatRoot.embeddedData.firstParty.Add(newEmote);
- }
- Console.WriteLine("Output third party emote count: " + chatRoot.embeddedData.firstParty.Count);
-
- // Twitch badges
- if (chatRoot.embeddedData.twitchBadges == null || inputOptions.UpdateOldEmbeds)
- {
- chatRoot.embeddedData.twitchBadges = new List();
- }
- Console.WriteLine("Input twitch badge count: " + chatRoot.embeddedData.twitchBadges.Count);
- List twitchBadges = new List();
- twitchBadges = Task.Run(() => TwitchHelper.GetChatBadges(chatRoot.streamer.id, cacheFolder, embeddedData: chatRoot.embeddedData)).Result;
- foreach (ChatBadge badge in twitchBadges)
- {
- EmbedChatBadge newBadge = new EmbedChatBadge();
- newBadge.name = badge.Name;
- newBadge.versions = badge.VersionsData;
- chatRoot.embeddedData.twitchBadges.Add(newBadge);
- }
- Console.WriteLine("Output twitch badge count: " + chatRoot.embeddedData.twitchBadges.Count);
-
- // Twitch bits / cheers
- if (chatRoot.embeddedData.twitchBits == null || inputOptions.UpdateOldEmbeds)
- {
- chatRoot.embeddedData.twitchBits = new List();
- }
- Console.WriteLine("Input twitch bit count: " + chatRoot.embeddedData.twitchBits.Count);
- List twitchBits = new List();
- twitchBits = Task.Run(() => TwitchHelper.GetBits(cacheFolder, chatRoot.streamer.id.ToString(), embeddedData: chatRoot.embeddedData)).Result;
- foreach (CheerEmote bit in twitchBits)
- {
- EmbedCheerEmote newBit = new EmbedCheerEmote();
- newBit.prefix = bit.prefix;
- newBit.tierList = new Dictionary();
- foreach (KeyValuePair emotePair in bit.tierList)
- {
- EmbedEmoteData newEmote = new EmbedEmoteData();
- newEmote.id = emotePair.Value.Id;
- newEmote.imageScale = emotePair.Value.ImageScale;
- newEmote.data = emotePair.Value.ImageData;
- newEmote.name = emotePair.Value.Name;
- newEmote.width = emotePair.Value.Width / emotePair.Value.ImageScale;
- newEmote.height = emotePair.Value.Height / emotePair.Value.ImageScale;
- newBit.tierList.Add(emotePair.Key, newEmote);
- }
- chatRoot.embeddedData.twitchBits.Add(newBit);
- }
- Console.WriteLine("Input twitch bit count: " + chatRoot.embeddedData.twitchBits.Count);
-
- // Finally save the output to file!
- // TODO: maybe in the future we could also export as HTML here too?
- if (outFormat == DownloadFormat.Json)
- {
- using (TextWriter writer = File.CreateText(inputOptions.OutputFile))
- {
- var serializer = new Newtonsoft.Json.JsonSerializer();
- serializer.Serialize(writer, chatRoot);
- }
- }
-
- // Clear our working directory, it's highly unlikely we would reuse it anyways
- if (Directory.Exists(cacheFolder))
- Directory.Delete(cacheFolder, true);
- }
- }
-}
diff --git a/TwitchDownloaderCLI/Modes/DownloadVideo.cs b/TwitchDownloaderCLI/Modes/DownloadVideo.cs
index e5e6ad93..e72deb5f 100644
--- a/TwitchDownloaderCLI/Modes/DownloadVideo.cs
+++ b/TwitchDownloaderCLI/Modes/DownloadVideo.cs
@@ -15,7 +15,7 @@ internal static void Download(VideoDownloadArgs inputOptions)
{
FfmpegHandler.DetectFfmpeg(inputOptions.FfmpegPath);
- if (inputOptions.Id == string.Empty || !inputOptions.Id.All(char.IsDigit))
+ if (string.IsNullOrWhiteSpace(inputOptions.Id) || !inputOptions.Id.All(char.IsDigit))
{
Console.WriteLine("[ERROR] - Invalid VOD ID, unable to parse. Must be only numbers.");
Environment.Exit(1);
@@ -32,7 +32,7 @@ internal static void Download(VideoDownloadArgs inputOptions)
CropBeginningTime = inputOptions.CropBeginningTime,
CropEnding = inputOptions.CropEndingTime > 0.0,
CropEndingTime = inputOptions.CropEndingTime,
- FfmpegPath = inputOptions.FfmpegPath is null or "" ? FfmpegHandler.ffmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath),
+ FfmpegPath = string.IsNullOrWhiteSpace(inputOptions.FfmpegPath) ? FfmpegHandler.ffmpegExecutableName : Path.GetFullPath(inputOptions.FfmpegPath),
TempFolder = inputOptions.TempFolder
};
diff --git a/TwitchDownloaderCLI/Modes/RenderChat.cs b/TwitchDownloaderCLI/Modes/RenderChat.cs
index 2d361d67..8d3fa4fd 100644
--- a/TwitchDownloaderCLI/Modes/RenderChat.cs
+++ b/TwitchDownloaderCLI/Modes/RenderChat.cs
@@ -37,16 +37,14 @@ internal static void Render(ChatRenderArgs inputOptions)
{
"normal" => SKFontStyle.Normal,
"bold" => SKFontStyle.Bold,
- "italic" => SKFontStyle.Italic,
- "italics" => SKFontStyle.Italic,
+ "italic" or "italics" => SKFontStyle.Italic,
_ => throw new NotImplementedException("Invalid message font style. Valid values are: normal, bold, and italic")
},
UsernameFontStyle = inputOptions.UsernameFontStyle.ToLower() switch
{
"normal" => SKFontStyle.Normal,
"bold" => SKFontStyle.Bold,
- "italic" => SKFontStyle.Italic,
- "italics" => SKFontStyle.Italic,
+ "italic" or "italics" => SKFontStyle.Italic,
_ => throw new NotImplementedException("Invalid username font style. Valid values are: normal, bold, and italic")
},
UpdateRate = inputOptions.UpdateRate,
@@ -59,7 +57,7 @@ internal static void Render(ChatRenderArgs inputOptions)
SubMessages = (bool)inputOptions.SubMessages,
ChatBadges = (bool)inputOptions.ChatBadges,
Timestamp = inputOptions.Timestamp,
- Offline = (bool)inputOptions.Offline,
+ Offline = inputOptions.Offline,
};
if (renderOptions.GenerateMask && renderOptions.BackgroundColor.Alpha == 255)
@@ -80,7 +78,7 @@ internal static void Render(ChatRenderArgs inputOptions)
}
}
- if (inputOptions.IgnoreUsersList != string.Empty)
+ if (inputOptions.IgnoreUsersList != "")
{
renderOptions.IgnoreUsersList = inputOptions.IgnoreUsersList.ToLower().Split(',',
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToList();
@@ -90,7 +88,7 @@ internal static void Render(ChatRenderArgs inputOptions)
ChatRenderer chatRenderer = new(renderOptions);
Progress progress = new();
progress.ProgressChanged += ProgressHandler.Progress_ProgressChanged;
- chatRenderer.ParseJson().Wait();
+ chatRenderer.ParseJsonAsync().Wait();
chatRenderer.RenderVideoAsync(progress, new CancellationToken()).Wait();
}
}
diff --git a/TwitchDownloaderCLI/Modes/UpdateChat.cs b/TwitchDownloaderCLI/Modes/UpdateChat.cs
new file mode 100644
index 00000000..80f31457
--- /dev/null
+++ b/TwitchDownloaderCLI/Modes/UpdateChat.cs
@@ -0,0 +1,72 @@
+using System;
+using System.IO;
+using System.Threading;
+using TwitchDownloaderCLI.Modes.Arguments;
+using TwitchDownloaderCLI.Tools;
+using TwitchDownloaderCore;
+using TwitchDownloaderCore.Options;
+
+namespace TwitchDownloaderCLI.Modes
+{
+ internal class UpdateChat
+ {
+ internal static void Update(ChatUpdateArgs inputOptions)
+ {
+ if (!File.Exists(inputOptions.InputFile))
+ {
+ Console.WriteLine("[ERROR] - Input file does not exist!");
+ Environment.Exit(1);
+ }
+ ChatFormat inFormat = Path.GetExtension(inputOptions.InputFile)!.ToLower() switch
+ {
+ ".html" or ".htm" => ChatFormat.Html,
+ ".json" => ChatFormat.Json,
+ _ => ChatFormat.Text
+ };
+ ChatFormat outFormat = Path.GetExtension(inputOptions.OutputFile)!.ToLower() switch
+ {
+ ".html" or ".htm" => ChatFormat.Html,
+ ".json" => ChatFormat.Json,
+ _ => ChatFormat.Text
+ };
+ if (inFormat != ChatFormat.Json)
+ {
+ Console.WriteLine("[ERROR] - Input file must be json!");
+ Environment.Exit(1);
+ }
+ if (inputOptions.InputFile == inputOptions.OutputFile)
+ {
+ Console.WriteLine("[WARNING] - Output file path is identical to input file. This is not recommended in case something goes wrong. All data will be permanantly overwritten!");
+ }
+ if (!inputOptions.EmbedMissing && !inputOptions.ReplaceEmbeds && double.IsNegative(inputOptions.CropBeginningTime) && double.IsNegative(inputOptions.CropEndingTime))
+ {
+ Console.WriteLine("[ERROR] - No update options were passed. Please pass --embed-missing, --replace-embeds, -b, or -e");
+ Environment.Exit(1);
+ }
+
+ ChatUpdateOptions updateOptions = new()
+ {
+ InputFile = inputOptions.InputFile,
+ OutputFile = inputOptions.OutputFile,
+ OutputFormat = outFormat,
+ EmbedMissing = inputOptions.EmbedMissing,
+ ReplaceEmbeds = inputOptions.ReplaceEmbeds,
+ CropBeginning = !double.IsNegative(inputOptions.CropBeginningTime),
+ CropBeginningTime = inputOptions.CropBeginningTime,
+ CropEnding = !double.IsNegative(inputOptions.CropEndingTime),
+ CropEndingTime = inputOptions.CropEndingTime,
+ BttvEmotes = (bool)inputOptions.BttvEmotes,
+ FfzEmotes = (bool)inputOptions.FfzEmotes,
+ StvEmotes = (bool)inputOptions.StvEmotes,
+ TextTimestampFormat = inputOptions.TimeFormat,
+ TempFolder = inputOptions.TempFolder
+ };
+
+ ChatUpdater chatUpdater = new(updateOptions);
+ Progress progress = new();
+ progress.ProgressChanged += ProgressHandler.Progress_ProgressChanged;
+ chatUpdater.ParseJsonAsync().Wait();
+ chatUpdater.UpdateAsync(progress, new CancellationToken()).Wait();
+ }
+ }
+}
diff --git a/TwitchDownloaderCLI/Program.cs b/TwitchDownloaderCLI/Program.cs
index 07727d7b..139b0254 100644
--- a/TwitchDownloaderCLI/Program.cs
+++ b/TwitchDownloaderCLI/Program.cs
@@ -1,6 +1,5 @@
using CommandLine;
using System;
-using System.Diagnostics;
using System.IO;
using System.Linq;
using TwitchDownloaderCLI.Modes;
@@ -13,7 +12,7 @@ class Program
{
static void Main(string[] args)
{
- string processFileName = Environment.ProcessPath.Split(Path.DirectorySeparatorChar).Last();
+ string processFileName = Path.GetFileName(Environment.ProcessPath);
if (args.Length == 0)
{
if (Path.GetExtension(processFileName).Equals(".exe"))
@@ -42,11 +41,11 @@ static void Main(string[] args)
preParsedArgs = PreParseArgs.Process(args);
}
- Parser.Default.ParseArguments(preParsedArgs)
+ Parser.Default.ParseArguments(preParsedArgs)
.WithParsed(DownloadVideo.Download)
.WithParsed(DownloadClip.Download)
.WithParsed(DownloadChat.Download)
- .WithParsed(DownloadChatUpdater.Update)
+ .WithParsed(UpdateChat.Update)
.WithParsed(RenderChat.Render)
.WithParsed(FfmpegHandler.ParseArgs)
.WithParsed(CacheHandler.ParseArgs)
diff --git a/TwitchDownloaderCLI/README.md b/TwitchDownloaderCLI/README.md
index 9dc00650..aa370c2f 100644
--- a/TwitchDownloaderCLI/README.md
+++ b/TwitchDownloaderCLI/README.md
@@ -1,267 +1,289 @@
# TwitchDownloaderCLI
A cross platform command line tool that can do the main functions of the GUI program, which can download VODs/Clips/Chats and render chats.
- - [Arguments for mode videodownload](#arguments-for-mode-videodownload)
- - [Arguments for mode clipdownload](#arguments-for-mode-clipdownload)
- - [Arguments for mode chatdownload](#arguments-for-mode-chatdownload)
- - [Arguments for mode chatrender](#arguments-for-mode-chatrender)
- - [Arguments for mode chatupdate](#arguments-for-mode-chatupdate)
- - [Arguments for mode ffmpeg](#arguments-for-mode-ffmpeg)
- - [Arguments for mode cache](#arguments-for-mode-cache)
- - [Example commands](#example-commands)
- - [Notes](#notes)
+- [TwitchDownloaderCLI](#twitchdownloadercli)
+ - [Arguments for mode videodownload](#arguments-for-mode-videodownload)
+ - [Arguments for mode clipdownload](#arguments-for-mode-clipdownload)
+ - [Arguments for mode chatdownload](#arguments-for-mode-chatdownload)
+ - [Arguments for mode chatupdate](#arguments-for-mode-chatupdate)
+ - [Arguments for mode chatrender](#arguments-for-mode-chatrender)
+ - [Arguments for mode ffmpeg](#arguments-for-mode-ffmpeg)
+ - [Arguments for mode cache](#arguments-for-mode-cache)
+ - [Example Commands](#example-commands)
+ - [Additional Notes](#additional-notes)
---
## Arguments for mode videodownload
-Downloads a stream VOD from Twitch
+Downloads a stream VOD or highlight from Twitch
-**-u/-\-id (REQUIRED)**
+**-u / --id (REQUIRED)**
The ID of the VOD to download, currently only accepts Integer IDs and will accept URLs in the future.
-**-o/-\-output (REQUIRED)**
+**-o / --output (REQUIRED)**
File the program will output to.
-**-q/-\-quality**
+**-q / --quality**
The quality the program will attempt to download, for example "1080p60", if not found will download highest quality stream.
-**-b/-\-beginning**
+**-b / --beginning**
Time in seconds to crop beginning. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds.
-**-e/-\-ending**
+**-e / --ending**
Time in seconds to crop ending. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second.
Extra example, if I wanted only seconds 3-6 in a 10 second stream I would do `-b 3 -e 6`
-**-t/-\-threads**
+**-t / --threads**
(Default: 10) Number of download threads.
-**-\-oauth**
-OAuth access token to download subscriber only VODs. **DO NOT SHARE THIS WITH ANYONE.**
+**--oauth**
+OAuth access token to download subscriber only VODs. **DO NOT SHARE YOUR OUATH WITH ANYONE.**
-**-\-ffmpeg-path**
+**--ffmpeg-path**
Path to ffmpeg executable.
-**-\-temp-path**
+**--temp-path**
Path to temporary folder for cache.
## Arguments for mode clipdownload
-Downloads a clip from Twitch
+Downloads a clip from Twitch
-**-u/-\-id (REQUIRED)**
+**-u / --id (REQUIRED)**
The ID of the Clip to download, currently only accepts the string identifier and will accept URLs in the future.
-**-o/-\-output (REQUIRED)**
+**-o / --output (REQUIRED)**
File the program will output to.
-**-q/-\-quality**
+**-q / --quality**
The quality the program will attempt to download, for example "1080p60", if not found will download highest quality video.
## Arguments for mode chatdownload
-Downloads the chat from a VOD or clip
+Downloads the chat of a VOD, highlight, or clip
-**-u/-\-id (REQUIRED)**
+**-u / --id (REQUIRED)**
The ID of the VOD or clip to download. Does not currently accept URLs.
-**-o/-\-output (REQUIRED)**
-File the program will output to. File extension will be used to determine download type. Valid extensions are `json`, `html`, and `txt`.
+**-o / --output (REQUIRED)**
+File the program will output to. File extension will be used to determine download type. Valid extensions are: `json`, `html`, and `txt`.
-**-b/-\-beginning**
+**-b / --beginning**
Time in seconds to crop beginning. For example if I had a 10 second stream but only wanted the last 7 seconds of it I would use `-b 3` to skip the first 3 seconds.
-**-e/-\-ending**
+**-e / --ending**
Time in seconds to crop ending. For example if I had a 10 second stream but only wanted the first 4 seconds of it I would use `-e 4` to end on the 4th second.
-**-E/-\-embed-images**
+**-E / --embed-images**
(Default: false) Embed first party emotes, badges, and cheermotes into the download file for offline rendering. Useful for archival purposes, file size will be larger.
-**-\-bttv**
+**--bttv**
(Default: true) BTTV emote embedding. Requires `-E / --embed-images`.
-**-\-ffz**
+**--ffz**
(Default: true) FFZ emote embedding. Requires `-E / --embed-images`.
-**-\-stv**
+**--stv**
(Default: true) 7TV emote embedding. Requires `-E / --embed-images`.
-**-\-timestamp**
-(Default: false) Enable timestamps
+**--timestamp-format**
+(Default: Relative) Sets the timestamp format for .txt chat logs. Valid values are: `Utc`, `Relative`, and `None`.
-**-\-timestamp-format**
-(Default: Relative) Sets the timestamp format for .txt chat logs. Valid values are Utc, Relative, and None.
-
-**-\-chat-connections**
+**--chat-connections**
(Default: 4) The number of parallel downloads for chat.
+## Arguments for mode chatupdate
+Updates the embeded emotes, badges, bits, and crops of a chat download and/or converts a JSON chat to another format
+
+**-i / --input (REQUIRED)**
+Path to input file. Valid extensions are: `json`.
+
+**-o / --output (REQUIRED)**
+Path to output file. File extension will be used to determine new chat type. Valid extensions are: `json`, `html`, and `txt`.
+
+**-E / --embed-missing**
+(Default: false) Embed missing emotes, badges, and cheermotes. Already embedded images will be untouched.
+
+**-R / --replace-embeds**
+(Default: false) Replace all embedded emotes, badges, and cheermotes in the file. All embedded data will be overwritten!
+
+**b / --beginning**
+(Default: -1) New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop.
+
+**-e / --ending**
+(Default: -1) New time in seconds for chat beginning. Comments may be added but not removed. -1 = No crop.
+
+**--bttv**
+(Default: true) Enable embedding BTTV emotes.
+
+**--ffz**
+(Default: true) Enable embedding FFZ emotes.
+
+**--stv**
+(Default: true) Enable embedding 7TV emotes.
+
+**--timestamp-format**
+(Default: Relative) Sets the timestamp format for .txt chat logs. Valid values are: `Utc`, `Relative`, and `None`.
+
+**--temp-path**
+Path to temporary folder for cache.
+
+
## Arguments for mode chatrender
-Renders a chat JSON as a video
+Renders a chat JSON as a video
-**-i/-\-input (REQUIRED)**
+**-i / --input (REQUIRED)**
Path to JSON chat file input.
-**-o/-\-output (REQUIRED)**
+**-o / --output (REQUIRED)**
File the program will output to.
-**-\-background-color**
+**--background-color**
(Default: #111111) Color of background in HEX string format.
-**-\-message-color**
+**--message-color**
(Default: #ffffff) Color of messages in HEX string format.
-**-w/-\-chat-width**
+**-w / --chat-width**
(Default: 350) Width of chat render.
-**-h/-\-chat-height**
+**-h / --chat-height**
(Default: 600) Height of chat render.
-**-\-bttv**
+**--bttv**
(Default: true) Enable BTTV emotes.
-**-\-ffz**
+**--ffz**
(Default: true) Enable FFZ emotes.
-**-\-stv**
+**--stv**
(Default: true) Enable 7TV emotes.
-**-\-sub-messages**
-(Default: true) Enable sub/re-sub messages.
+**--sub-messages**
+(Default: true) Enable sub / re-sub messages.
-**-\-badges**
+**--badges**
(Default: true) Enable chat badges.
-**-\-outline**
+**--outline**
(Default: false) Enable outline around chat messages.
-**-\-outline-size**
+**--outline-size**
(Default: 4) Size of outline if outline is enabled.
-**-f/-\-font**
+**-f / --font**
(Default: Inter Embedded) Font to use.
-**-\-font-size**
+**--font-size**
(Default: 12) Font size.
-**-\-message-fontstyle**
+**--message-fontstyle**
(Default: normal) Font style of message. Valid values are **normal**, **bold**, and **italic**.
-**-\-username-fontstyle**
+**--username-fontstyle**
(Default: bold) Font style of username. Valid values are **normal**, **bold**, and **italic**.
-**-\-timestamp**
+**--timestamp**
(Default: false) Enables timestamps to left of messages, similar to VOD chat on Twitch.
-**-\-generate-mask**
+**--generate-mask**
(Default: false) Generates a mask file of the chat in addition to the rendered chat.
-**-\-framerate**
+**--framerate**
(Default: 30) Framerate of the render.
-**-\-update-rate**
+**--update-rate**
(Default: 0.2) Time in seconds to update chat render output.
-**-\-input-args**
-(Default: -framerate {fps} -f rawvideo -analyzeduration {max_int} -probesize {max_int} -pix_fmt bgra -video_size {width}x{height} -i -) Input (pass1) arguments for ffmpeg chat render.
+**--input-args**
+(Default: -framerate {fps} -f rawvideo -analyzeduration {max_int} -probesize {max_int} -pix_fmt bgra -video_size {width}x{height} -i -) Input arguments for ffmpeg chat render.
-**-\-output-args**
-(Default: -c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{save_path}") Output (pass2) arguments for ffmpeg chat render.
+**--output-args**
+(Default: -c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{save_path}") Output arguments for ffmpeg chat render.
-**-\-ignore-users**
+**--ignore-users**
(Default: ) List of usernames to ignore when rendering, separated by commas.
-**-\-badge-filter**
+**--badge-filter**
(Default: 0) Bitmask of types of Chat Badges to filter out. Add the numbers of the types of badges you want to filter. For example, to filter out Moderator and Broadcaster badges only enter the value of 6.
-Other = `1`,
-Broadcaster = `2`,
-Moderator = `4`,
-VIP = `8`,
-Subscriber = `16`,
-Predictions = `32`,
-NoAudioVisual = `64`,
-PrimeGaming = `128`
+Other = `1`, Broadcaster = `2`, Moderator = `4`, VIP = `8`, Subscriber = `16`, Predictions = `32`, NoAudioVisual = `64`, PrimeGaming = `128`
-**-\-offline**
-Render completely offline, using only resources embedded emotes, badges, and bits in the input json.
+**--offline**
+Render completely offline using only embedded emotes, badges, and bits from the input json.
-**-\-ffmpeg-path**
+**--ffmpeg-path**
Path to ffmpeg executable.
-**-\-temp-path**
-Path to temporary folder for cache.
-
-
-## Arguments for mode chatupdate
-
-**-i/-\-input (REQUIRED)**
-Path to input file. Valid extensions are json
-
-**-o/-\-output (REQUIRED)**
-Path to output file. Extension should match the input.
-
-**-E/-\-embed-missing**
-(Default: true) Embed missing emotes, badges, and bits. Already embedded images will be untouched.
-
-**-U/-\-update-old**
-(Default: false) Update old emotes, badges, and bits to the current. All embedded images will be overwritten!
-
-**-\-bttv**
-(Default: true) Enable embedding BTTV emotes.
-
-**-\-ffz**
-(Default: true) Enable embedding FFZ emotes.
-
-**-\-stv**
-(Default: true) Enable embedding 7TV emotes.
-
-**-\-temp-path**
+**--temp-path**
Path to temporary folder for cache.
## Arguments for mode ffmpeg
-Manage standalone ffmpeg
+Manage standalone ffmpeg
-**-d/-\-download**
+**-d / --download**
(Default: false) Downloads ffmpeg as a standalone file.
## Arguments for mode cache
- Manage the working cache.
+Manage the working cache.
-**-c/-\-clear**
+**-c / --clear**
(Default: false) Clears the default cache folder.
-**-\-force-clear**
+**--force-clear**
(Default: false) Clears the default cache folder, bypassing the confirmation prompt.
---
## Example Commands
-Download a VOD
+Examples of typical use cases
+
+Download a VOD with defaults
TwitchDownloaderCLI videodownload --id 612942303 -o video.mp4
-Download a Clip
+
+Download a Clip with defaults
TwitchDownloaderCLI clipdownload --id NurturingCalmHamburgerVoHiYo -o clip.mp4
-Download a Chat (plain text with timestamps)
- TwitchDownloaderCLI chatdownload --id 612942303 --timestamp-format Relative -o chat.txt
-Download a Chat (JSON with embeded emotes from Twitch and Bttv)
+Download a Chat JSON with embeded emotes/badges from Twitch and emotes from Bttv
TwitchDownloaderCLI chatdownload --id 612942303 --embed-images --bttv=true --ffz=false --stv=false -o chat.json
+
+Download a Chat as plain text with timestamps
+
+ TwitchDownloaderCLI chatdownload --id 612942303 --timestamp-format Relative -o chat.txt
+
+Add embeds to a chat file that was downloaded without embeds
+
+ TwitchDownloaderCLI chatupdate -i chat.json -o chat_embedded.json --embed-missing
+
+Convert a JSON chat file to HTML
+
+ TwitchDownloaderCLI chatupdate -i chat.json -o chat.html
+
Render a chat with defaults
TwitchDownloaderCLI chatrender -i chat.json -o chat.mp4
-Render a chat with different heights and values
+
+Render a chat with custom video settings and message outlines
TwitchDownloaderCLI chatrender -i chat.json -h 1440 -w 720 --framerate 60 --outline -o chat.mp4
+
Render a chat with custom ffmpeg arguments
TwitchDownloaderCLI chatrender -i chat.json --output-args='-c:v libx264 -preset veryfast -crf 18 -pix_fmt yuv420p "{save_path}"' -o chat.mp4
---
-## Notes
-Due to some limitations, default true boolean flags must be assigned: `--default-true-flag=false`. Default false boolean flags must still be raised normally: `--default-false-flag`
\ No newline at end of file
+## Additional Notes
+
+String arguments, such as output file, that contain spaces should be wrapped in double quotes " .
+
+Default true boolean flags must be assigned: `--default-true-flag=false`. Default false boolean flags should still be raised normally: `--default-false-flag`
+
+For Linux users, ensure both `fontconfig` and `libfontconfig1` are installed. `apt-get install fontconfig libfontconfig1` on Ubuntu.
\ No newline at end of file
diff --git a/TwitchDownloaderCLI/Tools/PreParseArgs.cs b/TwitchDownloaderCLI/Tools/PreParseArgs.cs
index 4ecc3b5d..ced87d9d 100644
--- a/TwitchDownloaderCLI/Tools/PreParseArgs.cs
+++ b/TwitchDownloaderCLI/Tools/PreParseArgs.cs
@@ -14,50 +14,65 @@ internal static string[] Process(string[] args)
}
///
- /// Converts an argument array that uses any legacy syntax to the current syntax
+ /// Converts an argument [] using any legacy syntax to the current syntax and prints corresponding warning messages
///
///
- /// The same array but using current syntax instead
+ /// An argument [] using current syntaxes that represent the intentions of the legacy syntax
internal static string[] ConvertFromOldSyntax(string[] args, string processFileName)
{
- int argsLength = args.Length;
List processedArgs = args.ToList();
if (args.Any(x => x.Equals("--embed-emotes")))
{
- Console.WriteLine("[INFO] The program has switched from --embed-emotes to --embed-images OR -E, consider using those instead. Run \'{0} help\' for more information.", processFileName);
- for (int i = 0; i < argsLength; i++)
+ Console.WriteLine("[INFO] The program has switched from --embed-emotes to -E / --embed-images, consider using those instead. Run \'{0} help\' for more information.", processFileName);
+ processedArgs = ConvertEmbedEmoteSyntax(processedArgs);
+ }
+
+ if (args.Any(x => x is "-m" or "--mode"))
+ {
+ Console.WriteLine("[INFO] The program has switched from --mode to verbs (like \'git \'), consider using verbs instead. Run \'{0} help\' for more information.", processFileName);
+ processedArgs = ConvertModeSyntax(processedArgs);
+ }
+
+ return processedArgs.ToArray();
+ }
+
+ internal static List ConvertEmbedEmoteSyntax(List args)
+ {
+ int argsLength = args.Count;
+
+ for (int i = 0; i < argsLength; i++)
+ {
+ if (args[i].Equals("--embed-emotes"))
{
- if (processedArgs[i].Equals("--embed-emotes"))
- {
- processedArgs[i] = "-E";
- break;
- }
+ args[i] = "-E";
+ break;
}
}
- // This must always be performed last
- if (args.Any(x => x.Equals("-m") || x.Equals("--mode")))
+ return args;
+ }
+
+ internal static List ConvertModeSyntax(List args)
+ {
+ int argsLength = args.Count;
+ string[] processedArgs = new string[argsLength - 1];
+
+ int j = 1;
+ for (int i = 0; i < argsLength; i++)
{
- Console.WriteLine("[INFO] The program has switched from --mode to verbs (like \'git \'), consider using verbs instead. Run \'{0} help\' for more information.", processFileName);
- int j = 1;
- for (int i = 0; i < argsLength; i++)
+ if (args[i].Equals("-m") || args[i].Equals("--mode"))
{
- if (processedArgs[i].Equals("-m") || processedArgs[i].Equals("--mode"))
- {
- // Copy the runmode to the verb position
- processedArgs[0] = processedArgs[i + 1];
- i++;
- continue;
- }
- processedArgs[j] = processedArgs[i];
- j++;
+ // Copy the runmode to the verb position
+ processedArgs[0] = args[i + 1];
+ i++;
+ continue;
}
- // Remove last element as it will be a duplicate of second last element
- processedArgs.RemoveAt(processedArgs.Count - 1);
+ processedArgs[j] = args[i];
+ j++;
}
- return processedArgs.ToArray();
+ return processedArgs.ToList();
}
}
}
diff --git a/TwitchDownloaderCLI/Tools/ProgressHandler.cs b/TwitchDownloaderCLI/Tools/ProgressHandler.cs
index aedcc9f9..eb0aeb8e 100644
--- a/TwitchDownloaderCLI/Tools/ProgressHandler.cs
+++ b/TwitchDownloaderCLI/Tools/ProgressHandler.cs
@@ -5,38 +5,51 @@ namespace TwitchDownloaderCLI.Tools
{
internal class ProgressHandler
{
- private static string previousStatus = string.Empty;
- private static bool was_last_message_percent = false;
+ private static string previousMessage = "";
+ private static bool previousMessageWasStatusInfo = false;
internal static void Progress_ProgressChanged(object sender, ProgressReport e)
{
- if (e.reportType == ReportType.Message)
+ if (e.ReportType == ReportType.Status)
{
- if (was_last_message_percent)
+ if (previousMessageWasStatusInfo)
{
- was_last_message_percent = false;
+ previousMessageWasStatusInfo = false;
Console.WriteLine();
}
- string currentStatus = "[STATUS] - " + e.data;
- if (currentStatus != previousStatus)
+
+ string currentStatus = "[STATUS] - " + e.Data;
+ if (currentStatus != previousMessage)
{
- previousStatus = currentStatus;
+ previousMessage = currentStatus;
Console.WriteLine(currentStatus);
}
}
- else if (e.reportType == ReportType.Log)
+ else if (e.ReportType == ReportType.StatusInfo)
{
- if (was_last_message_percent)
+ string currentStatus = "\r[STATUS] - " + e.Data;
+ if (currentStatus != previousMessage)
{
- was_last_message_percent = false;
- Console.WriteLine();
+ previousMessageWasStatusInfo = true;
+
+ // This ensures the previous message is fully overwritten
+ currentStatus = currentStatus.PadRight(previousMessage.Length);
+
+ previousMessage = currentStatus.TrimEnd();
+ Console.Write(currentStatus);
}
- Console.WriteLine("[LOG] - " + e.data);
}
- else if (e.reportType == ReportType.MessageInfo)
+ else if (e.ReportType == ReportType.Log)
{
- Console.Write("\r[STATUS] - " + e.data);
- was_last_message_percent = true;
+ if (previousMessageWasStatusInfo)
+ {
+ previousMessageWasStatusInfo = false;
+ Console.WriteLine();
+ }
+
+ string currentStatus = "[LOG] - " + e.Data;
+ previousMessage = currentStatus;
+ Console.WriteLine(currentStatus);
}
}
}
diff --git a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
index a078acb4..d1162425 100644
--- a/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
+++ b/TwitchDownloaderCLI/TwitchDownloaderCLI.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/TwitchDownloaderCore/ChatDownloader.cs b/TwitchDownloaderCore/ChatDownloader.cs
index 0aced38a..266fd442 100644
--- a/TwitchDownloaderCore/ChatDownloader.cs
+++ b/TwitchDownloaderCore/ChatDownloader.cs
@@ -7,24 +7,27 @@
using System.Text;
using System.Threading;
using System.Threading.Tasks;
-using System.Web;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.TwitchObjects;
using TwitchDownloaderCore.TwitchObjects.Gql;
namespace TwitchDownloaderCore
{
- public class ChatDownloader
+ public sealed class ChatDownloader
{
- ChatDownloadOptions downloadOptions;
- enum DownloadType { Clip, Video }
+ private readonly ChatDownloadOptions downloadOptions;
+ private enum DownloadType { Clip, Video }
public ChatDownloader(ChatDownloadOptions DownloadOptions)
{
downloadOptions = DownloadOptions;
+ downloadOptions.TempFolder = Path.Combine(
+ string.IsNullOrWhiteSpace(downloadOptions.TempFolder) ? Path.GetTempPath() : downloadOptions.TempFolder,
+ "TwitchDownloader");
}
- private async Task DownloadSection(IProgress progress, CancellationToken cancellationToken, double videoStart, double videoEnd, string videoId, SortedSet comments, object commentLock)
+ private static async Task DownloadSection(double videoStart, double videoEnd, string videoId, SortedSet comments, object commentLock, IProgress progress, CancellationToken cancellationToken)
{
using (WebClient client = new WebClient())
{
@@ -81,7 +84,7 @@ private async Task DownloadSection(IProgress progress, Cancellat
cursor = commentResponse.data.video.comments.edges.Last().cursor;
int percent = (int)Math.Floor((latestMessage - videoStart) / videoDuration * 100);
- progress.Report(new ProgressReport() { reportType = ReportType.Percent, data = percent });
+ progress.Report(new ProgressReport() { ReportType = ReportType.Percent, Data = percent });
cancellationToken.ThrowIfCancellationRequested();
@@ -92,7 +95,7 @@ private async Task DownloadSection(IProgress progress, Cancellat
}
}
- private List ConvertComments(CommentVideo video)
+ private static List ConvertComments(CommentVideo video)
{
List returnList = new List();
@@ -167,13 +170,17 @@ private List ConvertComments(CommentVideo video)
public async Task DownloadAsync(IProgress progress, CancellationToken cancellationToken)
{
- DownloadType downloadType = downloadOptions.Id.All(x => char.IsDigit(x)) ? DownloadType.Video : DownloadType.Clip;
+ if (string.IsNullOrWhiteSpace(downloadOptions.Id))
+ {
+ throw new NullReferenceException("Null or empty video/clip ID");
+ }
+ DownloadType downloadType = downloadOptions.Id.All(char.IsDigit) ? DownloadType.Video : DownloadType.Clip;
List comments = new List();
ChatRoot chatRoot = new ChatRoot() { FileInfo = new ChatRootInfo() { Version = new ChatRootVersion(1, 1, 0) }, streamer = new Streamer(), video = new Video(), comments = comments };
- string videoId = "";
- string videoTitle = "";
+ string videoId = downloadOptions.Id;
+ string videoTitle;
DateTime videoCreatedAt;
double videoStart = 0.0;
double videoEnd = 0.0;
@@ -183,8 +190,12 @@ public async Task DownloadAsync(IProgress progress, Cancellation
if (downloadType == DownloadType.Video)
{
- videoId = downloadOptions.Id;
GqlVideoResponse taskVideoInfo = await TwitchHelper.GetVideoInfo(int.Parse(videoId));
+ if (taskVideoInfo.data.video == null)
+ {
+ throw new NullReferenceException("Invalid VOD, deleted/expired VOD possibly?");
+ }
+
chatRoot.streamer.name = taskVideoInfo.data.video.owner.displayName;
chatRoot.streamer.id = int.Parse(taskVideoInfo.data.video.owner.id);
videoTitle = taskVideoInfo.data.video.title;
@@ -195,12 +206,12 @@ public async Task DownloadAsync(IProgress progress, Cancellation
}
else
{
- GqlClipResponse taskClipInfo = await TwitchHelper.GetClipInfo(downloadOptions.Id);
-
+ GqlClipResponse taskClipInfo = await TwitchHelper.GetClipInfo(videoId);
if (taskClipInfo.data.clip.video == null || taskClipInfo.data.clip.videoOffsetSeconds == null)
- throw new Exception("Invalid VOD for clip, deleted/expired VOD possibly?");
+ {
+ throw new NullReferenceException("Invalid VOD for clip, deleted/expired VOD possibly?");
+ }
- videoId = taskClipInfo.data.clip.video.id;
downloadOptions.CropBeginning = true;
downloadOptions.CropBeginningTime = (int)taskClipInfo.data.clip.videoOffsetSeconds;
downloadOptions.CropEnding = true;
@@ -235,13 +246,13 @@ public async Task DownloadAsync(IProgress progress, Cancellation
percentages.Add(0);
var taskProgress = new Progress(progressReport =>
{
- if (progressReport.reportType != ReportType.Percent)
+ if (progressReport.ReportType != ReportType.Percent)
{
progress.Report(progressReport);
}
else
{
- int percent = (int)(progressReport.data);
+ int percent = (int)progressReport.Data;
if (percent > 100)
{
percent = 100;
@@ -251,15 +262,17 @@ public async Task DownloadAsync(IProgress progress, Cancellation
percent = 0;
for (int j = 0; j < connectionCount; j++)
+ {
percent += percentages[j];
- percent = percent / connectionCount;
+ }
+ percent /= connectionCount;
- progress.Report(new ProgressReport() { reportType = ReportType.MessageInfo, data = $"Downloading {percent}%" });
- progress.Report(new ProgressReport() { reportType = ReportType.Percent, data = percent });
+ progress.Report(new ProgressReport() { ReportType = ReportType.StatusInfo, Data = $"Downloading {percent}%" });
+ progress.Report(new ProgressReport() { ReportType = ReportType.Percent, Data = percent });
}
});
double start = videoStart + chunk * i;
- tasks.Add(DownloadSection(taskProgress, cancellationToken, start, start + chunk, videoId, commentsSet, commentLock));
+ tasks.Add(DownloadSection(start, start + chunk, videoId, commentsSet, commentLock, taskProgress, cancellationToken));
}
await Task.WhenAll(tasks);
@@ -267,25 +280,17 @@ public async Task DownloadAsync(IProgress progress, Cancellation
comments = commentsSet.DistinctBy(x => x._id).ToList();
chatRoot.comments = comments;
- if (downloadOptions.EmbedData && (downloadOptions.DownloadFormat == DownloadFormat.Json || downloadOptions.DownloadFormat == DownloadFormat.Html))
+ if (downloadOptions.EmbedData && (downloadOptions.DownloadFormat is ChatFormat.Json or ChatFormat.Html))
{
- progress.Report(new ProgressReport() { reportType = ReportType.Message, data = "Downloading + Embedding Images" });
+ progress.Report(new ProgressReport() { ReportType = ReportType.Status, Data = "Downloading + Embedding Images" });
chatRoot.embeddedData = new EmbeddedData();
- List firstPartyReturnList = new List();
- List thirdPartyReturnList = new List();
- List badgesReturnList = new List();
- List bitsReturnList = new List();
-
- string cacheFolder = Path.Combine(Path.GetTempPath(), "TwitchDownloader", "cache");
- List thirdPartyEmotes = new List();
- List firstPartyEmotes = new List();
- List twitchBadges = new List();
- List twitchBits = new List();
-
- thirdPartyEmotes = await TwitchHelper.GetThirdPartyEmotes(chatRoot.streamer.id, cacheFolder, bttv: downloadOptions.BttvEmotes, ffz: downloadOptions.FfzEmotes, stv: downloadOptions.StvEmotes);
- firstPartyEmotes = await TwitchHelper.GetEmotes(comments, cacheFolder);
- twitchBadges = await TwitchHelper.GetChatBadges(chatRoot.streamer.id, cacheFolder);
- twitchBits = await TwitchHelper.GetBits(cacheFolder, chatRoot.streamer.id.ToString());
+
+ // This is the exact same process as in ChatUpdater.cs but not in a task oriented manner
+ // TODO: Combine this with ChatUpdater in a different file
+ List thirdPartyEmotes = await TwitchHelper.GetThirdPartyEmotes(chatRoot.streamer.id, downloadOptions.TempFolder, bttv: downloadOptions.BttvEmotes, ffz: downloadOptions.FfzEmotes, stv: downloadOptions.StvEmotes);
+ List firstPartyEmotes = await TwitchHelper.GetEmotes(comments, downloadOptions.TempFolder);
+ List twitchBadges = await TwitchHelper.GetChatBadges(chatRoot.streamer.id, downloadOptions.TempFolder);
+ List twitchBits = await TwitchHelper.GetBits(downloadOptions.TempFolder, chatRoot.streamer.id.ToString());
foreach (TwitchEmote emote in thirdPartyEmotes)
{
@@ -296,7 +301,7 @@ public async Task DownloadAsync(IProgress progress, Cancellation
newEmote.name = emote.Name;
newEmote.width = emote.Width / emote.ImageScale;
newEmote.height = emote.Height / emote.ImageScale;
- thirdPartyReturnList.Add(newEmote);
+ chatRoot.embeddedData.thirdParty.Add(newEmote);
}
foreach (TwitchEmote emote in firstPartyEmotes)
{
@@ -306,14 +311,14 @@ public async Task DownloadAsync(IProgress progress, Cancellation
newEmote.data = emote.ImageData;
newEmote.width = emote.Width / emote.ImageScale;
newEmote.height = emote.Height / emote.ImageScale;
- firstPartyReturnList.Add(newEmote);
+ chatRoot.embeddedData.firstParty.Add(newEmote);
}
foreach (ChatBadge badge in twitchBadges)
{
EmbedChatBadge newBadge = new EmbedChatBadge();
newBadge.name = badge.Name;
newBadge.versions = badge.VersionsData;
- badgesReturnList.Add(newBadge);
+ chatRoot.embeddedData.twitchBadges.Add(newBadge);
}
foreach (CheerEmote bit in twitchBits)
{
@@ -331,193 +336,24 @@ public async Task DownloadAsync(IProgress progress, Cancellation
newEmote.height = emotePair.Value.Height / emotePair.Value.ImageScale;
newBit.tierList.Add(emotePair.Key, newEmote);
}
- bitsReturnList.Add(newBit);
+ chatRoot.embeddedData.twitchBits.Add(newBit);
}
-
- chatRoot.embeddedData.thirdParty = thirdPartyReturnList;
- chatRoot.embeddedData.firstParty = firstPartyReturnList;
- chatRoot.embeddedData.twitchBadges = badgesReturnList;
- chatRoot.embeddedData.twitchBits = bitsReturnList;
}
- if (downloadOptions.DownloadFormat == DownloadFormat.Json)
+ switch (downloadOptions.DownloadFormat)
{
- using (TextWriter writer = File.CreateText(downloadOptions.Filename))
- {
- var serializer = new JsonSerializer();
- serializer.Serialize(writer, chatRoot);
- }
- }
- else if (downloadOptions.DownloadFormat == DownloadFormat.Text)
- {
- using (StreamWriter sw = new StreamWriter(downloadOptions.Filename))
- {
- foreach (var comment in chatRoot.comments)
- {
- string username = comment.commenter.display_name;
- string message = comment.message.body;
- if (downloadOptions.TimeFormat == TimestampFormat.Utc)
- {
- string timestamp = comment.created_at.ToString("u").Replace("Z", " UTC");
- sw.WriteLine(String.Format("[{0}] {1}: {2}", timestamp, username, message));
- }
- else if (downloadOptions.TimeFormat == TimestampFormat.Relative)
- {
- TimeSpan time = new TimeSpan(0, 0, (int)comment.content_offset_seconds);
- string timestamp = time.ToString(@"h\:mm\:ss");
- sw.WriteLine(String.Format("[{0}] {1}: {2}", timestamp, username, message));
- }
- else if (downloadOptions.TimeFormat == TimestampFormat.None)
- {
- sw.WriteLine(String.Format("{0}: {1}", username, message));
- }
- }
-
- sw.Flush();
- sw.Close();
- }
+ case ChatFormat.Json:
+ ChatJson.Serialize(downloadOptions.Filename, chatRoot);
+ break;
+ case ChatFormat.Html:
+ await ChatHtml.SerializeAsync(downloadOptions.Filename, chatRoot, downloadOptions.EmbedData);
+ break;
+ case ChatFormat.Text:
+ await ChatText.SerializeAsync(downloadOptions.Filename, chatRoot, downloadOptions.TimeFormat);
+ break;
+ default:
+ throw new NotImplementedException("Requested output chat format is not implemented");
}
- else if (downloadOptions.DownloadFormat == DownloadFormat.Html)
- {
- Dictionary thirdEmoteData = null;
- EmoteResponse emotes = await TwitchHelper.GetThirdPartyEmoteData(chatRoot.streamer.id.ToString(), true, true, true);
- thirdEmoteData = new Dictionary();
- List itemList = new List();
- itemList.AddRange(emotes.BTTV);
- itemList.AddRange(emotes.FFZ);
- itemList.AddRange(emotes.STV);
-
- foreach (var item in itemList)
- {
- if (!thirdEmoteData.ContainsKey(item.Code))
- {
- if (downloadOptions.EmbedData)
- {
- EmbedEmoteData embedEmoteData = chatRoot.embeddedData.thirdParty.FirstOrDefault(x => x.id == item.Id);
- if (embedEmoteData != null)
- {
- embedEmoteData.url = item.ImageUrl.Replace("[scale]", "1");
- thirdEmoteData[item.Code] = embedEmoteData;
- }
- }
- else
- {
- EmbedEmoteData embedEmoteData = new EmbedEmoteData();
- embedEmoteData.url = item.ImageUrl.Replace("[scale]", "1");
- thirdEmoteData[item.Code] = embedEmoteData;
- }
- }
- }
-
- List templateStrings = new List(Properties.Resources.template.Split('\n'));
- StringBuilder finalString = new StringBuilder();
-
- for (int i = 0; i < templateStrings.Count; i++)
- {
- switch (templateStrings[i].TrimEnd('\r', '\n'))
- {
- case "":
- finalString.AppendLine(HttpUtility.HtmlEncode(Path.GetFileNameWithoutExtension(downloadOptions.Filename)));
- break;
- case "/* [CUSTOM CSS] */":
- if (downloadOptions.EmbedData)
- {
- foreach (var emote in chatRoot.embeddedData.firstParty)
- {
- finalString.AppendLine(".first-" + emote.id + " { content:url(\"data:image/png;base64, " + Convert.ToBase64String(emote.data) + "\"); }");
- }
- foreach (var emote in chatRoot.embeddedData.thirdParty)
- {
- finalString.AppendLine(".third-" + emote.id + " { content:url(\"data:image/png;base64, " + Convert.ToBase64String(emote.data) + "\"); }");
- }
- }
- break;
- case "":
- foreach (Comment comment in chatRoot.comments)
- {
- TimeSpan time = new TimeSpan(0, 0, (int)comment.content_offset_seconds);
- string timestamp = time.ToString(@"h\:mm\:ss");
- finalString.Append($"\n");
- }
- break;
- default:
- finalString.AppendLine(templateStrings[i].TrimEnd('\r', '\n'));
- break;
- }
- }
-
- File.WriteAllText(downloadOptions.Filename, finalString.ToString(), Encoding.Unicode);
- }
-
- chatRoot = null;
- GC.Collect();
- }
-
- private string GetMessageHtml(bool embedEmotes, Dictionary thirdEmoteData, ChatRoot chatRoot, Comment comment)
- {
- StringBuilder message = new StringBuilder();
-
- if (comment.message.fragments == null)
- {
- comment.message.fragments = new List();
- comment.message.fragments.Add(new Fragment() { text = comment.message.body });
- }
-
- foreach (var fragment in comment.message.fragments)
- {
- if (fragment.emoticon == null)
- {
- List wordList = new List(fragment.text.Split(' '));
-
- foreach (var word in wordList)
- {
- if (thirdEmoteData.ContainsKey(word))
- {
- if (embedEmotes)
- {
- message.Append($"{word}
");
- }
- else
- {
- message.Append($"{word}
");
- }
- }
- else if (word != "")
- message.Append(HttpUtility.HtmlEncode(word) + " ");
- }
- }
- else
- {
- if (embedEmotes && chatRoot.embeddedData.firstParty.Any(x => x.id == fragment.emoticon.emoticon_id))
- {
- message.Append($"{fragment.text}
");
- }
- else
- {
- message.Append($"{fragment.text}
");
- }
- }
- }
- return message.ToString();
- }
- }
-
- internal class SortedCommentComparer : IComparer
- {
- // Modified from double.CompareTo
- public int Compare(Comment x, Comment y)
- {
- double m_value = x.content_offset_seconds;
- double value = y.content_offset_seconds;
- if (m_value < value) return -1;
- if (m_value > value) return 1;
- if (m_value == value) return 1;
-
- // At least one of the values is NaN.
- if (double.IsNaN(m_value))
- return double.IsNaN(value) ? 0 : -1;
- else
- return 1;
}
}
}
\ No newline at end of file
diff --git a/TwitchDownloaderCore/ChatRenderer.cs b/TwitchDownloaderCore/ChatRenderer.cs
index f7e5539e..e4497950 100644
--- a/TwitchDownloaderCore/ChatRenderer.cs
+++ b/TwitchDownloaderCore/ChatRenderer.cs
@@ -8,22 +8,25 @@
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Text.Json;
+using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using TwitchDownloaderCore.Options;
+using TwitchDownloaderCore.Tools;
using TwitchDownloaderCore.TwitchObjects;
namespace TwitchDownloaderCore
{
- public class ChatRenderer
+ public sealed class ChatRenderer
{
static readonly string[] defaultColors = new string[] { "#FF0000", "#0000FF", "#00FF00", "#B22222", "#FF7F50", "#9ACD32", "#FF4500", "#2E8B57", "#DAA520", "#D2691E", "#5F9EA0", "#1E90FF", "#FF69B4", "#8A2BE2", "#00FF7F" };
- static readonly string emojiRegex = @"(?:[#*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])?))?))";
-
+ static readonly Regex rtlRegex = new("[\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC]");
+ 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);
public ChatRoot chatRoot { get; set; } = new ChatRoot();
- private readonly ChatRenderOptions renderOptions = new ChatRenderOptions();
+
+ private readonly ChatRenderOptions renderOptions;
private List badgeList = new List();
private List emoteList = new List();
private List emoteThirdList = new List();
@@ -38,17 +41,20 @@ public class ChatRenderer
public ChatRenderer(ChatRenderOptions chatRenderOptions)
{
renderOptions = chatRenderOptions;
- renderOptions.TempFolder = string.IsNullOrWhiteSpace(renderOptions.TempFolder) ? Path.Combine(Path.GetTempPath(), "TwitchDownloader") : Path.Combine(renderOptions.TempFolder, "TwitchDownloader");
+ renderOptions.TempFolder = Path.Combine(
+ string.IsNullOrWhiteSpace(renderOptions.TempFolder) ? Path.GetTempPath() : renderOptions.TempFolder,
+ "TwitchDownloader");
}
public async Task RenderVideoAsync(IProgress progress, CancellationToken cancellationToken)
{
- progress.Report(new ProgressReport() { reportType = ReportType.Message, data = "Fetching Images" });
- Task> badgeTask = Task.Run(() => TwitchHelper.GetChatBadges(chatRoot.streamer.id, renderOptions.TempFolder, chatRoot.embeddedData, offline: renderOptions.Offline));
- Task> emoteTask = Task.Run(() => TwitchHelper.GetEmotes(chatRoot.comments, renderOptions.TempFolder, chatRoot.embeddedData, offline: renderOptions.Offline));
- Task> emoteThirdTask = Task.Run(() => TwitchHelper.GetThirdPartyEmotes(chatRoot.streamer.id, renderOptions.TempFolder, chatRoot.embeddedData, renderOptions.BttvEmotes, renderOptions.FfzEmotes, renderOptions.StvEmotes, offline: renderOptions.Offline));
- Task> cheerTask = Task.Run(() => TwitchHelper.GetBits(renderOptions.TempFolder, chatRoot.streamer.id.ToString(), chatRoot.embeddedData, offline: renderOptions.Offline));
- Task> emojiTask = Task.Run(() => TwitchHelper.GetTwitterEmojis(renderOptions.TempFolder));
+ progress.Report(new ProgressReport(ReportType.Status, "Fetching Images"));
+
+ Task> badgeTask = TwitchHelper.GetChatBadges(chatRoot.streamer.id, renderOptions.TempFolder, chatRoot.embeddedData, renderOptions.Offline);
+ Task> emoteTask = TwitchHelper.GetEmotes(chatRoot.comments, renderOptions.TempFolder, chatRoot.embeddedData, renderOptions.Offline);
+ Task> emoteThirdTask = TwitchHelper.GetThirdPartyEmotes(chatRoot.streamer.id, renderOptions.TempFolder, chatRoot.embeddedData, renderOptions.BttvEmotes, renderOptions.FfzEmotes, renderOptions.StvEmotes, renderOptions.Offline);
+ Task> cheerTask = TwitchHelper.GetBits(renderOptions.TempFolder, chatRoot.streamer.id.ToString(), chatRoot.embeddedData, renderOptions.Offline);
+ Task> emojiTask = TwitchHelper.GetTwitterEmojis(renderOptions.TempFolder);
await Task.WhenAll(badgeTask, emoteTask, emoteThirdTask, cheerTask, emojiTask);
@@ -58,11 +64,18 @@ public async Task RenderVideoAsync(IProgress progress, Cancellat
cheermotesList = cheerTask.Result;
emojiCache = emojiTask.Result;
- await Task.Run(ScaleImages);
+ // Dispose of the tasks to free up the memory now
+ // TODO: move the tasks to a dedicated function so they are disposed by the scope instead
+ badgeTask.Dispose();
+ emoteTask.Dispose();
+ emoteThirdTask.Dispose();
+ cheerTask.Dispose();
+ emojiTask.Dispose();
+
+ await Task.Run(ScaleImages, cancellationToken);
FloorCommentOffsets(chatRoot.comments);
outlinePaint = new SKPaint() { Style = SKPaintStyle.Stroke, StrokeWidth = (float)(renderOptions.OutlineSize * renderOptions.ReferenceScale), StrokeJoin = SKStrokeJoin.Round, Color = SKColors.Black, IsAntialias = true, IsAutohinted = true, LcdRenderText = true, SubpixelText = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High };
-
nameFont = new SKPaint() { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High };
messageFont = new SKPaint() { LcdRenderText = true, SubpixelText = true, TextSize = (float)renderOptions.FontSize, IsAntialias = true, IsAutohinted = true, HintingLevel = SKPaintHinting.Full, FilterQuality = SKFilterQuality.High, Color = renderOptions.MessageColor };
@@ -77,27 +90,25 @@ public async Task RenderVideoAsync(IProgress progress, Cancellat
messageFont.Typeface = SKTypeface.FromFamilyName(renderOptions.Font, renderOptions.MessageFontStyle);
}
- (int, int) tickValues = GetTotalTicks();
- int totalTicks = tickValues.Item2;
- int startTick = tickValues.Item1;
+ (int startTick, int totalTicks) = GetVideoTicks();
if (File.Exists(renderOptions.OutputFile))
File.Delete(renderOptions.OutputFile);
- if (renderOptions.GenerateMask && File.Exists(renderOptions.OutputFileMask))
- File.Delete(renderOptions.OutputFileMask);
+ if (renderOptions.GenerateMask && File.Exists(renderOptions.MaskFile))
+ File.Delete(renderOptions.MaskFile);
- progress.Report(new ProgressReport() { reportType = ReportType.MessageInfo, data = "Rendering Video 0%" });
- (Process, string) processInfo = GetFfmpegProcess(0, false);
+ progress.Report(new ProgressReport(ReportType.StatusInfo, "Rendering Video: 0%"));
+ (Process ffmpegProcess, string ffmpegSavePath) = GetFfmpegProcess(0, false);
if (renderOptions.GenerateMask)
{
- (Process, string) maskInfo = GetFfmpegProcess(0, true);
- await Task.Run(() => RenderVideoSection(processInfo.Item1, maskInfo.Item1, startTick, startTick + totalTicks, progress), cancellationToken);
+ (Process maskProcess, string maskSavePath) = GetFfmpegProcess(0, true);
+ await Task.Run(() => RenderVideoSection(startTick, startTick + totalTicks, ffmpegProcess, maskProcess, progress), cancellationToken);
}
else
{
- await Task.Run(() => RenderVideoSection(processInfo.Item1, null, startTick, startTick + totalTicks, progress), cancellationToken);
+ await Task.Run(() => RenderVideoSection(startTick, startTick + totalTicks, ffmpegProcess, progress: progress), cancellationToken);
}
}
@@ -112,9 +123,13 @@ private void FloorCommentOffsets(List comments)
foreach (var comment in comments)
{
if (renderOptions.UpdateRate > 1)
+ {
comment.content_offset_seconds = Math.Floor(comment.content_offset_seconds);
+ }
else
- comment.content_offset_seconds = Math.Floor(comment.content_offset_seconds * (1 / renderOptions.UpdateRate)) / (1 / renderOptions.UpdateRate);
+ {
+ comment.content_offset_seconds = Math.Floor(comment.content_offset_seconds / renderOptions.UpdateRate) * renderOptions.UpdateRate;
+ }
}
}
@@ -132,9 +147,9 @@ private static SKTypeface GetInterTypeface(SKFontStyle fontStyle)
}
}
- private void RenderVideoSection(Process ffmpegProcess, Process maskProcess, int startTick, int endTick, IProgress progress = null)
+ private void RenderVideoSection(int startTick, int endTick, Process ffmpegProcess, Process maskProcess = null, IProgress progress = null)
{
- UpdateFrame lastestUpdate = null;
+ UpdateFrame latestUpdate = null;
BinaryWriter ffmpegStream = new BinaryWriter(ffmpegProcess.StandardInput.BaseStream);
BinaryWriter maskStream = null;
if (maskProcess != null)
@@ -143,14 +158,18 @@ private void RenderVideoSection(Process ffmpegProcess, Process maskProcess, int
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
+ // Measure some sample text to determine the text height, cannot assume it is font size
+ SKRect sampleTextBounds = new SKRect();
+ messageFont.MeasureText("abc123", ref sampleTextBounds);
+
for (int currentTick = startTick; currentTick < endTick; currentTick++)
{
if (currentTick % renderOptions.UpdateFrame == 0)
{
- lastestUpdate = GenerateUpdateFrame(currentTick, lastestUpdate);
+ latestUpdate = GenerateUpdateFrame(currentTick, sampleTextBounds.Height, latestUpdate);
}
- using (SKBitmap frame = GetFrameFromTick(currentTick, lastestUpdate))
+ using (SKBitmap frame = GetFrameFromTick(currentTick, sampleTextBounds.Height, latestUpdate))
{
ffmpegStream.Write(frame.Bytes);
@@ -161,19 +180,24 @@ private void RenderVideoSection(Process ffmpegProcess, Process maskProcess, int
}
}
- double percentDouble = (double)(currentTick - startTick) / (double)(endTick - startTick) * 100.0;
- int percentInt = (int)Math.Floor(percentDouble);
if (progress != null)
{
- progress.Report(new ProgressReport() { reportType = ReportType.Percent, data = percentInt });
- int timeLeftInt = (int)Math.Floor(100.0 / percentDouble * stopwatch.Elapsed.TotalSeconds) - (int)stopwatch.Elapsed.TotalSeconds;
+ double percentDouble = (currentTick - startTick) / (double)(endTick - startTick) * 100.0;
+ int percentInt = (int)percentDouble;
+ progress.Report(new ProgressReport(percentInt));
+
+ int timeLeftInt = (int)(100.0 / percentDouble * stopwatch.Elapsed.TotalSeconds) - (int)stopwatch.Elapsed.TotalSeconds;
TimeSpan timeLeft = new TimeSpan(0, 0, timeLeftInt);
- progress.Report(new ProgressReport() { reportType = ReportType.MessageInfo, data = $"Rendering Video {percentInt}% ({timeLeft.ToString(@"h\hm\ms\s")} left)" });
+ TimeSpan timeElapsed = new TimeSpan(0, 0, (int)stopwatch.Elapsed.TotalSeconds);
+ progress.Report(new ProgressReport(ReportType.StatusInfo, $"Rendering Video: {percentInt}% ({timeElapsed.ToString(@"h\hm\ms\s")} Elapsed | {timeLeft.ToString(@"h\hm\ms\s")} Remaining)"));
}
}
- progress.Report(new ProgressReport() { reportType = ReportType.MessageInfo, data = "Rendering Video 100%" });
+
stopwatch.Stop();
- progress.Report(new ProgressReport() { reportType = ReportType.Log, data = $"FINISHED. RENDER TIME: {(int)stopwatch.Elapsed.TotalSeconds}s SPEED: {((endTick - startTick) / renderOptions.Framerate / stopwatch.Elapsed.TotalSeconds).ToString("0.##")}x" });
+ progress?.Report(new ProgressReport(ReportType.StatusInfo, "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"));
+
+ latestUpdate?.Image.Dispose();
ffmpegStream.Dispose();
maskStream?.Dispose();
@@ -204,13 +228,13 @@ private void SetFrameMask(SKBitmap frame)
}
}
- private (Process, string) GetFfmpegProcess(int partNumer, bool isMask)
+ private (Process process, string savePath) GetFfmpegProcess(int partNumber, bool isMask)
{
string savePath;
- if (partNumer == 0)
+ if (partNumber == 0)
{
if (isMask)
- savePath = renderOptions.OutputFileMask;
+ savePath = renderOptions.MaskFile;
else
savePath = renderOptions.OutputFile;
}
@@ -248,9 +272,9 @@ private void SetFrameMask(SKBitmap frame)
return (process, savePath);
}
- private SKBitmap GetFrameFromTick(int currentTick, UpdateFrame currentFrame = null)
+ private SKBitmap GetFrameFromTick(int currentTick, float sampleTextHeight, UpdateFrame currentFrame = null)
{
- currentFrame ??= GenerateUpdateFrame(currentTick);
+ currentFrame ??= GenerateUpdateFrame(currentTick, sampleTextHeight);
SKBitmap frame = DrawAnimatedEmotes(currentFrame.Image, currentFrame.Comments, currentTick);
return frame;
}
@@ -289,7 +313,7 @@ private SKBitmap DrawAnimatedEmotes(SKBitmap updateFrame, List c
return newFrame;
}
- private UpdateFrame GenerateUpdateFrame(int currentTick, UpdateFrame lastestUpdate = null)
+ private UpdateFrame GenerateUpdateFrame(int currentTick, float sampleTextHeight, UpdateFrame lastestUpdate = null)
{
List commentList = new List();
SKBitmap newFrame = new SKBitmap(renderOptions.ChatWidth, renderOptions.ChatHeight);
@@ -319,7 +343,7 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, UpdateFrame lastestUpda
continue;
}
- CommentSection comment = GenerateCommentSection(commentIndex);
+ CommentSection comment = GenerateCommentSection(commentIndex, sampleTextHeight);
if (comment != null)
{
commentList.Add(comment);
@@ -343,7 +367,7 @@ private UpdateFrame GenerateUpdateFrame(int currentTick, UpdateFrame lastestUpda
return new UpdateFrame() { Image = newFrame, Comments = commentList, CommentIndex = newestCommentIndex };
}
- private CommentSection GenerateCommentSection(int commentIndex)
+ private CommentSection GenerateCommentSection(int commentIndex, float sampleTextHeight)
{
CommentSection newSection = new CommentSection();
List<(Point, TwitchEmote)> emoteSectionList = new List<(Point, TwitchEmote)>();
@@ -380,10 +404,7 @@ private CommentSection GenerateCommentSection(int commentIndex)
}
AddImageSection(sectionImages, ref drawPos, defaultPos);
- //Measure some sample text to determine position to draw text in, cannot assume height is font size
- SKRect textBounds = new SKRect();
- messageFont.MeasureText("abc123", ref textBounds);
- defaultPos.Y = (int)(((renderOptions.SectionHeight - textBounds.Height) / 2.0) + textBounds.Height);
+ defaultPos.Y = (int)(((renderOptions.SectionHeight - sampleTextHeight) / 2.0) + sampleTextHeight);
drawPos.Y = defaultPos.Y;
if ((comment.message.user_notice_params != null && (comment.message.user_notice_params.msg_id is "sub" or "resub" or "subgift")) || IsSubMessage(comment))
@@ -452,7 +473,7 @@ private static string GetKeyName(IEnumerable codepoints)
}
}
- string emojiKey = string.Join(" ", codepointList);
+ string emojiKey = string.Join(' ', codepointList);
return emojiKey;
}
@@ -493,23 +514,35 @@ private void DrawMessage(Comment comment, List sectionImages, List<(Po
emotePoint.Y = (int)(sectionImages.Sum(x => x.Height) - renderOptions.SectionHeight + ((renderOptions.SectionHeight - twitchEmote.Height) / 2.0));
emotePositionList.Add((emotePoint, twitchEmote));
}
- else if (Regex.Match(fragmentString, emojiRegex).Success)
+ else if (emojiRegex.IsMatch(fragmentString))
{
while (!string.IsNullOrWhiteSpace(fragmentString))
{
- List emojiMatches = Emoji.All.Where(x => fragmentString.StartsWith(x.ToString()) && fragmentString.Contains(x.Sequence.AsString.Trim('\uFE0F'))).ToList();
+ // Old LINQ method. Leaving this for reference
+ //List emojiMatches = Emoji.All.Where(x => fragmentString.StartsWith(x.ToString()) && fragmentString.Contains(x.Sequence.AsString.Trim('\uFE0F'))).ToList();
+
+ List emojiMatches = new List();
+ foreach (var emoji in Emoji.All)
+ {
+ if (fragmentString.StartsWith(emoji.ToString()))
+ {
+ emojiMatches.Add(emoji);
+ }
+ }
- //Make sure the found emojis actually exist in our cache
- for (int j = 0; j < emojiMatches.Count; j++)
+ // Make sure the found emojis actually exist in our cache
+ int emojiMatchesCount = emojiMatches.Count;
+ for (int j = 0; j < emojiMatchesCount; j++)
{
if (!emojiCache.ContainsKey(GetKeyName(emojiMatches[j].Sequence.Codepoints)))
{
emojiMatches.RemoveAt(j);
+ emojiMatchesCount--;
j--;
}
}
- if (emojiMatches.Count > 0)
+ if (emojiMatchesCount > 0)
{
SingleEmoji selectedEmoji = emojiMatches.OrderByDescending(x => x.Sequence.Codepoints.Count()).First();
SKBitmap emojiImage = emojiCache[GetKeyName(selectedEmoji.Sequence.Codepoints)];
@@ -539,50 +572,50 @@ private void DrawMessage(Comment comment, List sectionImages, List<(Po
}
else if (new StringInfo(fragmentString).LengthInTextElements < fragmentString.Length || !messageFont.ContainsGlyphs(fragmentString))
{
- List charList = new List(fragmentString.ToArray());
+ char[] fragmentChars = fragmentString.ToCharArray();
//Very rough estimation of width of text, because we don't know the font yet. This is to show ASCII spam properly
- int textWidth = (int)Math.Floor(charList.Count * messageFont.MeasureText("0"));
+ int textWidth = (int)(fragmentChars.Length * messageFont.MeasureText("0"));
if (drawPos.X + textWidth > renderOptions.ChatWidth - renderOptions.SidePadding - defaultPos.X)
{
AddImageSection(sectionImages, ref drawPos, defaultPos);
}
- //There are either surrogate pairs or characters not in the messageFont, draw one at a time
- string messageBuffer = "";
- for (int j = 0; j < charList.Count; j++)
+ // The fragment has either surrogate pairs or characters not in the messageFont, draw one at a time
+ var messageBuffer = new StringBuilder();
+ for (int j = 0; j < fragmentChars.Length; j++)
{
- if (char.IsHighSurrogate(charList[j]) && j + 1 < charList.Count && char.IsLowSurrogate(charList[j + 1]))
+ if (char.IsHighSurrogate(fragmentChars[j]) && j + 1 < fragmentChars.Length && char.IsLowSurrogate(fragmentChars[j + 1]))
{
- if (messageBuffer != "")
+ if (messageBuffer.Length > 0)
{
- DrawText(messageBuffer, messageFont, true, sectionImages, ref drawPos, defaultPos);
+ DrawText(messageBuffer.ToString(), messageFont, true, sectionImages, ref drawPos, defaultPos);
+ messageBuffer.Clear();
}
- SKPaint fallbackFont = GetFallbackFont(char.ConvertToUtf32(charList[j], charList[j + 1]), renderOptions);
+ SKPaint fallbackFont = GetFallbackFont(char.ConvertToUtf32(fragmentChars[j], fragmentChars[j + 1]), renderOptions);
fallbackFont.Color = renderOptions.MessageColor;
- DrawText(charList[j].ToString() + charList[j + 1].ToString(), fallbackFont, false, sectionImages, ref drawPos, defaultPos);
- messageBuffer = "";
+ DrawText(fragmentChars[j].ToString() + fragmentChars[j + 1].ToString(), fallbackFont, false, sectionImages, ref drawPos, defaultPos);
j++;
}
- else if (new StringInfo(charList[j].ToString()).LengthInTextElements == 0 || !messageFont.ContainsGlyphs(charList[j].ToString()))
+ else if (new StringInfo(fragmentChars[j].ToString()).LengthInTextElements == 0 || !messageFont.ContainsGlyphs(fragmentChars[j].ToString()))
{
- if (messageBuffer != "")
+ if (messageBuffer.Length > 0)
{
- DrawText(messageBuffer, messageFont, true, sectionImages, ref drawPos, defaultPos);
+ DrawText(messageBuffer.ToString(), messageFont, true, sectionImages, ref drawPos, defaultPos);
+ messageBuffer.Clear();
}
- SKPaint fallbackFont = GetFallbackFont(charList[j], renderOptions);
+ SKPaint fallbackFont = GetFallbackFont(fragmentChars[j], renderOptions);
fallbackFont.Color = renderOptions.MessageColor;
- DrawText(charList[j].ToString(), fallbackFont, true, sectionImages, ref drawPos, defaultPos);
- messageBuffer = "";
+ DrawText(fragmentChars[j].ToString(), fallbackFont, true, sectionImages, ref drawPos, defaultPos);
}
else
{
- messageBuffer += charList[j];
+ messageBuffer.Append(fragmentChars[j]);
}
}
- if (messageBuffer != "")
+ if (messageBuffer.Length > 0)
{
- DrawText(messageBuffer, messageFont, true, sectionImages, ref drawPos, defaultPos);
- messageBuffer = "";
+ DrawText(messageBuffer.ToString(), messageFont, true, sectionImages, ref drawPos, defaultPos);
+ messageBuffer.Clear();
}
}
else
@@ -614,8 +647,7 @@ private void DrawMessage(Comment comment, List sectionImages, List<(Po
}
}
}
- catch
- { }
+ catch { }
if (!bitsPrinted)
{
DrawText(fragmentString, messageFont, true, sectionImages, ref drawPos, defaultPos);
@@ -651,39 +683,19 @@ private void DrawMessage(Comment comment, List sectionImages, List<(Po
private void DrawText(string drawText, SKPaint textFont, bool padding, List sectionImages, ref Point drawPos, Point defaultPos)
{
- float textWidth;
bool isRtl = IsRightToLeft(drawText);
+ float textWidth = MeasureText(drawText, textFont, isRtl);
int effectiveChatWidth = renderOptions.ChatWidth - renderOptions.SidePadding - defaultPos.X;
- if (isRtl)
- {
- textWidth = MeasureRtlText(drawText, textFont);
-
- // while drawText is wider than the chat width
- while (textWidth > effectiveChatWidth)
- {
- string newDrawText = SubstringRtlToTextWidth(drawText, textFont, effectiveChatWidth, new char[] { '?', '-' });
-
- DrawText(newDrawText, textFont, padding, sectionImages, ref drawPos, defaultPos);
-
- drawText = drawText[newDrawText.Length..];
- textWidth = MeasureRtlText(drawText, textFont);
- }
- }
- else
+ // while drawText is wider than the chat width
+ while (textWidth > effectiveChatWidth)
{
- textWidth = textFont.MeasureText(drawText);
-
- // while drawText is wider than the chat width
- while (textWidth > effectiveChatWidth)
- {
- string newDrawText = SubstringToTextWidth(drawText, textFont, effectiveChatWidth, new char[] { '?', '-' });
+ string newDrawText = SubstringToTextWidth(drawText, textFont, effectiveChatWidth, isRtl, new char[] { '?', '-' });
- DrawText(newDrawText, textFont, padding, sectionImages, ref drawPos, defaultPos);
+ DrawText(newDrawText, textFont, padding, sectionImages, ref drawPos, defaultPos);
- drawText = drawText[newDrawText.Length..];
- textWidth = textFont.MeasureText(drawText);
- }
+ drawText = drawText[newDrawText.Length..];
+ textWidth = MeasureText(drawText, textFont, isRtl);
}
if (drawPos.X + textWidth > effectiveChatWidth)
{
@@ -711,7 +723,7 @@ private void DrawText(string drawText, SKPaint textFont, bool padding, List
/// Produces a less than or equal to when drawn with OR substringed to the last index of any character in .
///
- /// A shorter width or delimited , whichever comes first.
- private static string SubstringToTextWidth(string text, SKPaint textFont, int maxWidth, char[] delimiters)
+ /// A shortened in visual width or delimited , whichever comes first.
+ private static string SubstringToTextWidth(string text, SKPaint textFont, int maxWidth, bool isRtl, char[] delimiters)
{
+ ReadOnlySpan inputText = text.AsSpan();
+
// input text was already less than max width
- if (textFont.MeasureText(text) <= maxWidth)
+ if (MeasureText(inputText, textFont, isRtl) <= maxWidth)
{
return text;
}
- // cut in string half until <= width
- string shortText = text;
+ // Cut in half until <= width
+ int length = inputText.Length;
do
{
- shortText = shortText[..(shortText.Length / 2)];
- } while (textFont.MeasureText(shortText) > maxWidth);
+ length /= 2;
+ }
+ while (MeasureText(inputText.Slice(0, length), textFont, isRtl) > maxWidth);
- // add chars until 1 too long for width
- int charAt = shortText.Length - 1;
- int delimiterIndex = shortText.LastIndexOfAny(delimiters) + 1;
+ // Add chars until greater than width, then remove the last
do
{
- charAt++;
- shortText += text[charAt];
- if (delimiters.Any(x => x.Equals(shortText[charAt])))
- {
- delimiterIndex = charAt;
- }
+ length++;
+ } while (MeasureText(inputText.Slice(0, length), textFont, isRtl) < maxWidth);
+ inputText = inputText.Slice(0, length - 1);
- // prioritize wrapping at last delimiter char to increase URL readability
- if (delimiterIndex > 0)
- {
- // we're at the end of a delimiter char chain
- if (delimiterIndex != charAt)
- {
- return shortText[..delimiterIndex];
- }
- }
- } while (textFont.MeasureText(shortText) < maxWidth);
+ // Cut at the last delimiter character if applicable
+ int delimiterIndex = inputText.LastIndexOfAny(delimiters);
+ if (delimiterIndex != -1)
+ {
+ return inputText.Slice(0, delimiterIndex).ToString();
+ }
- return shortText[..^1];
+ return inputText.ToString();
}
+#pragma warning restore IDE0057
- ///
- /// Produces a less than or equal to when drawn with OR substringed to the last index of any character in .
- ///
- /// Right to left text
- /// A shorter width or delimited , whichever comes first.
- private static string SubstringRtlToTextWidth(string rtlText, SKPaint textFont, int maxWidth, char[] delimiters)
+ private static float MeasureText(ReadOnlySpan text, SKPaint textFont, bool? isRtl = null)
{
- // input text was already less than max width
- if (MeasureRtlText(rtlText, textFont) <= maxWidth)
- {
- return rtlText;
- }
+ isRtl ??= IsRightToLeft(text[0].ToString());
- // cut in string half until <= width
- string shortText = rtlText;
- do
+ if (isRtl == false)
{
- shortText = shortText[..(shortText.Length / 2)];
- } while (MeasureRtlText(shortText, textFont) > maxWidth);
-
- // add chars until 1 too long for width
- int charAt = shortText.Length - 1;
- int delimiterIndex = shortText.LastIndexOfAny(delimiters) + 1;
- do
+ return textFont.MeasureText(text);
+ }
+ else
{
- charAt++;
- shortText += rtlText[charAt];
- if (delimiters.Any(x => x.Equals(shortText[charAt])))
- {
- delimiterIndex = charAt;
- }
-
- // prioritize wrapping at last delimiter char
- if (delimiterIndex > 0)
- {
- // we're at the end of a delimiter char chain
- if (delimiterIndex != charAt)
- {
- return shortText[..delimiterIndex];
- }
- }
- } while (MeasureRtlText(shortText, textFont) < maxWidth);
-
- return shortText[..^1];
+ return MeasureRtlText(text, textFont);
+ }
}
+ 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);
- float textWidth = measure.Points[^1].X;
-
- return textWidth;
+ return measure.Points[^1].X;
}
private void DrawUsername(Comment comment, List sectionImages, ref Point drawPos)
@@ -834,7 +811,7 @@ private void DrawUsername(Comment comment, List sectionImages, ref Poi
if (comment.commenter.display_name.Any(IsNotAscii))
{
- userPaint = GetFallbackFont(comment.commenter.display_name.Where(x => IsNotAscii(x)).First(), renderOptions).Clone();
+ userPaint = GetFallbackFont(comment.commenter.display_name.Where(IsNotAscii).First(), renderOptions).Clone();
userPaint.Color = userColor;
}
@@ -847,8 +824,6 @@ private void DrawUsername(Comment comment, List sectionImages, ref Poi
userPaint.Color = userColor;
sectionImageCanvas.DrawText(comment.commenter.display_name + ":", drawPos.X, drawPos.Y, userPaint);
drawPos.X += textWidth + renderOptions.WordSpacing;
-
- userPaint.Dispose();
}
private static SKColor GenerateUserColor(SKColor userColor, SKColor background_color, ChatRenderOptions renderOptions)
@@ -877,6 +852,7 @@ private static SKColor GenerateUserColor(SKColor userColor, SKColor background_c
return userColor;
}
+#if DEBUG
//For debugging, works on Windows only
void OpenImage(SKBitmap newBitmap)
{
@@ -886,7 +862,7 @@ void OpenImage(SKBitmap newBitmap)
Process.Start(new ProcessStartInfo(tempFile) { UseShellExecute = true });
}
-
+#endif
private void DrawBadges(Comment comment, List sectionImages, ref Point drawPos)
{
using SKCanvas sectionImageCanvas = new SKCanvas(sectionImages.Last());
@@ -917,21 +893,21 @@ private void DrawBadges(Comment comment, List sectionImages, ref Point
foreach (var cachedBadge in badgeList)
{
- if (cachedBadge.Name == id)
+ if (cachedBadge.Name != id)
+ continue;
+
+ foreach (var cachedVersion in cachedBadge.Versions)
{
- foreach (var cachedVersion in cachedBadge.Versions)
+ if (cachedVersion.Key == version)
{
- if (cachedVersion.Key == version)
- {
- returnList.Add((cachedVersion.Value, cachedBadge.Type));
- foundBadge = true;
- break;
- }
- }
-
- if (foundBadge)
+ returnList.Add((cachedVersion.Value, cachedBadge.Type));
+ foundBadge = true;
break;
+ }
}
+
+ if (foundBadge)
+ break;
}
}
}
@@ -1004,7 +980,7 @@ private void ScaleImages()
}
}
- private (int, int) GetTotalTicks()
+ private (int startTick, int totalTicks) GetVideoTicks()
{
if (renderOptions.StartOverride != -1 && renderOptions.EndOverride != -1)
{
@@ -1027,6 +1003,7 @@ private void ScaleImages()
return (videoStartTick, totalTicks);
}
}
+
public SKPaint GetFallbackFont(int input, ChatRenderOptions renderOptions)
{
if (fallbackCache.ContainsKey(input))
@@ -1041,6 +1018,7 @@ private static bool IsNotAscii(char input)
{
return input > 127;
}
+
private static string[] SwapRightToLeft(string[] words)
{
List finalWords = new List();
@@ -1066,6 +1044,7 @@ private static string[] SwapRightToLeft(string[] words)
}
return finalWords.ToArray();
}
+
private static bool IsRightToLeft(string message)
{
if (message.Length > 0)
@@ -1080,50 +1059,16 @@ private static bool IsRightToLeft(string message)
return false;
}
}
- public async Task ParseJson()
- {
- chatRoot = await ParseJsonStatic(renderOptions.InputFile);
-
- if (chatRoot.streamer == null)
- {
- chatRoot.streamer = new Streamer();
- chatRoot.streamer.id = int.Parse(chatRoot.comments.First().channel_id);
- chatRoot.streamer.name = await TwitchHelper.GetStreamerName(chatRoot.streamer.id);
- }
-
- return chatRoot;
- }
- public static async Task ParseJsonStatic(string inputJson)
+ public async Task ParseJsonAsync()
{
- ChatRoot chatRoot = new ChatRoot();
+ chatRoot = await ChatJson.DeserializeAsync(renderOptions.InputFile);
- using FileStream fs = new FileStream(inputJson, FileMode.Open, FileAccess.Read);
- using var jsonDocument = JsonDocument.Parse(fs);
-
- if (jsonDocument.RootElement.TryGetProperty("streamer", out JsonElement streamerJson))
+ chatRoot.streamer ??= new Streamer
{
- chatRoot.streamer = streamerJson.Deserialize();
- }
-
- if (jsonDocument.RootElement.TryGetProperty("video", out JsonElement videoJson))
- {
- chatRoot.video = videoJson.Deserialize