init
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 joaquintorres
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,130 @@
|
||||
# autosubsync-mpv
|
||||
|
||||
Automatic subtitle synchronization script for [mpv](https://wiki.archlinux.org/index.php/Mpv).
|
||||
|
||||
A demo can be viewed on <a target="_blank" href="https://www.youtube.com/watch?v=w1vwnUiF6Bc"><img src="https://user-images.githubusercontent.com/69171671/115097010-4bd13c80-9f17-11eb-83e9-2583658f73bc.png" width="80px"></a>
|
||||
|
||||
Supported backends:
|
||||
* [ffsubsync](https://github.com/smacke/ffsubsync)
|
||||
* [alass](https://github.com/kaegi/alass)
|
||||
|
||||
## Installation
|
||||
|
||||
0. Make sure you have mpv v0.33 or higher installed.
|
||||
```
|
||||
$ mpv --version
|
||||
```
|
||||
1. Install [FFmpeg](https://wiki.archlinux.org/index.php/FFmpeg):
|
||||
```
|
||||
$ pacman -S ffmpeg
|
||||
```
|
||||
Windows users have to manually install FFmpeg from [here](https://ffmpeg.zeranoe.com/builds/).
|
||||
2. Install your retiming program of choice,
|
||||
[ffsubsync](https://github.com/smacke/ffsubsync), [alass](https://github.com/kaegi/alass) or both:
|
||||
```
|
||||
$ pip install ffsubsync
|
||||
```
|
||||
```
|
||||
$ trizen -S alass-git # for Arch-based distros
|
||||
```
|
||||
|
||||
3. Download the add-on and save it to your mpv scripts folder.
|
||||
|
||||
| GNU/Linux | Windows |
|
||||
|---|---|
|
||||
| `~/.config/mpv/scripts` | `%AppData%\mpv\scripts\` |
|
||||
|
||||
To do it in one command:
|
||||
|
||||
```
|
||||
$ git clone 'https://github.com/Ajatt-Tools/autosubsync-mpv' ~/.config/mpv/scripts/autosubsync
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can skip this step if the add-on works out of the box.
|
||||
|
||||
Create a config file:
|
||||
|
||||
| GNU/Linux | Windows |
|
||||
|---|---|
|
||||
| `~/.config/mpv/script-opts/autosubsync.conf` | `%AppData%\mpv\script-opts\autosubsync.conf` |
|
||||
|
||||
Example config:
|
||||
|
||||
```
|
||||
# Absolute paths to the executables, if needed:
|
||||
|
||||
# 1. ffmpeg
|
||||
ffmpeg_path=C:/Program Files/ffmpeg/bin/ffmpeg.exe
|
||||
ffmpeg_path=/usr/bin/ffmpeg
|
||||
|
||||
# 2. ffsubsync
|
||||
ffsubsync_path=C:/Program Files/ffsubsync/ffsubsync.exe
|
||||
ffsubsync_path=/home/user/.local/bin/ffsubsync
|
||||
|
||||
# 3. alass
|
||||
alass_path=C:/Program Files/ffmpeg/bin/alass.exe
|
||||
alass_path=/usr/bin/alass
|
||||
|
||||
# Preferred retiming tool. Allowed options: 'ffsubsync', 'alass', 'ask'.
|
||||
# If set to 'ask', the add-on will ask to choose the tool every time:
|
||||
|
||||
# 1. Preferred tool for syncing to audio.
|
||||
audio_subsync_tool=ask
|
||||
audio_subsync_tool=ffsubsync
|
||||
audio_subsync_tool=alass
|
||||
|
||||
# 2. Preferred tool for syncing to another subtitle.
|
||||
altsub_subsync_tool=ask
|
||||
altsub_subsync_tool=ffsubsync
|
||||
altsub_subsync_tool=alass
|
||||
|
||||
# Unload old subs (yes,no)
|
||||
# After retiming, tell mpv to forget the original subtitle track.
|
||||
unload_old_sub=yes
|
||||
unload_old_sub=no
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
* On Windows, you need to use forward slashes or double backslashes for your path.
|
||||
For example, `"C:\\Users\\YourPath\\Scripts\\ffsubsync"`
|
||||
or `"C:/Users/YourPath/Scripts/ffsubsync"`,
|
||||
or it might not work.
|
||||
|
||||
* On GNU/Linux you can use `which ffsubsync` to find out where it is.
|
||||
|
||||
## Usage
|
||||
|
||||
When you have an out of sync sub, press `n` to synchronize it.
|
||||
|
||||
`ffsubsync` can typically take up to about 20-30 seconds
|
||||
to synchronize (I've seen it take as much as 2 minutes
|
||||
with a very large file on a lower end computer), so it
|
||||
would probably be faster to find another, properly
|
||||
synchronized subtitle with `autosub` or `trueautosub`.
|
||||
Many times this is just not possible, as all available
|
||||
subs for your specific language are out of sync.
|
||||
|
||||
Take into account that using this script has the
|
||||
same limitations as `ffsubsync`, so subtitles that have
|
||||
a lot of extra text or are meant for an entirely different
|
||||
version of the video might not sync properly. `alass` is supposed
|
||||
to handle some edge cases better, but I haven't fully tested it yet,
|
||||
obtaining similar results with both.
|
||||
|
||||
Note that the script will create a new subtitle file, in the same folder
|
||||
as the original, with the `_retimed` suffix at the end.
|
||||
|
||||
## Issues and feedback
|
||||
|
||||
If you are having trouble getting it to work or you've found a bug,
|
||||
feel free to [join our community](https://tatsumoto-ren.github.io/blog/join-our-community.html) to ask directly.
|
||||
|
||||
Try to check if
|
||||
[ffsubsync](https://github.com/smacke/ffsubsync)
|
||||
or
|
||||
[alass](https://github.com/kaegi/alass)
|
||||
works properly outside of `mpv` first.
|
||||
If the retiming tool of choice isn't working, `autosubsync` will likely fail.
|
||||
@@ -0,0 +1,559 @@
|
||||
-- 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 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')
|
||||
|
||||
local function is_empty(var)
|
||||
return var == nil or var == '' or (type(var) == 'table' and next(var) == nil)
|
||||
end
|
||||
|
||||
----- string
|
||||
local function replace(str, what, with)
|
||||
if is_empty(str) then return "" end
|
||||
if is_empty(what) then return str end
|
||||
if with == nil then with = "" end
|
||||
what = string.gsub(what, "[%(%)%.%+%-%*%?%[%]%^%$%%]", "%%%1")
|
||||
with = string.gsub(with, "[%%]", "%%%%")
|
||||
return string.gsub(str, what, with)
|
||||
end
|
||||
|
||||
local function esc_for_title(string)
|
||||
string = string:gsub('^[%._%-%s]*', '')
|
||||
:gsub('%.%w+$', '')
|
||||
return string
|
||||
end
|
||||
|
||||
local function esc_for_code(trackCode)
|
||||
if trackCode:find("PGS") then trackCode = "PGS"
|
||||
elseif trackCode:find("SUBRIP") then trackCode = "SRT"
|
||||
elseif trackCode:find("VTT") then trackCode = "VTT"
|
||||
elseif trackCode:find("DVD_SUB") then trackCode = "VOB_SUB"
|
||||
elseif trackCode:find("DVB_SUB") then trackCode = "DVB_SUB"
|
||||
elseif trackCode:find("DVB_TELE") then trackCode = "TELETEXT"
|
||||
elseif trackCode:find("ARIB") then trackCode = "ARIB"
|
||||
end
|
||||
return trackCode
|
||||
end
|
||||
|
||||
-- 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)()
|
||||
|
||||
-- Courtesy of https://stackoverflow.com/questions/4990990/check-if-a-file-exists-with-lua
|
||||
local function file_exists(filepath)
|
||||
if not filepath then
|
||||
return false
|
||||
end
|
||||
local f = io.open(filepath, "r")
|
||||
if f ~= nil then
|
||||
io.close(f)
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function find_executable(name)
|
||||
local os_path = os.getenv("PATH") or ""
|
||||
local fallback_path = utils.join_path("/usr/bin", name)
|
||||
local exec_path
|
||||
for path in os_path:gmatch("[^:]+") do
|
||||
exec_path = utils.join_path(path, name)
|
||||
if file_exists(exec_path) then
|
||||
return exec_path
|
||||
end
|
||||
end
|
||||
return fallback_path
|
||||
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("错误: 没有选择类型为 '%s' 的轨道", 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 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("错误: 不支持的格式: %s", subtitle_track['codec']), "error", 3)
|
||||
end
|
||||
local temp_sub_fp = utils.join_path(os_temp(), 'autosubsync_extracted.' .. ext)
|
||||
notify("提取内封字幕...", nil, 3)
|
||||
local screenx, screeny, aspect = mp.get_osd_size()
|
||||
mp.set_osd_ass(screenx, screeny, "{\\an9}● ")
|
||||
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
|
||||
}
|
||||
mp.set_osd_ass(screenx, screeny, "")
|
||||
if ret == nil or ret.status ~= 0 then
|
||||
return notify("无法提取内封字幕.\n请先确保在脚本配置文件中为 ffmpeg 指定了正确的路径\n并确保视频有内封字幕.", "error", 7)
|
||||
end
|
||||
return temp_sub_fp
|
||||
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 not file_exists(subtitle_path) then
|
||||
return notify(
|
||||
table.concat {
|
||||
"字幕同步失败:\n无法找到 ",
|
||||
subtitle_path or "外部字幕文件."
|
||||
},
|
||||
"error",
|
||||
3
|
||||
)
|
||||
end
|
||||
|
||||
local retimed_subtitle_path = mkfp_retimed(subtitle_path)
|
||||
|
||||
notify(string.format("开始 %s...", engine_name), nil, 2)
|
||||
|
||||
local ret
|
||||
local screenx, screeny, aspect = mp.get_osd_size()
|
||||
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
|
||||
mp.set_osd_ass(screenx, screeny, "{\\an9}● ")
|
||||
ret = subprocess(args)
|
||||
mp.set_osd_ass(screenx, screeny, "")
|
||||
else
|
||||
mp.set_osd_ass(screenx, screeny, "{\\an9}● ")
|
||||
ret = subprocess { config.alass_path, reference_file_path, subtitle_path, retimed_subtitle_path }
|
||||
mp.set_osd_ass(screenx, screeny, "")
|
||||
end
|
||||
|
||||
if ret == nil then
|
||||
return notify("解析失败或没有传递参数.", "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("字幕同步.", nil, 2)
|
||||
mp.set_property("sub-delay", 0)
|
||||
if config.unload_old_sub then
|
||||
mp.commandv("sub_remove", old_sid)
|
||||
end
|
||||
else
|
||||
notify("错误: 不能添加同步字幕.", "error", 3)
|
||||
end
|
||||
else
|
||||
notify(string.format("字幕同步失败.\n请确保在脚本配置文件中为 %s 指定了正确的路径.\n或音轨提取失败", engine_name), "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
|
||||
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("没有手动调整时轴,什么都做不了!", "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("错误: 不支持的格式: %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("手动同步保存,加载 '%s'", s.filename), "info", 7)
|
||||
end
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Menu actions & bindings
|
||||
|
||||
ref_selector = menu:new {
|
||||
items = { '与音频同步', '与其他字幕同步', '保存当前时轴', '退出' },
|
||||
last_choice = 'audio',
|
||||
pos_x = 50,
|
||||
pos_y = 50,
|
||||
rect_width = 400,
|
||||
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', '退出' },
|
||||
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 { }
|
||||
|
||||
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.tracks = {}
|
||||
self.items = {}
|
||||
|
||||
local filename = mp.get_property_native('filename/no-ext')
|
||||
for _, track in ipairs(self.all_sub_tracks) do
|
||||
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
|
||||
|
||||
if not track.selected and supported_format then
|
||||
table.insert(self.tracks, track)
|
||||
table.insert(
|
||||
self.items,
|
||||
string.format(
|
||||
"%s #%s - %s%s%s",
|
||||
(track.external and 'External' or 'Internal'),
|
||||
track['id'],
|
||||
(track.lang or (track.title and
|
||||
esc_for_title(replace(track.title, filename, '')) or 'unknown')),
|
||||
(track.codec and '[' .. esc_for_code(track.codec:upper()) .. ']' or ''),
|
||||
(track.selected and ' (active)' or '')
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
if #self.items == 0 then
|
||||
notify("没有找到受支持的字幕轨道.", "warn", 5)
|
||||
return
|
||||
end
|
||||
|
||||
table.insert(self.items, "退出")
|
||||
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] = is_empty(config[config_key]) and find_executable(executable) or config[config_key]
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------------
|
||||
-- Entry point
|
||||
|
||||
init()
|
||||
mp.add_key_binding("n", "autosubsync-menu", function() ref_selector:open() end)
|
||||
@@ -0,0 +1 @@
|
||||
require('autosubsync')
|
||||
@@ -0,0 +1,107 @@
|
||||
------------------------------------------------------------
|
||||
-- Menu visuals
|
||||
|
||||
local mp = require('mp')
|
||||
local assdraw = require('mp.assdraw')
|
||||
local Menu = assdraw.ass_new()
|
||||
|
||||
function Menu:new(o)
|
||||
self.__index = self
|
||||
o = o or {}
|
||||
o.selected = o.selected or 1
|
||||
o.canvas_width = o.canvas_width or 1280
|
||||
o.canvas_height = o.canvas_height or 720
|
||||
o.pos_x = o.pos_x or 0
|
||||
o.pos_y = o.pos_y or 0
|
||||
o.rect_width = o.rect_width or 320
|
||||
o.rect_height = o.rect_height or 40
|
||||
o.active_color = o.active_color or 'ffffff'
|
||||
o.inactive_color = o.inactive_color or 'aaaaaa'
|
||||
o.border_color = o.border_color or '000000'
|
||||
o.text_color = o.text_color or 'ffffff'
|
||||
|
||||
return setmetatable(o, self)
|
||||
end
|
||||
|
||||
function Menu:set_position(x, y)
|
||||
self.pos_x = x
|
||||
self.pos_y = y
|
||||
end
|
||||
|
||||
function Menu:font_size(size)
|
||||
self:append(string.format([[{\fs%s}]], size))
|
||||
end
|
||||
|
||||
function Menu:set_text_color(code)
|
||||
self:append(string.format("{\\1c&H%s%s%s&\\1a&H05&}", code:sub(5, 6), code:sub(3, 4), code:sub(1, 2)))
|
||||
end
|
||||
|
||||
function Menu:set_border_color(code)
|
||||
self:append(string.format("{\\3c&H%s%s%s&}", code:sub(5, 6), code:sub(3, 4), code:sub(1, 2)))
|
||||
end
|
||||
|
||||
function Menu:apply_text_color()
|
||||
self:set_border_color(self.border_color)
|
||||
self:set_text_color(self.text_color)
|
||||
end
|
||||
|
||||
function Menu:apply_rect_color(i)
|
||||
self:set_border_color(self.border_color)
|
||||
if i == self.selected then
|
||||
self:set_text_color(self.active_color)
|
||||
else
|
||||
self:set_text_color(self.inactive_color)
|
||||
end
|
||||
end
|
||||
|
||||
function Menu:draw_text(i)
|
||||
local padding = 5
|
||||
local font_size = 25
|
||||
|
||||
self:new_event()
|
||||
self:pos(self.pos_x + padding, self.pos_y + self.rect_height * (i - 1) + padding)
|
||||
self:font_size(font_size)
|
||||
self:apply_text_color(i)
|
||||
self:append(self.items[i])
|
||||
end
|
||||
|
||||
function Menu:draw_item(i)
|
||||
self:new_event()
|
||||
self:pos(self.pos_x, self.pos_y)
|
||||
self:apply_rect_color(i)
|
||||
self:draw_start()
|
||||
self:rect_cw(0, 0 + (i - 1) * self.rect_height, self.rect_width, i * self.rect_height)
|
||||
self:draw_stop()
|
||||
self:draw_text(i)
|
||||
end
|
||||
|
||||
function Menu:draw()
|
||||
self.text = ''
|
||||
for i, _ in ipairs(self.items) do
|
||||
self:draw_item(i)
|
||||
end
|
||||
|
||||
mp.set_osd_ass(self.canvas_width, self.canvas_height, self.text)
|
||||
end
|
||||
|
||||
function Menu:erase()
|
||||
mp.set_osd_ass(self.canvas_width, self.canvas_height, '')
|
||||
end
|
||||
|
||||
function Menu:up()
|
||||
self.selected = self.selected - 1
|
||||
if self.selected == 0 then
|
||||
self.selected = #self.items
|
||||
end
|
||||
self:draw()
|
||||
end
|
||||
|
||||
function Menu:down()
|
||||
self.selected = self.selected + 1
|
||||
if self.selected > #self.items then
|
||||
self.selected = 1
|
||||
end
|
||||
self:draw()
|
||||
end
|
||||
|
||||
return Menu
|
||||
@@ -0,0 +1,276 @@
|
||||
local P = {}
|
||||
|
||||
local TimeStamp = {}
|
||||
local TimeStamp_mt = { __index = TimeStamp }
|
||||
function TimeStamp:new(hours, minutes, seconds)
|
||||
local new = {}
|
||||
new.hours = hours
|
||||
new.minutes = minutes
|
||||
new.seconds = seconds
|
||||
return setmetatable(new, TimeStamp_mt)
|
||||
end
|
||||
|
||||
function TimeStamp.toTimeStamp(seconds)
|
||||
local diff, h, m, s = seconds, 0, 0, 0
|
||||
h = math.floor(diff / 3600)
|
||||
diff = diff - (h * 3600)
|
||||
m = math.floor(diff / 60)
|
||||
diff = diff - (m * 60)
|
||||
s = diff
|
||||
return TimeStamp:new(h, m, s)
|
||||
end
|
||||
|
||||
function TimeStamp:toSeconds()
|
||||
return (3600 * self.hours) + (60 * self.minutes) + self.seconds
|
||||
end
|
||||
|
||||
function TimeStamp:adjustTime(seconds)
|
||||
return self.toTimeStamp(self:toSeconds() + seconds)
|
||||
end
|
||||
|
||||
function TimeStamp:toString(decimal_symbol)
|
||||
local seconds_fmt = string.format("%06.3f", self.seconds):gsub("%.", decimal_symbol)
|
||||
return string.format("%02d:%02d:%s", self.hours, self.minutes, seconds_fmt)
|
||||
end
|
||||
|
||||
function TimeStamp.to_seconds(seconds, milliseconds)
|
||||
return tonumber(string.format("%s.%s", seconds, milliseconds))
|
||||
end
|
||||
|
||||
local AbstractSubtitle = {}
|
||||
local AbstractSubtitle_mt = { __index = AbstractSubtitle }
|
||||
|
||||
function AbstractSubtitle:create()
|
||||
local new = {}
|
||||
return setmetatable(new, AbstractSubtitle_mt)
|
||||
end
|
||||
|
||||
function AbstractSubtitle:save()
|
||||
print(string.format("Writing '%s' to file..", self.filename))
|
||||
local f = io.open(self.filename, 'w')
|
||||
f:write(self:toString())
|
||||
f:close()
|
||||
end
|
||||
|
||||
-- strip Byte Order Mark from file, if it's present
|
||||
function AbstractSubtitle:sanitize(line)
|
||||
local bom_table = { 0xEF, 0xBB, 0xBF } -- TODO maybe add other ones (like UTF-16)
|
||||
local function has_bom()
|
||||
for i = 1, #bom_table do
|
||||
if i > #line then return false end
|
||||
local ch, byte = line:sub(i, i), line:byte(i, i)
|
||||
if byte ~= bom_table[i] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
return has_bom() and string.sub(line, #bom_table + 1) or line
|
||||
end
|
||||
|
||||
local function trim(s)
|
||||
return s:match "^%s*(.-)%s*$"
|
||||
end
|
||||
|
||||
function AbstractSubtitle:parse_file(filename)
|
||||
local lines = {}
|
||||
for line in io.lines(filename) do
|
||||
if #lines == 0 then line = self:sanitize(line) end
|
||||
line = line:gsub('\r\n?', '') -- make sure there's no carriage return
|
||||
line = trim(line)
|
||||
table.insert(lines, line)
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
function AbstractSubtitle:shift_timing(diff_seconds)
|
||||
for _, entry in pairs(self.entries) do
|
||||
if self.valid_entry(entry) then
|
||||
entry.start_time = entry.start_time:adjustTime(diff_seconds)
|
||||
entry.end_time = entry.end_time:adjustTime(diff_seconds)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function AbstractSubtitle.valid_entry(entry)
|
||||
return entry ~= nil
|
||||
end
|
||||
|
||||
local function inheritsFrom (baseClass)
|
||||
local new_class = {}
|
||||
local class_mt = { __index = new_class }
|
||||
|
||||
function new_class:create(filename)
|
||||
local instance = {
|
||||
filename = filename,
|
||||
language = nil,
|
||||
header = nil, -- will be empty for srt, some stuff for ass
|
||||
entries = {} -- list of entries
|
||||
}
|
||||
setmetatable(instance, class_mt)
|
||||
return instance
|
||||
end
|
||||
|
||||
if baseClass then
|
||||
setmetatable(new_class, { __index = baseClass })
|
||||
end
|
||||
return new_class
|
||||
end
|
||||
|
||||
local SRT = inheritsFrom(AbstractSubtitle)
|
||||
function SRT.entry()
|
||||
return { index = nil, start_time = nil, end_time = nil, text = {} }
|
||||
end
|
||||
|
||||
function SRT:populate(filename)
|
||||
local timestamp_fmt = "^(%d+):(%d+):(%d+),(%d+) %-%-> (%d+):(%d+):(%d+),(%d+)$"
|
||||
local function parse_timestamp(timestamp)
|
||||
local function to_seconds(seconds, milliseconds)
|
||||
return tonumber(string.format("%s.%s", seconds, milliseconds))
|
||||
end
|
||||
local _, _, from_h, from_m, from_s, from_ms, to_h, to_m, to_s, to_ms = timestamp:find(timestamp_fmt)
|
||||
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))
|
||||
end
|
||||
|
||||
local new = self:create(filename)
|
||||
local entry = self.entry()
|
||||
local f_idx, idx = 1, 1
|
||||
for _, line in pairs(self:parse_file(filename)) do
|
||||
if idx == 1 and #line > 0 then
|
||||
assert(line:match("^%d+$"), string.format("SRT FORMAT ERROR (line %d): expected a number but got '%s'", f_idx, line))
|
||||
entry.index = line
|
||||
elseif idx == 2 then
|
||||
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))
|
||||
local t_start, t_end = parse_timestamp(line)
|
||||
entry.start_time, entry.end_time = t_start, t_end
|
||||
else
|
||||
if #line == 0 then
|
||||
-- end of text
|
||||
if entry.index ~= nil then
|
||||
table.insert(new.entries, entry)
|
||||
end
|
||||
entry = SRT.entry()
|
||||
idx = 0
|
||||
else
|
||||
table.insert(entry.text, line)
|
||||
end
|
||||
end
|
||||
idx = idx + 1
|
||||
f_idx = f_idx + 1
|
||||
end
|
||||
return new
|
||||
end
|
||||
|
||||
function SRT:toString()
|
||||
local stringbuilder = {}
|
||||
local function append(s)
|
||||
table.insert(stringbuilder, s)
|
||||
end
|
||||
for _, entry in pairs(self.entries) do
|
||||
append(entry.index)
|
||||
local timestamp_string = string.format("%s --> %s", entry.start_time:toString(","), entry.end_time:toString(","))
|
||||
append(timestamp_string)
|
||||
if type(entry.text) == 'table' then
|
||||
append(table.concat(entry.text, "\n"))
|
||||
else append(entry.text) end
|
||||
append('')
|
||||
end
|
||||
return table.concat(stringbuilder, '\n')
|
||||
end
|
||||
|
||||
local ASS = inheritsFrom(AbstractSubtitle)
|
||||
ASS.header_mapper = { ["Start"] = "start_time", ["End"] = "end_time" }
|
||||
|
||||
function ASS.valid_entry(entry)
|
||||
return entry['type'] ~= nil
|
||||
end
|
||||
|
||||
function ASS:toString()
|
||||
local stringbuilder = {}
|
||||
local function append(s) table.insert(stringbuilder, s) end
|
||||
append(self.header)
|
||||
append('[Events]')
|
||||
for i = 1, #self.entries do
|
||||
if i == 1 then
|
||||
-- stringbuilder for events header
|
||||
local event_sb = {};
|
||||
for _, v in pairs(self.event_header) do table.insert(event_sb, v) end
|
||||
append(string.format("Format: %s", table.concat(event_sb, ", ")))
|
||||
end
|
||||
local entry = self.entries[i]
|
||||
local entry_sb = {}
|
||||
for _, col in pairs(self.event_header) do
|
||||
local value = entry[col]
|
||||
local timestamp_entry_column = self.header_mapper[col]
|
||||
if timestamp_entry_column then
|
||||
value = entry[timestamp_entry_column]:toString(".")
|
||||
end
|
||||
table.insert(entry_sb, value)
|
||||
end
|
||||
append(string.format("%s: %s", entry['type'], table.concat(entry_sb, ",")))
|
||||
end
|
||||
return table.concat(stringbuilder, '\n')
|
||||
end
|
||||
|
||||
function ASS:populate(filename, language)
|
||||
local header, events, parser = {}, {}, nil
|
||||
for _, line in pairs(self:parse_file(filename)) do
|
||||
local _, _, event = string.find(line, "^%[([^%]]+)%]%s*$")
|
||||
if event then
|
||||
if event == "Events" then
|
||||
parser = function(x) table.insert(events, x) end
|
||||
else
|
||||
parser = function(x) table.insert(header, x) end
|
||||
parser(line)
|
||||
end
|
||||
else
|
||||
parser(line)
|
||||
end
|
||||
end
|
||||
-- create subtitle instance
|
||||
local ev_regex = "^(%a+):%s(.+)$"
|
||||
local function parse_event(header_columns, ev)
|
||||
local function create_timestamp(timestamp_str)
|
||||
local timestamp_fmt = "^(%d+):(%d+):(%d+).(%d+)"
|
||||
local _, _, h, m, s, ms = timestamp_str:find(timestamp_fmt)
|
||||
return TimeStamp:new(h, m, TimeStamp.to_seconds(s, ms))
|
||||
end
|
||||
local new_event = {}
|
||||
local _, _, ev_type, ev_values = string.find(ev, ev_regex)
|
||||
new_event['type'] = ev_type
|
||||
-- skipping last column, since that's the text, which can contain commas
|
||||
local last_idx = 0;
|
||||
for i = 1, #header_columns - 1 do
|
||||
local col = header_columns[i]
|
||||
local idx = string.find(ev_values, ",", last_idx + 1)
|
||||
local val = ev_values:sub(last_idx + 1, idx - 1)
|
||||
local timestamp_entry_column = self.header_mapper[col]
|
||||
if timestamp_entry_column then
|
||||
new_event[timestamp_entry_column] = create_timestamp(val)
|
||||
else
|
||||
new_event[col] = val
|
||||
end
|
||||
last_idx = idx
|
||||
end
|
||||
new_event[header_columns[#header_columns]] = ev_values:sub(last_idx + 1)
|
||||
return new_event
|
||||
end
|
||||
|
||||
local sub = self:create(filename)
|
||||
sub.header = table.concat(header, "\n")
|
||||
sub.language = language
|
||||
-- remove and process first entry in events, which is a header
|
||||
local _, _, colstring = string.find(table.remove(events, 1), "^%a+:%s(.+)$")
|
||||
local columns = {};
|
||||
for i in colstring:gmatch("[^%,%s]+") do table.insert(columns, i) end
|
||||
sub.event_header = columns
|
||||
for _, event in pairs(events) do
|
||||
if #event > 0 then
|
||||
table.insert(sub.entries, parse_event(columns, event))
|
||||
end
|
||||
end
|
||||
return sub
|
||||
end
|
||||
|
||||
P.AbstractSubtitle = AbstractSubtitle
|
||||
P.ASS = ASS
|
||||
P.SRT = SRT
|
||||
return P
|
||||
Reference in New Issue
Block a user