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