From 6d04ba1aae9f2dd3f7e58869b43d67ba5e3ede73 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 30 Dec 2025 16:22:45 -0800 Subject: [PATCH 01/13] Render video subtitles --- Source/CMakeLists.txt | 1 + Source/storm/storm_svid.cpp | 94 ++++++++++++++++++++++++++++++++++++ Source/utils/srt_parser.cpp | 95 +++++++++++++++++++++++++++++++++++++ Source/utils/srt_parser.hpp | 39 +++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 Source/utils/srt_parser.cpp create mode 100644 Source/utils/srt_parser.hpp diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 4501d6cc9..802a9441e 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -164,6 +164,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) diff --git a/Source/storm/storm_svid.cpp b/Source/storm/storm_svid.cpp index 615db05a9..119fe1bd4 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,11 +33,13 @@ #include "engine/assets.hpp" #include "engine/dx.h" #include "engine/palette.h" +#include "engine/render/text_render.hpp" #include "options.h" #include "utils/display.h" #include "utils/log.hpp" #include "utils/sdl_compat.h" #include "utils/sdl_wrap.h" +#include "utils/srt_parser.hpp" namespace devilution { namespace { @@ -77,6 +82,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 entries for current video +std::vector SVidSubtitles; bool IsLandscapeFit(unsigned long srcW, unsigned long srcH, unsigned long dstW, unsigned long dstH) { @@ -294,6 +304,71 @@ bool BlitFrame() } } + // Render subtitles if available + if (!SVidSubtitles.empty()) { + const uint64_t currentTimeSmk = GetTicksSmk(); + const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); + const std::string subtitleText = GetSubtitleAtTime(SVidSubtitles, videoTimeMs); + + if (!subtitleText.empty()) { + SDL_Surface *outputSurface = GetOutputSurface(); + SDL_Rect outputRect; +#ifndef USE_SDL1 + if (renderer != nullptr) { + // When using renderer, video fills the entire output surface + outputRect.w = outputSurface->w; + outputRect.h = outputSurface->h; + outputRect.x = 0; + outputRect.y = 0; + } else +#endif + { + // Calculate video rect (same logic as above) +#ifdef USE_SDL1 + const bool isIndexedOutputFormat = SDLBackport_IsPixelFormatIndexed(outputSurface->format); +#else +#ifdef USE_SDL3 + const SDL_PixelFormat wndFormat = SDL_GetWindowPixelFormat(ghMainWnd); +#else + const Uint32 wndFormat = SDL_GetWindowPixelFormat(ghMainWnd); +#endif + const bool isIndexedOutputFormat = SDL_ISPIXELFORMAT_INDEXED(wndFormat); +#endif + if (isIndexedOutputFormat) { + outputRect.w = static_cast(SVidWidth); + outputRect.h = static_cast(SVidHeight); + } else if (IsLandscapeFit(SVidWidth, SVidHeight, outputSurface->w, outputSurface->h)) { + outputRect.w = outputSurface->w; + outputRect.h = SVidHeight * outputSurface->w / SVidWidth; + } else { + outputRect.w = SVidWidth * outputSurface->h / SVidHeight; + outputRect.h = outputSurface->h; + } + outputRect.x = (outputSurface->w - outputRect.w) / 2; + outputRect.y = (outputSurface->h - outputRect.h) / 2; + } + + // Calculate subtitle position (bottom center, with some padding) + constexpr int SubtitlePadding = 20; + const int subtitleY = outputRect.y + outputRect.h - SubtitlePadding; + const int subtitleX = outputRect.x; + const int subtitleWidth = outputRect.w; + + // Create a surface for rendering text + Surface outSurface(outputSurface); + // Allow enough height for multiple lines of text + constexpr int SubtitleMaxHeight = 120; + const int subtitleRectY = std::max(0, subtitleY - SubtitleMaxHeight); + Rectangle subtitleRect { { subtitleX, subtitleRectY }, { subtitleWidth, SubtitleMaxHeight } }; + + // Render subtitle with white text, centered, and outlined for visibility + TextRenderOptions opts; + opts.flags = UiFlags::AlignCenter | UiFlags::ColorWhite | UiFlags::Outlined; + opts.spacing = 1; + DrawString(outSurface, subtitleText, subtitleRect, opts); + } + } + RenderPresent(); return true; } @@ -327,6 +402,20 @@ void SVidInitAudioStream(const SmackerAudioInfo &audioInfo) } #endif +// Load subtitles for the given video filename +void LoadSubtitles(const char *videoFilename) +{ + SVidSubtitles.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"; + + SVidSubtitles = LoadSrtFile(subtitlePath); +} + } // namespace bool SVidPlayBegin(const char *filename, int flags) @@ -345,6 +434,9 @@ bool SVidPlayBegin(const char *filename, int flags) // 0x800000 // Edge detection // 0x200800 // Clear FB + // Load subtitles if available + LoadSubtitles(filename); + auto *videoStream = OpenAssetAsSdlRwOps(filename); SVidHandle = Smacker_Open(videoStream); if (!SVidHandle.isValid) { @@ -449,6 +541,7 @@ bool SVidPlayBegin(const char *filename, int flags) UpdatePalette(); SVidFrameEnd = GetTicksSmk() + SVidFrameLength; + SVidStartTime = GetTicksSmk(); return true; } @@ -523,6 +616,7 @@ void SVidPlayEnd() SVidPalette = nullptr; SVidSurface = nullptr; SVidFrameBuffer = nullptr; + SVidSubtitles.clear(); #ifndef USE_SDL1 if (renderer != nullptr) { diff --git a/Source/utils/srt_parser.cpp b/Source/utils/srt_parser.cpp new file mode 100644 index 000000000..50df4dd23 --- /dev/null +++ b/Source/utils/srt_parser.cpp @@ -0,0 +1,95 @@ +#include "utils/srt_parser.hpp" + +#include +#include +#include +#include +#include + +#include "engine/assets.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()) + 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) { + return entry.text; + } + } + return ""; +} + +} // namespace devilution + diff --git a/Source/utils/srt_parser.hpp b/Source/utils/srt_parser.hpp new file mode 100644 index 000000000..9bf733cfa --- /dev/null +++ b/Source/utils/srt_parser.hpp @@ -0,0 +1,39 @@ +#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 + From 916e55f0c366b919413f3d1477e062b39b0beca9 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 30 Dec 2025 16:23:27 -0800 Subject: [PATCH 02/13] Add diabend subtitles --- assets/gendata/diabend.srt | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 assets/gendata/diabend.srt diff --git a/assets/gendata/diabend.srt b/assets/gendata/diabend.srt new file mode 100644 index 000000000..53c7308f8 --- /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. From cd015f2d3473cac6bedef1c0b50c5cb4acc51f17 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 30 Dec 2025 16:31:42 -0800 Subject: [PATCH 03/13] Update subtatiles translation extraction --- Source/translation_dummy.cpp | 17 ++++++++ Source/utils/srt_parser.cpp | 5 ++- tools/extract_translation_data.py | 72 +++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) 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/srt_parser.cpp b/Source/utils/srt_parser.cpp index 50df4dd23..148cde835 100644 --- a/Source/utils/srt_parser.cpp +++ b/Source/utils/srt_parser.cpp @@ -7,6 +7,7 @@ #include #include "engine/assets.hpp" +#include "utils/language.h" namespace devilution { @@ -85,7 +86,9 @@ std::string GetSubtitleAtTime(const std::vector &subtitles, uint6 { for (const auto &entry : subtitles) { if (videoTimeMs >= entry.startTimeMs && videoTimeMs < entry.endTimeMs) { - return entry.text; + // Translate the subtitle text + std::string_view translated = LanguageTranslate(entry.text); + return std::string(translated); } } return ""; 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') From 2052750d3895dba3c317c7044dd8d5ba5b2f4e2e Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 30 Dec 2025 18:58:49 -0800 Subject: [PATCH 04/13] Add subtitle file to Assets.cmake --- CMake/Assets.cmake | 1 + 1 file changed, 1 insertion(+) 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 From 6c200df5511ec845877d54a143eec198bfc57776 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 21 Jan 2026 13:51:02 -0800 Subject: [PATCH 05/13] Remove puctuation from subtitle --- assets/gendata/diabend.srt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/gendata/diabend.srt b/assets/gendata/diabend.srt index 53c7308f8..ddf390b02 100644 --- a/assets/gendata/diabend.srt +++ b/assets/gendata/diabend.srt @@ -4,7 +4,7 @@ The Soulstone burns with hellfire as an eerie 2 00:00:04,520 --> 00:00:06,240 -red glow blurs your vision. +red glow blurs your vision 3 00:00:06,980 --> 00:00:09,560 @@ -12,19 +12,19 @@ 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. +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. +You have done what you knew must be done 6 00:00:15,360 --> 00:00:17,460 -The essence of Diablo is contained. +The essence of Diablo is contained 7 00:00:18,200 --> 00:00:18,860 -For now. +For now... 8 00:00:19,980 --> 00:00:22,240 @@ -32,7 +32,7 @@ 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. +to contain the demon and keep him at bay 10 00:00:25,200 --> 00:00:28,260 @@ -44,7 +44,7 @@ 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. +up from the dark recesses of your soul 13 00:00:34,880 --> 00:00:38,040 @@ -52,7 +52,7 @@ 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. +the ancient, mystic lands of the Far East 15 00:00:41,300 --> 00:00:45,100 @@ -60,8 +60,8 @@ Perhaps there, beyond the desolate wastes of Aranak, 16 00:00:45,340 --> 00:00:46,560 -you will find an answer. +you will find an answer 17 00:00:47,140 --> 00:00:49,340 -Or perhaps, salvation. +Or perhaps, salvation From a9098348f2e2ea550b75e8467a7c93f6fb8cd9f3 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 21 Jan 2026 13:52:59 -0800 Subject: [PATCH 06/13] Fix subtitles rendering --- Source/DiabloUI/credits.cpp | 7 +- Source/storm/storm_svid.cpp | 191 ++++++++++++++++++++++------------ Source/utils/srt_parser.cpp | 199 ++++++++++++++++++------------------ 3 files changed, 227 insertions(+), 170 deletions(-) diff --git a/Source/DiabloUI/credits.cpp b/Source/DiabloUI/credits.cpp index 7ac898cb5..75483f647 100644 --- a/Source/DiabloUI/credits.cpp +++ b/Source/DiabloUI/credits.cpp @@ -27,6 +27,7 @@ #include "engine/render/text_render.hpp" #include "engine/surface.hpp" #include "hwcursor.hpp" +#include "movie.h" #include "utils/display.h" #include "utils/is_of.hpp" #include "utils/language.h" @@ -186,10 +187,8 @@ bool TextDialog(const char *const *text, std::size_t textLines) bool UiCreditsDialog() { - ArtBackgroundWidescreen = LoadOptionalClx("ui_art\\creditsw.clx"); - LoadBackgroundArt("ui_art\\credits"); - - return TextDialog(CreditLines, CreditLinesSize); + play_movie("gendata\\diabend.smk", true); + return true; } bool UiSupportDialog() diff --git a/Source/storm/storm_svid.cpp b/Source/storm/storm_svid.cpp index 119fe1bd4..fa5efb1bb 100644 --- a/Source/storm/storm_svid.cpp +++ b/Source/storm/storm_svid.cpp @@ -42,7 +42,6 @@ #include "utils/srt_parser.hpp" namespace devilution { -namespace { #ifndef NOSOUND #ifdef USE_SDL3 @@ -88,6 +87,10 @@ uint64_t SVidStartTime; // Subtitle entries for current video std::vector SVidSubtitles; +// Subtitle overlay surface and palette for rendering text +SDLSurfaceUniquePtr SVidSubtitleSurface; +SDLPaletteUniquePtr SVidSubtitlePalette; + bool IsLandscapeFit(unsigned long srcW, unsigned long srcH, unsigned long dstW, unsigned long dstH) { return srcW * dstH > dstW * srcH; @@ -229,6 +232,115 @@ void UpdatePalette() bool BlitFrame() { + // Render subtitles if available - do this BEFORE blitting to output + if (!SVidSubtitles.empty()) { + const uint64_t currentTimeSmk = GetTicksSmk(); + const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); + const std::string subtitleText = GetSubtitleAtTime(SVidSubtitles, videoTimeMs); + + if (!subtitleText.empty()) { + LogVerbose(LogCategory::Video, "Rendering subtitle at {}ms: \"{}\"", videoTimeMs, subtitleText); + + SDL_Surface *videoSurface = SVidSurface.get(); + + if (videoSurface != nullptr && SDLC_SURFACE_BITSPERPIXEL(videoSurface) == 8) { + const int videoWidth = static_cast(SVidWidth); + const int videoHeight = static_cast(SVidHeight); + + // Create subtitle overlay surface if not already created + if (SVidSubtitleSurface == nullptr) { + constexpr int SubtitleMaxHeight = 100; + SVidSubtitleSurface = SDLWrap::CreateRGBSurface( + 0, videoWidth, SubtitleMaxHeight, 8, 0, 0, 0, 0); + + // Create and set up palette for subtitle surface - copy from video surface + SVidSubtitlePalette = 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 = SVidSubtitlePalette->colors; + for (int i = 0; i < 256 && 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 = SVidSubtitlePalette->colors; + colors[0].r = 0; + colors[0].g = 0; + colors[0].b = 0; + for (int i = 1; i < 256; i++) { + colors[i].r = 255; + colors[i].g = 255; + colors[i].b = 255; + } + } +#ifndef USE_SDL1 + for (int i = 0; i < 256; i++) { + SVidSubtitlePalette->colors[i].a = SDL_ALPHA_OPAQUE; + } +#endif + + if (!SDLC_SetSurfacePalette(SVidSubtitleSurface.get(), SVidSubtitlePalette.get())) { + Log("Failed to set subtitle overlay palette"); + } + + // Set color key for transparency (index 0 = transparent) +#ifdef USE_SDL1 + SDL_SetColorKey(SVidSubtitleSurface.get(), SDL_SRCCOLORKEY, 0); +#else + if (!SDL_SetSurfaceColorKey(SVidSubtitleSurface.get(), true, 0)) { + Log("Failed to set color key: {}", SDL_GetError()); + } +#endif + } + + // Clear the overlay surface (fill with transparent color) + SDL_FillSurfaceRect(SVidSubtitleSurface.get(), nullptr, 0); + + // Render text to the overlay surface + Surface overlaySurface(SVidSubtitleSurface.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; + Rectangle subtitleRect { { 10, textY }, { videoWidth - 20, 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 = videoHeight - SubtitleMaxHeight - SubtitleBottomPadding; + dstRect.w = videoWidth; + dstRect.h = 100; + +#ifdef USE_SDL3 + if (!SDL_BlitSurface(SVidSubtitleSurface.get(), nullptr, videoSurface, &dstRect)) { + Log("Failed to blit subtitle overlay: {}", SDL_GetError()); + } +#else + if (SDL_BlitSurface(SVidSubtitleSurface.get(), nullptr, videoSurface, &dstRect) < 0) { + Log("Failed to blit subtitle overlay: {}", SDL_GetError()); + } +#endif + } + } + } + #ifndef USE_SDL1 if (renderer != nullptr) { if ( @@ -304,71 +416,6 @@ bool BlitFrame() } } - // Render subtitles if available - if (!SVidSubtitles.empty()) { - const uint64_t currentTimeSmk = GetTicksSmk(); - const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); - const std::string subtitleText = GetSubtitleAtTime(SVidSubtitles, videoTimeMs); - - if (!subtitleText.empty()) { - SDL_Surface *outputSurface = GetOutputSurface(); - SDL_Rect outputRect; -#ifndef USE_SDL1 - if (renderer != nullptr) { - // When using renderer, video fills the entire output surface - outputRect.w = outputSurface->w; - outputRect.h = outputSurface->h; - outputRect.x = 0; - outputRect.y = 0; - } else -#endif - { - // Calculate video rect (same logic as above) -#ifdef USE_SDL1 - const bool isIndexedOutputFormat = SDLBackport_IsPixelFormatIndexed(outputSurface->format); -#else -#ifdef USE_SDL3 - const SDL_PixelFormat wndFormat = SDL_GetWindowPixelFormat(ghMainWnd); -#else - const Uint32 wndFormat = SDL_GetWindowPixelFormat(ghMainWnd); -#endif - const bool isIndexedOutputFormat = SDL_ISPIXELFORMAT_INDEXED(wndFormat); -#endif - if (isIndexedOutputFormat) { - outputRect.w = static_cast(SVidWidth); - outputRect.h = static_cast(SVidHeight); - } else if (IsLandscapeFit(SVidWidth, SVidHeight, outputSurface->w, outputSurface->h)) { - outputRect.w = outputSurface->w; - outputRect.h = SVidHeight * outputSurface->w / SVidWidth; - } else { - outputRect.w = SVidWidth * outputSurface->h / SVidHeight; - outputRect.h = outputSurface->h; - } - outputRect.x = (outputSurface->w - outputRect.w) / 2; - outputRect.y = (outputSurface->h - outputRect.h) / 2; - } - - // Calculate subtitle position (bottom center, with some padding) - constexpr int SubtitlePadding = 20; - const int subtitleY = outputRect.y + outputRect.h - SubtitlePadding; - const int subtitleX = outputRect.x; - const int subtitleWidth = outputRect.w; - - // Create a surface for rendering text - Surface outSurface(outputSurface); - // Allow enough height for multiple lines of text - constexpr int SubtitleMaxHeight = 120; - const int subtitleRectY = std::max(0, subtitleY - SubtitleMaxHeight); - Rectangle subtitleRect { { subtitleX, subtitleRectY }, { subtitleWidth, SubtitleMaxHeight } }; - - // Render subtitle with white text, centered, and outlined for visibility - TextRenderOptions opts; - opts.flags = UiFlags::AlignCenter | UiFlags::ColorWhite | UiFlags::Outlined; - opts.spacing = 1; - DrawString(outSurface, subtitleText, subtitleRect, opts); - } - } - RenderPresent(); return true; } @@ -413,11 +460,17 @@ void LoadSubtitles(const char *videoFilename) const size_t extPos = subtitlePath.rfind('.'); subtitlePath = (extPos != std::string::npos ? subtitlePath.substr(0, extPos) : subtitlePath) + ".srt"; + Log("Loading subtitles from: {}", subtitlePath); SVidSubtitles = LoadSrtFile(subtitlePath); + Log("Loaded {} subtitle entries", SVidSubtitles.size()); + if (!SVidSubtitles.empty()) { + Log("First subtitle: {}ms-{}ms: \"{}\"", + SVidSubtitles[0].startTimeMs, + SVidSubtitles[0].endTimeMs, + SVidSubtitles[0].text); + } } -} // namespace - bool SVidPlayBegin(const char *filename, int flags) { if ((flags & 0x10000) != 0 || (flags & 0x20000000) != 0) { @@ -617,6 +670,8 @@ void SVidPlayEnd() SVidSurface = nullptr; SVidFrameBuffer = nullptr; SVidSubtitles.clear(); + SVidSubtitleSurface = nullptr; + SVidSubtitlePalette = nullptr; #ifndef USE_SDL1 if (renderer != nullptr) { diff --git a/Source/utils/srt_parser.cpp b/Source/utils/srt_parser.cpp index 148cde835..c4a4ae998 100644 --- a/Source/utils/srt_parser.cpp +++ b/Source/utils/srt_parser.cpp @@ -1,98 +1,101 @@ -#include "utils/srt_parser.hpp" - -#include -#include -#include -#include -#include - -#include "engine/assets.hpp" -#include "utils/language.h" - -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()) - 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 - +#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 + From 1ce3f315331321b4bd3769d381010c508d096b74 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 21 Jan 2026 14:01:22 -0800 Subject: [PATCH 07/13] Extract subtitle renderer --- Source/CMakeLists.txt | 11 ++ Source/engine/render/subtitle_renderer.cpp | 167 +++++++++++++++++++++ Source/engine/render/subtitle_renderer.hpp | 62 ++++++++ Source/storm/storm_svid.cpp | 145 +----------------- 4 files changed, 247 insertions(+), 138 deletions(-) create mode 100644 Source/engine/render/subtitle_renderer.cpp create mode 100644 Source/engine/render/subtitle_renderer.hpp diff --git a/Source/CMakeLists.txt b/Source/CMakeLists.txt index 802a9441e..d3144b2fb 100644 --- a/Source/CMakeLists.txt +++ b/Source/CMakeLists.txt @@ -711,6 +711,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 +962,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/storm/storm_svid.cpp b/Source/storm/storm_svid.cpp index fa5efb1bb..c49db8df8 100644 --- a/Source/storm/storm_svid.cpp +++ b/Source/storm/storm_svid.cpp @@ -33,13 +33,12 @@ #include "engine/assets.hpp" #include "engine/dx.h" #include "engine/palette.h" -#include "engine/render/text_render.hpp" +#include "engine/render/subtitle_renderer.hpp" #include "options.h" #include "utils/display.h" #include "utils/log.hpp" #include "utils/sdl_compat.h" #include "utils/sdl_wrap.h" -#include "utils/srt_parser.hpp" namespace devilution { @@ -84,12 +83,8 @@ uint32_t SVidFrameLength; // Video start time in SMK time units (when playback began). uint64_t SVidStartTime; -// Subtitle entries for current video -std::vector SVidSubtitles; - -// Subtitle overlay surface and palette for rendering text -SDLSurfaceUniquePtr SVidSubtitleSurface; -SDLPaletteUniquePtr SVidSubtitlePalette; +// Subtitle renderer for current video +SubtitleRenderer SVidSubtitleRenderer; bool IsLandscapeFit(unsigned long srcW, unsigned long srcH, unsigned long dstW, unsigned long dstH) { @@ -233,112 +228,10 @@ void UpdatePalette() bool BlitFrame() { // Render subtitles if available - do this BEFORE blitting to output - if (!SVidSubtitles.empty()) { + if (SVidSubtitleRenderer.HasSubtitles()) { const uint64_t currentTimeSmk = GetTicksSmk(); const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); - const std::string subtitleText = GetSubtitleAtTime(SVidSubtitles, videoTimeMs); - - if (!subtitleText.empty()) { - LogVerbose(LogCategory::Video, "Rendering subtitle at {}ms: \"{}\"", videoTimeMs, subtitleText); - - SDL_Surface *videoSurface = SVidSurface.get(); - - if (videoSurface != nullptr && SDLC_SURFACE_BITSPERPIXEL(videoSurface) == 8) { - const int videoWidth = static_cast(SVidWidth); - const int videoHeight = static_cast(SVidHeight); - - // Create subtitle overlay surface if not already created - if (SVidSubtitleSurface == nullptr) { - constexpr int SubtitleMaxHeight = 100; - SVidSubtitleSurface = SDLWrap::CreateRGBSurface( - 0, videoWidth, SubtitleMaxHeight, 8, 0, 0, 0, 0); - - // Create and set up palette for subtitle surface - copy from video surface - SVidSubtitlePalette = 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 = SVidSubtitlePalette->colors; - for (int i = 0; i < 256 && 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 = SVidSubtitlePalette->colors; - colors[0].r = 0; - colors[0].g = 0; - colors[0].b = 0; - for (int i = 1; i < 256; i++) { - colors[i].r = 255; - colors[i].g = 255; - colors[i].b = 255; - } - } -#ifndef USE_SDL1 - for (int i = 0; i < 256; i++) { - SVidSubtitlePalette->colors[i].a = SDL_ALPHA_OPAQUE; - } -#endif - - if (!SDLC_SetSurfacePalette(SVidSubtitleSurface.get(), SVidSubtitlePalette.get())) { - Log("Failed to set subtitle overlay palette"); - } - - // Set color key for transparency (index 0 = transparent) -#ifdef USE_SDL1 - SDL_SetColorKey(SVidSubtitleSurface.get(), SDL_SRCCOLORKEY, 0); -#else - if (!SDL_SetSurfaceColorKey(SVidSubtitleSurface.get(), true, 0)) { - Log("Failed to set color key: {}", SDL_GetError()); - } -#endif - } - - // Clear the overlay surface (fill with transparent color) - SDL_FillSurfaceRect(SVidSubtitleSurface.get(), nullptr, 0); - - // Render text to the overlay surface - Surface overlaySurface(SVidSubtitleSurface.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; - Rectangle subtitleRect { { 10, textY }, { videoWidth - 20, 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 = videoHeight - SubtitleMaxHeight - SubtitleBottomPadding; - dstRect.w = videoWidth; - dstRect.h = 100; - -#ifdef USE_SDL3 - if (!SDL_BlitSurface(SVidSubtitleSurface.get(), nullptr, videoSurface, &dstRect)) { - Log("Failed to blit subtitle overlay: {}", SDL_GetError()); - } -#else - if (SDL_BlitSurface(SVidSubtitleSurface.get(), nullptr, videoSurface, &dstRect) < 0) { - Log("Failed to blit subtitle overlay: {}", SDL_GetError()); - } -#endif - } - } + SVidSubtitleRenderer.RenderSubtitles(SVidSurface.get(), SVidWidth, SVidHeight, videoTimeMs); } #ifndef USE_SDL1 @@ -449,28 +342,6 @@ void SVidInitAudioStream(const SmackerAudioInfo &audioInfo) } #endif -// Load subtitles for the given video filename -void LoadSubtitles(const char *videoFilename) -{ - SVidSubtitles.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); - SVidSubtitles = LoadSrtFile(subtitlePath); - Log("Loaded {} subtitle entries", SVidSubtitles.size()); - if (!SVidSubtitles.empty()) { - Log("First subtitle: {}ms-{}ms: \"{}\"", - SVidSubtitles[0].startTimeMs, - SVidSubtitles[0].endTimeMs, - SVidSubtitles[0].text); - } -} - bool SVidPlayBegin(const char *filename, int flags) { if ((flags & 0x10000) != 0 || (flags & 0x20000000) != 0) { @@ -488,7 +359,7 @@ bool SVidPlayBegin(const char *filename, int flags) // 0x200800 // Clear FB // Load subtitles if available - LoadSubtitles(filename); + SVidSubtitleRenderer.LoadSubtitles(filename); auto *videoStream = OpenAssetAsSdlRwOps(filename); SVidHandle = Smacker_Open(videoStream); @@ -669,9 +540,7 @@ void SVidPlayEnd() SVidPalette = nullptr; SVidSurface = nullptr; SVidFrameBuffer = nullptr; - SVidSubtitles.clear(); - SVidSubtitleSurface = nullptr; - SVidSubtitlePalette = nullptr; + SVidSubtitleRenderer.Clear(); #ifndef USE_SDL1 if (renderer != nullptr) { From eea593994e04b729ac7e3843aab35245e3d9028b Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 21 Jan 2026 14:13:35 -0800 Subject: [PATCH 08/13] Rollback cinematics logic --- Source/DiabloUI/credits.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Source/DiabloUI/credits.cpp b/Source/DiabloUI/credits.cpp index 75483f647..dd9181521 100644 --- a/Source/DiabloUI/credits.cpp +++ b/Source/DiabloUI/credits.cpp @@ -187,8 +187,10 @@ bool TextDialog(const char *const *text, std::size_t textLines) bool UiCreditsDialog() { - play_movie("gendata\\diabend.smk", true); - return true; + ArtBackgroundWidescreen = LoadOptionalClx("ui_art\\creditsw.clx"); + LoadBackgroundArt("ui_art\\credits"); + + return TextDialog(CreditLines, CreditLinesSize); } bool UiSupportDialog() From 3017824b734f2769a6a0ae2d953fc830cbdda75a Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 28 Jan 2026 11:52:22 -0800 Subject: [PATCH 09/13] Add SDLC_SetSurfacePalette to sdl_compat.h --- Source/utils/sdl_compat.h | 7 +++++++ 1 file changed, 7 insertions(+) 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; } From 84e4a50f964f3262e70c7f41b0603f84b02add90 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 28 Jan 2026 11:54:46 -0800 Subject: [PATCH 10/13] Add option to display subtitles --- Source/options.cpp | 2 ++ Source/options.h | 2 ++ Source/storm/storm_svid.cpp | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Source/options.cpp b/Source/options.cpp index 65ae9a892..aa32f87cb 100644 --- a/Source/options.cpp +++ b/Source/options.cpp @@ -515,6 +515,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 }) @@ -530,6 +531,7 @@ std::vector AudioOptions::GetEntries() &walkingSound, &autoEquipSound, &itemPickupSound, + &showSubtitles, &sampleRate, &channels, &bufferSize, diff --git a/Source/options.h b/Source/options.h index 7e4da902d..ffe775b74 100644 --- a/Source/options.h +++ b/Source/options.h @@ -495,6 +495,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 c49db8df8..9ff53333b 100644 --- a/Source/storm/storm_svid.cpp +++ b/Source/storm/storm_svid.cpp @@ -227,8 +227,8 @@ void UpdatePalette() bool BlitFrame() { - // Render subtitles if available - do this BEFORE blitting to output - if (SVidSubtitleRenderer.HasSubtitles()) { + // Render subtitles if available and enabled - do this BEFORE blitting to output + if (GetOptions().Audio.showSubtitles.value && SVidSubtitleRenderer.HasSubtitles()) { const uint64_t currentTimeSmk = GetTicksSmk(); const uint64_t videoTimeMs = TimeSmkToMs(currentTimeSmk - SVidStartTime); SVidSubtitleRenderer.RenderSubtitles(SVidSurface.get(), SVidWidth, SVidHeight, videoTimeMs); From d2fe3acfd58875d8cf93e3dc695cd3db6c120449 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 28 Jan 2026 12:14:03 -0800 Subject: [PATCH 11/13] Fix build --- Source/storm/storm_svid.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Source/storm/storm_svid.cpp b/Source/storm/storm_svid.cpp index 9ff53333b..3b5c85b05 100644 --- a/Source/storm/storm_svid.cpp +++ b/Source/storm/storm_svid.cpp @@ -228,7 +228,7 @@ void UpdatePalette() bool BlitFrame() { // Render subtitles if available and enabled - do this BEFORE blitting to output - if (GetOptions().Audio.showSubtitles.value && SVidSubtitleRenderer.HasSubtitles()) { + 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); From d14f9e87cf33bda00a54c841d133117c315716a0 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 28 Jan 2026 12:20:40 -0800 Subject: [PATCH 12/13] Remove unused import --- Source/DiabloUI/credits.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/Source/DiabloUI/credits.cpp b/Source/DiabloUI/credits.cpp index dd9181521..7ac898cb5 100644 --- a/Source/DiabloUI/credits.cpp +++ b/Source/DiabloUI/credits.cpp @@ -27,7 +27,6 @@ #include "engine/render/text_render.hpp" #include "engine/surface.hpp" #include "hwcursor.hpp" -#include "movie.h" #include "utils/display.h" #include "utils/is_of.hpp" #include "utils/language.h" From 1b9e01f41424b1524743a8c9b0fa0bb54822f3e8 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Wed, 28 Jan 2026 15:18:36 -0800 Subject: [PATCH 13/13] Please clang-formatting --- Source/utils/srt_parser.cpp | 1 - Source/utils/srt_parser.hpp | 1 - 2 files changed, 2 deletions(-) diff --git a/Source/utils/srt_parser.cpp b/Source/utils/srt_parser.cpp index c4a4ae998..823746e4f 100644 --- a/Source/utils/srt_parser.cpp +++ b/Source/utils/srt_parser.cpp @@ -98,4 +98,3 @@ std::string GetSubtitleAtTime(const std::vector &subtitles, uint6 } } // namespace devilution - diff --git a/Source/utils/srt_parser.hpp b/Source/utils/srt_parser.hpp index 9bf733cfa..3866ec55f 100644 --- a/Source/utils/srt_parser.hpp +++ b/Source/utils/srt_parser.hpp @@ -36,4 +36,3 @@ std::vector LoadSrtFile(std::string_view subtitlePath); std::string GetSubtitleAtTime(const std::vector &subtitles, uint64_t videoTimeMs); } // namespace devilution -