Initial import: mpv config + scripts
- mpv.conf: screenshot dir, loop-file - input.conf: zoom/pan/export/screenshot bindings - export-loop.lua (e): A-B loop to MP4 with zoom/pan/brightness bake - export-loop-webp.lua (W): A-B loop to animated WebP + GIF - screenshot-duo.lua (S): raw+on-screen dual screenshots - webp-anim-bridge.lua: animated WebP playback via ImageMagick - transcribe-subtitles.lua (T): faster-whisper SRT export
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
*.log
|
||||
*.debug
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
KP8 add video-pan-y -0.05
|
||||
KP2 add video-pan-y 0.05
|
||||
|
||||
KP4 add video-rotate 90
|
||||
KP6 add video-rotate -90
|
||||
z seek 3 exact
|
||||
|
||||
a vf toggle hflip
|
||||
|
||||
# Export current A-B loop to a video file (via export-loop.lua)
|
||||
e script-binding export_loop/export
|
||||
|
||||
# Export current A-B loop to an animated WebP (frame-accurate, preserves gamma/brightness, via export-loop-webp.lua)
|
||||
W script-binding export_loop_webp/export
|
||||
|
||||
# Transcribe current video to subtitles (SRT) via faster-whisper (via transcribe-subtitles.lua)
|
||||
T script-binding transcribe_subtitles/transcribe
|
||||
|
||||
# Capture two screenshots: raw frame + on-screen (with gamma/brightness/etc, via screenshot-duo.lua)
|
||||
S script-binding screenshot_duo/capture
|
||||
@@ -0,0 +1,319 @@
|
||||
-- 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
|
||||
|
||||
-- WebP settings
|
||||
local WEBP_FPS = 15
|
||||
local WEBP_QUALITY = 80
|
||||
local WEBP_LOSSLESS = false
|
||||
local WEBP_MAX_WIDTH = 800
|
||||
local WEBP_LOOP = 0
|
||||
|
||||
-- GIF settings
|
||||
local GIF_FPS = 15
|
||||
local GIF_MAX_WIDTH = 800 -- bumped from 600 to match WebP
|
||||
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 parts = {}
|
||||
local crop = zoom_pan_crop()
|
||||
if crop then table.insert(parts, crop) end
|
||||
table.insert(parts, string.format("fps=%d", WEBP_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 parts = {}
|
||||
local crop = zoom_pan_crop()
|
||||
if crop then table.insert(parts, crop) end
|
||||
table.insert(parts, string.format("fps=%d", GIF_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 = mp.get_property_number("mf-fps", 10)
|
||||
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)
|
||||
@@ -0,0 +1,310 @@
|
||||
-- export-loop.lua
|
||||
-- Export the current A-B loop to an MP4 via ffmpeg.
|
||||
-- Frame-accurate (re-encodes). Exports both the raw loop and one with
|
||||
-- mpv's gamma/brightness/contrast/saturation baked in.
|
||||
--
|
||||
-- Suggested input.conf binding:
|
||||
-- e script-binding export_loop/export
|
||||
--
|
||||
-- If both ab-loop-a and ab-loop-b are set, spawns ffmpeg with libx264
|
||||
-- and saves to ~/Videos/mpv-loops/.
|
||||
|
||||
local mp = require("mp")
|
||||
local utils = require("mp.utils")
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ CONFIGURATION ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
local OUTPUT_DIR = os.getenv("HOME") .. "/Videos/mpv-loops"
|
||||
local ENABLE_OSD_MSG = true
|
||||
local VIDEO_CRF = 18 -- lower = better quality, 18 is visually lossless
|
||||
local VIDEO_PRESET = "ultrafast" -- encoding speed/compression tradeoff
|
||||
local VIDEO_MAX_WIDTH = 1920 -- scale to fit this width (auto height)
|
||||
local VIDEO_CONTAINER = "mp4" -- output container format
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ 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
|
||||
|
||||
-- Map mpv's video adjustment properties to ffmpeg eq filter string.
|
||||
-- Returns nil if no adjustments are active.
|
||||
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.
|
||||
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))
|
||||
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 vf string. When include_eq is false, eq filters are omitted.
|
||||
-- Zoom/pan crop is prepended before scale when active.
|
||||
local function build_vf_string(include_eq)
|
||||
local parts = {}
|
||||
local crop = zoom_pan_crop()
|
||||
if crop then table.insert(parts, crop) end
|
||||
table.insert(parts, string.format("scale='min(%d,iw)':-1:flags=lanczos", VIDEO_MAX_WIDTH))
|
||||
if include_eq then
|
||||
local eq_str = build_eq_filter()
|
||||
if eq_str then
|
||||
table.insert(parts, eq_str)
|
||||
end
|
||||
end
|
||||
return table.concat(parts, ",")
|
||||
end
|
||||
|
||||
-- Resolve a non-overwriting path: append _N before extension if needed.
|
||||
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 ffmpeg arg prefix: input + seeking + vf.
|
||||
-- Handles mf:// sources from webp-anim-bridge (animated WebP frame sequences).
|
||||
local function build_ffmpeg_base_args(a, b, vf_string)
|
||||
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
|
||||
local pattern = file_path
|
||||
if pattern:sub(1, 5) == "mf://" then
|
||||
pattern = pattern:sub(6)
|
||||
end
|
||||
local mf_fps = mp.get_property_number("mf-fps", 10)
|
||||
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)
|
||||
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
|
||||
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
|
||||
table.insert(args, "-vf"); table.insert(args, vf_string)
|
||||
return args
|
||||
end
|
||||
|
||||
-- Format a file size for display.
|
||||
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_loop()
|
||||
-- 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. Build output filenames
|
||||
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 base_path = utils.join_path(OUTPUT_DIR, stem)
|
||||
|
||||
local normal_path = dedup_path(base_path, "normal", VIDEO_CONTAINER)
|
||||
local modified_path = dedup_path(base_path, "modified", VIDEO_CONTAINER)
|
||||
|
||||
-- 4. Ensure output directory exists
|
||||
mp.command_native({"run", "mkdir", "-p", OUTPUT_DIR})
|
||||
|
||||
-- 5. Detect whether any adjustments are active
|
||||
local has_adjustments = build_eq_filter() ~= nil
|
||||
local ext = VIDEO_CONTAINER
|
||||
|
||||
-- 6. Build ffmpeg arg lists
|
||||
local normal_vf = build_vf_string(false)
|
||||
local normal_args = build_ffmpeg_base_args(a, b, normal_vf)
|
||||
-- encoder
|
||||
table.insert(normal_args, "-c:v"); table.insert(normal_args, "libx264")
|
||||
table.insert(normal_args, "-preset"); table.insert(normal_args, VIDEO_PRESET)
|
||||
table.insert(normal_args, "-crf"); table.insert(normal_args, string.format("%d", VIDEO_CRF))
|
||||
table.insert(normal_args, "-pix_fmt"); table.insert(normal_args, "yuv420p")
|
||||
table.insert(normal_args, "-c:a"); table.insert(normal_args, "copy")
|
||||
table.insert(normal_args, normal_path)
|
||||
|
||||
local modified_args
|
||||
if has_adjustments then
|
||||
local modified_vf = build_vf_string(true)
|
||||
modified_args = build_ffmpeg_base_args(a, b, modified_vf)
|
||||
table.insert(modified_args, "-c:v"); table.insert(modified_args, "libx264")
|
||||
table.insert(modified_args, "-preset"); table.insert(modified_args, VIDEO_PRESET)
|
||||
table.insert(modified_args, "-crf"); table.insert(modified_args, string.format("%d", VIDEO_CRF))
|
||||
table.insert(modified_args, "-pix_fmt"); table.insert(modified_args, "yuv420p")
|
||||
table.insert(modified_args, "-c:a"); table.insert(modified_args, "copy")
|
||||
table.insert(modified_args, modified_path)
|
||||
end
|
||||
|
||||
-- 7. Launch exports in parallel
|
||||
local pending = has_adjustments and 2 or 1
|
||||
local successes = {}
|
||||
local errors = {}
|
||||
local names = {normal_path}
|
||||
|
||||
if pending == 1 then
|
||||
-- Only normal (no adjustments — skip making an identical duplicate)
|
||||
osd_msg(string.format("Exporting loop (normal) ..."), 2)
|
||||
|
||||
mp.command_native({
|
||||
name = "subprocess", args = normal_args,
|
||||
playback_only = false, capture_stdout = false, capture_stderr = false,
|
||||
}, function(success, result, err)
|
||||
if success then
|
||||
osd_msg(string.format("Loop exported: %s%s", normal_path, format_size(normal_path)), 4)
|
||||
print("[export-loop] Saved: " .. normal_path)
|
||||
else
|
||||
osd_msg("Export failed. Check console.", 4)
|
||||
print("[export-loop] ERROR: " .. (err or "unknown"))
|
||||
end
|
||||
end)
|
||||
return
|
||||
end
|
||||
|
||||
names = {normal_path, modified_path}
|
||||
osd_msg("Exporting normal and modified ...", 2)
|
||||
print(string.format("[export-loop] Normal: %s", normal_path))
|
||||
print(string.format("[export-loop] Modified: %s", modified_path))
|
||||
|
||||
local function on_done()
|
||||
if successes[1] and successes[2] then
|
||||
osd_msg(string.format("Both exported: %s%s, %s%s",
|
||||
names[1], format_size(names[1]),
|
||||
names[2], format_size(names[2])), 5)
|
||||
elseif successes[1] then
|
||||
osd_msg(string.format("Normal exported%s. Modified failed. Check console.",
|
||||
format_size(names[1])), 5)
|
||||
elseif successes[2] then
|
||||
osd_msg(string.format("Modified exported%s. Normal failed. Check console.",
|
||||
format_size(names[2])), 5)
|
||||
else
|
||||
osd_msg("Both exports failed. Check console.", 5)
|
||||
end
|
||||
|
||||
local failures_found = false
|
||||
for i = 1, 2 do
|
||||
if errors[i] then
|
||||
print(string.format("[export-loop] ERROR (job %d): %s", i, errors[i]))
|
||||
failures_found = true
|
||||
end
|
||||
end
|
||||
if not failures_found then
|
||||
print(string.format("[export-loop] Both saved: %s, %s", names[1], names[2]))
|
||||
end
|
||||
end
|
||||
|
||||
local completed = {false, false}
|
||||
for i = 1, 2 do
|
||||
local idx = i
|
||||
local args = (idx == 1) and normal_args or modified_args
|
||||
mp.command_native({
|
||||
name = "subprocess", args = args,
|
||||
playback_only = false, capture_stdout = false, capture_stderr = false,
|
||||
}, function(success, result, err)
|
||||
completed[idx] = true
|
||||
successes[idx] = success
|
||||
if not success then errors[idx] = err or "unknown" end
|
||||
if completed[1] and completed[2] then
|
||||
on_done()
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ KEYBINDING REGISTRATION ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
mp.add_key_binding(nil, "export", export_loop)
|
||||
@@ -0,0 +1,176 @@
|
||||
-- screenshot-duo.lua
|
||||
-- Press S to capture two screenshots at the current frame:
|
||||
-- _normal.png — raw frame via ffmpeg (no adjustments)
|
||||
-- _modified.png — what you see on screen (gamma/brightness/etc baked in)
|
||||
--
|
||||
-- Suggested input.conf binding:
|
||||
-- S script-binding screenshot_duo/capture
|
||||
--
|
||||
-- Saves to ~/Videos/mpv-loops/.
|
||||
|
||||
local mp = require("mp")
|
||||
local utils = require("mp.utils")
|
||||
|
||||
local OUTPUT_DIR = os.getenv("HOME") .. "/Videos/mpv-loops"
|
||||
local ENABLE_OSD_MSG = true
|
||||
|
||||
local function osd_msg(text, duration)
|
||||
if ENABLE_OSD_MSG then
|
||||
mp.osd_message(text, duration or 3)
|
||||
end
|
||||
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 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
|
||||
|
||||
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
|
||||
|
||||
-- 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))
|
||||
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
|
||||
|
||||
local function capture_pair()
|
||||
-- 1. Get current position and file path
|
||||
local time_pos = mp.get_property_number("time-pos")
|
||||
if not time_pos then
|
||||
osd_msg("No video position.", 2)
|
||||
return
|
||||
end
|
||||
|
||||
local file_path = mp.get_property("path")
|
||||
if not file_path then
|
||||
osd_msg("No file loaded.", 2)
|
||||
return
|
||||
end
|
||||
|
||||
-- 2. Build filenames
|
||||
local filename_stripped = mp.get_property("filename/no-ext") or "video"
|
||||
local basename = filename_stripped:gsub("[^%w._-]", "_")
|
||||
|
||||
local label = format_label(time_pos)
|
||||
local stem = string.format("%s_%s", basename, label)
|
||||
local base_path = utils.join_path(OUTPUT_DIR, stem)
|
||||
|
||||
local ext = "png"
|
||||
local normal_path = dedup_path(base_path, "normal", ext)
|
||||
local modified_path = dedup_path(base_path, "modified", ext)
|
||||
|
||||
-- 3. Ensure output directory exists
|
||||
mp.command_native({"run", "mkdir", "-p", OUTPUT_DIR})
|
||||
|
||||
-- 4. Build ffmpeg args for normal screenshot
|
||||
local crop_filter = zoom_pan_crop()
|
||||
local ffmpeg_args = {"ffmpeg", "-y"}
|
||||
local src = mp.get_property("stream-path") or mp.get_property("path", "")
|
||||
local mf_prefix = "mf:///tmp/mpv-webp-anim/"
|
||||
|
||||
if src:sub(1, #mf_prefix) == mf_prefix or src:find("/tmp/mpv-webp-anim/") then
|
||||
-- mf:// from webp-anim-bridge: use specific frame
|
||||
local pattern = src
|
||||
if pattern:sub(1, 5) == "mf://" then pattern = pattern:sub(6) end
|
||||
local mf_fps = mp.get_property_number("mf-fps", 10)
|
||||
local frame_num = math.floor(time_pos * mf_fps)
|
||||
local frame_file = pattern:gsub("%%04d", string.format("%04d", frame_num))
|
||||
if frame_file == pattern then
|
||||
local dir = pattern:match("^(.*/)") or "."
|
||||
frame_file = dir .. string.format("frame_%04d.png", frame_num)
|
||||
end
|
||||
table.insert(ffmpeg_args, "-i"); table.insert(ffmpeg_args, frame_file)
|
||||
table.insert(ffmpeg_args, "-vframes"); table.insert(ffmpeg_args, "1")
|
||||
table.insert(ffmpeg_args, "-q:v"); table.insert(ffmpeg_args, "3")
|
||||
else
|
||||
table.insert(ffmpeg_args, "-ss"); table.insert(ffmpeg_args, string.format("%06.3f", time_pos))
|
||||
table.insert(ffmpeg_args, "-i"); table.insert(ffmpeg_args, src)
|
||||
table.insert(ffmpeg_args, "-vframes"); table.insert(ffmpeg_args, "1")
|
||||
table.insert(ffmpeg_args, "-q:v"); table.insert(ffmpeg_args, "3")
|
||||
end
|
||||
if crop_filter then
|
||||
table.insert(ffmpeg_args, "-vf")
|
||||
table.insert(ffmpeg_args, crop_filter)
|
||||
end
|
||||
table.insert(ffmpeg_args, normal_path)
|
||||
|
||||
-- 5. Modified screenshot via mpv (captures current on-screen image with adjustments)
|
||||
mp.commandv("screenshot-to-file", modified_path)
|
||||
|
||||
-- 6. Launch ffmpeg async (background, no flashing)
|
||||
osd_msg("Capturing screenshots ...", 2)
|
||||
print(string.format("[screenshot-duo] Normal: %s", normal_path))
|
||||
print(string.format("[screenshot-duo] Modified: %s", modified_path))
|
||||
|
||||
mp.command_native({
|
||||
name = "subprocess",
|
||||
args = ffmpeg_args,
|
||||
playback_only = false,
|
||||
capture_stdout = false,
|
||||
capture_stderr = false,
|
||||
}, function(success, result, err)
|
||||
if success then
|
||||
local mpv_info = utils.file_info(modified_path)
|
||||
local mpv_size = ""
|
||||
if mpv_info and mpv_info.size then
|
||||
if mpv_info.size > 1024 * 1024 then
|
||||
mpv_size = string.format(" (%.1f MB)", mpv_info.size / 1024 / 1024)
|
||||
elseif mpv_info.size > 1024 then
|
||||
mpv_size = string.format(" (%.0f KB)", mpv_info.size / 1024)
|
||||
else
|
||||
mpv_size = string.format(" (%d B)", mpv_info.size)
|
||||
end
|
||||
end
|
||||
osd_msg(string.format("Screenshots: %s%s, %s%s",
|
||||
normal_path, format_size(normal_path),
|
||||
modified_path, mpv_size), 5)
|
||||
print(string.format("[screenshot-duo] Both saved: %s, %s", normal_path, modified_path))
|
||||
else
|
||||
-- mpv screenshot succeeded but ffmpeg failed
|
||||
osd_msg("Normal screenshot failed. Modified saved.", 4)
|
||||
print("[screenshot-duo] ffmpeg ERROR: " .. (err or "unknown"))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
mp.add_key_binding(nil, "capture", capture_pair)
|
||||
@@ -0,0 +1,75 @@
|
||||
-- transcribe-subtitles.lua
|
||||
-- Press T to generate subtitles for the current video via faster-whisper.
|
||||
-- Spawns transcription in background, polls for SRT file, loads it when done.
|
||||
--
|
||||
-- input.conf: T script-binding transcribe_subtitles/transcribe
|
||||
|
||||
local mp = require("mp")
|
||||
local utils = require("mp.utils")
|
||||
|
||||
local function load_srt(srt_path)
|
||||
mp.commandv("sub-add", srt_path)
|
||||
local count = mp.get_property_number("track-list/count", 0)
|
||||
for i = 0, count - 1 do
|
||||
if mp.get_property("track-list/" .. i .. "/type") == "sub"
|
||||
and mp.get_property("track-list/" .. i .. "/external") == "yes"
|
||||
and mp.get_property("track-list/" .. i .. "/external-filename") == srt_path then
|
||||
mp.set_property_number("sub-track-id", i + 1)
|
||||
break
|
||||
end
|
||||
end
|
||||
mp.osd_message("Subtitles loaded", 2)
|
||||
end
|
||||
|
||||
local function poll_srt(srt_path, elapsed, max_wait)
|
||||
if elapsed >= max_wait then
|
||||
mp.osd_message("Transcription timed out", 3)
|
||||
return
|
||||
end
|
||||
|
||||
local f = io.open(srt_path, "r")
|
||||
if f then
|
||||
f:close()
|
||||
load_srt(srt_path)
|
||||
return
|
||||
end
|
||||
|
||||
mp.add_timeout(2, function()
|
||||
poll_srt(srt_path, elapsed + 2, max_wait)
|
||||
end)
|
||||
end
|
||||
|
||||
local function transcribe()
|
||||
local file_path = mp.get_property("path")
|
||||
if not file_path then mp.osd_message("No file loaded.", 2); return end
|
||||
|
||||
local abs_path = mp.get_property("stream-path")
|
||||
or utils.join_path(mp.get_property("working-directory", "."), file_path)
|
||||
|
||||
local base = abs_path:gsub("%.[^./]+$", "")
|
||||
local srt_path = base .. ".srt"
|
||||
|
||||
-- If SRT already exists, just load it
|
||||
local existing = io.open(srt_path, "r")
|
||||
if existing then
|
||||
existing:close()
|
||||
load_srt(srt_path)
|
||||
return
|
||||
end
|
||||
|
||||
local transcriber = os.getenv("HOME") .. "/.local/bin/transcribe-video"
|
||||
mp.osd_message("Transcribing subtitles ...", 1)
|
||||
|
||||
local shell_cmd = "export LD_LIBRARY_PATH=/opt/cuda/targets/x86_64-linux/lib; "
|
||||
.. transcriber .. " --srt \"" .. abs_path .. "\" \"" .. srt_path .. "\" &>/dev/null &"
|
||||
|
||||
mp.command_native({
|
||||
name = "subprocess",
|
||||
args = { "bash", "-c", shell_cmd },
|
||||
playback_only = false,
|
||||
})
|
||||
|
||||
poll_srt(srt_path, 0, 900)
|
||||
end
|
||||
|
||||
mp.add_key_binding(nil, "transcribe", transcribe)
|
||||
@@ -0,0 +1,144 @@
|
||||
-- webp-anim-bridge.lua
|
||||
-- Play animated WebP files in mpv via ImageMagick frame extraction.
|
||||
--
|
||||
-- Detects animated WebP at load time. If animated, extracts frames to a temp
|
||||
-- directory and plays them as an image sequence. Falls through to normal mpv
|
||||
-- handling for still WebP.
|
||||
--
|
||||
-- No keybinding needed — runs automatically on file load.
|
||||
|
||||
local mp = require("mp")
|
||||
local utils = require("mp.utils")
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ CONFIGURATION ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
local TMP_BASE = "/tmp/mpv-webp-anim"
|
||||
local FRAME_DELAY_MS = 100 -- default frame delay in ms (overridden by EXIF/detect)
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ ANIMATED WEBP DETECTION ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
local function read_u32_le(s, offset)
|
||||
return s:byte(offset) +
|
||||
s:byte(offset + 1) * 256 +
|
||||
s:byte(offset + 2) * 65536 +
|
||||
s:byte(offset + 3) * 16777216
|
||||
end
|
||||
|
||||
local function is_animated_webp(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return false end
|
||||
local header = f:read(200)
|
||||
f:close()
|
||||
if not header or #header < 24 then return false end
|
||||
local riff = header:sub(1, 4)
|
||||
local webp_id = header:sub(9, 12)
|
||||
if riff ~= "RIFF" or webp_id ~= "WEBP" then return false end
|
||||
-- Walk chunks looking for VP8X with animation flag
|
||||
-- First chunk starts at byte 13 (1-based), after 12-byte RIFF header
|
||||
local pos = 13
|
||||
while pos <= #header - 8 do
|
||||
local ck_id = header:sub(pos, pos + 3)
|
||||
local ck_size = read_u32_le(header, pos + 4)
|
||||
if ck_id == "VP8X" and #header >= pos + 9 then
|
||||
local flags = header:byte(pos + 8)
|
||||
-- bit 1 (0x02) = animation flag
|
||||
return flags % 4 >= 2
|
||||
end
|
||||
if ck_size == 0 then break end
|
||||
pos = pos + 8 + ck_size
|
||||
if pos % 2 ~= 0 then pos = pos + 1 end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ FRAME EXTRACTION ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
local function extract_frames(path)
|
||||
-- Create a temp dir unique to this file
|
||||
local stamp = tostring(math.floor(mp.get_time() * 1000))
|
||||
local tmpdir = TMP_BASE .. "/" .. stamp
|
||||
os.execute("mkdir -p " .. tmpdir)
|
||||
|
||||
-- Use ImageMagick to extract frames as PNGs
|
||||
local args = {
|
||||
"magick", path,
|
||||
"-coalesce",
|
||||
tmpdir .. "/frame_%04d.png"
|
||||
}
|
||||
local es = mp.command_native({
|
||||
name = "subprocess",
|
||||
args = args,
|
||||
playback_only = false,
|
||||
capture_stdout = false,
|
||||
capture_stderr = false,
|
||||
})
|
||||
if not es or not es.status or es.status ~= 0 then
|
||||
print("[webp-anim-bridge] magick extraction failed")
|
||||
os.execute("rm -rf " .. tmpdir)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Get frame count and sort numerically
|
||||
local handle = io.popen("ls " .. tmpdir .. "/frame_*.png 2>/dev/null | sort")
|
||||
if not handle then
|
||||
os.execute("rm -rf " .. tmpdir)
|
||||
return nil
|
||||
end
|
||||
local frames = {}
|
||||
for fname in handle:lines() do
|
||||
table.insert(frames, fname)
|
||||
end
|
||||
handle:close()
|
||||
|
||||
if #frames == 0 then
|
||||
os.execute("rm -rf " .. tmpdir)
|
||||
return nil
|
||||
end
|
||||
|
||||
return frames, tmpdir
|
||||
end
|
||||
|
||||
-- ╔══════════════════════════════════════════╗
|
||||
-- ║ MAIN HOOK ║
|
||||
-- ╚══════════════════════════════════════════╝
|
||||
|
||||
-- on_load hook fires before the file is opened by the demuxer.
|
||||
-- Aborts the original load and replaces it with mf:// image sequence.
|
||||
local function hook_on_load()
|
||||
local path = mp.get_property("path", "")
|
||||
if not path:lower():match("%.webp$") then return end
|
||||
|
||||
-- Resolve absolute path
|
||||
local resolved = mp.get_property("stream-path") or
|
||||
utils.join_path(mp.get_property("working-directory", "."), path)
|
||||
|
||||
-- Skip if already extracted (prevents recursion when we load mf://)
|
||||
if resolved:find("/tmp/mpv-webp-anim/") then return end
|
||||
|
||||
-- Check if animated
|
||||
if not is_animated_webp(resolved) then return end
|
||||
|
||||
-- Extract frames
|
||||
local frames, tmpdir = extract_frames(resolved)
|
||||
if not frames or #frames == 0 then
|
||||
mp.msg.warn("[webp-anim-bridge] Failed to extract frames from " .. resolved)
|
||||
return
|
||||
end
|
||||
|
||||
-- Build mf:// URL (image sequence)
|
||||
local fps = math.max(1, math.floor(1000 / FRAME_DELAY_MS))
|
||||
|
||||
-- Abort original load, replace with mf:// sequence
|
||||
mp.commandv("loadfile", "mf://" .. tmpdir .. "/frame_%04d.png", "replace")
|
||||
mp.set_property("mf-fps", tostring(fps))
|
||||
mp.set_property("loop-file", "inf")
|
||||
mp.set_property("keep-open", "yes")
|
||||
end
|
||||
|
||||
mp.add_hook("on_load", 50, hook_on_load)
|
||||
Reference in New Issue
Block a user