This commit is contained in:
2026-03-27 07:06:16 +01:00
commit 1541961403
340 changed files with 151916 additions and 0 deletions
+21
View File
@@ -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.
+130
View File
@@ -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.
+559
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
require('autosubsync')
+107
View File
@@ -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
+276
View File
@@ -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