--[[ 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 . ]]-- --[[ 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 = "lua-settings/" .. 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}}, {"c", function() self:key_event("CROSSHAIR") end }, {"d", function() self:key_event("CROP_DETECT") end }, {"x", function() self:key_event("GUIDES") end }, {"t", function() self:key_event("TEST") end }, {"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 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 = mp.get_property_native("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 = 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 propex:expand(option_values.output_template) == test_path then properties.full = true local temporary_screenshot_path = propex:expand(option_values.output_template) return test_path, temporary_screenshot_path else -- Figure out an unique filename while true do test_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 = 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)