-- 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)