515 lines
16 KiB
Lua
515 lines
16 KiB
Lua
-- thumbfast.lua
|
|
--
|
|
-- High-performance on-the-fly thumbnailer
|
|
--
|
|
-- Built for easy integration in third-party UIs.
|
|
|
|
local options = {
|
|
-- Socket path (leave empty for auto)
|
|
socket = "",
|
|
|
|
-- Thumbnail path (leave empty for auto)
|
|
thumbnail = "",
|
|
|
|
-- Maximum thumbnail size in pixels (scaled down to fit)
|
|
-- Values are scaled when hidpi is enabled
|
|
max_height = 200,
|
|
max_width = 200,
|
|
|
|
-- Overlay id
|
|
overlay_id = 42,
|
|
|
|
-- Thumbnail interval in seconds, set to 0 to disable (warning: high cpu usage)
|
|
-- Clamped to min_thumbnails and max_thumbnails
|
|
interval = 6,
|
|
|
|
-- Number of thumbnails
|
|
min_thumbnails = 6,
|
|
max_thumbnails = 120,
|
|
|
|
-- Spawn thumbnailer on file load for faster initial thumbnails
|
|
spawn_first = false,
|
|
|
|
-- Enable on network playback
|
|
network = false,
|
|
|
|
-- Enable on audio playback
|
|
audio = false
|
|
}
|
|
|
|
mp.utils = require "mp.utils"
|
|
mp.options = require "mp.options"
|
|
mp.options.read_options(options, "thumbfast")
|
|
|
|
if options.min_thumbnails < 1 then
|
|
options.min_thumbnails = 1
|
|
end
|
|
|
|
local os_name = ""
|
|
|
|
math.randomseed(os.time())
|
|
local unique = math.random(10000000)
|
|
local init = false
|
|
|
|
local spawned = false
|
|
local can_generate = true
|
|
local network = false
|
|
local disabled = false
|
|
local interval = 0
|
|
|
|
local x = nil
|
|
local y = nil
|
|
local last_x = x
|
|
local last_y = y
|
|
|
|
local last_index = nil
|
|
local last_request = nil
|
|
local last_request_time = nil
|
|
local last_display_time = 0
|
|
|
|
local effective_w = options.max_width
|
|
local effective_h = options.max_height
|
|
local thumb_size = effective_w * effective_h * 4
|
|
|
|
local filters_reset = {["lavfi-crop"]=true, crop=true}
|
|
local filters_runtime = {hflip=true, vflip=true}
|
|
local filters_all = filters_runtime
|
|
for k,v in pairs(filters_reset) do filters_all[k] = v end
|
|
|
|
local last_vf_reset = ""
|
|
local last_vf_runtime = ""
|
|
|
|
local last_rotate = 0
|
|
|
|
local par = ""
|
|
local last_par = ""
|
|
|
|
local function get_os()
|
|
local raw_os_name = ""
|
|
|
|
if jit and jit.os and jit.arch then
|
|
raw_os_name = jit.os
|
|
else
|
|
if package.config:sub(1,1) == "\\" then
|
|
-- Windows
|
|
local env_OS = os.getenv("OS")
|
|
if env_OS then
|
|
raw_os_name = env_OS
|
|
end
|
|
else
|
|
raw_os_name = mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = {"uname", "-s"}}).stdout
|
|
end
|
|
end
|
|
|
|
raw_os_name = (raw_os_name):lower()
|
|
|
|
local os_patterns = {
|
|
["windows"] = "Windows",
|
|
|
|
-- Uses socat
|
|
["linux"] = "Linux",
|
|
|
|
["osx"] = "Mac",
|
|
["mac"] = "Mac",
|
|
["darwin"] = "Mac",
|
|
|
|
["^mingw"] = "Windows",
|
|
["^cygwin"] = "Windows",
|
|
|
|
-- Because they have the good netcat (with -U)
|
|
["bsd$"] = "Mac",
|
|
["sunos"] = "Mac"
|
|
}
|
|
|
|
-- Default to linux
|
|
local str_os_name = "Linux"
|
|
|
|
for pattern, name in pairs(os_patterns) do
|
|
if raw_os_name:match(pattern) then
|
|
str_os_name = name
|
|
break
|
|
end
|
|
end
|
|
|
|
return str_os_name
|
|
end
|
|
|
|
local function vf_string(filters, full)
|
|
local vf = ""
|
|
local vf_table = mp.get_property_native("vf")
|
|
|
|
if #vf_table > 0 then
|
|
for i = #vf_table, 1, -1 do
|
|
if filters[vf_table[i].name] then
|
|
local args = ""
|
|
for key, value in pairs(vf_table[i].params) do
|
|
if args ~= "" then
|
|
args = args .. ":"
|
|
end
|
|
args = args .. key .. "=" .. value
|
|
end
|
|
vf = vf .. vf_table[i].name .. "=" .. args .. ","
|
|
end
|
|
end
|
|
end
|
|
|
|
if full then
|
|
vf = vf.."scale=w="..effective_w..":h="..effective_h..par..",pad=w="..effective_w..":h="..effective_h..":x=(ow-iw)/2:y=(oh-ih)/2,format=bgra"
|
|
end
|
|
|
|
return vf
|
|
end
|
|
|
|
local function calc_dimensions()
|
|
local width = mp.get_property_number("video-out-params/dw")
|
|
local height = mp.get_property_number("video-out-params/dh")
|
|
if not width or not height then return end
|
|
|
|
local scale = mp.get_property_number("display-hidpi-scale", 1)
|
|
|
|
if width / height > options.max_width / options.max_height then
|
|
effective_w = math.floor(options.max_width * scale + 0.5)
|
|
effective_h = math.floor(height / width * effective_w + 0.5)
|
|
else
|
|
effective_h = math.floor(options.max_height * scale + 0.5)
|
|
effective_w = math.floor(width / height * effective_h + 0.5)
|
|
end
|
|
|
|
thumb_size = effective_w * effective_h * 4
|
|
|
|
local v_par = mp.get_property_number("video-out-params/par", 1)
|
|
if v_par == 1 then
|
|
par = ":force_original_aspect_ratio=decrease"
|
|
else
|
|
par = ""
|
|
end
|
|
end
|
|
|
|
local function info()
|
|
local display_w, display_h = effective_w, effective_h
|
|
if mp.get_property_number("video-params/rotate", 0) % 180 == 90 then
|
|
display_w, display_h = effective_h, effective_w
|
|
end
|
|
|
|
local json, err = mp.utils.format_json({width=display_w, height=display_h, disabled=disabled, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id})
|
|
mp.commandv("script-message", "thumbfast-info", json)
|
|
end
|
|
|
|
local function remove_thumbnail_files()
|
|
os.remove(options.thumbnail)
|
|
os.remove(options.thumbnail..".bgra")
|
|
end
|
|
|
|
local function spawn(time)
|
|
if disabled then return end
|
|
|
|
local path = mp.get_property("path")
|
|
if path == nil then return end
|
|
|
|
spawned = true
|
|
|
|
local open_filename = mp.get_property("stream-open-filename")
|
|
local ytdl = open_filename and network and path ~= open_filename
|
|
if ytdl then
|
|
path = open_filename
|
|
end
|
|
|
|
if os_name == "" then
|
|
os_name = get_os()
|
|
end
|
|
|
|
if options.socket == "" then
|
|
if os_name == "Windows" then
|
|
options.socket = "thumbfast"
|
|
elseif os_name == "Mac" then
|
|
options.socket = "/tmp/thumbfast"
|
|
else
|
|
options.socket = "/tmp/thumbfast"
|
|
end
|
|
end
|
|
|
|
if options.thumbnail == "" then
|
|
if os_name == "Windows" then
|
|
options.thumbnail = os.getenv("TEMP").."\\thumbfast.out"
|
|
elseif os_name == "Mac" then
|
|
options.thumbnail = "/tmp/thumbfast.out"
|
|
else
|
|
options.thumbnail = "/tmp/thumbfast.out"
|
|
end
|
|
end
|
|
|
|
if not init then
|
|
-- ensure uniqueness
|
|
options.socket = options.socket .. unique
|
|
options.thumbnail = options.thumbnail .. unique
|
|
init = true
|
|
end
|
|
|
|
remove_thumbnail_files()
|
|
|
|
calc_dimensions()
|
|
|
|
info()
|
|
|
|
mp.command_native_async(
|
|
{name = "subprocess", playback_only = true, args = {
|
|
"mpv", path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always",
|
|
"--edition="..(mp.get_property_number("edition") or "auto"), "--vid="..(mp.get_property_number("vid") or "auto"), "--no-sub", "--no-audio",
|
|
"--input-ipc-server="..options.socket,
|
|
"--start="..time, "--hr-seek=no",
|
|
"--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB",
|
|
"--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast",
|
|
"--vf="..vf_string(filters_all, true),
|
|
"--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear",
|
|
"--video-rotate="..last_rotate,
|
|
"--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail
|
|
}},
|
|
function() end
|
|
)
|
|
end
|
|
|
|
local function run(command, callback)
|
|
if not spawned then return end
|
|
|
|
callback = callback or function() end
|
|
|
|
local seek_command
|
|
if os_name == "Windows" then
|
|
seek_command = {"cmd", "/c", "echo "..command.." > \\\\.\\pipe\\" .. options.socket}
|
|
elseif os_name == "Mac" then
|
|
-- this doesn't work, on my system. not sure why.
|
|
seek_command = {"/usr/bin/env", "sh", "-c", "echo '"..command.."' | nc -w0 -U " .. options.socket}
|
|
else
|
|
seek_command = {"/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket}
|
|
end
|
|
|
|
mp.command_native_async(
|
|
{name = "subprocess", playback_only = true, capture_stdout = true, args = seek_command},
|
|
callback
|
|
)
|
|
end
|
|
|
|
local function thumb_index(thumbtime)
|
|
return math.floor(thumbtime / interval)
|
|
end
|
|
|
|
local function index_time(index, thumbtime)
|
|
if interval > 0 then
|
|
local time = index * interval
|
|
return time + interval / 3
|
|
else
|
|
return thumbtime
|
|
end
|
|
end
|
|
|
|
local function draw(w, h, thumbtime, display_time, script)
|
|
local display_w, display_h = w, h
|
|
if mp.get_property_number("video-params/rotate", 0) % 180 == 90 then
|
|
display_w, display_h = h, w
|
|
end
|
|
|
|
if x ~= nil then
|
|
mp.command_native(
|
|
{name = "overlay-add", id=options.overlay_id, x=x, y=y, file=options.thumbnail..".bgra", offset=0, fmt="bgra", w=display_w, h=display_h, stride=(4*display_w)}
|
|
)
|
|
elseif script then
|
|
local json, err = mp.utils.format_json({width=display_w, height=display_h, x=x, y=y, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id})
|
|
mp.commandv("script-message-to", script, "thumbfast-render", json)
|
|
end
|
|
end
|
|
|
|
local function display_img(w, h, thumbtime, display_time, script, redraw)
|
|
if last_display_time > display_time or disabled then return end
|
|
|
|
if not redraw then
|
|
can_generate = false
|
|
|
|
local info = mp.utils.file_info(options.thumbnail)
|
|
if not info or info.size ~= thumb_size then
|
|
if thumbtime == -1 then
|
|
can_generate = true
|
|
return
|
|
end
|
|
|
|
if thumbtime < 0 then
|
|
thumbtime = thumbtime + 1
|
|
end
|
|
|
|
-- display last successful thumbnail if one exists
|
|
local info2 = mp.utils.file_info(options.thumbnail..".bgra")
|
|
if info2 and info2.size == thumb_size then
|
|
draw(w, h, thumbtime, display_time, script)
|
|
end
|
|
|
|
-- retry up to 5 times
|
|
return mp.add_timeout(0.05, function() display_img(w, h, thumbtime < 0 and thumbtime or -5, display_time, script) end)
|
|
end
|
|
|
|
if last_display_time > display_time then return end
|
|
|
|
-- os.rename can't replace files on windows
|
|
if os_name == "Windows" then
|
|
os.remove(options.thumbnail..".bgra")
|
|
end
|
|
-- move the file because it can get overwritten while overlay-add is reading it, and crash the player
|
|
os.rename(options.thumbnail, options.thumbnail..".bgra")
|
|
|
|
last_display_time = display_time
|
|
else
|
|
local info = mp.utils.file_info(options.thumbnail..".bgra")
|
|
if not info or info.size ~= thumb_size then
|
|
-- still waiting on intial thumbnail
|
|
return mp.add_timeout(0.05, function() display_img(w, h, thumbtime, display_time, script) end)
|
|
end
|
|
if not can_generate then
|
|
return draw(w, h, thumbtime, display_time, script)
|
|
end
|
|
end
|
|
|
|
draw(w, h, thumbtime, display_time, script)
|
|
|
|
can_generate = true
|
|
|
|
if not redraw then
|
|
-- often, the file we read will be the last requested thumbnail
|
|
-- retry after a small delay to ensure we got the latest image
|
|
if thumbtime ~= -1 then
|
|
mp.add_timeout(0.05, function() display_img(w, h, -1, display_time, script) end)
|
|
mp.add_timeout(0.1, function() display_img(w, h, -1, display_time, script) end)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function thumb(time, r_x, r_y, script)
|
|
if disabled then return end
|
|
|
|
time = tonumber(time)
|
|
if time == nil then return end
|
|
|
|
if r_x == nil or r_y == nil then
|
|
x, y = nil, nil
|
|
else
|
|
x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5)
|
|
end
|
|
|
|
local index = thumb_index(time)
|
|
local seek_time = index_time(index, time)
|
|
|
|
if last_request == seek_time or (interval > 0 and index == last_index) then
|
|
last_index = index
|
|
if x ~= last_x or y ~= last_y then
|
|
last_x, last_y = x, y
|
|
display_img(effective_w, effective_h, time, mp.get_time(), script, true)
|
|
end
|
|
return
|
|
end
|
|
|
|
local cur_request_time = mp.get_time()
|
|
|
|
last_index = index
|
|
last_request_time = cur_request_time
|
|
last_request = seek_time
|
|
|
|
if not spawned then
|
|
spawn(seek_time)
|
|
if can_generate then
|
|
display_img(effective_w, effective_h, time, cur_request_time, script)
|
|
mp.add_timeout(0.15, function() display_img(effective_w, effective_h, time, cur_request_time, script) end)
|
|
end
|
|
return
|
|
end
|
|
|
|
run("async seek "..seek_time.." absolute+keyframes", function() if can_generate then display_img(effective_w, effective_h, time, cur_request_time, script) end end)
|
|
end
|
|
|
|
local function clear()
|
|
last_display_time = mp.get_time()
|
|
can_generate = true
|
|
last_x = nil
|
|
last_y = nil
|
|
mp.command_native(
|
|
{name = "overlay-remove", id=options.overlay_id}
|
|
)
|
|
end
|
|
|
|
local function watch_changes()
|
|
local old_w = effective_w
|
|
local old_h = effective_h
|
|
|
|
calc_dimensions()
|
|
|
|
local vf_reset = vf_string(filters_reset)
|
|
local rotate = mp.get_property_number("video-rotate", 0)
|
|
|
|
if spawned then
|
|
if old_w ~= effective_w or old_h ~= effective_h or last_vf_reset ~= vf_reset or (last_rotate % 180) ~= (rotate % 180) or par ~= last_par then
|
|
last_rotate = rotate
|
|
-- mpv doesn't allow us to change output size
|
|
run("quit")
|
|
clear()
|
|
info()
|
|
spawned = false
|
|
spawn(last_request or mp.get_property_number("time-pos", 0))
|
|
else
|
|
if rotate ~= last_rotate then
|
|
run("set video-rotate "..rotate)
|
|
end
|
|
local vf_runtime = vf_string(filters_runtime)
|
|
if vf_runtime ~= last_vf_runtime then
|
|
run("vf set "..vf_string(filters_all, true))
|
|
last_vf_runtime = vf_runtime
|
|
end
|
|
end
|
|
else
|
|
if old_w ~= effective_w or old_h ~= effective_h or last_vf_reset ~= vf_reset or (last_rotate % 180) ~= (rotate % 180) or par ~= last_par then
|
|
last_rotate = rotate
|
|
info()
|
|
end
|
|
last_vf_runtime = vf_string(filters_runtime)
|
|
end
|
|
|
|
last_vf_reset = vf_reset
|
|
last_rotate = rotate
|
|
last_par = par
|
|
end
|
|
|
|
local function sync_changes(prop, val)
|
|
if spawned and val then
|
|
run("set "..prop.." "..val)
|
|
end
|
|
end
|
|
|
|
local function file_load()
|
|
clear()
|
|
|
|
network = mp.get_property_bool("demuxer-via-network", false)
|
|
local image = mp.get_property_native('current-tracks/video/image', true)
|
|
local albumart = image and mp.get_property_native("current-tracks/video/albumart", false)
|
|
|
|
disabled = (network and not options.network) or (albumart and not options.audio) or (image and not albumart)
|
|
info()
|
|
if disabled then return end
|
|
|
|
interval = math.min(math.max(mp.get_property_number("duration", 1) / options.max_thumbnails, options.interval), mp.get_property_number("duration", options.interval * options.min_thumbnails) / options.min_thumbnails)
|
|
|
|
spawned = false
|
|
if options.spawn_first then spawn(mp.get_property_number("time-pos", 0)) end
|
|
end
|
|
|
|
local function shutdown()
|
|
run("quit")
|
|
remove_thumbnail_files()
|
|
os.remove(options.socket)
|
|
end
|
|
|
|
mp.observe_property("display-hidpi-scale", "native", watch_changes)
|
|
mp.observe_property("video-out-params", "native", watch_changes)
|
|
mp.observe_property("vf", "native", watch_changes)
|
|
mp.observe_property("vid", "native", sync_changes)
|
|
mp.observe_property("edition", "native", sync_changes)
|
|
|
|
mp.register_script_message("thumb", thumb)
|
|
mp.register_script_message("clear", clear)
|
|
|
|
mp.register_event("file-loaded", file_load)
|
|
mp.register_event("shutdown", shutdown) |