Skip to content

Instantly share code, notes, and snippets.

@HedioKojima
Last active June 6, 2026 02:08
Show Gist options
  • Select an option

  • Save HedioKojima/33e5b14f9a4a7c0bcc0a04580c2ba80e to your computer and use it in GitHub Desktop.

Select an option

Save HedioKojima/33e5b14f9a4a7c0bcc0a04580c2ba80e to your computer and use it in GitHub Desktop.
thumbnailer (screenshot-mosaic) by chatgpt
-- thumbnailer.lua
-- Windows版:生成 x*y 缩略图,左上角写入视频信息
-- 输出到 ~~/screenshots/ (通常就是 portable_config 下对应目录)
-- input.conf可以Ctrl+Shift+s script-binding thumbnailer/generate_thumbnails
-- 依赖:ffmpeg
local mp = require("mp")
local utils = require("mp.utils")
local msg = require("mp.msg")
local options = {
grid_cols = 4,
grid_rows = 4,
thumb_width = 360,
padding_top = 250, -- 给标题区留更大空间,避免和缩略图重叠
font_size_header = 20, -- 标题字体
font_size_timestamp = 20, -- 时间戳字体
output_dir = "~~/screenshots/",
format = "webp", -- "jpeg", "jxl" 或 "webp" <-- 【修改点1:更新注释】
skip_start_sec = 15, -- 跳过开头黑屏/片头
skip_end_sec = 15, -- 顺手跳过结尾几秒,防止片尾黑屏
}
local overlay = mp.create_osd_overlay("ass-events")
local function set_loading_indicator(show)
if show then
overlay.res_x = 1920
overlay.res_y = 1080
overlay.data = "{\\an9\\pos(1900,20)\\fs60\\1c&HFFFFFF&\\bord2\\3c&H000000&}●"
overlay:update()
else
overlay:remove()
end
end
local function format_time(seconds)
if not seconds or seconds <= 0 then
return "00:00:00"
end
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 format_bytes(bytes)
if not bytes or bytes <= 0 then
return "Unknown"
end
if bytes >= 1024 * 1024 * 1024 then
return string.format("%.2f GB (%d bytes)", bytes / (1024 * 1024 * 1024), bytes)
elseif bytes >= 1024 * 1024 then
return string.format("%.2f MB (%d bytes)", bytes / (1024 * 1024), bytes)
elseif bytes >= 1024 then
return string.format("%.2f KB (%d bytes)", bytes / 1024, bytes)
end
return string.format("%d bytes", bytes)
end
local function format_bitrate(bps)
if not bps or bps <= 0 then
return "Unknown"
end
if bps >= 1000 * 1000 then
return string.format("%.2f Mbps", bps / 1000 / 1000)
end
return string.format("%.0f kbps", bps / 1000)
end
local function is_url(path)
return type(path) == "string" and path:match("^%a[%w+%.%-]*://") ~= nil
end
local function sanitize_filename(name)
name = name or "video"
name = name:gsub("%.[^.]+$", "")
name = name:gsub('[<>:"/\\|%?%*%c]', "_")
name = name:gsub("^%s+", ""):gsub("%s+$", "")
if name == "" then
name = "video"
end
return name
end
local function ensure_dir_exists(dir)
local fi = utils.file_info(dir)
if fi and fi.is_dir then
return true
end
local result = mp.command_native({
name = "subprocess",
args = { "cmd", "/c", "mkdir", dir },
playback_only = false,
capture_stdout = true,
capture_stderr = true,
})
local fi_after = utils.file_info(dir)
if fi_after and fi_after.is_dir then
return true
end
return false, (result and result.stderr) or "failed to create directory"
end
local function write_text_file(path, text)
local f, err = io.open(path, "wb")
if not f then
return nil, err
end
f:write(text)
f:close()
return true
end
local function escape_ffmpeg_filter_value(s)
s = s:gsub("\\", "/")
s = s:gsub(":", "\\:")
s = s:gsub("'", "\\'")
s = s:gsub("%[", "\\[")
s = s:gsub("%]", "\\]")
s = s:gsub(",", "\\,")
s = s:gsub(";", "\\;")
return s
end
local function generate_thumbnails()
local path = mp.get_property("path")
if not path then
mp.osd_message("没有可用输入。", 3)
return
end
local duration = mp.get_property_number("duration", 0)
local size = mp.get_property_number("file-size", 0)
local width = mp.get_property_number("width", 0)
local height = mp.get_property_number("height", 0)
local fps = mp.get_property_number("estimated-vf-fps", 0)
if fps <= 0 then
fps = mp.get_property_number("container-fps", 0)
end
local codec = mp.get_property("video-codec", "unknown")
-- 码率优先按“文件大小 / 时长”计算,更稳定
local bitrate = 0
if size > 0 and duration > 0 then
bitrate = (size * 8) / duration
else
bitrate = mp.get_property_number("video-bitrate", 0)
if bitrate <= 0 then
bitrate = mp.get_property_number("bitrate", 0)
end
end
local filename = mp.get_property("filename") or "video"
local base_name = sanitize_filename(filename)
local resolution_str = (width > 0 and height > 0) and string.format("%dx%d", width, height) or "Unknown"
local fps_str = (fps > 0) and string.format("%.3f fps", fps) or "Unknown"
local header_text = table.concat({
"File: " .. filename,
"Size: " .. format_bytes(size),
"Duration: " .. format_time(duration),
"Bitrate: " .. format_bitrate(bitrate),
"Resolution: " .. resolution_str,
"FPS: " .. fps_str,
"Codec: " .. tostring(codec or "unknown"),
}, "\n")
local screenshot_dir = mp.command_native({ "expand-path", options.output_dir })
if not screenshot_dir or screenshot_dir == "" then
mp.osd_message("无法展开输出目录。", 3)
return
end
local ok, dir_err = ensure_dir_exists(screenshot_dir)
if not ok then
mp.osd_message("创建输出目录失败: " .. tostring(dir_err), 4)
msg.error("Failed to create output dir: " .. tostring(dir_err))
return
end
-- 【修改点2:增加 webp 校验】
local ext = string.lower(options.format)
if ext ~= "jpeg" and ext ~= "jxl" and ext ~= "webp" then
ext = "jpeg"
end
local out_file = utils.join_path(screenshot_dir, base_name .. "_thumb." .. ext)
local temp_file = utils.join_path(
screenshot_dir,
base_name .. "._thumb_header_" .. os.time() .. "_" .. math.random(1000, 9999) .. ".txt"
)
local wrote, write_err = write_text_file(temp_file, header_text)
if not wrote then
mp.osd_message("写入临时标题文件失败: " .. tostring(write_err), 4)
msg.error("Failed to write temp text file: " .. tostring(write_err))
return
end
local tiles = options.grid_cols * options.grid_rows
local start_sec = math.min(options.skip_start_sec or 0, math.max(duration - 1, 0))
local end_sec = math.max(duration - (options.skip_end_sec or 0), start_sec + 1)
local effective_duration = end_sec - start_sec
if effective_duration <= 0 then
start_sec = 0
end_sec = duration
effective_duration = duration
end
local interval = effective_duration / (tiles + 1)
if interval <= 0 then
interval = 1.0
end
local escaped_textfile = escape_ffmpeg_filter_value(temp_file)
local vf = string.format(
"select='gte(t\\,%f)*lte(t\\,%f)*(isnan(prev_selected_t)+gte(t-prev_selected_t\\,%f))'," ..
"scale=%d:-2," ..
"drawtext=fontsize=%d:fontcolor=white:box=1:boxcolor=black@0.6:" ..
"text='%%{pts\\:hms}':x=w-tw-10:y=h-th-10," ..
"tile=%dx%d:nb_frames=%d," ..
"pad=width=iw:height=ih+%d:x=0:y=%d:color=black," ..
"drawtext=expansion=none:fontsize=%d:fontcolor=white:line_spacing=4:" ..
"textfile='%s':reload=0:x=20:y=20",
start_sec, end_sec, interval,
options.thumb_width,
options.font_size_timestamp,
options.grid_cols, options.grid_rows, tiles,
options.padding_top, options.padding_top,
options.font_size_header,
escaped_textfile
)
local args = {
"ffmpeg",
"-v", "error",
"-y",
"-skip_frame", "nokey",
"-i", path,
"-map", "0:v:0",
"-an", "-sn", "-dn",
"-vf", vf,
"-frames:v", "1",
}
-- 【修改点3:增加 webp 的 FFmpeg 编码参数】
if ext == "jxl" then
table.insert(args, "-c:v")
table.insert(args, "libjxl")
elseif ext == "webp" then
table.insert(args, "-c:v")
table.insert(args, "libwebp")
table.insert(args, "-quality")
table.insert(args, "90") -- webp 质量范围是 0-100,90 为高质量
else
table.insert(args, "-q:v")
table.insert(args, "2")
end
table.insert(args, out_file)
set_loading_indicator(true)
mp.osd_message("正在生成缩略图...", 2)
msg.info("Running FFmpeg: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
set_loading_indicator(false)
pcall(os.remove, temp_file)
if success and result and result.status == 0 then
mp.osd_message("缩略图已保存: " .. out_file, 3)
msg.info("Saved: " .. out_file)
else
local err_text = "Unknown error"
if result and result.stderr and result.stderr ~= "" then
err_text = result.stderr
elseif error and error ~= "" then
err_text = error
end
mp.osd_message("生成失败,详见控制台。", 5)
msg.error("FFmpeg Error: " .. err_text)
end
end)
end
mp.add_key_binding("ctrl+T", "generate_thumbnails", generate_thumbnails)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment