commit c352ba0e2e82ceeeef9880c674cdd34b2f9e9499
parent 07d244abecaf71ebf83ad5db64ee9d39a946ba37
Author: Alex Balgavy <alex@balgavy.eu>
Date: Tue, 29 Jun 2021 14:58:57 +0200
mpv: add encode scripts
Diffstat:
4 files changed, 369 insertions(+), 0 deletions(-)
diff --git a/mpv/input.conf b/mpv/input.conf
@@ -194,3 +194,6 @@ P script-binding osc/visibility # cycle OSC display
c show_text "${chapter-metadata}${chapter}"
C show_text "${chapter-list}"
alt+c script-message-to crop toggle-crop
+e script-message-to encode set-timestamp
+alt+e script-message-to encode set-timestamp encode_webm
+E script-message-to encode set-timestamp encode_slice
diff --git a/mpv/script-opts/encode_slice.conf b/mpv/script-opts/encode_slice.conf
@@ -0,0 +1,13 @@
+# profile to slice the current video without reencoding it
+# watch out that the extract will be snapped to keyframes; this is unavoidable when copying streams
+# see encode_webm.conf for a detailed explanations of all the options
+
+only_active_tracks=yes
+preserve_filters=no
+append_filter=
+codec=-c copy
+output_format=$f_$n.$x
+output_directory=
+detached=yes
+ffmpeg_command=ffmpeg
+print=yes
diff --git a/mpv/script-opts/encode_webm.conf b/mpv/script-opts/encode_webm.conf
@@ -0,0 +1,39 @@
+# if yes, only encode the currently active tracks
+# for example, mute the player / hide the subtitles if you don't want audio / subs to be part of the extract
+only_active_tracks=no
+
+# whether to preserve some of the applied filters (crop, rotate, flip and mirror) into the extract
+# this is pretty useful in combination with crop.lua
+# note that you cannot copy video streams and apply filters at the same time
+preserve_filters=yes
+
+# apply another filter after the ones from the previous option if any
+# can be used to limit the resolution of the output, for example with
+# append_filter=scale=2*trunc(iw/max(1\,sqrt((iw*ih)/(960*540)))/2):-2
+append_filter=
+
+# additional parameters passed to ffmpeg
+codec=-an -sn -c:v libvpx -crf 10 -b:v 1000k
+
+# format of the output filename
+# Does basic interpolation on the following variables: $f, $x, $t, $s, $e, $d, $p, $n which respectively represent
+# input filename, input extension, title, start timestamp, end timestamp, duration, profile name and an incrementing number in case of conflicts
+# if the extension is not among the recognized ones, it will default to mkv
+output_format=$f_$n.webm
+
+# the directory in which to create the extract
+# empty means the same directory as the input file
+# relative paths are relative to mpv's working directory, absolute ones work like you would expect
+output_directory=
+
+# if yes, the ffmpeg process will run detached from mpv and we won't know if it succeeded or not
+# if no, we know the result of calling ffmpeg, but we can only encode one extract at a time and mpv will block on exit
+detached=yes
+
+# executable to run when encoding (or its full path if not in PATH)
+# for example, this can be used with a wrapper script that calls ffmpeg and triggers a notification when finished
+# note that the executable gets the ffmpeg arguments as-is, and is expected to call ffmpeg itself
+ffmpeg_command=ffmpeg
+
+# if yes, print the ffmpeg call before executing it
+print=yes
diff --git a/mpv/scripts/encode.lua b/mpv/scripts/encode.lua
@@ -0,0 +1,314 @@
+local utils = require "mp.utils"
+local msg = require "mp.msg"
+local options = require "mp.options"
+
+local ON_WINDOWS = (package.config:sub(1,1) ~= "/")
+
+local start_timestamp = nil
+local profile_start = ""
+
+-- implementation detail of the osd message
+local timer = nil
+local timer_duration = 2
+
+function append_table(lhs, rhs)
+ for i = 1,#rhs do
+ lhs[#lhs+1] = rhs[i]
+ end
+ return lhs
+end
+
+function file_exists(name)
+ local f = io.open(name, "r")
+ if f ~= nil then
+ io.close(f)
+ return true
+ else
+ return false
+ end
+end
+
+function get_extension(path)
+ local candidate = string.match(path, "%.([^.]+)$")
+ if candidate then
+ for _, ext in ipairs({ "mkv", "webm", "mp4", "avi" }) do
+ if candidate == ext then
+ return candidate
+ end
+ end
+ end
+ return "mkv"
+end
+
+function get_output_string(dir, format, input, extension, title, from, to, profile)
+ local res = utils.readdir(dir)
+ if not res then
+ return nil
+ end
+ local files = {}
+ for _, f in ipairs(res) do
+ files[f] = true
+ end
+ local output = format
+ output = string.gsub(output, "$f", input)
+ output = string.gsub(output, "$t", title)
+ output = string.gsub(output, "$s", seconds_to_time_string(from, true))
+ output = string.gsub(output, "$e", seconds_to_time_string(to, true))
+ output = string.gsub(output, "$d", seconds_to_time_string(to-from, true))
+ output = string.gsub(output, "$x", extension)
+ output = string.gsub(output, "$p", profile)
+ if ON_WINDOWS then
+ output = string.gsub(output, "[/\\|<>?:\"*]", "_")
+ end
+ if not string.find(output, "$n") then
+ return files[output] and nil or output
+ end
+ local i = 1
+ while true do
+ local potential_name = string.gsub(output, "$n", tostring(i))
+ if not files[potential_name] then
+ return potential_name
+ end
+ i = i + 1
+ end
+end
+
+function get_video_filters()
+ local filters = {}
+ for _, vf in ipairs(mp.get_property_native("vf")) do
+ local name = vf["name"]
+ local filter
+ if name == "crop" then
+ local p = vf["params"]
+ filter = string.format("crop=%d:%d:%d:%d", p.w, p.h, p.x, p.y)
+ elseif name == "mirror" then
+ filter = "hflip"
+ elseif name == "flip" then
+ filter = "vflip"
+ elseif name == "rotate" then
+ local rotation = vf["params"]["angle"]
+ -- rotate is NOT the filter we want here
+ if rotation == "90" then
+ filter = "transpose=clock"
+ elseif rotation == "180" then
+ filter = "transpose=clock,transpose=clock"
+ elseif rotation == "270" then
+ filter = "transpose=cclock"
+ end
+ end
+ filters[#filters + 1] = filter
+ end
+ return filters
+end
+
+function get_input_info(default_path, only_active)
+ local accepted = {
+ video = true,
+ audio = not mp.get_property_bool("mute"),
+ sub = mp.get_property_bool("sub-visibility")
+ }
+ local ret = {}
+ for _, track in ipairs(mp.get_property_native("track-list")) do
+ local track_path = track["external-filename"] or default_path
+ if not only_active or (track["selected"] and accepted[track["type"]]) then
+ local tracks = ret[track_path]
+ if not tracks then
+ ret[track_path] = { track["ff-index"] }
+ else
+ tracks[#tracks + 1] = track["ff-index"]
+ end
+ end
+ end
+ return ret
+end
+
+function seconds_to_time_string(seconds, full)
+ local ret = string.format("%02d:%02d.%03d"
+ , math.floor(seconds / 60) % 60
+ , math.floor(seconds) % 60
+ , seconds * 1000 % 1000
+ )
+ if full or seconds > 3600 then
+ ret = string.format("%d:%s", math.floor(seconds / 3600), ret)
+ end
+ return ret
+end
+
+function start_encoding(from, to, settings)
+ local args = {
+ settings.ffmpeg_command,
+ "-loglevel", "panic", "-hide_banner",
+ }
+ local append_args = function(table) args = append_table(args, table) end
+
+ local path = mp.get_property("path")
+ local is_stream = not file_exists(path)
+ if is_stream then
+ path = mp.get_property("stream-path")
+ end
+
+ local track_args = {}
+ local start = seconds_to_time_string(from, false)
+ local input_index = 0
+ for input_path, tracks in pairs(get_input_info(path, settings.only_active_tracks)) do
+ append_args({
+ "-ss", start,
+ "-i", input_path,
+ })
+ if settings.only_active_tracks then
+ for _, track_index in ipairs(tracks) do
+ track_args = append_table(track_args, { "-map", string.format("%d:%d", input_index, track_index)})
+ end
+ else
+ track_args = append_table(track_args, { "-map", tostring(input_index)})
+ end
+ input_index = input_index + 1
+ end
+
+ append_args({"-to", tostring(to-from)})
+ append_args(track_args)
+
+ -- apply some of the video filters currently in the chain
+ local filters = {}
+ if settings.preserve_filters then
+ filters = get_video_filters()
+ end
+ if settings.append_filter ~= "" then
+ filters[#filters + 1] = settings.append_filter
+ end
+ if #filters > 0 then
+ append_args({ "-filter:v", table.concat(filters, ",") })
+ end
+
+ -- split the user-passed settings on whitespace
+ for token in string.gmatch(settings.codec, "[^%s]+") do
+ args[#args + 1] = token
+ end
+
+ -- path of the output
+ local output_directory = settings.output_directory
+ if output_directory == "" then
+ if is_stream then
+ output_directory = "."
+ else
+ output_directory, _ = utils.split_path(path)
+ end
+ else
+ output_directory = string.gsub(output_directory, "^~", os.getenv("HOME") or "~")
+ end
+ local input_name = mp.get_property("filename/no-ext") or "encode"
+ local title = mp.get_property("media-title")
+ local extension = get_extension(path)
+ local output_name = get_output_string(output_directory, settings.output_format, input_name, extension, title, from, to, settings.profile)
+ if not output_name then
+ mp.osd_message("Invalid path " .. output_directory)
+ return
+ end
+ args[#args + 1] = utils.join_path(output_directory, output_name)
+
+ if settings.print then
+ local o = ""
+ -- fuck this is ugly
+ for i = 1, #args do
+ local fmt = ""
+ if i == 1 then
+ fmt = "%s%s"
+ elseif i >= 2 and i <= 4 then
+ fmt = "%s"
+ elseif args[i-1] == "-i" or i == #args or args[i-1] == "-filter:v" then
+ fmt = "%s '%s'"
+ else
+ fmt = "%s %s"
+ end
+ o = string.format(fmt, o, args[i])
+ end
+ print(o)
+ end
+ if settings.detached then
+ utils.subprocess_detached({ args = args })
+ else
+ local res = utils.subprocess({ args = args, max_size = 0, cancellable = false })
+ if res.status == 0 then
+ mp.osd_message("Finished encoding succesfully")
+ else
+ mp.osd_message("Failed to encode, check the log")
+ end
+ end
+end
+
+function clear_timestamp()
+ timer:kill()
+ start_timestamp = nil
+ profile_start = ""
+ mp.remove_key_binding("encode-ESC")
+ mp.remove_key_binding("encode-ENTER")
+ mp.osd_message("", 0)
+end
+
+function set_timestamp(profile)
+ if not mp.get_property("path") then
+ mp.osd_message("No file currently playing")
+ return
+ end
+ if not mp.get_property_bool("seekable") then
+ mp.osd_message("Cannot encode non-seekable media")
+ return
+ end
+
+ if not start_timestamp or profile ~= profile_start then
+ profile_start = profile
+ start_timestamp = mp.get_property_number("time-pos")
+ local msg = function()
+ mp.osd_message(
+ string.format("encode [%s]: waiting for end timestamp", profile or "default"),
+ timer_duration
+ )
+ end
+ msg()
+ timer = mp.add_periodic_timer(timer_duration, msg)
+ mp.add_forced_key_binding("ESC", "encode-ESC", clear_timestamp)
+ mp.add_forced_key_binding("ENTER", "encode-ENTER", function() set_timestamp(profile) end)
+ else
+ local from = start_timestamp
+ local to = mp.get_property_number("time-pos")
+ if to <= from then
+ mp.osd_message("Second timestamp cannot be before the first", timer_duration)
+ timer:kill()
+ timer:resume()
+ return
+ end
+ clear_timestamp()
+ mp.osd_message(string.format("Encoding from %s to %s"
+ , seconds_to_time_string(from, false)
+ , seconds_to_time_string(to, false)
+ ), timer_duration)
+ -- include the current frame into the extract
+ local fps = mp.get_property_number("container-fps") or 30
+ to = to + 1 / fps / 2
+ local settings = {
+ detached = true,
+ container = "",
+ only_active_tracks = false,
+ preserve_filters = true,
+ append_filter = "",
+ codec = "-an -sn -c:v libvpx -crf 10 -b:v 1000k",
+ output_format = "$f_$n.webm",
+ output_directory = "",
+ ffmpeg_command = "ffmpeg",
+ print = true,
+ }
+ if profile then
+ options.read_options(settings, profile)
+ if settings.container ~= "" then
+ msg.warn("The 'container' setting is deprecated, use 'output_format' now")
+ settings.output_format = settings.output_format .. "." .. settings.container
+ end
+ settings.profile = profile
+ else
+ settings.profile = "default"
+ end
+ start_encoding(from, to, settings)
+ end
+end
+
+mp.add_key_binding(nil, "set-timestamp", set_timestamp)