Files
mpv-scripts/scripts/webp-anim-bridge.lua
T
harley 7a6a5fc1f2 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
2026-07-07 23:53:06 -04:00

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)