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)