dotfiles

My personal shell configs and stuff
git clone git://git.alex.balgavy.eu/dotfiles.git
Log | Files | Refs | Submodules | README | LICENSE

mpv_thumbnail_script_server-thread-1.lua (22839B)


      1 --[[
      2     Copyright (C) 2017 AMM
      3 
      4     This program is free software: you can redistribute it and/or modify
      5     it under the terms of the GNU General Public License as published by
      6     the Free Software Foundation, either version 3 of the License, or
      7     (at your option) any later version.
      8 
      9     This program is distributed in the hope that it will be useful,
     10     but WITHOUT ANY WARRANTY; without even the implied warranty of
     11     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     12     GNU General Public License for more details.
     13 
     14     You should have received a copy of the GNU General Public License
     15     along with this program.  If not, see <http://www.gnu.org/licenses/>.
     16 ]]--
     17 --[[
     18     mpv_thumbnail_script.lua 0.4.2 - commit b4cf490 (branch master)
     19     https://github.com/TheAMM/mpv_thumbnail_script
     20     Built on 2021-05-01 11:01:10
     21 ]]--
     22 local assdraw = require 'mp.assdraw'
     23 local msg = require 'mp.msg'
     24 local opt = require 'mp.options'
     25 local utils = require 'mp.utils'
     26 
     27 -- Determine platform --
     28 ON_WINDOWS = (package.config:sub(1,1) ~= '/')
     29 
     30 -- Some helper functions needed to parse the options --
     31 function isempty(v) return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) end
     32 
     33 function divmod (a, b)
     34   return math.floor(a / b), a % b
     35 end
     36 
     37 -- Better modulo
     38 function bmod( i, N )
     39   return (i % N + N) % N
     40 end
     41 
     42 function join_paths(...)
     43   local sep = ON_WINDOWS and "\\" or "/"
     44   local result = "";
     45   for i, p in pairs({...}) do
     46     if p ~= "" then
     47       if is_absolute_path(p) then
     48         result = p
     49       else
     50         result = (result ~= "") and (result:gsub("[\\"..sep.."]*$", "") .. sep .. p) or p
     51       end
     52     end
     53   end
     54   return result:gsub("[\\"..sep.."]*$", "")
     55 end
     56 
     57 -- /some/path/file.ext -> /some/path, file.ext
     58 function split_path( path )
     59   local sep = ON_WINDOWS and "\\" or "/"
     60   local first_index, last_index = path:find('^.*' .. sep)
     61 
     62   if last_index == nil then
     63     return "", path
     64   else
     65     local dir = path:sub(0, last_index-1)
     66     local file = path:sub(last_index+1, -1)
     67 
     68     return dir, file
     69   end
     70 end
     71 
     72 function is_absolute_path( path )
     73   local tmp, is_win  = path:gsub("^[A-Z]:\\", "")
     74   local tmp, is_unix = path:gsub("^/", "")
     75   return (is_win > 0) or (is_unix > 0)
     76 end
     77 
     78 function Set(source)
     79   local set = {}
     80   for _, l in ipairs(source) do set[l] = true end
     81   return set
     82 end
     83 
     84 ---------------------------
     85 -- More helper functions --
     86 ---------------------------
     87 
     88 -- Removes all keys from a table, without destroying the reference to it
     89 function clear_table(target)
     90   for key, value in pairs(target) do
     91     target[key] = nil
     92   end
     93 end
     94 function shallow_copy(target)
     95   local copy = {}
     96   for k, v in pairs(target) do
     97     copy[k] = v
     98   end
     99   return copy
    100 end
    101 
    102 -- Rounds to given decimals. eg. round_dec(3.145, 0) => 3
    103 function round_dec(num, idp)
    104   local mult = 10^(idp or 0)
    105   return math.floor(num * mult + 0.5) / mult
    106 end
    107 
    108 function file_exists(name)
    109   local f = io.open(name, "rb")
    110   if f ~= nil then
    111     local ok, err, code = f:read(1)
    112     io.close(f)
    113     return code == nil
    114   else
    115     return false
    116   end
    117 end
    118 
    119 function path_exists(name)
    120   local f = io.open(name, "rb")
    121   if f ~= nil then
    122     io.close(f)
    123     return true
    124   else
    125     return false
    126   end
    127 end
    128 
    129 function create_directories(path)
    130   local cmd
    131   if ON_WINDOWS then
    132     cmd = { args = {"cmd", "/c", "mkdir", path} }
    133   else
    134     cmd = { args = {"mkdir", "-p", path} }
    135   end
    136   utils.subprocess(cmd)
    137 end
    138 
    139 -- Find an executable in PATH or CWD with the given name
    140 function find_executable(name)
    141   local delim = ON_WINDOWS and ";" or ":"
    142 
    143   local pwd = os.getenv("PWD") or utils.getcwd()
    144   local path = os.getenv("PATH")
    145 
    146   local env_path = pwd .. delim .. path -- Check CWD first
    147 
    148   local result, filename
    149   for path_dir in env_path:gmatch("[^"..delim.."]+") do
    150     filename = join_paths(path_dir, name)
    151     if file_exists(filename) then
    152       result = filename
    153       break
    154     end
    155   end
    156 
    157   return result
    158 end
    159 
    160 local ExecutableFinder = { path_cache = {} }
    161 -- Searches for an executable and caches the result if any
    162 function ExecutableFinder:get_executable_path( name, raw_name )
    163   name = ON_WINDOWS and not raw_name and (name .. ".exe") or name
    164 
    165   if self.path_cache[name] == nil then
    166     self.path_cache[name] = find_executable(name) or false
    167   end
    168   return self.path_cache[name]
    169 end
    170 
    171 -- Format seconds to HH.MM.SS.sss
    172 function format_time(seconds, sep, decimals)
    173   decimals = decimals == nil and 3 or decimals
    174   sep = sep and sep or "."
    175   local s = seconds
    176   local h, s = divmod(s, 60*60)
    177   local m, s = divmod(s, 60)
    178 
    179   local second_format = string.format("%%0%d.%df", 2+(decimals > 0 and decimals+1 or 0), decimals)
    180 
    181   return string.format("%02d"..sep.."%02d"..sep..second_format, h, m, s)
    182 end
    183 
    184 -- Format seconds to 1h 2m 3.4s
    185 function format_time_hms(seconds, sep, decimals, force_full)
    186   decimals = decimals == nil and 1 or decimals
    187   sep = sep ~= nil and sep or " "
    188 
    189   local s = seconds
    190   local h, s = divmod(s, 60*60)
    191   local m, s = divmod(s, 60)
    192 
    193   if force_full or h > 0 then
    194     return string.format("%dh"..sep.."%dm"..sep.."%." .. tostring(decimals) .. "fs", h, m, s)
    195   elseif m > 0 then
    196     return string.format("%dm"..sep.."%." .. tostring(decimals) .. "fs", m, s)
    197   else
    198     return string.format("%." .. tostring(decimals) .. "fs", s)
    199   end
    200 end
    201 
    202 -- Writes text on OSD and console
    203 function log_info(txt, timeout)
    204   timeout = timeout or 1.5
    205   msg.info(txt)
    206   mp.osd_message(txt, timeout)
    207 end
    208 
    209 -- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-"
    210 function join_table(source, before, after, sep)
    211   before = before or ""
    212   after = after or ""
    213   sep = sep or ", "
    214   local result = ""
    215   for i, v in pairs(source) do
    216     if not isempty(v) then
    217       local part = before .. v .. after
    218       if i == 1 then
    219         result = part
    220       else
    221         result = result .. sep .. part
    222       end
    223     end
    224   end
    225   return result
    226 end
    227 
    228 function wrap(s, char)
    229   char = char or "'"
    230   return char .. s .. char
    231 end
    232 -- Wraps given string into 'string' and escapes any 's in it
    233 function escape_and_wrap(s, char, replacement)
    234   char = char or "'"
    235   replacement = replacement or "\\" .. char
    236   return wrap(string.gsub(s, char, replacement), char)
    237 end
    238 -- Escapes single quotes in a string and wraps the input in single quotes
    239 function escape_single_bash(s)
    240   return escape_and_wrap(s, "'", "'\\''")
    241 end
    242 
    243 -- Returns (a .. b) if b is not empty or nil
    244 function joined_or_nil(a, b)
    245   return not isempty(b) and (a .. b) or nil
    246 end
    247 
    248 -- Put items from one table into another
    249 function extend_table(target, source)
    250   for i, v in pairs(source) do
    251     table.insert(target, v)
    252   end
    253 end
    254 
    255 -- Creates a handle and filename for a temporary random file (in current directory)
    256 function create_temporary_file(base, mode, suffix)
    257   local handle, filename
    258   suffix = suffix or ""
    259   while true do
    260     filename = base .. tostring(math.random(1, 5000)) .. suffix
    261     handle = io.open(filename, "r")
    262     if not handle then
    263       handle = io.open(filename, mode)
    264       break
    265     end
    266     io.close(handle)
    267   end
    268   return handle, filename
    269 end
    270 
    271 
    272 function get_processor_count()
    273   local proc_count
    274 
    275   if ON_WINDOWS then
    276     proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS"))
    277   else
    278     local cpuinfo_handle = io.open("/proc/cpuinfo")
    279     if cpuinfo_handle ~= nil then
    280       local cpuinfo_contents = cpuinfo_handle:read("*a")
    281       local _, replace_count = cpuinfo_contents:gsub('processor', '')
    282       proc_count = replace_count
    283     end
    284   end
    285 
    286   if proc_count and proc_count > 0 then
    287       return proc_count
    288   else
    289     return nil
    290   end
    291 end
    292 
    293 function substitute_values(string, values)
    294   local substitutor = function(match)
    295     if match == "%" then
    296        return "%"
    297     else
    298       -- nil is discarded by gsub
    299       return values[match]
    300     end
    301   end
    302 
    303   local substituted = string:gsub('%%(.)', substitutor)
    304   return substituted
    305 end
    306 
    307 -- ASS HELPERS --
    308 function round_rect_top( ass, x0, y0, x1, y1, r )
    309   local c = 0.551915024494 * r -- circle approximation
    310   ass:move_to(x0 + r, y0)
    311   ass:line_to(x1 - r, y0) -- top line
    312   if r > 0 then
    313       ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner
    314   end
    315   ass:line_to(x1, y1) -- right line
    316   ass:line_to(x0, y1) -- bottom line
    317   ass:line_to(x0, y0 + r) -- left line
    318   if r > 0 then
    319       ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner
    320   end
    321 end
    322 
    323 function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl)
    324     local c = 0.551915024494
    325     ass:move_to(x0 + rtl, y0)
    326     ass:line_to(x1 - rtr, y0) -- top line
    327     if rtr > 0 then
    328         ass:bezier_curve(x1 - rtr + rtr*c, y0, x1, y0 + rtr - rtr*c, x1, y0 + rtr) -- top right corner
    329     end
    330     ass:line_to(x1, y1 - rbr) -- right line
    331     if rbr > 0 then
    332         ass:bezier_curve(x1, y1 - rbr + rbr*c, x1 - rbr + rbr*c, y1, x1 - rbr, y1) -- bottom right corner
    333     end
    334     ass:line_to(x0 + rbl, y1) -- bottom line
    335     if rbl > 0 then
    336         ass:bezier_curve(x0 + rbl - rbl*c, y1, x0, y1 - rbl + rbl*c, x0, y1 - rbl) -- bottom left corner
    337     end
    338     ass:line_to(x0, y0 + rtl) -- left line
    339     if rtl > 0 then
    340         ass:bezier_curve(x0, y0 + rtl - rtl*c, x0 + rtl - rtl*c, y0, x0 + rtl, y0) -- top left corner
    341     end
    342 end
    343 local SCRIPT_NAME = "mpv_thumbnail_script"
    344 
    345 local default_cache_base = ON_WINDOWS and os.getenv("TEMP") or "/tmp/"
    346 
    347 local thumbnailer_options = {
    348     -- The thumbnail directory
    349     cache_directory = join_paths(default_cache_base, "mpv_thumbs_cache"),
    350 
    351     ------------------------
    352     -- Generation options --
    353     ------------------------
    354 
    355     -- Automatically generate the thumbnails on video load, without a keypress
    356     autogenerate = true,
    357 
    358     -- Only automatically thumbnail videos shorter than this (seconds)
    359     autogenerate_max_duration = 3600, -- 1 hour
    360 
    361     -- SHA1-sum filenames over this length
    362     -- It's nice to know what files the thumbnails are (hence directory names)
    363     -- but long URLs may approach filesystem limits.
    364     hash_filename_length = 128,
    365 
    366     -- Use mpv to generate thumbnail even if ffmpeg is found in PATH
    367     -- ffmpeg does not handle ordered chapters (MKVs which rely on other MKVs)!
    368     -- mpv is a bit slower, but has better support overall (eg. subtitles in the previews)
    369     prefer_mpv = true,
    370 
    371     -- Explicitly disable subtitles on the mpv sub-calls
    372     mpv_no_sub = false,
    373     -- Add a "--no-config" to the mpv sub-call arguments
    374     mpv_no_config = false,
    375     -- Add a "--profile=<mpv_profile>" to the mpv sub-call arguments
    376     -- Use "" to disable
    377     mpv_profile = "",
    378     -- Output debug logs to <thumbnail_path>.log, ala <cache_directory>/<video_filename>/000000.bgra.log
    379     -- The logs are removed after successful encodes, unless you set mpv_keep_logs below
    380     mpv_logs = true,
    381     -- Keep all mpv logs, even the succesfull ones
    382     mpv_keep_logs = false,
    383 
    384     -- Disable the built-in keybind ("T") to add your own
    385     disable_keybinds = false,
    386 
    387     ---------------------
    388     -- Display options --
    389     ---------------------
    390 
    391     -- Move the thumbnail up or down
    392     -- For example:
    393     --   topbar/bottombar: 24
    394     --   rest: 0
    395     vertical_offset = 24,
    396 
    397     -- Adjust background padding
    398     -- Examples:
    399     --   topbar:       0, 10, 10, 10
    400     --   bottombar:   10,  0, 10, 10
    401     --   slimbox/box: 10, 10, 10, 10
    402     pad_top   = 10,
    403     pad_bot   =  0,
    404     pad_left  = 10,
    405     pad_right = 10,
    406 
    407     -- If true, pad values are screen-pixels. If false, video-pixels.
    408     pad_in_screenspace = true,
    409     -- Calculate pad into the offset
    410     offset_by_pad = true,
    411 
    412     -- Background color in BBGGRR
    413     background_color = "000000",
    414     -- Alpha: 0 - fully opaque, 255 - transparent
    415     background_alpha = 80,
    416 
    417     -- Keep thumbnail on the screen near left or right side
    418     constrain_to_screen = true,
    419 
    420     -- Do not display the thumbnailing progress
    421     hide_progress = false,
    422 
    423     -----------------------
    424     -- Thumbnail options --
    425     -----------------------
    426 
    427     -- The maximum dimensions of the thumbnails (pixels)
    428     thumbnail_width = 200,
    429     thumbnail_height = 200,
    430 
    431     -- The thumbnail count target
    432     -- (This will result in a thumbnail every ~10 seconds for a 25 minute video)
    433     thumbnail_count = 150,
    434 
    435     -- The above target count will be adjusted by the minimum and
    436     -- maximum time difference between thumbnails.
    437     -- The thumbnail_count will be used to calculate a target separation,
    438     -- and min/max_delta will be used to constrict it.
    439 
    440     -- In other words, thumbnails will be:
    441     --   at least min_delta seconds apart (limiting the amount)
    442     --   at most max_delta seconds apart (raising the amount if needed)
    443     min_delta = 5,
    444     -- 120 seconds aka 2 minutes will add more thumbnails when the video is over 5 hours!
    445     max_delta = 90,
    446 
    447 
    448     -- Overrides for remote urls (you generally want less thumbnails!)
    449     -- Thumbnailing network paths will be done with mpv
    450 
    451     -- Allow thumbnailing network paths (naive check for "://")
    452     thumbnail_network = false,
    453     -- Override thumbnail count, min/max delta
    454     remote_thumbnail_count = 60,
    455     remote_min_delta = 15,
    456     remote_max_delta = 120,
    457 
    458     -- Try to grab the raw stream and disable ytdl for the mpv subcalls
    459     -- Much faster than passing the url to ytdl again, but may cause problems with some sites
    460     remote_direct_stream = true,
    461 }
    462 
    463 read_options(thumbnailer_options, SCRIPT_NAME)
    464 function skip_nil(tbl)
    465     local n = {}
    466     for k, v in pairs(tbl) do
    467         table.insert(n, v)
    468     end
    469     return n
    470 end
    471 
    472 function create_thumbnail_mpv(file_path, timestamp, size, output_path, options)
    473     options = options or {}
    474 
    475     local ytdl_disabled = not options.enable_ytdl and (mp.get_property_native("ytdl") == false
    476                                                        or thumbnailer_options.remote_direct_stream)
    477 
    478     local header_fields_arg = nil
    479     local header_fields = mp.get_property_native("http-header-fields")
    480     if #header_fields > 0 then
    481         -- We can't escape the headers, mpv won't parse "--http-header-fields='Name: value'" properly
    482         header_fields_arg = "--http-header-fields=" .. table.concat(header_fields, ",")
    483     end
    484 
    485     local profile_arg = nil
    486     if thumbnailer_options.mpv_profile ~= "" then
    487         profile_arg = "--profile=" .. thumbnailer_options.mpv_profile
    488     end
    489 
    490     local log_arg = "--log-file=" .. output_path .. ".log"
    491 
    492     local mpv_command = skip_nil({
    493         "mpv",
    494         -- Hide console output
    495         "--msg-level=all=no",
    496 
    497         -- Disable ytdl
    498         (ytdl_disabled and "--no-ytdl" or nil),
    499         -- Pass HTTP headers from current instance
    500         header_fields_arg,
    501         -- Pass User-Agent and Referer - should do no harm even with ytdl active
    502         "--user-agent=" .. mp.get_property_native("user-agent"),
    503         "--referrer=" .. mp.get_property_native("referrer"),
    504         -- Disable hardware decoding
    505         "--hwdec=no",
    506 
    507         -- Insert --no-config, --profile=... and --log-file if enabled
    508         (thumbnailer_options.mpv_no_config and "--no-config" or nil),
    509         profile_arg,
    510         (thumbnailer_options.mpv_logs and log_arg or nil),
    511 
    512         file_path,
    513 
    514         "--start=" .. tostring(timestamp),
    515         "--frames=1",
    516         "--hr-seek=yes",
    517         "--no-audio",
    518         -- Optionally disable subtitles
    519         (thumbnailer_options.mpv_no_sub and "--no-sub" or nil),
    520 
    521         ("--vf=scale=%d:%d"):format(size.w, size.h),
    522         "--vf-add=format=bgra",
    523         "--of=rawvideo",
    524         "--ovc=rawvideo",
    525         "--o=" .. output_path
    526     })
    527     return utils.subprocess({args=mpv_command})
    528 end
    529 
    530 
    531 function create_thumbnail_ffmpeg(file_path, timestamp, size, output_path)
    532     local ffmpeg_command = {
    533         "ffmpeg",
    534         "-loglevel", "quiet",
    535         "-noaccurate_seek",
    536         "-ss", format_time(timestamp, ":"),
    537         "-i", file_path,
    538 
    539         "-frames:v", "1",
    540         "-an",
    541 
    542         "-vf", ("scale=%d:%d"):format(size.w, size.h),
    543         "-c:v", "rawvideo",
    544         "-pix_fmt", "bgra",
    545         "-f", "rawvideo",
    546 
    547         "-y", output_path
    548     }
    549     return utils.subprocess({args=ffmpeg_command})
    550 end
    551 
    552 
    553 function check_output(ret, output_path, is_mpv)
    554     local log_path = output_path .. ".log"
    555     local success = true
    556 
    557     if ret.killed_by_us then
    558         return nil
    559     else
    560         if ret.error or ret.status ~= 0 then
    561             msg.error("Thumbnailing command failed!")
    562             msg.error("mpv process error:", ret.error)
    563             msg.error("Process stdout:", ret.stdout)
    564             if is_mpv then
    565                 msg.error("Debug log:", log_path)
    566             end
    567 
    568             success = false
    569         end
    570 
    571         if not file_exists(output_path) then
    572             msg.error("Output file missing!", output_path)
    573             success = false
    574         end
    575     end
    576 
    577     if is_mpv and not thumbnailer_options.mpv_keep_logs then
    578         -- Remove successful debug logs
    579         if success and file_exists(log_path) then
    580             os.remove(log_path)
    581         end
    582     end
    583 
    584     return success
    585 end
    586 
    587 
    588 function do_worker_job(state_json_string, frames_json_string)
    589     msg.debug("Handling given job")
    590     local thumb_state, err = utils.parse_json(state_json_string)
    591     if err then
    592         msg.error("Failed to parse state JSON")
    593         return
    594     end
    595 
    596     local thumbnail_indexes, err = utils.parse_json(frames_json_string)
    597     if err then
    598         msg.error("Failed to parse thumbnail frame indexes")
    599         return
    600     end
    601 
    602     local thumbnail_func = create_thumbnail_mpv
    603     if not thumbnailer_options.prefer_mpv then
    604         if ExecutableFinder:get_executable_path("ffmpeg") then
    605             thumbnail_func = create_thumbnail_ffmpeg
    606         else
    607             msg.warn("Could not find ffmpeg in PATH! Falling back on mpv.")
    608         end
    609     end
    610 
    611     local file_duration = mp.get_property_native("duration")
    612     local file_path = thumb_state.worker_input_path
    613 
    614     if thumb_state.is_remote then
    615         if (thumbnail_func == create_thumbnail_ffmpeg) then
    616             msg.warn("Thumbnailing remote path, falling back on mpv.")
    617         end
    618         thumbnail_func = create_thumbnail_mpv
    619     end
    620 
    621     local generate_thumbnail_for_index = function(thumbnail_index)
    622         -- Given a 1-based thumbnail index, generate a thumbnail for it based on the thumbnailer state
    623         local thumb_idx = thumbnail_index - 1
    624         msg.debug("Starting work on thumbnail", thumb_idx)
    625 
    626         local thumbnail_path = thumb_state.thumbnail_template:format(thumb_idx)
    627         -- Grab the "middle" of the thumbnail duration instead of the very start, and leave some margin in the end
    628         local timestamp = math.min(file_duration - 0.25, (thumb_idx + 0.5) * thumb_state.thumbnail_delta)
    629 
    630         mp.commandv("script-message", "mpv_thumbnail_script-progress", tostring(thumbnail_index))
    631 
    632         -- The expected size (raw BGRA image)
    633         local thumbnail_raw_size = (thumb_state.thumbnail_size.w * thumb_state.thumbnail_size.h * 4)
    634 
    635         local need_thumbnail_generation = false
    636 
    637         -- Check if the thumbnail already exists and is the correct size
    638         local thumbnail_file = io.open(thumbnail_path, "rb")
    639         if thumbnail_file == nil then
    640             need_thumbnail_generation = true
    641         else
    642             local existing_thumbnail_filesize = thumbnail_file:seek("end")
    643             if existing_thumbnail_filesize ~= thumbnail_raw_size then
    644                 -- Size doesn't match, so (re)generate
    645                 msg.warn("Thumbnail", thumb_idx, "did not match expected size, regenerating")
    646                 need_thumbnail_generation = true
    647             end
    648             thumbnail_file:close()
    649         end
    650 
    651         if need_thumbnail_generation then
    652             local ret = thumbnail_func(file_path, timestamp, thumb_state.thumbnail_size, thumbnail_path, thumb_state.worker_extra)
    653             local success = check_output(ret, thumbnail_path, thumbnail_func == create_thumbnail_mpv)
    654 
    655             if success == nil then
    656                 -- Killed by us, changing files, ignore
    657                 msg.debug("Changing files, subprocess killed")
    658                 return true
    659             elseif not success then
    660                 -- Real failure
    661                 mp.osd_message("Thumbnailing failed, check console for details", 3.5)
    662                 return true
    663             end
    664         else
    665             msg.debug("Thumbnail", thumb_idx, "already done!")
    666         end
    667 
    668         -- Verify thumbnail size
    669         -- Sometimes ffmpeg will output an empty file when seeking to a "bad" section (usually the end)
    670         thumbnail_file = io.open(thumbnail_path, "rb")
    671 
    672         -- Bail if we can't read the file (it should really exist by now, we checked this in check_output!)
    673         if thumbnail_file == nil then
    674             msg.error("Thumbnail suddenly disappeared!")
    675             return true
    676         end
    677 
    678         -- Check the size of the generated file
    679         local thumbnail_file_size = thumbnail_file:seek("end")
    680         thumbnail_file:close()
    681 
    682         -- Check if the file is big enough
    683         local missing_bytes = math.max(0, thumbnail_raw_size - thumbnail_file_size)
    684         if missing_bytes > 0 then
    685             msg.warn(("Thumbnail missing %d bytes (expected %d, had %d), padding %s"):format(
    686               missing_bytes, thumbnail_raw_size, thumbnail_file_size, thumbnail_path
    687             ))
    688             -- Pad the file if it's missing content (eg. ffmpeg seek to file end)
    689             thumbnail_file = io.open(thumbnail_path, "ab")
    690             thumbnail_file:write(string.rep(string.char(0), missing_bytes))
    691             thumbnail_file:close()
    692         end
    693 
    694         msg.debug("Finished work on thumbnail", thumb_idx)
    695         mp.commandv("script-message", "mpv_thumbnail_script-ready", tostring(thumbnail_index), thumbnail_path)
    696     end
    697 
    698     msg.debug(("Generating %d thumbnails @ %dx%d for %q"):format(
    699         #thumbnail_indexes,
    700         thumb_state.thumbnail_size.w,
    701         thumb_state.thumbnail_size.h,
    702         file_path))
    703 
    704     for i, thumbnail_index in ipairs(thumbnail_indexes) do
    705         local bail = generate_thumbnail_for_index(thumbnail_index)
    706         if bail then return end
    707     end
    708 
    709 end
    710 
    711 -- Set up listeners and keybinds
    712 
    713 -- Job listener
    714 mp.register_script_message("mpv_thumbnail_script-job", do_worker_job)
    715 
    716 
    717 -- Register this worker with the master script
    718 local register_timer = nil
    719 local register_timeout = mp.get_time() + 1.5
    720 
    721 local register_function = function()
    722     if mp.get_time() > register_timeout and register_timer then
    723         msg.error("Thumbnail worker registering timed out")
    724         register_timer:stop()
    725     else
    726         msg.debug("Announcing self to master...")
    727         mp.commandv("script-message", "mpv_thumbnail_script-worker", mp.get_script_name())
    728     end
    729 end
    730 
    731 register_timer = mp.add_periodic_timer(0.1, register_function)
    732 
    733 mp.register_script_message("mpv_thumbnail_script-slaved", function()
    734     msg.debug("Successfully registered with master")
    735     register_timer:stop()
    736 end)