diff options
Diffstat (limited to 'mac/.config/mpv/scripts/mpv_crop_script.lua')
| -rw-r--r-- | mac/.config/mpv/scripts/mpv_crop_script.lua | 3438 |
1 files changed, 3438 insertions, 0 deletions
diff --git a/mac/.config/mpv/scripts/mpv_crop_script.lua b/mac/.config/mpv/scripts/mpv_crop_script.lua new file mode 100644 index 0000000..7b3f7be --- /dev/null +++ b/mac/.config/mpv/scripts/mpv_crop_script.lua @@ -0,0 +1,3438 @@ +--[[ + Copyright (C) 2017 AMM + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. +]] +-- +--[[ + mpv_crop_script.lua 0.5.0 - commit 472281e (branch master) + Built on 2018-09-30 14:22:46 +]] +-- +--[[ + Assorted helper functions, from checking falsey values to path utils + to escaping and wrapping strings. + + Does not depend on other libs. +]] +-- + +local assdraw = require("mp.assdraw") +local msg = require("mp.msg") +local utils = require("mp.utils") + +-- Determine platform -- +ON_WINDOWS = (package.config:sub(1, 1) ~= "/") + +-- Some helper functions needed to parse the options -- +function isempty(v) + return (v == false) or (v == nil) or (v == "") or (v == 0) or (type(v) == "table" and next(v) == nil) +end + +function divmod(a, b) + return math.floor(a / b), a % b +end + +-- Better modulo +function bmod(i, N) + return (i % N + N) % N +end + +-- Path utils +local path_utils = { + abspath = true, + split = true, + dirname = true, + basename = true, + + isabs = true, + normcase = true, + splitdrive = true, + join = true, + normpath = true, + relpath = true, +} + +-- Helpers +path_utils._split_parts = function(path, sep) + local path_parts = {} + for c in path:gmatch("[^" .. sep .. "]+") do + table.insert(path_parts, c) + end + return path_parts +end + +-- Common functions +path_utils.abspath = function(path) + if not path_utils.isabs(path) then + local cwd = os.getenv("PWD") or utils.getcwd() + path = path_utils.join(cwd, path) + end + return path_utils.normpath(path) +end + +path_utils.split = function(path) + local drive, path = path_utils.splitdrive(path) + -- Technically unix path could contain a \, but meh + local first_index, last_index = path:find("^.*[/\\]") + + if last_index == nil then + return drive .. "", path + else + local head = path:sub(0, last_index - 1) + local tail = path:sub(last_index + 1) + if head == "" then + head = sep + end + return drive .. head, tail + end +end + +path_utils.dirname = function(path) + local head, tail = path_utils.split(path) + return head +end + +path_utils.basename = function(path) + local head, tail = path_utils.split(path) + return tail +end + +path_utils.expanduser = function(path) + -- Expands the following from the start of the path: + -- ~ to HOME + -- ~~ to mpv config directory (first result of mp.find_config_file('.')) + -- ~~desktop to Windows desktop, otherwise HOME + -- ~~temp to Windows temp or /tmp/ + + local first_index, last_index = path:find("^.-[/\\]") + local head = path + local tail = "" + + local sep = "" + + if last_index then + head = path:sub(0, last_index - 1) + tail = path:sub(last_index + 1) + sep = path:sub(last_index, last_index) + end + + if head == "~~desktop" then + head = ON_WINDOWS and path_utils.join(os.getenv("USERPROFILE"), "Desktop") or os.getenv("HOME") + elseif head == "~~temp" then + head = ON_WINDOWS and os.getenv("TEMP") or (os.getenv("TMP") or "/tmp/") + elseif head == "~~" then + local mpv_config_dir = mp.find_config_file(".") + if mpv_config_dir then + head = path_utils.dirname(mpv_config_dir) + else + msg.warn("Could not find mpv config directory (using mp.find_config_file), using temp instead") + head = ON_WINDOWS and os.getenv("TEMP") or (os.getenv("TMP") or "/tmp/") + end + elseif head == "~" then + head = ON_WINDOWS and os.getenv("USERPROFILE") or os.getenv("HOME") + end + + return path_utils.normpath(path_utils.join(head .. sep, tail)) +end + +if ON_WINDOWS then + local sep = "\\" + local altsep = "/" + local curdir = "." + local pardir = ".." + local colon = ":" + + local either_sep = function(c) + return c == sep or c == altsep + end + + path_utils.isabs = function(path) + local prefix, path = path_utils.splitdrive(path) + return either_sep(path:sub(1, 1)) + end + + path_utils.normcase = function(path) + return path:gsub(altsep, sep):lower() + end + + path_utils.splitdrive = function(path) + if #path >= 2 then + local norm = path:gsub(altsep, sep) + if (norm:sub(1, 2) == (sep .. sep)) and (norm:sub(3, 3) ~= sep) then + -- UNC path + local index = norm:find(sep, 3) + if not index then + return "", path + end + + local index2 = norm:find(sep, index + 1) + if index2 == index + 1 then + return "", path + elseif not index2 then + index2 = path:len() + end + + return path:sub(1, index2 - 1), path:sub(index2) + elseif norm:sub(2, 2) == colon then + return path:sub(1, 2), path:sub(3) + end + end + return "", path + end + + path_utils.join = function(path, ...) + local paths = { ... } + + local result_drive, result_path = path_utils.splitdrive(path) + + function inner(p) + local p_drive, p_path = path_utils.splitdrive(p) + if either_sep(p_path:sub(1, 1)) then + -- Path is absolute + if p_drive ~= "" or result_drive == "" then + result_drive = p_drive + end + result_path = p_path + return + elseif p_drive ~= "" and p_drive ~= result_drive then + if p_drive:lower() ~= result_drive:lower() then + -- Different paths, ignore first + result_drive = p_drive + result_path = p_path + return + end + end + + if result_path ~= "" and not either_sep(result_path:sub(-1)) then + result_path = result_path .. sep + end + result_path = result_path .. p_path + end + + for i, p in ipairs(paths) do + inner(p) + end + + -- add separator between UNC and non-absolute path + if + result_path ~= "" + and not either_sep(result_path:sub(1, 1)) + and result_drive ~= "" + and result_drive:sub(-1) ~= colon + then + return result_drive .. sep .. result_path + end + return result_drive .. result_path + end + + path_utils.normpath = function(path) + if path:find("\\\\.\\", nil, true) == 1 or path:find("\\\\?\\", nil, true) == 1 then + -- Device names and literal paths - return as-is + return path + end + + path = path:gsub(altsep, sep) + local prefix, path = path_utils.splitdrive(path) + + if path:find(sep) == 1 then + prefix = prefix .. sep + path = path:gsub("^[\\]+", "") + end + + local comps = path_utils._split_parts(path, sep) + + local i = 1 + while i <= #comps do + if comps[i] == curdir then + table.remove(comps, i) + elseif comps[i] == pardir then + if i > 1 and comps[i - 1] ~= pardir then + table.remove(comps, i) + table.remove(comps, i - 1) + i = i - 1 + elseif i == 1 and prefix:match("\\$") then + table.remove(comps, i) + else + i = i + 1 + end + else + i = i + 1 + end + end + + if prefix == "" and #comps == 0 then + comps[1] = curdir + end + + return prefix .. table.concat(comps, sep) + end + + path_utils.relpath = function(path, start) + start = start or curdir + + local start_abs = path_utils.abspath(path_utils.normpath(start)) + local path_abs = path_utils.abspath(path_utils.normpath(path)) + + local start_drive, start_rest = path_utils.splitdrive(start_abs) + local path_drive, path_rest = path_utils.splitdrive(path_abs) + + if path_utils.normcase(start_drive) ~= path_utils.normcase(path_drive) then + -- Different drives + return nil + end + + local start_list = path_utils._split_parts(start_rest, sep) + local path_list = path_utils._split_parts(path_rest, sep) + + local i = 1 + for j = 1, math.min(#start_list, #path_list) do + if path_utils.normcase(start_list[j]) ~= path_utils.normcase(path_list[j]) then + break + end + i = j + 1 + end + + local rel_list = {} + for j = 1, (#start_list - i + 1) do + rel_list[j] = pardir + end + for j = i, #path_list do + table.insert(rel_list, path_list[j]) + end + + if #rel_list == 0 then + return curdir + end + + return path_utils.join(unpack(rel_list)) + end +else + -- LINUX + local sep = "/" + local curdir = "." + local pardir = ".." + + path_utils.isabs = function(path) + return path:sub(1, 1) == "/" + end + path_utils.normcase = function(path) + return path + end + path_utils.splitdrive = function(path) + return "", path + end + + path_utils.join = function(path, ...) + local paths = { ... } + + for i, p in ipairs(paths) do + if p:sub(1, 1) == sep then + path = p + elseif path == "" or path:sub(-1) == sep then + path = path .. p + else + path = path .. sep .. p + end + end + + return path + end + + path_utils.normpath = function(path) + if path == "" then + return curdir + end + + local initial_slashes = (path:sub(1, 1) == sep) and 1 + if initial_slashes and path:sub(2, 2) == sep and path:sub(3, 3) ~= sep then + initial_slashes = 2 + end + + local comps = path_utils._split_parts(path, sep) + local new_comps = {} + + for i, comp in ipairs(comps) do + if comp == "" or comp == curdir then + -- pass + elseif + comp ~= pardir + or (not initial_slashes and #new_comps == 0) + or (#new_comps > 0 and new_comps[#new_comps] == pardir) + then + table.insert(new_comps, comp) + elseif #new_comps > 0 then + table.remove(new_comps) + end + end + + comps = new_comps + path = table.concat(comps, sep) + if initial_slashes then + path = sep:rep(initial_slashes) .. path + end + + return (path ~= "") and path or curdir + end + + path_utils.relpath = function(path, start) + start = start or curdir + + local start_abs = path_utils.abspath(path_utils.normpath(start)) + local path_abs = path_utils.abspath(path_utils.normpath(path)) + + local start_list = path_utils._split_parts(start_abs, sep) + local path_list = path_utils._split_parts(path_abs, sep) + + local i = 1 + for j = 1, math.min(#start_list, #path_list) do + if start_list[j] ~= path_list[j] then + break + end + i = j + 1 + end + + local rel_list = {} + for j = 1, (#start_list - i + 1) do + rel_list[j] = pardir + end + for j = i, #path_list do + table.insert(rel_list, path_list[j]) + end + + if #rel_list == 0 then + return curdir + end + + return path_utils.join(unpack(rel_list)) + end +end +-- Path utils end + +-- Check if path is local (by looking if it's prefixed by a proto://) +local path_is_local = function(path) + local proto = path:match("(..-)://") + return proto == nil +end + +function Set(source) + local set = {} + for _, l in ipairs(source) do + set[l] = true + end + return set +end + +--------------------------- +-- More helper functions -- +--------------------------- + +function busy_wait(seconds) + local target = mp.get_time() + seconds + local cycles = 0 + while target > mp.get_time() do + cycles = cycles + 1 + end + return cycles +end + +-- Removes all keys from a table, without destroying the reference to it +function clear_table(target) + for key, value in pairs(target) do + target[key] = nil + end +end +function shallow_copy(target) + if type(target) == "table" then + local copy = {} + for k, v in pairs(target) do + copy[k] = v + end + return copy + else + return target + end +end + +function deep_copy(target) + local copy = {} + for k, v in pairs(target) do + if type(v) == "table" then + copy[k] = deep_copy(v) + else + copy[k] = v + end + end + return copy +end + +-- Rounds to given decimals. eg. round_dec(3.145, 0) => 3 +function round_dec(num, idp) + local mult = 10 ^ (idp or 0) + return math.floor(num * mult + 0.5) / mult +end + +function file_exists(name) + local f = io.open(name, "rb") + if f ~= nil then + local ok, err, code = f:read(1) + io.close(f) + return code == nil + else + return false + end +end + +function path_exists(name) + local f = io.open(name, "rb") + if f ~= nil then + io.close(f) + return true + else + return false + end +end + +function create_directories(path) + local cmd + if ON_WINDOWS then + cmd = { args = { "cmd", "/c", "mkdir", path } } + else + cmd = { args = { "mkdir", "-p", path } } + end + utils.subprocess(cmd) +end + +function move_file(source_path, target_path) + local cmd + if ON_WINDOWS then + cmd = { cancellable = false, args = { "cmd", "/c", "move", "/Y", source_path, target_path } } + utils.subprocess(cmd) + else + -- cmd = { cancellable=false, args = {'mv', source_path, target_path } } + os.rename(source_path, target_path) + end +end + +function check_pid(pid) + -- Checks if a PID exists and returns true if so + local cmd, r + if ON_WINDOWS then + cmd = { cancellable = false, args = { + "tasklist", + "/FI", + ("PID eq %d"):format(pid), + } } + r = utils.subprocess(cmd) + return r.stdout:sub(1, 1) == "\13" + else + cmd = { cancellable = false, args = { + "sh", + "-c", + ("kill -0 %d 2>/dev/null"):format(pid), + } } + r = utils.subprocess(cmd) + return r.status == 0 + end +end + +function kill_pid(pid) + local cmd, r + if ON_WINDOWS then + cmd = { cancellable = false, args = { "taskkill", "/F", "/PID", tostring(pid) } } + else + cmd = { cancellable = false, args = { "kill", tostring(pid) } } + end + r = utils.subprocess(cmd) + return r.status == 0, r +end + +-- Find an executable in PATH or CWD with the given name +function find_executable(name) + local delim = ON_WINDOWS and ";" or ":" + + local pwd = os.getenv("PWD") or utils.getcwd() + local path = os.getenv("PATH") + + local env_path = pwd .. delim .. path -- Check CWD first + + local result, filename + for path_dir in env_path:gmatch("[^" .. delim .. "]+") do + filename = path_utils.join(path_dir, name) + if file_exists(filename) then + result = filename + break + end + end + + return result +end + +local ExecutableFinder = { path_cache = {} } +-- Searches for an executable and caches the result if any +function ExecutableFinder:get_executable_path(name, raw_name) + name = ON_WINDOWS and not raw_name and (name .. ".exe") or name + + if self.path_cache[name] == nil then + self.path_cache[name] = find_executable(name) or false + end + return self.path_cache[name] +end + +-- Format seconds to HH.MM.SS.sss +function format_time(seconds, sep, decimals) + decimals = decimals == nil and 3 or decimals + sep = sep and sep or ":" + local s = seconds + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + local second_format = string.format("%%0%d.%df", 2 + (decimals > 0 and decimals + 1 or 0), decimals) + + return string.format("%02d" .. sep .. "%02d" .. sep .. second_format, h, m, s) +end + +-- Format seconds to 1h 2m 3.4s +function format_time_hms(seconds, sep, decimals, force_full) + decimals = decimals == nil and 1 or decimals + sep = sep ~= nil and sep or " " + + local s = seconds + local h, s = divmod(s, 60 * 60) + local m, s = divmod(s, 60) + + if force_full or h > 0 then + return string.format("%dh" .. sep .. "%dm" .. sep .. "%." .. tostring(decimals) .. "fs", h, m, s) + elseif m > 0 then + return string.format("%dm" .. sep .. "%." .. tostring(decimals) .. "fs", m, s) + else + return string.format("%." .. tostring(decimals) .. "fs", s) + end +end + +-- Writes text on OSD and console +function log_info(txt, timeout) + timeout = timeout or 1.5 + msg.info(txt) + mp.osd_message(txt, timeout) +end + +-- Join table items, ala ({"a", "b", "c"}, "=", "-", ", ") => "=a-, =b-, =c-" +function join_table(source, before, after, sep) + before = before or "" + after = after or "" + sep = sep or ", " + local result = "" + for i, v in pairs(source) do + if not isempty(v) then + local part = before .. v .. after + if i == 1 then + result = part + else + result = result .. sep .. part + end + end + end + return result +end + +function wrap(s, char) + char = char or "'" + return char .. s .. char +end +-- Wraps given string into 'string' and escapes any 's in it +function escape_and_wrap(s, char, replacement) + char = char or "'" + replacement = replacement or "\\" .. char + return wrap(string.gsub(s, char, replacement), char) +end +-- Escapes single quotes in a string and wraps the input in single quotes +function escape_single_bash(s) + return escape_and_wrap(s, "'", "'\\''") +end + +-- Returns (a .. b) if b is not empty or nil +function joined_or_nil(a, b) + return not isempty(b) and (a .. b) or nil +end + +-- Put items from one table into another +function extend_table(target, source) + for i, v in pairs(source) do + table.insert(target, v) + end +end + +-- Creates a handle and filename for a temporary random file (in current directory) +function create_temporary_file(base, mode, suffix) + local handle, filename + suffix = suffix or "" + while true do + filename = base .. tostring(math.random(1, 5000)) .. suffix + handle = io.open(filename, "r") + if not handle then + handle = io.open(filename, mode) + break + end + io.close(handle) + end + return handle, filename +end + +function get_processor_count() + local proc_count + + if ON_WINDOWS then + proc_count = tonumber(os.getenv("NUMBER_OF_PROCESSORS")) + else + local cpuinfo_handle = io.open("/proc/cpuinfo") + if cpuinfo_handle ~= nil then + local cpuinfo_contents = cpuinfo_handle:read("*a") + local _, replace_count = cpuinfo_contents:gsub("processor", "") + proc_count = replace_count + end + end + + if proc_count and proc_count > 0 then + return proc_count + else + return nil + end +end + +function substitute_values(string, values) + local substitutor = function(match) + if match == "%" then + return "%" + else + -- nil is discarded by gsub + return values[match] + end + end + + local substituted = string:gsub("%%(.)", substitutor) + return substituted +end + +-- ASS HELPERS -- +function round_rect_top(ass, x0, y0, x1, y1, r) + local c = 0.551915024494 * r -- circle approximation + ass:move_to(x0 + r, y0) + ass:line_to(x1 - r, y0) -- top line + if r > 0 then + ass:bezier_curve(x1 - r + c, y0, x1, y0 + r - c, x1, y0 + r) -- top right corner + end + ass:line_to(x1, y1) -- right line + ass:line_to(x0, y1) -- bottom line + ass:line_to(x0, y0 + r) -- left line + if r > 0 then + ass:bezier_curve(x0, y0 + r - c, x0 + r - c, y0, x0 + r, y0) -- top left corner + end +end + +function round_rect(ass, x0, y0, x1, y1, rtl, rtr, rbr, rbl) + local c = 0.551915024494 + ass:move_to(x0 + rtl, y0) + ass:line_to(x1 - rtr, y0) -- top line + if rtr > 0 then + ass:bezier_curve(x1 - rtr + rtr * c, y0, x1, y0 + rtr - rtr * c, x1, y0 + rtr) -- top right corner + end + ass:line_to(x1, y1 - rbr) -- right line + if rbr > 0 then + ass:bezier_curve(x1, y1 - rbr + rbr * c, x1 - rbr + rbr * c, y1, x1 - rbr, y1) -- bottom right corner + end + ass:line_to(x0 + rbl, y1) -- bottom line + if rbl > 0 then + ass:bezier_curve(x0 + rbl - rbl * c, y1, x0, y1 - rbl + rbl * c, x0, y1 - rbl) -- bottom left corner + end + ass:line_to(x0, y0 + rtl) -- left line + if rtl > 0 then + ass:bezier_curve(x0, y0 + rtl - rtl * c, x0 + rtl - rtl * c, y0, x0 + rtl, y0) -- top left corner + end +end +--[[ + A slightly more advanced option parser for scripts. + It supports documenting the options, and can export an example config. + It also can rewrite the config file with overrides, preserving the + original lines and appending changes to the end, along with profiles. + + Does not depend on other libs. +]] +-- + +local OptionParser = {} +OptionParser.__index = OptionParser + +setmetatable(OptionParser, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function OptionParser.new(identifier) + local self = setmetatable({}, OptionParser) + + self.identifier = identifier + self.config_file = self:_get_config_file(identifier) + + self.OVERRIDE_START = "# Script-saved overrides below this line. Edits will be lost!" + + -- All the options contained, as a list + self.options_list = {} + -- All the options contained, as a table with keys. See add_option + self.options = {} + + self.default_profile = { name = "default", values = {}, loaded = {}, config_lines = {} } + self.profiles = {} + + self.active_profile = self.default_profile + + -- Recusing metatable magic to wrap self.values.key.sub_key into + -- self.options["key.sub_key"].value, with support for assignments as well + function get_value_or_mapper(key) + local cur_option = self.options[key] + + if cur_option then + -- Wrap tables + if cur_option.type == "table" then + return setmetatable({}, { + __index = function(t, sub_key) + return get_value_or_mapper(key .. "." .. sub_key) + end, + __newindex = function(t, sub_key, value) + local sub_option = self.options[key .. "." .. sub_key] + if sub_option and sub_option.type ~= "table" then + self.active_profile.values[key .. "." .. sub_key] = value + end + end, + }) + else + return self.active_profile.values[key] + end + end + end + + -- Same recusing metatable magic to get the .default + function get_default_or_mapper(key) + local cur_option = self.options[key] + + if cur_option then + if cur_option.type == "table" then + return setmetatable({}, { + __index = function(t, sub_key) + return get_default_or_mapper(key .. "." .. sub_key) + end, + }) + else + return cur_option.default + -- return self.active_profile.values[key] + end + end + end + + -- Easy lookups for values and defaults + self.values = setmetatable({}, { + __index = function(t, key) + return get_value_or_mapper(key) + end, + __newindex = function(t, key, value) + local option = self.options[key] + if option then + -- option.value = value + self.active_profile.values[key] = value + end + end, + }) + + self.defaults = setmetatable({}, { + __index = function(t, key) + return get_default_or_mapper(key) + end, + }) + + -- Hacky way to run after the script is initialized and options (hopefully) added + mp.add_timeout(0, function() + -- Handle a '--script-opts identifier-example-config=example.conf' to save an example config to a file + local example_dump_filename = mp.get_opt(self.identifier .. "-example-config") + if example_dump_filename then + self:save_example_options(example_dump_filename) + end + local explain_config = mp.get_opt(self.identifier .. "-explain-config") + if explain_config then + self:explain_options() + end + + if (example_dump_filename or explain_config) and mp.get_property_native("options/idle") then + msg.info("Exiting.") + mp.commandv("quit") + end + end) + + return self +end + +function OptionParser:activate_profile(profile_name) + local chosen_profile = nil + if profile_name then + for i, profile in ipairs(self.profiles) do + if profile.name == profile_name then + chosen_profile = profile + break + end + end + else + chosen_profile = self.default_profile + end + + if chosen_profile then + self.active_profile = chosen_profile + end +end + +function OptionParser:add_option(key, default, description, pad_before) + if self.options[key] ~= nil then + -- Already exists! + return nil + end + + local option_index = #self.options_list + 1 + local option_type = type(default) + + -- Check if option is an array + if option_type == "table" then + if default._array then + option_type = "array" + end + default._array = nil + end + + local option = { + index = option_index, + type = option_type, + key = key, + default = default, + + description = description, + pad_before = pad_before, + } + + self.options_list[option_index] = option + + -- table-options are just containers for sub-options and have no value + if option_type == "table" then + option.default = nil + + -- Add sub-options + for i, sub_option_data in ipairs(default) do + local sub_key = sub_option_data[1] + sub_option_data[1] = key .. "." .. sub_key + local sub_option = self:add_option(unpack(sub_option_data)) + end + end + + if key then + self.options[key] = option + self.default_profile.values[option.key] = option.default + end + + return option +end + +function OptionParser:add_options(list_of_options) + for i, option_args in ipairs(list_of_options) do + self:add_option(unpack(option_args)) + end +end + +function OptionParser:restore_defaults() + for key, option in pairs(self.options) do + if option.type ~= "table" then + self.active_profile.values[option.key] = option.default + end + end +end + +function OptionParser:restore_loaded() + for key, option in pairs(self.options) do + if option.type ~= "table" then + -- Non-default profiles will have an .loaded entry for all options + local value = self.active_profile.loaded[option.key] + if value == nil then + value = option.default + end + self.active_profile.values[option.key] = value + end + end +end + +function OptionParser:_get_config_file(identifier) + local config_filename = "script-opts/" .. identifier .. ".conf" + local config_file = mp.find_config_file(config_filename) + + if not config_file then + config_filename = "script-opts/" .. identifier .. ".conf" + config_file = mp.find_config_file(config_filename) + + if config_file then + msg.warn("lua-settings/ is deprecated, use directory script-opts/") + end + end + + return config_file +end + +function OptionParser:value_to_string(value) + if type(value) == "boolean" then + if value then + value = "yes" + else + value = "no" + end + elseif type(value) == "table" then + return utils.format_json(value) + end + return tostring(value) +end + +function OptionParser:string_to_value(option_type, value) + if option_type == "boolean" then + if value == "yes" or value == "true" then + value = true + elseif value == "no" or value == "false" then + value = false + else + -- can't parse as boolean + value = nil + end + elseif option_type == "number" then + value = tonumber(value) + if value == nil then + -- Can't parse as number + end + elseif option_type == "array" then + value = utils.parse_json(value) + end + return value +end + +function OptionParser:get_profile(profile_name) + for i, profile in ipairs(self.profiles) do + if profile.name == profile_name then + return profile + end + end +end + +function OptionParser:create_profile(profile_name, base_on_original) + if not self:get_profile(profile_name) then + new_profile = { name = profile_name, values = {}, loaded = {}, config_lines = {} } + + if base_on_original then + -- Copy values from default config + for k, v in pairs(self.default_profile.values) do + new_profile.values[k] = v + end + for k, v in pairs(self.default_profile.loaded) do + new_profile.loaded[k] = v + end + else + -- Copy current values, but not loaded + for k, v in pairs(self.active_profile.values) do + new_profile.values[k] = v + end + end + + table.insert(self.profiles, new_profile) + return new_profile + end +end + +function OptionParser:load_options() + if not self.config_file then + return + end + local file = io.open(self.config_file, "r") + if not file then + return + end + + local trim = function(text) + return (text:gsub("^%s*(.-)%s*$", "%1")) + end + + local current_profile = self.default_profile + local override_reached = false + local line_index = 1 + + -- Read all lines in advance + local lines = {} + for line in file:lines() do + table.insert(lines, line) + end + file:close() + + local total_lines = #lines + + while line_index < total_lines + 1 do + local line = lines[line_index] + + local profile_name = line:match("^%[(..-)%]$") + + if line == self.OVERRIDE_START then + override_reached = true + elseif line:find("#") == 1 then + -- Skip comments + elseif profile_name then + current_profile = self:get_profile(profile_name) or self:create_profile(profile_name, true) + override_reached = false + else + local key, value = line:match("^(..-)=(.+)$") + if key then + key = trim(key) + value = trim(value) + + local option = self.options[key] + if not option then + msg.warn(("%s:%d ignoring unknown key '%s'"):format(self.config_file, line_index, key)) + elseif option.type == "table" then + msg.warn(("%s:%d ignoring value for table-option %s"):format(self.config_file, line_index, key)) + else + -- If option is an array, make sure we read all lines + if option.type == "array" then + local start_index = line_index + -- Read lines until one ends with ] + while not value:match("%]%s*$") do + line_index = line_index + 1 + if line_index > total_lines then + msg.error( + ("%s:%d non-ending %s for key '%s'"):format( + self.config_file, + start_index, + option.type, + key + ) + ) + end + value = value .. trim(lines[line_index]) + end + end + local parsed_value = self:string_to_value(option.type, value) + + if parsed_value == nil then + msg.error( + ("%s:%d error parsing value '%s' for key '%s' (as %s)"):format( + self.config_file, + line_index, + value, + key, + option.type + ) + ) + else + current_profile.values[option.key] = parsed_value + if not override_reached then + current_profile.loaded[option.key] = parsed_value + end + end + end + end + end + + if not override_reached and not profile_name then + table.insert(current_profile.config_lines, line) + end + + line_index = line_index + 1 + end +end + +function OptionParser:save_options() + if not self.config_file then + return nil, "no configuration file found" + end + + local file = io.open(self.config_file, "w") + if not file then + return nil, "unable to open configuration file for writing" + end + + local profiles = { self.default_profile } + for i, profile in ipairs(self.profiles) do + table.insert(profiles, profile) + end + + local out_lines = {} + + local add_linebreak = function() + if out_lines[#out_lines] ~= "" then + table.insert(out_lines, "") + end + end + + for profile_index, profile in ipairs(profiles) do + local profile_override_lines = {} + for option_index, option in ipairs(self.options_list) do + local option_value = profile.values[option.key] + local option_loaded = profile.loaded[option.key] + + if option_loaded == nil then + option_loaded = self.default_profile.loaded[option.key] + end + if option_loaded == nil then + option_loaded = option.default + end + + -- If value is different from default AND loaded value, store it in array + if option.key then + if option_value ~= option_loaded then + table.insert( + profile_override_lines, + ("%s=%s"):format(option.key, self:value_to_string(option_value)) + ) + end + end + end + + if (#profile.config_lines > 0 or #profile_override_lines > 0) and profile ~= self.default_profile then + -- Write profile name, if this is not default profile + add_linebreak() + table.insert(out_lines, ("[%s]"):format(profile.name)) + end + + -- Write original config lines + for line_index, line in ipairs(profile.config_lines) do + table.insert(out_lines, line) + end + -- end + + if #profile_override_lines > 0 then + -- Add another newline before the override comment, if needed + add_linebreak() + + table.insert(out_lines, self.OVERRIDE_START) + for override_line_index, override_line in ipairs(profile_override_lines) do + table.insert(out_lines, override_line) + end + end + end + + -- Add a final linebreak if needed + add_linebreak() + + file:write(table.concat(out_lines, "\n")) + file:close() + + return true +end + +function OptionParser:get_default_config_lines() + local example_config_lines = {} + + for option_index, option in ipairs(self.options_list) do + if option.pad_before then + table.insert(example_config_lines, "") + end + + if option.description then + for description_line in option.description:gmatch("[^\r\n]+") do + table.insert(example_config_lines, ("# " .. description_line)) + end + end + if option.key and option.type ~= "table" then + table.insert(example_config_lines, ("%s=%s"):format(option.key, self:value_to_string(option.default))) + end + end + return example_config_lines +end + +function OptionParser:explain_options() + local example_config_lines = self:get_default_config_lines() + msg.info(table.concat(example_config_lines, "\n")) +end + +function OptionParser:save_example_options(filename) + local file = io.open(filename, "w") + if not file then + msg.error("Unable to open file '" .. filename .. "' for writing") + else + local example_config_lines = self:get_default_config_lines() + file:write(table.concat(example_config_lines, "\n")) + file:close() + msg.info("Wrote example config to file '" .. filename .. "'") + end +end +local SCRIPT_NAME = "mpv_crop_script" + +local SCRIPT_KEYBIND = "c" +local SCRIPT_HANDLER = "crop-screenshot" + +-------------------- +-- Script options -- +-------------------- + +local script_options = OptionParser(SCRIPT_NAME) +local option_values = script_options.values + +script_options:add_options({ + { nil, nil, "mpv_crop_script.lua options and default values" }, + { nil, nil, "Output options #", true }, + { + "output_template", + "${filename}${!is_image: ${#pos:%02h.%02m.%06.3s}}${!full: ${crop_w}x${crop_h}} ${%unique:%03d}.${ext}", + "Filename output template. See README.md for property expansion documentation.", + }, + { + nil, + nil, + [[Script-provided properties: + filename - filename without extension + file_ext - original extension without leading dot + path - original file path + pos - playback time + ext - output file extension without leading dot + crop_w - crop width + crop_h - crop height + crop_x - left + crop_y - top + crop_x2 - right + crop_y2 - bottom + full - boolean denoting a full (temporary) screenshot instead of crop + is_image - boolean denoting the source file is likely an image (zero duration and position) + unique - counter that will increase per each existing filename, until a unique name is found]], + }, + + { "output_format", "png", "Format (encoder) to save final crops in. For example, png, mjpeg, targa, bmp" }, + { "output_extension", "", "Output extension. Leave blank to try to choose from the encoder (if supported)" }, + + { + "create_directories", + false, + "Whether to create the directories in the final output path (defined by output_template)", + }, + { + "skip_screenshot_for_images", + true, + "If the current file is an image, skip taking a temporary screenshot and crop the image directly", + }, + { "keep_original", false, "Keep the full-sized temporary screenshot as well" }, + + { nil, nil, "Crop tool options #", true }, + { + "overlay_transparency", + 160, + "Transparency (0 - opaque, 255 - transparent) of the dim overlay on the non-cropped area", + }, + { "overlay_lightness", 0, "Ligthness (0 - black, 255 - white) of the dim overlay on the non-cropped area" }, + { "draw_mouse", false, "Draw the crop crosshair" }, + { "guide_type", "none", "Crop guide type. One of: none, grid, center" }, + { "color_invert", false, "Use black lines instead of white for the crop frame and crosshair" }, + { + "auto_invert", + false, + "Try to check if video is light or dark upon opening crop tool, and invert the colors if necessary", + }, + + { nil, nil, "Misc options #", true }, + { + "warn_about_template", + true, + "Warn about output_template missing ${ext}, to ensure the extension is not missing", + }, + { "disable_keybind", false, "Disable the built-in keybind" }, +}) + +-- Read user-given options, if any +script_options:load_options() +--[[ + DisplayState keeps track of the current display state, and can + handle mapping between video-space coords and display-space coords. + Handles panscan and offsets and aligns and all that, following what + mpv itself does (video/out/aspect.c). + + Does not depend on other libs. +]] +-- + +local DisplayState = {} +DisplayState.__index = DisplayState + +setmetatable(DisplayState, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function DisplayState.new() + local self = setmetatable({}, DisplayState) + + self:reset() + + return self +end + +function DisplayState:reset() + self.screen = {} -- Display (window, fullscreen) size + self.video = {} -- Video size + self.scale = {} -- video / screen + self.bounds = {} -- Video rect within display + + self.screen_ready = false + self.video_ready = false + + -- Stores internal display state (panscan, align, zoom etc) + self.current_state = nil +end + +function DisplayState:setup_events() + mp.register_event("file-loaded", function() + self:event_file_loaded() + end) +end + +function DisplayState:event_file_loaded() + self:reset() + self:recalculate_bounds(true) +end + +-- Turns screen-space XY to video XY (can go negative) +function DisplayState:screen_to_video(x, y) + local nx = (x - self.bounds.left) * self.scale.x + local ny = (y - self.bounds.top) * self.scale.y + return nx, ny +end + +-- Turns video-space XY to screen XY +function DisplayState:video_to_screen(x, y) + local nx = (x / self.scale.x) + self.bounds.left + local ny = (y / self.scale.y) + self.bounds.top + return nx, ny +end + +function DisplayState:_collect_display_state() + local screen_w, screen_h, screen_aspect = mp.get_osd_size() + + local state = { + screen_w = screen_w, + screen_h = screen_h, + screen_aspect = screen_aspect, + + video_w = mp.get_property_native("dwidth"), + video_h = mp.get_property_native("dheight"), + + video_w_raw = mp.get_property_native("video-out-params/w"), + video_h_raw = mp.get_property_native("video-out-params/h"), + + panscan = mp.get_property_native("panscan"), + video_zoom = mp.get_property_native("video-zoom"), + video_unscaled = mp.get_property_native("video-unscaled"), + + video_align_x = mp.get_property_native("video-align-x"), + video_align_y = mp.get_property_native("video-align-y"), + + video_pan_x = mp.get_property_native("video-pan-x"), + video_pan_y = mp.get_property_native("video-pan-y"), + + fullscreen = mp.get_property_native("fullscreen"), + keepaspect = mp.get_property_native("keepaspect"), + keepaspect_window = mp.get_property_native("keepaspect-window"), + } + + return state +end + +function DisplayState:_state_changed(state) + if self.current_state == nil then + return true + end + + for k in pairs(state) do + if state[k] ~= self.current_state[k] then + return true + end + end + return false +end + +function DisplayState:recalculate_bounds(forced) + local new_state = self:_collect_display_state() + if not (forced or self:_state_changed(new_state)) then + -- Early out + return self.screen_ready + end + self.current_state = new_state + + -- Store screen dimensions + self.screen.width = new_state.screen_w + self.screen.height = new_state.screen_h + self.screen.ratio = new_state.screen_w / new_state.screen_h + self.screen_ready = true + + -- Video dimensions + if new_state.video_w and new_state.video_h then + self.video.width = new_state.video_w + self.video.height = new_state.video_h + self.video.ratio = new_state.video_w / new_state.video_h + + -- This magic has been adapted from mpv's own video/out/aspect.c + + if new_state.keepaspect then + local scaled_w, scaled_h = self:_aspect_calc_panscan(new_state) + local video_left, video_right = self:_split_scaling( + new_state.screen_w, + scaled_w, + new_state.video_zoom, + new_state.video_align_x, + new_state.video_pan_x + ) + local video_top, video_bottom = self:_split_scaling( + new_state.screen_h, + scaled_h, + new_state.video_zoom, + new_state.video_align_y, + new_state.video_pan_y + ) + self.bounds = { + left = video_left, + right = video_right, + + top = video_top, + bottom = video_bottom, + + width = video_right - video_left, + height = video_bottom - video_top, + } + else + self.bounds = { + left = 0, + top = 0, + right = self.screen.width, + bottom = self.screen.height, + + width = self.screen.width, + height = self.screen.height, + } + end + + self.scale.x = new_state.video_w_raw / self.bounds.width + self.scale.y = new_state.video_h_raw / self.bounds.height + + self.video_ready = true + end + + return self.screen_ready +end + +function DisplayState:_aspect_calc_panscan(state) + -- From video/out/aspect.c + local f_width = state.screen_w + local f_height = (state.screen_w / state.video_w) * state.video_h + + if f_height > state.screen_h or f_height < state.video_h_raw then + local tmp_w = (state.screen_h / state.video_h) * state.video_w + if tmp_w <= state.screen_w then + f_height = state.screen_h + f_width = tmp_w + end + end + + local vo_panscan_area = state.screen_h - f_height + + local f_w = f_width / f_height + local f_h = 1 + if vo_panscan_area == 0 then + vo_panscan_area = state.screen_w - f_width + f_w = 1 + f_h = f_height / f_width + end + + if state.video_unscaled then + vo_panscan_area = 0 + if + state.video_unscaled ~= "downscale-big" + or ((state.video_w <= state.screen_w) and (state.video_h <= state.screen_h)) + then + f_width = state.video_w + f_height = state.video_h + end + end + + local scaled_w = math.floor(f_width + vo_panscan_area * state.panscan * f_w) + local scaled_h = math.floor(f_height + vo_panscan_area * state.panscan * f_h) + return scaled_w, scaled_h +end + +function DisplayState:_split_scaling(dst_size, scaled_src_size, zoom, align, pan) + -- From video/out/aspect.c as well + scaled_src_size = math.floor(scaled_src_size * 2 ^ zoom) + align = (align + 1) / 2 + + local dst_start = (dst_size - scaled_src_size) * align + pan * scaled_src_size + local dst_end = dst_start + scaled_src_size + + -- We don't actually want these - we want to go out of bounds! + -- dst_start = math.max(0, dst_start) + -- dst_end = math.min(dst_size, dst_end) + + return math.floor(dst_start), math.floor(dst_end) +end +--[[ + ASSCropper is a tool to get crop values with a visual tool + that handles mouse clicks and drags to manipulate a crop box, + with a crosshair, guides, etc. + + Indirectly depends on DisplayState (as a given instance). +]] +-- + +local ASSCropper = {} +ASSCropper.__index = ASSCropper + +setmetatable(ASSCropper, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function ASSCropper.new(display_state) + local self = setmetatable({}, ASSCropper) + local script_name = mp.get_script_name() + self.keybind_group = script_name .. "_asscropper_binds" + self.cropdetect_label = script_name .. "_asscropper_cropdetect" + self.blackframe_label = script_name .. "_asscropper_blackframe" + self.crop_label = script_name .. "_asscropper_crop" + + self.display_state = display_state + + self.tick_callback = nil + self.tick_timer = mp.add_periodic_timer(1 / 60, function() + if self.tick_callback then + self.tick_callback() + end + end) + self.tick_timer:stop() + + self.text_size = 18 + + self.overlay_transparency = 160 + self.overlay_lightness = 0 + + self.corner_size = 40 + self.corner_required_size = self.corner_size * 3 + + self.guide_type_names = { + [0] = "No guides", + [1] = "Grid guides", + [2] = "Center guides", + } + self.guide_type_count = 3 + + self.default_options = { + even_dimensions = false, + guide_type = 0, + draw_mouse = false, + draw_help = true, + color_invert = false, + auto_invert = false, + } + self.options = default_options + + self.active = false + + self.mouse_screen = { x = 0, y = 0 } + self.mouse_video = { x = 0, y = 0 } + + -- Crop in video-space + self.current_crop = nil + + self.dragging = 0 + self.drag_start = { x = 0, y = 0 } + self.restrict_ratio = false + + self.testing_crop = false + + self.detecting_crop = nil + self.cropdetect_wait = nil + self.cropdetect_timeout = nil + + self.detecting_blackframe = nil + self.blackframe_wait = nil + self.blackframe_timeout = nil + + self.nudges = { + NUDGE_LEFT = { -1, 0, -1, 0 }, + NUDGE_UP = { 0, -1, 0, -1 }, + NUDGE_RIGHT = { 1, 0, 1, 0 }, + NUDGE_DOWN = { 0, 1, 0, 1 }, + } + + self.resizes = { + SHRINK_LEFT = { 1, 0, 0, 0 }, + SHRINK_TOP = { 0, 1, 0, 0 }, + SHRINK_RIGHT = { 0, 0, -1, 0 }, + SHRINK_BOT = { 0, 0, 0, -1 }, + + GROW_LEFT = { -1, 0, 0, 0 }, + GROW_TOP = { 0, -1, 0, 0 }, + GROW_RIGHT = { 0, 0, 1, 0 }, + GROW_BOT = { 0, 0, 0, 1 }, + } + + self._key_binds = { + { + "mouse_move", + function() + self:update_mouse_position() + end, + }, + { + "mouse_btn0", + function(e) + self:on_mouse("mouse_btn0", e) + end, + { complex = true }, + }, + { + "shift+mouse_btn0", + function(e) + self:on_mouse("mouse_btn0", e, true) + end, + { complex = true }, + }, + + { + "ctrl+shift+c", + function() + self:key_event("CROSSHAIR") + end, + }, + { + "ctrl+shift+d", + function() + self:key_event("CROP_DETECT") + end, + }, + { + "ctrl+shift+x", + function() + self:key_event("GUIDES") + end, + }, + { + "ctrl+shift+t", + function() + self:key_event("TEST") + end, + }, + { + "ctrl+shift+z", + function() + self:key_event("INVERT") + end, + }, + + { + "shift+left", + function() + self:key_event("NUDGE_LEFT") + end, + { repeatable = true }, + }, + { + "shift+up", + function() + self:key_event("NUDGE_UP") + end, + { repeatable = true }, + }, + { + "shift+right", + function() + self:key_event("NUDGE_RIGHT") + end, + { repeatable = true }, + }, + { + "shift+down", + function() + self:key_event("NUDGE_DOWN") + end, + { repeatable = true }, + }, + + { + "ctrl+left", + function() + self:key_event("GROW_LEFT") + end, + { repeatable = true }, + }, + { + "ctrl+up", + function() + self:key_event("GROW_TOP") + end, + { repeatable = true }, + }, + { + "ctrl+right", + function() + self:key_event("SHRINK_LEFT") + end, + { repeatable = true }, + }, + { + "ctrl+down", + function() + self:key_event("SHRINK_TOP") + end, + { repeatable = true }, + }, + + { + "ctrl+shift+left", + function() + self:key_event("SHRINK_RIGHT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+up", + function() + self:key_event("SHRINK_BOT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+right", + function() + self:key_event("GROW_RIGHT") + end, + { repeatable = true }, + }, + { + "ctrl+shift+down", + function() + self:key_event("GROW_BOT") + end, + { repeatable = true }, + }, + + { + "ENTER", + function() + self:key_event("ENTER") + end, + }, + { + "ESC", + function() + self:key_event("ESC") + end, + }, + } + + self._keys_bound = false + + for k, v in pairs(self._key_binds) do + -- Insert a key name into the tables + table.insert(v, 2, self.keybind_group .. "_key_" .. v[1]) + end + + return self +end + +function ASSCropper:enable_key_bindings() + if not self._keys_bound then + for k, v in pairs(self._key_binds) do + mp.add_forced_key_binding(unpack(v)) + end + -- Clear "allow-vo-dragging" + mp.input_enable_section("input_forced_" .. mp.script_name) + self._keys_bound = true + end +end + +function ASSCropper:disable_key_bindings() + for k, v in pairs(self._key_binds) do + mp.remove_key_binding(v[2]) -- remove by name + end + self._keys_bound = false +end + +function ASSCropper:finalize_crop() + if self.current_crop ~= nil then + local x1, x2 = self.current_crop[1].x, self.current_crop[2].x + local y1, y2 = self.current_crop[1].y, self.current_crop[2].y + + self.current_crop.x, self.current_crop.y = x1, y1 + self.current_crop.w, self.current_crop.h = x2 - x1, y2 - y1 + + if self.options.even_dimensions then + self.current_crop.w = self.current_crop.w - (self.current_crop.w % 2) + self.current_crop.h = self.current_crop.h - (self.current_crop.h % 2) + end + + self.current_crop.x1, self.current_crop.x2 = x1, x1 + self.current_crop.w + self.current_crop.y1, self.current_crop.y2 = y1, y1 + self.current_crop.h + + self.current_crop[2].x, self.current_crop[2].y = self.current_crop.x2, self.current_crop.y2 + end +end + +function ASSCropper:key_event(name) + if name == "ENTER" then + self:stop_crop(false) + + self:finalize_crop() + + if self.callback_on_crop == nil then + mp.set_osd_ass(0, 0, "") + else + self.callback_on_crop(self.current_crop) + end + elseif name == "ESC" then + self:stop_crop(true) + + if self.callback_on_cancel == nil then + mp.set_osd_ass(0, 0, "") + else + self.callback_on_cancel() + end + elseif name == "TEST" then + self:toggle_testing() + elseif not self.testing_crop then + if name == "CROP_DETECT" then + self:toggle_crop_detect() + elseif name == "CROSSHAIR" then + self.options.draw_mouse = not self.options.draw_mouse + elseif name == "INVERT" then + self.options.color_invert = not self.options.color_invert + elseif name == "GUIDES" then + self.options.guide_type = (self.options.guide_type + 1) % self.guide_type_count + mp.osd_message(self.guide_type_names[self.options.guide_type]) + elseif self.nudges[name] then + self:nudge(true, unpack(self.nudges[name])) + elseif self.resizes[name] then + self:nudge(false, unpack(self.resizes[name])) + end + end +end + +function ASSCropper:nudge(keep_size, left, top, right, bottom) + if self.current_crop == nil then + return + end + + local x1, y1 = self.current_crop[1].x, self.current_crop[1].y + local x2, y2 = self.current_crop[2].x, self.current_crop[2].y + + local w, h = x2 - x1, y2 - y1 + if not keep_size then + w, h = 0, 0 + + if self.options.even_dimensions then + left = left * 2 + top = top * 2 + right = right * 2 + bottom = bottom * 2 + end + end + + local vw, vh = self.display_state.video.width, self.display_state.video.height + + x1 = math.max(0, math.min(vw - w, x1 + left)) + y1 = math.max(0, math.min(vh - h, y1 + top)) + + x2 = math.max(w, math.min(vw, x2 + right)) + y2 = math.max(h, math.min(vh, y2 + bottom)) + + local x_offset = math.max(0, 0 - x1) - math.max(0, x2 - vw) + local y_offset = math.max(0, 0 - y1) - math.max(0, y2 - vh) + + x1 = x1 + x_offset + y1 = y1 + y_offset + x2 = x2 + x_offset + y2 = y2 + y_offset + + self.current_crop[1].x, self.current_crop[2].x = order_pair(x1, x2) + self.current_crop[1].y, self.current_crop[2].y = order_pair(y1, y2) +end + +function ASSCropper:blackframe_stop() + if self.detecting_blackframe then + self.detecting_blackframe:stop() + self.detecting_blackframe = nil + + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.blackframe_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + end +end + +function ASSCropper:toggle_testing() + if self.testing_crop then + self:stop_testing() + else + self:start_testing() + end +end + +function ASSCropper:start_testing() + if not self.testing_crop then + local cw = self.current_crop and (self.current_crop[2].x - self.current_crop[1].x) or 0 + local ch = self.current_crop and (self.current_crop[2].y - self.current_crop[1].y) or 0 + + if cw == 0 or ch == 0 then + return mp.osd_message("Can't test current crop") + end + + self:cropdetect_stop() + self:blackframe_stop() + + local crop_filter = ("@%s:crop=w=%d:h=%d:x=%d:y=%d"):format( + self.crop_label, + cw, + ch, + self.current_crop[1].x, + self.current_crop[1].y + ) + local ret = mp.commandv("vf", "add", crop_filter) + if ret then + self.testing_crop = true + end + end +end + +function ASSCropper:stop_testing() + if self.testing_crop then + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.crop_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + self.testing_crop = false + end +end + +function ASSCropper:blackframe_check() + local blackframe_metadata = mp.get_property_native("vf-metadata/" .. self.blackframe_label) + local black_percentage = tonumber(blackframe_metadata["lavfi.blackframe.pblack"]) + + local now = mp.get_time() + if black_percentage ~= nil and now >= self.blackframe_wait then + self:blackframe_stop() + + self.options.color_invert = black_percentage < 50 + elseif now > self.blackframe_timeout then + -- Couldn't get blackframe metadata in time! + self:blackframe_stop() + end +end + +function ASSCropper:blackframe_start() + self:blackframe_stop() + if not self.detecting_blackframe then + local blackframe_filter = ("@%s:blackframe=amount=%d:threshold=%d"):format(self.blackframe_label, 0, 128) + + local ret = mp.commandv("vf", "add", blackframe_filter) + if ret then + self.blackframe_wait = mp.get_time() + 0.15 + self.blackframe_timeout = self.blackframe_wait + 1 + + self.detecting_blackframe = mp.add_periodic_timer(1 / 10, function() + self:blackframe_check() + end) + end + end +end + +function ASSCropper:cropdetect_stop() + if self.detecting_crop then + self.detecting_crop:stop() + self.detecting_crop = nil + self.cropdetect_wait = nil + self.cropdetect_timeout = nil + + local filters = mp.get_property_native("vf") + for i, filter in ipairs(filters) do + if filter.label == self.cropdetect_label then + table.remove(filters, i) + end + end + mp.set_property_native("vf", filters) + end +end + +function ASSCropper:cropdetect_check() + local cropdetect_metadata = mp.get_property_native("vf-metadata/" .. self.cropdetect_label) + local get_n = function(s) + return tonumber(cropdetect_metadata["lavfi.cropdetect." .. s]) + end + + local now = mp.get_time() + if not isempty(cropdetect_metadata) and now >= self.cropdetect_wait then + self:cropdetect_stop() + + self.current_crop = { + { x = get_n("x1"), y = get_n("y1") }, + { x = get_n("x2") + 1, y = get_n("y2") + 1 }, + } + + mp.osd_message("Crop detected") + elseif now > self.cropdetect_timeout then + mp.osd_message("Crop detect timed out") + self:cropdetect_stop() + end +end + +function ASSCropper:toggle_crop_detect() + if self.detecting_crop then + self:cropdetect_stop() + mp.osd_message("Cancelled crop detect") + else + local cropdetect_filter = ("@%s:cropdetect=limit=%f:round=2:reset=0"):format(self.cropdetect_label, 30 / 255) + + local ret = mp.commandv("vf", "add", cropdetect_filter) + if not ret then + mp.osd_message("Crop detect failed") + else + self.cropdetect_wait = mp.get_time() + 0.2 + self.cropdetect_timeout = self.cropdetect_wait + 1.5 + + mp.osd_message("Starting automatic crop detect") + self.detecting_crop = mp.add_periodic_timer(1 / 10, function() + self:cropdetect_check() + end) + end + end +end + +function ASSCropper:start_crop(options, on_crop, on_cancel) + -- Refresh display state + self.display_state:recalculate_bounds(true) + if self.display_state.video_ready then + self.active = true + self.tick_timer:resume() + + self.options = {} + + for k, v in pairs(self.default_options) do + self.options[k] = v + end + for k, v in pairs(options or {}) do + self.options[k] = v + end + + self.callback_on_crop = on_crop + self.callback_on_cancel = on_cancel + + self.dragging = 0 + + self:enable_key_bindings() + self:update_mouse_position() + + if self.options.auto_invert then + self:blackframe_start() + end + end +end + +function ASSCropper:stop_crop(clear) + self.active = false + self.tick_timer:stop() + + self:cropdetect_stop() + self:blackframe_stop() + self:stop_testing() + + self:disable_key_bindings() + if clear then + self.current_crop = nil + end +end + +function ASSCropper:on_tick() + -- Unused, for debugging + if self.active then + self.display_state:recalculate_bounds() + self:render() + end +end + +function ASSCropper:update_mouse_position() + -- These are real on-screen coords. + self.mouse_screen.x, self.mouse_screen.y = mp.get_mouse_pos() + + if self.display_state:recalculate_bounds() and self.display_state.video_ready then + -- These are on-video coords. + local mx, my = self.display_state:screen_to_video(self.mouse_screen.x, self.mouse_screen.y) + self.mouse_video.x = mx + self.mouse_video.y = my + end +end + +function ASSCropper:get_hitboxes(crop_box) + crop_box = crop_box or self.current_crop + if crop_box == nil then + return nil + end + + local x1, x2 = order_pair(crop_box[1].x, crop_box[2].x) + local y1, y2 = order_pair(crop_box[1].y, crop_box[2].y) + local w, h = math.abs(x2 - x1), math.abs(y2 - y1) + + -- Corner and required corner size in videospace pixels + local mult = math.min(self.display_state.scale.x, self.display_state.scale.y) + local videospace_corner_size = self.corner_size * mult + local videospace_required_size = self.corner_required_size * mult + + local handles_outside = (math.min(w, h) <= videospace_required_size) + + local hitbox_bases = { + { x1, y2, x1, y2 }, -- BL + { x1, y2, x2, y2 }, -- B + { x2, y2, x2, y2 }, -- BR + + { x1, y1, x1, y2 }, -- L + { x1, y1, x2, y2 }, -- Center + { x2, y1, x2, y2 }, -- R + + { x1, y1, x1, y1 }, -- TL + { x1, y1, x2, y1 }, -- T + { x2, y1, x2, y1 }, -- TR + } + + local hitbox_mults + if handles_outside then + hitbox_mults = { + { -1, 0, 0, 1 }, + { 0, 0, 0, 1 }, + { 0, 0, 1, 1 }, + + { -1, 0, 0, 0 }, + { 0, 0, 0, 0 }, + { 0, 0, 1, 0 }, + + { -1, -1, 0, 0 }, + { 0, -1, 0, 0 }, + { 0, -1, 1, 0 }, + } + else + hitbox_mults = { + { 0, -1, 1, 0 }, + { 1, -1, -1, 0 }, + { -1, -1, 0, 0 }, + + { 0, 1, 1, -1 }, + { 1, 1, -1, -1 }, + { -1, 1, 0, -1 }, + + { 0, 0, 1, 1 }, + { 1, 0, -1, 1 }, + { -1, 0, 0, 1 }, + } + end + + local hitboxes = {} + for index, hitbox_base in ipairs(hitbox_bases) do + local hitbox_mult = hitbox_mults[index] + + hitboxes[index] = { + hitbox_base[1] + hitbox_mult[1] * videospace_corner_size, + hitbox_base[2] + hitbox_mult[2] * videospace_corner_size, + hitbox_base[3] + hitbox_mult[3] * videospace_corner_size, + hitbox_base[4] + hitbox_mult[4] * videospace_corner_size, + } + end + -- Pseudobox to easily pass the original crop box + hitboxes[10] = { x1, y1, x2, y2 } + + return hitboxes +end + +function ASSCropper:hit_test(hitboxes, position) + if hitboxes == nil then + return 0 + else + local px, py = position.x, position.y + + for i = 1, 9 do + local hb = hitboxes[i] + + if (px >= hb[1] and px < hb[3]) and (py >= hb[2] and py < hb[4]) then + return i + end + end + -- No hits + return 0 + end +end + +function ASSCropper:on_mouse(button, event, shift_down) + if not (event.event == "up" or event.event == "down") then + return + end + mouse_down = event.event == "down" + shift_down = shift_down or false + + if button == "mouse_btn0" and self.active and not self.detecting_crop and not self.testing_crop then + local mouse_pos = { x = self.mouse_video.x, y = self.mouse_video.y } + + -- Helpers + local xy_same = function(a, b) + return a.x == b.x and a.y == b.y + end + local xy_distance = function(a, b) + local dx = a.x - b.x + local dy = a.y - b.y + return math.sqrt(dx * dx + dy * dy) + end + -- + + if mouse_down then -- Mouse pressed + local bound_mouse_pos = { + x = math.max(0, math.min(self.display_state.video.width, mouse_pos.x)), + y = math.max(0, math.min(self.display_state.video.height, mouse_pos.y)), + } + + if self.current_crop == nil then + self.current_crop = { bound_mouse_pos, bound_mouse_pos } + + self.dragging = 3 + self.anchor_pos = { bound_mouse_pos.x, bound_mouse_pos.y } + + self.crop_ratio = 1 + self.drag_start = bound_mouse_pos + + local handle_pos = self:_get_anchor_positions()[hit] + self.drag_offset = { 0, 0 } + + self.restrict_ratio = shift_down + elseif self.dragging == 0 then + -- Check if we drag from a handle + local hitboxes = self:get_hitboxes() + local hit = self:hit_test(hitboxes, mouse_pos) + + self.dragging = hit + self.anchor_pos = self:_get_anchor_positions()[10 - hit] + + self.crop_ratio = (hitboxes[10][3] - hitboxes[10][1]) / (hitboxes[10][4] - hitboxes[10][2]) + self.drag_start = mouse_pos + + local handle_pos = self:_get_anchor_positions()[hit] or { mouse_pos.x, mouse_pos.y } + self.drag_offset = { mouse_pos.x - handle_pos[1], mouse_pos.y - handle_pos[2] } + + self.restrict_ratio = shift_down + + -- Start a new drag if not on handle + if self.dragging == 0 then + self.current_crop = { bound_mouse_pos, bound_mouse_pos } + self.crop_ratio = 1 + + self.dragging = 3 + self.anchor_pos = { bound_mouse_pos.x, bound_mouse_pos.y } + -- self.drag_start = mouse_pos + end + end + else -- Mouse released + if + xy_same(self.current_crop[1], self.current_crop[2]) + and xy_distance(self.current_crop[1], mouse_pos) < 5 + then + -- Mouse released after first click - ignore + elseif self.dragging > 0 then + -- Adjust current crop + self.current_crop = self:offset_crop_by_drag() + self.dragging = 0 + end + end + end +end + +function ASSCropper:_get_anchor_positions() + local x1, y1 = self.current_crop[1].x, self.current_crop[1].y + local x2, y2 = self.current_crop[2].x, self.current_crop[2].y + return { + [1] = { x1, y2 }, + [2] = { (x1 + x2) / 2, y2 }, + [3] = { x2, y2 }, + + [4] = { x1, (y1 + y2) / 2 }, + [5] = { (x1 + x2) / 2, (y1 + y2) / 2 }, + [6] = { x2, (y1 + y2) / 2 }, + + [7] = { x1, y1 }, + [8] = { (x1 + x2) / 2, y1 }, + [9] = { x2, y1 }, + } +end + +function ASSCropper:offset_crop_by_drag() + -- Here be dragons lol + local vw, vh = self.display_state.video.width, self.display_state.video.height + local mx, my = self.mouse_video.x, self.mouse_video.y + + local x1, x2 = self.current_crop[1].x, self.current_crop[2].x + local y1, y2 = self.current_crop[1].y, self.current_crop[2].y + + local anchor_positions = self:_get_anchor_positions() + + local handle = self.dragging + if handle > 0 then + local ax, ay = self.anchor_pos[1], self.anchor_pos[2] + + local ox, oy = self.drag_offset[1], self.drag_offset[2] + + local dx, dy = mx - ax - ox, my - ay - oy + + -- Select active corner + if handle % 2 == 1 and handle ~= 5 then -- Change corners 4/6, 2/8 + handle = (mx - ox < ax) and 1 or 3 + handle = handle + ((my - oy < ay) and 6 or 0) + else -- Change edges 1, 3, 7, 9 + if handle == 4 and mx - ox > ax then + handle = 6 + elseif handle == 6 and mx - ox < ax then + handle = 4 + elseif handle == 2 and my - oy < ay then + handle = 8 + elseif handle == 8 and my - oy > ay then + handle = 2 + end + end + + -- Handle booleans for logic + local h_bot = handle >= 1 and handle <= 3 + local h_top = handle >= 7 and handle <= 9 + local h_left = (handle - 1) % 3 == 0 + local h_right = handle % 3 == 0 + + local h_horiz = handle == 4 or handle == 6 + local h_vert = handle == 2 or handle == 8 + + -- Keep rect aspect ratio + if self.restrict_ratio then + local adx, ady = math.abs(dx), math.abs(dy) + + -- Fit rect to mouse + local tmpy = adx / self.crop_ratio + if tmpy < ady then + adx = ady * self.crop_ratio + else + ady = tmpy + end + + -- Figure out max size for corners, limit adx/ady + local max_w, max_h = vw, vh + + if h_bot then + max_h = vh - ay -- Max height is from anchor to video bottom + elseif h_top then + max_h = ay -- Max height is from video bottom to anchor + elseif h_horiz then + -- Max height is closest edge * 2 + max_h = math.min(vh - ay, ay) * 2 + end + + if h_left then + max_w = ax + elseif h_right then + max_w = vw - ax + elseif h_vert then + max_w = math.min(vw - ax, ax) * 2 + end + + -- Limit size to corners + if handle ~= 5 then + -- TODO this can be done tidier? + + -- If wider than max width, scale down + if adx > max_w then + adx = max_w + ady = adx / self.crop_ratio + end + -- If taller than max height, scale down + if ady > max_h then + ady = max_h + adx = ady * self.crop_ratio + end + end + + -- Hacky offsets + if handle == 1 then + dx = -adx + dy = ady + elseif handle == 2 then + dx = adx + dy = ady + elseif handle == 3 then + dx = adx + dy = ady + elseif handle == 4 then + dx = -adx + dy = ady + elseif handle == 5 then + -- pass + elseif handle == 6 then + dx = adx + dy = ady + elseif handle == 7 then + dy = -ady + dx = -adx + elseif handle == 8 then + dx = adx + dy = -ady + elseif handle == 9 then + dx = adx + dy = -ady + end + end + + -- Can this be done not-manually? + -- Re-create the rect with some corners anchored etc + if handle == 5 then + -- Simply move the box around + x1, x2 = x1 + dx, x2 + dx + y1, y2 = y1 + dy, y2 + dy + elseif handle == 1 then + x1, x2 = ax + dx, ax + y1, y2 = ay, ay + dy + elseif handle == 2 then + y1, y2 = ay, ay + dy + + if self.restrict_ratio then + x1, x2 = ax - dx / 2, ax + dx / 2 + end + elseif handle == 3 then + x1, x2 = ax, ax + dx + y1, y2 = ay, ay + dy + elseif handle == 4 then + x1, x2 = ax + dx, ax + + if self.restrict_ratio then + y1, y2 = ay - dy / 2, ay + dy / 2 + end + elseif handle == 6 then + x1, x2 = ax, ax + dx + + if self.restrict_ratio then + y1, y2 = ay - dy / 2, ay + dy / 2 + end + elseif handle == 7 then + x1, x2 = ax + dx, ax + y1, y2 = ay + dy, ay + elseif handle == 8 then + y1, y2 = ay + dy, ay + + if self.restrict_ratio then + x1, x2 = ax - dx / 2, ax + dx / 2 + end + elseif handle == 9 then + x1, x2 = ax, ax + dx + y1, y2 = ay + dy, ay + end + + if self.dragging == 5 then + -- On moving the entire box, we have to figure out how much to "offset" every corner if we go over the edge + local x_min = math.max(0, 0 - x1) + local y_min = math.max(0, 0 - y1) + + local x_max = math.max(0, x2 - vw) + local y_max = math.max(0, y2 - vh) + + x1 = x1 + x_min - x_max + y1 = y1 + y_min - y_max + x2 = x2 + x_min - x_max + y2 = y2 + y_min - y_max + elseif not self.restrict_ratio then + -- This is already done for restricted ratios, hence the if + + -- Constrict the crop to video space + -- Since one corner/edge is moved at a time, we can just minmax this + x1, x2 = math.max(0, x1), math.min(vw, x2) + y1, y2 = math.max(0, y1), math.min(vh, y2) + end + end -- /drag + + if self.dragging > 0 and self.options.even_dimensions then + local w, h = x2 - x1, y2 - y1 + local even_w = w - (w % 2) + local even_h = h - (h % 2) + + if handle == 1 or handle == 2 or handle == 3 then + y2 = y1 + even_h + elseif handle == 7 or handle == 8 or handle == 9 then + y1 = y2 - even_h + end + if handle == 1 or handle == 4 or handle == 7 then + x1 = x2 - even_w + elseif handle == 3 or handle == 6 or handle == 9 then + x2 = x1 + even_w + end + end + + local fx1, fx2 = order_pair(math.floor(x1), math.floor(x2)) + local fy1, fy2 = order_pair(math.floor(y1), math.floor(y2)) + + -- msg.info(fx1, fy1, fx2, fy2, handle) + + return { { x = fx1, y = fy1 }, { x = fx2, y = fy2 } }, handle +end + +function order_pair(a, b) + if a < b then + return a, b + else + return b, a + end +end + +function ASSCropper:render() + -- For debugging + local ass_txt = self:get_render_ass() + + local ds = self.display_state + mp.set_osd_ass(ds.screen.width, ds.screen.height, ass_txt) +end + +function ASSCropper:get_render_ass(dim_only) + if not self.display_state.video_ready then + msg.info("No video info on display_state") + return "" + end + + line_color = self.options.color_invert and 20 or 220 + local guide_format = string.format( + "{\\3a&HFF&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1\\shad0}", + 128, + line_color, + line_color, + line_color + ) + + ass = assdraw.ass_new() + if self.current_crop then + if self.testing_crop then + -- Just draw simple help + ass:new_event() + ass:pos(self.display_state.screen.width - 5, 5) + ass:append(string.format("{\\fs%d\\an%d\\bord2}", self.text_size, 9)) + + local fmt_key = function(key, text) + return string.format("[{\\c&HBEBEBE&}%s{\\c} %s]", key:upper(), text) + end + + ass:append( + fmt_key("ENTER", "Accept crop") + .. " " + .. fmt_key("ESC", "Cancel crop") + .. "\\N" + .. fmt_key("T", "Stop testing") + ) + return ass.text + end + + local temp_crop, drawn_handle = self:offset_crop_by_drag() + local v_hb = self:get_hitboxes(temp_crop) + -- Map coords to screen + local s_hb = {} + for index, coords in pairs(v_hb) do + local x1, y1 = self.display_state:video_to_screen(coords[1], coords[2]) + local x2, y2 = self.display_state:video_to_screen(coords[3], coords[4]) + s_hb[index] = { x1, y1, x2, y2 } + end + + -- Full crop + local v_crop = v_hb[10] -- Video-space + local s_crop = s_hb[10] -- Screen-space + + -- Inverse clipping for the crop box + ass:new_event() + ass:append(string.format("{\\iclip(%d,%d,%d,%d)}", s_crop[1], s_crop[2], s_crop[3], s_crop[4])) + + -- Dim overlay + local format_dim = string.format( + "{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", + self.overlay_transparency, + self.overlay_lightness, + self.overlay_lightness, + self.overlay_lightness + ) + ass:pos(0, 0) + ass:draw_start() + ass:append(format_dim) + ass:rect_cw(0, 0, self.display_state.screen.width, self.display_state.screen.height) + ass:draw_stop() + + if dim_only then -- Early out with just the dim outline + return ass.text + end + + if draw_text then + -- Text on end + ass:new_event() + ass:pos(ce_x, ce_y) + -- Text align + local txt_a = ((ce_x > cs_x) and 3 or 1) + ((ce_y > cs_y) and 0 or 6) + ass:an(txt_a) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%dx%d", math.abs(ce_x - cs_x), math.abs(ce_y - cs_y))) + end + + local box_format = + string.format("{\\1a&HFF&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1}", 0, line_color, line_color, line_color) + local handle_hilight_format = string.format( + "{\\1a&H%02X&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord0}", + 230, + 0, + line_color, + line_color, + line_color + ) + local handle_drag_format = string.format( + "{\\1a&H%02X&\\3a&H%02X&\\3c&H%02X%02X%02X&\\bord1}", + 200, + 0, + line_color, + line_color, + line_color + ) + + -- Main crop box + ass:new_event() + ass:pos(0, 0) + ass:append(box_format) + ass:draw_start() + ass:rect_cw(s_crop[1], s_crop[2], s_crop[3], s_crop[4]) + ass:draw_stop() + + -- Guide grid, 3x3 + if self.options.guide_type then + ass:new_event() + ass:pos(0, 0) + ass:append(guide_format) + ass:draw_start() + + local w = (s_crop[3] - s_crop[1]) + local h = (s_crop[4] - s_crop[2]) + + local w_3rd = w / 3 + local h_3rd = h / 3 + local w_2 = w / 2 + local h_2 = h / 2 + if self.options.guide_type == 1 then + -- 3x3 grid + ass:move_to(s_crop[1] + w_3rd, s_crop[2]) + ass:line_to(s_crop[1] + w_3rd, s_crop[4]) + + ass:move_to(s_crop[1] + w_3rd * 2, s_crop[2]) + ass:line_to(s_crop[1] + w_3rd * 2, s_crop[4]) + + ass:move_to(s_crop[1], s_crop[2] + h_3rd) + ass:line_to(s_crop[3], s_crop[2] + h_3rd) + + ass:move_to(s_crop[1], s_crop[2] + h_3rd * 2) + ass:line_to(s_crop[3], s_crop[2] + h_3rd * 2) + elseif self.options.guide_type == 2 then + -- Top to bottom + ass:move_to(s_crop[1] + w_2, s_crop[2]) + ass:line_to(s_crop[1] + w_2, s_crop[4]) + + -- Left to right + ass:move_to(s_crop[1], s_crop[2] + h_2) + ass:line_to(s_crop[3], s_crop[2] + h_2) + end + ass:draw_stop() + end + + if self.dragging > 0 and drawn_handle ~= 5 then + -- While dragging, draw only the dragging handle + ass:new_event() + ass:append(handle_drag_format) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(s_hb[drawn_handle][1], s_hb[drawn_handle][2], s_hb[drawn_handle][3], s_hb[drawn_handle][4]) + ass:draw_stop() + elseif self.dragging == 0 then + local hit_index = self:hit_test(s_hb, self.mouse_screen) + if hit_index > 0 and hit_index ~= 5 then + -- Hilight handle + ass:new_event() + ass:append(handle_hilight_format) + ass:pos(0, 0) + ass:draw_start() + ass:rect_cw(s_hb[hit_index][1], s_hb[hit_index][2], s_hb[hit_index][3], s_hb[hit_index][4]) + ass:draw_stop() + end + + ass:new_event() + ass:pos(0, 0) + ass:append(box_format) + ass:draw_start() + + -- Draw corner handles + for k, v in pairs({ 1, 3, 7, 9 }) do + ass:rect_cw(s_hb[v][1], s_hb[v][2], s_hb[v][3], s_hb[v][4]) + end + ass:draw_stop() + end + + if true or draw_text then + local br_pos = { s_crop[3] - 2, s_crop[4] + 2 } + local br_align = 9 + if br_pos[2] >= self.display_state.screen.height - 20 then + br_pos[2] = br_pos[2] - 4 + br_align = 3 + end + + ass:new_event() + ass:pos(unpack(br_pos)) + ass:an(br_align) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%dx%d", v_crop[3] - v_crop[1], v_crop[4] - v_crop[2])) + + local tl_pos = { s_crop[1] + 2, s_crop[2] - 2 } + local tl_align = 1 + if tl_pos[2] < 20 then + tl_pos[2] = tl_pos[2] + 4 + tl_align = 7 + end + + ass:new_event() + ass:pos(unpack(tl_pos)) + ass:an(tl_align) + ass:append("{\\fs20\\shad0\\be0\\bord2}") + ass:append(string.format("%d,%d", v_crop[1], v_crop[2])) + end + + ass:draw_stop() + end + + -- Crosshair for mouse + if self.options.draw_mouse and not dim_only then + ass:new_event() + ass:pos(0, 0) + ass:append(guide_format) + ass:draw_start() + + ass:move_to(self.mouse_screen.x, 0) + ass:line_to(self.mouse_screen.x, self.display_state.screen.height) + + ass:move_to(0, self.mouse_screen.y) + ass:line_to(self.display_state.screen.width, self.mouse_screen.y) + + ass:draw_stop() + end + + if self.options.draw_help and not dim_only then + ass:new_event() + ass:pos(self.display_state.screen.width - 5, 5) + local text_align = 9 + ass:append(string.format("{\\fs%d\\an%d\\bord2}", self.text_size, text_align)) + + local fmt_key = function(key, text) + return string.format("[{\\c&HBEBEBE&}%s{\\c} %s]", key:upper(), text) + end + + local crosshair_txt = self.options.draw_mouse and "Hide" or "Show" + lines = { + fmt_key("ENTER", "Accept crop") .. " " .. fmt_key("ESC", "Cancel crop") .. " " .. fmt_key( + "^D", + "Autodetect crop" + ) .. " " .. fmt_key("^T", "Test crop"), + fmt_key("SHIFT-Drag", "Constrain ratio") .. " " .. fmt_key("SHIFT-Arrow", "Nudge"), + fmt_key("^C", crosshair_txt .. " crosshair") .. " " .. fmt_key("^X", "Cycle guides") .. " " .. fmt_key( + "^Z", + "Invert color" + ), + } + + local full_line = nil + for i, line in pairs(lines) do + if line ~= nil then + full_line = full_line and (full_line .. "\\N" .. line) or line + end + end + ass:append(full_line) + end + + return ass.text +end +--[[ + A tool to expand properties in template strings, mimicking mpv's + property expansion but with a few extras (like formatting times). + + Depends on helpers.lua (isempty) +]] +-- + +local PropertyExpander = {} +PropertyExpander.__index = PropertyExpander + +setmetatable(PropertyExpander, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function PropertyExpander.new(property_source) + local self = setmetatable({}, PropertyExpander) + self.sentinel = {} + + -- property_source is a table which defines the following functions: + -- get_raw_property(name, def) - returns a raw property or def + -- get_property(name) - returns a string + -- get_property_osd(name) - returns an OSD formatted string (whatever that'll mean) + self.property_source = property_source + return self +end + +-- Formats seconds to H:M:S based on a %h-%m-%s format +function PropertyExpander:_format_time(seconds, time_format) + -- In case "seconds" is not a number, give it back + if type(seconds) ~= "number" then + return seconds + end + + time_format = time_format or "%02h.%02m.%06.3s" + + local types = { h = "d", m = "d", s = "f", S = "f", M = "d" } + local values = { + h = math.floor(seconds / 3600), + m = math.floor((seconds % 3600) / 60), + s = (seconds % 60), + S = seconds, + M = math.floor((seconds % 1) * 1000), + } + + local substitutor = function(sub_format, char) + local v = values[char] + local t = types[char] + if t == nil then + return nil + end + + sub_format = "%" .. sub_format .. types[char] + return v and sub_format:format(v) or nil + end + + return time_format:gsub("%%([%-%+ #0]*%d*.?%d*)([%a%%])", substitutor) +end + +-- Format a date +function PropertyExpander:_format_date(seconds, date_format) + -- In case "seconds" is not nil or a number, give it back + if type(seconds) ~= "number" and type(seconds) ~= "nil" then + return seconds + end + + --[[ + As stated by Lua docs: + %a abbreviated weekday name (e.g., Wed) + %A full weekday name (e.g., Wednesday) + %b abbreviated month name (e.g., Sep) + %B full month name (e.g., September) + %c date and time (e.g., 09/16/98 23:48:10) + %d day of the month (16) [01-31] + %H hour, using a 24-hour clock (23) [00-23] + %I hour, using a 12-hour clock (11) [01-12] + %M minute (48) [00-59] + %m month (09) [01-12] + %p either "am" or "pm" (pm) + %S second (10) [00-61] + %w weekday (3) [0-6 = Sunday-Saturday] + %x date (e.g., 09/16/98) + %X time (e.g., 23:48:10) + %Y full year (1998) + %y two-digit year (98) [00-99] + %% the character `%ยด + ]] + -- + date_format = date_format or "%Y-%m-%d %H-%M-%S" + return os.date(date_format, seconds) +end + +function PropertyExpander:expand(format_string) + local comparisons = { + { + -- Less than or equal + "^(..-)<=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value <= tonumber(other_value) + end, + }, + { + -- More than or equal + "^(..-)>=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value >= tonumber(other_value) + end, + }, + { + -- Less than + "^(..-)<(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value < tonumber(other_value) + end, + }, + { + -- More than + "^(..-)>(.+)$", + function(property_value, other_value) + if type(property_value) ~= "number" then + return nil + end + return property_value > tonumber(other_value) + end, + }, + { + -- Equal + "^(..-)==(.+)$", + function(property_value, other_value) + if type(property_value) == "number" then + other_value = tonumber(other_value) + elseif type(property_value) ~= "string" then + -- Ignore booleans and others + return nil + end + return property_value == other_value + end, + }, + { + -- Starts with + "^(..-)^=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return property_value:sub(1, other_value:len()) == other_value + end, + }, + { + -- Ends with + "^(..-)$=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return other_value == "" or property_value:sub(-other_value:len()) == other_value + end, + }, + { + -- Contains + "^(..-)~=(.+)$", + function(property_value, other_value) + if type(property_value) ~= "string" then + return nil + end + return property_value:find(other_value, nil, true) ~= nil + end, + }, + } + + local substitutor = function(match) + local command, inner = match:sub(3, -2):match("^([%?!~^%%#&]?)(.+)$") + local colon_index = inner:find(":") + + local property_name = inner + local secondary = "" + local has_colon = colon_index and true or false + + if colon_index then + property_name = inner:sub(1, colon_index - 1) + secondary = inner:sub(colon_index + 1, -1) + end + + local used_comparison = nil + local comparison_value = nil + for i, comparison in ipairs(comparisons) do + local name, other_value = property_name:match(comparison[1]) + if name then + property_name = name + comparison_value = other_value + used_comparison = comparison[2] + break + end + end + + local raw_property_value = self.property_source:get_raw_property(property_name, self.sentinel) + local property_exists = raw_property_value ~= self.sentinel + + if command == "" then + if used_comparison then + if used_comparison(raw_property_value, comparison_value) then + return self:expand(secondary) + else + return "" + end + end + + -- Return the property value if it's not nil, else the (expanded) secondary + return property_exists and self.property_source:get_property(property_name) or self:expand(secondary) + elseif command == "?" then + -- Return (expanded) secondary if property is truthy (sentinel is falsey) + if not isempty(raw_property_value) then + return self:expand(secondary) + else + return "" + end + elseif command == "!" then + if used_comparison then + if not used_comparison(raw_property_value, comparison_value) then + return self:expand(secondary) + else + return "" + end + end + + -- Return (expanded) secondary if property is falsey + if isempty(raw_property_value) then + return self:expand(secondary) + else + return "" + end + elseif command == "^" then + -- Return (expanded) secondary if property does not exist + return not property_exists and self:expand(secondary) or "" + elseif command == "%" then + -- Return the value formatted using the secondary string + return secondary:format(raw_property_value) + elseif command == "#" then + -- Format a number to HMS + return self:_format_time(raw_property_value, has_colon and secondary or nil) + elseif command == "&" then + -- Format a date + return self:_format_date(nil, has_colon and secondary or nil) + elseif command == "@" then + -- Format the value for OSD - mostly useful for latching onto mpv's properties + return property_exists and self.property_source:get_property_osd(property_name) or self:expand(secondary) + end + end + + -- Lua patterns are generally a pain, but %b is comfy! + local expanded = format_string:gsub("%$%b{}", substitutor) + return expanded +end + +local MPVPropertySource = {} +MPVPropertySource.__index = MPVPropertySource + +setmetatable(MPVPropertySource, { + __call = function(cls, ...) + return cls.new(...) + end, +}) + +function MPVPropertySource.new(values) + local self = setmetatable({}, MPVPropertySource) + self.values = values + + return self +end + +function MPVPropertySource:get_raw_property(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property_native(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return v + else + return default + end +end + +function MPVPropertySource:get_property(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return tostring(v) + else + return default + end +end + +function MPVPropertySource:get_property_osd(name, default) + if name:find("mpv/") ~= nil then + return mp.get_property_osd(name:sub(5), default) + end + local v = self.values[name] + if v ~= nil then + return tostring(v) + else + return default + end +end +function script_crop_toggle() + if asscropper.active then + asscropper:stop_crop(true) + else + local on_crop = function(crop) + mp.set_osd_ass(0, 0, "") + screenshot(crop) + end + local on_cancel = function() + mp.osd_message("Crop canceled") + mp.set_osd_ass(0, 0, "") + end + + local crop_options = { + guide_type = ({ none = 0, grid = 1, center = 2 })[option_values.guide_type], + draw_mouse = option_values.draw_mouse, + color_invert = option_values.color_invert, + auto_invert = option_values.auto_invert, + } + asscropper:start_crop(crop_options, on_crop, on_cancel) + if not asscropper.active then + mp.osd_message("No video to crop!", 2) + end + end +end + +local next_tick_time = nil +function on_tick_listener() + local now = mp.get_time() + if next_tick_time == nil or now >= next_tick_time then + if asscropper.active and display_state:recalculate_bounds() then + mp.set_osd_ass(display_state.screen.width, display_state.screen.height, asscropper:get_render_ass()) + end + next_tick_time = now + (1 / 60) + end +end + +local function resolve_path(path) + -- Expand tilde (~) to home directory + local home = os.getenv("HOME") + if home and path:sub(1, 2) == "~/" then + path = home .. path:sub(2) + end + -- Convert relative paths to absolute paths + return mp.command_native({ "expand-path", path }) +end + +function expand_output_path(cropbox) + local filename = mp.get_property_native("filename") + local playback_time = mp.get_property_native("playback-time") + local duration = mp.get_property_native("duration") + + local filename_without_ext, extension = filename:match("^(.+)%.(.-)$") + + local properties = { + path = mp.get_property_native("path"), -- Original path + + filename = filename_without_ext or filename, -- Filename without extension (or filename if no dots) + file_ext = extension or "", -- Original extension without leading dot (or empty string) + + pos = playback_time, + + full = false, + is_image = (duration == 0 and playback_time == 0), + + crop_w = cropbox.w, + crop_h = cropbox.h, + crop_x = cropbox.x, + crop_y = cropbox.y, + crop_x2 = cropbox.x2, + crop_y2 = cropbox.y2, + + unique = 0, + + ext = option_values.output_extension, + } + + local propex = PropertyExpander(MPVPropertySource(properties)) + + local test_path = resolve_path(propex:expand(option_values.output_template)) + -- If the paths do not change when incrementing the unique, it's not used. + -- Return early and avoid the endless loop + properties.unique = 1 + if resolve_path(propex:expand(option_values.output_template)) == test_path then + properties.full = true + local temporary_screenshot_path = resolve_path(propex:expand(option_values.output_template)) + return test_path, temporary_screenshot_path + else + -- Figure out a unique filename + while true do + test_path = resolve_path(propex:expand(option_values.output_template)) + + -- Check if filename is free + if not path_exists(test_path) then + properties.full = true + local temporary_screenshot_path = resolve_path(propex:expand(option_values.output_template)) + return test_path, temporary_screenshot_path + else + -- Try the next one + properties.unique = properties.unique + 1 + end + end + end +end + +function screenshot(crop) + local size = round_dec(crop.w) .. "x" .. round_dec(crop.h) + + -- Bail on bad crop sizes + if not (crop.w > 0 and crop.h > 0) then + mp.osd_message("Bad crop (" .. size .. ")!") + return + end + + local output_path, temporary_screenshot_path = expand_output_path(crop) + + -- Optionally create directories + if option_values.create_directories then + local paths = {} + paths[1] = path_utils.dirname(output_path) + paths[2] = path_utils.dirname(temporary_screenshot_path) + + -- Check if we can read the paths + for i, path in ipairs(paths) do + local l, err = utils.readdir(path) + if err then + create_directories(path) + end + end + end + + local playback_time = mp.get_property_native("playback-time") + local duration = mp.get_property_native("duration") + + local input_path = nil + + if option_values.skip_screenshot_for_images and duration == 0 and playback_time == 0 then + -- Seems to be an image (or at least static file) + input_path = mp.get_property_native("path") + temporary_screenshot_path = nil + else + -- Not an image, take a temporary screenshot + + -- In case the full-size output path is identical to the crop path, + -- crudely make it different + if temporary_screenshot_path == output_path then + temporary_screenshot_path = temporary_screenshot_path .. "_full.png" + end + + -- Temporarily lower the PNG compression + local previous_png_compression = mp.get_property_native("screenshot-png-compression") + mp.set_property_native("screenshot-png-compression", 0) + -- Take the screenshot + mp.commandv("raw", "no-osd", "screenshot-to-file", temporary_screenshot_path) + -- Return the previous value + mp.set_property_native("screenshot-png-compression", previous_png_compression) + + if not path_exists(temporary_screenshot_path) then + msg.error("Failed to take screenshot: " .. temporary_screenshot_path) + mp.osd_message("Unable to save screenshot") + return + end + + input_path = temporary_screenshot_path + end + + local crop_string = string.format("%d:%d:%d:%d", crop.w, crop.h, crop.x, crop.y) + local cmd = { + args = { + "mpv", + input_path, + "--no-config", + "--vf=crop=" .. crop_string, + "--frames=1", + "--ovc=" .. option_values.output_format, + "-o", + output_path, + }, + } + + msg.info("Cropping: ", crop_string, output_path) + local ret = utils.subprocess(cmd) + + if not option_values.keep_original and temporary_screenshot_path then + os.remove(temporary_screenshot_path) + end + + if ret.error or ret.status ~= 0 then + mp.osd_message("Screenshot failed, see console for details") + msg.error("Crop failed! mpv exit code: " .. tostring(ret.status)) + msg.error("mpv stdout:") + msg.error(ret.stdout) + else + msg.info("Crop finished!") + mp.osd_message("Took screenshot (" .. size .. ")") + end +end + +---------------------- +-- Instances, binds -- +---------------------- + +-- Sanity-check output_template +if option_values.warn_about_template and not option_values.output_template:find("%${ext}") then + msg.warn("Output template missing ${ext}! If this is desired, set warn_about_template=yes in config!") +end + +-- Short list of extensions for encoders +local ENCODER_EXTENSION_MAP = { + png = "png", + mjpeg = "jpg", + targa = "tga", + tiff = "tiff", + gif = "gif", -- please don't + bmp = "bmp", + jpegls = "jpg", + ljpeg = "jpg", + jpeg2000 = "jp2", +} +-- Pick an extension if one was not provided +if option_values.output_extension == "" then + local extension = ENCODER_EXTENSION_MAP[option_values.output_format] + if not extension then + msg.error( + "Unrecognized output format '" .. option_values.output_format .. "', unable to pick an extension! Bailing!" + ) + mp.osd_message("mpv_crop_script was unable to choose an extension, check your config", 3) + end + option_values.output_extension = extension +end + +display_state = DisplayState() +asscropper = ASSCropper(display_state) +asscropper.overlay_transparency = option_values.overlay_transparency +asscropper.overlay_lightness = option_values.overlay_lightness + +asscropper.tick_callback = on_tick_listener +mp.register_event("tick", on_tick_listener) + +local used_keybind = SCRIPT_KEYBIND +-- Disable the default keybind if asked to +if option_values.disable_keybind then + used_keybind = nil +end +mp.add_key_binding(used_keybind, SCRIPT_HANDLER, script_crop_toggle) |
