This commit is contained in:
2026-08-15 17:50:48 +02:00
parent 22246060e6
commit 4dc6687de7
35 changed files with 4978 additions and 1509 deletions
+670
View File
@@ -0,0 +1,670 @@
--[[
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)