├── .clang-format ├── .gitmodules ├── CMakeLists.txt ├── LICENSE ├── README.md ├── cli_main.cpp ├── example ├── gui.jpg ├── result.jpg └── tsoding.ini ├── fonts └── lucon.hpp ├── gui_main.cpp ├── image_view.h ├── stb_image.h ├── ytt.ytt └── ytt_generator.h /.clang-format: -------------------------------------------------------------------------------- 1 | --- 2 | Language: Cpp 3 | BasedOnStyle: LLVM 4 | 5 | AccessModifierOffset: -2 6 | AlignAfterOpenBracket: Align 7 | AlignConsecutiveMacros: true 8 | AlignConsecutiveAssignments: true 9 | AlignEscapedNewlines: Right 10 | AlignOperands: false 11 | AlignTrailingComments: true 12 | AllowAllArgumentsOnNextLine: true 13 | AllowAllConstructorInitializersOnNextLine: true 14 | AllowAllParametersOfDeclarationOnNextLine: true 15 | AllowShortBlocksOnASingleLine: true 16 | AllowShortCaseLabelsOnASingleLine: true 17 | AllowShortFunctionsOnASingleLine: Empty 18 | AllowShortIfStatementsOnASingleLine: Never 19 | AllowShortLambdasOnASingleLine: All 20 | AllowShortLoopsOnASingleLine: false 21 | AlwaysBreakAfterDefinitionReturnType: None 22 | AlwaysBreakAfterReturnType: None 23 | AlwaysBreakBeforeMultilineStrings: false 24 | AlwaysBreakTemplateDeclarations: Yes 25 | BreakBeforeBraces: Attach 26 | BreakBeforeTernaryOperators: false 27 | BreakConstructorInitializers: AfterColon 28 | ColumnLimit: 180 29 | CompactNamespaces: false 30 | ConstructorInitializerAllOnOneLineOrOnePerLine: false 31 | ExperimentalAutoDetectBinPacking: false 32 | FixNamespaceComments: false 33 | IncludeBlocks: Preserve 34 | IndentCaseLabels: true 35 | IndentWidth: 4 36 | PointerAlignment: Left 37 | ReflowComments: false 38 | SortIncludes: false 39 | SortUsingDeclarations: false 40 | SpaceAfterCStyleCast: false 41 | SpaceAfterLogicalNot: false 42 | SpaceAfterTemplateKeyword: true 43 | SpaceBeforeCtorInitializerColon: true 44 | SpaceBeforeInheritanceColon: true 45 | SpaceBeforeParens: ControlStatements 46 | SpaceBeforeRangeBasedForLoopColon: true 47 | SpaceInEmptyParentheses: false 48 | SpacesBeforeTrailingComments: 1 49 | SpacesInAngles: false 50 | SpacesInCStyleCastParentheses: false 51 | SpacesInContainerLiterals: false 52 | SpacesInParentheses: false 53 | SpacesInSquareBrackets: false 54 | Standard: Auto 55 | TabWidth: 4 56 | UseTab: Never 57 | 58 | AllowShortEnumsOnASingleLine: false 59 | 60 | BraceWrapping: 61 | AfterEnum: false 62 | 63 | AlignConsecutiveDeclarations: AcrossEmptyLines 64 | 65 | NamespaceIndentation: All -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/imgui"] 2 | path = submodules/imgui 3 | url = https://github.com/ocornut/imgui 4 | branch = docking 5 | [submodule "submodules/tinyxml2"] 6 | path = submodules/tinyxml2 7 | url = https://github.com/leethomason/tinyxml2 8 | [submodule "submodules/utfcpp"] 9 | path = submodules/utfcpp 10 | url = https://github.com/nemtrif/utfcpp 11 | [submodule "submodules/glfw"] 12 | path = submodules/glfw 13 | url = https://github.com/glfw/glfw.git 14 | [submodule "submodules/CLI11"] 15 | path = submodules/CLI11 16 | url = https://github.com/CLIUtils/CLI11 17 | [submodule "submodules/nativefiledialog-extended"] 18 | path = submodules/nativefiledialog-extended 19 | url = https://github.com/btzy/nativefiledialog-extended 20 | [submodule "submodules/magic_enum"] 21 | path = submodules/magic_enum 22 | url = https://github.com/Neargye/magic_enum/ 23 | [submodule "submodules/simpleini"] 24 | path = submodules/simpleini 25 | url = https://github.com/brofield/simpleini -------------------------------------------------------------------------------- /CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.14) 2 | project(SubChat LANGUAGES C CXX) 3 | 4 | set(CMAKE_CXX_STANDARD 23) 5 | set(CMAKE_CXX_STANDARD_REQUIRED ON) 6 | 7 | if (NOT CMAKE_BUILD_TYPE) 8 | set(CMAKE_BUILD_TYPE Release CACHE STRING "Choose the type of build (Debug or Release)" FORCE) 9 | endif () 10 | 11 | 12 | option(BUILD_GUI "Build the GUI config generator" ON) 13 | 14 | # External headers common to both targets 15 | set(TINYXML_DIR "${CMAKE_SOURCE_DIR}/submodules/tinyxml2") 16 | set(SIMPLEINI_DIR "${CMAKE_SOURCE_DIR}/submodules/simpleini") 17 | set(MAGICENUM_DIR "${CMAKE_SOURCE_DIR}/submodules/magic_enum") 18 | set(UTFCPP_DIR "${CMAKE_SOURCE_DIR}/submodules/utfcpp/") 19 | include_directories( 20 | ${TINYXML_DIR} 21 | ${SIMPLEINI_DIR} 22 | ${MAGICENUM_DIR}/include/magic_enum 23 | ${UTFCPP_DIR}/source 24 | ) 25 | 26 | # ───────────────────────────────────────────────────────────────── 27 | # Static executable for subtitle generation (CLI) 28 | # ───────────────────────────────────────────────────────────────── 29 | set(CSV_CXX_STANDARD ${CMAKE_CXX_STANDARD}) 30 | add_subdirectory("${CMAKE_SOURCE_DIR}/submodules/CLI11") 31 | 32 | add_executable(subtitles_generator 33 | cli_main.cpp 34 | ${TINYXML_DIR}/tinyxml2.cpp 35 | ) 36 | target_include_directories(subtitles_generator 37 | PUBLIC 38 | ${TINYXML_DIR} 39 | ${SIMPLEINI_DIR} 40 | ${UTFCPP_DIR}/source 41 | ${MAGICENUM_DIR}/include/magic_enum 42 | ) 43 | target_link_options(subtitles_generator PRIVATE -static) 44 | target_link_libraries(subtitles_generator 45 | PRIVATE 46 | CLI11::CLI11 47 | ) 48 | 49 | # ───────────────────────────────────────────────────────────────── 50 | # GUI config generator 51 | # ───────────────────────────────────────────────────────────────── 52 | if (BUILD_GUI) 53 | # Find GUI dependencies 54 | find_package(OpenGL REQUIRED) 55 | find_package(GLEW REQUIRED) 56 | 57 | # GLFW configuration 58 | set(GLFW_DIR "${CMAKE_SOURCE_DIR}/submodules/glfw") 59 | option(GLFW_BUILD_EXAMPLES "Build the GLFW example programs" OFF) 60 | option(GLFW_BUILD_TESTS "Build the GLFW test programs" OFF) 61 | option(GLFW_BUILD_DOCS "Build the GLFW documentation" OFF) 62 | option(GLFW_INSTALL "Generate installation target" OFF) 63 | option(GLFW_DOCUMENT_INTERNALS "Include internals in documentation" OFF) 64 | add_subdirectory(${GLFW_DIR} ${CMAKE_BINARY_DIR}/glfw EXCLUDE_FROM_ALL) 65 | include_directories(${GLFW_DIR}/include ${GLFW_DIR}/deps) 66 | 67 | # Dear ImGui configuration 68 | set(IMGUI_DIR "${CMAKE_SOURCE_DIR}/submodules/imgui") 69 | include_directories(${IMGUI_DIR} ${IMGUI_DIR}/backends) 70 | 71 | # Add Native File Dialog 72 | add_subdirectory("${CMAKE_SOURCE_DIR}/submodules/nativefiledialog-extended") 73 | 74 | # Add GUI executable 75 | add_executable(config_generator_gui 76 | ${IMGUI_DIR}/backends/imgui_impl_glfw.cpp 77 | ${IMGUI_DIR}/backends/imgui_impl_opengl3.cpp 78 | ${IMGUI_DIR}/imgui.cpp 79 | ${IMGUI_DIR}/imgui_draw.cpp 80 | ${IMGUI_DIR}/imgui_demo.cpp 81 | ${IMGUI_DIR}/imgui_tables.cpp 82 | ${IMGUI_DIR}/imgui_widgets.cpp 83 | ${IMGUI_DIR}/misc/cpp/imgui_stdlib.cpp 84 | ${TINYXML_DIR}/tinyxml2.cpp 85 | gui_main.cpp 86 | fonts/lucon.hpp 87 | ) 88 | 89 | target_link_libraries(config_generator_gui 90 | PRIVATE 91 | glfw 92 | OpenGL::GL 93 | GLEW::GLEW 94 | nfd 95 | ) 96 | endif () -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kam1k4dze 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SubChat 2 | 3 | SubChat is a command-line and GUI toolset for generating YouTube subtitles from chat logs. 4 | 5 | **IMPORTANT:** Currently, only YouTube in desktop browsers is supported. 6 | 7 | ![Tsoding](example/result.jpg) 8 | Chat on this screenshot was created entirely using YouTube subtitles. 9 | 10 | Screenshot from [Tsoding](https://www.twitch.tv/tsoding) stream. 11 | 12 | ## Project Components 13 | 14 | The project has two separate targets: 15 | 16 | - **config_generator_gui**: A GUI tool for creating and editing INI config files used by the subtitle generator. 17 | *System Dependencies*: OpenGL, GLEW 18 | 19 | *Uses Submodules*: GLFW, Dear ImGui, TinyXML2, SimpleIni, Magic Enum, UTFCPP, nativefiledialog-extended. 20 | 21 | - **subtitles_generator**: A CLI tool that converts CSV chat logs into subtitle files (YTT/SRV3) using a given config file. 22 | *Uses Submodules*: CLI11, TinyXML2, SimpleIni, Magic Enum, UTFCPP. 23 | 24 | --- 25 | 26 | ## CSV Format Specification 27 | 28 | The input CSV file must follow this schema: 29 | 30 | ``` 31 | time,user_name,user_color,message 32 | ``` 33 | 34 | Where: 35 | 36 | - `time`: Timestamp when the message was sent (in milliseconds or seconds, see `-u` flag) 37 | - `user_name`: The display name of the user who sent the message 38 | - `user_color`: Hex color code for the username (e.g., `#FF0000` for red) 39 | - `message`: The actual chat message content 40 | 41 | Example CSV: 42 | 43 | ``` 44 | time,user_name,user_color,message 45 | 1234567,User1,#FF0000,"Hello world!" 46 | 1235000,User2,,"Hi there!" 47 | 1240000,User1,#FF0000,"How are you?" 48 | ``` 49 | 50 | For example, you can download chat from Twitch VOD using https://www.twitchchatdownloader.com/ 51 | 52 | --- 53 | 54 | ## Cloning the Repository 55 | 56 | Clone the repository recursively to fetch all submodules: 57 | 58 | ```bash 59 | git clone --recursive --shallow-submodules https://github.com/Kam1k4dze/SubChat 60 | ``` 61 | 62 | If already cloned without submodules: 63 | 64 | ```bash 65 | git submodule update --init --recursive 66 | ``` 67 | 68 | --- 69 | 70 | ## Building the Project 71 | 72 | The project uses CMake (minimum required version 3.14) and is set up to build both targets. Note that OpenGL and GLEW are only needed for the GUI target. 73 | 74 | ### Steps to Build All Targets 75 | 76 | 1. **Create a build directory and navigate into it:** 77 | 78 | ```bash 79 | mkdir build && cd build 80 | ``` 81 | 82 | 2. **Configure the project:** 83 | 84 | ```bash 85 | cmake .. 86 | ``` 87 | 88 | 3. **Build everything:** 89 | 90 | ```bash 91 | cmake --build . 92 | ``` 93 | 94 | ### Building Without GUI 95 | 96 | If you only need the CLI tool and don't have OpenGL/GLEW installed: 97 | 98 | ```bash 99 | cmake -DBUILD_GUI=OFF .. 100 | cmake --build . 101 | ``` 102 | 103 | --- 104 | 105 | ## Usage 106 | 107 | ### config_generator_gui 108 | 109 | Launch this tool to generate or modify INI config files: 110 | 111 | ```bash 112 | ./config_generator_gui 113 | ``` 114 | ![GUI Interface](example/gui.jpg) 115 | 116 | ### subtitles_generator 117 | 118 | Convert a chat CSV into a subtitle file using a config file. 119 | 120 | #### Command-Line Options 121 | 122 | ```bash 123 | ./subtitles_generator -c -i -o -u 124 | ``` 125 | 126 | - `-h, --help` 127 | Display help information and exit. 128 | 129 | - `-c, --config` 130 | Path to the INI config file. 131 | 132 | - `-i, --input` 133 | Path to the CSV file with chat data. 134 | 135 | - `-o, --output` 136 | Output subtitle file (e.g., `output.ytt` or `output.srv3`). 137 | 138 | - `-u, --time-unit` 139 | Time unit in the CSV: `"ms"` or `"sec"`. 140 | -------------------------------------------------------------------------------- /cli_main.cpp: -------------------------------------------------------------------------------- 1 | #include "ytt_generator.h" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | int main(int argc, char *argv[]) { 9 | CLI::App app{"Chat → YTT/SRV3 subtitle generator"}; 10 | 11 | std::filesystem::path configPath, csvPath, outputPath; 12 | std::string timeUnit; 13 | 14 | app.add_option("-c,--config", configPath, "Path to INI config file") 15 | ->required() 16 | ->check(CLI::ExistingFile); 17 | app.add_option("-i,--input", csvPath, "Path to chat CSV file") 18 | ->required() 19 | ->check(CLI::ExistingFile); 20 | app.add_option("-o,--output", outputPath, "Output file (e.g. output.srv3 or output.ytt)") 21 | ->required(); 22 | app.add_option("-u,--time-unit", timeUnit, "Time unit inside CSV: “ms” or “sec”") 23 | ->required() 24 | ->check(CLI::IsMember({"ms", "sec"}, CLI::ignore_case)); 25 | 26 | CLI11_PARSE(app, argc, argv); 27 | 28 | int multiplier = (timeUnit == "sec") ? 1000 : 1; 29 | 30 | ChatParams params; 31 | if (!params.loadFromFile(configPath.c_str())) { 32 | std::cerr << "Error: Cannot open config file: " << configPath << "\n"; 33 | return 1; 34 | } 35 | 36 | auto chat = parseCSV(csvPath, multiplier); 37 | if (chat.empty()) { 38 | std::cerr << "Error: Failed to parse chat CSV or it's empty: " << csvPath << "\n"; 39 | return 1; 40 | } 41 | 42 | std::string xml = generateXML(generateBatches(chat,params), params); 43 | 44 | std::ofstream out(outputPath); 45 | if (!out) { 46 | std::cerr << "Error: Cannot open output file: " << outputPath << "\n"; 47 | return 1; 48 | } 49 | out << xml; 50 | std::cout << "Successfully wrote subtitles to: " << outputPath << "\n"; 51 | return 0; 52 | } 53 | -------------------------------------------------------------------------------- /example/gui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kam1k4dze/SubChat/1b7bfd036bcb24190f7eff6f7a0c57dfad59d31e/example/gui.jpg -------------------------------------------------------------------------------- /example/result.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kam1k4dze/SubChat/1b7bfd036bcb24190f7eff6f7a0c57dfad59d31e/example/result.jpg -------------------------------------------------------------------------------- /example/tsoding.ini: -------------------------------------------------------------------------------- 1 | [General] 2 | 3 | ;true/false 4 | bold = false 5 | 6 | ;true/false 7 | italic = false 8 | 9 | ;true/false 10 | underline = false 11 | 12 | ;Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA 13 | textForegroundColor = #FEFEFE 14 | 15 | ;Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA 16 | textBackgroundColor = #FEFEFE00 17 | 18 | ;Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA 19 | textEdgeColor = #000000 20 | 21 | ;Options: None, HardShadow, Bevel, GlowOutline, SoftShadow 22 | textEdgeType = SoftShadow 23 | 24 | ;Options: Default, Monospaced, Proportional, MonospacedSans, ProportionalSans, Casual, Cursive, SmallCapitals 25 | fontStyle = MonospacedSans 26 | 27 | ;0–300 (virtual percent) 28 | fontSizePercent = 0 29 | 30 | ;Options: Left, Right, Center 31 | textAlignment = Left 32 | 33 | ;0–100 (virtual percent) 34 | horizontalMargin = 71 35 | 36 | ;0-100 (virtual percent) 37 | verticalMargin = 0 38 | 39 | ;virtual pixels 40 | verticalSpacing = 4 41 | 42 | ;lines 43 | totalDisplayLines = 13 44 | 45 | ;characters 46 | maxCharsPerLine = 25 47 | 48 | ;string between name and message 49 | usernameSeparator = : 50 | -------------------------------------------------------------------------------- /gui_main.cpp: -------------------------------------------------------------------------------- 1 | #ifndef IMGUI_DEFINE_MATH_OPERATORS 2 | #define IMGUI_DEFINE_MATH_OPERATORS 3 | #endif // IMGUI_DEFINE_MATH_OPERATORS 4 | 5 | #include "imgui.h" 6 | #include "imgui_impl_glfw.h" 7 | #include "imgui_impl_opengl3.h" 8 | #include 9 | #include 10 | 11 | #define GL_SILENCE_DEPRECATION 12 | 13 | #include 14 | 15 | #include "image_view.h" 16 | #include "misc/cpp/imgui_stdlib.h" 17 | 18 | #define STB_IMAGE_IMPLEMENTATION 19 | 20 | #include "stb_image.h" 21 | #include "ytt_generator.h" 22 | #include "fonts/lucon.hpp" 23 | #include 24 | 25 | constexpr float FONT_LOAD_SIZE = 96; 26 | constexpr float FONT_SIZE = 20; 27 | constexpr float FONT_SCALE_CONSTANT = FONT_SIZE / FONT_LOAD_SIZE; 28 | 29 | bool first_time_layout = true; 30 | static ImGuiID dockspace_id = 0; 31 | 32 | // Simple helper function to load an image into a OpenGL texture with common settings 33 | bool LoadTextureFromMemory(const void *data, size_t data_size, GLuint *out_texture, int *out_width, int *out_height) { 34 | // Load from file 35 | int image_width = 0; 36 | int image_height = 0; 37 | unsigned char *image_data = stbi_load_from_memory(static_cast(data), 38 | static_cast(data_size), &image_width, 39 | &image_height, nullptr, 4); 40 | if (image_data == nullptr) 41 | return false; 42 | 43 | // Create a OpenGL texture identifier 44 | GLuint image_texture; 45 | glGenTextures(1, &image_texture); 46 | glBindTexture(GL_TEXTURE_2D, image_texture); 47 | 48 | // Setup filtering parameters for display 49 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); 50 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); 51 | 52 | // Upload pixels into texture 53 | glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); 54 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image_width, image_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, image_data); 55 | stbi_image_free(image_data); 56 | 57 | *out_texture = image_texture; 58 | *out_width = image_width; 59 | *out_height = image_height; 60 | 61 | return true; 62 | } 63 | 64 | // Open and read a file, then forward to LoadTextureFromMemory() 65 | bool LoadTextureFromFile(const char *file_name, GLuint *out_texture, int *out_width, int *out_height) { 66 | FILE *f = fopen(file_name, "rb"); 67 | if (f == nullptr) 68 | return false; 69 | fseek(f, 0, SEEK_END); 70 | auto file_size = static_cast(ftell(f)); 71 | if (file_size == -1) 72 | return false; 73 | fseek(f, 0, SEEK_SET); 74 | void *file_data = IM_ALLOC(file_size); 75 | fread(file_data, 1, file_size, f); 76 | fclose(f); 77 | bool ret = LoadTextureFromMemory(file_data, file_size, out_texture, out_width, out_height); 78 | IM_FREE(file_data); 79 | return ret; 80 | } 81 | 82 | static void glfw_error_callback(int error, const char *description) { 83 | fprintf(stderr, "GLFW Error %d: %s\n", error, description); 84 | } 85 | 86 | static float GetDPIScale(GLFWwindow *window) { 87 | float xscale, yscale; 88 | glfwGetWindowContentScale(window, &xscale, &yscale); 89 | return xscale; // Assuming uniform scaling for simplicity 90 | } 91 | 92 | void LoadFonts() { 93 | ImGuiIO &io = ImGui::GetIO(); 94 | io.Fonts->Clear(); 95 | ImFontConfig font_cfg; 96 | font_cfg.FontDataOwnedByAtlas = false; 97 | auto font = io.Fonts->AddFontFromMemoryCompressedTTF(LuCon_compressed_data, LuCon_compressed_size, FONT_LOAD_SIZE, 98 | &font_cfg, 99 | io.Fonts->GetGlyphRangesCyrillic()); 100 | font->Scale = FONT_SCALE_CONSTANT; 101 | io.Fonts->Build(); 102 | } 103 | 104 | void ApplyDPI(float dpi_scale) { 105 | static const ImGuiStyle default_style = ImGui::GetStyle(); 106 | ImGui::GetStyle() = default_style; 107 | ImGuiStyle &style = ImGui::GetStyle(); 108 | style.ScaleAllSizes(dpi_scale); 109 | 110 | } 111 | 112 | 113 | void ShowDockSpace() { 114 | ImGuiViewport *vp = ImGui::GetMainViewport(); 115 | ImGui::SetNextWindowPos(vp->Pos); 116 | ImGui::SetNextWindowSize(vp->Size); 117 | ImGui::SetNextWindowViewport(vp->ID); 118 | 119 | ImGuiWindowFlags wflags = 120 | ImGuiWindowFlags_NoTitleBar 121 | | ImGuiWindowFlags_NoCollapse 122 | | ImGuiWindowFlags_NoResize 123 | | ImGuiWindowFlags_NoMove 124 | | ImGuiWindowFlags_NoBringToFrontOnFocus 125 | | ImGuiWindowFlags_NoNavFocus 126 | | ImGuiWindowFlags_NoBackground; 127 | ImGui::Begin("##MainDockSpace", nullptr, wflags); 128 | 129 | dockspace_id = ImGui::GetID("MyDockSpace"); 130 | ImGui::DockSpace(dockspace_id, ImVec2(0, 0), ImGuiDockNodeFlags_PassthruCentralNode); 131 | 132 | if (first_time_layout) { 133 | first_time_layout = false; 134 | ImGui::DockBuilderRemoveNode(dockspace_id); 135 | ImGui::DockBuilderAddNode(dockspace_id, ImGuiDockNodeFlags_None); 136 | ImGui::DockBuilderSetNodeSize(dockspace_id, vp->Size); 137 | 138 | ImGuiID left_id, right_id; 139 | ImGui::DockBuilderSplitNode(dockspace_id, ImGuiDir_Left, 0.7f, &left_id, &right_id); 140 | 141 | ImGui::DockBuilderDockWindow("Preview", left_id); 142 | ImGui::DockBuilderDockWindow("Settings", right_id); 143 | 144 | ImGui::DockBuilderFinish(dockspace_id); 145 | } 146 | 147 | ImGui::End(); 148 | } 149 | 150 | void ShowColorEdit(const char *label, Color &color) { 151 | float col[4] = {color.r / 255.f, 152 | color.g / 255.f, 153 | color.b / 255.f, 154 | color.a / 255.f 155 | }; 156 | 157 | // ImGui color edit widget 158 | if (ImGui::ColorEdit4(label, col)) { 159 | color.r = col[0] * 255; 160 | color.g = col[1] * 255; 161 | color.b = col[2] * 255; 162 | color.a = col[3] * 255; 163 | } 164 | } 165 | 166 | 167 | int main(int, char **) { 168 | glfwSetErrorCallback(glfw_error_callback); 169 | if (!glfwInit()) 170 | return 1; 171 | const char *glsl_version = "#version 130"; 172 | glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); 173 | glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0); 174 | GLFWwindow *window = glfwCreateWindow(1280, 720, "SubChat Config Generator", nullptr, nullptr); 175 | if (window == nullptr) 176 | return 1; 177 | glfwMakeContextCurrent(window); 178 | glfwSwapInterval(1); 179 | 180 | if (NFD_Init() != NFD_OKAY) { 181 | printf("Error: %s\n", NFD_GetError()); 182 | return 1; 183 | } 184 | 185 | IMGUI_CHECKVERSION(); 186 | ImGui::CreateContext(); 187 | ImGuiIO &io = ImGui::GetIO(); 188 | io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; 189 | io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; 190 | ImGui::StyleColorsDark(); 191 | ImGui_ImplGlfw_InitForOpenGL(window, true); 192 | ImGui_ImplOpenGL3_Init(glsl_version); 193 | 194 | float dpi_scale = GetDPIScale(window); 195 | LoadFonts(); 196 | ApplyDPI(dpi_scale); 197 | 198 | ImVec4 clear_color = ImVec4(0.45f, 0.55f, 0.60f, 1.00f); 199 | 200 | int preview_width = 0; 201 | int preview_height = 0; 202 | GLuint preview_texture = 0; 203 | 204 | PreloadPreviewFont(); 205 | InteractiveTextOverlay text_overlay{}; 206 | while (!glfwWindowShouldClose(window)) { 207 | glfwPollEvents(); 208 | float new_dpi_scale = GetDPIScale(window); 209 | if (fabs(dpi_scale - new_dpi_scale) > 0.1f * std::max(fabs(dpi_scale), fabs(new_dpi_scale))) { 210 | dpi_scale = new_dpi_scale; 211 | ApplyDPI(dpi_scale); 212 | } 213 | 214 | ImGui_ImplOpenGL3_NewFrame(); 215 | ImGui_ImplGlfw_NewFrame(); 216 | ImGui::NewFrame(); 217 | ShowDockSpace(); 218 | 219 | ShowInteractiveImage(preview_texture, preview_width, preview_height, &text_overlay); 220 | 221 | auto &p = text_overlay.params; 222 | 223 | ImGui::Begin("Settings"); 224 | if (ImGui::Button("Load Image for Preview")) { 225 | nfdu8char_t *outPath = nullptr; 226 | nfdu8filteritem_t filters[1] = {{"Image Files", "png,jpg,jpeg"}}; 227 | nfdopendialogu8args_t args = {0}; 228 | args.filterList = filters; 229 | args.filterCount = 1; 230 | // Set the parent window handle using the GLFW binding 231 | //NFD_GetNativeWindowFromGLFWWindow(window, &args.parentWindow); 232 | nfdresult_t result = NFD_OpenDialogU8_With(&outPath, &args); 233 | if (result == NFD_OKAY) { 234 | if (preview_texture != 0) { 235 | glDeleteTextures(1, &preview_texture); 236 | preview_texture = 0; 237 | } 238 | bool ret = LoadTextureFromFile(outPath, &preview_texture, &preview_width, &preview_height); 239 | if (!ret) 240 | printf("Failed to load image: %s\n", outPath); 241 | NFD_FreePathU8(outPath); 242 | } else if (result == NFD_CANCEL) { 243 | // Do nothing 244 | } else { 245 | printf("Error: %s\n", NFD_GetError()); 246 | } 247 | } 248 | if (preview_texture == 0) ImGui::BeginDisabled(); 249 | if (ImGui::Button("Load Chat Logs for Preview")) { 250 | nfdu8char_t *outPath = nullptr; 251 | nfdu8filteritem_t filters[1] = {{"CSV files", "csv"}}; 252 | nfdopendialogu8args_t args = {0}; 253 | args.filterList = filters; 254 | args.filterCount = 1; 255 | // Set the parent window handle using the GLFW binding 256 | //NFD_GetNativeWindowFromGLFWWindow(window, &args.parentWindow); 257 | nfdresult_t result = NFD_OpenDialogU8_With(&outPath, &args); 258 | if (result == NFD_OKAY) { 259 | int multiplier = 1; // TODO: some way to customize time units 260 | text_overlay.messages = parseCSV(outPath, multiplier); 261 | text_overlay.revalidatePreview = true; 262 | NFD_FreePathU8(outPath); 263 | } else if (result == NFD_CANCEL) { 264 | // Do nothing 265 | } else { 266 | printf("Error: %s\n", NFD_GetError()); 267 | } 268 | } 269 | ImGui::SliderInt("X", &p.horizontalMargin, 0, 100); 270 | ImGui::SliderInt("Y", &p.verticalMargin, 0, 100); 271 | ImGui::SliderInt("Font size", &p.fontSizePercent, 0, 300); 272 | ImGui::SliderInt("Vertical\nspacing", &p.verticalSpacing, 0, 25); 273 | // TODO add more options to GUI 274 | // ImGui::Checkbox("Bold", &p.textBold); 275 | // ImGui::SameLine(); 276 | // ImGui::Checkbox("Italic", &p.textItalic); 277 | // ImGui::SameLine(); 278 | // ImGui::Checkbox("Underline", &p.textUnderline); 279 | ShowColorEdit("Text Color", p.textForegroundColor); 280 | text_overlay.revalidatePreview += ImGui::SliderInt("Characters\nper line", &p.maxCharsPerLine, 5, 50); 281 | text_overlay.revalidatePreview += ImGui::SliderInt("Line\nCount", &p.totalDisplayLines, 1, 50); 282 | text_overlay.revalidatePreview += ImGui::InputText("Username\nSeparator", &p.usernameSeparator); 283 | if (ImGui::Button("Load Config")) { 284 | nfdu8char_t *outPath = nullptr; 285 | nfdu8filteritem_t filters[1] = {{"Chat configs", "ini"}}; 286 | nfdopendialogu8args_t args = {0}; 287 | args.filterList = filters; 288 | args.filterCount = 1; 289 | // TODO Set the parent window handle using the GLFW binding 290 | //NFD_GetNativeWindowFromGLFWWindow(window, &args.parentWindow); 291 | nfdresult_t result = NFD_OpenDialogU8_With(&outPath, &args); 292 | if (result == NFD_OKAY) { 293 | p.loadFromFile(outPath); 294 | text_overlay.revalidatePreview = true; 295 | NFD_FreePathU8(outPath); 296 | } else if (result == NFD_CANCEL) { 297 | printf("User pressed cancel on load config.\n"); 298 | } else { 299 | printf("Error (load config): %s\n", NFD_GetError()); 300 | } 301 | } 302 | ImGui::SameLine(); 303 | if (!text_overlay.isInsidePicture) 304 | ImGui::BeginDisabled(); 305 | if (ImGui::Button("Save Config")) { 306 | nfdu8char_t *outPath = nullptr; 307 | nfdu8filteritem_t filters[1] = {{"Chat configs", "ini"}}; 308 | nfdsavedialogu8args_t args = {0}; 309 | args.filterList = filters; 310 | args.filterCount = 1; 311 | args.defaultName = "config.ini"; 312 | // TODO NFD_GetNativeWindowFromGLFWWindow(window, &args.parentWindow); 313 | nfdresult_t result = NFD_SaveDialogU8_With(&outPath, &args); 314 | if (result == NFD_OKAY) { 315 | p.saveToFile(outPath); 316 | NFD_FreePathU8(outPath); 317 | } else if (result == NFD_CANCEL) { 318 | printf("User pressed cancel on save configs.\n"); 319 | } else { 320 | printf("Error (save configs): %s\n", NFD_GetError()); 321 | } 322 | } 323 | if (!text_overlay.isInsidePicture) ImGui::EndDisabled(); 324 | if (preview_texture == 0) ImGui::EndDisabled(); 325 | 326 | ImGui::End(); 327 | 328 | ImGui::Render(); 329 | int display_w, display_h; 330 | glfwGetFramebufferSize(window, &display_w, &display_h); 331 | glViewport(0, 0, display_w, display_h); 332 | glClearColor(clear_color.x * clear_color.w, clear_color.y * clear_color.w, clear_color.z * clear_color.w, 333 | clear_color.w); 334 | glClear(GL_COLOR_BUFFER_BIT); 335 | ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); 336 | glfwSwapBuffers(window); 337 | } 338 | 339 | ImGui_ImplOpenGL3_Shutdown(); 340 | ImGui_ImplGlfw_Shutdown(); 341 | ImGui::DestroyContext(); 342 | NFD_Quit(); 343 | glfwDestroyWindow(window); 344 | glfwTerminate(); 345 | return 0; 346 | } 347 | -------------------------------------------------------------------------------- /image_view.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include "imgui.h" 4 | #include "imgui_internal.h" 5 | #include 6 | #include 7 | #include 8 | #include "ytt_generator.h" 9 | #include "fonts/lucon.hpp" 10 | 11 | 12 | static ImFont *g_font = nullptr; 13 | 14 | 15 | struct InteractiveTextOverlay { 16 | 17 | ChatParams params; 18 | 19 | 20 | float realFontSize(int height) const { 21 | return (100.0f + (params.fontSizePercent - 100.0f) / 4.0f) / 100.0f * (height / 22.5f); 22 | } 23 | 24 | float realX() const { 25 | return ((params.horizontalMargin * 0.96) + 2.5f) / 100.0f; 26 | } 27 | 28 | float realY(int N = 0) const { 29 | return (((params.verticalMargin + N * params.verticalSpacing) * 0.96f) + 2.15f) / 100.0f; 30 | } 31 | 32 | // TODO: make preview take into account the color of the nickname from the messages 33 | std::vector> preview; 34 | bool revalidatePreview = true; 35 | bool isInsidePicture = true; 36 | 37 | void generatePreview() { 38 | preview.clear(); 39 | for (const auto &message: messages) { 40 | auto [username, wrapped] = wrapMessage(message.user.name, params.usernameSeparator, message.message, params.maxCharsPerLine); 41 | if (wrapped.empty()) { 42 | continue; 43 | } 44 | if (preview.size() < params.totalDisplayLines) { 45 | preview.emplace_back(username, wrapped[0]); 46 | } else { 47 | break; 48 | } 49 | for (size_t i = 1; i < wrapped.size() && preview.size() < params.totalDisplayLines; ++i) { 50 | preview.emplace_back("", wrapped[i]); 51 | } 52 | } 53 | revalidatePreview = false; 54 | } 55 | 56 | std::vector messages = { 57 | {0, {"Sirius"}, "Lorem ipsum dolor sit amet, consectetur adipiscing elit."}, 58 | {0, {"Betelgeuse"}, "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."}, 59 | {0, {"Vega"}, "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris."}, 60 | {0, {"Rigel"}, "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore."}, 61 | {0, {"Antares"}, "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia."}, 62 | {0, {"Arcturus"}, "Curabitur pretium tincidunt lacus. Nulla gravida orci a odio."}, 63 | {0, {"Aldebaran"}, "Pellentesque habitant morbi tristique senectus et netus et malesuada fames."}, 64 | {0, {"Procyon"}, "Maecenas sed diam eget risus varius blandit sit amet non magna."}, 65 | {0, {"Capella"}, "Cras mattis consectetur purus sit amet fermentum."}, 66 | {0, {"Altair"}, "Aenean lacinia bibendum nulla sed consectetur."}, 67 | {0, {"Pollux"}, "Vestibulum id ligula porta felis euismod semper."}, 68 | {0, {"Spica"}, "Praesent commodo cursus magna, vel scelerisque nisl consectetur et."}, 69 | {0, {"Deneb"}, "Nullam quis risus eget urna mollis ornare vel eu leo."}, 70 | {0, {"Canopus"}, "Etiam porta sem malesuada magna mollis euismod."}, 71 | {0, {"Fomalhaut"}, "Donec ullamcorper nulla non metus auctor fringilla."}, 72 | {0, {"Bellatrix"}, "Aenean eu leo quam. Pellentesque ornare sem lacinia quam venenatis."}, 73 | {0, {"Achernar"}, "Integer posuere erat a ante venenatis dapibus posuere velit aliquet."}, 74 | {0, {"Regulus"}, "Sed posuere consectetur est at lobortis."}, 75 | {0, {"Castor"}, "Curabitur blandit tempus porttitor."}, 76 | {0, {"Mira"}, "Morbi leo risus, porta ac consectetur ac, vestibulum at eros."}, 77 | {0, {"Alpheratz"}, "Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh."}, 78 | {0, {"Shaula"}, "Donec id elit non mi porta gravida at eget metus."}, 79 | {0, {"Zubenelgenubi"}, "Vivamus sagittis lacus vel augue laoreet rutrum faucibus dolor auctor."}, 80 | {0, {"Sadr"}, "Integer nec odio. Praesent libero. Sed cursus ante dapibus diam."}, 81 | {0, {"Nunki"}, "Suspendisse potenti. Morbi fringilla convallis sapien."}, 82 | {0, {"Hadar"}, "Curabitur tortor. Pellentesque nibh."}, 83 | {0, {"Mintaka"}, "Aenean quam. In scelerisque sem at dolor."}, 84 | {0, {"Alnilam"}, "Maecenas mattis. Sed convallis tristique sem."}, 85 | {0, {"Wezen"}, "Proin ut ligula vel nunc egestas porttitor."}, 86 | {0, {"Naos"}, "Aliquam erat volutpat. Nulla facilisi."}, 87 | {0, {"Rasalhague"}, "Nam dui ligula, fringilla a, euismod sodales, sollicitudin vel, wisi."}, 88 | {0, {"Markab"}, "Nulla facilisi. Aenean nec eros."}, 89 | {0, {"Diphda"}, "Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere."}, 90 | {0, {"Enif"}, "Duis cursus, mi quis viverra ornare, eros dolor interdum nulla."}, 91 | {0, {"Unukalhai"}, "Fusce lacinia arcu et nulla."}, 92 | {0, {"Gienah"}, "Suspendisse in justo eu magna luctus suscipit."}, 93 | {0, {"Algol"}, "Curabitur at lacus ac velit ornare lobortis."}, 94 | {0, {"Menkar"}, "Nullam nulla eros, ultricies sit amet, nonummy id, imperdiet feugiat."}, 95 | {0, {"Saiph"}, "Phasellus viverra nulla ut metus varius laoreet."}, 96 | {0, {"Izar"}, "Quisque rutrum. Aenean imperdiet."}, 97 | {0, {"Alhena"}, "Etiam ultricies nisi vel augue."}, 98 | {0, {"Menkalinan"}, "Curabitur ullamcorper ultricies nisi."}, 99 | {0, {"Avior"}, "Donec mollis hendrerit risus."}, 100 | {0, {"Peacock"}, "Praesent egestas tristique nibh."}, 101 | {0, {"Hamal"}, "Curabitur blandit mollis lacus."}, 102 | {0, {"Eltanin"}, "Nam adipiscing. Vestibulum eu odio."}, 103 | {0, {"Sadalmelik"}, "Curabitur vestibulum aliquam leo."}, 104 | {0, {"Ankaa"}, "Pellentesque habitant morbi tristique senectus et netus et malesuada."}, 105 | {0, {"Tarazed"}, "Nunc nonummy metus. Vestibulum volutpat pretium libero."}, 106 | {0, {"Caph"}, "Duis leo. Sed fringilla mauris sit amet nibh."}, 107 | {0, {"Alsephina"}, "Donec sodales sagittis magna."}, 108 | {0, {"Sabik"}, "Fusce fermentum odio nec arcu."} 109 | }; 110 | }; 111 | 112 | void PreloadPreviewFont() { 113 | ImGuiIO &io = ImGui::GetIO(); 114 | ImFontConfig font_cfg; 115 | font_cfg.FontDataOwnedByAtlas = false; 116 | g_font = io.Fonts->AddFontFromMemoryCompressedTTF(LuCon_compressed_data, LuCon_compressed_size, 256.0f, nullptr, 117 | io.Fonts->GetGlyphRangesCyrillic()); 118 | io.Fonts->Build(); 119 | } 120 | 121 | 122 | inline void ShowInteractiveImage( 123 | ImTextureID texture, int texWidth, int texHeight, 124 | InteractiveTextOverlay *overlay) { 125 | 126 | ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; 127 | ImGui::Begin("Preview", nullptr, flags); 128 | if (texture == 0) { 129 | ImGui::TextDisabled("No image loaded"); 130 | ImGui::End(); 131 | return; 132 | } 133 | if (!texture || !overlay || texWidth <= 0 || texHeight <= 0) { 134 | return; 135 | } 136 | 137 | 138 | if (overlay->revalidatePreview) overlay->generatePreview(); 139 | 140 | ImVec2 availSize = ImGui::GetContentRegionAvail(); 141 | float scale = ImMin(availSize.x / static_cast(texWidth), availSize.y / static_cast(texHeight)); 142 | ImVec2 imageSize(texWidth * scale, texHeight * scale); 143 | 144 | ImVec2 offset((availSize.x - imageSize.x) * 0.5f, (availSize.y - imageSize.y) * 0.5f); 145 | ImGui::SetCursorPos(ImGui::GetCursorPos() + offset); 146 | 147 | ImGui::Image(texture, imageSize); 148 | // Get the screen-space top-left of the image. 149 | ImVec2 imgPos = ImGui::GetItemRectMin(); 150 | 151 | if (!g_font) { 152 | ImGui::End(); 153 | return; 154 | } 155 | 156 | float desiredFontSize = overlay->realFontSize(texHeight) * scale; 157 | ImGui::PushFont(g_font); 158 | 159 | float textScale = desiredFontSize / g_font->FontSize; 160 | ImDrawList *drawList = ImGui::GetWindowDrawList(); 161 | ImVec2 letterSize = ImGui::CalcTextSize("M"); 162 | float boxWidth = letterSize.x * textScale; 163 | ImVec2 startPos; 164 | ImVec2 endPos; 165 | for (int i = 0; i < overlay->preview.size(); i++) { 166 | ImVec2 textPos(imgPos.x + overlay->realX() * imageSize.x, imgPos.y + overlay->realY(i) * imageSize.y); 167 | 168 | if (i == 0) { 169 | startPos = textPos; 170 | endPos.x = startPos.x + overlay->params.maxCharsPerLine * boxWidth; 171 | } 172 | 173 | const char *firstText = overlay->preview[i].first.c_str(); 174 | ImVec2 firstBaseTextSize = ImGui::CalcTextSize(firstText); 175 | 176 | ImVec2 firstTextSize(firstBaseTextSize.x * textScale, firstBaseTextSize.y * textScale); 177 | 178 | 179 | const char *secondText = overlay->preview[i].second.c_str(); 180 | 181 | ImVec2 secondBaseTextSize = ImGui::CalcTextSize(secondText); 182 | 183 | ImVec2 secondTextSize(secondBaseTextSize.x * textScale, secondBaseTextSize.y * textScale); 184 | 185 | 186 | const Color randomColor = getRandomColor(firstText); 187 | const auto &textColor = overlay->params.textForegroundColor; 188 | drawList->AddText(g_font, desiredFontSize, textPos, 189 | IM_COL32(randomColor.r, randomColor.g, randomColor.b, textColor.a), 190 | firstText); 191 | 192 | 193 | textPos.x += firstTextSize.x; 194 | endPos.y = textPos.y + secondTextSize.y; 195 | drawList->AddText(g_font, desiredFontSize, textPos, 196 | IM_COL32(textColor.r, textColor.g, textColor.b, textColor.a), 197 | secondText); 198 | 199 | } 200 | ImGui::PopFont(); 201 | 202 | const ImVec2 imgEndPos = imgPos + imageSize; 203 | overlay->isInsidePicture = startPos.x >= imgPos.x && 204 | startPos.y >= imgPos.y && 205 | endPos.x <= imgEndPos.x && 206 | endPos.y <= imgEndPos.y; 207 | 208 | ImGui::GetWindowDrawList()->AddRect( 209 | startPos, 210 | endPos, 211 | overlay->isInsidePicture ? IM_COL32(255, 255, 0, 200) : IM_COL32(255, 0, 0, 255), 212 | 0.0f, 213 | 0, 214 | overlay->isInsidePicture ? 2.0f : 4.0f 215 | ); 216 | 217 | ImGui::End(); 218 | } 219 | -------------------------------------------------------------------------------- /ytt.ytt: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 |

Bold

113 |

Italic

114 |

Underline

115 |

Bold Italic Underline

116 | 117 | 123 | 124 | 125 |

Red (a = 40)

126 |

Green (a = 127)

127 |

Blue (a = 254)

128 |

Red Green Blue

129 | 130 | 131 |

Red (a = 40)

132 |

Green (a = 127)

133 |

Blue (a = 254)

134 |

Opaque

135 |

Red Green Blue

136 | 137 | 138 |

Edge type 1

139 |

Edge type 2

140 |

Edge type 3

141 |

Edge type 4

142 |

One Two Three Four

143 | 144 | 145 |

Font 0

146 |

Font 1

147 |

Font 2

148 |

Font 3

149 |

Font 4

150 |

Font 5

151 |

Font 6

152 |

Font 7

153 | 154 | 155 |

Zero One Two Three Four Five Six Seven

156 | 157 | 158 |

30%

159 |

100%

160 |

300%

161 |

30% 100% 300%

162 | 163 | 164 |

Top left

165 |

Top center

166 |

Top right

167 |

Middle left

168 |

Middle center

169 |

Middle right

170 |

Bottom left

171 |

Bottom center

172 |

Bottom right

173 | 174 | 175 |

Left- 176 | aligned line

177 |

Centered 178 | line

179 |

Right- 180 | aligned line

181 | 182 | 183 | 184 | 185 |

Karaoke

186 | 187 | 188 |

Box​ 189 | at start of span

190 |

Line break 191 | in middle of span

192 |

Line break 193 | at end of span

194 | 195 | 205 |

(かん)()

206 | 207 | 208 |

Subscript Regular Superscript

209 | 210 | 211 |

Vertical 212 | text (RTL)

213 |

Vertical 214 | text (LTR)

215 |

Rotated 216 | text (RTL)

217 |

Rotated 218 | text (LTR)

219 | 220 |
221 | -------------------------------------------------------------------------------- /ytt_generator.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | #include 15 | #include 16 | #include 17 | #include "utf8.h" 18 | #include "tinyxml2.h" 19 | #include "SimpleIni.h" 20 | #include "magic_enum.hpp" 21 | #include 22 | 23 | // Returns the number of UTF‑8 code points in s. 24 | inline int utf8_length(const std::string &s) { 25 | return static_cast(utf8::distance(s.begin(), s.end())); 26 | } 27 | 28 | // Returns the first 'count' UTF‑8 code points of s. 29 | inline std::string utf8_substr(const std::string &s, int count) { 30 | auto it = s.begin(); 31 | int i = 0; 32 | while (it != s.end() && i < count) { 33 | utf8::next(it, s.end()); 34 | ++i; 35 | } 36 | return {s.begin(), it}; 37 | } 38 | 39 | // Returns the remainder of s after consuming the first 'count' UTF‑8 code points. 40 | inline std::string utf8_consume(const std::string &s, int count) { 41 | auto it = s.begin(); 42 | int i = 0; 43 | while (it != s.end() && i < count) { 44 | utf8::next(it, s.end()); 45 | ++i; 46 | } 47 | return {it, s.end()}; 48 | } 49 | 50 | template 51 | struct Clamped { 52 | T value; 53 | 54 | Clamped(T v = 0) : value(clamp(v)) { 55 | } 56 | 57 | static T clamp(T v) { 58 | return (v > Max) ? Max : v; 59 | } 60 | 61 | operator T() const { 62 | return value; 63 | } 64 | 65 | Clamped &operator=(T v) { 66 | value = clamp(v); 67 | return *this; 68 | } 69 | }; 70 | 71 | struct Color { 72 | static constexpr int maxValue = 254; 73 | typedef unsigned char cType; 74 | 75 | Clamped r; 76 | Clamped g; 77 | Clamped b; 78 | Clamped a; 79 | 80 | 81 | static int hexToInt(char c) { 82 | if (c >= '0' && c <= '9') return c - '0'; 83 | if (c >= 'A' && c <= 'F') return c - 'A' + 10; 84 | if (c >= 'a' && c <= 'f') return c - 'a' + 10; 85 | return 0; 86 | } 87 | 88 | 89 | void parseHex(const std::string &hex) { 90 | if (hex.empty()) return; 91 | 92 | std::string cleaned = hex; 93 | if (cleaned[0] == '#') { 94 | cleaned = cleaned.substr(1); 95 | } 96 | 97 | // Convert to uppercase for consistency 98 | std::ranges::transform(cleaned, cleaned.begin(), ::toupper); 99 | 100 | // Support formats: 101 | // - #RGB : 3-digit, assume opaque (alpha = maxValue) 102 | // - #RGBA : 4-digit, includes alpha 103 | // - #RRGGBB : 6-digit, assume opaque 104 | // - #RRGGBBAA : 8-digit, includes alpha 105 | if (cleaned.length() == 3) { 106 | r = static_cast(hexToInt(cleaned[0]) * 16 + hexToInt(cleaned[0])); 107 | g = static_cast(hexToInt(cleaned[1]) * 16 + hexToInt(cleaned[1])); 108 | b = static_cast(hexToInt(cleaned[2]) * 16 + hexToInt(cleaned[2])); 109 | a = maxValue; 110 | } else if (cleaned.length() == 4) { 111 | r = static_cast(hexToInt(cleaned[0]) * 16 + hexToInt(cleaned[0])); 112 | g = static_cast(hexToInt(cleaned[1]) * 16 + hexToInt(cleaned[1])); 113 | b = static_cast(hexToInt(cleaned[2]) * 16 + hexToInt(cleaned[2])); 114 | a = static_cast(hexToInt(cleaned[3]) * 16 + hexToInt(cleaned[3])); 115 | } else if (cleaned.length() == 6) { 116 | r = static_cast(hexToInt(cleaned[0]) * 16 + hexToInt(cleaned[1])); 117 | g = static_cast(hexToInt(cleaned[2]) * 16 + hexToInt(cleaned[3])); 118 | b = static_cast(hexToInt(cleaned[4]) * 16 + hexToInt(cleaned[5])); 119 | a = maxValue; 120 | } else if (cleaned.length() == 8) { 121 | r = static_cast(hexToInt(cleaned[0]) * 16 + hexToInt(cleaned[1])); 122 | g = static_cast(hexToInt(cleaned[2]) * 16 + hexToInt(cleaned[3])); 123 | b = static_cast(hexToInt(cleaned[4]) * 16 + hexToInt(cleaned[5])); 124 | a = static_cast(hexToInt(cleaned[6]) * 16 + hexToInt(cleaned[7])); 125 | } 126 | } 127 | 128 | Color() = default; 129 | 130 | Color(cType red, cType green, cType blue, cType alpha = maxValue) 131 | : r(red), g(green), b(blue), a(alpha) { 132 | } 133 | 134 | Color(const std::string &hexCode) { 135 | parseHex(hexCode); 136 | } 137 | 138 | Color(const char *hexCode) { 139 | if (hexCode) parseHex(hexCode); 140 | } 141 | 142 | // Convert to hex string (always uppercase with #) 143 | // If alpha is maxValue, the output will be in #RRGGBB format; 144 | // otherwise, it will include alpha as #RRGGBBAA. 145 | std::string toHexString() const { 146 | std::stringstream ss; 147 | ss << '#' << std::uppercase << std::hex << std::setfill('0') 148 | << std::setw(2) << static_cast(r) 149 | << std::setw(2) << static_cast(g) 150 | << std::setw(2) << static_cast(b); 151 | if (a != maxValue) { 152 | ss << std::setw(2) << static_cast(a); 153 | } 154 | return ss.str(); 155 | } 156 | 157 | operator std::string() const { 158 | return toHexString(); 159 | } 160 | 161 | bool operator==(const Color &other) const { 162 | return r == other.r && g == other.g && b == other.b && a == other.a; 163 | } 164 | 165 | bool operator!=(const Color &other) const { 166 | return !(*this == other); 167 | } 168 | 169 | bool operator<(const Color &other) const { 170 | if (r != other.r) return r < other.r; 171 | if (g != other.g) return g < other.g; 172 | if (b != other.b) return b < other.b; 173 | return a < other.a; 174 | } 175 | 176 | std::string toAssColor() const { 177 | std::stringstream ss; 178 | ss << "{\\c&H" 179 | << std::uppercase << std::hex << std::setfill('0') 180 | << std::setw(2) << static_cast(b) 181 | << std::setw(2) << static_cast(g) 182 | << std::setw(2) << static_cast(r) 183 | << "&"; 184 | 185 | // Here we check if alpha is not equal to maxValue (opaque). 186 | // If you want to force output alpha, simply output it. 187 | if (a != maxValue) { 188 | ss << "\\a&H" 189 | << std::setw(2) << static_cast(a) 190 | << "&"; 191 | } 192 | ss << "}"; 193 | return ss.str(); 194 | } 195 | }; 196 | 197 | enum class HorizontalAlignment { 198 | Left, Center, Right 199 | }; 200 | 201 | enum class FontStyle { 202 | Default, 203 | Monospaced, // Courier New 204 | Proportional, // Times New Roman 205 | MonospacedSans, // Lucida Console 206 | ProportionalSans, // Roboto 207 | Casual, // Comic Sans! 208 | Cursive, // Monotype Corsiva 209 | SmallCapitals // (Arial with font-variant: small-caps) 210 | }; 211 | 212 | enum class EdgeType { 213 | None, HardShadow, Bevel, GlowOutline, SoftShadow 214 | }; 215 | 216 | enum class TextAlignment { 217 | Left, Right, Center 218 | }; 219 | 220 | 221 | template 222 | std::string enumToString(E e) { 223 | auto name = magic_enum::enum_name(e); 224 | return name.empty() 225 | ? std::to_string(static_cast>(e)) 226 | : std::string(name); 227 | } 228 | 229 | template 230 | E enumFromString(const std::string &s) { 231 | if (auto v = magic_enum::enum_cast(s)) 232 | return *v; 233 | try { 234 | auto i = std::stoi(s); 235 | return static_cast(i); 236 | } catch (...) { 237 | throw std::invalid_argument( 238 | "Invalid " + std::string(magic_enum::enum_type_name()) 239 | + " value: '" + s + "'"); 240 | } 241 | } 242 | 243 | template 244 | std::string enumToIntString(E e) { 245 | return std::to_string(magic_enum::enum_integer(e)); 246 | } 247 | 248 | template 249 | std::string enumOptionsComment() { 250 | std::string c = ";Options: "; 251 | bool first = true; 252 | for (auto name: magic_enum::enum_names()) { 253 | if (!first) c += ", "; 254 | c += name; 255 | first = false; 256 | } 257 | return c; 258 | } 259 | 260 | 261 | struct ChatParams { 262 | bool textBold = false; 263 | bool textItalic = false; 264 | bool textUnderline = false; 265 | 266 | Color textForegroundColor = {254, 254, 254, 254}; 267 | Color textBackgroundColor = {254, 254, 254, 0}; 268 | Color textEdgeColor = {0, 0, 0, 254}; 269 | 270 | EdgeType textEdgeType = EdgeType::SoftShadow; 271 | FontStyle fontStyle = FontStyle::MonospacedSans; 272 | int fontSizePercent = 0; 273 | 274 | TextAlignment textAlignment = TextAlignment::Left; 275 | int horizontalMargin = 71; 276 | int verticalMargin = 0; 277 | int verticalSpacing = -1; 278 | int totalDisplayLines = 13; 279 | 280 | int maxCharsPerLine = 25; 281 | std::string usernameSeparator = ":"; 282 | 283 | void saveToFile(const char *filename) const { 284 | CSimpleIniCaseA ini; 285 | ini.SetUnicode(); 286 | ini.SetQuotes(); 287 | constexpr auto S = "General"; 288 | 289 | // bools 290 | ini.SetBoolValue(S, "bold", textBold, 291 | ";true/false"); 292 | ini.SetBoolValue(S, "italic", textItalic, 293 | ";true/false"); 294 | ini.SetBoolValue(S, "underline", textUnderline, 295 | ";true/false"); 296 | 297 | // colors with format comment 298 | { 299 | const auto hf = textForegroundColor.toHexString(); 300 | ini.SetValue(S, "textForegroundColor", hf.c_str(), 301 | ";Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA"); 302 | } { 303 | const auto hb = textBackgroundColor.toHexString(); 304 | ini.SetValue(S, "textBackgroundColor", hb.c_str(), 305 | ";Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA"); 306 | } { 307 | const auto he = textEdgeColor.toHexString(); 308 | ini.SetValue(S, "textEdgeColor", he.c_str(), 309 | ";Hex color: #RGB, #RGBA, #RRGGBB or #RRGGBBAA"); 310 | } 311 | 312 | // enums with generated option lists 313 | { 314 | const auto val = enumToString(textEdgeType); 315 | const auto cm = enumOptionsComment(); 316 | ini.SetValue(S, "textEdgeType", val.c_str(), cm.c_str()); 317 | } { 318 | const auto val = enumToString(fontStyle); 319 | const auto cm = enumOptionsComment(); 320 | ini.SetValue(S, "fontStyle", val.c_str(), cm.c_str()); 321 | } 322 | ini.SetLongValue(S, "fontSizePercent", fontSizePercent, 323 | ";0–300 (virtual percent)"); { 324 | const auto val = enumToString(textAlignment); 325 | const auto cm = enumOptionsComment(); 326 | ini.SetValue(S, "textAlignment", val.c_str(), cm.c_str()); 327 | } 328 | ini.SetLongValue(S, "horizontalMargin", horizontalMargin, 329 | ";0–100 (virtual percent)"); 330 | ini.SetLongValue(S, "verticalMargin", verticalMargin, 331 | ";0-100 (virtual percent)"); 332 | ini.SetLongValue(S, "verticalSpacing", verticalSpacing, 333 | ";virtual pixels"); 334 | ini.SetLongValue(S, "totalDisplayLines", totalDisplayLines, 335 | ";lines"); 336 | 337 | ini.SetLongValue(S, "maxCharsPerLine", maxCharsPerLine, 338 | ";characters"); 339 | ini.SetValue(S, "usernameSeparator", usernameSeparator.c_str(), 340 | ";string between name and message"); 341 | 342 | ini.SaveFile(filename); 343 | } 344 | 345 | bool loadFromFile(const char *filename) { 346 | CSimpleIniCaseA ini; 347 | ini.SetUnicode(); 348 | ini.SetQuotes(); 349 | if (ini.LoadFile(filename) < 0) return false; 350 | constexpr auto S = "General"; 351 | 352 | // bools (fallback to existing defaults) 353 | textBold = ini.GetBoolValue(S, "bold", textBold); 354 | textItalic = ini.GetBoolValue(S, "italic", textItalic); 355 | textUnderline = ini.GetBoolValue(S, "underline", textUnderline); 356 | 357 | // colors 358 | textForegroundColor = Color{ 359 | ini.GetValue(S, "textForegroundColor", 360 | textForegroundColor.toHexString().c_str()) 361 | }; 362 | textBackgroundColor = Color{ 363 | ini.GetValue(S, "textBackgroundColor", 364 | textBackgroundColor.toHexString().c_str()) 365 | }; 366 | textEdgeColor = Color{ 367 | ini.GetValue(S, "textEdgeColor", 368 | textEdgeColor.toHexString().c_str()) 369 | }; 370 | 371 | // enums (fallback via default string) 372 | try { 373 | textEdgeType = enumFromString( 374 | ini.GetValue(S, "textEdgeType", 375 | enumToString(textEdgeType).c_str())); 376 | } catch (...) { 377 | } 378 | try { 379 | fontStyle = enumFromString( 380 | ini.GetValue(S, "fontStyle", 381 | enumToString(fontStyle).c_str())); 382 | } catch (...) { 383 | } 384 | try { 385 | textAlignment = enumFromString( 386 | ini.GetValue(S, "textAlignment", 387 | enumToString(textAlignment).c_str())); 388 | } catch (...) { 389 | } 390 | fontSizePercent = static_cast( 391 | ini.GetLongValue(S, "fontSizePercent", 392 | fontSizePercent)); 393 | horizontalMargin = static_cast( 394 | ini.GetLongValue(S, "horizontalMargin", 395 | horizontalMargin)); 396 | verticalMargin = static_cast( 397 | ini.GetLongValue(S, "verticalMargin", 398 | verticalMargin)); 399 | verticalSpacing = static_cast( 400 | ini.GetLongValue(S, "verticalSpacing", 401 | verticalSpacing)); 402 | totalDisplayLines = static_cast( 403 | ini.GetLongValue(S, "totalDisplayLines", 404 | totalDisplayLines)); 405 | 406 | maxCharsPerLine = static_cast( 407 | ini.GetLongValue(S, "maxCharsPerLine", 408 | maxCharsPerLine)); 409 | usernameSeparator = ini.GetValue(S, "usernameSeparator", 410 | usernameSeparator.c_str()); 411 | 412 | 413 | return true; 414 | } 415 | }; 416 | 417 | struct User { 418 | std::string name; 419 | Color color; 420 | }; 421 | 422 | 423 | // A single parsed chat message. 424 | struct ChatMessage { 425 | uint64_t time = 0; // Timestamp in milliseconds 426 | User user; 427 | std::string message; 428 | }; 429 | 430 | // A single wrapped chat line. 431 | struct ChatLine { 432 | std::optional user; 433 | std::string text; 434 | }; 435 | 436 | // A batch represents the current accumulated chat lines at a given timestamp. 437 | struct Batch { 438 | int time; 439 | std::deque lines; 440 | }; 441 | 442 | inline std::pair > wrapMessage(std::string username, 443 | std::string separator, 444 | const std::string &message, 445 | int maxWidth) { 446 | std::vector lines; 447 | int availableSpace = maxWidth; 448 | if (utf8_length(username) > maxWidth) { 449 | username = utf8_substr(username, maxWidth); 450 | lines.push_back(""); 451 | } else { 452 | availableSpace -= utf8_length(username); 453 | } 454 | 455 | if (utf8_length(separator) > availableSpace) { 456 | separator = utf8_substr(separator, availableSpace); 457 | } 458 | lines.push_back(separator); 459 | availableSpace -= utf8_length(separator); 460 | 461 | 462 | std::istringstream iss(message); 463 | std::vector words; 464 | std::string word; 465 | bool firstWord = true; 466 | while (iss >> word) { 467 | bool bigWord = false; 468 | while (utf8_length(word) > maxWidth) { 469 | bigWord = true; 470 | if (availableSpace < 2) { 471 | availableSpace = maxWidth; 472 | lines.push_back(utf8_substr(word, availableSpace)); 473 | firstWord = false; 474 | } else { 475 | if (!firstWord) { 476 | lines.back() += " "; 477 | availableSpace--; 478 | } 479 | lines.back() += utf8_substr(word, availableSpace); 480 | firstWord = false; 481 | } 482 | word = utf8_consume(word, availableSpace); // add split 483 | availableSpace = 0; 484 | } 485 | if (bigWord) { 486 | //if (utf8_length(word) < availableSpace) word += " "; 487 | lines.push_back(word); 488 | availableSpace = maxWidth - utf8_length(word); 489 | firstWord = false; 490 | continue; 491 | } 492 | if (utf8_length(word) < availableSpace) { 493 | if (!firstWord) { 494 | lines.back() += " "; 495 | availableSpace--; 496 | } 497 | lines.back() += word; 498 | availableSpace -= utf8_length(word); 499 | } else { 500 | //if (utf8_length(word) < maxWidth) word += " "; 501 | lines.push_back(word); 502 | availableSpace = maxWidth - utf8_length(word); 503 | } 504 | firstWord = false; 505 | } 506 | // for (const auto& line :lines){ 507 | // assert(utf8_length(line)<=maxWidth); 508 | // } 509 | return {username, lines}; 510 | } 511 | 512 | inline std::vector generateBatches(const std::vector &messages, const ChatParams ¶ms) { 513 | std::vector batches; 514 | std::deque currentLines; 515 | for (const auto &msg: messages) { 516 | auto [username, wrapped] = wrapMessage(msg.user.name, params.usernameSeparator, msg.message, 517 | params.maxCharsPerLine); 518 | if (wrapped.empty()) 519 | continue; 520 | 521 | currentLines.emplace_back(std::make_optional(username, msg.user.color), wrapped[0]); 522 | if (currentLines.size() > params.totalDisplayLines) currentLines.pop_front(); 523 | for (size_t i = 1; i < wrapped.size(); ++i) { 524 | currentLines.emplace_back(std::nullopt, wrapped[i]); 525 | if (currentLines.size() > params.totalDisplayLines) currentLines.pop_front(); 526 | } 527 | if (!batches.empty() && batches.back().time == msg.time) 528 | continue; 529 | batches.emplace_back(msg.time, currentLines); 530 | } 531 | return batches; 532 | } 533 | 534 | inline std::string generateXML(const std::vector &batches, const ChatParams ¶ms) { 535 | using namespace tinyxml2; 536 | XMLDocument doc; 537 | 538 | std::map colors; 539 | colors[params.textForegroundColor] = ""; 540 | // Not optimal, but I want to factor out messages 541 | for (const auto &m: batches) { 542 | for (const auto &l: m.lines) { 543 | if (l.user.has_value())colors[l.user->color] = ""; 544 | } 545 | } 546 | 547 | XMLElement *root = doc.NewElement("timedtext"); 548 | root->SetAttribute("format", "3"); 549 | doc.InsertFirstChild(root); 550 | 551 | XMLElement *head = doc.NewElement("head"); 552 | root->InsertEndChild(head); 553 | XMLElement *body = doc.NewElement("body"); 554 | root->InsertEndChild(body); 555 | 556 | // Create pen elements for each unique color. 557 | int penIndex = 0; 558 | for (auto &kv: colors) { 559 | const Color &color = kv.first; 560 | XMLElement *pen = doc.NewElement("pen"); 561 | pen->SetAttribute("id", std::to_string(penIndex).c_str()); 562 | pen->SetAttribute("b", (params.textBold ? "1" : "0")); 563 | pen->SetAttribute("i", (params.textItalic ? "1" : "0")); 564 | pen->SetAttribute("u", (params.textUnderline ? "1" : "0")); 565 | 566 | // Use the friendly textForegroundColor if it differs from default white. 567 | pen->SetAttribute("fc", static_cast(color).c_str()); 568 | pen->SetAttribute("fo", std::to_string(params.textForegroundColor.a).c_str()); 569 | pen->SetAttribute("bc", static_cast(params.textBackgroundColor).c_str()); 570 | pen->SetAttribute("bo", std::to_string(params.textBackgroundColor.a).c_str()); 571 | 572 | // Set edge attributes if provided. 573 | std::string textEdgeType = enumToIntString(params.textEdgeType); 574 | if (!textEdgeType.empty()) { 575 | pen->SetAttribute("ec", static_cast(params.textEdgeColor).c_str()); 576 | pen->SetAttribute("et", textEdgeType.c_str()); 577 | } 578 | 579 | pen->SetAttribute("fs", enumToIntString(params.fontStyle).c_str()); 580 | pen->SetAttribute("sz", std::to_string(params.fontSizePercent).c_str()); 581 | head->InsertEndChild(pen); 582 | kv.second = std::to_string(penIndex); 583 | penIndex++; 584 | } 585 | 586 | // Create workspace element for whatever reason. 587 | XMLElement *ws = doc.NewElement("ws"); 588 | ws->SetAttribute("id", "1"); // default workspace id 589 | ws->SetAttribute("ju", enumToIntString(params.textAlignment).c_str()); 590 | head->InsertEndChild(ws); 591 | 592 | // Create window position (wp) elements. 593 | for (int i = 0; i < params.totalDisplayLines; ++i) { 594 | XMLElement *wp = doc.NewElement("wp"); 595 | wp->SetAttribute("id", std::to_string(i).c_str()); 596 | wp->SetAttribute("ap", "0"); // anchor point 597 | wp->SetAttribute("ah", std::to_string(params.horizontalMargin).c_str()); 598 | wp->SetAttribute("av", std::to_string(i * params.verticalSpacing).c_str()); 599 | head->InsertEndChild(wp); 600 | } 601 | // Zero-width space (ZWSP) as a UTF-8 string. 602 | auto defaultPen = colors[params.textForegroundColor]; 603 | constexpr const char *ZWSP = "\xE2\x80\x8B"; 604 | for (size_t batchIndex = 0; batchIndex + 1 < batches.size(); ++batchIndex) { 605 | const Batch &batch = batches[batchIndex]; 606 | const Batch &nextBatch = batches[batchIndex + 1]; 607 | if (params.verticalSpacing == -1) { 608 | XMLElement *pElem = doc.NewElement("p"); 609 | pElem->SetAttribute("t", std::to_string(batch.time).c_str()); 610 | int duration = nextBatch.time - batch.time; 611 | pElem->SetAttribute("d", std::to_string(duration).c_str()); 612 | pElem->SetAttribute("wp", "0"); 613 | pElem->SetAttribute("ws", "1"); 614 | pElem->SetAttribute("p", defaultPen.c_str()); 615 | pElem->LinkEndChild(doc.NewText("")); 616 | 617 | for (const auto &[idx, line]: batch.lines | std::ranges::views::enumerate) { 618 | if (line.user.has_value()) { 619 | XMLElement *sUser = doc.NewElement("s"); 620 | sUser->SetAttribute("p", colors[line.user->color].c_str()); 621 | std::string userText = line.user->name; 622 | sUser->SetText(userText.c_str()); 623 | pElem->InsertEndChild(sUser); 624 | pElem->LinkEndChild(doc.NewText(ZWSP)); 625 | } 626 | XMLElement *sText = doc.NewElement("s"); 627 | sText->SetAttribute("p", defaultPen.c_str()); 628 | sText->SetText(line.text.c_str()); 629 | pElem->InsertEndChild(sText); 630 | pElem->LinkEndChild(doc.NewText("\n")); 631 | } 632 | body->InsertEndChild(pElem); 633 | } else { 634 | for (const auto &[idx, line]: batch.lines | std::ranges::views::enumerate) { 635 | XMLElement *pElem = doc.NewElement("p"); 636 | pElem->SetAttribute("t", std::to_string(batch.time).c_str()); 637 | int duration = nextBatch.time - batch.time; 638 | pElem->SetAttribute("d", std::to_string(duration).c_str()); 639 | pElem->SetAttribute("wp", std::to_string(idx).c_str()); 640 | pElem->SetAttribute("ws", "1"); 641 | pElem->SetAttribute("p", defaultPen.c_str()); 642 | 643 | pElem->LinkEndChild(doc.NewText("")); 644 | if (line.user.has_value()) { 645 | XMLElement *sUser = doc.NewElement("s"); 646 | sUser->SetAttribute("p", colors[line.user->color].c_str()); 647 | std::string userText = line.user->name; 648 | sUser->SetText(userText.c_str()); 649 | pElem->InsertEndChild(sUser); 650 | pElem->LinkEndChild(doc.NewText(ZWSP)); 651 | } 652 | 653 | XMLElement *sText = doc.NewElement("s"); 654 | sText->SetAttribute("p", defaultPen.c_str()); 655 | sText->SetText(line.text.c_str()); 656 | pElem->InsertEndChild(sText); 657 | pElem->LinkEndChild(doc.NewText("")); 658 | 659 | body->InsertEndChild(pElem); 660 | } 661 | } 662 | } 663 | 664 | XMLPrinter printer; 665 | doc.Print(&printer); 666 | return printer.CStr(); 667 | } 668 | 669 | 670 | inline Color getRandomColor(const std::string &username) { 671 | std::vector defaultColors = { 672 | "#ff0000", "#0000ff", "#008000", "#b22222", "#ff7f50", 673 | "#9acd32", "#ff4500", "#2e8b57", "#daa520", "#d2691e", 674 | "#5f9ea0", "#1e90ff", "#ff69b4", "#8a2be2", "#00ff7f" 675 | }; 676 | std::hash hasher; 677 | return defaultColors[hasher(username) % defaultColors.size()]; 678 | } 679 | 680 | // dumb and simple way to parse CSV 681 | inline std::vector parseCSV(const std::filesystem::path &filename, int timeMultiplier) { 682 | std::vector messages; 683 | std::ifstream file(filename); 684 | std::string line; 685 | 686 | if (!file.is_open()) { 687 | std::cerr << "Error: Could not open file " << filename << "\n"; 688 | std::exit(-1); 689 | } 690 | 691 | std::getline(file, line); 692 | if (line != "time,user_name,user_color,message") { 693 | std::cerr << "Error: Unexpected CSV header format.\n"; 694 | std::exit(-1); 695 | } 696 | 697 | while (std::getline(file, line)) { 698 | std::stringstream ss(line); 699 | std::string field; 700 | ChatMessage msg; 701 | 702 | std::getline(ss, field, ','); 703 | msg.time = std::stoi(field) * timeMultiplier; 704 | 705 | std::getline(ss, msg.user.name, ','); 706 | 707 | std::getline(ss, field, ','); 708 | msg.user.color = field.empty() ? getRandomColor(msg.user.name) : Color(field); 709 | 710 | std::getline(ss, msg.message); 711 | 712 | if (msg.message.size() >= 2 && 713 | msg.message.front() == '"' && 714 | msg.message.back() == '"') { 715 | msg.message = msg.message.substr(1, msg.message.size() - 2); 716 | } 717 | 718 | messages.emplace_back(std::move(msg)); 719 | } 720 | 721 | return messages; 722 | } 723 | 724 | inline float realFontScale(int yttFontSize) { 725 | return static_cast((100.0 + (yttFontSize - 100.0) / 4.0) / 100.0); 726 | } 727 | 728 | 729 | inline double assFontSize(const int yttFontSize, const int assHeight) { 730 | return (realFontScale(yttFontSize) * 64.107) / 1440.0 * static_cast(assHeight); 731 | } 732 | 733 | inline double assX(int yttX, const int yttFontSize, const int assWidth) { 734 | return (51.2821 + 24.5844 * yttX + 15.9109 * realFontScale(yttFontSize)) * assWidth / 2560.0; 735 | } 736 | 737 | inline double assY(const int yttY, const int yttFontSize, const int assHeight, const uint32_t line = 0) { 738 | // double rFontScale = realFontScale(yttFontSize); 739 | // 740 | // // double lC = 3.4442 * rFontScale * rFontScale * rFontScale 741 | // // - 8.3489 * rFontScale * rFontScale 742 | // // + 74.5018 * rFontScale - 1.6156; 743 | // // double lC = 3.4441851202 * rFontScale * rFontScale * rFontScale 744 | // // - 8.3488610000 * rFontScale * rFontScale 745 | // // + 74.5018286601 * rFontScale - 1.6155972200; 746 | // double lC = 5.1862772740 * rFontScale * rFontScale * rFontScale 747 | // - 14.1350956536 * rFontScale * rFontScale 748 | // + 80.4294668977 * rFontScale - 3.3718297322; 749 | const int yttY2 = yttY * yttY; 750 | const int yttY3 = yttY2 * yttY; 751 | const double fScale = realFontScale(yttFontSize); 752 | const double fScale2 = fScale * fScale; 753 | 754 | return ((0.0000036448 * fScale - 0.0000102298) * yttY3 + 755 | (0.0001228929 * fScale + 0.0006313481) * yttY2 + 756 | (-0.0299080260 * fScale + 13.8373127706) * yttY + 757 | (2.3156363636 * fScale2 - 2.1625454545 * fScale + 30.1938181818) + 758 | (2.095321207 * fScale2 + 64.752581827 * fScale + 1.158860604) * line 759 | ) * assHeight / 1440.0; 760 | } 761 | 762 | 763 | static std::string formatTime(uint64_t ms) { 764 | uint64_t total = ms / 10; 765 | uint64_t h = total / 360000; 766 | uint64_t m = (total / 6000) % 60; 767 | uint64_t s = (total / 100) % 60; 768 | uint64_t cs = total % 100; 769 | return std::format("{}:{:02}:{:02}.{:02}", h, m, s, cs); 770 | } 771 | 772 | static std::string escapeText(const std::string &raw) { 773 | std::string out; 774 | out.reserve(raw.size()); 775 | for (char c: raw) { 776 | switch (c) { 777 | case '\\': 778 | out += "\\\\"; 779 | break; 780 | case '{': 781 | out += "\\{"; 782 | break; 783 | case '}': 784 | out += "\\}"; 785 | break; 786 | case '\n': 787 | out += "\\n"; 788 | break; 789 | //TODO Improve this 790 | default: 791 | out += c; 792 | break; 793 | } 794 | } 795 | return out; 796 | } 797 | 798 | 799 | inline std::string generateAss(const std::vector &batches, 800 | const ChatParams &chat_params, 801 | int video_width, int video_height) { 802 | static constexpr std::string_view header = 803 | "\xEF\xBB\xBF" // BOM 804 | "[Script Info]\n"; 805 | static constexpr std::string_view info = 806 | "\n; Script generated by Kam1k4dze's SubChat\n" 807 | "Title: SubChat preview\n" 808 | "ScriptType: v4.00+\n" 809 | "WrapStyle: 2\n" 810 | "ScaledBorderAndShadow: yes\n" 811 | "YCbCr Matrix: None\n"; 812 | static constexpr std::string_view stylesHeader = 813 | "[V4+ Styles]\n" 814 | "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, " 815 | "Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, " 816 | "Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"; 817 | static constexpr std::string_view eventsHeader = 818 | "[Events]\n" 819 | "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"; 820 | 821 | static std::string ass; 822 | ass.clear(); 823 | ass += header; 824 | ass += info; 825 | ass += std::format("PlayResX: {}\nPlayResY: {}\nLayoutResX: {}\nLayoutResY: {}\n\n", 826 | video_width, video_height, video_width, video_height); 827 | ass += stylesHeader; 828 | float fontSize = assFontSize(chat_params.fontSizePercent, video_height); 829 | ass += std::format( 830 | "Style: Default,Lucida Console,{:.2f},&H00000000,&H000000FF,&H00000000,&H00000000," 831 | "0,0,0,0,100,100,0,0,1,0,2.5,7,0,0,0,1\n\n", 832 | fontSize 833 | ); 834 | ass += eventsHeader; 835 | double posX = assX(chat_params.horizontalMargin, chat_params.fontSizePercent, video_width); 836 | size_t maxLines = chat_params.totalDisplayLines; 837 | std::vector posY(maxLines); 838 | for (size_t idx = 0; idx < maxLines; ++idx) { 839 | posY[idx] = (chat_params.verticalSpacing < 0) 840 | ? assY(chat_params.verticalMargin, chat_params.fontSizePercent, video_height, idx) 841 | : assY(chat_params.verticalMargin + chat_params.verticalSpacing * idx, 842 | chat_params.fontSizePercent, video_height); 843 | } 844 | 845 | for (size_t i = 0; i + 1 < batches.size(); ++i) { 846 | const auto &curr = batches[i]; 847 | const auto &next = batches[i + 1]; 848 | auto start = formatTime(curr.time); 849 | auto end = formatTime(next.time); 850 | 851 | for (size_t idx = 0; idx < curr.lines.size(); ++idx) { 852 | const auto &line = curr.lines[idx]; 853 | std::format_to(std::back_inserter(ass), 854 | "Dialogue: 0,{},{},Default,,0,0,0,,{{\\pos({:.3f},{:.3f})}}", 855 | start, end, posX, posY[idx] 856 | ); 857 | 858 | if (line.user) { 859 | ass += line.user->color.toAssColor(); 860 | ass += escapeText(line.user->name); 861 | } 862 | ass += chat_params.textForegroundColor.toAssColor(); 863 | ass += escapeText(line.text); 864 | ass += '\n'; 865 | } 866 | } 867 | 868 | return ass; 869 | } 870 | --------------------------------------------------------------------------------