├── .gitignore ├── LICENSE ├── README.md ├── autosubsync.lua ├── helpers.lua ├── main.lua ├── menu.lua └── subtitle.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 joaquintorres 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # autosubsync-mpv 2 | 3 | Automatic subtitle synchronization script for [mpv](https://wiki.archlinux.org/index.php/Mpv). 4 | 5 | A demo can be viewed on 6 | 7 | Supported backends: 8 | * [ffsubsync](https://github.com/smacke/ffsubsync) 9 | * [alass](https://github.com/kaegi/alass) 10 | 11 | ## Installation 12 | 13 | 0. Make sure you have mpv v0.33 or higher installed. 14 | ``` 15 | $ mpv --version 16 | ``` 17 | 1. Install [FFmpeg](https://wiki.archlinux.org/index.php/FFmpeg): 18 | ``` 19 | $ pacman -S ffmpeg 20 | ``` 21 | Windows users have to manually install FFmpeg from [here](https://ffmpeg.zeranoe.com/builds/). 22 | 2. Install your retiming program of choice, 23 | [ffsubsync](https://github.com/smacke/ffsubsync), [alass](https://github.com/kaegi/alass) or both: 24 | ``` 25 | $ pip install ffsubsync 26 | ``` 27 | ``` 28 | $ trizen -S alass-git # for Arch-based distros 29 | ``` 30 | 31 | 3. Download the add-on and save it to your mpv scripts folder. 32 | 33 | | GNU/Linux | Windows | 34 | |---|---| 35 | | `~/.config/mpv/scripts` | `%AppData%\mpv\scripts\` | 36 | 37 | To do it in one command: 38 | 39 | ``` 40 | $ git clone 'https://github.com/Ajatt-Tools/autosubsync-mpv' ~/.config/mpv/scripts/autosubsync 41 | ``` 42 | 43 | ## Configuration 44 | 45 | You can skip this step if the add-on works out of the box. 46 | 47 | Create a config file: 48 | 49 | | GNU/Linux | Windows | 50 | |---|---| 51 | | `~/.config/mpv/script-opts/autosubsync.conf` | `%AppData%\mpv\script-opts\autosubsync.conf` | 52 | 53 | Example config: 54 | 55 | ``` 56 | # Absolute paths to the executables, if needed: 57 | 58 | # 1. ffmpeg 59 | ffmpeg_path=C:/Program Files/ffmpeg/bin/ffmpeg.exe 60 | ffmpeg_path=/usr/bin/ffmpeg 61 | 62 | # 2. ffsubsync 63 | ffsubsync_path=C:/Program Files/ffsubsync/ffsubsync.exe 64 | ffsubsync_path=/home/user/.local/bin/ffsubsync 65 | 66 | # 3. alass 67 | alass_path=C:/Program Files/ffmpeg/bin/alass.exe 68 | alass_path=/usr/bin/alass 69 | 70 | # Preferred retiming tool. Allowed options: 'ffsubsync', 'alass', 'ask'. 71 | # If set to 'ask', the add-on will ask to choose the tool every time: 72 | 73 | # 1. Preferred tool for syncing to audio. 74 | audio_subsync_tool=ask 75 | audio_subsync_tool=ffsubsync 76 | audio_subsync_tool=alass 77 | 78 | # 2. Preferred tool for syncing to another subtitle. 79 | altsub_subsync_tool=ask 80 | altsub_subsync_tool=ffsubsync 81 | altsub_subsync_tool=alass 82 | 83 | # Unload old subs (yes,no) 84 | # After retiming, tell mpv to forget the original subtitle track. 85 | unload_old_sub=yes 86 | unload_old_sub=no 87 | 88 | # Overwrite the original subtitle file. 89 | # Replace the old subtitle file with the retimed file. 90 | overwrite_old_sub=yes 91 | overwrite_old_sub=no 92 | ``` 93 | 94 | ## Notes 95 | 96 | * On Windows, you need to use forward slashes or double backslashes for your path. 97 | For example, `"C:\\Users\\YourPath\\Scripts\\ffsubsync"` 98 | or `"C:/Users/YourPath/Scripts/ffsubsync"`, 99 | or it might not work. 100 | 101 | * On GNU/Linux you can use `which ffsubsync` to find out where it is. 102 | 103 | ## Usage 104 | 105 | When you have an out of sync sub, press `n` to synchronize it. 106 | 107 | `ffsubsync` can typically take up to about 20-30 seconds 108 | to synchronize (I've seen it take as much as 2 minutes 109 | with a very large file on a lower end computer), so it 110 | would probably be faster to find another, properly 111 | synchronized subtitle with `autosub` or `trueautosub`. 112 | Many times this is just not possible, as all available 113 | subs for your specific language are out of sync. 114 | 115 | Take into account that using this script has the 116 | same limitations as `ffsubsync`, so subtitles that have 117 | a lot of extra text or are meant for an entirely different 118 | version of the video might not sync properly. `alass` is supposed 119 | to handle some edge cases better, but I haven't fully tested it yet, 120 | obtaining similar results with both. 121 | 122 | Note that the script will create a new subtitle file, in the same folder 123 | as the original, with the `_retimed` suffix at the end. 124 | 125 | ## Issues and feedback 126 | 127 | If you are having trouble getting it to work or you've found a bug, 128 | feel free to [join our community](https://tatsumoto-ren.github.io/blog/join-our-community.html) to ask directly. 129 | 130 | Try to check if 131 | [ffsubsync](https://github.com/smacke/ffsubsync) 132 | or 133 | [alass](https://github.com/kaegi/alass) 134 | works properly outside of `mpv` first. 135 | If the retiming tool of choice isn't working, `autosubsync` will likely fail. 136 | -------------------------------------------------------------------------------- /autosubsync.lua: -------------------------------------------------------------------------------- 1 | -- Usage: 2 | -- default keybinding: n 3 | -- add the following to your input.conf to change the default keybinding: 4 | -- keyname script_binding autosubsync-menu 5 | 6 | local mp = require('mp') 7 | local utils = require('mp.utils') 8 | local mpopt = require('mp.options') 9 | local menu = require('menu') 10 | local sub = require('subtitle') 11 | local h = require('helpers') 12 | local ref_selector 13 | local engine_selector 14 | local track_selector 15 | 16 | -- Config 17 | -- Options can be changed here or in a separate config file. 18 | -- Config path: ~/.config/mpv/script-opts/autosubsync.conf 19 | local config = { 20 | -- Change the following lines if the locations of executables differ from the defaults 21 | -- If set to empty, the path will be guessed. 22 | ffmpeg_path = "", 23 | ffsubsync_path = "", 24 | alass_path = "", 25 | 26 | -- Choose what tool to use. Allowed options: ffsubsync, alass, ask. 27 | -- If set to ask, the add-on will ask to choose the tool every time. 28 | audio_subsync_tool = "ask", 29 | altsub_subsync_tool = "ask", 30 | 31 | -- After retiming, tell mpv to forget the original subtitle track. 32 | unload_old_sub = true, 33 | 34 | -- Overwrite the original subtitle file 35 | overwrite_old_sub = false, 36 | } 37 | mpopt.read_options(config, 'autosubsync') 38 | 39 | -- Snippet borrowed from stackoverflow to get the operating system 40 | -- originally found at: https://stackoverflow.com/a/30960054 41 | local os_name = (function() 42 | if os.getenv("HOME") == nil then 43 | return function() 44 | return "Windows" 45 | end 46 | else 47 | return function() 48 | return "*nix" 49 | end 50 | end 51 | end)() 52 | 53 | local os_temp = (function() 54 | if os_name() == "Windows" then 55 | return function() 56 | return os.getenv('TEMP') 57 | end 58 | else 59 | return function() 60 | return '/tmp/' 61 | end 62 | end 63 | end)() 64 | 65 | local function notify(message, level, duration) 66 | level = level or 'info' 67 | duration = duration or 1 68 | mp.msg[level](message) 69 | mp.osd_message(message, duration) 70 | end 71 | 72 | local function subprocess(args) 73 | return mp.command_native { 74 | name = "subprocess", 75 | playback_only = false, 76 | capture_stdout = true, 77 | args = args 78 | } 79 | end 80 | 81 | local url_decode = function(url) 82 | local function hex_to_char(x) 83 | return string.char(tonumber(x, 16)) 84 | end 85 | if url ~= nil then 86 | url = url:gsub("^file://", "") 87 | url = url:gsub("+", " ") 88 | url = url:gsub("%%(%x%x)", hex_to_char) 89 | return url 90 | else 91 | return 92 | end 93 | end 94 | 95 | local function get_loaded_tracks(track_type) 96 | local result = {} 97 | local track_list = mp.get_property_native('track-list') 98 | for _, track in pairs(track_list) do 99 | if track.type == track_type then 100 | track['external-filename'] = track.external and url_decode(track['external-filename']) 101 | table.insert(result, track) 102 | end 103 | end 104 | return result 105 | end 106 | 107 | local function get_active_track(track_type) 108 | local track_list = mp.get_property_native('track-list') 109 | for num, track in ipairs(track_list) do 110 | if track.type == track_type and track.selected == true then 111 | if track.external and not h.file_exists(track['external-filename']) then 112 | track['external-filename'] = url_decode(track['external-filename']) 113 | end 114 | if not (track_type == 'sub' and track.id == mp.get_property_native('secondary-sid')) then 115 | return num, track 116 | end 117 | end 118 | end 119 | return notify(string.format("Error: no track of type '%s' selected", track_type), "error", 3) 120 | end 121 | 122 | local function remove_extension(filename) 123 | return filename:gsub('%.%w+$', '') 124 | end 125 | 126 | local function get_extension(filename) 127 | return filename:match("^.+(%.%w+)$") 128 | end 129 | 130 | local function startswith(str, prefix) 131 | return string.sub(str, 1, string.len(prefix)) == prefix 132 | end 133 | 134 | local function mkfp_retimed(sub_path) 135 | if config.overwrite_old_sub then 136 | return sub_path 137 | elseif not startswith(sub_path, os_temp()) then 138 | return table.concat { remove_extension(sub_path), '_retimed', get_extension(sub_path) } 139 | else 140 | return table.concat { remove_extension(mp.get_property("path")), '_retimed', get_extension(sub_path) } 141 | end 142 | end 143 | 144 | local function engine_is_set() 145 | local subsync_tool = ref_selector:get_subsync_tool() 146 | if h.is_empty(subsync_tool) or subsync_tool == "ask" then 147 | return false 148 | else 149 | return true 150 | end 151 | end 152 | 153 | local function extract_to_file(subtitle_track) 154 | local codec_ext_map = { subrip = "srt", ass = "ass" } 155 | local ext = codec_ext_map[subtitle_track['codec']] 156 | if ext == nil then 157 | return notify(string.format("Error: unsupported codec: %s", subtitle_track['codec']), "error", 3) 158 | end 159 | local temp_sub_fp = utils.join_path(os_temp(), 'autosubsync_extracted.' .. ext) 160 | notify("Extracting internal subtitles...", nil, 3) 161 | local ret = subprocess { 162 | config.ffmpeg_path, 163 | "-hide_banner", 164 | "-nostdin", 165 | "-y", 166 | "-loglevel", "quiet", 167 | "-an", 168 | "-vn", 169 | "-i", mp.get_property("path"), 170 | "-map", "0:" .. (subtitle_track and subtitle_track['ff-index'] or 's'), 171 | "-f", ext, 172 | temp_sub_fp 173 | } 174 | if ret == nil or ret.status ~= 0 or not h.file_exists(temp_sub_fp) then 175 | return notify("Couldn't extract internal subtitle.\nMake sure the video has internal subtitles.", "error", 7) 176 | end 177 | return temp_sub_fp 178 | end 179 | 180 | local function sync_subtitles(ref_sub_path) 181 | local reference_file_path = ref_sub_path or mp.get_property("path") 182 | local _, sub_track = get_active_track('sub') 183 | if sub_track == nil then 184 | return 185 | end 186 | local subtitle_path = sub_track.external and sub_track['external-filename'] or extract_to_file(sub_track) 187 | local engine_name = engine_selector:get_engine_name() 188 | local engine_path = config[engine_name .. '_path'] 189 | 190 | if h.is_path(config.ffmpeg_path) and not h.file_exists(engine_path) then 191 | return notify( 192 | string.format("Can't find %s executable.\nPlease specify the correct path in the config.", engine_name), 193 | "error", 194 | 5 195 | ) 196 | end 197 | 198 | if not h.file_exists(subtitle_path) then 199 | return notify( 200 | table.concat { 201 | "Subtitle synchronization failed:\nCouldn't find ", 202 | subtitle_path or "external subtitle file." 203 | }, 204 | "error", 205 | 3 206 | ) 207 | end 208 | 209 | local retimed_subtitle_path = mkfp_retimed(subtitle_path) 210 | 211 | notify(string.format("Starting %s...", engine_name), nil, 2) 212 | 213 | local ret 214 | if engine_name == "ffsubsync" then 215 | local args = { config.ffsubsync_path, reference_file_path, "-i", subtitle_path, "-o", retimed_subtitle_path } 216 | if not ref_sub_path then 217 | table.insert(args, '--reference-stream') 218 | table.insert(args, '0:' .. get_active_track('audio')) 219 | end 220 | ret = subprocess(args) 221 | else 222 | ret = subprocess { config.alass_path, reference_file_path, subtitle_path, retimed_subtitle_path } 223 | end 224 | 225 | if ret == nil then 226 | return notify("Parsing failed or no args passed.", "fatal", 3) 227 | end 228 | 229 | if ret.status == 0 then 230 | local old_sid = mp.get_property("sid") 231 | if mp.commandv("sub_add", retimed_subtitle_path) then 232 | notify("Subtitle synchronized.", nil, 2) 233 | mp.set_property("sub-delay", 0) 234 | if config.unload_old_sub then 235 | mp.commandv("sub_remove", old_sid) 236 | end 237 | else 238 | notify("Error: couldn't add synchronized subtitle.", "error", 3) 239 | end 240 | else 241 | notify("Subtitle synchronization failed.", "error", 3) 242 | end 243 | end 244 | 245 | local function sync_to_subtitle() 246 | local selected_track = track_selector:get_selected_track() 247 | 248 | if selected_track and selected_track.external then 249 | sync_subtitles(selected_track['external-filename']) 250 | else 251 | if h.is_path(config.ffmpeg_path) and not h.file_exists(config.ffmpeg_path) then 252 | return notify("Can't find ffmpeg executable.\nPlease specify the correct path in the config.", "error", 5) 253 | end 254 | local temp_sub_fp = extract_to_file(selected_track) 255 | if temp_sub_fp then 256 | sync_subtitles(temp_sub_fp) 257 | os.remove(temp_sub_fp) 258 | end 259 | end 260 | end 261 | 262 | local function sync_to_manual_offset() 263 | local _, track = get_active_track('sub') 264 | local sub_delay = tonumber(mp.get_property("sub-delay")) 265 | if tonumber(sub_delay) == 0 then 266 | return notify("There were no manual timings set, nothing to do!", "error", 7) 267 | end 268 | local file_path = track.external and track['external-filename'] or extract_to_file(track) 269 | if file_path == nil then 270 | return 271 | end 272 | 273 | local ext = get_extension(file_path) 274 | local codec_parser_map = { ass = sub.ASS, subrip = sub.SRT } 275 | local parser = codec_parser_map[track['codec']] 276 | if parser == nil then 277 | return notify(string.format("Error: unsupported codec: %s", track['codec']), "error", 3) 278 | end 279 | local s = parser:populate(file_path) 280 | s:shift_timing(sub_delay) 281 | if track.external == false then 282 | os.remove(file_path) 283 | s.filename = mp.get_property("filename/no-ext") .. "_manual_timing" .. ext 284 | else 285 | s.filename = remove_extension(s.filename) .. '_manual_timing' .. ext 286 | end 287 | s:save() 288 | mp.commandv("sub_add", s.filename) 289 | if config.unload_old_sub then 290 | mp.commandv("sub_remove", track.id) 291 | end 292 | mp.set_property("sub-delay", 0) 293 | return notify(string.format("Manual timings saved, loading '%s'", s.filename), "info", 7) 294 | end 295 | 296 | ------------------------------------------------------------ 297 | -- Menu actions & bindings 298 | 299 | ref_selector = menu:new { 300 | items = { 'Sync to audio', 'Sync to another subtitle', 'Save current timings', 'Cancel' }, 301 | last_choice = 'audio', 302 | pos_x = 50, 303 | pos_y = 50, 304 | text_color = 'fff5da', 305 | border_color = '2f1728', 306 | active_color = 'ff6b71', 307 | inactive_color = 'fff5da', 308 | } 309 | 310 | function ref_selector:get_keybindings() 311 | return { 312 | { key = 'h', fn = function() self:close() end }, 313 | { key = 'j', fn = function() self:down() end }, 314 | { key = 'k', fn = function() self:up() end }, 315 | { key = 'l', fn = function() self:act() end }, 316 | { key = 'down', fn = function() self:down() end }, 317 | { key = 'up', fn = function() self:up() end }, 318 | { key = 'Enter', fn = function() self:act() end }, 319 | { key = 'ESC', fn = function() self:close() end }, 320 | { key = 'n', fn = function() self:close() end }, 321 | { key = 'WHEEL_DOWN', fn = function() self:down() end }, 322 | { key = 'WHEEL_UP', fn = function() self:up() end }, 323 | { key = 'MBTN_LEFT', fn = function() self:act() end }, 324 | { key = 'MBTN_RIGHT', fn = function() self:close() end }, 325 | } 326 | end 327 | 328 | function ref_selector:new(o) 329 | self.__index = self 330 | o = o or {} 331 | return setmetatable(o, self) 332 | end 333 | 334 | function ref_selector:get_ref() 335 | if self.selected == 1 then 336 | return 'audio' 337 | elseif self.selected == 2 then 338 | return 'sub' 339 | else 340 | return nil 341 | end 342 | end 343 | 344 | function ref_selector:get_subsync_tool() 345 | if self.selected == 1 then 346 | return config.audio_subsync_tool 347 | elseif self.selected == 2 then 348 | return config.altsub_subsync_tool 349 | end 350 | end 351 | 352 | function ref_selector:act() 353 | self:close() 354 | 355 | if self.selected == 3 then 356 | return sync_to_manual_offset() 357 | end 358 | if self.selected == 4 then 359 | return 360 | end 361 | 362 | engine_selector:init() 363 | end 364 | 365 | function ref_selector:call_subsync() 366 | if self.selected == 1 then 367 | sync_subtitles() 368 | elseif self.selected == 2 then 369 | sync_to_subtitle() 370 | elseif self.selected == 3 then 371 | sync_to_manual_offset() 372 | end 373 | end 374 | 375 | function ref_selector:open() 376 | self.selected = 1 377 | for _, val in pairs(self:get_keybindings()) do 378 | mp.add_forced_key_binding(val.key, val.key, val.fn) 379 | end 380 | self:draw() 381 | end 382 | 383 | function ref_selector:close() 384 | for _, val in pairs(self:get_keybindings()) do 385 | mp.remove_key_binding(val.key) 386 | end 387 | self:erase() 388 | end 389 | 390 | 391 | ------------------------------------------------------------ 392 | -- Engine selector 393 | 394 | engine_selector = ref_selector:new { 395 | items = { 'ffsubsync', 'alass', 'Cancel' }, 396 | last_choice = 'ffsubsync', 397 | } 398 | 399 | function engine_selector:init() 400 | if not engine_is_set() then 401 | engine_selector:open() 402 | else 403 | track_selector:init() 404 | end 405 | end 406 | 407 | function engine_selector:get_engine_name() 408 | return engine_is_set() and ref_selector:get_subsync_tool() or self.last_choice 409 | end 410 | 411 | function engine_selector:act() 412 | self:close() 413 | 414 | if self.selected == 1 then 415 | self.last_choice = 'ffsubsync' 416 | elseif self.selected == 2 then 417 | self.last_choice = 'alass' 418 | elseif self.selected == 3 then 419 | return 420 | end 421 | 422 | track_selector:init() 423 | end 424 | 425 | ------------------------------------------------------------ 426 | -- Track selector 427 | 428 | track_selector = ref_selector:new { } 429 | 430 | local function is_supported_format(track) 431 | local supported_format = true 432 | if track.external then 433 | local ext = get_extension(track['external-filename']) 434 | if ext ~= '.srt' and ext ~= '.ass' then 435 | supported_format = false 436 | end 437 | end 438 | return supported_format 439 | end 440 | 441 | function track_selector:init() 442 | self.selected = 0 443 | 444 | if ref_selector:get_ref() == 'audio' then 445 | return ref_selector:call_subsync() 446 | end 447 | 448 | self.all_sub_tracks = get_loaded_tracks(ref_selector:get_ref()) 449 | self.secondary_sid = mp.get_property_native('secondary-sid') 450 | self.tracks = {} 451 | self.items = {} 452 | 453 | for _, track in ipairs(self.all_sub_tracks) do 454 | if (not track.selected or track.id == self.secondary_sid) and is_supported_format(track) then 455 | table.insert(self.tracks, track) 456 | table.insert( 457 | self.items, 458 | string.format( 459 | "%s #%s - %s%s", 460 | (track.external and 'External' or 'Internal'), 461 | track['id'], 462 | (track.lang or (track.title and track.title:gsub('^.*%.', '') or 'unknown')), 463 | (track.selected and ' (active)' or '') 464 | ) 465 | ) 466 | end 467 | end 468 | 469 | if #self.items == 0 then 470 | notify("No supported subtitle tracks found.", "warn", 5) 471 | return 472 | end 473 | 474 | table.insert(self.items, "Cancel") 475 | self:open() 476 | end 477 | 478 | function track_selector:get_selected_track() 479 | if self.selected < 1 then 480 | return nil 481 | end 482 | return self.tracks[self.selected] 483 | end 484 | 485 | function track_selector:act() 486 | self:close() 487 | 488 | if self.selected == #self.items then 489 | return 490 | end 491 | 492 | ref_selector:call_subsync() 493 | end 494 | 495 | ------------------------------------------------------------ 496 | -- Initialize the addon 497 | 498 | local function init() 499 | for _, executable in pairs { 'ffmpeg', 'ffsubsync', 'alass' } do 500 | local config_key = executable .. '_path' 501 | config[config_key] = h.is_empty(config[config_key]) and h.find_executable(executable) or config[config_key] 502 | end 503 | end 504 | 505 | ------------------------------------------------------------ 506 | -- Entry point 507 | 508 | init() 509 | mp.add_key_binding("n", "autosubsync-menu", function() ref_selector:open() end) 510 | -------------------------------------------------------------------------------- /helpers.lua: -------------------------------------------------------------------------------- 1 | local utils = require('mp.utils') 2 | local self = {} 3 | 4 | function self.is_empty(var) 5 | return var == nil or var == '' or (type(var) == 'table' and next(var) == nil) 6 | end 7 | 8 | function self.file_exists(filepath) 9 | if not self.is_empty(filepath) then 10 | local info = utils.file_info(filepath) 11 | if info and info.is_file then 12 | return true 13 | end 14 | end 15 | return false 16 | end 17 | 18 | function self.alt_dirs() 19 | return { 20 | '/opt/homebrew/bin', 21 | '/usr/local/bin', 22 | utils.join_path(os.getenv("HOME") or "~", '.local/bin'), 23 | } 24 | end 25 | 26 | function self.find_executable(name) 27 | local exec_path 28 | for _, path in pairs(self.alt_dirs()) do 29 | exec_path = utils.join_path(path, name) 30 | if self.file_exists(exec_path) then 31 | return exec_path 32 | end 33 | end 34 | return name 35 | end 36 | 37 | function self.is_path(str) 38 | return not not string.match(str, '[/\\]') 39 | end 40 | 41 | return self 42 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | require('autosubsync') 2 | -------------------------------------------------------------------------------- /menu.lua: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------ 2 | -- Menu visuals 3 | 4 | local mp = require('mp') 5 | local assdraw = require('mp.assdraw') 6 | local Menu = assdraw.ass_new() 7 | 8 | function Menu:new(o) 9 | self.__index = self 10 | o = o or {} 11 | o.selected = o.selected or 1 12 | o.canvas_width = o.canvas_width or 1280 13 | o.canvas_height = o.canvas_height or 720 14 | o.pos_x = o.pos_x or 0 15 | o.pos_y = o.pos_y or 0 16 | o.rect_width = o.rect_width or 320 17 | o.rect_height = o.rect_height or 40 18 | o.active_color = o.active_color or 'ffffff' 19 | o.inactive_color = o.inactive_color or 'aaaaaa' 20 | o.border_color = o.border_color or '000000' 21 | o.text_color = o.text_color or 'ffffff' 22 | 23 | return setmetatable(o, self) 24 | end 25 | 26 | function Menu:set_position(x, y) 27 | self.pos_x = x 28 | self.pos_y = y 29 | end 30 | 31 | function Menu:font_size(size) 32 | self:append(string.format([[{\fs%s}]], size)) 33 | end 34 | 35 | function Menu:set_text_color(code) 36 | self:append(string.format("{\\1c&H%s%s%s&\\1a&H05&}", code:sub(5, 6), code:sub(3, 4), code:sub(1, 2))) 37 | end 38 | 39 | function Menu:set_border_color(code) 40 | self:append(string.format("{\\3c&H%s%s%s&}", code:sub(5, 6), code:sub(3, 4), code:sub(1, 2))) 41 | end 42 | 43 | function Menu:apply_text_color() 44 | self:set_border_color(self.border_color) 45 | self:set_text_color(self.text_color) 46 | end 47 | 48 | function Menu:apply_rect_color(i) 49 | self:set_border_color(self.border_color) 50 | if i == self.selected then 51 | self:set_text_color(self.active_color) 52 | else 53 | self:set_text_color(self.inactive_color) 54 | end 55 | end 56 | 57 | function Menu:draw_text(i) 58 | local padding = 5 59 | local font_size = 25 60 | 61 | self:new_event() 62 | self:pos(self.pos_x + padding, self.pos_y + self.rect_height * (i - 1) + padding) 63 | self:font_size(font_size) 64 | self:apply_text_color(i) 65 | self:append(self.items[i]) 66 | end 67 | 68 | function Menu:draw_item(i) 69 | self:new_event() 70 | self:pos(self.pos_x, self.pos_y) 71 | self:apply_rect_color(i) 72 | self:draw_start() 73 | self:rect_cw(0, 0 + (i - 1) * self.rect_height, self.rect_width, i * self.rect_height) 74 | self:draw_stop() 75 | self:draw_text(i) 76 | end 77 | 78 | function Menu:draw() 79 | self.text = '' 80 | for i, _ in ipairs(self.items) do 81 | self:draw_item(i) 82 | end 83 | 84 | mp.set_osd_ass(self.canvas_width, self.canvas_height, self.text) 85 | end 86 | 87 | function Menu:erase() 88 | mp.set_osd_ass(self.canvas_width, self.canvas_height, '') 89 | end 90 | 91 | function Menu:up() 92 | self.selected = self.selected - 1 93 | if self.selected == 0 then 94 | self.selected = #self.items 95 | end 96 | self:draw() 97 | end 98 | 99 | function Menu:down() 100 | self.selected = self.selected + 1 101 | if self.selected > #self.items then 102 | self.selected = 1 103 | end 104 | self:draw() 105 | end 106 | 107 | return Menu 108 | -------------------------------------------------------------------------------- /subtitle.lua: -------------------------------------------------------------------------------- 1 | local P = {} 2 | 3 | local TimeStamp = {} 4 | local TimeStamp_mt = { __index = TimeStamp } 5 | function TimeStamp:new(hours, minutes, seconds) 6 | local new = {} 7 | new.hours = hours 8 | new.minutes = minutes 9 | new.seconds = seconds 10 | return setmetatable(new, TimeStamp_mt) 11 | end 12 | 13 | function TimeStamp.toTimeStamp(seconds) 14 | local diff, h, m, s = seconds, 0, 0, 0 15 | h = math.floor(diff / 3600) 16 | diff = diff - (h * 3600) 17 | m = math.floor(diff / 60) 18 | diff = diff - (m * 60) 19 | s = diff 20 | return TimeStamp:new(h, m, s) 21 | end 22 | 23 | function TimeStamp:toSeconds() 24 | return (3600 * self.hours) + (60 * self.minutes) + self.seconds 25 | end 26 | 27 | function TimeStamp:adjustTime(seconds) 28 | return self.toTimeStamp(self:toSeconds() + seconds) 29 | end 30 | 31 | function TimeStamp:toString(decimal_symbol) 32 | local seconds_fmt = string.format("%06.3f", self.seconds):gsub("%.", decimal_symbol) 33 | return string.format("%02d:%02d:%s", self.hours, self.minutes, seconds_fmt) 34 | end 35 | 36 | function TimeStamp.to_seconds(seconds, milliseconds) 37 | return tonumber(string.format("%s.%s", seconds, milliseconds)) 38 | end 39 | 40 | local AbstractSubtitle = {} 41 | local AbstractSubtitle_mt = { __index = AbstractSubtitle } 42 | 43 | function AbstractSubtitle:create() 44 | local new = {} 45 | return setmetatable(new, AbstractSubtitle_mt) 46 | end 47 | 48 | function AbstractSubtitle:save() 49 | print(string.format("Writing '%s' to file..", self.filename)) 50 | local f = io.open(self.filename, 'w') 51 | f:write(self:toString()) 52 | f:close() 53 | end 54 | 55 | -- strip Byte Order Mark from file, if it's present 56 | function AbstractSubtitle:sanitize(line) 57 | local bom_table = { 0xEF, 0xBB, 0xBF } -- TODO maybe add other ones (like UTF-16) 58 | local function has_bom() 59 | for i = 1, #bom_table do 60 | if i > #line then return false end 61 | local ch, byte = line:sub(i, i), line:byte(i, i) 62 | if byte ~= bom_table[i] then return false end 63 | end 64 | return true 65 | end 66 | return has_bom() and string.sub(line, #bom_table + 1) or line 67 | end 68 | 69 | local function trim(s) 70 | return s:match "^%s*(.-)%s*$" 71 | end 72 | 73 | function AbstractSubtitle:parse_file(filename) 74 | local lines = {} 75 | for line in io.lines(filename) do 76 | if #lines == 0 then line = self:sanitize(line) end 77 | line = line:gsub('\r\n?', '') -- make sure there's no carriage return 78 | line = trim(line) 79 | table.insert(lines, line) 80 | end 81 | return lines 82 | end 83 | 84 | function AbstractSubtitle:shift_timing(diff_seconds) 85 | for _, entry in pairs(self.entries) do 86 | if self.valid_entry(entry) then 87 | entry.start_time = entry.start_time:adjustTime(diff_seconds) 88 | entry.end_time = entry.end_time:adjustTime(diff_seconds) 89 | end 90 | end 91 | end 92 | 93 | function AbstractSubtitle.valid_entry(entry) 94 | return entry ~= nil 95 | end 96 | 97 | local function inheritsFrom (baseClass) 98 | local new_class = {} 99 | local class_mt = { __index = new_class } 100 | 101 | function new_class:create(filename) 102 | local instance = { 103 | filename = filename, 104 | language = nil, 105 | header = nil, -- will be empty for srt, some stuff for ass 106 | entries = {} -- list of entries 107 | } 108 | setmetatable(instance, class_mt) 109 | return instance 110 | end 111 | 112 | if baseClass then 113 | setmetatable(new_class, { __index = baseClass }) 114 | end 115 | return new_class 116 | end 117 | 118 | local SRT = inheritsFrom(AbstractSubtitle) 119 | function SRT.entry() 120 | return { index = nil, start_time = nil, end_time = nil, text = {} } 121 | end 122 | 123 | function SRT:populate(filename) 124 | local timestamp_fmt = "^(%d+):(%d+):(%d+),(%d+) %-%-> (%d+):(%d+):(%d+),(%d+)$" 125 | local function parse_timestamp(timestamp) 126 | local function to_seconds(seconds, milliseconds) 127 | return tonumber(string.format("%s.%s", seconds, milliseconds)) 128 | end 129 | local _, _, from_h, from_m, from_s, from_ms, to_h, to_m, to_s, to_ms = timestamp:find(timestamp_fmt) 130 | return TimeStamp:new(from_h, from_m, to_seconds(from_s, from_ms)), TimeStamp:new(to_h, to_m, to_seconds(to_s, to_ms)) 131 | end 132 | 133 | local new = self:create(filename) 134 | local entry = self.entry() 135 | local f_idx, idx = 1, 1 136 | for _, line in pairs(self:parse_file(filename)) do 137 | if idx == 1 and #line > 0 then 138 | assert(line:match("^%d+$"), string.format("SRT FORMAT ERROR (line %d): expected a number but got '%s'", f_idx, line)) 139 | entry.index = line 140 | elseif idx == 2 then 141 | assert(line:match("^%d+:%d+:%d+,%d+ %-%-> %d+:%d+:%d+,%d+$"), string.format("SRT FORMAT ERROR (line %d): expected a timecode string but got '%s'", f_idx, line)) 142 | local t_start, t_end = parse_timestamp(line) 143 | entry.start_time, entry.end_time = t_start, t_end 144 | else 145 | if #line == 0 then 146 | -- end of text 147 | if entry.index ~= nil then 148 | table.insert(new.entries, entry) 149 | end 150 | entry = SRT.entry() 151 | idx = 0 152 | else 153 | table.insert(entry.text, line) 154 | end 155 | end 156 | idx = idx + 1 157 | f_idx = f_idx + 1 158 | end 159 | return new 160 | end 161 | 162 | function SRT:toString() 163 | local stringbuilder = {} 164 | local function append(s) 165 | table.insert(stringbuilder, s) 166 | end 167 | for _, entry in pairs(self.entries) do 168 | append(entry.index) 169 | local timestamp_string = string.format("%s --> %s", entry.start_time:toString(","), entry.end_time:toString(",")) 170 | append(timestamp_string) 171 | if type(entry.text) == 'table' then 172 | append(table.concat(entry.text, "\n")) 173 | else append(entry.text) end 174 | append('') 175 | end 176 | return table.concat(stringbuilder, '\n') 177 | end 178 | 179 | local ASS = inheritsFrom(AbstractSubtitle) 180 | ASS.header_mapper = { ["Start"] = "start_time", ["End"] = "end_time" } 181 | 182 | function ASS.valid_entry(entry) 183 | return entry['type'] ~= nil 184 | end 185 | 186 | function ASS:toString() 187 | local stringbuilder = {} 188 | local function append(s) table.insert(stringbuilder, s) end 189 | append(self.header) 190 | append('[Events]') 191 | for i = 1, #self.entries do 192 | if i == 1 then 193 | -- stringbuilder for events header 194 | local event_sb = {}; 195 | for _, v in pairs(self.event_header) do table.insert(event_sb, v) end 196 | append(string.format("Format: %s", table.concat(event_sb, ", "))) 197 | end 198 | local entry = self.entries[i] 199 | local entry_sb = {} 200 | for _, col in pairs(self.event_header) do 201 | local value = entry[col] 202 | local timestamp_entry_column = self.header_mapper[col] 203 | if timestamp_entry_column then 204 | value = entry[timestamp_entry_column]:toString(".") 205 | end 206 | table.insert(entry_sb, value) 207 | end 208 | append(string.format("%s: %s", entry['type'], table.concat(entry_sb, ","))) 209 | end 210 | return table.concat(stringbuilder, '\n') 211 | end 212 | 213 | function ASS:populate(filename, language) 214 | local header, events, parser = {}, {}, nil 215 | for _, line in pairs(self:parse_file(filename)) do 216 | local _, _, event = string.find(line, "^%[([^%]]+)%]%s*$") 217 | if event then 218 | if event == "Events" then 219 | parser = function(x) table.insert(events, x) end 220 | else 221 | parser = function(x) table.insert(header, x) end 222 | parser(line) 223 | end 224 | else 225 | parser(line) 226 | end 227 | end 228 | -- create subtitle instance 229 | local ev_regex = "^(%a+):%s(.+)$" 230 | local function parse_event(header_columns, ev) 231 | local function create_timestamp(timestamp_str) 232 | local timestamp_fmt = "^(%d+):(%d+):(%d+).(%d+)" 233 | local _, _, h, m, s, ms = timestamp_str:find(timestamp_fmt) 234 | return TimeStamp:new(h, m, TimeStamp.to_seconds(s, ms)) 235 | end 236 | local new_event = {} 237 | local _, _, ev_type, ev_values = string.find(ev, ev_regex) 238 | new_event['type'] = ev_type 239 | -- skipping last column, since that's the text, which can contain commas 240 | local last_idx = 0; 241 | for i = 1, #header_columns - 1 do 242 | local col = header_columns[i] 243 | local idx = string.find(ev_values, ",", last_idx + 1) 244 | local val = ev_values:sub(last_idx + 1, idx - 1) 245 | local timestamp_entry_column = self.header_mapper[col] 246 | if timestamp_entry_column then 247 | new_event[timestamp_entry_column] = create_timestamp(val) 248 | else 249 | new_event[col] = val 250 | end 251 | last_idx = idx 252 | end 253 | new_event[header_columns[#header_columns]] = ev_values:sub(last_idx + 1) 254 | return new_event 255 | end 256 | 257 | local sub = self:create(filename) 258 | sub.header = table.concat(header, "\n") 259 | sub.language = language 260 | -- remove and process first entry in events, which is a header 261 | local _, _, colstring = string.find(table.remove(events, 1), "^%a+:%s(.+)$") 262 | local columns = {}; 263 | for i in colstring:gmatch("[^%,%s]+") do table.insert(columns, i) end 264 | sub.event_header = columns 265 | for _, event in pairs(events) do 266 | if #event > 0 then 267 | table.insert(sub.entries, parse_event(columns, event)) 268 | end 269 | end 270 | return sub 271 | end 272 | 273 | P.AbstractSubtitle = AbstractSubtitle 274 | P.ASS = ASS 275 | P.SRT = SRT 276 | return P 277 | --------------------------------------------------------------------------------