From 6d04ba1aae9f2dd3f7e58869b43d67ba5e3ede73 Mon Sep 17 00:00:00 2001 From: Yuri Pourre Date: Tue, 30 Dec 2025 16:22:45 -0800 Subject: [PATCH] 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 +