519 lines
15 KiB
Lua
519 lines
15 KiB
Lua
-- Usage:
|
|
-- default keybinding: n
|
|
-- add the following to your input.conf to change the default keybinding:
|
|
-- keyname script_binding autosubsync-menu
|
|
|
|
local mp = require('mp')
|
|
local utils = require('mp.utils')
|
|
local mpopt = require('mp.options')
|
|
local menu = require('menu')
|
|
local sub = require('subtitle')
|
|
local h = require('helpers')
|
|
local ref_selector
|
|
local engine_selector
|
|
local track_selector
|
|
|
|
-- Config
|
|
-- Options can be changed here or in a separate config file.
|
|
-- Config path: ~/.config/mpv/script-opts/autosubsync.conf
|
|
local config = {
|
|
-- Change the following lines if the locations of executables differ from the defaults
|
|
-- If set to empty, the path will be guessed.
|
|
ffmpeg_path = "",
|
|
ffsubsync_path = "",
|
|
alass_path = "",
|
|
|
|
-- Choose what tool to use. Allowed options: ffsubsync, alass, ask.
|
|
-- If set to ask, the add-on will ask to choose the tool every time.
|
|
audio_subsync_tool = "ask",
|
|
altsub_subsync_tool = "ask",
|
|
|
|
-- After retiming, tell mpv to forget the original subtitle track.
|
|
unload_old_sub = true,
|
|
}
|
|
mpopt.read_options(config, 'autosubsync')
|
|
|
|
-- Snippet borrowed from stackoverflow to get the operating system
|
|
-- originally found at: https://stackoverflow.com/a/30960054
|
|
local os_name = (function()
|
|
if os.getenv("HOME") == nil then
|
|
return function()
|
|
return "Windows"
|
|
end
|
|
else
|
|
return function()
|
|
return "*nix"
|
|
end
|
|
end
|
|
end)()
|
|
|
|
local os_temp = (function()
|
|
if os_name() == "Windows" then
|
|
return function()
|
|
return os.getenv('TEMP')
|
|
end
|
|
else
|
|
return function()
|
|
return '/tmp/'
|
|
end
|
|
end
|
|
end)()
|
|
|
|
local function notify(message, level, duration)
|
|
level = level or 'info'
|
|
duration = duration or 1
|
|
mp.msg[level](message)
|
|
mp.osd_message(message, duration)
|
|
end
|
|
|
|
local function subprocess(args)
|
|
return mp.command_native {
|
|
name = "subprocess",
|
|
playback_only = false,
|
|
capture_stdout = true,
|
|
args = args
|
|
}
|
|
end
|
|
|
|
local url_decode = function(url)
|
|
local function hex_to_char(x)
|
|
return string.char(tonumber(x, 16))
|
|
end
|
|
if url ~= nil then
|
|
url = url:gsub("^file://", "")
|
|
url = url:gsub("+", " ")
|
|
url = url:gsub("%%(%x%x)", hex_to_char)
|
|
return url
|
|
else
|
|
return
|
|
end
|
|
end
|
|
|
|
local function get_loaded_tracks(track_type)
|
|
local result = {}
|
|
local track_list = mp.get_property_native('track-list')
|
|
for _, track in pairs(track_list) do
|
|
if track.type == track_type then
|
|
track['external-filename'] = track.external and url_decode(track['external-filename'])
|
|
table.insert(result, track)
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
local function get_active_track(track_type)
|
|
local track_list = mp.get_property_native('track-list')
|
|
for num, track in ipairs(track_list) do
|
|
if track.type == track_type and track.selected == true then
|
|
if track.external then
|
|
track['external-filename'] = url_decode(track['external-filename'])
|
|
end
|
|
if not (track_type == 'sub' and track.id == mp.get_property_native('secondary-sid')) then
|
|
return num, track
|
|
end
|
|
end
|
|
end
|
|
return notify(string.format("Error: no track of type '%s' selected", track_type), "error", 3)
|
|
end
|
|
|
|
local function remove_extension(filename)
|
|
return filename:gsub('%.%w+$', '')
|
|
end
|
|
|
|
local function get_extension(filename)
|
|
return filename:match("^.+(%.%w+)$")
|
|
end
|
|
|
|
local function startswith(str, prefix)
|
|
return string.sub(str, 1, string.len(prefix)) == prefix
|
|
end
|
|
|
|
local function mkfp_retimed(sub_path)
|
|
if not startswith(sub_path, os_temp()) then
|
|
return table.concat { remove_extension(sub_path), '_retimed', get_extension(sub_path) }
|
|
else
|
|
return table.concat { remove_extension(mp.get_property("path")), '_retimed', get_extension(sub_path) }
|
|
end
|
|
end
|
|
|
|
local function engine_is_set()
|
|
local subsync_tool = ref_selector:get_subsync_tool()
|
|
if h.is_empty(subsync_tool) or subsync_tool == "ask" then
|
|
return false
|
|
else
|
|
return true
|
|
end
|
|
end
|
|
|
|
local function extract_to_file(subtitle_track)
|
|
local codec_ext_map = { subrip = "srt", ass = "ass" }
|
|
local ext = codec_ext_map[subtitle_track['codec']]
|
|
if ext == nil then
|
|
return notify(string.format("Error: unsupported codec: %s", subtitle_track['codec']), "error", 3)
|
|
end
|
|
local temp_sub_fp = utils.join_path(os_temp(), 'autosubsync_extracted.' .. ext)
|
|
notify("Extracting internal subtitles...", nil, 3)
|
|
local ret = subprocess {
|
|
config.ffmpeg_path,
|
|
"-hide_banner",
|
|
"-nostdin",
|
|
"-y",
|
|
"-loglevel", "quiet",
|
|
"-an",
|
|
"-vn",
|
|
"-i", mp.get_property("path"),
|
|
"-map", "0:" .. (subtitle_track and subtitle_track['ff-index'] or 's'),
|
|
"-f", ext,
|
|
temp_sub_fp
|
|
}
|
|
if ret == nil or ret.status ~= 0 then
|
|
return notify("Couldn't extract internal subtitle.\nMake sure the video has internal subtitles.", "error", 7)
|
|
end
|
|
return temp_sub_fp
|
|
end
|
|
|
|
local function dump(o)
|
|
if type(o) == 'table' then
|
|
local s = '{ '
|
|
for k,v in pairs(o) do
|
|
if type(k) ~= 'number' then k = '"'..k..'"' end
|
|
s = s .. '['..k..'] = ' .. dump(v) .. ','
|
|
end
|
|
return s .. '} '
|
|
else
|
|
return tostring(o)
|
|
end
|
|
end
|
|
|
|
|
|
local function sync_subtitles(ref_sub_path)
|
|
local reference_file_path = ref_sub_path or mp.get_property("path")
|
|
local _, sub_track = get_active_track('sub')
|
|
if sub_track == nil then
|
|
return
|
|
end
|
|
local subtitle_path = sub_track.external and sub_track['external-filename'] or extract_to_file(sub_track)
|
|
local engine_name = engine_selector:get_engine_name()
|
|
local engine_path = config[engine_name .. '_path']
|
|
|
|
if h.is_path(config.ffmpeg_path) and not h.file_exists(engine_path) then
|
|
return notify(
|
|
string.format("Can't find %s executable.\nPlease specify the correct path in the config.", engine_name),
|
|
"error",
|
|
5
|
|
)
|
|
end
|
|
|
|
if not h.file_exists(subtitle_path) then
|
|
return notify(
|
|
table.concat {
|
|
"Subtitle synchronization failed:\nCouldn't find ",
|
|
subtitle_path or "external subtitle file."
|
|
},
|
|
"error",
|
|
3
|
|
)
|
|
end
|
|
|
|
local retimed_subtitle_path = mkfp_retimed(subtitle_path)
|
|
|
|
notify(string.format("Starting %s...", engine_name), nil, 2)
|
|
|
|
local ret
|
|
if engine_name == "ffsubsync" then
|
|
local args = { config.ffsubsync_path, reference_file_path, "-i", subtitle_path, "-o", retimed_subtitle_path }
|
|
if not ref_sub_path then
|
|
table.insert(args, '--reference-stream')
|
|
table.insert(args, '0:' .. get_active_track('audio'))
|
|
end
|
|
ret = subprocess(args)
|
|
else
|
|
ret = subprocess { config.alass_path, reference_file_path, subtitle_path, retimed_subtitle_path }
|
|
end
|
|
|
|
if ret == nil then
|
|
return notify("Parsing failed or no args passed.", "fatal", 3)
|
|
end
|
|
|
|
if ret.status == 0 then
|
|
local old_sid = mp.get_property("sid")
|
|
if mp.commandv("sub_add", retimed_subtitle_path) then
|
|
notify("Subtitle synchronized.", nil, 2)
|
|
mp.set_property("sub-delay", 0)
|
|
if config.unload_old_sub then
|
|
mp.commandv("sub_remove", old_sid)
|
|
end
|
|
else
|
|
notify("Error: couldn't add synchronized subtitle.", "error", 3)
|
|
end
|
|
else
|
|
notify("Subtitle synchronization failed.", "error", 3)
|
|
end
|
|
end
|
|
|
|
local function sync_to_subtitle()
|
|
local selected_track = track_selector:get_selected_track()
|
|
|
|
if selected_track and selected_track.external then
|
|
sync_subtitles(selected_track['external-filename'])
|
|
else
|
|
if h.is_path(config.ffmpeg_path) and not h.file_exists(config.ffmpeg_path) then
|
|
return notify("Can't find ffmpeg executable.\nPlease specify the correct path in the config.", "error", 5)
|
|
end
|
|
local temp_sub_fp = extract_to_file(selected_track)
|
|
if temp_sub_fp then
|
|
sync_subtitles(temp_sub_fp)
|
|
os.remove(temp_sub_fp)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function sync_to_manual_offset()
|
|
local _, track = get_active_track('sub')
|
|
local sub_delay = tonumber(mp.get_property("sub-delay"))
|
|
if tonumber(sub_delay) == 0 then
|
|
return notify("There were no manual timings set, nothing to do!", "error", 7)
|
|
end
|
|
local file_path = track.external and track['external-filename'] or extract_to_file(track)
|
|
if file_path == nil then
|
|
return
|
|
end
|
|
|
|
local ext = get_extension(file_path)
|
|
local codec_parser_map = { ass = sub.ASS, subrip = sub.SRT }
|
|
local parser = codec_parser_map[track['codec']]
|
|
if parser == nil then
|
|
return notify(string.format("Error: unsupported codec: %s", track['codec']), "error", 3)
|
|
end
|
|
local s = parser:populate(file_path)
|
|
s:shift_timing(sub_delay)
|
|
if track.external == false then
|
|
os.remove(file_path)
|
|
s.filename = mp.get_property("filename/no-ext") .. "_manual_timing" .. ext
|
|
else
|
|
s.filename = remove_extension(s.filename) .. '_manual_timing' .. ext
|
|
end
|
|
s:save()
|
|
mp.commandv("sub_add", s.filename)
|
|
if config.unload_old_sub then
|
|
mp.commandv("sub_remove", track.id)
|
|
end
|
|
mp.set_property("sub-delay", 0)
|
|
return notify(string.format("Manual timings saved, loading '%s'", s.filename), "info", 7)
|
|
end
|
|
|
|
------------------------------------------------------------
|
|
-- Menu actions & bindings
|
|
|
|
ref_selector = menu:new {
|
|
items = { 'Sync to audio', 'Sync to another subtitle', 'Save current timings', 'Cancel' },
|
|
last_choice = 'audio',
|
|
pos_x = 50,
|
|
pos_y = 50,
|
|
text_color = 'fff5da',
|
|
border_color = '2f1728',
|
|
active_color = 'ff6b71',
|
|
inactive_color = 'fff5da',
|
|
}
|
|
|
|
function ref_selector:get_keybindings()
|
|
return {
|
|
{ key = 'h', fn = function() self:close() end },
|
|
{ key = 'j', fn = function() self:down() end },
|
|
{ key = 'k', fn = function() self:up() end },
|
|
{ key = 'l', fn = function() self:act() end },
|
|
{ key = 'down', fn = function() self:down() end },
|
|
{ key = 'up', fn = function() self:up() end },
|
|
{ key = 'Enter', fn = function() self:act() end },
|
|
{ key = 'ESC', fn = function() self:close() end },
|
|
{ key = 'n', fn = function() self:close() end },
|
|
{ key = 'WHEEL_DOWN', fn = function() self:down() end },
|
|
{ key = 'WHEEL_UP', fn = function() self:up() end },
|
|
{ key = 'MBTN_LEFT', fn = function() self:act() end },
|
|
{ key = 'MBTN_RIGHT', fn = function() self:close() end },
|
|
}
|
|
end
|
|
|
|
function ref_selector:new(o)
|
|
self.__index = self
|
|
o = o or {}
|
|
return setmetatable(o, self)
|
|
end
|
|
|
|
function ref_selector:get_ref()
|
|
if self.selected == 1 then
|
|
return 'audio'
|
|
elseif self.selected == 2 then
|
|
return 'sub'
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
|
|
function ref_selector:get_subsync_tool()
|
|
if self.selected == 1 then
|
|
return config.audio_subsync_tool
|
|
elseif self.selected == 2 then
|
|
return config.altsub_subsync_tool
|
|
end
|
|
end
|
|
|
|
function ref_selector:act()
|
|
self:close()
|
|
|
|
if self.selected == 3 then
|
|
return sync_to_manual_offset()
|
|
end
|
|
if self.selected == 4 then
|
|
return
|
|
end
|
|
|
|
engine_selector:init()
|
|
end
|
|
|
|
function ref_selector:call_subsync()
|
|
if self.selected == 1 then
|
|
sync_subtitles()
|
|
elseif self.selected == 2 then
|
|
sync_to_subtitle()
|
|
elseif self.selected == 3 then
|
|
sync_to_manual_offset()
|
|
end
|
|
end
|
|
|
|
function ref_selector:open()
|
|
self.selected = 1
|
|
for _, val in pairs(self:get_keybindings()) do
|
|
mp.add_forced_key_binding(val.key, val.key, val.fn)
|
|
end
|
|
self:draw()
|
|
end
|
|
|
|
function ref_selector:close()
|
|
for _, val in pairs(self:get_keybindings()) do
|
|
mp.remove_key_binding(val.key)
|
|
end
|
|
self:erase()
|
|
end
|
|
|
|
|
|
------------------------------------------------------------
|
|
-- Engine selector
|
|
|
|
engine_selector = ref_selector:new {
|
|
items = { 'ffsubsync', 'alass', 'Cancel' },
|
|
last_choice = 'ffsubsync',
|
|
}
|
|
|
|
function engine_selector:init()
|
|
if not engine_is_set() then
|
|
engine_selector:open()
|
|
else
|
|
track_selector:init()
|
|
end
|
|
end
|
|
|
|
function engine_selector:get_engine_name()
|
|
return engine_is_set() and ref_selector:get_subsync_tool() or self.last_choice
|
|
end
|
|
|
|
function engine_selector:act()
|
|
self:close()
|
|
|
|
if self.selected == 1 then
|
|
self.last_choice = 'ffsubsync'
|
|
elseif self.selected == 2 then
|
|
self.last_choice = 'alass'
|
|
elseif self.selected == 3 then
|
|
return
|
|
end
|
|
|
|
track_selector:init()
|
|
end
|
|
|
|
------------------------------------------------------------
|
|
-- Track selector
|
|
|
|
track_selector = ref_selector:new { }
|
|
|
|
local function is_supported_format(track)
|
|
local supported_format = true
|
|
if track.external then
|
|
local ext = get_extension(track['external-filename'])
|
|
if ext ~= '.srt' and ext ~= '.ass' then
|
|
supported_format = false
|
|
end
|
|
end
|
|
return supported_format
|
|
end
|
|
|
|
function track_selector:init()
|
|
self.selected = 0
|
|
|
|
if ref_selector:get_ref() == 'audio' then
|
|
return ref_selector:call_subsync()
|
|
end
|
|
|
|
self.all_sub_tracks = get_loaded_tracks(ref_selector:get_ref())
|
|
self.secondary_sid = mp.get_property_native('secondary-sid')
|
|
self.tracks = {}
|
|
self.items = {}
|
|
|
|
for _, track in ipairs(self.all_sub_tracks) do
|
|
if (not track.selected or track.id == self.secondary_sid) and is_supported_format(track) then
|
|
table.insert(self.tracks, track)
|
|
table.insert(
|
|
self.items,
|
|
string.format(
|
|
"%s #%s - %s%s",
|
|
(track.external and 'External' or 'Internal'),
|
|
track['id'],
|
|
(track.lang or (track.title and track.title:gsub('^.*%.', '') or 'unknown')),
|
|
(track.selected and ' (active)' or '')
|
|
)
|
|
)
|
|
end
|
|
end
|
|
|
|
if #self.items == 0 then
|
|
notify("No supported subtitle tracks found.", "warn", 5)
|
|
return
|
|
end
|
|
|
|
table.insert(self.items, "Cancel")
|
|
self:open()
|
|
end
|
|
|
|
function track_selector:get_selected_track()
|
|
if self.selected < 1 then
|
|
return nil
|
|
end
|
|
return self.tracks[self.selected]
|
|
end
|
|
|
|
function track_selector:act()
|
|
self:close()
|
|
|
|
if self.selected == #self.items then
|
|
return
|
|
end
|
|
|
|
ref_selector:call_subsync()
|
|
end
|
|
|
|
------------------------------------------------------------
|
|
-- Initialize the addon
|
|
|
|
local function init()
|
|
for _, executable in pairs { 'ffmpeg', 'ffsubsync', 'alass' } do
|
|
local config_key = executable .. '_path'
|
|
config[config_key] = h.is_empty(config[config_key]) and h.find_executable(executable) or config[config_key]
|
|
end
|
|
end
|
|
|
|
------------------------------------------------------------
|
|
-- Entry point
|
|
|
|
init()
|
|
mp.add_key_binding("n", "autosubsync-menu", function() ref_selector:open() end)
|