13 changed files with 568 additions and 3 deletions
@ -0,0 +1,167 @@
|
||||
#include "engine/render/subtitle_renderer.hpp" |
||||
|
||||
#include <algorithm> |
||||
#include <string> |
||||
|
||||
#ifdef USE_SDL3 |
||||
#include <SDL3/SDL_error.h> |
||||
#include <SDL3/SDL_pixels.h> |
||||
#include <SDL3/SDL_rect.h> |
||||
#include <SDL3/SDL_surface.h> |
||||
#else |
||||
#include <SDL.h> |
||||
#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<int>(videoWidth); |
||||
const int videoHeightInt = static_cast<int>(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
|
||||
@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @file subtitle_renderer.hpp |
||||
* |
||||
* Subtitle rendering for video playback. |
||||
*/ |
||||
#pragma once |
||||
|
||||
#include <cstdint> |
||||
#include <string> |
||||
#include <vector> |
||||
|
||||
#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<SubtitleEntry> subtitles_; |
||||
SDLSurfaceUniquePtr subtitleSurface_; |
||||
SDLPaletteUniquePtr subtitlePalette_; |
||||
}; |
||||
|
||||
} // namespace devilution
|
||||
@ -0,0 +1,100 @@
|
||||
#include "utils/srt_parser.hpp" |
||||
|
||||
#include <algorithm> |
||||
#include <cstdio> |
||||
#include <istream> |
||||
#include <sstream> |
||||
#include <string> |
||||
|
||||
#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<uint64_t>(h * 3600000ULL + m * 60000ULL + s * 1000ULL + ms); |
||||
} |
||||
return 0; |
||||
} |
||||
|
||||
std::vector<SubtitleEntry> LoadSrtFile(std::string_view subtitlePath) |
||||
{ |
||||
std::vector<SubtitleEntry> 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<unsigned char>(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<SubtitleEntry> &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
|
||||
@ -0,0 +1,38 @@
|
||||
#pragma once |
||||
|
||||
#include <cstdint> |
||||
#include <string> |
||||
#include <string_view> |
||||
#include <vector> |
||||
|
||||
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<SubtitleEntry> 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<SubtitleEntry> &subtitles, uint64_t videoTimeMs); |
||||
|
||||
} // namespace devilution
|
||||
@ -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 |
||||
Loading…
Reference in new issue