From ac5a94ac0397d3b1f0c35a2ae64321ead7e18760 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Fri, 20 Oct 2023 00:10:01 +0200 Subject: [PATCH 01/26] Refactor initialization to be able to run without a wxApp First steps towards implementing aegisub-cli's features inside of Aegisub itself. This involves some terrible callback spaghetti in order to set up everything in the correct order. (cherry picked from commit c7e40f847dab87d4594adf5bd371d89c1bb0be35) --- meson.build | 2 +- src/main.cpp | 149 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 38 deletions(-) diff --git a/meson.build b/meson.build index ce42591e5a..81e90b0cd9 100644 --- a/meson.build +++ b/meson.build @@ -151,7 +151,7 @@ libass_dep = dependency('libass', version: '>=0.9.7', deps += libass_dep boost_static = meson.version().version_compare('>=0.63.0') and get_option('prefer_static') # boost dependencies do not respect prefer_static https://github.com/mesonbuild/meson/issues/15458 -boost_modules = ['chrono', 'locale'] +boost_modules = ['chrono', 'locale', 'program_options'] if boost_static boost_modules += 'thread' # static boost dependencies do not include transitive dependencies https://github.com/mesonbuild/meson/issues/15457 diff --git a/src/main.cpp b/src/main.cpp index ec8c2f9e74..efcb797216 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -66,6 +66,8 @@ #include #include +#include +#include #include #include #include @@ -78,7 +80,7 @@ namespace config { Automation4::AutoloadScriptManager *global_scripts; } -wxIMPLEMENT_APP(AegisubApp); +wxIMPLEMENT_APP_NO_MAIN(AegisubApp); static const char *LastStartupState = nullptr; @@ -125,36 +127,9 @@ wxDEFINE_EVENT(EVT_CALL_THUNK, ValueEvent); /// Message displayed when an exception has occurred. static wxString exception_message = "Oops, Aegisub has crashed!\n\nAn attempt has been made to save a copy of your file to:\n\n%s\n\nAegisub will now close."; -/// @brief Gets called when application starts. -/// @return bool -bool AegisubApp::OnInit() { - // App name (yeah, this is a little weird to get rid of an odd warning) -#if defined(__WXMSW__) || defined(__WXMAC__) - SetAppName("Aegisub"); -#else - SetAppName("aegisub"); -#endif - - // The logger isn't created on demand on background threads, so force it to - // be created now - (void)wxLog::GetActiveTarget(); - - agi::util::InitLocale(); - - agi::dispatch::Init([](agi::dispatch::Thunk f) { - auto evt = new ValueEvent(EVT_CALL_THUNK, -1, std::move(f)); - wxTheApp->QueueEvent(evt); - }); - - wxTheApp->Bind(EVT_CALL_THUNK, [this](ValueEvent& evt) { - try { - evt.Get()(); - } - catch (...) { - OnExceptionInMainLoop(); - } - }); - +/// @brief Non-GUI-specific initialization, shared between the GUI startup +/// path and CLI mode. @a app is null when running without a wxApp. +bool AegisubInitialize(AegisubApp *app, std::function showError) { config::path = new agi::Path; agi::log::log = new agi::log::LogSink; @@ -247,13 +222,14 @@ bool AegisubApp::OnInit() { StartupLog("Initialize final locale"); - // Set locale - auto lang = OPT_GET("App/Language")->GetString(); - if (lang.empty() || (lang != "en_US" && !locale.HasLanguage(lang))) { - lang = locale.PickLanguage(); - OPT_SET("App/Language")->SetString(lang); + if (app) { + auto lang = OPT_GET("App/Language")->GetString(); + if (lang.empty() || (lang != "en_US" && !app->locale.HasLanguage(lang))) { + lang = app->locale.PickLanguage(); + OPT_SET("App/Language")->SetString(lang); + } + app->locale.Init(lang); } - locale.Init(lang); #ifdef __APPLE__ // When run from an app bundle, LC_CTYPE defaults to "C", which breaks on @@ -279,7 +255,106 @@ bool AegisubApp::OnInit() { StartupLog("Register export filters"); AssExportFilterChain::Register(std::make_unique()); AssExportFilterChain::Register(std::make_unique()); + } + catch (agi::Exception const& e) { + showError(e.GetMessage(), "Fatal error while initializing"); + return false; + } + catch (std::exception const& e) { + showError(e.what(), "Fatal error while initializing"); + return false; + } +#ifndef _DEBUG + catch (...) { + showError("Fatal error while initializing", "Unhandled exception"); + return false; + } +#endif + return true; +} + +/// @brief Gets called when application starts. +/// @return int +int main(int argc, char *argv[]) { + wxDISABLE_DEBUG_SUPPORT(); + + boost::program_options::options_description cmdline("Options"); + boost::program_options::options_description flags("Options"); + boost::program_options::positional_options_description posdesc; + + flags.add_options() + ("help", "produce help message") + ("cli", "whether to run in CLI mode, without opening a GUI window") + ; + + cmdline.add(flags); + boost::program_options::variables_map vm; + + boost::program_options::store( + boost::program_options::command_line_parser(argc, argv). + options(cmdline).positional(posdesc).run(), vm); + boost::program_options::notify(vm); + + if (vm.count("help")) { + std::cout << argv[0] << " [options] " << std::endl; + std::cout << flags << std::endl; + return 0; + } + + bool cli = vm.count("cli"); + + agi::util::InitLocale(); + + if (cli) { + // TODO force everything onto one thread or figure something else out here + agi::dispatch::Init([](agi::dispatch::Thunk f) { + f(); + }); + + if (!AegisubInitialize(nullptr, [&](std::string msg, std::string title) { std::cerr << title << ": " << msg << "\n"; })) { + return -1; + } + return 0; + } else { + return wxEntry(argc, argv); + } +} + + +/// @brief wx's initialization function. Called from main() to initialize the GUI-specific stuff and then call Initialize() +/// @return bool +bool AegisubApp::OnInit() { + // App name (yeah, this is a little weird to get rid of an odd warning) +#if defined(__WXMSW__) || defined(__WXMAC__) + SetAppName("Aegisub"); +#else + SetAppName("aegisub"); +#endif + // The logger isn't created on demand on background threads, so force it to + // be created now + (void)wxLog::GetActiveTarget(); + + // Pointless `this` capture required due to http://gcc.gnu.org/bugzilla/show_bug.cgi?id=51494 + agi::dispatch::Init([this](agi::dispatch::Thunk f) { + auto evt = new ValueEvent(EVT_CALL_THUNK, -1, std::move(f)); + wxTheApp->QueueEvent(evt); + }); + + wxTheApp->Bind(EVT_CALL_THUNK, [this](ValueEvent& evt) { + try { + evt.Get()(); + } + catch (...) { + OnExceptionInMainLoop(); + } + }); + + if (!::AegisubInitialize(this, [](std::string msg, std::string title) { wxMessageBox(to_wx(msg), to_wx(title)); })) { + return false; + } + + try { StartupLog("Install PNG handler"); wxImage::AddHandler(new wxPNGHandler); From f07fb8c3ccd1cffad4de23e33320dc749ff35afa Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Fri, 20 Oct 2023 23:54:52 +0200 Subject: [PATCH 02/26] Allow initializing a Context without a GUI This mainly involves making the subtitle/video/audio providers only initialize their timers when they have a GUI. (cherry picked from commit d26c21b659d9ff7977d96ddba39718aea93c6324) --- src/audio_controller.cpp | 12 +++++++----- src/audio_controller.h | 2 +- src/main.cpp | 5 +++++ src/options.h | 1 + src/subs_controller.cpp | 11 +++++++---- src/subs_controller.h | 2 +- src/video_controller.cpp | 19 +++++++++++++++---- src/video_controller.h | 4 ++-- 8 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/audio_controller.cpp b/src/audio_controller.cpp index 12ec3aa99b..3381291b3b 100644 --- a/src/audio_controller.cpp +++ b/src/audio_controller.cpp @@ -41,10 +41,12 @@ AudioController::AudioController(agi::Context *context) : context(context) -, playback_timer(this) , provider_connection(context->project->AddAudioProviderListener(&AudioController::OnAudioProvider, this)) { - Bind(wxEVT_TIMER, &AudioController::OnPlaybackTimer, this, playback_timer.GetId()); + if (config::hasGui) { + playback_timer = std::make_unique(this); + Bind(wxEVT_TIMER, &AudioController::OnPlaybackTimer, this, playback_timer->GetId()); + } #ifdef wxHAS_POWER_EVENTS Bind(wxEVT_POWER_SUSPENDED, &AudioController::OnComputerSuspending, this); @@ -140,7 +142,7 @@ void AudioController::PlayRange(const TimeRange &range) player->Play(SamplesFromMilliseconds(range.begin()), SamplesFromMilliseconds(range.length())); playback_mode = PM_Range; - playback_timer.Start(20); + playback_timer->Start(20); AnnouncePlaybackPosition(range.begin()); } @@ -166,7 +168,7 @@ void AudioController::PlayToEnd(int start_ms) int64_t start_sample = SamplesFromMilliseconds(start_ms); player->Play(start_sample, provider->GetNumSamples()-start_sample); playback_mode = PM_ToEnd; - playback_timer.Start(20); + playback_timer->Start(20); AnnouncePlaybackPosition(start_ms); } @@ -177,7 +179,7 @@ void AudioController::Stop() player->Stop(); playback_mode = PM_NotPlaying; - playback_timer.Stop(); + playback_timer->Stop(); AnnouncePlaybackStop(); } diff --git a/src/audio_controller.h b/src/audio_controller.h index c61c7aa61f..e09941a34d 100644 --- a/src/audio_controller.h +++ b/src/audio_controller.h @@ -85,7 +85,7 @@ class AudioController final : public wxEvtHandler, private agi::signal::Connecti PlaybackMode playback_mode = PM_NotPlaying; /// Timer used for playback position updates - wxTimer playback_timer; + std::unique_ptr playback_timer; /// The audio provider agi::AudioProvider *provider = nullptr; diff --git a/src/main.cpp b/src/main.cpp index efcb797216..2dcd7c6e7f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -78,6 +78,7 @@ namespace config { agi::MRUManager *mru = nullptr; agi::Path *path = nullptr; Automation4::AutoloadScriptManager *global_scripts; + bool hasGui = false; } wxIMPLEMENT_APP_NO_MAIN(AegisubApp); @@ -305,6 +306,8 @@ int main(int argc, char *argv[]) { agi::util::InitLocale(); + config::hasGui = !cli; + if (cli) { // TODO force everything onto one thread or figure something else out here agi::dispatch::Init([](agi::dispatch::Thunk f) { @@ -314,6 +317,8 @@ int main(int argc, char *argv[]) { if (!AegisubInitialize(nullptr, [&](std::string msg, std::string title) { std::cerr << title << ": " << msg << "\n"; })) { return -1; } + + std::unique_ptr context = std::make_unique(); return 0; } else { return wxEntry(argc, argv); diff --git a/src/options.h b/src/options.h index 77e6676a2c..8af3861c64 100644 --- a/src/options.h +++ b/src/options.h @@ -29,6 +29,7 @@ namespace config { extern agi::MRUManager *mru; ///< Most Recently Used extern agi::Path *path; extern Automation4::AutoloadScriptManager *global_scripts; + extern bool hasGui; } /// Macro to get OptionValue object diff --git a/src/subs_controller.cpp b/src/subs_controller.cpp index d747c4868d..a4fb25a928 100644 --- a/src/subs_controller.cpp +++ b/src/subs_controller.cpp @@ -149,10 +149,13 @@ SubsController::SubsController(agi::Context *context) , text_selection_connection(context->textSelectionController->AddSelectionListener(&SubsController::OnTextSelectionChanged, this)) , autosave_queue(agi::dispatch::Create()) { - autosave_timer_changed(&autosave_timer); - BindConnection(OPT_SUB("App/Auto/Save", [this] { autosave_timer_changed(&autosave_timer); })); - BindConnection(OPT_SUB("App/Auto/Save Every Seconds", [this] { autosave_timer_changed(&autosave_timer); })); - autosave_timer.Bind(wxEVT_TIMER, [this](wxTimerEvent&) { AutoSave(); }); + if (config::hasGui) { + autosave_timer = std::make_unique(); + autosave_timer_changed(autosave_timer.get()); + BindConnection(OPT_SUB("App/Auto/Save", [this] { autosave_timer_changed(autosave_timer.get()); })); + BindConnection(OPT_SUB("App/Auto/Save Every Seconds", [this] { autosave_timer_changed(autosave_timer.get()); })); + autosave_timer->Bind(wxEVT_TIMER, [this](wxTimerEvent&) { AutoSave(); }); + } } SubsController::~SubsController() { diff --git a/src/subs_controller.h b/src/subs_controller.h index c8cf9b9ae5..2a120ed619 100644 --- a/src/subs_controller.h +++ b/src/subs_controller.h @@ -55,7 +55,7 @@ class SubsController : private agi::signal::ConnectionScope { int next_commit_id = 1; /// Timer for triggering autosaves - wxTimer autosave_timer; + std::unique_ptr autosave_timer; /// Queue which autosaves are performed on std::unique_ptr autosave_queue; diff --git a/src/video_controller.cpp b/src/video_controller.cpp index eb6fb9d574..753ba05c57 100644 --- a/src/video_controller.cpp +++ b/src/video_controller.cpp @@ -57,7 +57,11 @@ VideoController::VideoController(agi::Context *c) { Bind(EVT_VIDEO_ERROR, &VideoController::OnVideoError, this); Bind(EVT_SUBTITLES_ERROR, &VideoController::OnSubtitlesError, this); - playback.Bind(wxEVT_TIMER, &VideoController::OnPlayTimer, this); + + if (config::hasGui) { + playback = std::make_unique(); + playback->Bind(wxEVT_TIMER, &VideoController::OnPlayTimer, this); + } } void VideoController::OnNewVideoProvider(AsyncVideoProvider *new_provider) { @@ -133,6 +137,7 @@ void VideoController::PrevFrame() { } void VideoController::Play() { + assert(config::hasGui); if (IsPlaying()) { Stop(); return; @@ -146,10 +151,11 @@ void VideoController::Play() { context->audioController->PlayToEnd(start_ms); playback_start_time = std::chrono::steady_clock::now(); - playback.Start(10); + playback->Start(10); } void VideoController::PlayLine() { + assert(config::hasGui); Stop(); AssDialogue *curline = context->selectionController->GetActiveLine(); @@ -165,12 +171,13 @@ void VideoController::PlayLine() { JumpToFrame(startFrame); playback_start_time = std::chrono::steady_clock::now(); - playback.Start(10); + playback->Start(10); } void VideoController::Stop() { + assert(config::hasGui); if (IsPlaying()) { - playback.Stop(); + playback->Stop(); context->audioController->Stop(); } } @@ -189,6 +196,10 @@ void VideoController::OnPlayTimer(wxTimerEvent &) { } } +bool VideoController::IsPlaying() const { + return config::hasGui && playback->IsRunning(); +} + double VideoController::GetARFromType(AspectRatio type) const { switch (type) { case AspectRatio::Default: return (double)provider->GetWidth()/provider->GetHeight(); diff --git a/src/video_controller.h b/src/video_controller.h index 70fd9d3cbd..9e70f6eca6 100644 --- a/src/video_controller.h +++ b/src/video_controller.h @@ -74,7 +74,7 @@ class VideoController final : public wxEvtHandler { /// Playback timer used to periodically check if we should go to the next /// frame while playing video - wxTimer playback; + std::unique_ptr playback; /// Time when playback was last started std::chrono::steady_clock::time_point playback_start_time; @@ -117,7 +117,7 @@ class VideoController final : public wxEvtHandler { VideoController(agi::Context *context); /// Is the video currently playing? - bool IsPlaying() const { return playback.IsRunning(); } + bool IsPlaying() const; /// Get the current frame number int GetFrameN() const { return frame_n; } From c85c0c2a7fa6f7289ab19e6ab8f7c3aff8307839 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Thu, 26 Oct 2023 22:42:09 +0200 Subject: [PATCH 03/26] Don't do a bunch of things when in cli mode This includes reading/writing mru.json, logging to files, and autobackups. (cherry picked from commit c0f1acfde140ce65ea61eda44ecf15482c899fab) --- libaegisub/common/mru.cpp | 3 +++ src/main.cpp | 14 ++++++++------ src/subs_controller.cpp | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/libaegisub/common/mru.cpp b/libaegisub/common/mru.cpp index 6029139d4e..9b4cbc79c9 100644 --- a/libaegisub/common/mru.cpp +++ b/libaegisub/common/mru.cpp @@ -114,6 +114,9 @@ agi::fs::path const& MRUManager::GetEntry(std::string_view key, const size_t ent } void MRUManager::Flush() { + if (config_name.string().empty()) + return; + json::Object out; for (size_t i = 0; i < mru.size(); ++i) { diff --git a/src/main.cpp b/src/main.cpp index 2dcd7c6e7f..56d5d7001a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -157,11 +157,13 @@ bool AegisubInitialize(AegisubApp *app, std::functionDecode("?user")); - StartupLog("Create log writer"); - auto path_log = config::path->Decode("?user/log/"); - agi::fs::CreateDirectory(path_log); - agi::log::log->Subscribe(std::make_unique(path_log)); - CleanCache(path_log, "*.json", 10, 100); + if (config::hasGui) { + StartupLog("Create log writer"); + auto path_log = config::path->Decode("?user/log/"); + agi::fs::CreateDirectory(path_log); + agi::log::log->Subscribe(std::make_unique(path_log)); + CleanCache(path_log, "*.json", 10, 100); + } StartupLog("Load user configuration"); try { @@ -197,7 +199,7 @@ bool AegisubInitialize(AegisubApp *app, std::functionDecode("?user/mru.json"), GET_DEFAULT_CONFIG(default_mru), config::opt); + config::mru = new agi::MRUManager(config::hasGui ? config::path->Decode("?user/mru.json") : "", GET_DEFAULT_CONFIG(default_mru), config::opt); agi::util::SetThreadName("AegiMain"); diff --git a/src/subs_controller.cpp b/src/subs_controller.cpp index a4fb25a928..edc507f120 100644 --- a/src/subs_controller.cpp +++ b/src/subs_controller.cpp @@ -185,7 +185,7 @@ ProjectProperties SubsController::Load(agi::fs::path const& filename, const char context->ass->Commit("", AssFile::COMMIT_NEW); // Save backup of file - if (CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) { + if (config::hasGui && CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) { auto path_str = OPT_GET("Path/Auto/Backup")->GetString(); agi::fs::path path; if (path_str.empty()) From 06a6c5a547e32ea474711b40a63823ca198ff120 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Tue, 31 Oct 2023 02:32:25 +0100 Subject: [PATCH 04/26] Don't show progress dialogs when in cli mode (cherry picked from commit 4c19114de2fa8dfe7b280c85e66e42ee71fadc5c) --- .../include/libaegisub/background_runner.h | 18 ++++++++++++++++++ src/auto4_base.cpp | 6 +++--- src/auto4_base.h | 4 ++-- src/dialog_progress.cpp | 19 +++++++++++++++++++ src/dialog_progress.h | 14 ++++++++++++++ src/mkv_wrap.cpp | 2 +- src/project.cpp | 4 ++-- src/project.h | 4 ++-- 8 files changed, 61 insertions(+), 10 deletions(-) diff --git a/libaegisub/include/libaegisub/background_runner.h b/libaegisub/include/libaegisub/background_runner.h index 29bd8efcbc..63c6740fe9 100644 --- a/libaegisub/include/libaegisub/background_runner.h +++ b/libaegisub/include/libaegisub/background_runner.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include @@ -54,6 +55,23 @@ namespace agi { virtual bool IsCancelled()=0; }; + /// @class CLIProgressSink + /// @brief A progress sink that doesn't do anything with its progress updates except print logs + class CLIProgressSink : public ProgressSink { + std::ostream &stream; + + public: + CLIProgressSink(std::ostream &stream = std::cout) : stream(stream) {}; + + void SetIndeterminate() {} + void SetTitle(std::string const&) {} + void SetMessage(std::string const&) {} + void SetProgress(int64_t, int64_t) {} + void Log(std::string const& str) { stream << str; } + void SetStayOpen(bool) {} + bool IsCancelled() { return false; } + }; + /// @class BackgroundRunner /// @brief A class which runs a function, providing it with a progress sink /// diff --git a/src/auto4_base.cpp b/src/auto4_base.cpp index 74254bb55a..4ae81ae925 100644 --- a/src/auto4_base.cpp +++ b/src/auto4_base.cpp @@ -224,7 +224,7 @@ namespace Automation4 { } BackgroundScriptRunner::BackgroundScriptRunner(wxWindow *parent, std::string const& title) - : impl(new DialogProgress(parent, to_wx(title))) + : impl(new OptDialogProgress(parent, to_wx(title))) { } @@ -242,12 +242,12 @@ namespace Automation4 { wxWindow *BackgroundScriptRunner::GetParentWindow() const { - return impl.get(); + return impl->getImpl(); } std::string BackgroundScriptRunner::GetTitle() const { - return from_wx(impl->GetTitle()); + return from_wx(impl->getImpl()->GetTitle()); } // Script diff --git a/src/auto4_base.h b/src/auto4_base.h index eeea820506..f3a60e704d 100644 --- a/src/auto4_base.h +++ b/src/auto4_base.h @@ -40,7 +40,7 @@ #include class AssStyle; -class DialogProgress; +class OptDialogProgress; class wxWindow; class wxDialog; @@ -97,7 +97,7 @@ namespace Automation4 { class ProgressSink; class BackgroundScriptRunner { - std::unique_ptr impl; + std::unique_ptr impl; public: wxWindow *GetParentWindow() const; diff --git a/src/dialog_progress.cpp b/src/dialog_progress.cpp index 314c79ec34..e26e673432 100644 --- a/src/dialog_progress.cpp +++ b/src/dialog_progress.cpp @@ -24,6 +24,7 @@ #include #include +#include #include #include @@ -256,3 +257,21 @@ void DialogProgress::SetProgress(int target) { progress_anim_start_time = now; progress_target = target; } + +OptDialogProgress::OptDialogProgress(wxWindow *parent, wxString const& title, wxString const& message) +: impl(config::hasGui ? new DialogProgress(parent, title, message) : nullptr) +{ } + +void OptDialogProgress::Run(std::function task) { + if (impl) { + impl->Run(task); + } else { + agi::CLIProgressSink ps; + try { + task(&ps); + } + catch (agi::Exception const& e) { + ps.Log(e.GetMessage()); + } + } +} diff --git a/src/dialog_progress.h b/src/dialog_progress.h index 492cdbcd61..7f6b827db8 100644 --- a/src/dialog_progress.h +++ b/src/dialog_progress.h @@ -68,3 +68,17 @@ class DialogProgress final : public wxDialog, public agi::BackgroundRunner { /// BackgroundWorker implementation void Run(std::function task) override; }; + +/// @class OptDialogProgress +/// @brief Background runner that uses DialogProgress in gui mode and runs the task directly otherwise +class OptDialogProgress : public agi::BackgroundRunner { + std::unique_ptr impl; + +public: + OptDialogProgress(wxWindow *parent, wxString const& title="", wxString const& message=""); + + DialogProgress *getImpl() { return impl.get(); }; + + /// BackgroundWorker implementation + void Run(std::function task) override; +}; diff --git a/src/mkv_wrap.cpp b/src/mkv_wrap.cpp index ba56834951..95ce681230 100644 --- a/src/mkv_wrap.cpp +++ b/src/mkv_wrap.cpp @@ -304,7 +304,7 @@ void MatroskaWrapper::GetSubtitles(agi::fs::path const& filename, AssFile *targe // Progress bar auto totalTime = double(segInfo->Duration) / timecodeScale; - DialogProgress progress(nullptr, _("Parsing Matroska"), _("Reading subtitles from Matroska file.")); + OptDialogProgress progress(nullptr, _("Parsing Matroska"), _("Reading subtitles from Matroska file.")); bool result; progress.Run([&](agi::ProgressSink *ps) { result = read_subtitles(ps, file, &input, srt, totalTime, &parser, cs); }); diff --git a/src/project.cpp b/src/project.cpp index e1ef6170ec..aeaaf72bff 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -318,7 +318,7 @@ void Project::LoadUnloadFiles(ProjectProperties properties) { void Project::DoLoadAudio(agi::fs::path const& path, bool quiet) { if (!progress) - progress = new DialogProgress(context->parent); + progress = new OptDialogProgress(context->parent); try { try { @@ -364,7 +364,7 @@ void Project::CloseAudio() { bool Project::DoLoadVideo(agi::fs::path const& path) { if (!progress) - progress = new DialogProgress(context->parent); + progress = new OptDialogProgress(context->parent); try { video_provider = std::make_unique(path, context->ass->GetYCbCrMatrix(), context->videoController.get(), progress); diff --git a/src/project.h b/src/project.h index 876ddd9613..334c1e3a54 100644 --- a/src/project.h +++ b/src/project.h @@ -24,7 +24,7 @@ #include class AsyncVideoProvider; -class DialogProgress; +class OptDialogProgress; class wxString; namespace agi { class AudioProvider; } namespace agi { struct Context; } @@ -47,7 +47,7 @@ class Project : private agi::signal::ConnectionScope { agi::signal::Signal const&> AnnounceKeyframesModified; bool video_has_subtitles = false; - DialogProgress *progress = nullptr; + OptDialogProgress *progress = nullptr; agi::Context *context = nullptr; void ShowError(wxString const& message); From f5260c31b0688dd9661386533429fb9114ff2013 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Wed, 1 Nov 2023 21:12:22 +0100 Subject: [PATCH 05/26] Disable a bunch more dialogs and messageboxes in cli mode Add wrappers that show the dialogs when a gui is present and fall back to something else when it's not. In particular, single choice dialogs can be given predetermined answers from the global config, which will hopefully be configurable from the command line in the future. (cherry picked from commit 9ae1727f6f7f22bd362a41d853a3c243fd5e44e6) --- src/charset_detect.cpp | 4 ++- src/ffmpegsource_common.cpp | 5 +-- src/gui_wrap.cpp | 67 +++++++++++++++++++++++++++++++++++++ src/gui_wrap.h | 26 ++++++++++++++ src/main.cpp | 2 ++ src/meson.build | 1 + src/mkv_wrap.cpp | 5 ++- src/options.h | 2 ++ src/project.cpp | 8 +++-- src/subtitle_format.cpp | 3 +- 10 files changed, 113 insertions(+), 10 deletions(-) create mode 100644 src/gui_wrap.cpp create mode 100644 src/gui_wrap.h diff --git a/src/charset_detect.cpp b/src/charset_detect.cpp index 459fdac81c..108886f87d 100644 --- a/src/charset_detect.cpp +++ b/src/charset_detect.cpp @@ -30,6 +30,7 @@ #include "charset_detect.h" #include "compat.h" +#include "gui_wrap.h" #include #include @@ -46,7 +47,8 @@ std::string GetEncoding(agi::fs::path const& filename) { return encoding; auto choices = agi::charset::GetEncodingsList(); - int choice = wxGetSingleChoiceIndex( + int choice = wrapChoiceDialog( + "subtitle-charset", _("Aegisub could not narrow down the character set to a single one.\nPlease pick one below:"), _("Choose character set"), choices); diff --git a/src/ffmpegsource_common.cpp b/src/ffmpegsource_common.cpp index 0d86a07d53..eef1094c32 100644 --- a/src/ffmpegsource_common.cpp +++ b/src/ffmpegsource_common.cpp @@ -37,6 +37,7 @@ #include "compat.h" #include "format.h" +#include "gui_wrap.h" #include "options.h" #include "utils.h" @@ -48,7 +49,6 @@ #include #include #include -#include FFmpegSourceProvider::FFmpegSourceProvider(agi::BackgroundRunner *br) : br(br) @@ -152,7 +152,8 @@ FFmpegSourceProvider::AskForTrackSelection(const std::map &Tra TrackNumbers.push_back(track.first); } - int Choice = wxGetSingleChoiceIndex( + int Choice = wrapChoiceDialog( + Type == FFMS_TYPE_VIDEO ? "video-track" : "audio-track", Type == FFMS_TYPE_VIDEO ? _("Multiple video tracks detected, please choose the one you wish to load:") : _("Multiple audio tracks detected, please choose the one you wish to load:"), Type == FFMS_TYPE_VIDEO ? _("Choose video track") : _("Choose audio track"), Choices); diff --git a/src/gui_wrap.cpp b/src/gui_wrap.cpp new file mode 100644 index 0000000000..a57a9df813 --- /dev/null +++ b/src/gui_wrap.cpp @@ -0,0 +1,67 @@ +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +#include "gui_wrap.h" + +#include "libaegisub/log.h" +#include "libaegisub/format.h" +#include "libaegisub/util.h" + +#include "compat.h" +#include "options.h" + +#include +#include // Keep this last so wxUSE_CHOICEDLG is set. + + +int wrapMessageBox(const wxString &message, const wxString &caption, long style, wxWindow *parent) { + if (config::hasGui) + return wxMessageBox(message, caption, style, parent); + + agi::log::Severity severity = agi::log::Info; + if (style | wxICON_ERROR) + severity = agi::log::Exception; + if (style | wxICON_WARNING) + severity = agi::log::Warning; + + LOG_SINK("agi", severity) << caption << ": " << message; + return 0; +} + +int wrapChoiceDialog(std::string const& key, const wxString& message, const wxString& caption, const wxArrayString& choices, wxWindow *parent) { + if (config::hasGui) + return wxGetSingleChoiceIndex(message, caption, choices, parent); + + if (!config::choice_indices.count(key) || config::choice_indices[key].empty()) { + LOG_W("agi") << agi::format("No answer given for choice \"%s\". Using first choice \"%s\".", from_wx(caption), from_wx(choices[0])); + return 0; + } + + std::string choice = *config::choice_indices[key].rbegin(); + config::choice_indices[key].pop_back(); + + auto index = std::find(choices.begin(), choices.end(), to_wx(choice)); + if (index != choices.end()) + return std::distance(choices.begin(), index); + + int result; + if (!agi::util::try_parse(choice, &result)) { + LOG_W("agi") << agi::format("Invalid answer \"%s\" given for choice \"%s\". Using first choice \"%s\".", choice, from_wx(caption), from_wx(choices[0])); + return 0; + } + return result; +} + diff --git a/src/gui_wrap.h b/src/gui_wrap.h new file mode 100644 index 0000000000..db6751d531 --- /dev/null +++ b/src/gui_wrap.h @@ -0,0 +1,26 @@ +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +/// @file gui_wrap.h +/// @brief Wrappers around some common wx dialogs that makes them not show in CLI mode + +#include +#include + +int wrapMessageBox(const wxString &message, const wxString &caption, long style=wxOK | wxCENTRE, wxWindow *parent=nullptr); + +/// Wraps wxGetSingleChoiceIndex. The first argument is the key under which a choice should be searched for in the global config. +int wrapChoiceDialog(std::string const& key, const wxString& message, const wxString& caption, const wxArrayString& choices, wxWindow *parent=nullptr); diff --git a/src/main.cpp b/src/main.cpp index 56d5d7001a..6445d2ceb4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -78,7 +78,9 @@ namespace config { agi::MRUManager *mru = nullptr; agi::Path *path = nullptr; Automation4::AutoloadScriptManager *global_scripts; + bool hasGui = false; + std::map> choice_indices; } wxIMPLEMENT_APP_NO_MAIN(AegisubApp); diff --git a/src/meson.build b/src/meson.build index 876d551ffb..370d3afd05 100644 --- a/src/meson.build +++ b/src/meson.build @@ -93,6 +93,7 @@ aegisub_src = files( 'gl_text.cpp', 'gl_wrap.cpp', 'grid_column.cpp', + 'gui_wrap.cpp', 'help_button.cpp', 'hotkey.cpp', 'hotkey_data_view_model.cpp', diff --git a/src/mkv_wrap.cpp b/src/mkv_wrap.cpp index 95ce681230..2f2c61dcce 100644 --- a/src/mkv_wrap.cpp +++ b/src/mkv_wrap.cpp @@ -38,6 +38,7 @@ #include "ass_parser.h" #include "compat.h" #include "dialog_progress.h" +#include "gui_wrap.h" #include "MatroskaParser.h" #include "options.h" #include "subtitle_format_srt.h" @@ -54,8 +55,6 @@ #include #include -#include // Keep this last so wxUSE_CHOICEDLG is set. - struct MkvStdIO final : InputStream { agi::read_file_mapping file; std::string error; @@ -259,7 +258,7 @@ void MatroskaWrapper::GetSubtitles(agi::fs::path const& filename, AssFile *targe trackToRead = tracksFound[0]; // Pick a track else { - int choice = wxGetSingleChoiceIndex(_("Choose which track to read:"), _("Multiple subtitle tracks found"), to_wx(tracksNames)); + int choice = wrapChoiceDialog("subtitle-track", _("Choose which track to read:"), _("Multiple subtitle tracks found"), to_wx(tracksNames)); if (choice == -1) throw agi::UserCancelException("canceled"); diff --git a/src/options.h b/src/options.h index 8af3861c64..146cf6b9df 100644 --- a/src/options.h +++ b/src/options.h @@ -29,7 +29,9 @@ namespace config { extern agi::MRUManager *mru; ///< Most Recently Used extern agi::Path *path; extern Automation4::AutoloadScriptManager *global_scripts; + extern bool hasGui; + extern std::map> choice_indices; } /// Macro to get OptionValue object diff --git a/src/project.cpp b/src/project.cpp index aeaaf72bff..3ed02cec17 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -28,6 +28,7 @@ #include "dialog_progress.h" #include "dialogs.h" #include "format.h" +#include "gui_wrap.h" #include "include/aegisub/context.h" #include "include/aegisub/video_provider.h" #include "mkv_wrap.h" @@ -86,7 +87,7 @@ void Project::ReloadVideo() { } void Project::ShowError(wxString const& message) { - wxMessageBox(message, _("Error loading file"), wxOK | wxICON_ERROR | wxCENTER, context->parent); + wrapMessageBox(message, _("Error loading file"), wxOK | wxICON_ERROR | wxCENTER, context->parent); } void Project::ShowError(std::string const& message) { @@ -155,7 +156,8 @@ bool Project::DoLoadSubtitles(agi::fs::path const& path, std::string encoding, P sel.insert(active_line); } context->selectionController->SetSelectionAndActive(std::move(sel), active_line); - context->subsGrid->ScrollTo(properties.scroll_position); + if (config::hasGui) + context->subsGrid->ScrollTo(properties.scroll_position); return true; } @@ -394,7 +396,7 @@ bool Project::DoLoadVideo(agi::fs::path const& path) { std::string warning = video_provider->GetWarning(); if (!warning.empty()) - wxMessageBox(to_wx(warning), _("Warning"), wxICON_WARNING | wxOK); + wrapMessageBox(to_wx(warning), _("Warning"), wxICON_WARNING | wxOK); video_has_subtitles = false; if (agi::fs::HasExtension(path, "mkv")) diff --git a/src/subtitle_format.cpp b/src/subtitle_format.cpp index 387059ce54..82c3ebb63c 100644 --- a/src/subtitle_format.cpp +++ b/src/subtitle_format.cpp @@ -37,6 +37,7 @@ #include "ass_dialogue.h" #include "ass_file.h" #include "compat.h" +#include "gui_wrap.h" #include "format.h" #include "subtitle_format_ass.h" #include "subtitle_format_ebu3264.h" @@ -123,7 +124,7 @@ agi::vfr::Framerate SubtitleFormat::AskForFPS(bool allow_vfr, bool show_smpte, a bool was_busy = wxIsBusy(); if (was_busy) wxEndBusyCursor(); - int choice = wxGetSingleChoiceIndex(_("Please choose the appropriate FPS for the subtitles:"), _("FPS"), choices); + int choice = wrapChoiceDialog("subtitle-fps", _("Please choose the appropriate FPS for the subtitles:"), _("FPS"), choices); if (was_busy) wxBeginBusyCursor(); using agi::vfr::Framerate; From 3257ca57384c051e72cc7ec68047cfbe7f9123b2 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Fri, 3 Nov 2023 00:50:11 +0100 Subject: [PATCH 06/26] Disable more video provider things on CLI mode The AsyncVideoProvider still doesn't fully work, though, since it relies on wxEvents to send frame ready events. (cherry picked from commit e3962c4a6dca6f0f216fd22fb989a43a24c92a5e) --- src/project.cpp | 3 ++- src/video_controller.cpp | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/project.cpp b/src/project.cpp index 3ed02cec17..4579711dbd 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -384,7 +384,8 @@ bool Project::DoLoadVideo(agi::fs::path const& path) { AnnounceVideoProviderModified(video_provider.get()); - UpdateVideoProperties(context->ass.get(), video_provider.get(), context->parent); + if (config::hasGui) + UpdateVideoProperties(context->ass.get(), video_provider.get(), context->parent); video_provider->LoadSubtitles(context->ass.get()); timecodes = video_provider->GetFPS(); diff --git a/src/video_controller.cpp b/src/video_controller.cpp index 753ba05c57..92dff3a814 100644 --- a/src/video_controller.cpp +++ b/src/video_controller.cpp @@ -65,7 +65,8 @@ VideoController::VideoController(agi::Context *c) } void VideoController::OnNewVideoProvider(AsyncVideoProvider *new_provider) { - Stop(); + if (config::hasGui) + Stop(); provider = new_provider; color_matrix = std::nullopt; } @@ -88,7 +89,7 @@ void VideoController::OnSubtitlesCommit(int type, const AssDialogue *changed) { } void VideoController::OnActiveLineChanged(AssDialogue *line) { - if (line && provider && OPT_GET("Video/Subtitle Sync")->GetBool()) { + if (config::hasGui && line && provider && OPT_GET("Video/Subtitle Sync")->GetBool()) { Stop(); JumpToTime(line->Start); } From aae7fda5ed55d3bd810af7e54b39eb4010d90375 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Sat, 4 Nov 2023 03:00:57 +0100 Subject: [PATCH 07/26] Implement aegisub-cli's API when run with --cli Almost all of this code is taken from aegisub-cli, with a couple of fixes and improvements. Only --loglevel wasn't added for now since I plan to add some more settings for that. Note that aegisub.text_extents doesn't work on non-Windows yet. Co-authored-by: Myaamori (cherry picked from commit a5a3491fea2dc72d16d74259d76105d5f59af67a) --- src/auto4_lua.h | 4 + src/auto4_lua_dialog.cpp | 34 +++-- src/auto4_lua_progresssink.cpp | 85 +++++++++---- src/cli.cpp | 184 +++++++++++++++++++++++++++ src/cli.h | 33 +++++ src/main.cpp | 223 ++++++++++++++++++++++++++++++--- src/meson.build | 1 + src/options.h | 2 + 8 files changed, 521 insertions(+), 45 deletions(-) create mode 100644 src/cli.cpp create mode 100644 src/cli.h diff --git a/src/auto4_lua.h b/src/auto4_lua.h index 00b78310a3..8bb3680ff5 100644 --- a/src/auto4_lua.h +++ b/src/auto4_lua.h @@ -219,6 +219,10 @@ namespace Automation4 { /// in a single table int LuaReadBack(lua_State *L); + /// Simulates the given button being pressed. Useful when the dialog + /// isn't actually being shown, like in CLI mode. + void PushButton(int button); + // ScriptDialog implementation wxWindow* CreateWindow(wxWindow *parent) override; std::string Serialise() override; diff --git a/src/auto4_lua_dialog.cpp b/src/auto4_lua_dialog.cpp index 4bbeb651b0..4ae520ce88 100644 --- a/src/auto4_lua_dialog.cpp +++ b/src/auto4_lua_dialog.cpp @@ -36,6 +36,7 @@ #include "colour_button.h" #include "compat.h" +#include "options.h" #include "validators.h" #include @@ -453,6 +454,15 @@ namespace Automation4 { btn->first = id; }); } + + if (buttons.empty()) { + buttons.emplace_back(wxID_OK, ""); + buttons.emplace_back(wxID_CANCEL, ""); + } + + for (int i = 0; i < std::ssize(buttons); i++) { + LOG_D("automation/lua/dialog") << "created button: " << buttons[i].second << " (" << i << ")"; + } } wxWindow* LuaDialog::CreateWindow(wxWindow *parent) { @@ -484,11 +494,6 @@ namespace Automation4 { return window; } - if (buttons.size() == 0) { - buttons.emplace_back(wxID_OK, ""); - buttons.emplace_back(wxID_CANCEL, ""); - } - auto dialog = static_cast(parent); auto bs = new wxStdDialogButtonSizer; @@ -532,10 +537,14 @@ namespace Automation4 { int LuaDialog::LuaReadBack(lua_State *L) { // First read back which button was pressed, if any if (use_buttons) { - if (button_pushed == -1 || buttons[button_pushed].first == wxID_CANCEL) + if (button_pushed == -1 || buttons[button_pushed].first == wxID_CANCEL) { + LOG_I("automation/lua/dialog") << "Pushing cancel"; lua_pushboolean(L, false); - else - lua_pushstring(L, buttons[button_pushed].second.c_str()); + } else { + const char* s = buttons[button_pushed].second.c_str(); + LOG_I("automation/lua/dialog") << "Pushing " << s; + lua_pushstring(L, s); + } } // Then read controls back @@ -548,6 +557,15 @@ namespace Automation4 { return use_buttons ? 2 : 1; } + void LuaDialog::PushButton(int button) { + if (button != -1 && (button < 0 || button >= std::ssize(buttons))) { + LOG_E("agi/auto4_lua_dialog") << "Button " << button << " not in range; defaulting to cancel"; + button = -1; + } + + button_pushed = button; + } + std::string LuaDialog::Serialise() { std::string res; diff --git a/src/auto4_lua_progresssink.cpp b/src/auto4_lua_progresssink.cpp index 8569121311..a923d5f434 100644 --- a/src/auto4_lua_progresssink.cpp +++ b/src/auto4_lua_progresssink.cpp @@ -35,8 +35,10 @@ #include "auto4_lua.h" #include "compat.h" +#include "options.h" #include +#include #include #include @@ -190,7 +192,15 @@ namespace Automation4 { ProgressSink *ps = GetObjPointer(L, lua_upvalueindex(1)); LuaDialog dlg(L, true); // magically creates the config dialog structure etc - ps->ShowDialog(&dlg); + + if (config::hasGui) { + ps->ShowDialog(&dlg); + } else if (config::dialog_responses.size() > 0) { + auto& pair = config::dialog_responses.front(); + dlg.PushButton(pair.first); + dlg.Unserialise(pair.second); + config::dialog_responses.pop_front(); + } // more magic: puts two values on stack: button pushed and table with control results return dlg.LuaReadBack(L); @@ -211,23 +221,44 @@ namespace Automation4 { if (must_exist) flags |= wxFD_FILE_MUST_EXIST; - agi::dispatch::Main().Sync([&] { - wxFileDialog diag(nullptr, message, dir, file, wildcard, flags); - if (diag.ShowModal() == wxID_CANCEL) { - lua_pushnil(L); - } else if (multiple) { - wxArrayString files; - diag.GetPaths(files); - - lua_createtable(L, files.size(), 0); - for (size_t i = 0; i < files.size(); ++i) { - lua_pushstring(L, files[i].utf8_str()); + if (config::hasGui) { + agi::dispatch::Main().Sync([&] { + wxFileDialog diag(nullptr, message, dir, file, wildcard, flags); + if (diag.ShowModal() == wxID_CANCEL) { + lua_pushnil(L); + } else if (multiple) { + wxArrayString files; + diag.GetPaths(files); + + lua_createtable(L, files.size(), 0); + for (size_t i = 0; i < files.size(); ++i) { + lua_pushstring(L, files[i].utf8_str()); + lua_rawseti(L, -2, i + 1); + } + } else { + lua_pushstring(L, diag.GetPath().utf8_str()); + } + }); + } else if (!config::file_responses.empty()) { + auto& paths = config::file_responses.front(); + + if (multiple) { + lua_createtable(L, paths.size(), 0); + for (size_t i = 0; i < paths.size(); ++i) { + LOG_I("automation/lua/dialog") << "Opening " << paths[i]; + lua_pushstring(L, paths[i].string().c_str()); lua_rawseti(L, -2, i + 1); } } else { - lua_pushstring(L, diag.GetPath().utf8_str()); + LOG_I("automation/lua/dialog") << "Opening " << paths[0]; + lua_pushstring(L, paths[0].string().c_str()); } - }); + + config::file_responses.pop_front(); + } else { + LOG_I("automation/lua/dialog") << "Canceling open dialog"; + lua_pushnil(L); + } return 1; } @@ -245,14 +276,24 @@ namespace Automation4 { if (prompt_overwrite) flags |= wxFD_OVERWRITE_PROMPT; - agi::dispatch::Main().Sync([&] { - wxFileDialog diag(ps->GetParentWindow(), message, dir, file, wildcard, flags); - if (diag.ShowModal() == wxID_CANCEL) { - lua_pushnil(L); - } else { - lua_pushstring(L, diag.GetPath().utf8_str()); - } - }); + if (config::hasGui) { + agi::dispatch::Main().Sync([&] { + wxFileDialog diag(ps->GetParentWindow(), message, dir, file, wildcard, flags); + if (diag.ShowModal() == wxID_CANCEL) { + lua_pushnil(L); + } else { + lua_pushstring(L, diag.GetPath().utf8_str()); + } + }); + } else if (!config::file_responses.empty()){ + auto& paths = config::file_responses.front(); + LOG_I("automation/lua/dialog") << "Saving to " << paths[0]; + lua_pushstring(L, paths[0].string().c_str()); + config::file_responses.pop_front(); + } else { + LOG_I("automation/lua/dialog") << "Canceling save dialog"; + lua_pushnil(L); + } return 1; } diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000000..9ab4dc5f31 --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,184 @@ +// Copyright (c) 2020, Myaamori +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +#include "cli.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +std::set parse_range(const std::string& s) { + std::set lines; + for (auto tok : agi::Split(s, ',')) { + auto item = agi::str(tok); + int i; + + if (agi::util::try_parse(item, &i)) { + lines.insert(i); + } else { + auto sep = item.find('-'); + if (sep == std::string::npos) { + throw agi::InvalidInputException("Invalid number or range: " + item); + } + + if (!agi::util::try_parse(item.substr(0, sep), &i)) { + throw agi::InvalidInputException("Invalid start number in range: " + item); + } + + int j; + if (!agi::util::try_parse(item.substr(sep + 1), &j)) { + throw agi::InvalidInputException("Invalid end number in range: " + item); + } + + for (int k = i; k <= j; k++) { + lines.insert(k); + } + } + } + return lines; +} + +std::string serialize_element(const json::UnknownElement& elem) { + try { + auto& val_str = static_cast(elem); + return agi::ass::inline_string_encode(val_str); + } + catch (json::Exception const& e) {} + + try { + auto& val_int = static_cast(elem); + return std::to_string(val_int); + } + catch (json::Exception const& e) {} + + try { + auto& val_double = static_cast(elem); + return std::to_string(val_double); + } + catch (json::Exception const& e) {} + + try { + auto& val_bool = static_cast(elem); + return val_bool ? "1" : "0"; + } catch (json::Exception const& e) {} + + auto& val_array = static_cast(elem); + if (val_array.size() != 3 && val_array.size() != 4) { + LOG_E("main/cli") << "Wrong array length for color"; + throw json::Exception("Wrong array length for color"); + } + + agi::Color color; + for (int i = 0; i < val_array.size(); i++) { + int arrelem_int = static_cast(val_array[i]); + if (arrelem_int < 0 || arrelem_int >= 256) { + LOG_E("main/cli") << "Color part not within [0,255] bounds"; + throw json::Exception("Color part not within [0,255] bounds"); + } + + switch (i) { + case 0: color.r = arrelem_int; break; + case 1: color.g = arrelem_int; break; + case 2: color.b = arrelem_int; break; + case 3: color.a = arrelem_int; break; + } + } + return val_array.size() == 4 ? color.GetAssStyleFormatted() : color.GetAssOverrideFormatted(); +} + +std::pair json_to_lua(const std::string& s) { + std::istringstream stream(s); + auto elem = agi::json_util::parse(stream); + auto& root = static_cast(elem); + auto button = root.find("button"); + if (button == root.end()) { + throw agi::InvalidInputException("No button specified in JSON: " + s); + } + + const json::Integer* button_num = nullptr; + try { + button_num = &static_cast(button->second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException("'button' not an integer in JSON: " + s); + } + + auto values = root.find("values"); + if (values == root.end()) { + return std::make_pair(*button_num, ""); + } + + const json::Object* values_obj = nullptr; + try { + values_obj = &static_cast(values->second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException("'values' not an object in JSON: " + s); + } + + std::string vals = ""; + for (auto& p : *values_obj) { + if (!vals.empty()) { + vals += "|"; + } + + auto& key = p.first; + try { + vals += agi::ass::inline_string_encode(key) + ":" + serialize_element(p.second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException(agi::format("Could not serialize 'values.%s' in JSON: %s", key, s)); + } + } + + return std::make_pair(*button_num, vals); +} + +std::list> parse_dialog_responses(const std::vector& args) { + std::list> responses; + for (auto& s : args) { + auto pair = json_to_lua(s); + LOG_D("main/cli") << agi::format("Dialog response: button %d -> %s", pair.first, pair.second); + responses.push_back(std::move(pair)); + } + return responses; +} + +std::list> parse_file_responses(const std::vector& args) { + std::list> responses; + for (auto& s : args) { + std::vector paths; + std::stringstream ss; + for (const auto& tok : agi::Split(s, '|')) { + agi::fs::path p = std::filesystem::absolute(agi::str(tok)); + ss << p << " "; + paths.push_back(std::move(p)); + } + LOG_E("main/cli") << "File response: " << ss.str(); + responses.push_back(std::move(paths)); + } + return responses; +} diff --git a/src/cli.h b/src/cli.h new file mode 100644 index 0000000000..c33bc7e160 --- /dev/null +++ b/src/cli.h @@ -0,0 +1,33 @@ +// Copyright (c) 2020, Myaamori +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +/// @file cli.h +/// @brief Parser and helper functions for Aegisub's CLI interface + +#include + +#include +#include +#include +#include +#include + +std::set parse_range(const std::string& s); + +std::list> parse_dialog_responses(const std::vector& s); + +std::list> parse_file_responses(const std::vector& s); diff --git a/src/main.cpp b/src/main.cpp index 6445d2ceb4..a54978d55a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,8 +37,11 @@ #include "command/command.h" #include "include/aegisub/hotkey.h" +#include "ass_dialogue.h" +#include "ass_file.h" #include "auto4_base.h" #include "auto4_lua_factory.h" +#include "cli.h" #include "compat.h" #include "crash_writer.h" #include "dialogs.h" @@ -50,6 +53,7 @@ #include "libresrc/libresrc.h" #include "options.h" #include "project.h" +#include "selection_controller.h" #include "subs_controller.h" #include "subtitles_provider_libass.h" #include "utils.h" @@ -63,8 +67,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -80,7 +86,10 @@ namespace config { Automation4::AutoloadScriptManager *global_scripts; bool hasGui = false; + bool loadGlobalAutomation = false; std::map> choice_indices; + std::list> dialog_responses; + std::list> file_responses; } wxIMPLEMENT_APP_NO_MAIN(AegisubApp); @@ -253,13 +262,15 @@ bool AegisubInitialize(AegisubApp *app, std::functionGetString()); - - // Load export filters - StartupLog("Register export filters"); - AssExportFilterChain::Register(std::make_unique()); - AssExportFilterChain::Register(std::make_unique()); + if (config::loadGlobalAutomation) { + StartupLog("Load global Automation scripts"); + config::global_scripts = new Automation4::AutoloadScriptManager(OPT_GET("Path/Automation/Autoload")->GetString()); + + // Load export filters + StartupLog("Register export filters"); + AssExportFilterChain::Register(std::make_unique()); + AssExportFilterChain::Register(std::make_unique()); + } } catch (agi::Exception const& e) { showError(e.GetMessage(), "Fatal error while initializing"); @@ -278,6 +289,62 @@ bool AegisubInitialize(AegisubApp *app, std::function; + int result = std::codecvt_base::error; + if (std::has_facet(locale)) { + wchar_t test[] = L"\xFFFE"; + char buff[8]; + auto mb = std::mbstate_t(); + const wchar_t* from_next; + char* to_next; + result = std::use_facet(locale).out(mb, + test, std::end(test), from_next, + buff, std::end(buff), to_next); + } + + // If we didn't get a UTF-8 locale, force it to a known one + if (result != std::codecvt_base::ok) + locale = boost::locale::generator().generate("en_US.UTF-8"); + std::locale::global(locale); +} + +std::unique_ptr find_script(const std::string& file) +{ + auto absolute = agi::fs::path(file); + auto relative = boost::filesystem::current_path() / file; + + agi::fs::path script; + + if (agi::fs::FileExists(absolute)) { + script = absolute; + } else if (agi::fs::FileExists(relative)) { + script = relative; + } else { + auto autodirs = OPT_GET("Path/Automation/Autoload")->GetString(); + + for (auto tok : agi::Split(autodirs, '|')) { + auto dirname = config::path->Decode(agi::str(tok)); + if (!agi::fs::DirectoryExists(dirname)) continue; + + auto scriptname = dirname / file; + if (agi::fs::FileExists(scriptname)) { + script = scriptname; + } + } + } + + if (script.empty()) { + throw agi::InvalidInputException("Could not find script file: " + file); + } + + return Automation4::ScriptFactory::CreateFromFile(script, true, false); +} + /// @brief Gets called when application starts. /// @return int int main(int argc, char *argv[]) { @@ -287,31 +354,50 @@ int main(int argc, char *argv[]) { boost::program_options::options_description flags("Options"); boost::program_options::positional_options_description posdesc; + cmdline.add_options() + ("in-file", boost::program_options::value(), "input file") + ("out-file", boost::program_options::value(), "output file") + ("macro", boost::program_options::value(), "macro to run") + ; + flags.add_options() ("help", "produce help message") - ("cli", "whether to run in CLI mode, without opening a GUI window") + ("cli", "run in CLI mode, without a GUI window. Enables the other options") + ("video", boost::program_options::value(), "video to load") + ("timecodes", boost::program_options::value(), "timecodes to load") + ("keyframes", boost::program_options::value(), "keyframes to load") + ("automation", boost::program_options::value>(), "an automation script to run") + ("active-line", boost::program_options::value()->default_value(-1), "the active line") + ("selected-lines", boost::program_options::value()->default_value(""), "the selected lines") + ("dialog", boost::program_options::value>(), "response to a dialog, in JSON") + ("file", boost::program_options::value>(), "filename to supply to an open/save call") ; + //TODO figure this out properly + cmdline.add(flags); + posdesc.add("in-file", 1); + posdesc.add("out-file", 1); + posdesc.add("macro", 1); boost::program_options::variables_map vm; - boost::program_options::store( boost::program_options::command_line_parser(argc, argv). options(cmdline).positional(posdesc).run(), vm); boost::program_options::notify(vm); - if (vm.count("help")) { + bool cli = vm.count("cli"); + config::hasGui = !cli; + + if (vm.count("help") || (cli && !vm.count("macro"))) { + if (!vm.count("help")) + std::cout << "Too few arguments." << std::endl; std::cout << argv[0] << " [options] " << std::endl; std::cout << flags << std::endl; return 0; } - bool cli = vm.count("cli"); - agi::util::InitLocale(); - config::hasGui = !cli; - if (cli) { // TODO force everything onto one thread or figure something else out here agi::dispatch::Init([](agi::dispatch::Thunk f) { @@ -322,9 +408,116 @@ int main(int argc, char *argv[]) { return -1; } - std::unique_ptr context = std::make_unique(); + agi::Context context; + + LOG_D("main") << "Loading subtitles..."; + context.project->LoadSubtitles(std::filesystem::absolute(vm["in-file"].as()), "", false); + + if (vm.count("video")) { + LOG_D("main") << "Loading video..."; + context.project->LoadVideo(std::filesystem::absolute(vm["video"].as())); + } + + if (vm.count("timecodes")) { + LOG_D("main") << "Loading timecodes..."; + context.project->LoadTimecodes(std::filesystem::absolute(vm["timecodes"].as())); + } + + if (vm.count("keyframes")) { + LOG_D("main") << "Loading keyframes..."; + context.project->LoadKeyframes(std::filesystem::absolute(vm["keyframes"].as())); + } + + auto active_index = vm["active-line"].as(); + AssDialogue* active_line = nullptr; + + auto selected_indices = parse_range(vm["selected-lines"].as()); + Selection selected_lines; + + int i = 0; + for (auto& line : context.ass->Events) { + if (i == active_index) { + active_line = &line; + } + + if (selected_indices.empty() || selected_indices.count(i)) { + selected_lines.insert(&line); + if (active_line == nullptr) { + // assign first line in selection as a fallback + active_line = &line; + } + } + i++; + } + + if (active_line == nullptr) { + // selection was empty + active_line = &context.ass->Events.front(); + selected_lines.insert(active_line); + } + + context.selectionController->SetSelectionAndActive( + std::move(selected_lines), active_line); + + if (vm.count("dialog")) + config::dialog_responses = parse_dialog_responses(vm["dialog"].as>()); + + if (vm.count("file")) + config::file_responses = parse_file_responses(vm["file"].as>()); + + // cache cwd in case automation changes it + auto cwd = std::filesystem::current_path(); + + std::vector> scripts; + if (vm.count("automation")) { + for (auto& s : vm["automation"].as>()) { + LOG_D("main") << "Loading " << s; + auto script = find_script(s); + if (!script) { + return 1; + } + scripts.emplace_back(std::move(script)); + } + } + + auto macro = vm["macro"].as(); + + cmd::Command *cmd = nullptr; + + // Allow calling automation scripts by their display name + for (auto const& script : scripts) { + for (auto const& c: script->GetMacros()) { + if (c->StrMenu(&context) == to_wx(macro)) { + cmd = c; + } + } + } + + // If we don't find one, try the command name instead + if (!cmd) { + try { + cmd = cmd::get(macro); + } catch (cmd::CommandNotFound const&) { + std::cerr << "Command not found: " << macro << std::endl; + LOG_E("main") << "Command not found: " << macro; + return 1; + } + } + + if (!cmd->Validate(&context)) { + LOG_E("main") << "Skipping automation because validation function returned false"; + return 1; + } + + LOG_D("main") << "Calling " << cmd->name(); + (*cmd)(&context); + + // restore cwd for saving + std::filesystem::current_path(cwd); + context.subsController->Save(std::filesystem::absolute(vm["out-file"].as())); return 0; } else { + config::loadGlobalAutomation = true; return wxEntry(argc, argv); } } diff --git a/src/meson.build b/src/meson.build index 370d3afd05..b8808d922a 100644 --- a/src/meson.build +++ b/src/meson.build @@ -35,6 +35,7 @@ aegisub_src = files( 'auto4_lua_progresssink.cpp', 'base_grid.cpp', 'charset_detect.cpp', + 'cli.cpp', 'colorspace.cpp', 'colour_button.cpp', 'command/app.cpp', diff --git a/src/options.h b/src/options.h index 146cf6b9df..091c7ce76e 100644 --- a/src/options.h +++ b/src/options.h @@ -32,6 +32,8 @@ namespace config { extern bool hasGui; extern std::map> choice_indices; + extern std::list> dialog_responses; + extern std::list> file_responses; } /// Macro to get OptionValue object From 594bec79712d989cdc7fcb1d1dc81a4a48445f34 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Sat, 4 Nov 2023 03:30:38 +0100 Subject: [PATCH 08/26] Allow computing text extents in cli mode This requires wx to be initialized on non-Windows, so we initialize it as soon as CalculateTextExtents is called for the first time. Since this requires a display server, we don't do it on startup. (cherry picked from commit 59e51c7cb235bc4c5b821268ddc96dc57857535a) --- src/auto4_base.cpp | 8 ++++++++ src/main.cpp | 6 ++++++ src/options.h | 1 + 3 files changed, 15 insertions(+) diff --git a/src/auto4_base.cpp b/src/auto4_base.cpp index 4ae81ae925..cdfdc2a77e 100644 --- a/src/auto4_base.cpp +++ b/src/auto4_base.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,7 @@ #include #include +#include #include #include #include @@ -121,6 +123,12 @@ namespace Automation4 { DeleteObject(dc); #else // not WIN32 + if (!config::hasInitializedWx) { + config::hasInitializedWx = true; + LOG_W("agi") << "Text extents were requested. Initializing wxWidgets. This requires a display server."; + wxInitialize(); + } + wxMemoryDC thedc; // fix fontsize to be 72 DPI diff --git a/src/main.cpp b/src/main.cpp index a54978d55a..76186ab3e4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -86,6 +86,7 @@ namespace config { Automation4::AutoloadScriptManager *global_scripts; bool hasGui = false; + bool hasInitializedWx = false; bool loadGlobalAutomation = false; std::map> choice_indices; std::list> dialog_responses; @@ -515,9 +516,14 @@ int main(int argc, char *argv[]) { // restore cwd for saving std::filesystem::current_path(cwd); context.subsController->Save(std::filesystem::absolute(vm["out-file"].as())); + + if (config::hasInitializedWx) { + wxUninitialize(); + } return 0; } else { config::loadGlobalAutomation = true; + config::hasInitializedWx = true; return wxEntry(argc, argv); } } diff --git a/src/options.h b/src/options.h index 091c7ce76e..6baea40a92 100644 --- a/src/options.h +++ b/src/options.h @@ -31,6 +31,7 @@ namespace config { extern Automation4::AutoloadScriptManager *global_scripts; extern bool hasGui; + extern bool hasInitializedWx; extern std::map> choice_indices; extern std::list> dialog_responses; extern std::list> file_responses; From 08ff39c22b676577444e59e410173e83c1b33d78 Mon Sep 17 00:00:00 2001 From: arch1t3cht Date: Thu, 15 Feb 2024 22:52:38 +0100 Subject: [PATCH 09/26] Clean up some of the locale initialization spaghetti Partly, this uses some of the refactors made upstream. (cherry picked from commit f9a9ff9268147b74f30d57afd0b7ef8c364968e8) --- src/main.cpp | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 76186ab3e4..b4d0cfb44b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -290,30 +290,6 @@ bool AegisubInitialize(AegisubApp *app, std::function; - int result = std::codecvt_base::error; - if (std::has_facet(locale)) { - wchar_t test[] = L"\xFFFE"; - char buff[8]; - auto mb = std::mbstate_t(); - const wchar_t* from_next; - char* to_next; - result = std::use_facet(locale).out(mb, - test, std::end(test), from_next, - buff, std::end(buff), to_next); - } - - // If we didn't get a UTF-8 locale, force it to a known one - if (result != std::codecvt_base::ok) - locale = boost::locale::generator().generate("en_US.UTF-8"); - std::locale::global(locale); -} - std::unique_ptr find_script(const std::string& file) { auto absolute = agi::fs::path(file); From c0f999f838882b12c156e36a6259dbc11eb5159a Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 19:17:31 +0000 Subject: [PATCH 10/26] Fix up the ported CLI commits for current master The cli branch predates the boost::filesystem -> std::filesystem migration, the removal of agi::make_unique/agi::str, and the move of string_codec into libaegisub, so the cherry-picks needed adjusting: - boost::filesystem -> std::filesystem (with explicit agi::fs::path conversions) in the CLI entry point and cli.cpp - agi::make_unique -> std::make_unique, agi::str -> std::string - string_codec.h -> libaegisub/ass/string_codec.h - add missing standard includes for the new config globals - fix --timecodes calling LoadKeyframes instead of LoadTimecodes --- src/cli.cpp | 4 ++-- src/dialog_progress.cpp | 2 +- src/main.cpp | 15 +++++++-------- src/options.h | 7 +++++++ 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/cli.cpp b/src/cli.cpp index 9ab4dc5f31..a092f0c69f 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -33,7 +33,7 @@ std::set parse_range(const std::string& s) { std::set lines; for (auto tok : agi::Split(s, ',')) { - auto item = agi::str(tok); + auto item = std::string(tok); int i; if (agi::util::try_parse(item, &i)) { @@ -173,7 +173,7 @@ std::list> parse_file_responses(const std::vector paths; std::stringstream ss; for (const auto& tok : agi::Split(s, '|')) { - agi::fs::path p = std::filesystem::absolute(agi::str(tok)); + agi::fs::path p(std::filesystem::absolute(std::string(tok))); ss << p << " "; paths.push_back(std::move(p)); } diff --git a/src/dialog_progress.cpp b/src/dialog_progress.cpp index e26e673432..314cc8d785 100644 --- a/src/dialog_progress.cpp +++ b/src/dialog_progress.cpp @@ -20,11 +20,11 @@ #include "dialog_progress.h" #include "compat.h" +#include "options.h" #include "utils.h" #include #include -#include #include #include diff --git a/src/main.cpp b/src/main.cpp index b4d0cfb44b..74d0f0168d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -70,7 +70,6 @@ #include #include -#include #include #include #include @@ -293,7 +292,7 @@ bool AegisubInitialize(AegisubApp *app, std::function find_script(const std::string& file) { auto absolute = agi::fs::path(file); - auto relative = boost::filesystem::current_path() / file; + auto relative = agi::fs::path(std::filesystem::current_path()) / file; agi::fs::path script; @@ -305,7 +304,7 @@ std::unique_ptr find_script(const std::string& file) auto autodirs = OPT_GET("Path/Automation/Autoload")->GetString(); for (auto tok : agi::Split(autodirs, '|')) { - auto dirname = config::path->Decode(agi::str(tok)); + auto dirname = config::path->Decode(std::string(tok)); if (!agi::fs::DirectoryExists(dirname)) continue; auto scriptname = dirname / file; @@ -388,21 +387,21 @@ int main(int argc, char *argv[]) { agi::Context context; LOG_D("main") << "Loading subtitles..."; - context.project->LoadSubtitles(std::filesystem::absolute(vm["in-file"].as()), "", false); + context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false); if (vm.count("video")) { LOG_D("main") << "Loading video..."; - context.project->LoadVideo(std::filesystem::absolute(vm["video"].as())); + context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(vm["video"].as()))); } if (vm.count("timecodes")) { LOG_D("main") << "Loading timecodes..."; - context.project->LoadTimecodes(std::filesystem::absolute(vm["timecodes"].as())); + context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(vm["timecodes"].as()))); } if (vm.count("keyframes")) { LOG_D("main") << "Loading keyframes..."; - context.project->LoadKeyframes(std::filesystem::absolute(vm["keyframes"].as())); + context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(vm["keyframes"].as()))); } auto active_index = vm["active-line"].as(); @@ -491,7 +490,7 @@ int main(int argc, char *argv[]) { // restore cwd for saving std::filesystem::current_path(cwd); - context.subsController->Save(std::filesystem::absolute(vm["out-file"].as())); + context.subsController->Save(agi::fs::path(std::filesystem::absolute(vm["out-file"].as()))); if (config::hasInitializedWx) { wxUninitialize(); diff --git a/src/options.h b/src/options.h index 6baea40a92..b1d308a181 100644 --- a/src/options.h +++ b/src/options.h @@ -16,10 +16,17 @@ #pragma once +#include #include #include #include +#include +#include +#include +#include +#include + namespace agi { class Path; } namespace Automation4 { class AutoloadScriptManager; } From 3cac25c2496b67cf84220df44a4260d602145253 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 19:27:09 +0000 Subject: [PATCH 11/26] Fix CLI mode to actually work without a display server - Pump a main-thread dispatch queue while automation scripts run on a worker thread (cli::RunWithMainLoop), mirroring the GUI threading model. This replaces the inline dispatch executor, which deadlocked in agi::dispatch::Queue::Sync for anything that bounces through the main thread (text extents, clipboard access). - Give scripts a process-local clipboard in CLI mode instead of crashing on the uninitialized wx clipboard. - Fail text_extents gracefully when wxInitialize() fails due to a missing display server instead of crashing in wxMemoryDC. - Create ?user at startup in CLI mode; the hotkey map is flushed to it even when the GUI-mode directory-creating startup steps are skipped. - Don't try to load the MRU list from disk when constructed with an empty path (CLI mode). - Catch exceptions in the CLI main path and report them instead of calling std::terminate. --- libaegisub/common/mru.cpp | 3 + src/auto4_base.cpp | 7 +- src/auto4_lua.cpp | 14 ++- src/cli.cpp | 65 +++++++++++++ src/cli.h | 19 ++++ src/dialog_progress.cpp | 13 ++- src/main.cpp | 186 ++++++++++++++++++++------------------ 7 files changed, 215 insertions(+), 92 deletions(-) diff --git a/libaegisub/common/mru.cpp b/libaegisub/common/mru.cpp index 9b4cbc79c9..253ee9a731 100644 --- a/libaegisub/common/mru.cpp +++ b/libaegisub/common/mru.cpp @@ -68,6 +68,9 @@ MRUManager::MRUManager(agi::fs::path const& config, std::string_view default_con { LOG_D("agi/mru") << "Loading MRU List"; + // An empty config path means the MRU list is in-memory only (CLI mode) + if (config_name.empty()) return; + auto root = json_util::file(config, default_config); for (auto const& it : static_cast(root)) Load(it.first.c_str(), it.second); diff --git a/src/auto4_base.cpp b/src/auto4_base.cpp index cdfdc2a77e..b60ddfc1ab 100644 --- a/src/auto4_base.cpp +++ b/src/auto4_base.cpp @@ -124,9 +124,12 @@ namespace Automation4 { #else // not WIN32 if (!config::hasInitializedWx) { - config::hasInitializedWx = true; LOG_W("agi") << "Text extents were requested. Initializing wxWidgets. This requires a display server."; - wxInitialize(); + if (!wxInitialize()) { + LOG_E("agi") << "Initializing wxWidgets failed (no display server?); text extents are unavailable."; + return false; + } + config::hasInitializedWx = true; } wxMemoryDC thedc; diff --git a/src/auto4_lua.cpp b/src/auto4_lua.cpp index 72bd280c03..9a341bbe93 100644 --- a/src/auto4_lua.cpp +++ b/src/auto4_lua.cpp @@ -137,10 +137,17 @@ namespace { return 1; } + // In CLI mode there is no system clipboard to talk to (and doing so would + // need a display server), so scripts get a process-local clipboard + std::string cli_clipboard; + char *clipboard_get() { std::string data; - agi::dispatch::Main().Sync([&] { data = GetClipboard(); }); + if (config::hasGui) + agi::dispatch::Main().Sync([&] { data = GetClipboard(); }); + else + data = cli_clipboard; if (data.empty()) return nullptr; return strndup(data); @@ -148,6 +155,11 @@ namespace { bool clipboard_set(const char *str) { + if (!config::hasGui) { + cli_clipboard = str; + return true; + } + bool succeeded = false; agi::dispatch::Main().Sync([&] { diff --git a/src/cli.cpp b/src/cli.cpp index a092f0c69f..736003f097 100644 --- a/src/cli.cpp +++ b/src/cli.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -27,8 +28,12 @@ #include #include +#include +#include #include +#include #include +#include std::set parse_range(const std::string& s) { std::set lines; @@ -182,3 +187,63 @@ std::list> parse_file_responses(const std::vector main_queue; +std::mutex main_queue_mutex; +std::condition_variable main_queue_cv; +} + +namespace cli { +void InitDispatch() { + agi::dispatch::Init([](agi::dispatch::Thunk f) { + { + std::lock_guard lock(main_queue_mutex); + main_queue.push_back(std::move(f)); + } + main_queue_cv.notify_all(); + }); +} + +void RunWithMainLoop(std::function work) { + bool done = false; + std::exception_ptr err; + + std::thread worker([&] { + try { + work(); + } catch (...) { + err = std::current_exception(); + } + { + std::lock_guard lock(main_queue_mutex); + done = true; + } + main_queue_cv.notify_all(); + }); + + std::unique_lock lock(main_queue_mutex); + for (;;) { + main_queue_cv.wait(lock, [&] { return done || !main_queue.empty(); }); + if (main_queue.empty()) { + if (done) break; + continue; + } + auto f = std::move(main_queue.front()); + main_queue.pop_front(); + lock.unlock(); + try { + f(); + } catch (agi::Exception const& e) { + LOG_E("main/cli") << "Unhandled exception on the main thread: " << e.GetMessage(); + } catch (std::exception const& e) { + LOG_E("main/cli") << "Unhandled exception on the main thread: " << e.what(); + } + lock.lock(); + } + lock.unlock(); + + worker.join(); + if (err) std::rethrow_exception(err); +} +} diff --git a/src/cli.h b/src/cli.h index c33bc7e160..ba5648b90b 100644 --- a/src/cli.h +++ b/src/cli.h @@ -26,8 +26,27 @@ #include #include +#include + std::set parse_range(const std::string& s); std::list> parse_dialog_responses(const std::vector& s); std::list> parse_file_responses(const std::vector& s); + +namespace cli { + /// Install an agi::dispatch main-thread executor which queues up thunks + /// to be run by RunWithMainLoop. Must be called from the main thread + /// before anything uses agi::dispatch. + void InitDispatch(); + + /// Run work on a background thread while executing queued main-thread + /// thunks on the calling thread until the work completes, mirroring how + /// the GUI runs automation scripts on a worker thread while the main + /// thread pumps events. Without this, anything which does + /// agi::dispatch::Main().Sync() from the work function (e.g. text + /// extents) would deadlock in CLI mode. + /// + /// Exceptions thrown by the work function are rethrown on this thread. + void RunWithMainLoop(std::function work); +} diff --git a/src/dialog_progress.cpp b/src/dialog_progress.cpp index 314cc8d785..62e6dd5349 100644 --- a/src/dialog_progress.cpp +++ b/src/dialog_progress.cpp @@ -19,6 +19,7 @@ #include "dialog_progress.h" +#include "cli.h" #include "compat.h" #include "options.h" #include "utils.h" @@ -265,13 +266,19 @@ OptDialogProgress::OptDialogProgress(wxWindow *parent, wxString const& title, wx void OptDialogProgress::Run(std::function task) { if (impl) { impl->Run(task); - } else { - agi::CLIProgressSink ps; + return; + } + + // Mirror the GUI threading model: the task runs on a worker thread while + // this thread pumps the main-thread dispatch queue, so that the task can + // use agi::dispatch::Main().Sync() without deadlocking + agi::CLIProgressSink ps; + cli::RunWithMainLoop([&] { try { task(&ps); } catch (agi::Exception const& e) { ps.Log(e.GetMessage()); } - } + }); } diff --git a/src/main.cpp b/src/main.cpp index 74d0f0168d..def027c864 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -168,6 +168,11 @@ bool AegisubInitialize(AegisubApp *app, std::functionDecode("?user")); + // The config machinery writes to ?user even in CLI mode (e.g. flushing + // the hotkey map), and the directory-creating GUI startup steps are + // skipped there, so make sure the directory exists + agi::fs::CreateDirectory(config::path->Decode("?user")); + if (config::hasGui) { StartupLog("Create log writer"); auto path_log = config::path->Decode("?user/log/"); @@ -375,127 +380,136 @@ int main(int argc, char *argv[]) { agi::util::InitLocale(); if (cli) { - // TODO force everything onto one thread or figure something else out here - agi::dispatch::Init([](agi::dispatch::Thunk f) { - f(); - }); + // Scripts run on a worker thread while this thread pumps main-thread + // thunks, mirroring the GUI threading model (see cli::RunWithMainLoop) + cli::InitDispatch(); if (!AegisubInitialize(nullptr, [&](std::string msg, std::string title) { std::cerr << title << ": " << msg << "\n"; })) { return -1; } - agi::Context context; + try { + agi::Context context; - LOG_D("main") << "Loading subtitles..."; - context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false); + LOG_D("main") << "Loading subtitles..."; + context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false); - if (vm.count("video")) { - LOG_D("main") << "Loading video..."; - context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(vm["video"].as()))); - } + if (vm.count("video")) { + LOG_D("main") << "Loading video..."; + context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(vm["video"].as()))); + } - if (vm.count("timecodes")) { - LOG_D("main") << "Loading timecodes..."; - context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(vm["timecodes"].as()))); - } + if (vm.count("timecodes")) { + LOG_D("main") << "Loading timecodes..."; + context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(vm["timecodes"].as()))); + } - if (vm.count("keyframes")) { - LOG_D("main") << "Loading keyframes..."; - context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(vm["keyframes"].as()))); - } + if (vm.count("keyframes")) { + LOG_D("main") << "Loading keyframes..."; + context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(vm["keyframes"].as()))); + } - auto active_index = vm["active-line"].as(); - AssDialogue* active_line = nullptr; + auto active_index = vm["active-line"].as(); + AssDialogue* active_line = nullptr; - auto selected_indices = parse_range(vm["selected-lines"].as()); - Selection selected_lines; + auto selected_indices = parse_range(vm["selected-lines"].as()); + Selection selected_lines; - int i = 0; - for (auto& line : context.ass->Events) { - if (i == active_index) { - active_line = &line; - } - - if (selected_indices.empty() || selected_indices.count(i)) { - selected_lines.insert(&line); - if (active_line == nullptr) { - // assign first line in selection as a fallback + int i = 0; + for (auto& line : context.ass->Events) { + if (i == active_index) { active_line = &line; } + + if (selected_indices.empty() || selected_indices.count(i)) { + selected_lines.insert(&line); + if (active_line == nullptr) { + // assign first line in selection as a fallback + active_line = &line; + } + } + i++; } - i++; - } - if (active_line == nullptr) { - // selection was empty - active_line = &context.ass->Events.front(); - selected_lines.insert(active_line); - } + if (active_line == nullptr) { + // selection was empty + active_line = &context.ass->Events.front(); + selected_lines.insert(active_line); + } - context.selectionController->SetSelectionAndActive( - std::move(selected_lines), active_line); + context.selectionController->SetSelectionAndActive( + std::move(selected_lines), active_line); - if (vm.count("dialog")) - config::dialog_responses = parse_dialog_responses(vm["dialog"].as>()); + if (vm.count("dialog")) + config::dialog_responses = parse_dialog_responses(vm["dialog"].as>()); - if (vm.count("file")) - config::file_responses = parse_file_responses(vm["file"].as>()); + if (vm.count("file")) + config::file_responses = parse_file_responses(vm["file"].as>()); - // cache cwd in case automation changes it - auto cwd = std::filesystem::current_path(); + // cache cwd in case automation changes it + auto cwd = std::filesystem::current_path(); - std::vector> scripts; - if (vm.count("automation")) { - for (auto& s : vm["automation"].as>()) { - LOG_D("main") << "Loading " << s; - auto script = find_script(s); - if (!script) { - return 1; + std::vector> scripts; + if (vm.count("automation")) { + for (auto& s : vm["automation"].as>()) { + LOG_D("main") << "Loading " << s; + auto script = find_script(s); + if (!script) { + return 1; + } + scripts.emplace_back(std::move(script)); } - scripts.emplace_back(std::move(script)); } - } - auto macro = vm["macro"].as(); + auto macro = vm["macro"].as(); - cmd::Command *cmd = nullptr; + cmd::Command *cmd = nullptr; - // Allow calling automation scripts by their display name - for (auto const& script : scripts) { - for (auto const& c: script->GetMacros()) { - if (c->StrMenu(&context) == to_wx(macro)) { - cmd = c; + // Allow calling automation scripts by their display name + for (auto const& script : scripts) { + for (auto const& c: script->GetMacros()) { + if (c->StrMenu(&context) == to_wx(macro)) { + cmd = c; + } } } - } - // If we don't find one, try the command name instead - if (!cmd) { - try { - cmd = cmd::get(macro); - } catch (cmd::CommandNotFound const&) { - std::cerr << "Command not found: " << macro << std::endl; - LOG_E("main") << "Command not found: " << macro; - return 1; + // If we don't find one, try the command name instead + if (!cmd) { + try { + cmd = cmd::get(macro); + } catch (cmd::CommandNotFound const&) { + std::cerr << "Command not found: " << macro << std::endl; + LOG_E("main") << "Command not found: " << macro; + return 1; + } } - } - if (!cmd->Validate(&context)) { - LOG_E("main") << "Skipping automation because validation function returned false"; - return 1; - } + if (!cmd->Validate(&context)) { + LOG_E("main") << "Skipping automation because validation function returned false"; + return 1; + } - LOG_D("main") << "Calling " << cmd->name(); - (*cmd)(&context); + LOG_D("main") << "Calling " << cmd->name(); + (*cmd)(&context); - // restore cwd for saving - std::filesystem::current_path(cwd); - context.subsController->Save(agi::fs::path(std::filesystem::absolute(vm["out-file"].as()))); + // restore cwd for saving + std::filesystem::current_path(cwd); + context.subsController->Save(agi::fs::path(std::filesystem::absolute(vm["out-file"].as()))); - if (config::hasInitializedWx) { - wxUninitialize(); + if (config::hasInitializedWx) { + wxUninitialize(); + } + return 0; + } + catch (agi::Exception const& e) { + std::cerr << "Error: " << e.GetMessage() << std::endl; + return 1; + } + catch (std::exception const& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; } - return 0; } else { config::loadGlobalAutomation = true; config::hasInitializedWx = true; From 9bae44903e1be7e040d1bfc3d44d323583ddb05a Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 19:27:09 +0000 Subject: [PATCH 12/26] Add an end-to-end test for --cli mode Runs a deterministic automation macro on a fixture file in a throwaway HOME without a display server and checks the output. The macro also round-trips the clipboard, which exercises dispatch::Main().Sync() from the script thread through the CLI main loop. --- automation/tests/cli/cli-test-macro.lua | 42 ++++++++++++++ automation/tests/cli/input.ass | 20 +++++++ automation/tests/cli/run_test.py | 75 +++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 automation/tests/cli/cli-test-macro.lua create mode 100644 automation/tests/cli/input.ass create mode 100644 automation/tests/cli/run_test.py diff --git a/automation/tests/cli/cli-test-macro.lua b/automation/tests/cli/cli-test-macro.lua new file mode 100644 index 0000000000..9a3dcc69a5 --- /dev/null +++ b/automation/tests/cli/cli-test-macro.lua @@ -0,0 +1,42 @@ +-- Automation 4 test file +-- Deterministic macro used by the meson test suite to exercise --cli mode + +script_name = "CLI test macros" +script_description = "Macros used by the automated tests for Aegisub's CLI mode" +script_author = "Aegisub Project" +script_version = "1" + +local clipboard = require 'aegisub.clipboard' + +local function append_marker(subs, sel, act) + aegisub.progress.title("CLI test") + aegisub.progress.task("Appending markers") + + -- In CLI mode the clipboard is a process-local stand-in that never + -- touches the system clipboard or the main thread + assert(clipboard.set("cli-test-clipboard"), "clipboard.set failed") + assert(clipboard.get() == "cli-test-clipboard", "clipboard.get returned the wrong value") + + for n, i in ipairs(sel) do + local line = subs[i] + if not line.comment then + line.text = line.text .. "\\N{cli-test}" + subs[i] = line + end + aegisub.progress.set(100 * n / #sel) + end + aegisub.set_undo_point("append cli-test marker") +end + +local function count_lines(subs, sel, act) + local dialogue = 0 + for i = 1, #subs do + if subs[i].class == "dialogue" then + dialogue = dialogue + 1 + end + end + aegisub.log(0, "dialogue lines: " .. dialogue .. "\n") +end + +aegisub.register_macro("CLI Test/Append marker", "Appends a marker to all selected lines", append_marker) +aegisub.register_macro("CLI Test/Count lines", "Logs the number of dialogue lines", count_lines) diff --git a/automation/tests/cli/input.ass b/automation/tests/cli/input.ass new file mode 100644 index 0000000000..6ad7a3d69c --- /dev/null +++ b/automation/tests/cli/input.ass @@ -0,0 +1,20 @@ +[Script Info] +; Script generated by Aegisub +Title: CLI test file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None +PlayResX: 1280 +PlayResY: 720 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,First line +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Second line +Comment: 0,0:00:10.00,0:00:15.00,Default,,0,0,0,,A comment line +Dialogue: 0,0:00:15.00,0:00:20.00,Default,,0,0,0,,Third line diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py new file mode 100644 index 0000000000..115e017080 --- /dev/null +++ b/automation/tests/cli/run_test.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Smoke test for Aegisub's --cli mode. + +Runs a deterministic automation macro on a fixture file in a throwaway HOME +(so the user's real config is untouched) and checks the modified lines were +written out. Must work without a display server; DISPLAY/WAYLAND_DISPLAY are +stripped from the environment to make sure of that. +""" + +import os +import subprocess +import sys +import tempfile + +def main(): + aegisub, srcdir = sys.argv[1], sys.argv[2] + + env = dict(os.environ) + env.pop("DISPLAY", None) + env.pop("WAYLAND_DISPLAY", None) + + with tempfile.TemporaryDirectory() as tmp: + env["HOME"] = tmp + env["XDG_CONFIG_HOME"] = os.path.join(tmp, ".config") + env["XDG_CACHE_HOME"] = os.path.join(tmp, ".cache") + env["XDG_DATA_HOME"] = os.path.join(tmp, ".local", "share") + out_file = os.path.join(tmp, "out.ass") + + # ?data points at the install prefix, which doesn't exist for an + # uninstalled build, so make ?user/automation point at the source + # tree to give scripts access to the include files (moonscript etc.) + automation_dir = os.path.dirname(os.path.dirname(srcdir)) + user_dir = os.path.join(tmp, ".aegisub") + os.makedirs(user_dir) + os.symlink(automation_dir, os.path.join(user_dir, "automation")) + + cmd = [ + aegisub, "--cli", + os.path.join(srcdir, "input.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ] + proc = subprocess.run(cmd, env=env, timeout=240, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + sys.stdout.buffer.write(proc.stdout) + if proc.returncode != 0: + print(f"aegisub --cli exited with {proc.returncode}") + return 1 + + if not os.path.exists(out_file): + print("output file was not written") + return 1 + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + marked = out.count("\\N{cli-test}") + if marked != 3: + print(f"expected 3 marked dialogue lines, found {marked}") + print("---- output file ----") + print(out) + return 1 + + if "A comment line" not in out: + print("comment line went missing from the output") + print("---- output file ----") + print(out) + return 1 + + print("ok") + return 0 + +if __name__ == "__main__": + sys.exit(main()) From 24a776e5400370f24efc3a9db2e1c7df16013628 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 20:27:22 +0000 Subject: [PATCH 13/26] Fix the Windows build by keeping wx's entry point there A GUI-subsystem application on Windows links against WinMain, which wxIMPLEMENT_APP provides and our custom main() does not, so the branch did not link on Windows. Keep the stock wx entry point on Windows (no --cli mode there for now) and default the new config flags to the GUI behavior so platforms which never run our main() work unchanged. --- src/main.cpp | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index def027c864..d165a6b3f3 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -84,15 +84,24 @@ namespace config { agi::Path *path = nullptr; Automation4::AutoloadScriptManager *global_scripts; - bool hasGui = false; - bool hasInitializedWx = false; - bool loadGlobalAutomation = false; + // These default to the GUI behavior so that platforms which use wx's + // entry point (and thus never run our main()) work unchanged; CLI mode + // overrides them before initialization + bool hasGui = true; + bool hasInitializedWx = true; + bool loadGlobalAutomation = true; std::map> choice_indices; std::list> dialog_responses; std::list> file_responses; } +#ifdef __WXMSW__ +// A GUI-subsystem application's entry point on Windows is WinMain, which wx +// provides; --cli mode is not available there yet +wxIMPLEMENT_APP(AegisubApp); +#else wxIMPLEMENT_APP_NO_MAIN(AegisubApp); +#endif static const char *LastStartupState = nullptr; @@ -294,6 +303,7 @@ bool AegisubInitialize(AegisubApp *app, std::function find_script(const std::string& file) { auto absolute = agi::fs::path(file); @@ -380,6 +390,9 @@ int main(int argc, char *argv[]) { agi::util::InitLocale(); if (cli) { + config::hasInitializedWx = false; + config::loadGlobalAutomation = false; + // Scripts run on a worker thread while this thread pumps main-thread // thunks, mirroring the GUI threading model (see cli::RunWithMainLoop) cli::InitDispatch(); @@ -511,11 +524,10 @@ int main(int argc, char *argv[]) { return 1; } } else { - config::loadGlobalAutomation = true; - config::hasInitializedWx = true; return wxEntry(argc, argv); } } +#endif // !__WXMSW__ /// @brief wx's initialization function. Called from main() to initialize the GUI-specific stuff and then call Initialize() From 59dd8e7dd19a0942fb857f8d0687dd3931a96ccb Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 19:30:31 +0000 Subject: [PATCH 14/26] Don't render video frames asynchronously in CLI mode There is no event loop to deliver the rendered frame to, and queueing wx events without a wxApp crashes when a script triggers a subtitle commit while video is loaded. --- src/async_video_provider.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/async_video_provider.cpp b/src/async_video_provider.cpp index 2ffee2abd9..ea97efbe59 100644 --- a/src/async_video_provider.cpp +++ b/src/async_video_provider.cpp @@ -19,6 +19,7 @@ #include "ass_dialogue.h" #include "ass_file.h" #include "export_fixstyle.h" +#include "options.h" #include "include/aegisub/subtitles_provider.h" #include "video_frame.h" #include "video_provider_manager.h" @@ -240,6 +241,10 @@ bool AsyncVideoProvider::NeedUpdate(std::vector const& v } void AsyncVideoProvider::ProcAsync(uint_fast32_t req_version, bool check_updated) { + // There is no event loop to deliver the rendered frame to in CLI mode, + // and queueing wx events without a wxApp crashes + if (!config::hasGui) return; + // Only actually produce the frame if there's no queued changes waiting if (req_version < version || frame_number < 0) return; From 92a204866d71255fe5975e397ff0e3bf4cd1ab75 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 20:45:25 +0000 Subject: [PATCH 15/26] Make macro failures fail the process in CLI mode Script runtime errors surface as UserCancelException, which LuaCommand::operator() swallows after rolling back since in the GUI a cancelled macro is not an error. In CLI mode there is no user to cancel the run, so it either means the script threw an error or a dialog went unanswered; rethrow so aegisub --cli exits nonzero instead of writing the output file and reporting success. Found by running the Aegisub-Motion tests, where a failing test previously exited 0. --- src/auto4_lua.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/auto4_lua.cpp b/src/auto4_lua.cpp index 9a341bbe93..2ca4365c35 100644 --- a/src/auto4_lua.cpp +++ b/src/auto4_lua.cpp @@ -902,6 +902,11 @@ namespace { catch (agi::UserCancelException const&) { subsobj->Cancel(); stackcheck.check_stack(0); + // In CLI mode there is no user to cancel the run, so this either + // means the script threw an error or a dialog went unanswered; + // propagate it so the process can exit with an error + if (!config::hasGui) + throw; return; } From f47e915c0af692e36af06324db37b8ab139a3f35 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 18:16:39 -0700 Subject: [PATCH 16/26] Register the --cli end-to-end test in meson The original headless branch bundled this registration into the same commit as the aegisub-lua module-test wiring, which has since landed on master via #657, so only the e2e half needs restoring. Gate it to Linux: Windows keeps wx's entry point so --cli doesn't exist there, and the suite has only been vetted headless on the Linux runners. CI picks it up through the existing 'meson test --suite Aegisub' step. --- meson.build | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/meson.build b/meson.build index 81e90b0cd9..4aa428ace9 100644 --- a/meson.build +++ b/meson.build @@ -594,3 +594,20 @@ aegisub = executable('aegisub', aegisub_src, version_h, libresrc_headers, aegisu install_dir: bindir, dependencies: deps, win_subsystem: 'windows') + +if get_option('tests') and host_machine.system() == 'linux' + # End-to-end test of --cli mode: runs an automation macro on a fixture + # file without a display server and checks the output. Linux-only for + # now: Windows keeps wx's entry point so there is no --cli there, and + # the macOS CI builds have not been vetted for running outside a bundle. + test('automation-cli', + find_program('python3', 'python'), + args: [ + meson.current_source_dir() / 'automation' / 'tests' / 'cli' / 'run_test.py', + aegisub.full_path(), + meson.current_source_dir() / 'automation' / 'tests' / 'cli', + ], + suite: 'automation', + depends: aegisub, + timeout: 300) +endif From 10c3ffc74d003d40924437e4c96286fe6a24d103 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 18:18:09 -0700 Subject: [PATCH 17/26] Handle command line parsing errors instead of aborting boost::program_options::store/notify ran outside any exception handler, so an unknown option, a malformed value, or a repeated single-value argument killed the process with an uncaught exception rather than a diagnostic - and since this main() also fronts GUI launches on POSIX, a stray toolkit flag would take down the GUI too. Parse inside a handler that prints the error and usage, allow unregistered options through for the GUI (wx parses the original argv itself) while rejecting them in --cli mode, and make missing-argument errors exit nonzero instead of 0 so scripts can tell failure from help output. --- src/main.cpp | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index d165a6b3f3..098ba664ed 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -370,23 +370,52 @@ int main(int argc, char *argv[]) { posdesc.add("in-file", 1); posdesc.add("out-file", 1); posdesc.add("macro", 1); + auto print_usage = [&](std::ostream& out) { + out << argv[0] << " [options] " << std::endl; + out << flags << std::endl; + }; + boost::program_options::variables_map vm; - boost::program_options::store( - boost::program_options::command_line_parser(argc, argv). - options(cmdline).positional(posdesc).run(), vm); - boost::program_options::notify(vm); + boost::program_options::parsed_options parsed(&cmdline); + try { + // Unregistered options are collected rather than rejected so that GUI + // launches keep working with toolkit arguments (wx parses the + // original argv itself); CLI mode rejects them below + parsed = boost::program_options::command_line_parser(argc, argv). + options(cmdline).positional(posdesc).allow_unregistered().run(); + boost::program_options::store(parsed, vm); + boost::program_options::notify(vm); + } + catch (boost::program_options::error const& e) { + std::cerr << "Error: " << e.what() << std::endl; + print_usage(std::cerr); + return 1; + } bool cli = vm.count("cli"); config::hasGui = !cli; - if (vm.count("help") || (cli && !vm.count("macro"))) { - if (!vm.count("help")) - std::cout << "Too few arguments." << std::endl; - std::cout << argv[0] << " [options] " << std::endl; - std::cout << flags << std::endl; + if (cli) { + auto unrecognized = boost::program_options::collect_unrecognized( + parsed.options, boost::program_options::exclude_positional); + if (!unrecognized.empty()) { + std::cerr << "Error: unrecognised option '" << unrecognized.front() << "'" << std::endl; + print_usage(std::cerr); + return 1; + } + } + + if (vm.count("help")) { + print_usage(std::cout); return 0; } + if (cli && !vm.count("macro")) { + std::cerr << "Too few arguments." << std::endl; + print_usage(std::cerr); + return 1; + } + agi::util::InitLocale(); if (cli) { From 3d3bc47517685c688ff22709807158d30f797076 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 18:18:48 -0700 Subject: [PATCH 18/26] Fail cleanly in CLI mode when the input file can't be loaded Project::LoadSubtitles reports failures (missing file, parse errors) through the GUI error path rather than throwing, so the CLI carried on with the context's untouched blank document: at best saving an empty file with exit code 0, at worst crashing on Events.front() since the blank document has no dialogue lines. Detect the failed load and exit with a diagnostic instead. A valid file with no dialogue lines is not affected: SubsController guarantees at least one line on load, matching how the GUI opens such files. Also make wrapMessageBox print errors and warnings to stderr in CLI mode - previously they went only to the log sink, which is invisible on the console, so the reason for a failed load never reached the user - and fix its severity checks, which used | rather than & and so classified every message as a warning. --- src/gui_wrap.cpp | 13 ++++++++++--- src/main.cpp | 5 ++++- src/project.cpp | 5 +++-- src/project.h | 3 ++- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/gui_wrap.cpp b/src/gui_wrap.cpp index a57a9df813..f55a8836bb 100644 --- a/src/gui_wrap.cpp +++ b/src/gui_wrap.cpp @@ -23,6 +23,8 @@ #include "compat.h" #include "options.h" +#include + #include #include // Keep this last so wxUSE_CHOICEDLG is set. @@ -32,10 +34,15 @@ int wrapMessageBox(const wxString &message, const wxString &caption, long style, return wxMessageBox(message, caption, style, parent); agi::log::Severity severity = agi::log::Info; - if (style | wxICON_ERROR) - severity = agi::log::Exception; - if (style | wxICON_WARNING) + if (style & wxICON_WARNING) severity = agi::log::Warning; + if (style & wxICON_ERROR) + severity = agi::log::Exception; + + // The log sink isn't visible on the console, so errors and warnings + // should also reach the user of the CLI directly + if (severity != agi::log::Info) + std::cerr << caption.utf8_str() << ": " << message.utf8_str() << std::endl; LOG_SINK("agi", severity) << caption << ": " << message; return 0; diff --git a/src/main.cpp b/src/main.cpp index 098ba664ed..ef3b3006e4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -434,7 +434,10 @@ int main(int argc, char *argv[]) { agi::Context context; LOG_D("main") << "Loading subtitles..."; - context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false); + if (!context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false)) { + std::cerr << "Failed to load " << vm["in-file"].as() << std::endl; + return 1; + } if (vm.count("video")) { LOG_D("main") << "Loading video..."; diff --git a/src/project.cpp b/src/project.cpp index 4579711dbd..2e713a09d6 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -162,15 +162,16 @@ bool Project::DoLoadSubtitles(agi::fs::path const& path, std::string encoding, P return true; } -void Project::LoadSubtitles(agi::fs::path path, std::string encoding, bool load_linked) { +bool Project::LoadSubtitles(agi::fs::path path, std::string encoding, bool load_linked) { ProjectProperties properties; if (!DoLoadSubtitles(path, encoding, properties)) - return; + return false; if (load_linked) LoadUnloadFiles(properties); else UpdateRelativePaths(); + return true; } void Project::SetSubtitlesFilename(agi::fs::path path) { diff --git a/src/project.h b/src/project.h index 334c1e3a54..1b826c76f5 100644 --- a/src/project.h +++ b/src/project.h @@ -70,7 +70,8 @@ class Project : private agi::signal::ConnectionScope { Project(agi::Context *context); ~Project(); - void LoadSubtitles(agi::fs::path path, std::string encoding="", bool load_linked=true); + /// @return Whether the file was loaded as subtitles + bool LoadSubtitles(agi::fs::path path, std::string encoding="", bool load_linked=true); void SetSubtitlesFilename(agi::fs::path path); void CloseSubtitles(); bool CanLoadSubtitlesFromVideo() const { return video_has_subtitles; } From 7cefbbd7dcecc7a1419d570d67d55d45da05710a Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 18:21:09 -0700 Subject: [PATCH 19/26] Cover the CLI error paths in the end-to-end test Adds cases for the boundary conditions around the happy path: a valid file with no dialogue lines (loads with an inserted blank line and runs the macro, matching GUI behavior), a nonexistent input file (clean exit 1 with a diagnostic and no output written), a malformed option value, and an unknown option (both clean exit 1 rather than death by uncaught exception). --- automation/tests/cli/empty.ass | 16 ++++ automation/tests/cli/run_test.py | 151 ++++++++++++++++++++++--------- 2 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 automation/tests/cli/empty.ass diff --git a/automation/tests/cli/empty.ass b/automation/tests/cli/empty.ass new file mode 100644 index 0000000000..48d54fab0e --- /dev/null +++ b/automation/tests/cli/empty.ass @@ -0,0 +1,16 @@ +[Script Info] +; Script generated by Aegisub +Title: CLI test empty file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None +PlayResX: 1280 +PlayResY: 720 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py index 115e017080..c400606862 100644 --- a/automation/tests/cli/run_test.py +++ b/automation/tests/cli/run_test.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Smoke test for Aegisub's --cli mode. +"""End-to-end tests for Aegisub's --cli mode. -Runs a deterministic automation macro on a fixture file in a throwaway HOME -(so the user's real config is untouched) and checks the modified lines were -written out. Must work without a display server; DISPLAY/WAYLAND_DISPLAY are -stripped from the environment to make sure of that. +Runs a deterministic automation macro on fixture files in a throwaway HOME +(so the user's real config is untouched) and checks the results, plus the +error paths for bad command lines and unloadable input files. Must work +without a display server; DISPLAY/WAYLAND_DISPLAY are stripped from the +environment to make sure of that. """ import os @@ -12,6 +13,100 @@ import sys import tempfile +def run_cli(aegisub, args, env): + proc = subprocess.run([aegisub, "--cli"] + args, env=env, timeout=240, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + sys.stdout.buffer.write(proc.stdout) + return proc + +def fail(message, output=None): + print(message) + if output is not None: + print("---- output file ----") + print(output) + return 1 + +def test_macro_runs(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 0: + return fail(f"aegisub --cli exited with {proc.returncode}") + + if not os.path.exists(out_file): + return fail("output file was not written") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + marked = out.count("\\N{cli-test}") + if marked != 3: + return fail(f"expected 3 marked dialogue lines, found {marked}", out) + + if "A comment line" not in out: + return fail("comment line went missing from the output", out) + return 0 + +def test_empty_file(aegisub, srcdir, env, tmp): + # A valid file with no dialogue lines gets a blank line inserted on + # load, the same as when the GUI opens it, and the macro runs on that + out_file = os.path.join(tmp, "out-empty.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "empty.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 0: + return fail(f"empty input: aegisub --cli exited with {proc.returncode}") + + if not os.path.exists(out_file): + return fail("empty input: output file was not written") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + marked = out.count("\\N{cli-test}") + if marked != 1: + return fail(f"empty input: expected 1 marked line, found {marked}", out) + return 0 + +def test_missing_input(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out-missing.ass") + proc = run_cli(aegisub, [ + os.path.join(tmp, "does-not-exist.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 1: + return fail(f"missing input: expected exit code 1, got {proc.returncode}") + if b"Failed to load" not in proc.stdout: + return fail("missing input: no diagnostic was printed") + if os.path.exists(out_file): + return fail("missing input: an output file was written anyway") + return 0 + +def test_bad_option_value(aegisub, srcdir, env, tmp): + # A malformed value must produce a clean error, not an uncaught + # exception (which would show up here as death by SIGABRT) + proc = run_cli(aegisub, ["--active-line=notanumber"], env) + if proc.returncode != 1: + return fail(f"bad option value: expected exit code 1, got {proc.returncode}") + return 0 + +def test_unknown_option(aegisub, srcdir, env, tmp): + proc = run_cli(aegisub, ["--definitely-not-an-option"], env) + if proc.returncode != 1: + return fail(f"unknown option: expected exit code 1, got {proc.returncode}") + if b"unrecognised option" not in proc.stdout: + return fail("unknown option: no diagnostic was printed") + return 0 + def main(): aegisub, srcdir = sys.argv[1], sys.argv[2] @@ -24,7 +119,6 @@ def main(): env["XDG_CONFIG_HOME"] = os.path.join(tmp, ".config") env["XDG_CACHE_HOME"] = os.path.join(tmp, ".cache") env["XDG_DATA_HOME"] = os.path.join(tmp, ".local", "share") - out_file = os.path.join(tmp, "out.ass") # ?data points at the install prefix, which doesn't exist for an # uninstalled build, so make ?user/automation point at the source @@ -34,42 +128,15 @@ def main(): os.makedirs(user_dir) os.symlink(automation_dir, os.path.join(user_dir, "automation")) - cmd = [ - aegisub, "--cli", - os.path.join(srcdir, "input.ass"), - out_file, - "CLI Test/Append marker", - "--automation", os.path.join(srcdir, "cli-test-macro.lua"), - ] - proc = subprocess.run(cmd, env=env, timeout=240, - stdout=subprocess.PIPE, stderr=subprocess.STDOUT) - sys.stdout.buffer.write(proc.stdout) - if proc.returncode != 0: - print(f"aegisub --cli exited with {proc.returncode}") - return 1 - - if not os.path.exists(out_file): - print("output file was not written") - return 1 - - with open(out_file, encoding="utf-8-sig") as f: - out = f.read() - - marked = out.count("\\N{cli-test}") - if marked != 3: - print(f"expected 3 marked dialogue lines, found {marked}") - print("---- output file ----") - print(out) - return 1 - - if "A comment line" not in out: - print("comment line went missing from the output") - print("---- output file ----") - print(out) - return 1 - - print("ok") - return 0 + tests = [test_macro_runs, test_empty_file, test_missing_input, + test_bad_option_value, test_unknown_option] + for test in tests: + print(f"== {test.__name__}") + if test(aegisub, srcdir, env, tmp) != 0: + return 1 + + print("ok") + return 0 if __name__ == "__main__": sys.exit(main()) From c28978c84d5b76eb559ee85cba9f357a4832fa66 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Wed, 12 Aug 2026 18:41:22 -0700 Subject: [PATCH 20/26] Use CLI11 instead of boost::program_options for the CLI program_options is one of boost's compiled libraries, and this branch would have been the first thing in the tree to link it - the wrong direction given the appetite to shrink the boost dependency. CLI11 is a header-only single dependency consumed through its first-party meson support (the wrap is just a hash-pinned pointer to the upstream tarball), included by main.cpp alone. The port also removes the stringly-typed variables_map lookups in favor of options bound to typed locals, requires in-file and out-file up front in --cli mode instead of failing later with a bad_any_cast, and keeps the boost behaviors the tests pin down: positionals are also accessible as named options, repeatable options take exactly one value per occurrence (allow_extra_args(false) - CLI11's vector options are otherwise greedy and would swallow the positionals), unknown options still pass through for GUI launches and error out in CLI mode, and all parse failures exit 1 with a diagnostic. --- meson.build | 5 +- src/main.cpp | 157 ++++++++++++++++++----------------------- subprojects/cli11.wrap | 10 +++ 3 files changed, 82 insertions(+), 90 deletions(-) create mode 100644 subprojects/cli11.wrap diff --git a/meson.build b/meson.build index 4aa428ace9..154e181e1d 100644 --- a/meson.build +++ b/meson.build @@ -151,7 +151,7 @@ libass_dep = dependency('libass', version: '>=0.9.7', deps += libass_dep boost_static = meson.version().version_compare('>=0.63.0') and get_option('prefer_static') # boost dependencies do not respect prefer_static https://github.com/mesonbuild/meson/issues/15458 -boost_modules = ['chrono', 'locale', 'program_options'] +boost_modules = ['chrono', 'locale'] if boost_static boost_modules += 'thread' # static boost dependencies do not include transitive dependencies https://github.com/mesonbuild/meson/issues/15457 @@ -198,6 +198,9 @@ endif zlib_dep = dependency('zlib') deps += zlib_dep +# Header-only; the wrap builds CLI11's own first-party meson support +deps += dependency('CLI11', version: '>=2.0') + use_bundled_wx = force_all_fallbacks or force_fallback_for.contains('wxWidgets') if not use_bundled_wx wx_dep = dependency('wxWidgets', version: '>=' + get_option('wx_version'), diff --git a/src/main.cpp b/src/main.cpp index ef3b3006e4..8d438a364a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -71,7 +71,7 @@ #include #include -#include +#include #include #include #include @@ -341,79 +341,63 @@ std::unique_ptr find_script(const std::string& file) int main(int argc, char *argv[]) { wxDISABLE_DEBUG_SUPPORT(); - boost::program_options::options_description cmdline("Options"); - boost::program_options::options_description flags("Options"); - boost::program_options::positional_options_description posdesc; - - cmdline.add_options() - ("in-file", boost::program_options::value(), "input file") - ("out-file", boost::program_options::value(), "output file") - ("macro", boost::program_options::value(), "macro to run") - ; - - flags.add_options() - ("help", "produce help message") - ("cli", "run in CLI mode, without a GUI window. Enables the other options") - ("video", boost::program_options::value(), "video to load") - ("timecodes", boost::program_options::value(), "timecodes to load") - ("keyframes", boost::program_options::value(), "keyframes to load") - ("automation", boost::program_options::value>(), "an automation script to run") - ("active-line", boost::program_options::value()->default_value(-1), "the active line") - ("selected-lines", boost::program_options::value()->default_value(""), "the selected lines") - ("dialog", boost::program_options::value>(), "response to a dialog, in JSON") - ("file", boost::program_options::value>(), "filename to supply to an open/save call") - ; - - //TODO figure this out properly - - cmdline.add(flags); - posdesc.add("in-file", 1); - posdesc.add("out-file", 1); - posdesc.add("macro", 1); - auto print_usage = [&](std::ostream& out) { - out << argv[0] << " [options] " << std::endl; - out << flags << std::endl; - }; - - boost::program_options::variables_map vm; - boost::program_options::parsed_options parsed(&cmdline); + CLI::App app("Aegisub - Advanced Subtitle Editor"); + + bool cli = false; + std::string in_file, out_file, macro; + std::string video, timecodes, keyframes; + std::string selected_range; + int active_index = -1; + std::vector automation_scripts, dialog_json, file_names; + + // The positionals can also be passed as named options + auto in_file_opt = app.add_option("in-file,--in-file", in_file, "input file"); + auto out_file_opt = app.add_option("out-file,--out-file", out_file, "output file"); + auto macro_opt = app.add_option("macro,--macro", macro, "macro to run"); + + app.add_flag("--cli", cli, "run in CLI mode, without a GUI window. Enables the other options"); + app.add_option("--video", video, "video to load"); + app.add_option("--timecodes", timecodes, "timecodes to load"); + app.add_option("--keyframes", keyframes, "keyframes to load"); + // allow_extra_args(false) makes the repeatable options consume exactly + // one value per occurrence rather than greedily eating the positionals + app.add_option("--automation", automation_scripts, "an automation script to run")->allow_extra_args(false); + app.add_option("--active-line", active_index, "the active line")->capture_default_str(); + app.add_option("--selected-lines", selected_range, "the selected lines"); + app.add_option("--dialog", dialog_json, "response to a dialog, in JSON")->allow_extra_args(false); + app.add_option("--file", file_names, "filename to supply to an open/save call")->allow_extra_args(false); + + // Extra arguments are collected rather than rejected so that GUI + // launches keep working with toolkit arguments (wx parses the original + // argv itself); CLI mode rejects them below + app.allow_extras(); + try { - // Unregistered options are collected rather than rejected so that GUI - // launches keep working with toolkit arguments (wx parses the - // original argv itself); CLI mode rejects them below - parsed = boost::program_options::command_line_parser(argc, argv). - options(cmdline).positional(posdesc).allow_unregistered().run(); - boost::program_options::store(parsed, vm); - boost::program_options::notify(vm); - } - catch (boost::program_options::error const& e) { - std::cerr << "Error: " << e.what() << std::endl; - print_usage(std::cerr); + app.parse(argc, argv); + } + catch (CLI::Success const& e) { + // --help and friends print and exit successfully + return app.exit(e); + } + catch (CLI::ParseError const& e) { + app.exit(e); // prints the error and a --help hint to stderr return 1; } - bool cli = vm.count("cli"); config::hasGui = !cli; if (cli) { - auto unrecognized = boost::program_options::collect_unrecognized( - parsed.options, boost::program_options::exclude_positional); - if (!unrecognized.empty()) { - std::cerr << "Error: unrecognised option '" << unrecognized.front() << "'" << std::endl; - print_usage(std::cerr); + auto extras = app.remaining(); + if (!extras.empty()) { + std::cerr << "Error: unrecognised option '" << extras.front() << "'" << std::endl; return 1; } - } - - if (vm.count("help")) { - print_usage(std::cout); - return 0; - } - if (cli && !vm.count("macro")) { - std::cerr << "Too few arguments." << std::endl; - print_usage(std::cerr); - return 1; + if (!in_file_opt->count() || !out_file_opt->count() || !macro_opt->count()) { + std::cerr << "Too few arguments." << std::endl; + std::cerr << app.help() << std::endl; + return 1; + } } agi::util::InitLocale(); @@ -434,30 +418,29 @@ int main(int argc, char *argv[]) { agi::Context context; LOG_D("main") << "Loading subtitles..."; - if (!context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(vm["in-file"].as())), "", false)) { - std::cerr << "Failed to load " << vm["in-file"].as() << std::endl; + if (!context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(in_file)), "", false)) { + std::cerr << "Failed to load " << in_file << std::endl; return 1; } - if (vm.count("video")) { + if (!video.empty()) { LOG_D("main") << "Loading video..."; - context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(vm["video"].as()))); + context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(video))); } - if (vm.count("timecodes")) { + if (!timecodes.empty()) { LOG_D("main") << "Loading timecodes..."; - context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(vm["timecodes"].as()))); + context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(timecodes))); } - if (vm.count("keyframes")) { + if (!keyframes.empty()) { LOG_D("main") << "Loading keyframes..."; - context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(vm["keyframes"].as()))); + context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(keyframes))); } - auto active_index = vm["active-line"].as(); AssDialogue* active_line = nullptr; - auto selected_indices = parse_range(vm["selected-lines"].as()); + auto selected_indices = parse_range(selected_range); Selection selected_lines; int i = 0; @@ -485,29 +468,25 @@ int main(int argc, char *argv[]) { context.selectionController->SetSelectionAndActive( std::move(selected_lines), active_line); - if (vm.count("dialog")) - config::dialog_responses = parse_dialog_responses(vm["dialog"].as>()); + if (!dialog_json.empty()) + config::dialog_responses = parse_dialog_responses(dialog_json); - if (vm.count("file")) - config::file_responses = parse_file_responses(vm["file"].as>()); + if (!file_names.empty()) + config::file_responses = parse_file_responses(file_names); // cache cwd in case automation changes it auto cwd = std::filesystem::current_path(); std::vector> scripts; - if (vm.count("automation")) { - for (auto& s : vm["automation"].as>()) { - LOG_D("main") << "Loading " << s; - auto script = find_script(s); - if (!script) { - return 1; - } - scripts.emplace_back(std::move(script)); + for (auto& s : automation_scripts) { + LOG_D("main") << "Loading " << s; + auto script = find_script(s); + if (!script) { + return 1; } + scripts.emplace_back(std::move(script)); } - auto macro = vm["macro"].as(); - cmd::Command *cmd = nullptr; // Allow calling automation scripts by their display name @@ -540,7 +519,7 @@ int main(int argc, char *argv[]) { // restore cwd for saving std::filesystem::current_path(cwd); - context.subsController->Save(agi::fs::path(std::filesystem::absolute(vm["out-file"].as()))); + context.subsController->Save(agi::fs::path(std::filesystem::absolute(out_file))); if (config::hasInitializedWx) { wxUninitialize(); diff --git a/subprojects/cli11.wrap b/subprojects/cli11.wrap new file mode 100644 index 0000000000..3fc918d63a --- /dev/null +++ b/subprojects/cli11.wrap @@ -0,0 +1,10 @@ +[wrap-file] +directory = CLI11-2.7.2 +source_url = https://github.com/CLIUtils/CLI11/archive/refs/tags/v2.7.2.tar.gz +source_filename = CLI11-2.7.2.tar.gz +source_hash = 46eef3101da70852ec7af026e09d485ccee81813331c8c6052d39344443b83da +source_fallback_url = https://wrapdb.mesonbuild.com/v2/cli11_2.7.2-1/get_source/CLI11-2.7.2.tar.gz +wrapdb_version = 2.7.2-1 + +[provide] +dependency_names = CLI11 From dbf6de15cc69c40b5c11ec2d9e91bcd9daeeebe2 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Thu, 8 Oct 2026 13:24:44 -0700 Subject: [PATCH 21/26] Allow overriding ?data with AEGISUB_DATA_DIR The bundled Lua support modules are now only loaded from ?data, which for an uninstalled build points at an install prefix that doesn't exist, so the CLI test's trick of pointing ?user/automation at the source tree no longer works. Let the test point ?data at the source tree instead. Also set CFFIXED_USER_HOME in the test, since macOS ignores HOME when looking up ?user and the test would otherwise use the real config. Co-Authored-By: Claude Opus 5.5 --- automation/tests/cli/run_test.py | 11 +++++------ libaegisub/common/path.cpp | 11 +++++++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py index c400606862..223cf10056 100644 --- a/automation/tests/cli/run_test.py +++ b/automation/tests/cli/run_test.py @@ -119,14 +119,13 @@ def main(): env["XDG_CONFIG_HOME"] = os.path.join(tmp, ".config") env["XDG_CACHE_HOME"] = os.path.join(tmp, ".cache") env["XDG_DATA_HOME"] = os.path.join(tmp, ".local", "share") + # macOS looks up ?user through Foundation, which ignores HOME + env["CFFIXED_USER_HOME"] = tmp # ?data points at the install prefix, which doesn't exist for an - # uninstalled build, so make ?user/automation point at the source - # tree to give scripts access to the include files (moonscript etc.) - automation_dir = os.path.dirname(os.path.dirname(srcdir)) - user_dir = os.path.join(tmp, ".aegisub") - os.makedirs(user_dir) - os.symlink(automation_dir, os.path.join(user_dir, "automation")) + # uninstalled build, so point it at the source tree instead to give + # scripts access to the bundled include files (moonscript etc.) + env["AEGISUB_DATA_DIR"] = os.path.dirname(os.path.dirname(os.path.dirname(srcdir))) tests = [test_macro_runs, test_empty_file, test_missing_input, test_bad_option_value, test_unknown_option] diff --git a/libaegisub/common/path.cpp b/libaegisub/common/path.cpp index 8f88b41806..8e20b8450a 100644 --- a/libaegisub/common/path.cpp +++ b/libaegisub/common/path.cpp @@ -20,6 +20,8 @@ #include +#include + namespace { constexpr std::string_view tokens[] = { "?audio", @@ -61,6 +63,15 @@ Path::Path() { static_assert(sizeof(paths) / sizeof(paths[0]) == sizeof(tokens) / sizeof(tokens[0]), "Token and path arrays need to be the same size"); FillPlatformSpecificPaths(); + + // Lets an uninstalled build (e.g. in the test suite) use the bundled + // files from the source tree +#ifdef _WIN32 + if (const wchar_t *data = _wgetenv(L"AEGISUB_DATA_DIR"); data && *data) +#else + if (const char *data = getenv("AEGISUB_DATA_DIR"); data && *data) +#endif + SetToken("?data", fs::path(data)); } fs::path Path::Decode(std::string_view path) const { From 51ccdb521f23402bd452ec35c110d5795629b4c7 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Thu, 8 Oct 2026 13:24:44 -0700 Subject: [PATCH 22/26] Report startup errors through the error callback A config file that can't be read showed a message box even in CLI mode, where wx was never initialized; on macOS that hangs forever in an invisible modal dialog. Also restore the translations and the message/title order of the other startup errors. Co-Authored-By: Claude Opus 5.5 --- src/main.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 8d438a364a..95f7c0c385 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -202,7 +202,7 @@ bool AegisubInitialize(AegisubApp *app, std::functionConfigUser(); } catch (agi::Exception const& err) { - wxMessageBox(fmt_tl("Configuration file is invalid. Error reported:\n%s", err.GetMessage()), _("Error")); + showError(from_wx(fmt_tl("Configuration file is invalid. Error reported:\n%s", err.GetMessage())), from_wx(_("Error"))); } #ifdef _WIN32 @@ -287,16 +287,16 @@ bool AegisubInitialize(AegisubApp *app, std::function Date: Thu, 8 Oct 2026 13:24:44 -0700 Subject: [PATCH 23/26] Don't write the user's config or hotkeys in CLI mode CLI mode still reads config.json, but no longer writes it back, and uses the default hotkeys without reading or saving hotkey.json. Nothing else writes to ?user in CLI mode anymore, so it no longer needs to be created up front, and the test now checks that it stays empty. Co-Authored-By: Claude Opus 5.5 --- automation/tests/cli/run_test.py | 15 ++++++++++++++- libaegisub/common/hotkey.cpp | 3 +++ src/hotkey.cpp | 4 +++- src/main.cpp | 9 +++------ 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py index 223cf10056..0884c3d223 100644 --- a/automation/tests/cli/run_test.py +++ b/automation/tests/cli/run_test.py @@ -91,6 +91,18 @@ def test_missing_input(aegisub, srcdir, env, tmp): return fail("missing input: an output file was written anyway") return 0 +def test_no_user_state(aegisub, srcdir, env, tmp): + # None of the runs above may have written config, hotkeys, MRU lists, + # logs etc. into the user's config directory + written = [] + for user_dir in (os.path.join(tmp, ".aegisub"), + os.path.join(tmp, "Library", "Application Support", "Aegisub")): + for root, _, files in os.walk(user_dir): + written += [os.path.join(root, f) for f in files] + if written: + return fail("files were written to the user's config directory:\n" + "\n".join(written)) + return 0 + def test_bad_option_value(aegisub, srcdir, env, tmp): # A malformed value must produce a clean error, not an uncaught # exception (which would show up here as death by SIGABRT) @@ -128,7 +140,8 @@ def main(): env["AEGISUB_DATA_DIR"] = os.path.dirname(os.path.dirname(os.path.dirname(srcdir))) tests = [test_macro_runs, test_empty_file, test_missing_input, - test_bad_option_value, test_unknown_option] + test_bad_option_value, test_unknown_option, + test_no_user_state] for test in tests: print(f"== {test.__name__}") if test(aegisub, srcdir, env, tmp) != 0: diff --git a/libaegisub/common/hotkey.cpp b/libaegisub/common/hotkey.cpp index c775cdb85f..b2b9f29800 100644 --- a/libaegisub/common/hotkey.cpp +++ b/libaegisub/common/hotkey.cpp @@ -174,6 +174,9 @@ std::string_view Hotkey::GetHotkey(std::string_view context, std::string_view co } void Hotkey::Flush() { + if (config_file.empty()) + return; + json::Object root; auto get = [](json::Object& obj, std::string_view key) -> json::UnknownElement& { diff --git a/src/hotkey.cpp b/src/hotkey.cpp index 9f8ec05129..2d3c6f640f 100644 --- a/src/hotkey.cpp +++ b/src/hotkey.cpp @@ -77,8 +77,10 @@ namespace hotkey { agi::hotkey::Hotkey *inst = nullptr; void init() { + // Hotkeys are irrelevant in CLI mode, so don't touch the user's hotkey + // file there; an empty path means the defaults are used and never saved inst = new agi::hotkey::Hotkey( - config::path->Decode("?user/hotkey.json"), + config::hasGui ? config::path->Decode("?user/hotkey.json") : agi::fs::path(), GET_DEFAULT_CONFIG(default_hotkey)); auto migrations = OPT_GET("App/Hotkey Migrations")->GetListString(); diff --git a/src/main.cpp b/src/main.cpp index 95f7c0c385..14b228d720 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -177,11 +177,6 @@ bool AegisubInitialize(AegisubApp *app, std::functionDecode("?user")); - // The config machinery writes to ?user even in CLI mode (e.g. flushing - // the hotkey map), and the directory-creating GUI startup steps are - // skipped there, so make sure the directory exists - agi::fs::CreateDirectory(config::path->Decode("?user")); - if (config::hasGui) { StartupLog("Create log writer"); auto path_log = config::path->Decode("?user/log/"); @@ -193,7 +188,9 @@ bool AegisubInitialize(AegisubApp *app, std::functionDecode("?user/config.json"), GET_DEFAULT_CONFIG(default_config)); + // CLI mode reads the user's config but never writes it back + config::opt = new agi::Options(config::path->Decode("?user/config.json"), GET_DEFAULT_CONFIG(default_config), + config::hasGui ? agi::Options::NONE : agi::Options::FLUSH_SKIP); } catch (agi::Exception& e) { LOG_E("config/init") << "Caught exception: " << e.GetMessage(); } From fe0e199040639a9f080caeb208cedd964ca33a6e Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Thu, 8 Oct 2026 13:24:44 -0700 Subject: [PATCH 24/26] Don't prompt or touch playback in CLI mode Built-in commands can be run in CLI mode, and many of them stop playback first, which asserted. Make playback a no-op there instead, and make UpdateVideoProperties itself skip its prompts rather than relying on its caller to. Co-Authored-By: Claude Opus 5.5 --- src/dialog_video_properties.cpp | 4 ++++ src/project.cpp | 3 +-- src/video_controller.cpp | 6 +++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/dialog_video_properties.cpp b/src/dialog_video_properties.cpp index 8b0766f98a..167479afb6 100644 --- a/src/dialog_video_properties.cpp +++ b/src/dialog_video_properties.cpp @@ -513,6 +513,10 @@ bool update_layout_res(AssFile *file, const AsyncVideoProvider *new_provider, wx } void UpdateVideoProperties(AssFile *file, const AsyncVideoProvider *new_provider, wxWindow *parent) { + // Everything here prompts the user, which can't be done in CLI mode + if (!config::hasGui) + return; + if (update_ycbcr_matrix(file, new_provider, parent)) file->Commit(_("change ycbcr matrix"), AssFile::COMMIT_SCRIPTINFO); diff --git a/src/project.cpp b/src/project.cpp index 2e713a09d6..14a5350ba4 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -385,8 +385,7 @@ bool Project::DoLoadVideo(agi::fs::path const& path) { AnnounceVideoProviderModified(video_provider.get()); - if (config::hasGui) - UpdateVideoProperties(context->ass.get(), video_provider.get(), context->parent); + UpdateVideoProperties(context->ass.get(), video_provider.get(), context->parent); video_provider->LoadSubtitles(context->ass.get()); timecodes = video_provider->GetFPS(); diff --git a/src/video_controller.cpp b/src/video_controller.cpp index 92dff3a814..25bf734c6a 100644 --- a/src/video_controller.cpp +++ b/src/video_controller.cpp @@ -138,7 +138,8 @@ void VideoController::PrevFrame() { } void VideoController::Play() { - assert(config::hasGui); + // There's no playback in CLI mode + if (!config::hasGui) return; if (IsPlaying()) { Stop(); return; @@ -156,7 +157,7 @@ void VideoController::Play() { } void VideoController::PlayLine() { - assert(config::hasGui); + if (!config::hasGui) return; Stop(); AssDialogue *curline = context->selectionController->GetActiveLine(); @@ -176,7 +177,6 @@ void VideoController::PlayLine() { } void VideoController::Stop() { - assert(config::hasGui); if (IsPlaying()) { playback->Stop(); context->audioController->Stop(); From 2e84a71682a308f4f2e582a8b050bd99ce55b3eb Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Thu, 8 Oct 2026 13:24:44 -0700 Subject: [PATCH 25/26] Make tool/resampleres work in CLI mode muxtools resamples subtitles by running aegisub-cli with --video and tool/resampleres, which the standalone aegisub-cli implemented by resampling to the video's resolution without asking. Do the same, using the settings the resample dialog would suggest, which are now computed in one place for both. Also pass dummy video names through as-is instead of turning them into paths, which the new tests use. Co-Authored-By: Claude Opus 5.5 --- automation/tests/cli/run_test.py | 39 +++++++++++++++++ src/command/tool.cpp | 15 ++++++- src/dialog_resample.cpp | 72 +++++++++++++++++++++----------- src/dialogs.h | 4 ++ src/main.cpp | 3 +- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py index 0884c3d223..7d4d44335c 100644 --- a/automation/tests/cli/run_test.py +++ b/automation/tests/cli/run_test.py @@ -91,6 +91,44 @@ def test_missing_input(aegisub, srcdir, env, tmp): return fail("missing input: an output file was written anyway") return 0 +def test_resample(aegisub, srcdir, env, tmp): + # The built-in resampler has no UI in CLI mode and instead resamples to + # the video's resolution, which is what muxtools relies on + out_file = os.path.join(tmp, "out-resample.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "tool/resampleres", + "--video", "?dummy:24:48:1920:1080:0:0:0:", + ], env) + if proc.returncode != 0: + return fail(f"resample: aegisub --cli exited with {proc.returncode}") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + if "PlayResX: 1920" not in out or "PlayResY: 1080" not in out: + return fail("resample: PlayRes was not changed to the video's resolution", out) + # 1280x720 -> 1920x1080 scales sizes by 1.5 + if "Style: Default,Arial,72," not in out: + return fail("resample: the style was not resampled", out) + return 0 + +def test_resample_without_video(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out-resample-novideo.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "tool/resampleres", + ], env) + if proc.returncode != 1: + return fail(f"resample without video: expected exit code 1, got {proc.returncode}") + if b"requires --video" not in proc.stdout: + return fail("resample without video: no diagnostic was printed") + if os.path.exists(out_file): + return fail("resample without video: an output file was written anyway") + return 0 + def test_no_user_state(aegisub, srcdir, env, tmp): # None of the runs above may have written config, hotkeys, MRU lists, # logs etc. into the user's config directory @@ -140,6 +178,7 @@ def main(): env["AEGISUB_DATA_DIR"] = os.path.dirname(os.path.dirname(os.path.dirname(srcdir))) tests = [test_macro_runs, test_empty_file, test_missing_input, + test_resample, test_resample_without_video, test_bad_option_value, test_unknown_option, test_no_user_state] for test in tests: diff --git a/src/command/tool.cpp b/src/command/tool.cpp index 21517de8ff..9f5cf467df 100644 --- a/src/command/tool.cpp +++ b/src/command/tool.cpp @@ -38,9 +38,12 @@ #include "../dialogs.h" #include "../include/aegisub/context.h" #include "../libresrc/libresrc.h" +#include "../options.h" +#include "../project.h" #include "../resolution_resampler.h" #include "../video_controller.h" +#include #include #include @@ -97,8 +100,16 @@ struct tool_resampleres final : public Command { void operator()(agi::Context *c) override { c->videoController->Stop(); ResampleSettings settings; - if (PromptForResampleSettings(c, settings)) - ResampleResolution(c->ass.get(), settings); + if (!config::hasGui) { + // There's no one to ask in CLI mode, so do what the dialog + // suggests, as the standalone aegisub-cli did + if (!c->project->VideoProvider()) + throw agi::InvalidInputException("Resampling in CLI mode requires --video"); + settings = DefaultResampleSettings(c); + } + else if (!PromptForResampleSettings(c, settings)) + return; + ResampleResolution(c->ass.get(), settings); } }; diff --git a/src/dialog_resample.cpp b/src/dialog_resample.cpp index 8ef652047c..bd52565804 100644 --- a/src/dialog_resample.cpp +++ b/src/dialog_resample.cpp @@ -17,6 +17,7 @@ #include "ass_file.h" #include "async_video_provider.h" #include "compat.h" +#include "dialogs.h" #include "help_button.h" #include "include/aegisub/context.h" #include "libresrc/libresrc.h" @@ -118,19 +119,15 @@ DialogResample::DialogResample(agi::Context *c, ResampleSettings &settings) { d.SetIcons(GETICONS(resample_toolbutton)); - c->ass->GetResolution(script_w, script_h); - settings.source_x = script_w; - settings.source_y = script_h; + settings = DefaultResampleSettings(c); + script_w = settings.source_x; + script_h = settings.source_y; if (auto provider = c->project->VideoProvider()) { - settings.dest_x = video_w = provider->GetWidth(); - settings.dest_y = video_h = provider->GetHeight(); + video_w = provider->GetWidth(); + video_h = provider->GetHeight(); video_mat = provider->GetRealColorSpace(); } - else { - settings.dest_x = script_w; - settings.dest_y = script_h; - } // Create static box sizers auto source_res_box_sizer = new wxStaticBoxSizer(wxVERTICAL, &d, _("Source Resolution")); @@ -242,23 +239,9 @@ DialogResample::DialogResample(agi::Context *c, ResampleSettings &settings) source_matrix->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { OnMatrixChange(); }); dest_matrix->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { OnMatrixChange(); }); - // Find out if we want to suggest resampling colors. - // If the script matrix is None, we do not know what matrix to resample from, so we don't suggest to resample. - // If the video matrix is unspecified or does not correspond to a valid YCbCr Header value, we cannot currently resample to it. - // -> FIXME: The latter is not a theoretical reason but only due to the fact that resampling is not currently implemented for any other matrices in ycbcr_conv.h: - // We could in theory have a situation where a script with no YCbCr Header (so effectively TV.601) was authored on a BT.2020 video using color matching, - // and the user now wants to adjust the colors so that they can set the matrix to None instead. - // Otherwise, i.e. if e.g. the script has a TV.601 header but the video uses TV.709, we suggest to resample. - // (The assumption here being that, just like when resampling PlayRes, the user only uses the resample dialog when the script *currently* looks correct on the current video.) - agi::ycbcr::Header video_mat_header(video_mat); - if (video_mat_header != script_mat - && video_mat_header.valid() //< (Not necessary in theory, see FIXME above) - && script_mat.valid() //< (Should always be true since we use to_effective() above) - && std::holds_alternative(video_mat_header) - && std::holds_alternative(script_mat) - ) { + if (settings.matrix_conversion) { source_matrix->SetSelection(MatrixOptionFromHeader(script_mat)); - dest_matrix->SetSelection(MatrixOptionFromHeader(video_mat_header)); + dest_matrix->SetSelection(MatrixOptionFromHeader(agi::ycbcr::Header(video_mat))); } OnMatrixChange(); } @@ -323,6 +306,45 @@ void DialogResample::OnMarginChange(wxSpinCtrl *src, wxSpinCtrl *dst) { } } +ResampleSettings DefaultResampleSettings(agi::Context *c) { + ResampleSettings settings; + c->ass->GetResolution(settings.source_x, settings.source_y); + + auto provider = c->project->VideoProvider(); + if (!provider) { + settings.dest_x = settings.source_x; + settings.dest_y = settings.source_y; + return settings; + } + + settings.dest_x = provider->GetWidth(); + settings.dest_y = provider->GetHeight(); + + // Find out if we want to suggest resampling colors. + // If the script matrix is None, we do not know what matrix to resample from, so we don't suggest to resample. + // If the video matrix is unspecified or does not correspond to a valid YCbCr Header value, we cannot currently resample to it. + // -> FIXME: The latter is not a theoretical reason but only due to the fact that resampling is not currently implemented for any other matrices in ycbcr_conv.h: + // We could in theory have a situation where a script with no YCbCr Header (so effectively TV.601) was authored on a BT.2020 video using color matching, + // and the user now wants to adjust the colors so that they can set the matrix to None instead. + // Otherwise, i.e. if e.g. the script has a TV.601 header but the video uses TV.709, we suggest to resample. + // (The assumption here being that, just like when resampling PlayRes, the user only uses the resample dialog when the script *currently* looks correct on the current video.) + agi::ycbcr::Header script_mat = agi::ycbcr::Header(std::string(c->ass->GetScriptInfo("YCbCr Matrix"))).to_effective(); + agi::ycbcr::Header video_mat(provider->GetRealColorSpace()); + if (video_mat != script_mat + && video_mat.valid() //< (Not necessary in theory, see FIXME above) + && script_mat.valid() //< (Should always be true since we use to_effective() above) + && std::holds_alternative(video_mat) + && std::holds_alternative(script_mat) + // Only matrices the dialog offers can be resampled + && MatrixOptionFromHeader(video_mat) != 0 + && MatrixOptionFromHeader(script_mat) != 0 + ) { + settings.matrix_conversion = std::make_pair(std::get(script_mat), std::get(video_mat)); + } + + return settings; +} + bool PromptForResampleSettings(agi::Context *c, ResampleSettings &settings) { return DialogResample(c, settings).d.ShowModal() == wxID_OK; } diff --git a/src/dialogs.h b/src/dialogs.h index 019f8f0264..b8c9221841 100644 --- a/src/dialogs.h +++ b/src/dialogs.h @@ -48,6 +48,10 @@ std::string PickAutosaveFile(wxWindow *parent); /// new version actually exists. void PerformVersionCheck(bool interactive); +/// The resampling settings suggested by default: from the script's resolution +/// to the video's (if any), converting colors if the matrices differ +ResampleSettings DefaultResampleSettings(agi::Context *c); + /// Ask the user to pick settings for a script resampling /// @return Does the user want to proceed with the resampling? bool PromptForResampleSettings(agi::Context *c, ResampleSettings &settings); diff --git a/src/main.cpp b/src/main.cpp index 14b228d720..3083058a0c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -422,7 +422,8 @@ int main(int argc, char *argv[]) { if (!video.empty()) { LOG_D("main") << "Loading video..."; - context.project->LoadVideo(agi::fs::path(std::filesystem::absolute(video))); + // Dummy video "filenames" aren't paths, so leave them alone + context.project->LoadVideo(video.starts_with("?dummy") ? agi::fs::path(video) : agi::fs::path(std::filesystem::absolute(video))); } if (!timecodes.empty()) { From cd276d9179090687946b743d8e5cde3c20426c58 Mon Sep 17 00:00:00 2001 From: Ryan Lucia Date: Thu, 8 Oct 2026 13:45:53 -0700 Subject: [PATCH 26/26] Don't initialize the desktop portal in CLI mode The portal is only used by GUI dialogs, and xdp_portal_new() aborts the process when there's no D-Bus session, which made every CLI run abort on a headless Linux machine. Co-Authored-By: Claude Opus 5.5 --- src/main.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index 3083058a0c..d0c4401726 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -266,7 +266,10 @@ bool AegisubInitialize(AegisubApp *app, std::function());