Files
mpv-scripts/scripts/export-loop-webp.lua
harley 39171a4285 Dynamic FPS: WebP/GIF inherit source fps (min 30)
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.
2026-07-07 23:55:20 -04:00

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)