--[[ SOURCE_: https://github.com/WatanabeChika/mpv-lines-meme-generator Modify_: https://github.com/dyphire/mpv-scripts create long graph of lines with mpv requires ffmpeg. Usage: add bindings to input.conf -- key script-message crop-screenshot -- key script-message take-screenshot -- key script-message stitch-images ]] require("mp.options") local utils = require("mp.utils") local assdraw = require 'mp.assdraw' local msg = require 'mp.msg' local options = { ffmpeg_path = "ffmpeg", screenshot_dir = "~~/screenshots", -- your path to save screenshots lossless = false, -- use lossless screenshots ffmpeg_loglevel = "error", -- ffmpeg log level } read_options(options, _, function() end) local screenshot_dir = mp.command_native({ "expand-path", options.screenshot_dir }) local screenshot_format = options.lossless and ".png" or ".jpg" local screenshot_count = 0 local subtitle_top, subtitle_bottom = 0, 0 local screenshots = {} -- detect path separator, detect path separator, windows uses backslashes local is_windows = package.config:sub(1, 1) == "\\" --create screenshot_dir if it doesn't exist if screenshot_dir ~= '' then local meta = utils.file_info(screenshot_dir) if not meta or not meta.is_dir then local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", screenshot_dir) } local unix_args = { 'mkdir', '-p', screenshot_dir } local args = is_windows and windows_args or unix_args local res = mp.command_native({ name = "subprocess", capture_stdout = true, playback_only = false, args = args }) if res.status ~= 0 then msg.error("Failed to create screenshot_dir save directory " .. screenshot_dir .. ". Error: " .. (res.error or "unknown")) return end end end local function file_exist(path) local meta = utils.file_info(path) if not meta or not meta.is_file then return false end return true end -- helper: crop an input image file into output using mpv local function mpv_crop_file(input_path, out_path, crop_arg) local ENCODER_MAP = { [".png"] = "png", [".jpg"] = "mjpeg", } local ovc = ENCODER_MAP[screenshot_format] or "png" local cmd = { "mpv", input_path, "--no-config" } if crop_arg and crop_arg ~= "" then table.insert(cmd, "--vf=" .. crop_arg) end table.insert(cmd, "--frames=1") table.insert(cmd, "--ovc=" .. ovc) table.insert(cmd, "-o") table.insert(cmd, out_path) local res = utils.subprocess({ args = cmd, capture_stdout = true, capture_stderr = true }) if res and res.status ~= 0 then msg.error("mpv crop failed: status=" .. tostring(res.status) .. " error=" .. tostring(res.error)) if res.stdout then msg.error("mpv stdout: " .. tostring(res.stdout)) end if res.stderr then msg.error("mpv stderr: " .. tostring(res.stderr)) end end return res end -- take and crop screenshots local function perform_capture(idx, crop_arg_local, line) -- take original shots local screenshot_file = utils.join_path(screenshot_dir, "temp_screenshot_" .. idx .. screenshot_format) mp.commandv("screenshot-to-file", screenshot_file, "subtitles") -- crop the shots local processed_file = line and utils.join_path(screenshot_dir, string.format("line_shot_%03d" .. screenshot_format, idx)) or utils.join_path(screenshot_dir, string.format("crop_shot_%s%s", os.date("%Y%m%d_%H%M%S"), screenshot_format)) -- use helper to crop/export the temporary screenshot into final output local result = mpv_crop_file(screenshot_file, processed_file, crop_arg_local) if result and result.status == 0 then mp.osd_message("Shot saved: " .. processed_file) msg.verbose("Shot saved: " .. processed_file) if line then table.insert(screenshots, processed_file) end else mp.osd_message("Cropping failed") msg.verbose("Cropping failed: " .. (result and result.error or "unknown")) end -- delete the original shots os.remove(screenshot_file) end -- Minimal DisplayState derived from mpv_crop_script's implementation local DisplayState = {} DisplayState.__index = DisplayState function DisplayState.new() local self = setmetatable({}, DisplayState) self.screen = { width = 0, height = 0 } self.video = { width = 0, height = 0 } self.bounds = { left = 0, top = 0, right = 0, bottom = 0, width = 0, height = 0 } self.scale = { x = 1, y = 1 } self.current_state = nil self.screen_ready = false self.video_ready = false return self end function DisplayState:_collect_display_state() local screen_w, screen_h = mp.get_osd_size() local state = { screen_w = screen_w, screen_h = screen_h, video_w = mp.get_property_number("dwidth"), video_h = mp.get_property_number("dheight"), video_w_raw = mp.get_property_number("video-out-params/w"), video_h_raw = mp.get_property_number("video-out-params/h"), video_aspect_override = mp.get_property_native("video-aspect-override"), panscan = mp.get_property_native("panscan"), video_zoom = mp.get_property_native("video-zoom"), video_unscaled = mp.get_property_native("video-unscaled"), video_align_x = mp.get_property_native("video-align-x"), video_align_y = mp.get_property_native("video-align-y"), video_pan_x = mp.get_property_native("video-pan-x"), video_pan_y = mp.get_property_native("video-pan-y"), fullscreen = mp.get_property_native("fullscreen"), keepaspect = mp.get_property_native("keepaspect"), keepaspect_window = mp.get_property_native("keepaspect-window"), window_maximized = mp.get_property_native("window-maximized") } return state end function DisplayState:_aspect_calc_panscan(state) local f_width = state.screen_w local f_height = (state.screen_w / state.video_w) * state.video_h if f_height > state.screen_h or f_height < state.video_h_raw then local tmp_w = (state.screen_h / state.video_h) * state.video_w if tmp_w <= state.screen_w then f_height = state.screen_h f_width = tmp_w end end local vo_panscan_area = state.screen_h - f_height local f_w = f_width / f_height local f_h = 1 if (vo_panscan_area == 0) then vo_panscan_area = state.screen_w - f_width f_w = 1 f_h = f_height / f_width end if state.video_unscaled then vo_panscan_area = 0 if state.video_unscaled ~= "downscale-big" or ((state.video_w <= state.screen_w) and (state.video_h <= state.screen_h)) then f_width = state.video_w f_height = state.video_h end end local scaled_w = math.floor( f_width + vo_panscan_area * state.panscan * f_w ) local scaled_h = math.floor( f_height + vo_panscan_area * state.panscan * f_h ) return scaled_w, scaled_h end function DisplayState:_split_scaling(dst_size, scaled_src_size, zoom, align, pan) scaled_src_size = math.floor(scaled_src_size * 2^zoom) align = (align + 1) / 2 local dst_start = (dst_size - scaled_src_size) * align + pan * scaled_src_size local dst_end = dst_start + scaled_src_size return math.floor(dst_start), math.floor(dst_end) end function DisplayState:recalculate_bounds(forced) local new_state = self:_collect_display_state() if not (forced or self.current_state == nil) then local changed = false for k in pairs(new_state) do if new_state[k] ~= self.current_state[k] then changed = true break end end if not changed then return self.screen_ready end end self.current_state = new_state self.screen.width = new_state.screen_w self.screen.height = new_state.screen_h if new_state.video_w and new_state.video_h then self.video.width = new_state.video_w self.video.height = new_state.video_h -- Apply video-aspect-override when present (e.g. "16:9" or "1.7777") by adjusting raw output dims local vao = new_state.video_aspect_override if vao and vao ~= false and tostring(vao) ~= "no" then local s = tostring(vao) -- Try parse A:B style local na, nb = s:match("^(%d+)%s*[:xX]%s*(%d+)$") local ar = nil if na and nb then ar = tonumber(na) / tonumber(nb) else local f = tonumber(s) if f and f > 0 then ar = f end end if ar then -- Prefer to keep video_h_raw and compute video_w_raw to match aspect if new_state.video_h_raw and new_state.video_h_raw > 0 then new_state.video_w_raw = math.floor(new_state.video_h_raw * ar + 0.5) elseif new_state.video_w_raw and new_state.video_w_raw > 0 then new_state.video_h_raw = math.floor(new_state.video_w_raw / ar + 0.5) else -- fallback to reported dwidth/dheight if new_state.video_h and new_state.video_h > 0 then new_state.video_w_raw = math.floor(new_state.video_h * ar + 0.5) elseif new_state.video_w and new_state.video_w > 0 then new_state.video_h_raw = math.floor(new_state.video_w / ar + 0.5) end end -- Also update video_w/video_h (dwidth/dheight) to reflect the override so -- bounds calculation (which uses video_w/video_h) stays consistent with -- the adjusted raw output dimensions. Prefer keeping video_h and -- computing video_w, mirroring the logic above. if new_state.video_h and new_state.video_h > 0 then new_state.video_w = math.floor(new_state.video_h * ar + 0.5) elseif new_state.video_w and new_state.video_w > 0 then new_state.video_h = math.floor(new_state.video_w / ar + 0.5) end end end if new_state.keepaspect then local scaled_w, scaled_h = self:_aspect_calc_panscan(new_state) local video_left, video_right = self:_split_scaling(new_state.screen_w, scaled_w, new_state.video_zoom, new_state.video_align_x, new_state.video_pan_x) local video_top, video_bottom = self:_split_scaling(new_state.screen_h, scaled_h, new_state.video_zoom, new_state.video_align_y, new_state.video_pan_y) self.bounds = { left = video_left, right = video_right, top = video_top, bottom = video_bottom, width = video_right - video_left, height = video_bottom - video_top } else self.bounds = { left = 0, top = 0, right = self.screen.width, bottom = self.screen.height, width = self.screen.width, height = self.screen.height } end -- Use the same source for scale as was used to compute bounds (prefer dwidth/dheight) local basis_w = new_state.video_w or new_state.video_w_raw local basis_h = new_state.video_h or new_state.video_h_raw self.scale.x = basis_w / self.bounds.width self.scale.y = basis_h / self.bounds.height self.video_ready = true end self.screen_ready = true return self.screen_ready end function DisplayState:screen_to_video(x, y) local nx = (x - self.bounds.left) * self.scale.x local ny = (y - self.bounds.top) * self.scale.y return nx, ny end function DisplayState:video_to_screen(x, y) local nx = (x / self.scale.x) + self.bounds.left local ny = (y / self.scale.y) + self.bounds.top return nx, ny end -- create one instance for use display_state = DisplayState.new() -- store observer ids in a local table so we can unobserve on unload local display_observer_ids = {} -- Register observers for properties that affect display mapping. -- When any of these change, recalculate the display bounds once. local function register_display_observers() local props = { "dwidth", "dheight", "video-out-params/w", "video-out-params/h", "video-aspect-override", "panscan", "video-zoom", "video-unscaled", "video-align-x", "video-align-y", "video-pan-x", "video-pan-y", "keepaspect", "keepaspect-window", "fullscreen", "window-maximized" } for _, p in ipairs(props) do local cb = function(name, value) if display_state and display_state.recalculate_bounds then display_state:recalculate_bounds(true) end end local id = mp.observe_property(p, "native", cb) table.insert(display_observer_ids, id) end end local function unregister_display_observers() if not display_observer_ids then return end for _, id in ipairs(display_observer_ids) do mp.unobserve_property(id) end display_observer_ids = {} end local function osd_to_video_coords(x, y) -- Use cached DisplayState if display_state and display_state.recalculate_bounds then -- Ensure display state is up-to-date and video mapping is ready display_state:recalculate_bounds() if display_state.video_ready then local vx, vy = display_state:screen_to_video(x, y) return math.floor(vx + 0.5), math.floor(vy + 0.5) end end return nil end -- Simplified interactive ASS cropper local asscrop_active = false local asscrop_start_pos = nil local asscrop_end_pos = nil local asscrop_timer = nil local asscrop_count = 0 local asscrop_callback = nil local function asscrop_update() if not asscrop_overlay then return end local ass = assdraw.ass_new() if asscrop_start_pos then local sx, sy = asscrop_start_pos[1], asscrop_start_pos[2] local mx, my = mp.get_mouse_pos() local ex, ey if asscrop_end_pos then ex, ey = asscrop_end_pos[1], asscrop_end_pos[2] else ex, ey = mx, my end local x0 = math.min(sx, ex) local y0 = math.min(sy, ey) local x1 = math.max(sx, ex) local y1 = math.max(sy, ey) local osd_w, osd_h = mp.get_osd_size() -- dim outside area using inverse clip so the interior stays untouched local overlay_transparency = 160 -- 0-255 local overlay_lightness = 0 local format_dim = string.format("{\\bord0\\1a&H%02X&\\1c&H%02X%02X%02X&}", overlay_transparency, overlay_lightness, overlay_lightness, overlay_lightness) ass:new_event() ass:append(string.format("{\\iclip(%d,%d,%d,%d)}", x0, y0, x1, y1)) ass:pos(0,0) ass:append(format_dim) ass:draw_start() ass:rect_cw(0,0, osd_w, osd_h) ass:draw_stop() -- Draw border using transparent primary (no fill) and \\3c for outline color local line_color = 220 local box_format = string.format("{\\1a&HFF&\\1c&H000000&\\3c&H%02X%02X%02X&\\bord1}", line_color, line_color, line_color) ass:new_event() ass:pos(0,0) ass:append(box_format) ass:draw_start() ass:rect_cw(x0, y0, x1, y1) ass:draw_stop() -- Handles: transparent fill + white border local hs = 10 local handle_format = "{\\1a&HFF&\\1c&H000000&\\3c&HFFFFFF&\\bord1}" ass:new_event() ass:pos(0,0) ass:append(handle_format) ass:draw_start() ass:rect_cw(x0-hs, y0-hs, x0+hs, y0+hs) ass:rect_cw(x1-hs, y0-hs, x1+hs, y0+hs) ass:rect_cw(x0-hs, y1-hs, x0+hs, y1+hs) ass:rect_cw(x1-hs, y1-hs, x1+hs, y1+hs) ass:draw_stop() -- Size text local sw = math.max(0, x1 - x0) local sh = math.max(0, y1 - y0) ass:new_event() ass:pos(x1 - 6, y1 + 14) ass:an(9) ass:append(string.format("{\\fs16\\bord2\\shad0} %dx%d", sw, sh)) else ass:new_event() ass:pos(10,10) ass:append("{\\fs20\\bord2}Click once to set first corner, click again to set second. Enter to crop, Esc to cancel.") end local osd_w, osd_h = mp.get_osd_size() mp.set_osd_ass(osd_w, osd_h, ass.text) end local function asscrop_cancel(msg) asscrop_start_pos = nil asscrop_end_pos = nil asscrop_callback = nil if asscrop_timer then asscrop_timer:kill() asscrop_timer = nil end mp.set_osd_ass(0,0, "") mp.remove_key_binding("lines_asscrop_mouse") mp.remove_key_binding("lines_asscrop_enter") mp.remove_key_binding("lines_asscrop_esc") asscrop_active = false if msg then mp.osd_message("Crop canceled") end mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(), '') end local function asscrop_finish() if not (asscrop_start_pos and asscrop_end_pos) then mp.osd_message("No crop area set") msg.info("No crop area set") return end -- compute video-space crop local x1s, y1s = asscrop_start_pos[1], asscrop_start_pos[2] local x2s, y2s = asscrop_end_pos[1], asscrop_end_pos[2] local x0 = math.min(x1s, x2s) local y0 = math.min(y1s, y2s) local x1 = math.max(x1s, x2s) local y1 = math.max(y1s, y2s) local vx0, vy0 local vw, vh do local tx0, ty0 = osd_to_video_coords(x0, y0) local tx1, ty1 = osd_to_video_coords(x1, y1) if not tx0 or not tx1 then mp.osd_message("Unable to determine video coordinates") msg.warn("Unable to determine video coordinates") return end vx0, vy0 = tx0, ty0 vw = math.abs(tx1 - tx0) vh = math.abs(ty1 - ty0) end if vw <= 0 or vh <= 0 then mp.osd_message("Bad crop size") msg.warn("Bad crop size") return end -- If a callback is registered (used by take_screenshot first-shot flow), -- call it with crop and skip mpv crop if asscrop_callback then local cb = asscrop_callback asscrop_callback = nil cb({ x = vx0, y = vy0, w = vw, h = vh }) asscrop_cancel(false) return end local crop_arg = string.format("crop=%d:%d:%d:%d", vw, vh, vx0, vy0) asscrop_count = asscrop_count + 1 perform_capture(asscrop_count, crop_arg) asscrop_cancel(false) end local function asscrop_mouse(e) if not e or not e.event then return end if e.event == "down" then local mx, my = mp.get_mouse_pos() if not asscrop_start_pos then asscrop_start_pos = {mx, my} if not asscrop_timer then asscrop_timer = mp.add_periodic_timer(1/60, asscrop_update) end -- immediate update so rectangle follows cursor asscrop_update() else asscrop_end_pos = {mx, my} asscrop_update() end end end local function asscrop_begin() local path = mp.get_property("path") if not path then return end if asscrop_active then asscrop_cancel(true) return end asscrop_active = true asscrop_start_pos = nil asscrop_end_pos = nil asscrop_overlay = mp.create_osd_overlay("ass-events") asscrop_timer = nil -- disable certain uosc UI elements mp.commandv('script-message-to', 'uosc', 'disable-elements', mp.get_script_name(), 'top_bar,timeline,controls,volume,idle_indicator,audio_indicator,buffering_indicator,pause_indicator') -- forced mouse binding to capture clicks mp.add_forced_key_binding("MBTN_LEFT", "lines_asscrop_mouse", asscrop_mouse, {complex=true}) mp.add_forced_key_binding("ENTER", "lines_asscrop_enter", function() asscrop_finish() end) mp.add_forced_key_binding("ESC", "lines_asscrop_esc", function() asscrop_cancel(true) end) mp.osd_message("Crop: Click first corner, then second. Enter to confirm, Esc to cancel.") end local function take_screenshot() local crop_arg = "" local path = mp.get_property("path") if not path then return end local v_w = mp.get_property_number("dwidth") or mp.get_property_number("width", 0) local v_h = mp.get_property_number("dheight") or mp.get_property_number("height", 0) screenshot_count = screenshot_count + 1 if subtitle_top == 0 then screenshot_count = 1 end -- If first shot (or subtitle not set), defer actual capture until after ASS cropper callback if screenshot_count == 1 then asscrop_callback = function(crop) -- crop is video-space x,y,w,h subtitle_top = crop.y / v_h subtitle_bottom = (v_h - (crop.y + crop.h)) / v_h if subtitle_top < 0 or subtitle_bottom < 0 then subtitle_top = 0 subtitle_bottom = 0 mp.osd_message("Invalid subtitle margins\nplease make sure the subtitles are within the video frame") msg.warn("Invalid subtitle margins, please make sure the subtitles are within the video frame") return end mp.osd_message(string.format("Subtitle region set: top=%.3f bottom=%.3f", subtitle_top, subtitle_bottom)) msg.verbose(string.format("Subtitle region set: top=%.3f bottom=%.3f", subtitle_top, subtitle_bottom)) -- now perform the actual capture using the newly set margins local effective_subtitle_top = 1 - subtitle_bottom local crop_h = math.max(1, math.floor(v_h * effective_subtitle_top + 0.5)) local crop_w = v_w local crop_x = 0 local crop_y = 0 crop_arg = string.format("crop=%d:%d:%d:%d", crop_w, crop_h, crop_x, crop_y) perform_capture(screenshot_count, crop_arg, true) end -- Launch the ASS cropper UI and wait for callback to do the capture asscrop_begin() else local crop_height_frac = 1 - subtitle_top - subtitle_bottom if crop_height_frac <= 0 then screenshots = {} screenshot_count = 0 subtitle_top = 0 subtitle_bottom = 0 mp.osd_message("Invalid subtitle margins, skipping crop") msg.warn("Invalid subtitle margins, skipping crop") else local crop_h = math.max(1, math.floor(v_h * crop_height_frac + 0.5)) local crop_w = v_w local crop_x = 0 local crop_y = math.max(0, math.floor(v_h * subtitle_top + 0.5)) crop_arg = string.format("crop=%d:%d:%d:%d", crop_w, crop_h, crop_x, crop_y) perform_capture(screenshot_count, crop_arg, true) end end end -- stitch the cropped screenshots together local function stitch_images() if #screenshots <= 1 then mp.osd_message("No shots to stitch!") return end -- remove entries that no longer exist on disk for i = #screenshots, 1, -1 do if not file_exist(screenshots[i]) then table.remove(screenshots, i) end end local command = { options.ffmpeg_path, "-loglevel", options.ffmpeg_loglevel, "-y" } local output_file = utils.join_path(screenshot_dir, "stitched_screenshot_" .. os.date("%Y%m%d_%H%M%S") .. screenshot_format) local filter_expr = "vstack=" .. #screenshots -- add input files, filter, output filename, one by one for i = 1, #screenshots do table.insert(command, "-i") table.insert(command, screenshots[i]) end table.insert(command, "-filter_complex") table.insert(command, filter_expr) table.insert(command, output_file) -- run the command local result = utils.subprocess({ args = command }) if result.status == 0 then mp.osd_message("Stitched shot saved: " .. output_file) msg.verbose("Stitched shot saved: " .. output_file) else mp.osd_message("Stitching failed") msg.verbose("Stitching failed: " .. (result and result.error or "unknown")) return end -- delete the cropped shots and reset the counter for _, img in ipairs(screenshots) do os.remove(img) end screenshots = {} screenshot_count = 0 subtitle_top = 0 subtitle_bottom = 0 end mp.register_event("file-loaded", function() if display_state then display_state:recalculate_bounds(true) end register_display_observers() end) mp.add_hook("on_unload", 50, function() unregister_display_observers() end) -- bindings mp.add_key_binding(nil, "take-screenshot", take_screenshot) mp.add_key_binding(nil, "stitch-images", stitch_images) mp.add_key_binding(nil, "crop-screenshot", asscrop_begin)