Replaces hardcoded WEBP_FPS=15/GIF_FPS=15 with source_fps() that reads mf-fps, video-params/fps, or estimated-vf-fps, flooring at 30.
332 lines
13 KiB
Lua
332 lines
13 KiB
Lua
-- export-loop-webp.lua
|
|
-- Press W to export the current A-B loop as both an animated WebP + GIF.
|
|
-- Frame-accurate (re-encodes). Exports raw + adjusted (gamma/brightness/etc).
|
|
--
|
|
-- WebP → ~/Videos/mpv-loops/
|
|
-- GIF → ~/Videos/mpv-loops/gif/
|
|
--
|
|
-- Suggested input.conf binding:
|
|
-- W script-binding export_loop_webp/export
|
|
|
|
local mp = require("mp")
|
|
local utils = require("mp.utils")
|
|
|
|
-- ╔══════════════════════════════════════════╗
|
|
-- ║ CONFIGURATION ║
|
|
-- ╚══════════════════════════════════════════╝
|
|
|
|
local OUTPUT_DIR = os.getenv("HOME") .. "/Videos/mpv-loops"
|
|
local GIF_DIR = OUTPUT_DIR .. "/gif"
|
|
local ENABLE_OSD_MSG = true
|
|
|
|
-- Detect source frame rate (handles mf:// from webp-anim-bridge).
|
|
-- Returns at least 30fps.
|
|
local function source_fps()
|
|
local mf_fps = mp.get_property_number("mf-fps", 0)
|
|
if mf_fps and mf_fps > 0 then return math.max(30, mf_fps) end
|
|
local vfps = mp.get_property_number("video-params/fps", 0)
|
|
if vfps and vfps > 0 then return math.max(30, vfps) end
|
|
local efps = mp.get_property_number("estimated-vf-fps", 0)
|
|
if efps and efps > 0 then return math.max(30, efps) end
|
|
return 30
|
|
end
|
|
|
|
-- Config — FPS is now dynamic via source_fps(). Fallback minimum for mf:// only.
|
|
local WEBP_QUALITY = 80
|
|
local WEBP_LOSSLESS = false
|
|
local WEBP_MAX_WIDTH = 800
|
|
local WEBP_LOOP = 0
|
|
|
|
-- GIF settings
|
|
local GIF_MAX_WIDTH = 800
|
|
local GIF_DITHER = "bayer"
|
|
local GIF_LOOP = 0
|
|
|
|
-- ╔══════════════════════════════════════════╗
|
|
-- ║ FORMAT HELPERS ║
|
|
-- ╚══════════════════════════════════════════╝
|
|
|
|
local function format_ts(seconds)
|
|
local h = math.floor(seconds / 3600)
|
|
local m = math.floor((seconds % 3600) / 60)
|
|
local s = seconds % 60
|
|
return string.format("%02d:%02d:%06.3f", h, m, s)
|
|
end
|
|
|
|
local function format_label(seconds)
|
|
local h = math.floor(seconds / 3600)
|
|
local m = math.floor((seconds % 3600) / 60)
|
|
local s = math.floor(seconds % 60)
|
|
return string.format("%02d%02d%02d", h, m, s)
|
|
end
|
|
|
|
local function osd_msg(text, duration)
|
|
if ENABLE_OSD_MSG then
|
|
mp.osd_message(text, duration or 3)
|
|
end
|
|
end
|
|
|
|
local function build_eq_filter()
|
|
local brightness = mp.get_property_number("brightness", 0)
|
|
local contrast = mp.get_property_number("contrast", 0)
|
|
local saturation = mp.get_property_number("saturation", 0)
|
|
local gamma = mp.get_property_number("gamma", 0)
|
|
|
|
local parts = {}
|
|
if brightness ~= 0 then table.insert(parts, string.format("brightness=%.2f", brightness / 100.0)) end
|
|
if contrast ~= 0 then table.insert(parts, string.format("contrast=%.2f", 1.0 + contrast / 100.0)) end
|
|
if saturation ~= 0 then table.insert(parts, string.format("saturation=%.2f", 1.0 + saturation / 100.0)) end
|
|
if gamma ~= 0 then table.insert(parts, string.format("gamma=%.2f", 1.0 + gamma / 100.0)) end
|
|
|
|
if #parts == 0 then return nil end
|
|
return "eq=" .. table.concat(parts, ":")
|
|
end
|
|
|
|
-- Build crop filter string for current zoom/pan.
|
|
-- Returns nil if no zoom/pan is active (no filter needed).
|
|
local function zoom_pan_crop()
|
|
local zoom = mp.get_property_number("video-zoom", -999)
|
|
local pan_x = mp.get_property_number("video-pan-x", -999)
|
|
local pan_y = mp.get_property_number("video-pan-y", -999)
|
|
mp.msg.warn(string.format("[zp] raw: zoom=%+5.2f pan_x=%+5.2f pan_y=%+5.2f", zoom, pan_x, pan_y))
|
|
-- Treat sentinel -999 or actual 0 as "no value"
|
|
if zoom <= 0 then
|
|
mp.msg.warn("[zp] zoom <= 0, skipping crop")
|
|
return nil
|
|
end
|
|
if pan_x == -999 then pan_x = 0 end
|
|
if pan_y == -999 then pan_y = 0 end
|
|
local s = 2 ^ zoom
|
|
local filter = string.format("crop=iw/%g:ih/%g:iw*(0.5-0.5/%g-%g):ih*(0.5-0.5/%g-%g)",
|
|
s, s, s, pan_x, s, pan_y)
|
|
mp.msg.warn(string.format("[zp] filter=%s", filter))
|
|
mp.osd_message(string.format("ZOOM=%.2f PAN_X=%.2f PAN_Y=%.2f", zoom, pan_x, pan_y), 3)
|
|
return filter
|
|
end
|
|
|
|
-- Build the WebP vf string (fps + optional eq + zoom/pan crop)
|
|
local function build_webp_vf(include_eq)
|
|
local fps = source_fps()
|
|
local parts = {}
|
|
local crop = zoom_pan_crop()
|
|
if crop then table.insert(parts, crop) end
|
|
table.insert(parts, string.format("fps=%d", fps))
|
|
if include_eq then
|
|
local eq = build_eq_filter()
|
|
if eq then table.insert(parts, eq) end
|
|
end
|
|
return table.concat(parts, ",")
|
|
end
|
|
|
|
-- Build the GIF vf string (fps + optional eq + palettegen/paletteuse + zoom/pan crop)
|
|
local function build_gif_vf(include_eq)
|
|
local fps = source_fps()
|
|
local parts = {}
|
|
local crop = zoom_pan_crop()
|
|
if crop then table.insert(parts, crop) end
|
|
table.insert(parts, string.format("fps=%d", fps))
|
|
if include_eq then
|
|
local eq = build_eq_filter()
|
|
if eq then table.insert(parts, eq) end
|
|
end
|
|
local pre = table.concat(parts, ",")
|
|
-- palettegen+paletteuse pipeline (two-pass inside one vf)
|
|
return string.format("%s,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=%s",
|
|
pre, GIF_DITHER)
|
|
end
|
|
|
|
local function dedup_path(base_path, suffix, ext)
|
|
local candidate = string.format("%s_%s.%s", base_path, suffix, ext)
|
|
local counter = 1
|
|
while true do
|
|
local f = io.open(candidate, "r")
|
|
if not f then return candidate end
|
|
f:close()
|
|
candidate = string.format("%s_%s_%d.%s", base_path, suffix, counter, ext)
|
|
counter = counter + 1
|
|
end
|
|
end
|
|
|
|
-- Build the common ffmpeg prefix (input + seeking)
|
|
-- Handles mf:// sources from webp-anim-bridge (animated WebP frame sequences).
|
|
local function build_ffmpeg_seek_args(a, b)
|
|
local args = {"ffmpeg", "-y"}
|
|
local file_path = mp.get_property("stream-path") or mp.get_property("path", "")
|
|
local mf_prefix = "mf:///tmp/mpv-webp-anim/"
|
|
|
|
if file_path:sub(1, #mf_prefix) == mf_prefix or file_path:find("/tmp/mpv-webp-anim/") then
|
|
-- Source is from webp-anim-bridge: use the extracted PNG frames
|
|
local pattern = file_path
|
|
if pattern:sub(1, 5) == "mf://" then
|
|
pattern = pattern:sub(6)
|
|
end
|
|
local mf_fps = source_fps()
|
|
table.insert(args, "-framerate"); table.insert(args, tostring(mf_fps))
|
|
table.insert(args, "-start_number"); table.insert(args, "0")
|
|
table.insert(args, "-i"); table.insert(args, pattern)
|
|
-- Seek by frame number instead of timestamp
|
|
local start_frame = math.floor(a * mf_fps)
|
|
local end_frame = math.ceil(b * mf_fps) - 1
|
|
local num_frames = math.max(1, end_frame - start_frame + 1)
|
|
table.insert(args, "-start_number"); table.insert(args, tostring(start_frame))
|
|
table.insert(args, "-vframes"); table.insert(args, tostring(num_frames))
|
|
else
|
|
-- Normal source: seek by timestamp
|
|
table.insert(args, "-i"); table.insert(args, file_path)
|
|
table.insert(args, "-ss"); table.insert(args, format_ts(a))
|
|
table.insert(args, "-to"); table.insert(args, format_ts(b))
|
|
end
|
|
return args
|
|
end
|
|
|
|
local function format_size(path)
|
|
local info = utils.file_info(path)
|
|
if not info or not info.size then return "" end
|
|
local s = info.size
|
|
if s > 1024 * 1024 then
|
|
return string.format(" (%.1f MB)", s / 1024 / 1024)
|
|
elseif s > 1024 then
|
|
return string.format(" (%.0f KB)", s / 1024)
|
|
else
|
|
return string.format(" (%d B)", s)
|
|
end
|
|
end
|
|
|
|
-- ╔══════════════════════════════════════════╗
|
|
-- ║ MAIN EXPORT FUNCTION ║
|
|
-- ╚══════════════════════════════════════════╝
|
|
|
|
local function export_both()
|
|
-- 1. Read loop points
|
|
local a = mp.get_property_number("ab-loop-a")
|
|
local b = mp.get_property_number("ab-loop-b")
|
|
|
|
if not a or not b then
|
|
osd_msg("No A-B loop set. Press l twice to create one.", 3)
|
|
return
|
|
end
|
|
if a > b then a, b = b, a end
|
|
if a == b then
|
|
osd_msg("Loop has zero duration. Set different A and B points.", 3)
|
|
return
|
|
end
|
|
|
|
-- 2. Check a file is loaded
|
|
local has_file = mp.get_property("path")
|
|
if not has_file then
|
|
osd_msg("No file loaded.", 3)
|
|
return
|
|
end
|
|
|
|
-- 3. Filename stem
|
|
local filename_stripped = mp.get_property("filename/no-ext") or "video"
|
|
local basename = filename_stripped:gsub("[^%w._-]", "_")
|
|
local label_a = format_label(a)
|
|
local label_b = format_label(b)
|
|
local stem = string.format("%s_loop_%s-%s", basename, label_a, label_b)
|
|
|
|
local webp_base = utils.join_path(OUTPUT_DIR, stem)
|
|
local gif_base = utils.join_path(GIF_DIR, stem)
|
|
|
|
-- 4. Ensure output directories exist
|
|
mp.command_native({"run", "mkdir", "-p", OUTPUT_DIR, GIF_DIR})
|
|
|
|
-- 5. Build job list
|
|
local has_adjustments = build_eq_filter() ~= nil
|
|
local jobs = {}
|
|
|
|
local function add_job(base, suffix, ext, vf_string, encoder_fn)
|
|
local out_path = dedup_path(base, suffix, ext)
|
|
local args = build_ffmpeg_seek_args(a, b)
|
|
table.insert(args, "-vf"); table.insert(args, vf_string)
|
|
encoder_fn(args)
|
|
table.insert(args, out_path)
|
|
table.insert(jobs, {args = args, path = out_path, label = ("%s_%s.%s"):format(basename, suffix, ext)})
|
|
end
|
|
|
|
-- WebP encoder
|
|
local function webp_enc(args)
|
|
table.insert(args, "-c:v"); table.insert(args, "libwebp_anim")
|
|
table.insert(args, "-lossless"); table.insert(args, WEBP_LOSSLESS and "1" or "0")
|
|
table.insert(args, "-quality"); table.insert(args, string.format("%d", WEBP_QUALITY))
|
|
table.insert(args, "-loop"); table.insert(args, string.format("%d", WEBP_LOOP))
|
|
table.insert(args, "-an")
|
|
end
|
|
|
|
-- GIF encoder
|
|
local function gif_enc(args)
|
|
table.insert(args, "-loop"); table.insert(args, string.format("%d", GIF_LOOP))
|
|
end
|
|
|
|
-- WebP: normal
|
|
add_job(webp_base, "normal", "webp", build_webp_vf(false), webp_enc)
|
|
-- GIF: normal
|
|
add_job(gif_base, "normal", "gif", build_gif_vf(false), gif_enc)
|
|
|
|
if has_adjustments then
|
|
add_job(webp_base, "modified", "webp", build_webp_vf(true), webp_enc)
|
|
add_job(gif_base, "modified", "gif", build_gif_vf(true), gif_enc)
|
|
end
|
|
|
|
-- 6. Launch all jobs in parallel
|
|
local total = #jobs
|
|
local completed = {}
|
|
local results = {}
|
|
|
|
osd_msg(string.format("Exporting %d loops (WebP + GIF) ...", total), 2)
|
|
for _, j in ipairs(jobs) do
|
|
print(string.format("[export-loop-webp] %s", j.path))
|
|
end
|
|
|
|
local function on_all_done()
|
|
local ok_count = 0
|
|
local fail_list = {}
|
|
local size_parts = {}
|
|
for i = 1, total do
|
|
if results[i].success then
|
|
ok_count = ok_count + 1
|
|
table.insert(size_parts, results[i].label .. format_size(results[i].path))
|
|
else
|
|
table.insert(fail_list, results[i].label)
|
|
end
|
|
end
|
|
|
|
if ok_count == total then
|
|
osd_msg(string.format("All %d exported: %s", total, table.concat(size_parts, ", ")), 5)
|
|
elseif ok_count > 0 then
|
|
osd_msg(string.format("%d/%d exported. Failed: %s — check console.",
|
|
ok_count, total, table.concat(fail_list, ", ")), 5)
|
|
else
|
|
osd_msg("All exports failed. Check console.", 5)
|
|
end
|
|
|
|
for i = 1, total do
|
|
if not results[i].success then
|
|
print(string.format("[export-loop-webp] ERROR [%s]: %s",
|
|
results[i].label, results[i].error or "unknown"))
|
|
end
|
|
end
|
|
end
|
|
|
|
for i, j in ipairs(jobs) do
|
|
mp.command_native({
|
|
name = "subprocess", args = j.args,
|
|
playback_only = false, capture_stdout = false, capture_stderr = false,
|
|
}, function(success, result, err)
|
|
completed[i] = true
|
|
results[i] = {success = success, path = j.path, label = j.label, error = err}
|
|
-- Check if all are done
|
|
for k = 1, total do
|
|
if not completed[k] then return end
|
|
end
|
|
on_all_done()
|
|
end)
|
|
end
|
|
end
|
|
|
|
-- ╔══════════════════════════════════════════╗
|
|
-- ║ KEYBINDING REGISTRATION ║
|
|
-- ╚══════════════════════════════════════════╝
|
|
|
|
mp.add_key_binding(nil, "export", export_both)
|