3173 lines
89 KiB
Lua
Executable file
3173 lines
89 KiB
Lua
Executable file
--[[
|
||
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 = "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)
|