about summary refs log tree commit diff
path: root/alpine-river/config/mpv/scripts/thumbfast.lua
diff options
context:
space:
mode:
Diffstat (limited to 'alpine-river/config/mpv/scripts/thumbfast.lua')
-rw-r--r--alpine-river/config/mpv/scripts/thumbfast.lua951
1 files changed, 951 insertions, 0 deletions
diff --git a/alpine-river/config/mpv/scripts/thumbfast.lua b/alpine-river/config/mpv/scripts/thumbfast.lua
new file mode 100644
index 0000000..58d1870
--- /dev/null
+++ b/alpine-river/config/mpv/scripts/thumbfast.lua
@@ -0,0 +1,951 @@
+-- thumbfast.lua
+--
+-- High-performance on-the-fly thumbnailer
+--
+-- Built for easy integration in third-party UIs.
+
+--[[
+This Source Code Form is subject to the terms of the Mozilla Public
+License, v. 2.0. If a copy of the MPL was not distributed with this
+file, You can obtain one at https://mozilla.org/MPL/2.0/.
+]]
+
+local options = {
+    -- Socket path (leave empty for auto)
+    socket = "",
+
+    -- Thumbnail path (leave empty for auto)
+    thumbnail = "",
+
+    -- Maximum thumbnail generation size in pixels (scaled down to fit)
+    -- Values are scaled when hidpi is enabled
+    max_height = 200,
+    max_width = 200,
+
+    -- Scale factor for thumbnail display size (requires mpv 0.38+)
+    -- Note that this is lower quality than increasing max_height and max_width
+    scale_factor = 1,
+
+    -- Apply tone-mapping, no to disable
+    tone_mapping = "auto",
+
+    -- Overlay id
+    overlay_id = 42,
+
+    -- Spawn thumbnailer on file load for faster initial thumbnails
+    spawn_first = false,
+
+    -- Close thumbnailer process after an inactivity period in seconds, 0 to disable
+    quit_after_inactivity = 0,
+
+    -- Enable on network playback
+    network = false,
+
+    -- Enable on audio playback
+    audio = false,
+
+    -- Enable hardware decoding
+    hwdec = false,
+
+    -- Windows only: use native Windows API to write to pipe (requires LuaJIT)
+    direct_io = false,
+
+    -- Custom path to the mpv executable
+    mpv_path = "mpv"
+}
+
+mp.utils = require "mp.utils"
+mp.options = require "mp.options"
+mp.options.read_options(options, "thumbfast")
+
+local properties = {}
+local pre_0_30_0 = mp.command_native_async == nil
+local pre_0_33_0 = true
+local support_media_control = mp.get_property_native("media-controls") ~= nil
+
+function subprocess(args, async, callback)
+    callback = callback or function() end
+
+    if not pre_0_30_0 then
+        if async then
+            return mp.command_native_async({name = "subprocess", playback_only = true, args = args}, callback)
+        else
+            return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = args})
+        end
+    else
+        if async then
+            return mp.utils.subprocess_detached({args = args}, callback)
+        else
+            return mp.utils.subprocess({args = args})
+        end
+    end
+end
+
+local winapi = {}
+if options.direct_io then
+    local ffi_loaded, ffi = pcall(require, "ffi")
+    if ffi_loaded then
+        winapi = {
+            ffi = ffi,
+            C = ffi.C,
+            bit = require("bit"),
+            socket_wc = "",
+
+            -- WinAPI constants
+            CP_UTF8 = 65001,
+            GENERIC_WRITE = 0x40000000,
+            OPEN_EXISTING = 3,
+            FILE_FLAG_WRITE_THROUGH = 0x80000000,
+            FILE_FLAG_NO_BUFFERING = 0x20000000,
+            PIPE_NOWAIT = ffi.new("unsigned long[1]", 0x00000001),
+
+            INVALID_HANDLE_VALUE = ffi.cast("void*", -1),
+
+            -- don't care about how many bytes WriteFile wrote, so allocate something to store the result once
+            _lpNumberOfBytesWritten = ffi.new("unsigned long[1]"),
+        }
+        -- cache flags used in run() to avoid bor() call
+        winapi._createfile_pipe_flags = winapi.bit.bor(winapi.FILE_FLAG_WRITE_THROUGH, winapi.FILE_FLAG_NO_BUFFERING)
+
+        ffi.cdef[[
+            void* __stdcall CreateFileW(const wchar_t *lpFileName, unsigned long dwDesiredAccess, unsigned long dwShareMode, void *lpSecurityAttributes, unsigned long dwCreationDisposition, unsigned long dwFlagsAndAttributes, void *hTemplateFile);
+            bool __stdcall WriteFile(void *hFile, const void *lpBuffer, unsigned long nNumberOfBytesToWrite, unsigned long *lpNumberOfBytesWritten, void *lpOverlapped);
+            bool __stdcall CloseHandle(void *hObject);
+            bool __stdcall SetNamedPipeHandleState(void *hNamedPipe, unsigned long *lpMode, unsigned long *lpMaxCollectionCount, unsigned long *lpCollectDataTimeout);
+            int __stdcall MultiByteToWideChar(unsigned int CodePage, unsigned long dwFlags, const char *lpMultiByteStr, int cbMultiByte, wchar_t *lpWideCharStr, int cchWideChar);
+        ]]
+
+        winapi.MultiByteToWideChar = function(MultiByteStr)
+            if MultiByteStr then
+                local utf16_len = winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, nil, 0)
+                if utf16_len > 0 then
+                    local utf16_str = winapi.ffi.new("wchar_t[?]", utf16_len)
+                    if winapi.C.MultiByteToWideChar(winapi.CP_UTF8, 0, MultiByteStr, -1, utf16_str, utf16_len) > 0 then
+                        return utf16_str
+                    end
+                end
+            end
+            return ""
+        end
+
+    else
+        options.direct_io = false
+    end
+end
+
+local file
+local file_bytes = 0
+local spawned = false
+local disabled = false
+local force_disabled = false
+local spawn_waiting = false
+local spawn_working = false
+local script_written = false
+
+local dirty = false
+
+local x, y
+local last_x, last_y
+
+local last_seek_time
+
+local effective_w, effective_h = options.max_width, options.max_height
+local real_w, real_h
+local last_real_w, last_real_h
+
+local script_name
+
+local show_thumbnail = false
+
+local filters_reset = {["lavfi-crop"]=true, ["crop"]=true}
+local filters_runtime = {["hflip"]=true, ["vflip"]=true}
+local filters_all = {["hflip"]=true, ["vflip"]=true, ["lavfi-crop"]=true, ["crop"]=true}
+
+local tone_mappings = {["none"]=true, ["clip"]=true, ["linear"]=true, ["gamma"]=true, ["reinhard"]=true, ["hable"]=true, ["mobius"]=true}
+local last_tone_mapping
+
+local last_vf_reset = ""
+local last_vf_runtime = ""
+
+local last_rotate = 0
+
+local par = ""
+local last_par = ""
+
+local last_crop = nil
+
+local last_has_vid = 0
+local has_vid = 0
+
+local file_timer
+local file_check_period = 1/60
+
+local allow_fast_seek = true
+
+local client_script = [=[
+#!/usr/bin/env bash
+MPV_IPC_FD=0; MPV_IPC_PATH="%s"
+trap "kill 0" EXIT
+while [[ $# -ne 0 ]]; do case $1 in --mpv-ipc-fd=*) MPV_IPC_FD=${1/--mpv-ipc-fd=/} ;; esac; shift; done
+if echo "print-text thumbfast" >&"$MPV_IPC_FD"; then echo -n > "$MPV_IPC_PATH"; tail -f "$MPV_IPC_PATH" >&"$MPV_IPC_FD" & while read -r -u "$MPV_IPC_FD" 2>/dev/null; do :; done; fi
+]=]
+
+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 = subprocess({"uname", "-s"}).stdout
+        end
+    end
+
+    raw_os_name = (raw_os_name):lower()
+
+    local os_patterns = {
+        ["windows"] = "windows",
+        ["linux"]   = "linux",
+
+        ["osx"]     = "darwin",
+        ["mac"]     = "darwin",
+        ["darwin"]  = "darwin",
+
+        ["^mingw"]  = "windows",
+        ["^cygwin"] = "windows",
+
+        ["bsd$"]    = "darwin",
+        ["sunos"]   = "darwin"
+    }
+
+    -- 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 os_name = mp.get_property("platform") or get_os()
+
+local path_separator = os_name == "windows" and "\\" or "/"
+
+if options.socket == "" then
+    if os_name == "windows" then
+        options.socket = "thumbfast"
+    else
+        options.socket = "/tmp/thumbfast"
+    end
+end
+
+if options.thumbnail == "" then
+    if os_name == "windows" then
+        options.thumbnail = os.getenv("TEMP").."\\thumbfast.out"
+    else
+        options.thumbnail = "/tmp/thumbfast.out"
+    end
+end
+
+local unique = mp.utils.getpid()
+
+options.socket = options.socket .. unique
+options.thumbnail = options.thumbnail .. unique
+
+if options.direct_io then
+    if os_name == "windows" then
+        winapi.socket_wc = winapi.MultiByteToWideChar("\\\\.\\pipe\\" .. options.socket)
+    end
+
+    if winapi.socket_wc == "" then
+        options.direct_io = false
+    end
+end
+
+options.scale_factor = math.floor(options.scale_factor)
+
+local mpv_path = options.mpv_path
+local frontend_path
+
+if mpv_path == "mpv" and os_name == "windows" then
+    frontend_path = mp.get_property_native("user-data/frontend/process-path")
+    mpv_path = frontend_path or mpv_path
+end
+
+if mpv_path == "mpv" and os_name == "darwin" and unique then
+    -- TODO: look into ~~osxbundle/
+    mpv_path = string.gsub(subprocess({"ps", "-o", "comm=", "-p", tostring(unique)}).stdout, "[\n\r]", "")
+    if mpv_path ~= "mpv" then
+        mpv_path = string.gsub(mpv_path, "/mpv%-bundle$", "/mpv")
+        local mpv_bin = mp.utils.file_info("/usr/local/mpv")
+        if mpv_bin and mpv_bin.is_file then
+            mpv_path = "/usr/local/mpv"
+        else
+            local mpv_app = mp.utils.file_info("/Applications/mpv.app/Contents/MacOS/mpv")
+            if mpv_app and mpv_app.is_file then
+                mp.msg.warn("symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`")
+            else
+                mp.msg.warn("drag to your Applications folder and symlink mpv to fix Dock icons: `sudo ln -s /Applications/mpv.app/Contents/MacOS/mpv /usr/local/mpv`")
+            end
+        end
+    end
+end
+
+local function vo_tone_mapping()
+    local passes = mp.get_property_native("vo-passes")
+    if passes and passes["fresh"] then
+        for k, v in pairs(passes["fresh"]) do
+            for k2, v2 in pairs(v) do
+                if k2 == "desc" and v2 then
+                    local tone_mapping = string.match(v2, "([0-9a-z.-]+) tone map")
+                    if tone_mapping then
+                        return tone_mapping
+                    end
+                end
+            end
+        end
+    end
+end
+
+local function vf_string(filters, full)
+    local vf = ""
+    local vf_table = properties["vf"]
+
+    if (properties["video-crop"] or "") ~= "" then
+        vf = "lavfi-crop="..string.gsub(properties["video-crop"], "(%d*)x?(%d*)%+(%d+)%+(%d+)", "w=%1:h=%2:x=%3:y=%4")..","
+        local width = properties["video-out-params"] and properties["video-out-params"]["dw"]
+        local height = properties["video-out-params"] and properties["video-out-params"]["dh"]
+        if width and height then
+            vf = string.gsub(vf, "w=:h=:", "w="..width..":h="..height..":")
+        end
+    end
+
+    if vf_table and #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 and options.tone_mapping ~= "no") or options.tone_mapping == "auto" then
+        if properties["video-params"] and properties["video-params"]["primaries"] == "bt.2020" then
+            local tone_mapping = options.tone_mapping
+            if tone_mapping == "auto" then
+                tone_mapping = last_tone_mapping or properties["tone-mapping"]
+                if tone_mapping == "auto" and properties["current-vo"] == "gpu-next" then
+                    tone_mapping = vo_tone_mapping()
+                end
+            end
+            if not tone_mappings[tone_mapping] then
+                tone_mapping = "hable"
+            end
+            last_tone_mapping = tone_mapping
+            vf = vf .. "zscale=transfer=linear,format=gbrpf32le,tonemap="..tone_mapping..",zscale=transfer=bt709,"
+        end
+    end
+
+    if full then
+        vf = vf.."scale=w="..effective_w..":h="..effective_h..par..",pad=w="..effective_w..":h="..effective_h..":x=-1:y=-1,format=bgra"
+    end
+
+    return vf
+end
+
+local function calc_dimensions()
+    local width = properties["video-out-params"] and properties["video-out-params"]["dw"]
+    local height = properties["video-out-params"] and properties["video-out-params"]["dh"]
+    if not width or not height then return end
+
+    local scale = properties["display-hidpi-scale"] or 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
+
+    local v_par = properties["video-out-params"] and properties["video-out-params"]["par"] or 1
+    if v_par == 1 then
+        par = ":force_original_aspect_ratio=decrease"
+    else
+        par = ""
+    end
+end
+
+local info_timer = nil
+
+local function info(w, h)
+    local rotate = properties["video-params"] and properties["video-params"]["rotate"]
+    local image = properties["current-tracks/video"] and properties["current-tracks/video"]["image"]
+    local albumart = image and properties["current-tracks/video"]["albumart"]
+
+    disabled = (w or 0) == 0 or (h or 0) == 0 or
+        has_vid == 0 or
+        (properties["demuxer-via-network"] and not options.network) or
+        (albumart and not options.audio) or
+        (image and not albumart) or
+        force_disabled
+
+    if info_timer then
+        info_timer:kill()
+        info_timer = nil
+    elseif has_vid == 0 or (rotate == nil and not disabled) then
+        info_timer = mp.add_timeout(0.05, function() info(w, h) end)
+    end
+
+    local json, err = mp.utils.format_json({width=w * options.scale_factor, height=h * options.scale_factor, scale_factor=options.scale_factor, disabled=disabled, available=true, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id})
+    if pre_0_30_0 then
+        mp.command_native({"script-message", "thumbfast-info", json})
+    else
+        mp.command_native_async({"script-message", "thumbfast-info", json}, function() end)
+    end
+end
+
+local function remove_thumbnail_files()
+    if file then
+        file:close()
+        file = nil
+        file_bytes = 0
+    end
+    os.remove(options.thumbnail)
+    os.remove(options.thumbnail..".bgra")
+end
+
+local activity_timer
+
+local function spawn(time)
+    if disabled then return end
+
+    local path = properties["path"]
+    if path == nil then return end
+
+    if options.quit_after_inactivity > 0 then
+        if show_thumbnail or activity_timer:is_enabled() then
+            activity_timer:kill()
+        end
+        activity_timer:resume()
+    end
+
+    local open_filename = properties["stream-open-filename"]
+    local ytdl = open_filename and properties["demuxer-via-network"] and path ~= open_filename
+    if ytdl then
+        path = open_filename
+    end
+
+    remove_thumbnail_files()
+
+    local vid = properties["vid"]
+    has_vid = vid or 0
+
+    local args = {
+        mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal",
+        "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no",
+        "--edition="..(properties["edition"] or "auto"), "--vid="..(vid or "auto"), "--no-sub", "--no-audio",
+        "--start="..time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes",
+        "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB",
+        "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", "--hwdec="..(options.hwdec and "auto" or "no"),
+        "--vf="..vf_string(filters_all, true),
+        "--sws-scaler=fast-bilinear",
+        "--video-rotate="..last_rotate,
+        "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail
+    }
+
+    if not pre_0_30_0 then
+        table.insert(args, "--sws-allow-zimg=no")
+    end
+
+    if support_media_control then
+        table.insert(args, "--media-controls=no")
+    end
+
+    if os_name == "darwin" and properties["macos-app-activation-policy"] then
+        table.insert(args, "--macos-app-activation-policy=accessory")
+    end
+
+    if os_name == "windows" or pre_0_33_0 then
+        table.insert(args, "--input-ipc-server="..options.socket)
+    elseif not script_written then
+        local client_script_path = options.socket..".run"
+        local script = io.open(client_script_path, "w+")
+        if script == nil then
+            mp.msg.error("client script write failed")
+            return
+        else
+            script_written = true
+            script:write(string.format(client_script, options.socket))
+            script:close()
+            subprocess({"chmod", "+x", client_script_path}, true)
+            table.insert(args, "--scripts="..client_script_path)
+        end
+    else
+        local client_script_path = options.socket..".run"
+        table.insert(args, "--scripts="..client_script_path)
+    end
+
+    table.insert(args, "--")
+    table.insert(args, path)
+
+    spawned = true
+    spawn_waiting = true
+
+    subprocess(args, true,
+        function(success, result)
+            if spawn_waiting and (success == false or (result.status ~= 0 and result.status ~= -2)) then
+                spawned = false
+                spawn_waiting = false
+                options.tone_mapping = "no"
+                mp.msg.error("mpv subprocess create failed")
+                if not spawn_working then -- notify users of required configuration
+                    if options.mpv_path == "mpv" then
+                        if properties["current-vo"] == "libmpv" then
+                            if options.mpv_path == mpv_path then -- attempt to locate ImPlay
+                                mpv_path = "ImPlay"
+                                spawn(time)
+                            else -- ImPlay not in path
+                                if os_name ~= "darwin" then
+                                    force_disabled = true
+                                    info(real_w or effective_w, real_h or effective_h)
+                                end
+                                mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+                                mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+                            end
+                        else
+                            mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+                            if os_name == "windows" and frontend_path == nil then
+                                mp.commandv("script-message-to", "mpvnet", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20)
+                                mp.commandv("script-message", "mpv.net", "show-text", "thumbfast: ERROR! install standalone mpv, see README", 5000, 20)
+                            end
+                        end
+                    else
+                        mp.commandv("show-text", "thumbfast: ERROR! cannot create mpv subprocess", 5000)
+                        -- found ImPlay but not defined in config
+                        mp.commandv("script-message-to", "implay", "show-message", "thumbfast", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+                    end
+                end
+            elseif success == true and (result.status == 0 or result.status == -2) then
+                if not spawn_working and properties["current-vo"] == "libmpv" and options.mpv_path ~= mpv_path then
+                    mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay")
+                end
+                spawn_working = true
+                spawn_waiting = false
+            end
+        end
+    )
+end
+
+local function run(command)
+    if not spawned then return end
+
+    if options.direct_io then
+        local hPipe = winapi.C.CreateFileW(winapi.socket_wc, winapi.GENERIC_WRITE, 0, nil, winapi.OPEN_EXISTING, winapi._createfile_pipe_flags, nil)
+        if hPipe ~= winapi.INVALID_HANDLE_VALUE then
+            local buf = command .. "\n"
+            winapi.C.SetNamedPipeHandleState(hPipe, winapi.PIPE_NOWAIT, nil, nil)
+            winapi.C.WriteFile(hPipe, buf, #buf + 1, winapi._lpNumberOfBytesWritten, nil)
+            winapi.C.CloseHandle(hPipe)
+        end
+
+        return
+    end
+
+    local command_n = command.."\n"
+
+    if os_name == "windows" then
+        if file and file_bytes + #command_n >= 4096 then
+            file:close()
+            file = nil
+            file_bytes = 0
+        end
+        if not file then
+            file = io.open("\\\\.\\pipe\\"..options.socket, "r+b")
+        end
+    elseif pre_0_33_0 then
+        subprocess({"/usr/bin/env", "sh", "-c", "echo '" .. command .. "' | socat - " .. options.socket})
+        return
+    elseif not file then
+        file = io.open(options.socket, "r+")
+    end
+    if file then
+        file_bytes = file:seek("end")
+        file:write(command_n)
+        file:flush()
+    end
+end
+
+local function draw(w, h, script)
+    if not w or not show_thumbnail then return end
+    if x ~= nil then
+        local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil
+        if pre_0_30_0 then
+            mp.command_native({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h})
+        else
+            mp.command_native_async({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}, function() end)
+        end
+    elseif script then
+        local json, err = mp.utils.format_json({width=w, height=h, scale_factor=options.scale_factor, 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 real_res(req_w, req_h, filesize)
+    local count = filesize / 4
+    local diff = (req_w * req_h) - count
+
+    if (properties["video-params"] and properties["video-params"]["rotate"] or 0) % 180 == 90 then
+        req_w, req_h = req_h, req_w
+    end
+
+    if diff == 0 then
+        return req_w, req_h
+    else
+        local threshold = 5 -- throw out results that change too much
+        local long_side, short_side = req_w, req_h
+        if req_h > req_w then
+            long_side, short_side = req_h, req_w
+        end
+        for a = short_side, short_side - threshold, -1 do
+            if count % a == 0 then
+                local b = count / a
+                if long_side - b < threshold then
+                    if req_h < req_w then return b, a else return a, b end
+                end
+            end
+        end
+        return nil
+    end
+end
+
+local function move_file(from, to)
+    if os_name == "windows" then
+        os.remove(to)
+    end
+    -- move the file because it can get overwritten while overlay-add is reading it, and crash the player
+    os.rename(from, to)
+end
+
+local function seek(fast)
+    if last_seek_time then
+        run("async seek " .. last_seek_time .. (fast and " absolute+keyframes" or " absolute+exact"))
+    end
+end
+
+local seek_period = 3/60
+local seek_period_counter = 0
+local seek_timer
+seek_timer = mp.add_periodic_timer(seek_period, function()
+    if seek_period_counter == 0 then
+        seek(allow_fast_seek)
+        seek_period_counter = 1
+    else
+        if seek_period_counter == 2 then
+            if allow_fast_seek then
+                seek_timer:kill()
+                seek()
+            end
+        else seek_period_counter = seek_period_counter + 1 end
+    end
+end)
+seek_timer:kill()
+
+local function request_seek()
+    if seek_timer:is_enabled() then
+        seek_period_counter = 0
+    else
+        seek_timer:resume()
+        seek(allow_fast_seek)
+        seek_period_counter = 1
+    end
+end
+
+local function check_new_thumb()
+    -- the slave might start writing to the file after checking existance and
+    -- validity but before actually moving the file, so move to a temporary
+    -- location before validity check to make sure everything stays consistant
+    -- and valid thumbnails don't get overwritten by invalid ones
+    local tmp = options.thumbnail..".tmp"
+    move_file(options.thumbnail, tmp)
+    local finfo = mp.utils.file_info(tmp)
+    if not finfo then return false end
+    spawn_waiting = false
+    local w, h = real_res(effective_w, effective_h, finfo.size)
+    if w then -- only accept valid thumbnails
+        move_file(tmp, options.thumbnail..".bgra")
+
+        real_w, real_h = w, h
+        if real_w and (real_w ~= last_real_w or real_h ~= last_real_h) then
+            last_real_w, last_real_h = real_w, real_h
+            info(real_w, real_h)
+        end
+        if not show_thumbnail then
+            file_timer:kill()
+        end
+        return true
+    end
+
+    return false
+end
+
+file_timer = mp.add_periodic_timer(file_check_period, function()
+    if check_new_thumb() then
+        draw(real_w, real_h, script_name)
+    end
+end)
+file_timer:kill()
+
+local function clear()
+    file_timer:kill()
+    seek_timer:kill()
+    if options.quit_after_inactivity > 0 then
+        if show_thumbnail or activity_timer:is_enabled() then
+            activity_timer:kill()
+        end
+        activity_timer:resume()
+    end
+    last_seek_time = nil
+    show_thumbnail = false
+    last_x = nil
+    last_y = nil
+    if script_name then return end
+    if pre_0_30_0 then
+        mp.command_native({"overlay-remove", options.overlay_id})
+    else
+        mp.command_native_async({"overlay-remove", options.overlay_id}, function() end)
+    end
+end
+
+local function quit()
+    activity_timer:kill()
+    if show_thumbnail then
+        activity_timer:resume()
+        return
+    end
+    run("quit")
+    spawned = false
+    real_w, real_h = nil, nil
+    clear()
+end
+
+activity_timer = mp.add_timeout(options.quit_after_inactivity, quit)
+activity_timer:kill()
+
+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 == "" or r_y == "" then
+        x, y = nil, nil
+    else
+        x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5)
+    end
+
+    script_name = script
+    if last_x ~= x or last_y ~= y or not show_thumbnail then
+        show_thumbnail = true
+        last_x, last_y = x, y
+        draw(real_w, real_h, script)
+    end
+
+    if options.quit_after_inactivity > 0 then
+        if show_thumbnail or activity_timer:is_enabled() then
+            activity_timer:kill()
+        end
+        activity_timer:resume()
+    end
+
+    if time == last_seek_time then return end
+    last_seek_time = time
+    if not spawned then spawn(time) end
+    request_seek()
+    if not file_timer:is_enabled() then file_timer:resume() end
+end
+
+local function watch_changes()
+    if not dirty or not properties["video-out-params"] then return end
+    dirty = false
+
+    local old_w = effective_w
+    local old_h = effective_h
+
+    calc_dimensions()
+
+    local vf_reset = vf_string(filters_reset)
+    local rotate = properties["video-rotate"] or 0
+
+    local resized = 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 or last_crop ~= properties["video-crop"]
+
+    if resized then
+        last_rotate = rotate
+        info(effective_w, effective_h)
+    elseif last_has_vid ~= has_vid and has_vid ~= 0 then
+        info(effective_w, effective_h)
+    end
+
+    if spawned then
+        if resized then
+            -- mpv doesn't allow us to change output size
+            local seek_time = last_seek_time
+            run("quit")
+            clear()
+            spawned = false
+            spawn(seek_time or mp.get_property_number("time-pos", 0))
+            file_timer:resume()
+        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
+        last_vf_runtime = vf_string(filters_runtime)
+    end
+
+    last_vf_reset = vf_reset
+    last_rotate = rotate
+    last_par = par
+    last_crop = properties["video-crop"]
+    last_has_vid = has_vid
+
+    if not spawned and not disabled and options.spawn_first and resized then
+        spawn(mp.get_property_number("time-pos", 0))
+        file_timer:resume()
+    end
+end
+
+local function update_property(name, value)
+    properties[name] = value
+end
+
+local function update_property_dirty(name, value)
+    properties[name] = value
+    dirty = true
+    if name == "tone-mapping" then
+        last_tone_mapping = nil
+    end
+end
+
+local function update_tracklist(name, value)
+    -- current-tracks shim
+    for _, track in ipairs(value) do
+        if track.type == "video" and track.selected then
+            properties["current-tracks/video"] = track
+            return
+        end
+    end
+end
+
+local function sync_changes(prop, val)
+    update_property(prop, val)
+    if val == nil then return end
+
+    if type(val) == "boolean" then
+        if prop == "vid" then
+            has_vid = 0
+            last_has_vid = 0
+            info(effective_w, effective_h)
+            clear()
+            return
+        end
+        val = val and "yes" or "no"
+    end
+
+    if prop == "vid" then
+        has_vid = 1
+    end
+
+    if not spawned then return end
+
+    run("set "..prop.." "..val)
+    dirty = true
+end
+
+local function file_load()
+    clear()
+    spawned = false
+    real_w, real_h = nil, nil
+    last_real_w, last_real_h = nil, nil
+    last_tone_mapping = nil
+    last_seek_time = nil
+    if info_timer then
+        info_timer:kill()
+        info_timer = nil
+    end
+
+    calc_dimensions()
+    info(effective_w, effective_h)
+end
+
+local function shutdown()
+    run("quit")
+    remove_thumbnail_files()
+    if os_name ~= "windows" then
+        os.remove(options.socket)
+        os.remove(options.socket..".run")
+    end
+end
+
+local function on_duration(prop, val)
+    allow_fast_seek = (val or 30) >= 30
+end
+
+mp.observe_property("current-tracks/video", "native", function(name, value)
+    if pre_0_33_0 then
+        mp.unobserve_property(update_tracklist)
+        pre_0_33_0 = false
+    end
+    update_property(name, value)
+end)
+
+mp.observe_property("track-list", "native", update_tracklist)
+mp.observe_property("display-hidpi-scale", "native", update_property_dirty)
+mp.observe_property("video-out-params", "native", update_property_dirty)
+mp.observe_property("video-params", "native", update_property_dirty)
+mp.observe_property("vf", "native", update_property_dirty)
+mp.observe_property("tone-mapping", "native", update_property_dirty)
+mp.observe_property("demuxer-via-network", "native", update_property)
+mp.observe_property("stream-open-filename", "native", update_property)
+mp.observe_property("macos-app-activation-policy", "native", update_property)
+mp.observe_property("current-vo", "native", update_property)
+mp.observe_property("video-rotate", "native", update_property)
+mp.observe_property("video-crop", "native", update_property)
+mp.observe_property("path", "native", update_property)
+mp.observe_property("vid", "native", sync_changes)
+mp.observe_property("edition", "native", sync_changes)
+mp.observe_property("duration", "native", on_duration)
+
+mp.register_script_message("thumb", thumb)
+mp.register_script_message("clear", clear)
+
+mp.register_event("file-loaded", file_load)
+mp.register_event("shutdown", shutdown)
+
+mp.register_idle(watch_changes)