- 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
145 lines
5.4 KiB
Lua
145 lines
5.4 KiB
Lua
-- 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)
|