diff --git a/.gitignore b/.gitignore index f1366d097..c9cdd97cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /tests/data +automation/vapoursynth/__pycache__ packages/desktop/aegisub.desktop packages/desktop/aegisub.desktop.template src/libresrc/bitmap.cpp diff --git a/automation/meson.build b/automation/meson.build index cfa428d57..dc3319658 100644 --- a/automation/meson.build +++ b/automation/meson.build @@ -41,3 +41,7 @@ install_data( 'include/aegisub/unicode.moon', 'include/aegisub/util.moon', install_dir: automation_dir / 'include' / 'aegisub') + +install_data( + 'vapoursynth/aegisub_vs.py', + install_dir: automation_dir / 'vapoursynth') diff --git a/automation/vapoursynth/aegisub_vs.py b/automation/vapoursynth/aegisub_vs.py new file mode 100644 index 000000000..c3cc5876c --- /dev/null +++ b/automation/vapoursynth/aegisub_vs.py @@ -0,0 +1,208 @@ +""" +Utility functions for loading video files into Aegisub using the Vapoursynth +video provider. + +When encountering a file whose file extension is not .py or .vpy, the +Vapoursynth audio and video providers will execute the respective default +script set in Aegisub's configuration, with the following string variables set: +- filename: The path to the file that's being opened. +- __aegi_data, __aegi_dictionary, __aegi_local, __aegi_script, __aegi_temp, __aegi_user: + The values of ?data, ?dictionary, etc. respectively. +- __aegi_vscache: The path to a directory where the Vapoursynth script can + store cache files. This directory is cleaned by Aegisub when it gets too + large (as defined by Aegisub's configuration). + +The provider reads the video from the script's 0-th output node. By default, +the video is assumed to be CFR. The script can pass further information to +Aegisub using the following variables: + - __aegi_timecodes: List[int] | str: The timecodes for the video, or the + path to a timecodes file. + - __aegi_keyframes: List[int] | str: List of frame numbers to load as + keyframes, or the path to a keyframes file. + - __aegi_hasaudio: int: If nonzero, Aegisub will try to load an audio track + from the same file. + +This module provides some utility functions to obtain timecodes, keyframes, and +other data. +""" +import os +import os.path +import re +from collections import deque +from typing import Any, Dict, List, Tuple + +import vapoursynth as vs +core = vs.core + +def make_lwi_cache_filename(filename: str) -> str: + """ + Given a path to a video, will return a file name like the one LWLibavSource + would use for a .lwi file. + """ + max_len = 254 + extension = ".lwi" + + if len(filename) + len(extension) > max_len: + filename = filename[-(max_len + len(extension)):] + + return "".join(("_" if c in "/\\:" else c) for c in filename) + extension + + +def make_keyframes_filename(filename: str) -> str: + """ + Given a path `path/to/file.mkv`, will return the path + `path/to/file_keyframes.txt`. + """ + extlen = filename[::-1].find(".") + 1 + return filename[:len(filename) - extlen] + "_keyframes.txt" + + +lwindex_re1 = re.compile(r"Index=(?P-?[0-9]+),POS=(?P-?[0-9]+),PTS=(?P-?[0-9]+),DTS=(?P-?[0-9]+),EDI=(?P-?[0-9]+)") +lwindex_re2 = re.compile(r"Key=(?P-?[0-9]+),Pic=(?P-?[0-9]+),POC=(?P-?[0-9]+),Repeat=(?P-?[0-9]+),Field=(?P-?[0-9]+)") +streaminfo_re = re.compile(r"Codec=(?P[0-9]+),TimeBase=(?P[0-9\/]+),Width=(?P[0-9]+),Height=(?P[0-9]+),Format=(?P[0-9a-zA-Z]+),ColorSpace=(?P[0-9]+)") + +class LWIndexFrame: + pts: int + key: int + + def __init__(self, raw: list[str]): + match1 = lwindex_re1.match(raw[0]) + match2 = lwindex_re2.match(raw[1]) + if not match1 or not match2: + raise ValueError("Invalid lwindex format") + self.pts = int(match1.group("PTS")) + self.key = int(match2.group("Key")) + + def __int__(self) -> int: + return self.pts + + +def info_from_lwindex(indexfile: str) -> Dict[str, List[int]]: + """ + Given a path to an .lwi file, will return a dictionary containing + information about the video, with the keys + - timcodes: The timecodes. + - keyframes: Array of frame numbers of keyframes. + """ + with open(indexfile, encoding="latin1") as f: + index = f.read().splitlines() + + indexstart, indexend = index.index("") + 1, index.index("") + frames = [LWIndexFrame(index[i:i+2]) for i in range(indexstart, indexend, 2)] + frames.sort(key=int) + + streaminfo = streaminfo_re.match(index[indexstart - 2]) + if not streaminfo: + raise ValueError("Invalid lwindex format") + + timebase_num, timebase_den = [int(i) for i in streaminfo.group("TimeBase").split("/")] + + return { + "timecodes": [(f.pts * 1000 * timebase_num) // timebase_den for f in frames], + "keyframes": [i for i, f in enumerate(frames) if f.key], + } + + +def wrap_lwlibavsource(filename: str, cachedir: str, **kwargs: Any) -> Tuple[vs.VideoNode, Dict[str, List[int]]]: + """ + Given a path to a video file and a directory to store index files in + (usually __aegi_vscache), will open the video with LWLibavSource and read + the generated .lwi file to obtain the timecodes and keyframes. + Additional keyword arguments are passed on to LWLibavSource. + """ + try: + os.mkdir(cachedir) + except FileExistsError: + pass + cachefile = os.path.join(cachedir, make_lwi_cache_filename(filename)) + + if not hasattr(core, "lsmas"): + raise vs.Error("To use Aegisub's LWLibavSource wrapper, the `lsmas` plugin for VapourSynth must be installed") + + if b"-Dcachedir" not in core.lsmas.Version()["config"]: # type: ignore + raise vs.Error("To use Aegisub's LWLibavSource wrapper, the `lsmas` plugin must support the `cachedir` option for LWLibavSource.") + + clip = core.lsmas.LWLibavSource(source=filename, cachefile=cachefile, **kwargs) + + return clip, info_from_lwindex(cachefile) + + +def make_keyframes(clip: vs.VideoNode, use_scxvid: bool = False, + resize_h: int = 360, resize_format: int = vs.YUV420P8, + **kwargs: Any) -> List[int]: + """ + Generates a list of keyframes from a clip, using either WWXD or Scxvid. + Will be slightly more efficient with the `akarin` plugin installed. + + :param clip: Clip to process. + :param use_scxvid: Whether to use Scxvid. If False, the function uses WWXD. + :param resize_h: Height to resize the clip to before processing. + :param resize_format: Format to convert the clip to before processing. + + The remaining keyword arguments are passed on to the respective filter. + """ + + clip = core.resize.Bilinear(clip, width=resize_h * clip.width // clip.height, height=resize_h, format=resize_format); + try: + clip = core.scxvid.Scxvid(clip, **kwargs) if use_scxvid else core.wwxd.WWXD(clip, **kwargs) + except AttributeError: + raise vs.Error("To use the keyframe generation, the `{}` plugin for VapourSynth must be installed" + .format("scxvid" if use_scxvid else "wwxd")) + + keyframes = {} + done = 0 + def _cb(n: int, f: vs.VideoFrame) -> vs.VideoFrame: + nonlocal done + keyframes[n] = f.props._SceneChangePrev if use_scxvid else f.props.Scenechange # type: ignore + done += 1 + if done % (clip.num_frames // 25) == 0: + vs.core.log_message(vs.MESSAGE_TYPE_INFORMATION, "Detecting keyframes... {}% done.\n".format(100 * done // clip.num_frames)) + return f + + deque(clip.std.ModifyFrame(clip, _cb).frames(close=True), 0) + vs.core.log_message(vs.MESSAGE_TYPE_INFORMATION, "Done detecting keyframes.\n") + return [n for n in range(clip.num_frames) if keyframes[n]] + + +def save_keyframes(filename: str, keyframes: List[int]): + """ + Saves a list of keyframes in Aegisub's keyframe format v1 to a file with + the given filename. + """ + with open(filename, "w") as f: + f.write("# keyframe format v1\n") + f.write("fps 0\n") + f.write("".join(f"{n}\n" for n in keyframes)) + + +def get_keyframes(filename: str, clip: vs.VideoNode, **kwargs: Any) -> str: + """ + When not already present, creates a keyframe file for the given clip next + to the given filename using WWXD or Scxvid (see the make_keyframes docstring). + Additional keyword arguments are passed on to make_keyframes. + """ + kffilename = make_keyframes_filename(filename) + + if not os.path.exists(kffilename): + vs.core.log_message(vs.MESSAGE_TYPE_INFORMATION, "No keyframes file found, detecting keyframes...\n") + keyframes = make_keyframes(clip, **kwargs) + save_keyframes(kffilename, keyframes) + + return kffilename + + +def check_audio(filename: str, **kwargs: Any) -> bool: + """ + Checks whether the given file has an audio track by trying to open it with + BestAudioSource. Requires the `bas` plugin to return correct results, but + won't crash if it's not installed. + Additional keyword arguments are passed on to BestAudioSource. + """ + try: + vs.core.bas.Source(source=filename, **kwargs) + return True + except AttributeError: + pass + except vs.Error: + pass + return False diff --git a/libaegisub/include/libaegisub/background_runner.h b/libaegisub/include/libaegisub/background_runner.h index 29bd8efcb..d33f9d447 100644 --- a/libaegisub/include/libaegisub/background_runner.h +++ b/libaegisub/include/libaegisub/background_runner.h @@ -46,10 +46,15 @@ namespace agi { /// @brief Log a message /// - /// If any messages are logged then the dialog will not automatically close - /// when the task finishes so that the user has the chance to read them. + /// If any messages are logged and StayOpen is set (set by default) + /// then the dialog will not automatically close when the task finishes + /// so that the user has the chance to read them. virtual void Log(std::string const& str)=0; + /// Set whether the dialog should stay open after the task finishes. + /// Defaults to true. + virtual void SetStayOpen(bool stayopen)=0; + /// Has the user asked the task to cancel? virtual bool IsCancelled()=0; }; diff --git a/packages/win_installer/fragment_automation.iss b/packages/win_installer/fragment_automation.iss index 6843a3388..fc52a96a9 100644 --- a/packages/win_installer/fragment_automation.iss +++ b/packages/win_installer/fragment_automation.iss @@ -30,6 +30,8 @@ DestDir: {app}\automation\include; Source: {#SOURCE_ROOT}\automation\include\uni DestDir: {app}\automation\include; Source: {#SOURCE_ROOT}\automation\include\utils.lua; Flags: ignoreversion overwritereadonly uninsremovereadonly; Attribs: readonly; Components: main DestDir: {app}\automation\include; Source: {#SOURCE_ROOT}\automation\include\utils-auto4.lua; Flags: ignoreversion overwritereadonly uninsremovereadonly; Attribs: readonly; Components: main +DestDir: {app}\automation\vapoursynth; Source: {#SOURCE_ROOT}\automation\vapoursynth\aegisub_vs.py; Flags: ignoreversion overwritereadonly uninsremovereadonly; Attribs: readonly; Components: main + #ifdef DEPCTRL ; DepCtrl DestDir: {userappdata}\Aegisub\automation\include\l0; Source: {#DEPS_DIR}\DependencyControl\modules\*; Flags: ignoreversion recursesubdirs createallsubdirs; Components: macros\modules\depctrl diff --git a/src/audio_provider_vs.cpp b/src/audio_provider_vs.cpp index 55151e896..1f42803be 100644 --- a/src/audio_provider_vs.cpp +++ b/src/audio_provider_vs.cpp @@ -165,7 +165,6 @@ VapoursynthAudioProvider::~VapoursynthAudioProvider() { } std::unique_ptr CreateVapoursynthAudioProvider(agi::fs::path const& file, agi::BackgroundRunner *) { - agi::acs::CheckFileRead(file); return agi::make_unique(file); } #endif diff --git a/src/auto4_base.h b/src/auto4_base.h index 5f645ccd0..dae5f2fdc 100644 --- a/src/auto4_base.h +++ b/src/auto4_base.h @@ -127,6 +127,7 @@ namespace Automation4 { void SetMessage(std::string const& msg) override { impl->SetMessage(msg); } void SetProgress(int64_t cur, int64_t max) override { impl->SetProgress(cur, max); } void Log(std::string const& str) override { impl->Log(str); } + void SetStayOpen(bool stayopen) override { impl->SetStayOpen(stayopen); } bool IsCancelled() override { return impl->IsCancelled(); } /// Show the passed dialog on the GUI thread, blocking the calling diff --git a/src/dialog_progress.cpp b/src/dialog_progress.cpp index bcf4de96d..9970dd0e6 100644 --- a/src/dialog_progress.cpp +++ b/src/dialog_progress.cpp @@ -73,6 +73,7 @@ namespace { class DialogProgressSink final : public agi::ProgressSink { DialogProgress *dialog; std::atomic cancelled{false}; + std::atomic stayopen{true}; int progress = 0; public: @@ -98,6 +99,14 @@ public: Main().Async([=]{ dialog->pending_log += to_wx(str); }); } + void SetStayOpen(bool b) override { + stayopen = b; + } + + bool GetStayOpen() { + return stayopen; + } + bool IsCancelled() override { return cancelled; } @@ -169,7 +178,7 @@ void DialogProgress::Run(std::function task) { // so the user can read the debug output and switch the cancel button to a // close button bool cancelled = this->ps->IsCancelled(); - if (cancelled || (log_output->IsEmpty() && !pending_log)) + if (cancelled || !this->ps->GetStayOpen() || (log_output->IsEmpty() && !pending_log)) EndModal(!cancelled); else { if (!pending_log.empty()) { diff --git a/src/libresrc/default_config.json b/src/libresrc/default_config.json index ceb273735..e6c8d088d 100644 --- a/src/libresrc/default_config.json +++ b/src/libresrc/default_config.json @@ -351,7 +351,7 @@ "Aegisub Cache" : true }, "VapourSynth" : { - "Default Script" : "import vapoursynth as vs\nvs.core.bas.Source(source=filename).set_output()" + "Default Script" : "# This default script will load an audio file using BestAudioSource.\n# It requires the `bas` plugin.\n\nimport vapoursynth as vs\ntry:\n vs.core.bas.Source(source=filename).set_output()\nexcept AttributeError:\n raise vs.Error(\"To use Aegisub's default audio loader, the `bas` plugin for VapourSynth must be installed\")" } }, "Avisynth" : { @@ -391,7 +391,8 @@ "Seek Preroll" : 12 }, "VapourSynth" : { - "Default Script" : "import vapoursynth as vs\nvs.core.lsmas.LWLibavSource(source=filename).set_output()" + "Log Level": "Information", + "Default Script" : "# This default script will load a video file using LWLibavSource.\n# It requires the `lsmas` plugin.\n# See ?data/automation/vapoursynth/aegisub_vs.py for more information.\n\nimport aegisub_vs as a\nimport vapoursynth as vs\n\nclip, videoinfo = a.wrap_lwlibavsource(filename, __aegi_vscache)\nclip.set_output()\n__aegi_timecodes = videoinfo[\"timecodes\"]\n__aegi_keyframes = videoinfo[\"keyframes\"]\n# Uncomment this to automatically generate keyframes at scene changes.\n#__aegi_keyframes = a.get_keyframes(filename, clip)\n\n# Check if the file has an audio track. This requires the `bas` plugin.\n__aegi_hasaudio = 1 if a.check_audio(filename) else 0" } } }, diff --git a/src/libresrc/osx/default_config.json b/src/libresrc/osx/default_config.json index e03313dbc..38e58a404 100644 --- a/src/libresrc/osx/default_config.json +++ b/src/libresrc/osx/default_config.json @@ -351,7 +351,7 @@ "Aegisub Cache" : true }, "VapourSynth" : { - "Default Script" : "import vapoursynth as vs\nvs.core.bas.Source(source=filename).set_output()" + "Default Script" : "# This default script will load an audio file using BestAudioSource.\n# It requires the `bas` plugin.\n\nimport vapoursynth as vs\ntry:\n vs.core.bas.Source(source=filename).set_output()\nexcept AttributeError:\n raise vs.Error(\"To use Aegisub's default audio loader, the `bas` plugin for VapourSynth must be installed\")" } }, "Avisynth" : { @@ -391,7 +391,8 @@ "Seek Preroll" : 12 }, "VapourSynth" : { - "Default Script" : "import vapoursynth as vs\nvs.core.lsmas.LWLibavSource(source=filename).set_output()" + "Log Level": "Information", + "Default Script" : "# This default script will load a video file using LWLibavSource.\n# It requires the `lsmas` plugin.\n# See ?data/automation/vapoursynth/aegisub_vs.py for more information.\n\nimport aegisub_vs as a\nimport vapoursynth as vs\n\nclip, videoinfo = a.wrap_lwlibavsource(filename, __aegi_vscache)\nclip.set_output()\n__aegi_timecodes = videoinfo[\"timecodes\"]\n__aegi_keyframes = videoinfo[\"keyframes\"]\n# Uncomment this to automatically generate keyframes at scene changes.\n#__aegi_keyframes = a.get_keyframes(filename, clip)\n\n# Check if the file has an audio track. This requires the `bas` plugin.\n__aegi_hasaudio = 1 if a.check_audio(filename) else 0" } } }, diff --git a/src/preferences.cpp b/src/preferences.cpp index bc130ec63..efc3285ff 100644 --- a/src/preferences.cpp +++ b/src/preferences.cpp @@ -490,6 +490,12 @@ void Advanced_Video(wxTreebook *book, Preferences *parent) { void VapourSynth(wxTreebook *book, Preferences *parent) { #ifdef WITH_VAPOURSYNTH auto p = new OptionPage(book, parent, _("VapourSynth"), OptionPage::PAGE_SUB); + auto general = p->PageSizer(_("General")); + + const wxString log_levels[] = { "Quiet", "Fatal", "Critical", "Warning", "Information", "Debug" }; + wxArrayString log_levels_choice(6, log_levels); + p->OptionChoice(general, _("Log Level"), log_levels_choice, "Provider/Video/VapourSynth/Log Level"); + auto video = p->PageSizer(_("Default Video Script")); auto vhint = new wxStaticText(p, wxID_ANY, _("This script will be executed to load video files that aren't\nVapourSynth scripts (i.e. end in .py or .vpy).\nThe filename variable stores the path to the file.")); diff --git a/src/vapoursynth_common.cpp b/src/vapoursynth_common.cpp index 9a0e3ebe3..b360c6144 100644 --- a/src/vapoursynth_common.cpp +++ b/src/vapoursynth_common.cpp @@ -20,6 +20,7 @@ #include "vapoursynth_wrap.h" #include "options.h" #include "utils.h" +#include #include #include @@ -60,6 +61,28 @@ int OpenScriptOrVideo(const VSAPI *api, const VSSCRIPTAPI *sapi, VSScript *scrip return result; } +void VSLogToProgressSink(int msgType, const char *msg, void *userData) { + int loglevel = 0; + std::string loglevel_str = OPT_GET("Provider/Video/VapourSynth/Log Level")->GetString(); + if (loglevel_str == "Quiet") + loglevel = 5; + else if (loglevel_str == "Fatal") + loglevel = 4; + else if (loglevel_str == "Critical") + loglevel = 3; + else if (loglevel_str == "Warning") + loglevel = 2; + else if (loglevel_str == "Information") + loglevel = 1; + else if (loglevel_str == "Debug") + loglevel = 0; + + if (msgType < loglevel) + return; + + reinterpret_cast(userData)->Log(msg); +} + void VSCleanCache() { CleanCache(config::path->Decode("?local/vscache/"), "", diff --git a/src/vapoursynth_common.h b/src/vapoursynth_common.h index af35da3aa..634c3e94f 100644 --- a/src/vapoursynth_common.h +++ b/src/vapoursynth_common.h @@ -21,5 +21,6 @@ int OpenScriptOrVideo(const VSAPI *api, const VSSCRIPTAPI *sapi, VSScript *script, agi::fs::path const& filename, std::string default_script); void VSCleanCache(); +void VSLogToProgressSink(int msgType, const char *msg, void *userData); #endif // WITH_VAPOURSYNTH diff --git a/src/video_provider_vs.cpp b/src/video_provider_vs.cpp index 7650a5045..b08dd65a6 100644 --- a/src/video_provider_vs.cpp +++ b/src/video_provider_vs.cpp @@ -17,13 +17,17 @@ #ifdef WITH_VAPOURSYNTH #include "include/aegisub/video_provider.h" +#include "compat.h" #include "options.h" #include "video_frame.h" #include +#include #include -#include +#include +#include #include +#include #include @@ -33,6 +37,10 @@ #include "VSHelper4.h" #include "VSConstants4.h" +static const char *kf_key = "__aegi_keyframes"; +static const char *tc_key = "__aegi_timecodes"; +static const char *audio_key = "__aegi_hasaudio"; + namespace { class VapoursynthVideoProvider: public VideoProvider { VapourSynthWrapper vs; @@ -45,12 +53,13 @@ class VapoursynthVideoProvider: public VideoProvider { std::vector keyframes; std::string colorspace; std::string real_colorspace; + bool has_audio = false; const VSFrame *GetVSFrame(int n); - void SetResizeArg(VSMap *args, const VSMap *props, const char *arg_name, const char *prop_name, int deflt, int unspecified = -1); + void SetResizeArg(VSMap *args, const VSMap *props, const char *arg_name, const char *prop_name, int64_t deflt, int64_t unspecified = -1); public: - VapoursynthVideoProvider(agi::fs::path const& filename, std::string const& colormatrix); + VapoursynthVideoProvider(agi::fs::path const& filename, std::string const& colormatrix, agi::BackgroundRunner *br); ~VapoursynthVideoProvider(); void GetFrame(int n, VideoFrame &frame) override; @@ -65,7 +74,7 @@ public: std::vector GetKeyFrames() const override { return keyframes; } std::string GetColorSpace() const override { return GetRealColorSpace(); } std::string GetRealColorSpace() const override { return colorspace == "Unknown" ? "None" : colorspace; } - bool HasAudio() const override { return false; } + bool HasAudio() const override { return has_audio; } bool WantsCaching() const override { return true; } std::string GetDecoderName() const override { return "VapourSynth"; } bool ShouldSetVideoProperties() const override { return colorspace != "Unknown"; } @@ -96,9 +105,9 @@ std::string colormatrix_description(int colorFamily, int colorRange, int matrix) } // Adds an argument to the rescaler if the corresponding frameprop does not exist or is set as unspecified -void VapoursynthVideoProvider::SetResizeArg(VSMap *args, const VSMap *props, const char *arg_name, const char *prop_name, int deflt, int unspecified) { +void VapoursynthVideoProvider::SetResizeArg(VSMap *args, const VSMap *props, const char *arg_name, const char *prop_name, int64_t deflt, int64_t unspecified) { int err; - int result = vs.GetAPI()->mapGetInt(props, prop_name, 0, &err); + int64_t result = vs.GetAPI()->mapGetInt(props, prop_name, 0, &err); if (err != 0 || result == unspecified) { result = deflt; if (!strcmp(arg_name, "range_in")) { @@ -108,41 +117,52 @@ void VapoursynthVideoProvider::SetResizeArg(VSMap *args, const VSMap *props, con } } -VapoursynthVideoProvider::VapoursynthVideoProvider(agi::fs::path const& filename, std::string const& colormatrix) try { +VapoursynthVideoProvider::VapoursynthVideoProvider(agi::fs::path const& filename, std::string const& colormatrix, agi::BackgroundRunner *br) try { try { std::lock_guard lock(vs.GetMutex()); VSCleanCache(); - script = vs.GetScriptAPI()->createScript(nullptr); + int err1, err2; + VSCore *core = vs.GetAPI()->createCore(0); + if (core == nullptr) { + throw VapoursynthError("Error creating core"); + } + script = vs.GetScriptAPI()->createScript(core); if (script == nullptr) { + vs.GetAPI()->freeCore(core); throw VapoursynthError("Error creating script API"); } vs.GetScriptAPI()->evalSetWorkingDir(script, 1); - if (OpenScriptOrVideo(vs.GetAPI(), vs.GetScriptAPI(), script, filename, OPT_GET("Provider/Video/VapourSynth/Default Script")->GetString())) { + br->Run([&](agi::ProgressSink *ps) { + ps->SetTitle(from_wx(_("Executing Vapoursynth Script"))); + ps->SetMessage(""); + ps->SetIndeterminate(); + + VSLogHandle *logger = vs.GetAPI()->addLogHandler(VSLogToProgressSink, nullptr, ps, core); + err1 = OpenScriptOrVideo(vs.GetAPI(), vs.GetScriptAPI(), script, filename, OPT_GET("Provider/Video/VapourSynth/Default Script")->GetString()); + vs.GetAPI()->removeLogHandler(logger, core); + + ps->SetStayOpen(bool(err1)); + if (err1) + ps->SetMessage(from_wx(_("Failed to execute script! Press \"Close\" to continue."))); + }); + if (err1) { std::string msg = agi::format("Error executing VapourSynth script: %s", vs.GetScriptAPI()->getError(script)); - vs.GetScriptAPI()->freeScript(script); throw VapoursynthError(msg); } node = vs.GetScriptAPI()->getOutputNode(script, 0); - if (node == nullptr) { - vs.GetScriptAPI()->freeScript(script); + if (node == nullptr) throw VapoursynthError("No output node set"); - } + if (vs.GetAPI()->getNodeType(node) != mtVideo) { - vs.GetAPI()->freeNode(node); - vs.GetScriptAPI()->freeScript(script); throw VapoursynthError("Output node isn't a video node"); } vi = vs.GetAPI()->getVideoInfo(node); - if (!vsh::isConstantVideoFormat(vi)) { - vs.GetAPI()->freeNode(node); - vs.GetScriptAPI()->freeScript(script); + if (vi == nullptr) + throw VapoursynthError("Couldn't get video info"); + if (!vsh::isConstantVideoFormat(vi)) throw VapoursynthError("Video doesn't have constant format"); - } - // Assume constant frame rate, since handling VFR would require going through all frames when loading. - // Users can load custom timecodes files to deal with VFR. - // Alternatively (TODO) the provider could read timecodes and keyframes from a second output node. int fpsNum = vi->fpsNum; int fpsDen = vi->fpsDen; if (fpsDen == 0) { @@ -151,25 +171,89 @@ VapoursynthVideoProvider::VapoursynthVideoProvider(agi::fs::path const& filename } fps = agi::vfr::Framerate(fpsNum, fpsDen); - // Find the first frame to get some info - const VSFrame *frame; - try { - frame = GetVSFrame(0); - } catch (VapoursynthError const& err) { - vs.GetAPI()->freeNode(node); - vs.GetScriptAPI()->freeScript(script); - throw err; - } - int err1, err2; - const VSMap *props = vs.GetAPI()->getFramePropertiesRO(frame); - int sarn = vs.GetAPI()->mapGetInt(props, "_SARNum", 0, &err1); - int sard = vs.GetAPI()->mapGetInt(props, "_SARDen", 0, &err2); - if (!err1 && !err2) { - dar = ((double) vi->width * sarn) / (vi->height * sard); + // Get timecodes and/or keyframes if provided + VSMap *clipinfo = vs.GetAPI()->createMap(); + if (clipinfo == nullptr) + throw VapoursynthError("Couldn't create map"); + vs.GetScriptAPI()->getVariable(script, kf_key, clipinfo); + vs.GetScriptAPI()->getVariable(script, tc_key, clipinfo); + vs.GetScriptAPI()->getVariable(script, audio_key, clipinfo); + + int numkf = vs.GetAPI()->mapNumElements(clipinfo, kf_key); + int numtc = vs.GetAPI()->mapNumElements(clipinfo, tc_key); + + int64_t audio = vs.GetAPI()->mapGetInt(clipinfo, audio_key, 0, &err1); + if (!err1) + has_audio = bool(audio); + + if (numkf > 0) { + const int64_t *kfs = vs.GetAPI()->mapGetIntArray(clipinfo, kf_key, &err1); + const char *kfs_path = vs.GetAPI()->mapGetData(clipinfo, kf_key, 0, &err2); + if (err1 && err2) + throw VapoursynthError("Error getting keyframes from returned VSMap"); + + if (!err1) { + keyframes.reserve(numkf); + for (int i = 0; i < numkf; i++) + keyframes.push_back(int(kfs[i])); + } else { + int kfs_path_size = vs.GetAPI()->mapGetDataSize(clipinfo, kf_key, 0, &err1); + if (err1) + throw VapoursynthError("Error getting size of keyframes path"); + + try { + keyframes = agi::keyframe::Load(config::path->Decode(std::string(kfs_path, size_t(kfs_path_size)))); + } catch (agi::Exception const& e) { + LOG_E("vapoursynth/video/keyframes") << "Failed to open keyframes file specified by script: " << e.GetMessage(); + } + } } - int range = vs.GetAPI()->mapGetInt(props, "_ColorRange", 0, &err1); - int matrix = vs.GetAPI()->mapGetInt(props, "_Matrix", 0, &err2); + if (numtc != -1) { + const int64_t *tcs = vs.GetAPI()->mapGetIntArray(clipinfo, tc_key, &err1); + const char *tcs_path = vs.GetAPI()->mapGetData(clipinfo, tc_key, 0, &err2); + if (err1 && err2) + throw VapoursynthError("Error getting timecodes from returned map"); + + if (!err1) { + if (numtc != vi->numFrames) + throw VapoursynthError("Number of returned timecodes does not match number of frames"); + + std::vector timecodes; + timecodes.reserve(numtc); + for (int i = 0; i < numtc; i++) + timecodes.push_back(int(tcs[i])); + + fps = agi::vfr::Framerate(timecodes); + } else { + int tcs_path_size = vs.GetAPI()->mapGetDataSize(clipinfo, tc_key, 0, &err1); + if (err1) + throw VapoursynthError("Error getting size of keyframes path"); + + try { + fps = agi::vfr::Framerate(config::path->Decode(std::string(tcs_path, size_t(tcs_path_size)))); + } catch (agi::Exception const& e) { + // Throw an error here unlike with keyframes since the timecodes not being loaded might not be immediately noticeable + throw VapoursynthError("Failed to open timecodes file specified by script: " + e.GetMessage()); + } + } + } + vs.GetAPI()->freeMap(clipinfo); + + // Find the first frame Of the video to get some info + const VSFrame *frame = GetVSFrame(0); + + const VSMap *props = vs.GetAPI()->getFramePropertiesRO(frame); + if (props == nullptr) + throw VapoursynthError("Couldn't get frame properties"); + int64_t sarn = vs.GetAPI()->mapGetInt(props, "_SARNum", 0, &err1); + int64_t sard = vs.GetAPI()->mapGetInt(props, "_SARDen", 0, &err2); + if (!err1 && !err2) { + dar = double(vi->width * sarn) / (vi->height * sard); + } + + int64_t range = vs.GetAPI()->mapGetInt(props, "_ColorRange", 0, &err1); + int64_t matrix = vs.GetAPI()->mapGetInt(props, "_Matrix", 0, &err2); colorspace = colormatrix_description(vi->format.colorFamily, err1 == 0 ? range : -1, err2 == 0 ? matrix : -1); vs.GetAPI()->freeFrame(frame); @@ -177,13 +261,12 @@ VapoursynthVideoProvider::VapoursynthVideoProvider(agi::fs::path const& filename if (vi->format.colorFamily != cfRGB || vi->format.bitsPerSample != 8) { // Convert to RGB24 format VSPlugin *resize = vs.GetAPI()->getPluginByID(VSH_RESIZE_PLUGIN_ID, vs.GetScriptAPI()->getCore(script)); - if (resize == nullptr) { + if (resize == nullptr) throw VapoursynthError("Couldn't find resize plugin"); - } + VSMap *args = vs.GetAPI()->createMap(); - if (args == nullptr) { + if (args == nullptr) throw VapoursynthError("Failed to create argument map"); - } vs.GetAPI()->mapSetNode(args, "clip", node, maAppend); vs.GetAPI()->mapSetInt(args, "format", pfRGB24, maAppend); @@ -213,18 +296,18 @@ VapoursynthVideoProvider::VapoursynthVideoProvider(agi::fs::path const& filename } // Finally, try to get the first frame again, so if the filter does crash, it happens before loading finishes - const VSFrame *rgbframe; - try { - rgbframe = GetVSFrame(0); - } catch (VapoursynthError const& err) { - vs.GetAPI()->freeNode(node); - vs.GetScriptAPI()->freeScript(script); - throw err; - } + const VSFrame *rgbframe = GetVSFrame(0); vs.GetAPI()->freeFrame(rgbframe); } +} catch (VapoursynthError const& err) { // for try inside of function. We need both here since we need to catch errors from the VapoursynthWrap constructor. + if (node != nullptr) + vs.GetAPI()->freeNode(node); + if (script != nullptr) + vs.GetScriptAPI()->freeScript(script); + throw err; } -catch (VapoursynthError const& err) { +} +catch (VapoursynthError const& err) { // for the entire constructor throw VideoProviderError(agi::format("Vapoursynth error: %s", err.GetMessage())); } @@ -287,8 +370,7 @@ VapoursynthVideoProvider::~VapoursynthVideoProvider() { } namespace agi { class BackgroundRunner; } -std::unique_ptr CreateVapoursynthVideoProvider(agi::fs::path const& path, std::string const& colormatrix, agi::BackgroundRunner *) { - agi::acs::CheckFileRead(path); - return agi::make_unique(path, colormatrix); +std::unique_ptr CreateVapoursynthVideoProvider(agi::fs::path const& path, std::string const& colormatrix, agi::BackgroundRunner *br) { + return agi::make_unique(path, colormatrix, br); } #endif // WITH_VAPOURSYNTH