diff --git a/CMake/Assets.cmake b/CMake/Assets.cmake index e3ead57bc..21dda7058 100644 --- a/CMake/Assets.cmake +++ b/CMake/Assets.cmake @@ -150,6 +150,7 @@ set(devilutionx_assets gendata/cutportrw.clx gendata/cutstartw.clx gendata/cutttw.clx + gendata/diabend.srt gendata/pause.trn levels/l1data/sklkngt.dun levels/l2data/bonechat.dun diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 2179dadf2..885c87b69 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -165,6 +165,7 @@ set(libdevilutionx_SRCS utils/language.cpp utils/sdl_bilinear_scale.cpp utils/sdl_thread.cpp + utils/srt_parser.cpp utils/surface_to_clx.cpp utils/timer.cpp) @@ -711,6 +712,16 @@ target_link_dependencies(libdevilutionx_text_render libdevilutionx_utf8 ) +add_devilutionx_object_library(libdevilutionx_subtitle_renderer + engine/render/subtitle_renderer.cpp +) +target_link_dependencies(libdevilutionx_subtitle_renderer + PRIVATE + libdevilutionx_log + libdevilutionx_surface + libdevilutionx_text_render +) + add_devilutionx_object_library(libdevilutionx_ticks engine/ticks.cpp ) @@ -952,6 +963,7 @@ target_link_dependencies(libdevilutionx PUBLIC libdevilutionx_spells libdevilutionx_stores libdevilutionx_strings + libdevilutionx_subtitle_renderer libdevilutionx_text_render libdevilutionx_txtdata libdevilutionx_ticks diff --git a/Source/engine/render/subtitle_renderer.cpp b/Source/engine/render/subtitle_renderer.cpp new file mode 100644 index 000000000..59794a9b1 --- /dev/null +++ b/Source/engine/render/subtitle_renderer.cpp @@ -0,0 +1,167 @@ +#include "engine/render/subtitle_renderer.hpp" + +#include +#include + +#ifdef USE_SDL3 +#include +#include +#include +#include +#else +#include +#endif + +#include "DiabloUI/ui_flags.hpp" +#include "engine/rectangle.hpp" +#include "engine/render/text_render.hpp" +#include "engine/surface.hpp" +#include "utils/log.hpp" +#include "utils/sdl_compat.h" +#include "utils/sdl_wrap.h" +#include "utils/srt_parser.hpp" + +namespace devilution { + +void SubtitleRenderer::LoadSubtitles(const char *videoFilename) +{ + Clear(); + + // Generate subtitle filename by replacing .smk with .srt + std::string subtitlePath(videoFilename); + std::replace(subtitlePath.begin(), subtitlePath.end(), '\\', '/'); + const size_t extPos = subtitlePath.rfind('.'); + subtitlePath = (extPos != std::string::npos ? subtitlePath.substr(0, extPos) : subtitlePath) + ".srt"; + + Log("Loading subtitles from: {}", subtitlePath); + subtitles_ = LoadSrtFile(subtitlePath); + Log("Loaded {} subtitle entries", subtitles_.size()); + if (!subtitles_.empty()) { + Log("First subtitle: {}ms-{}ms: \"{}\"", + subtitles_[0].startTimeMs, + subtitles_[0].endTimeMs, + subtitles_[0].text); + } +} + +void SubtitleRenderer::RenderSubtitles(SDL_Surface *videoSurface, uint32_t videoWidth, uint32_t videoHeight, uint64_t currentTimeMs) +{ + if (subtitles_.empty() || videoSurface == nullptr) + return; + + const std::string subtitleText = GetSubtitleAtTime(subtitles_, currentTimeMs); + if (subtitleText.empty()) + return; + + LogVerbose(LogCategory::Video, "Rendering subtitle at {}ms: \"{}\"", currentTimeMs, subtitleText); + + if (SDLC_SURFACE_BITSPERPIXEL(videoSurface) != 8) + return; + + const int videoWidthInt = static_cast(videoWidth); + const int videoHeightInt = static_cast(videoHeight); + + // Create subtitle overlay surface if not already created + if (subtitleSurface_ == nullptr) { + constexpr int SubtitleMaxHeight = 100; + subtitleSurface_ = SDLWrap::CreateRGBSurface( + 0, videoWidthInt, SubtitleMaxHeight, 8, 0, 0, 0, 0); + + // Create and set up palette for subtitle surface - copy from video surface + subtitlePalette_ = SDLWrap::AllocPalette(); +#ifdef USE_SDL3 + const SDL_Palette *videoPalette = SDL_GetSurfacePalette(videoSurface); +#else + const SDL_Palette *videoPalette = videoSurface->format->palette; +#endif + if (videoPalette != nullptr) { + // Copy the video surface's palette so text colors map correctly + SDL_Color *colors = subtitlePalette_->colors; + constexpr int MaxColors = 256; + for (int i = 0; i < MaxColors && i < videoPalette->ncolors; i++) { + colors[i] = videoPalette->colors[i]; + } + // Ensure index 0 is black/transparent for color key + colors[0].r = 0; + colors[0].g = 0; + colors[0].b = 0; + } else { + // Fallback: initialize palette manually + SDL_Color *colors = subtitlePalette_->colors; + colors[0].r = 0; + colors[0].g = 0; + colors[0].b = 0; + constexpr int MaxColors = 256; + for (int i = 1; i < MaxColors; i++) { + colors[i].r = 255; + colors[i].g = 255; + colors[i].b = 255; + } + } +#ifndef USE_SDL1 + constexpr int MaxColors = 256; + for (int i = 0; i < MaxColors; i++) { + subtitlePalette_->colors[i].a = SDL_ALPHA_OPAQUE; + } +#endif + + if (!SDLC_SetSurfacePalette(subtitleSurface_.get(), subtitlePalette_.get())) { + Log("Failed to set subtitle overlay palette"); + } + + // Set color key for transparency (index 0 = transparent) +#ifdef USE_SDL1 + SDL_SetColorKey(subtitleSurface_.get(), SDL_SRCCOLORKEY, 0); +#else + if (!SDL_SetSurfaceColorKey(subtitleSurface_.get(), true, 0)) { + Log("Failed to set color key: {}", SDL_GetError()); + } +#endif + } + + // Clear the overlay surface (fill with transparent color) + SDL_FillSurfaceRect(subtitleSurface_.get(), nullptr, 0); + + // Render text to the overlay surface + Surface overlaySurface(subtitleSurface_.get()); + constexpr int SubtitleMaxHeight = 100; + constexpr int SubtitleBottomPadding = 12; + // Position text rectangle at bottom of overlay (FontSize12 has line height ~12) + // Position y so text appears near bottom of overlay + constexpr int TextLineHeight = 12; + const int textY = SubtitleMaxHeight - TextLineHeight - SubtitleBottomPadding; + constexpr int TextHorizontalPadding = 10; + Rectangle subtitleRect { { TextHorizontalPadding, textY }, { videoWidthInt - TextHorizontalPadding * 2, TextLineHeight + SubtitleBottomPadding } }; + + TextRenderOptions opts; + opts.flags = UiFlags::AlignCenter | UiFlags::ColorWhite | UiFlags::FontSize12; + opts.spacing = 1; + DrawString(overlaySurface, subtitleText, subtitleRect, opts); + + // Blit the overlay onto the video surface at the bottom + SDL_Rect dstRect; + dstRect.x = 0; + // Position overlay at the very bottom of the video, with small padding + dstRect.y = videoHeightInt - SubtitleMaxHeight - SubtitleBottomPadding; + dstRect.w = videoWidthInt; + dstRect.h = SubtitleMaxHeight; + +#ifdef USE_SDL3 + if (!SDL_BlitSurface(subtitleSurface_.get(), nullptr, videoSurface, &dstRect)) { + Log("Failed to blit subtitle overlay: {}", SDL_GetError()); + } +#else + if (SDL_BlitSurface(subtitleSurface_.get(), nullptr, videoSurface, &dstRect) < 0) { + Log("Failed to blit subtitle overlay: {}", SDL_GetError()); + } +#endif +} + +void SubtitleRenderer::Clear() +{ + subtitles_.clear(); + subtitleSurface_ = nullptr; + subtitlePalette_ = nullptr; +} + +} // namespace devilution diff --git a/Source/engine/render/subtitle_renderer.hpp b/Source/engine/render/subtitle_renderer.hpp new file mode 100644 index 000000000..eaee26eba --- /dev/null +++ b/Source/engine/render/subtitle_renderer.hpp @@ -0,0 +1,62 @@ +/** + * @file subtitle_renderer.hpp + * + * Subtitle rendering for video playback. + */ +#pragma once + +#include +#include +#include + +#include "utils/sdl_wrap.h" +#include "utils/srt_parser.hpp" + +namespace devilution { + +/** + * @brief Manages subtitle rendering state for video playback + */ +class SubtitleRenderer { +public: + SubtitleRenderer() = default; + ~SubtitleRenderer() = default; + + SubtitleRenderer(const SubtitleRenderer &) = delete; + SubtitleRenderer &operator=(const SubtitleRenderer &) = delete; + SubtitleRenderer(SubtitleRenderer &&) = default; + SubtitleRenderer &operator=(SubtitleRenderer &&) = default; + + /** + * @brief Load subtitles for the given video + * @param videoFilename Path to the video file (will look for matching .srt file) + */ + void LoadSubtitles(const char *videoFilename); + + /** + * @brief Render subtitles onto a video surface + * @param videoSurface The video surface to render subtitles onto + * @param videoWidth Width of the video + * @param videoHeight Height of the video + * @param currentTimeMs Current playback time in milliseconds + */ + void RenderSubtitles(SDL_Surface *videoSurface, uint32_t videoWidth, uint32_t videoHeight, uint64_t currentTimeMs); + + /** + * @brief Clear subtitle data and free resources + */ + void Clear(); + + /** + * @brief Check if subtitles are loaded + * @return True if subtitles are available + */ + bool HasSubtitles() const { return !subtitles_.empty(); } + +private: + std::vector subtitles_; + SDLSurfaceUniquePtr subtitleSurface_; + SDLPaletteUniquePtr subtitlePalette_; +}; + +} // namespace devilution diff --git a/Source/options.cpp b/Source/options.cpp index 52e2730ed..b12e1d5f8 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -516,6 +516,7 @@ AudioOptions::AudioOptions() , walkingSound("Walking Sound", OptionEntryFlags::None, N_("Walking Sound"), N_("Player emits sound when walking."), true) , autoEquipSound("Auto Equip Sound", OptionEntryFlags::None, N_("Auto Equip Sound"), N_("Automatically equipping items on pickup emits the equipment sound."), false) , itemPickupSound("Item Pickup Sound", OptionEntryFlags::None, N_("Item Pickup Sound"), N_("Picking up items emits the items pickup sound."), false) + , showSubtitles("Show Subtitles", OptionEntryFlags::None, N_("Show Subtitles"), N_("Display subtitles during video cutscenes."), true) , sampleRate("Sample Rate", OptionEntryFlags::CantChangeInGame, N_("Sample Rate"), N_("Output sample rate (Hz)."), DEFAULT_AUDIO_SAMPLE_RATE, { 22050, 44100, 48000 }) , channels("Channels", OptionEntryFlags::CantChangeInGame, N_("Channels"), N_("Number of output channels."), DEFAULT_AUDIO_CHANNELS, { 1, 2 }) , bufferSize("Buffer Size", OptionEntryFlags::CantChangeInGame, N_("Buffer Size"), N_("Buffer size (number of frames per channel)."), DEFAULT_AUDIO_BUFFER_SIZE, { 1024, 2048, 5120 }) @@ -532,6 +533,7 @@ std::vector AudioOptions::GetEntries() &walkingSound, &autoEquipSound, &itemPickupSound, + &showSubtitles, &sampleRate, &channels, &bufferSize, diff --git a/Source/options.h b/Source/options.h index 58954f55f..859fad135 100644 --- a/Source/options.h +++ b/Source/options.h @@ -497,6 +497,8 @@ struct AudioOptions : OptionCategoryBase { OptionEntryBoolean autoEquipSound; /** @brief Picking up items emits the items pickup sound. */ OptionEntryBoolean itemPickupSound; + /** @brief Display subtitles during video cutscenes. */ + OptionEntryBoolean showSubtitles; /** @brief Output sample rate (Hz). */ OptionEntryInt sampleRate; diff --git a/Source/storm/storm_svid.cpp b/Source/storm/storm_svid.cpp index 615db05a9..3b5c85b05 100644 --- a/Source/storm/storm_svid.cpp +++ b/Source/storm/storm_svid.cpp @@ -1,9 +1,12 @@ #include "storm/storm_svid.h" +#include #include #include #include #include +#include +#include #ifdef USE_SDL3 #include @@ -30,6 +33,7 @@ #include "engine/assets.hpp" #include "engine/dx.h" #include "engine/palette.h" +#include "engine/render/subtitle_renderer.hpp" #include "options.h" #include "utils/display.h" #include "utils/log.hpp" @@ -37,7 +41,6 @@ #include "utils/sdl_wrap.h" namespace devilution { -namespace { #ifndef NOSOUND #ifdef USE_SDL3 @@ -77,6 +80,11 @@ SDLSurfaceUniquePtr SVidSurface; uint64_t SVidFrameEnd; // The length of a frame in SMK time units. uint32_t SVidFrameLength; +// Video start time in SMK time units (when playback began). +uint64_t SVidStartTime; + +// Subtitle renderer for current video +SubtitleRenderer SVidSubtitleRenderer; bool IsLandscapeFit(unsigned long srcW, unsigned long srcH, unsigned long dstW, unsigned long dstH) { @@ -219,6 +227,13 @@ void UpdatePalette() bool BlitFrame() { + // Render subtitles if available and enabled - do this BEFORE blitting to output + if (*GetOptions().Audio.showSubtitles && SVidSubtitleRenderer.HasSubtitles()) { + const uint64_t currentTimeSmk = GetTicksSmk(); + const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); + SVidSubtitleRenderer.RenderSubtitles(SVidSurface.get(), SVidWidth, SVidHeight, videoTimeMs); + } + #ifndef USE_SDL1 if (renderer != nullptr) { if ( @@ -327,8 +342,6 @@ void SVidInitAudioStream(const SmackerAudioInfo &audioInfo) } #endif -} // namespace - bool SVidPlayBegin(const char *filename, int flags) { if ((flags & 0x10000) != 0 || (flags & 0x20000000) != 0) { @@ -345,6 +358,9 @@ bool SVidPlayBegin(const char *filename, int flags) // 0x800000 // Edge detection // 0x200800 // Clear FB + // Load subtitles if available + SVidSubtitleRenderer.LoadSubtitles(filename); + auto *videoStream = OpenAssetAsSdlRwOps(filename); SVidHandle = Smacker_Open(videoStream); if (!SVidHandle.isValid) { @@ -449,6 +465,7 @@ bool SVidPlayBegin(const char *filename, int flags) UpdatePalette(); SVidFrameEnd = GetTicksSmk() + SVidFrameLength; + SVidStartTime = GetTicksSmk(); return true; } @@ -523,6 +540,7 @@ void SVidPlayEnd() SVidPalette = nullptr; SVidSurface = nullptr; SVidFrameBuffer = nullptr; + SVidSubtitleRenderer.Clear(); #ifndef USE_SDL1 if (renderer != nullptr) { diff --git a/Source/translation_dummy.cpp b/Source/translation_dummy.cpp index e58af8d26..84e488cf0 100644 --- a/Source/translation_dummy.cpp +++ b/Source/translation_dummy.cpp @@ -1041,5 +1041,22 @@ const char *SPELL_RUNE_OF_LIGHT_NAME = P_("spell", "Rune of Light"); const char *SPELL_RUNE_OF_NOVA_NAME = P_("spell", "Rune of Nova"); const char *SPELL_RUNE_OF_IMMOLATION_NAME = P_("spell", "Rune of Immolation"); const char *SPELL_RUNE_OF_STONE_NAME = P_("spell", "Rune of Stone"); +const char *SUBTITLE_DIABEND_0 = N_("The Soulstone burns with hellfire as an eerie"); +const char *SUBTITLE_DIABEND_1 = N_("red glow blurs your vision."); +const char *SUBTITLE_DIABEND_2 = N_("Fresh blood flows into your eyes and you"); +const char *SUBTITLE_DIABEND_3 = N_("begin to hear the tormented whispers of the damned."); +const char *SUBTITLE_DIABEND_4 = N_("You have done what you knew must be done."); +const char *SUBTITLE_DIABEND_5 = N_("The essence of Diablo is contained."); +const char *SUBTITLE_DIABEND_6 = N_("For now."); +const char *SUBTITLE_DIABEND_7 = N_("You pray that you have become strong enough"); +const char *SUBTITLE_DIABEND_8 = N_("to contain the demon and keep him at bay."); +const char *SUBTITLE_DIABEND_9 = N_("Although you have been fortified by your quest,"); +const char *SUBTITLE_DIABEND_10 = N_("you can still feel him, clawing his way"); +const char *SUBTITLE_DIABEND_11 = N_("up from the dark recesses of your soul."); +const char *SUBTITLE_DIABEND_12 = N_("Fighting to retain control, your thoughts turn toward"); +const char *SUBTITLE_DIABEND_13 = N_("the ancient, mystic lands of the Far East."); +const char *SUBTITLE_DIABEND_14 = N_("Perhaps there, beyond the desolate wastes of Aranak,"); +const char *SUBTITLE_DIABEND_15 = N_("you will find an answer."); +const char *SUBTITLE_DIABEND_16 = N_("Or perhaps, salvation."); } // namespace diff --git a/Source/utils/sdl_compat.h b/Source/utils/sdl_compat.h index 898a151e5..872f0ad28 100644 --- a/Source/utils/sdl_compat.h +++ b/Source/utils/sdl_compat.h @@ -325,6 +325,13 @@ inline bool SDL_CursorVisible() { return SDL_ShowCursor(SDL_QUERY) == SDL_ENABLE inline bool SDLC_PointInRect(const SDL_Point *p, const SDL_Rect *r) { return SDL_PointInRect(p, r) == SDL_TRUE; } #endif +#ifdef USE_SDL1 +inline bool SDLC_SetSurfacePalette(SDL_Surface *surface, SDL_Palette *palette) +{ + return SDL_SetPalette(surface, SDL_LOGPAL, palette->colors, 0, palette->ncolors) != 0; +} +#endif + inline bool SDLC_ShowCursor() { return SDL_ShowCursor(SDL_ENABLE) >= 0; } inline bool SDLC_HideCursor() { return SDL_ShowCursor(SDL_DISABLE) >= 0; } diff --git a/Source/utils/srt_parser.cpp b/Source/utils/srt_parser.cpp new file mode 100644 index 000000000..823746e4f --- /dev/null +++ b/Source/utils/srt_parser.cpp @@ -0,0 +1,100 @@ +#include "utils/srt_parser.hpp" + +#include +#include +#include +#include +#include + +#include "engine/assets.hpp" +#include "utils/language.h" +#include "utils/log.hpp" + +namespace devilution { + +uint64_t ParseSrtTimestamp(std::string_view timestamp) +{ + unsigned long long h = 0, m = 0, s = 0, ms = 0; + if (sscanf(timestamp.data(), "%llu:%llu:%llu,%llu", &h, &m, &s, &ms) == 4 + || sscanf(timestamp.data(), "%llu:%llu:%llu.%llu", &h, &m, &s, &ms) == 4) { + return static_cast(h * 3600000ULL + m * 60000ULL + s * 1000ULL + ms); + } + return 0; +} + +std::vector LoadSrtFile(std::string_view subtitlePath) +{ + std::vector subtitles; + + std::string pathStr(subtitlePath); + auto assetData = LoadAsset(pathStr); + if (!assetData.has_value()) { + LogError("Subtitle file not found: {} ({})", subtitlePath, assetData.error()); + return subtitles; + } + + std::string content(assetData->data.get(), assetData->size); + std::istringstream stream(content); + std::string line, text; + uint64_t startTime = 0, endTime = 0; + + while (std::getline(stream, line)) { + // Remove \r if present + if (!line.empty() && line.back() == '\r') + line.pop_back(); + + // Skip empty lines (end of subtitle block) + if (line.empty()) { + if (!text.empty() && startTime < endTime) { + // Remove trailing newline from text + if (!text.empty() && text.back() == '\n') + text.pop_back(); + subtitles.push_back({ startTime, endTime, text }); + text.clear(); + } + continue; + } + + // Check if line is a number (subtitle index) - skip it + if (std::all_of(line.begin(), line.end(), [](char c) { return std::isdigit(static_cast(c)); })) + continue; + + // Check if line contains --> (timestamp line) + const size_t arrowPos = line.find("-->"); + if (arrowPos != std::string::npos) { + const std::string startStr = line.substr(0, arrowPos); + const std::string endStr = line.substr(arrowPos + 3); + startTime = ParseSrtTimestamp(startStr); + endTime = ParseSrtTimestamp(endStr); + continue; + } + + // Otherwise it's subtitle text + if (!text.empty()) + text += "\n"; + text += line; + } + + // Handle last subtitle if file doesn't end with blank line + if (!text.empty() && startTime < endTime) { + if (!text.empty() && text.back() == '\n') + text.pop_back(); + subtitles.push_back({ startTime, endTime, text }); + } + + return subtitles; +} + +std::string GetSubtitleAtTime(const std::vector &subtitles, uint64_t videoTimeMs) +{ + for (const auto &entry : subtitles) { + if (videoTimeMs >= entry.startTimeMs && videoTimeMs < entry.endTimeMs) { + // Translate the subtitle text + std::string_view translated = LanguageTranslate(entry.text); + return std::string(translated); + } + } + return ""; +} + +} // namespace devilution diff --git a/Source/utils/srt_parser.hpp b/Source/utils/srt_parser.hpp new file mode 100644 index 000000000..3866ec55f --- /dev/null +++ b/Source/utils/srt_parser.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include + +namespace devilution { + +struct SubtitleEntry { + uint64_t startTimeMs; // Start time in milliseconds + uint64_t endTimeMs; // End time in milliseconds + std::string text; // Subtitle text (may contain multiple lines) +}; + +/** + * @brief Parse SRT timestamp (HH:MM:SS,mmm or HH:MM:SS.mmm) to milliseconds + * @param timestamp Timestamp string in SRT format + * @return Time in milliseconds, or 0 if parsing fails + */ +uint64_t ParseSrtTimestamp(std::string_view timestamp); + +/** + * @brief Load and parse SRT subtitle file + * @param subtitlePath Path to the SRT file + * @return Vector of subtitle entries, empty if file not found or parsing fails + */ +std::vector LoadSrtFile(std::string_view subtitlePath); + +/** + * @brief Get subtitle text for a given time from a list of subtitle entries + * @param subtitles Vector of subtitle entries + * @param videoTimeMs Current video time in milliseconds + * @return Subtitle text if a subtitle is active at this time, empty string otherwise + */ +std::string GetSubtitleAtTime(const std::vector &subtitles, uint64_t videoTimeMs); + +} // namespace devilution diff --git a/assets/gendata/diabend.srt b/assets/gendata/diabend.srt new file mode 100644 index 000000000..ddf390b02 --- /dev/null +++ b/assets/gendata/diabend.srt @@ -0,0 +1,67 @@ +1 +00:00:00,600 --> 00:00:04,520 +The Soulstone burns with hellfire as an eerie + +2 +00:00:04,520 --> 00:00:06,240 +red glow blurs your vision + +3 +00:00:06,980 --> 00:00:09,560 +Fresh blood flows into your eyes and you + +4 +00:00:09,560 --> 00:00:11,840 +begin to hear the tormented whispers of the damned + +5 +00:00:12,860 --> 00:00:14,440 +You have done what you knew must be done + +6 +00:00:15,360 --> 00:00:17,460 +The essence of Diablo is contained + +7 +00:00:18,200 --> 00:00:18,860 +For now... + +8 +00:00:19,980 --> 00:00:22,240 +You pray that you have become strong enough + +9 +00:00:22,240 --> 00:00:24,820 +to contain the demon and keep him at bay + +10 +00:00:25,200 --> 00:00:28,260 +Although you have been fortified by your quest, + +11 +00:00:28,580 --> 00:00:31,439 +you can still feel him, clawing his way + +12 +00:00:31,439 --> 00:00:33,980 +up from the dark recesses of your soul + +13 +00:00:34,880 --> 00:00:38,040 +Fighting to retain control, your thoughts turn toward + +14 +00:00:38,040 --> 00:00:40,760 +the ancient, mystic lands of the Far East + +15 +00:00:41,300 --> 00:00:45,100 +Perhaps there, beyond the desolate wastes of Aranak, + +16 +00:00:45,340 --> 00:00:46,560 +you will find an answer + +17 +00:00:47,140 --> 00:00:49,340 +Or perhaps, salvation diff --git a/tools/extract_translation_data.py b/tools/extract_translation_data.py index 1a3f551d0..614ed8ac8 100755 --- a/tools/extract_translation_data.py +++ b/tools/extract_translation_data.py @@ -49,6 +49,68 @@ replacement_table = str.maketrans( def create_identifier(value, prefix = '', suffix = ''): return prefix + value.upper().translate(replacement_table) + suffix +def escape_cpp_string(s): + """Escape a string for use in a C++ string literal.""" + return s.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') + +def process_srt_file(srt_path, temp_source, prefix="SUBTITLE"): + """Parse an SRT file and extract subtitle text for translation.""" + if not srt_path.exists(): + return + try: + with open(srt_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception: + return + + lines = content.split('\n') + text = "" + subtitle_index = 0 + i = 0 + while i < len(lines): + line = lines[i] + # Remove \r if present (matching C++ parser behavior) + if line and line.endswith('\r'): + line = line[:-1] + + # Skip empty lines (end of subtitle block) + if not line: + if text: + # Remove trailing newline from text + text = text.rstrip('\n') + if text: + var_name = f'{prefix}_{subtitle_index}' + escaped_text = escape_cpp_string(text) + write_entry(temp_source, var_name, "subtitle", escaped_text, False) + subtitle_index += 1 + text = "" + i += 1 + continue + + # Check if line is a number (subtitle index) - skip it + if line.strip().isdigit(): + i += 1 + continue + + # Check if line contains --> (timestamp line) - skip it + if '-->' in line: + i += 1 + continue + + # Otherwise it's subtitle text + if text: + text += "\n" + text += line + i += 1 + + # Handle last subtitle if file doesn't end with blank line + if text: + text = text.rstrip('\n') + if text: + var_name = f'{prefix}_{subtitle_index}' + escaped_text = escape_cpp_string(text) + write_entry(temp_source, var_name, "subtitle", escaped_text, False) + def process_files(paths, temp_source): # Classes if "classdat" in paths: @@ -140,4 +202,14 @@ with open(translation_dummy_path, 'w') as temp_source: process_files(base_paths, temp_source) process_files(hf_paths, temp_source) + # Process SRT subtitle files + srt_files = [ + root.joinpath("assets/gendata/diabend.srt"), + ] + for srt_file in srt_files: + # Extract filename without extension and convert to uppercase for prefix + filename = srt_file.stem.upper() + prefix = f"SUBTITLE_{filename}" + process_srt_file(srt_file, temp_source, prefix) + temp_source.write(f'\n}} // namespace\n')