From 7974dc1afa6f0d00f4dff573176114d08a3ca5a1 Mon Sep 17 00:00:00 2001 From: Artur Arutyunyan <30243408+arturhg@users.noreply.github.com> Date: Sat, 16 Aug 2025 17:51:01 +0400 Subject: [PATCH] Add subtitle export feature with correct time-scale handling - Add --caption-export option to export subtitles to SRT/WebVTT files - Add --hide-captions option to hide captions while still exporting - Track video_time separately from runtime for correct subtitle timing - Support different framerates (25/30/60 fps) for video export - Maintain subtitle sync regardless of time-scale setting The implementation correctly handles the difference between simulation time (affected by --time-scale) and actual video playback time, ensuring that exported subtitles always sync with the video output. Supported formats: - SRT (.srt) - SubRip format - WebVTT (.vtt, .webvtt) - Web Video Text Tracks format Usage examples: gource --caption-file captions.txt --caption-export output.srt gource --caption-file captions.txt --hide-captions --caption-export output.vtt --- README.md | 5 ++ data/gource.1 | 5 ++ src/gource.cpp | 173 +++++++++++++++++++++++++++++++++------- src/gource.h | 8 ++ src/gource_settings.cpp | 13 +++ src/gource_settings.h | 2 + 6 files changed, 177 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b1e5c2ec..9f87a9fc 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ options: Hide one or more display elements from the list below: bloom - bloom effect + captions - caption text date - current date dirnames - names of directories files - file icons @@ -327,6 +328,10 @@ options: --caption-offset X Caption horizontal offset (0 to centre captions). + --caption-export FILE + Export captions to subtitle file (SRT or WebVTT format). + File format is determined by extension (.srt or .vtt/.webvtt). + -o, --output-ppm-stream FILE Output a PPM image stream to a file ('-' for STDOUT). diff --git a/data/gource.1 b/data/gource.1 index e46b22aa..80687e0b 100644 --- a/data/gource.1 +++ b/data/gource.1 @@ -268,6 +268,7 @@ Disable keyboard and mouse input. Hide one or more display elements from the list below: bloom \- bloom effect + captions \- caption text date \- current date dirnames \- names of directories files \- file icons @@ -300,6 +301,10 @@ Caption duration. \fB\-\-caption-offset X Caption horizontal offset (0 to centre captions). .TP +\fB\-\-caption-export FILE +Export captions to subtitle file (SRT or WebVTT format). +File format is determined by extension (.srt or .vtt/.webvtt). +.TP \fB\-o, \-\-output\-ppm\-stream FILE\fR Output a PPM image stream to a file ('\-' for STDOUT). diff --git a/src/gource.cpp b/src/gource.cpp index cf86c4f9..0a09073a 100644 --- a/src/gource.cpp +++ b/src/gource.cpp @@ -17,6 +17,10 @@ #include "gource.h" #include "core/png_writer.h" +#include +#include +#include +#include bool gGourceDrawBackground = true; bool gGourceQuadTreeDebug = false; @@ -28,6 +32,8 @@ Gource::Gource(FrameExporter* exporter) { this->logfile = gGourceSettings.path; commitlog = 0; + subtitle_export_stream = 0; + subtitle_export_index = 1; //disable OpenGL 2.0 functions if not supported if(!GLEW_VERSION_2_0) gGourceSettings.ffp = true; @@ -155,6 +161,7 @@ Gource::Gource(FrameExporter* exporter) { //min physics rate 60fps (ie maximum allowed delta 1.0/60) max_tick_rate = 1.0 / 60.0; runtime = 0.0f; + video_time = 0.0f; frameskip = 0; framecount = 0; @@ -216,6 +223,12 @@ void Gource::writeCustomLog(const std::string& logfile, const std::string& outpu Gource::~Gource() { reset(); + if(subtitle_export_stream) { + subtitle_export_stream->close(); + delete subtitle_export_stream; + subtitle_export_stream = 0; + } + if(logmill!=0) delete logmill; if(root!=0) delete root; @@ -256,7 +269,15 @@ void Gource::update(float t, float dt) { scaled_dt *= gGourceSettings.time_scale; //have to manage runtime internally as we're messing with dt - if(!paused) runtime += scaled_dt; + if(!paused) { + runtime += scaled_dt; + // Track unscaled video time for subtitle export + if(frameExporter != 0) { + video_time += max_tick_rate; // Advances by 1/framerate per frame (1/25, 1/30, or 1/60) + } else { + video_time = runtime; // When not exporting, use runtime + } + } if(gGourceSettings.stop_at_time > 0.0 && runtime >= gGourceSettings.stop_at_time) stop_position_reached = true; @@ -955,6 +976,7 @@ void Gource::reset() { currtime=0; lasttime=0; subseconds=0.0; + video_time=0.0f; tag_seq = 1; commit_seq = 1; } @@ -1128,6 +1150,76 @@ void Gource::loadCaptions() { } +void Gource::exportCaptions() { + if(gGourceSettings.caption_export_file.empty()) return; + + subtitle_export_stream = new std::ofstream(gGourceSettings.caption_export_file.c_str()); + if(!subtitle_export_stream->is_open()) { + fprintf(stderr, "Failed to open caption export file: %s\n", gGourceSettings.caption_export_file.c_str()); + delete subtitle_export_stream; + subtitle_export_stream = 0; + return; + } + + // Determine format from file extension and write header if needed + std::string ext = gGourceSettings.caption_export_file.substr(gGourceSettings.caption_export_file.find_last_of(".") + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + if(ext == "vtt" || ext == "webvtt") { + *subtitle_export_stream << "WEBVTT\n\n"; + } + + fprintf(stderr, "Caption export file opened: %s (will record actual timings)\n", gGourceSettings.caption_export_file.c_str()); +} + +void Gource::writeSubtitleEntry(RCaption* caption, float start_time, float end_time) { + if(!subtitle_export_stream || !subtitle_export_stream->is_open()) return; + + // Determine format from file extension + std::string ext = gGourceSettings.caption_export_file.substr(gGourceSettings.caption_export_file.find_last_of(".") + 1); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + bool is_webvtt = (ext == "vtt" || ext == "webvtt"); + + // Format time for SRT/WebVTT + int start_hours = (int)(start_time / 3600); + int start_mins = (int)((start_time - start_hours * 3600) / 60); + int start_secs = (int)(start_time - start_hours * 3600 - start_mins * 60); + int start_ms = (int)((start_time - (int)start_time) * 1000); + + int end_hours = (int)(end_time / 3600); + int end_mins = (int)((end_time - end_hours * 3600) / 60); + int end_secs = (int)(end_time - end_hours * 3600 - end_mins * 60); + int end_ms = (int)((end_time - (int)end_time) * 1000); + + if(is_webvtt) { + // WebVTT format + *subtitle_export_stream << std::setfill('0') << std::setw(2) << start_hours << ":" + << std::setfill('0') << std::setw(2) << start_mins << ":" + << std::setfill('0') << std::setw(2) << start_secs << "." + << std::setfill('0') << std::setw(3) << start_ms + << " --> " + << std::setfill('0') << std::setw(2) << end_hours << ":" + << std::setfill('0') << std::setw(2) << end_mins << ":" + << std::setfill('0') << std::setw(2) << end_secs << "." + << std::setfill('0') << std::setw(3) << end_ms << "\n"; + } else { + // SRT format + *subtitle_export_stream << subtitle_export_index++ << "\n"; + *subtitle_export_stream << std::setfill('0') << std::setw(2) << start_hours << ":" + << std::setfill('0') << std::setw(2) << start_mins << ":" + << std::setfill('0') << std::setw(2) << start_secs << "," + << std::setfill('0') << std::setw(3) << start_ms + << " --> " + << std::setfill('0') << std::setw(2) << end_hours << ":" + << std::setfill('0') << std::setw(2) << end_mins << ":" + << std::setfill('0') << std::setw(2) << end_secs << "," + << std::setfill('0') << std::setw(3) << end_ms << "\n"; + } + + *subtitle_export_stream << caption->getCaption() << "\n\n"; + subtitle_export_stream->flush(); +} + void Gource::readLog() { if(stop_position_reached) return; @@ -1717,6 +1809,7 @@ void Gource::logic(float t, float dt) { subseconds = 0.0; loadCaptions(); + exportCaptions(); } //set current time @@ -1806,51 +1899,71 @@ void Gource::logic(float t, float dt) { reloaded = false; } - while(captions.size() > 0) { - RCaption* caption = captions.front(); + // Process captions if we need to display them OR export them + bool process_captions = !gGourceSettings.hide_captions || (subtitle_export_stream && subtitle_export_stream->is_open()); + + if(process_captions) { + while(captions.size() > 0) { + RCaption* caption = captions.front(); - if(caption->timestamp > currtime) break; + if(caption->timestamp > currtime) break; - float y = caption_start_y; + // Record when this caption becomes active for subtitle export + if(subtitle_export_stream && subtitle_export_stream->is_open()) { + caption_start_times[caption] = video_time; // Use unscaled video time + } - while(1) { + // Only calculate position if we're showing captions + if(!gGourceSettings.hide_captions) { + float y = caption_start_y; - bool found = false; + while(1) { + bool found = false; - for(RCaption* cap : active_captions) { + for(RCaption* cap : active_captions) { + if(cap->getPos().y == y) { + found = true; + break; + } + } - if(cap->getPos().y == y) { - found = true; - break; + if(!found) break; + y -= caption_height; } - } - if(!found) break; + int caption_offset_x = gGourceSettings.caption_offset; - y -= caption_height; - } + // centre + if(caption_offset_x == 0) { + caption_offset_x = (display.width / 2) - (fontcaption.getWidth(caption->getCaption()) / 2); + } else if(caption_offset_x < 0) { + caption_offset_x = display.width + caption_offset_x - fontcaption.getWidth(caption->getCaption()); + } - int caption_offset_x = gGourceSettings.caption_offset; + caption->setPos(vec2(caption_offset_x, y)); + } - // centre - if(caption_offset_x == 0) { - caption_offset_x = (display.width / 2) - (fontcaption.getWidth(caption->getCaption()) / 2); - } else if(caption_offset_x < 0) { - caption_offset_x = display.width + caption_offset_x - fontcaption.getWidth(caption->getCaption()); + captions.pop_front(); + active_captions.push_back(caption); } - - caption->setPos(vec2(caption_offset_x, y)); - - captions.pop_front(); - active_captions.push_back(caption); } + // Process all active captions (visible or hidden) for(std::list::iterator it = active_captions.begin(); it!=active_captions.end();) { RCaption* caption = *it; caption->logic(dt); if(caption->isFinished()) { + // Export subtitle when it finishes + if(subtitle_export_stream && subtitle_export_stream->is_open()) { + auto start_it = caption_start_times.find(caption); + if(start_it != caption_start_times.end()) { + writeSubtitleEntry(caption, start_it->second, video_time); + caption_start_times.erase(start_it); + } + } + it = active_captions.erase(it); delete caption; continue; @@ -2655,10 +2768,12 @@ void Gource::draw(float t, float dt) { fontmedium.alignTop(true); } - for(std::list::iterator it = active_captions.begin(); it!=active_captions.end(); it++) { - RCaption* caption = *it; + if(!gGourceSettings.hide_captions) { + for(std::list::iterator it = active_captions.begin(); it!=active_captions.end(); it++) { + RCaption* caption = *it; - caption->draw(); + caption->draw(); + } } //file key diff --git a/src/gource.h b/src/gource.h index 338e7ac5..7292b605 100644 --- a/src/gource.h +++ b/src/gource.h @@ -145,6 +145,7 @@ class Gource : public SDLApp { time_t currtime; time_t lasttime; float runtime; + float video_time; // Unscaled time for subtitle export float subseconds; float splash; @@ -185,6 +186,11 @@ class Gource : public SDLApp { std::list captions; std::list active_captions; + + // Subtitle export tracking + std::ofstream* subtitle_export_stream; + int subtitle_export_index; + std::map caption_start_times; QuadTree* dirNodeTree; QuadTree* userTree; @@ -208,6 +214,8 @@ class Gource : public SDLApp { void selectNextUser(); void loadCaptions(); + void exportCaptions(); + void writeSubtitleEntry(RCaption* caption, float start_time, float end_time); void readLog(); diff --git a/src/gource_settings.cpp b/src/gource_settings.cpp index 76ec9479..3386069a 100644 --- a/src/gource_settings.cpp +++ b/src/gource_settings.cpp @@ -180,6 +180,7 @@ if(extended_help) { printf(" --filename-time SECONDS Duration to keep filenames on screen (default: 4.0)\n\n"); printf(" --caption-file FILE Caption file\n"); + printf(" --caption-export FILE Export captions to file (SRT or WebVTT format)\n"); printf(" --caption-size SIZE Caption font size\n"); printf(" --caption-colour FFFFFF Caption colour in hex\n"); printf(" --caption-duration SECONDS Caption duration (default: 10.0)\n"); @@ -271,6 +272,7 @@ GourceSettings::GourceSettings() { arg_types["hide-bloom"] = "bool"; arg_types["hide-mouse"] = "bool"; arg_types["hide-root"] = "bool"; + arg_types["hide-captions"] = "bool"; arg_types["highlight-users"] = "bool"; arg_types["highlight-dirs"] = "bool"; arg_types["file-extensions"] = "bool"; @@ -354,6 +356,7 @@ GourceSettings::GourceSettings() { arg_types["dir-colour"] = "string"; arg_types["caption-file"] = "string"; + arg_types["caption-export"] = "string"; arg_types["caption-size"] = "int"; arg_types["caption-duration"] = "float"; arg_types["caption-colour"] = "string"; @@ -383,6 +386,7 @@ void GourceSettings::setGourceDefaults() { hide_bloom = false; hide_mouse = false; hide_root = false; + hide_captions = false; start_timestamp = 0; start_date = ""; @@ -478,6 +482,7 @@ void GourceSettings::setGourceDefaults() { highlight_dirs = false; caption_file = ""; + caption_export_file = ""; caption_duration = 10.0f; caption_size = 16; caption_offset = 0; @@ -695,6 +700,7 @@ void GourceSettings::importGourceSettings(ConfFile& conffile, ConfSection* gourc else if(hidestr == "bloom") hide_bloom = true; else if(hidestr == "progress") hide_progress = true; else if(hidestr == "root") hide_root = true; + else if(hidestr == "captions") hide_captions = true; else if(hidestr == "mouse") { hide_mouse = true; hide_progress = true; @@ -874,6 +880,13 @@ void GourceSettings::importGourceSettings(ConfFile& conffile, ConfSection* gourc } } + if((entry = gource_settings->getEntry("caption-export")) != 0) { + + if(!entry->hasValue()) conffile.entryException(entry, "specify caption export file (filename)"); + + caption_export_file = entry->getString(); + } + if((entry = gource_settings->getEntry("caption-duration")) != 0) { if(!entry->hasValue()) conffile.entryException(entry, "specify caption duration (seconds)"); diff --git a/src/gource_settings.h b/src/gource_settings.h index 1975ec59..cc0f3869 100644 --- a/src/gource_settings.h +++ b/src/gource_settings.h @@ -41,6 +41,7 @@ class GourceSettings : public SDLAppSettings { bool hide_bloom; bool hide_mouse; bool hide_root; + bool hide_captions; bool disable_auto_rotate; @@ -156,6 +157,7 @@ class GourceSettings : public SDLAppSettings { bool file_extension_fallback; std::string caption_file; + std::string caption_export_file; vec3 caption_colour; float caption_duration; int caption_size;