Last active
June 6, 2026 02:08
-
-
Save HedioKojima/33e5b14f9a4a7c0bcc0a04580c2ba80e to your computer and use it in GitHub Desktop.
thumbnailer (screenshot-mosaic) by chatgpt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| -- 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