From 7a6a5fc1f298c408d2e38a47fc6f0791a8891161 Mon Sep 17 00:00:00 2001 From: Harley Date: Tue, 7 Jul 2026 23:53:06 -0400 Subject: [PATCH] Initial import: mpv config + scripts - mpv.conf: screenshot dir, loop-file - input.conf: zoom/pan/export/screenshot bindings - export-loop.lua (e): A-B loop to MP4 with zoom/pan/brightness bake - export-loop-webp.lua (W): A-B loop to animated WebP + GIF - screenshot-duo.lua (S): raw+on-screen dual screenshots - webp-anim-bridge.lua: animated WebP playback via ImageMagick - transcribe-subtitles.lua (T): faster-whisper SRT export --- .gitignore | 2 + input.conf | 20 ++ mpv.conf | 4 + scripts/export-loop-webp.lua | 319 +++++++++++++++++++++++++++++++ scripts/export-loop.lua | 310 ++++++++++++++++++++++++++++++ scripts/screenshot-duo.lua | 176 +++++++++++++++++ scripts/transcribe-subtitles.lua | 75 ++++++++ scripts/webp-anim-bridge.lua | 144 ++++++++++++++ 8 files changed, 1050 insertions(+) create mode 100644 .gitignore create mode 100644 input.conf create mode 100644 mpv.conf create mode 100644 scripts/export-loop-webp.lua create mode 100644 scripts/export-loop.lua create mode 100644 scripts/screenshot-duo.lua create mode 100644 scripts/transcribe-subtitles.lua create mode 100644 scripts/webp-anim-bridge.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1b20459 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.log +*.debug diff --git a/input.conf b/input.conf new file mode 100644 index 0000000..c54790a --- /dev/null +++ b/input.conf @@ -0,0 +1,20 @@ +KP8 add video-pan-y -0.05 +KP2 add video-pan-y 0.05 + +KP4 add video-rotate 90 +KP6 add video-rotate -90 +z seek 3 exact + +a vf toggle hflip + +# Export current A-B loop to a video file (via export-loop.lua) +e script-binding export_loop/export + +# Export current A-B loop to an animated WebP (frame-accurate, preserves gamma/brightness, via export-loop-webp.lua) +W script-binding export_loop_webp/export + +# Transcribe current video to subtitles (SRT) via faster-whisper (via transcribe-subtitles.lua) +T script-binding transcribe_subtitles/transcribe + +# Capture two screenshots: raw frame + on-screen (with gamma/brightness/etc, via screenshot-duo.lua) +S script-binding screenshot_duo/capture diff --git a/mpv.conf b/mpv.conf new file mode 100644 index 0000000..18a61c1 --- /dev/null +++ b/mpv.conf @@ -0,0 +1,4 @@ +screenshot-directory=~/Downloads/mpvshots + +loop-file=yes + diff --git a/scripts/export-loop-webp.lua b/scripts/export-loop-webp.lua new file mode 100644 index 0000000..81a0cf2 --- /dev/null +++ b/scripts/export-loop-webp.lua @@ -0,0 +1,319 @@ +-- export-loop-webp.lua +-- Press W to export the current A-B loop as both an animated WebP + GIF. +-- Frame-accurate (re-encodes). Exports raw + adjusted (gamma/brightness/etc). +-- +-- WebP → ~/Videos/mpv-loops/ +-- GIF → ~/Videos/mpv-loops/gif/ +-- +-- Suggested input.conf binding: +-- W script-binding export_loop_webp/export + +local mp = require("mp") +local utils = require("mp.utils") + +-- ╔══════════════════════════════════════════╗ +-- ║ CONFIGURATION ║ +-- ╚══════════════════════════════════════════╝ + +local OUTPUT_DIR = os.getenv("HOME") .. "/Videos/mpv-loops" +local GIF_DIR = OUTPUT_DIR .. "/gif" +local ENABLE_OSD_MSG = true + +-- WebP settings +local WEBP_FPS = 15 +local WEBP_QUALITY = 80 +local WEBP_LOSSLESS = false +local WEBP_MAX_WIDTH = 800 +local WEBP_LOOP = 0 + +-- GIF settings +local GIF_FPS = 15 +local GIF_MAX_WIDTH = 800 -- bumped from 600 to match WebP +local GIF_DITHER = "bayer" +local GIF_LOOP = 0 + +-- ╔══════════════════════════════════════════╗ +-- ║ FORMAT HELPERS ║ +-- ╚══════════════════════════════════════════╝ + +local function format_ts(seconds) + local h = math.floor(seconds / 3600) + local m = math.floor((seconds % 3600) / 60) + local s = seconds % 60 + return string.format("%02d:%02d:%06.3f", h, m, s) +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 osd_msg(text, duration) + if ENABLE_OSD_MSG then + mp.osd_message(text, duration or 3) + end +end + +local function build_eq_filter() + local brightness = mp.get_property_number("brightness", 0) + local contrast = mp.get_property_number("contrast", 0) + local saturation = mp.get_property_number("saturation", 0) + local gamma = mp.get_property_number("gamma", 0) + + local parts = {} + if brightness ~= 0 then table.insert(parts, string.format("brightness=%.2f", brightness / 100.0)) end + if contrast ~= 0 then table.insert(parts, string.format("contrast=%.2f", 1.0 + contrast / 100.0)) end + if saturation ~= 0 then table.insert(parts, string.format("saturation=%.2f", 1.0 + saturation / 100.0)) end + if gamma ~= 0 then table.insert(parts, string.format("gamma=%.2f", 1.0 + gamma / 100.0)) end + + if #parts == 0 then return nil end + return "eq=" .. table.concat(parts, ":") +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)) + -- Treat sentinel -999 or actual 0 as "no value" + 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 + +-- Build the WebP vf string (fps + optional eq + zoom/pan crop) +local function build_webp_vf(include_eq) + local parts = {} + local crop = zoom_pan_crop() + if crop then table.insert(parts, crop) end + table.insert(parts, string.format("fps=%d", WEBP_FPS)) + if include_eq then + local eq = build_eq_filter() + if eq then table.insert(parts, eq) end + end + return table.concat(parts, ",") +end + +-- Build the GIF vf string (fps + optional eq + palettegen/paletteuse + zoom/pan crop) +local function build_gif_vf(include_eq) + local parts = {} + local crop = zoom_pan_crop() + if crop then table.insert(parts, crop) end + table.insert(parts, string.format("fps=%d", GIF_FPS)) + if include_eq then + local eq = build_eq_filter() + if eq then table.insert(parts, eq) end + end + local pre = table.concat(parts, ",") + -- palettegen+paletteuse pipeline (two-pass inside one vf) + return string.format("%s,split[s0][s1];[s0]palettegen=max_colors=256[p];[s1][p]paletteuse=dither=%s", + pre, GIF_DITHER) +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 + +-- Build the common ffmpeg prefix (input + seeking) +-- Handles mf:// sources from webp-anim-bridge (animated WebP frame sequences). +local function build_ffmpeg_seek_args(a, b) + local args = {"ffmpeg", "-y"} + local file_path = mp.get_property("stream-path") or mp.get_property("path", "") + local mf_prefix = "mf:///tmp/mpv-webp-anim/" + + if file_path:sub(1, #mf_prefix) == mf_prefix or file_path:find("/tmp/mpv-webp-anim/") then + -- Source is from webp-anim-bridge: use the extracted PNG frames + local pattern = file_path + if pattern:sub(1, 5) == "mf://" then + pattern = pattern:sub(6) + end + local mf_fps = mp.get_property_number("mf-fps", 10) + table.insert(args, "-framerate"); table.insert(args, tostring(mf_fps)) + table.insert(args, "-start_number"); table.insert(args, "0") + table.insert(args, "-i"); table.insert(args, pattern) + -- Seek by frame number instead of timestamp + local start_frame = math.floor(a * mf_fps) + local end_frame = math.ceil(b * mf_fps) - 1 + local num_frames = math.max(1, end_frame - start_frame + 1) + table.insert(args, "-start_number"); table.insert(args, tostring(start_frame)) + table.insert(args, "-vframes"); table.insert(args, tostring(num_frames)) + else + -- Normal source: seek by timestamp + table.insert(args, "-i"); table.insert(args, file_path) + table.insert(args, "-ss"); table.insert(args, format_ts(a)) + table.insert(args, "-to"); table.insert(args, format_ts(b)) + end + return args +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 + +-- ╔══════════════════════════════════════════╗ +-- ║ MAIN EXPORT FUNCTION ║ +-- ╚══════════════════════════════════════════╝ + +local function export_both() + -- 1. Read loop points + local a = mp.get_property_number("ab-loop-a") + local b = mp.get_property_number("ab-loop-b") + + if not a or not b then + osd_msg("No A-B loop set. Press l twice to create one.", 3) + return + end + if a > b then a, b = b, a end + if a == b then + osd_msg("Loop has zero duration. Set different A and B points.", 3) + return + end + + -- 2. Check a file is loaded + local has_file = mp.get_property("path") + if not has_file then + osd_msg("No file loaded.", 3) + return + end + + -- 3. Filename stem + local filename_stripped = mp.get_property("filename/no-ext") or "video" + local basename = filename_stripped:gsub("[^%w._-]", "_") + local label_a = format_label(a) + local label_b = format_label(b) + local stem = string.format("%s_loop_%s-%s", basename, label_a, label_b) + + local webp_base = utils.join_path(OUTPUT_DIR, stem) + local gif_base = utils.join_path(GIF_DIR, stem) + + -- 4. Ensure output directories exist + mp.command_native({"run", "mkdir", "-p", OUTPUT_DIR, GIF_DIR}) + + -- 5. Build job list + local has_adjustments = build_eq_filter() ~= nil + local jobs = {} + + local function add_job(base, suffix, ext, vf_string, encoder_fn) + local out_path = dedup_path(base, suffix, ext) + local args = build_ffmpeg_seek_args(a, b) + table.insert(args, "-vf"); table.insert(args, vf_string) + encoder_fn(args) + table.insert(args, out_path) + table.insert(jobs, {args = args, path = out_path, label = ("%s_%s.%s"):format(basename, suffix, ext)}) + end + + -- WebP encoder + local function webp_enc(args) + table.insert(args, "-c:v"); table.insert(args, "libwebp_anim") + table.insert(args, "-lossless"); table.insert(args, WEBP_LOSSLESS and "1" or "0") + table.insert(args, "-quality"); table.insert(args, string.format("%d", WEBP_QUALITY)) + table.insert(args, "-loop"); table.insert(args, string.format("%d", WEBP_LOOP)) + table.insert(args, "-an") + end + + -- GIF encoder + local function gif_enc(args) + table.insert(args, "-loop"); table.insert(args, string.format("%d", GIF_LOOP)) + end + + -- WebP: normal + add_job(webp_base, "normal", "webp", build_webp_vf(false), webp_enc) + -- GIF: normal + add_job(gif_base, "normal", "gif", build_gif_vf(false), gif_enc) + + if has_adjustments then + add_job(webp_base, "modified", "webp", build_webp_vf(true), webp_enc) + add_job(gif_base, "modified", "gif", build_gif_vf(true), gif_enc) + end + + -- 6. Launch all jobs in parallel + local total = #jobs + local completed = {} + local results = {} + + osd_msg(string.format("Exporting %d loops (WebP + GIF) ...", total), 2) + for _, j in ipairs(jobs) do + print(string.format("[export-loop-webp] %s", j.path)) + end + + local function on_all_done() + local ok_count = 0 + local fail_list = {} + local size_parts = {} + for i = 1, total do + if results[i].success then + ok_count = ok_count + 1 + table.insert(size_parts, results[i].label .. format_size(results[i].path)) + else + table.insert(fail_list, results[i].label) + end + end + + if ok_count == total then + osd_msg(string.format("All %d exported: %s", total, table.concat(size_parts, ", ")), 5) + elseif ok_count > 0 then + osd_msg(string.format("%d/%d exported. Failed: %s — check console.", + ok_count, total, table.concat(fail_list, ", ")), 5) + else + osd_msg("All exports failed. Check console.", 5) + end + + for i = 1, total do + if not results[i].success then + print(string.format("[export-loop-webp] ERROR [%s]: %s", + results[i].label, results[i].error or "unknown")) + end + end + end + + for i, j in ipairs(jobs) do + mp.command_native({ + name = "subprocess", args = j.args, + playback_only = false, capture_stdout = false, capture_stderr = false, + }, function(success, result, err) + completed[i] = true + results[i] = {success = success, path = j.path, label = j.label, error = err} + -- Check if all are done + for k = 1, total do + if not completed[k] then return end + end + on_all_done() + end) + end +end + +-- ╔══════════════════════════════════════════╗ +-- ║ KEYBINDING REGISTRATION ║ +-- ╚══════════════════════════════════════════╝ + +mp.add_key_binding(nil, "export", export_both) diff --git a/scripts/export-loop.lua b/scripts/export-loop.lua new file mode 100644 index 0000000..37557fc --- /dev/null +++ b/scripts/export-loop.lua @@ -0,0 +1,310 @@ +-- export-loop.lua +-- Export the current A-B loop to an MP4 via ffmpeg. +-- Frame-accurate (re-encodes). Exports both the raw loop and one with +-- mpv's gamma/brightness/contrast/saturation baked in. +-- +-- Suggested input.conf binding: +-- e script-binding export_loop/export +-- +-- If both ab-loop-a and ab-loop-b are set, spawns ffmpeg with libx264 +-- and saves to ~/Videos/mpv-loops/. + +local mp = require("mp") +local utils = require("mp.utils") + +-- ╔══════════════════════════════════════════╗ +-- ║ CONFIGURATION ║ +-- ╚══════════════════════════════════════════╝ + +local OUTPUT_DIR = os.getenv("HOME") .. "/Videos/mpv-loops" +local ENABLE_OSD_MSG = true +local VIDEO_CRF = 18 -- lower = better quality, 18 is visually lossless +local VIDEO_PRESET = "ultrafast" -- encoding speed/compression tradeoff +local VIDEO_MAX_WIDTH = 1920 -- scale to fit this width (auto height) +local VIDEO_CONTAINER = "mp4" -- output container format + +-- ╔══════════════════════════════════════════╗ +-- ║ FORMAT HELPERS ║ +-- ╚══════════════════════════════════════════╝ + +local function format_ts(seconds) + local h = math.floor(seconds / 3600) + local m = math.floor((seconds % 3600) / 60) + local s = seconds % 60 + return string.format("%02d:%02d:%06.3f", h, m, s) +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 osd_msg(text, duration) + if ENABLE_OSD_MSG then + mp.osd_message(text, duration or 3) + end +end + +-- Map mpv's video adjustment properties to ffmpeg eq filter string. +-- Returns nil if no adjustments are active. +local function build_eq_filter() + local brightness = mp.get_property_number("brightness", 0) + local contrast = mp.get_property_number("contrast", 0) + local saturation = mp.get_property_number("saturation", 0) + local gamma = mp.get_property_number("gamma", 0) + + local parts = {} + if brightness ~= 0 then table.insert(parts, string.format("brightness=%.2f", brightness / 100.0)) end + if contrast ~= 0 then table.insert(parts, string.format("contrast=%.2f", 1.0 + contrast / 100.0)) end + if saturation ~= 0 then table.insert(parts, string.format("saturation=%.2f", 1.0 + saturation / 100.0)) end + if gamma ~= 0 then table.insert(parts, string.format("gamma=%.2f", 1.0 + gamma / 100.0)) end + + if #parts == 0 then return nil end + return "eq=" .. table.concat(parts, ":") +end + +-- Build crop filter string for current zoom/pan. +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 + +-- Build the vf string. When include_eq is false, eq filters are omitted. +-- Zoom/pan crop is prepended before scale when active. +local function build_vf_string(include_eq) + local parts = {} + local crop = zoom_pan_crop() + if crop then table.insert(parts, crop) end + table.insert(parts, string.format("scale='min(%d,iw)':-1:flags=lanczos", VIDEO_MAX_WIDTH)) + if include_eq then + local eq_str = build_eq_filter() + if eq_str then + table.insert(parts, eq_str) + end + end + return table.concat(parts, ",") +end + +-- Resolve a non-overwriting path: append _N before extension if needed. +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 + +-- Build the ffmpeg arg prefix: input + seeking + vf. +-- Handles mf:// sources from webp-anim-bridge (animated WebP frame sequences). +local function build_ffmpeg_base_args(a, b, vf_string) + local args = {"ffmpeg", "-y"} + local file_path = mp.get_property("stream-path") or mp.get_property("path", "") + local mf_prefix = "mf:///tmp/mpv-webp-anim/" + + if file_path:sub(1, #mf_prefix) == mf_prefix or file_path:find("/tmp/mpv-webp-anim/") then + local pattern = file_path + if pattern:sub(1, 5) == "mf://" then + pattern = pattern:sub(6) + end + local mf_fps = mp.get_property_number("mf-fps", 10) + table.insert(args, "-framerate"); table.insert(args, tostring(mf_fps)) + table.insert(args, "-start_number"); table.insert(args, "0") + table.insert(args, "-i"); table.insert(args, pattern) + local start_frame = math.floor(a * mf_fps) + local end_frame = math.ceil(b * mf_fps) - 1 + local num_frames = math.max(1, end_frame - start_frame + 1) + table.insert(args, "-start_number"); table.insert(args, tostring(start_frame)) + table.insert(args, "-vframes"); table.insert(args, tostring(num_frames)) + else + table.insert(args, "-i"); table.insert(args, file_path) + table.insert(args, "-ss"); table.insert(args, format_ts(a)) + table.insert(args, "-to"); table.insert(args, format_ts(b)) + end + table.insert(args, "-vf"); table.insert(args, vf_string) + return args +end + +-- Format a file size for display. +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 + +-- ╔══════════════════════════════════════════╗ +-- ║ MAIN EXPORT FUNCTION ║ +-- ╚══════════════════════════════════════════╝ + +local function export_loop() + -- 1. Read loop points + local a = mp.get_property_number("ab-loop-a") + local b = mp.get_property_number("ab-loop-b") + + if not a or not b then + osd_msg("No A-B loop set. Press l twice to create one.", 3) + return + end + + if a > b then a, b = b, a end + + if a == b then + osd_msg("Loop has zero duration. Set different A and B points.", 3) + return + end + + -- 2. Check a file is loaded + local has_file = mp.get_property("path") + if not has_file then + osd_msg("No file loaded.", 3) + return + end + + -- 3. Build output filenames + local filename_stripped = mp.get_property("filename/no-ext") or "video" + local basename = filename_stripped:gsub("[^%w._-]", "_") + + local label_a = format_label(a) + local label_b = format_label(b) + local stem = string.format("%s_loop_%s-%s", basename, label_a, label_b) + local base_path = utils.join_path(OUTPUT_DIR, stem) + + local normal_path = dedup_path(base_path, "normal", VIDEO_CONTAINER) + local modified_path = dedup_path(base_path, "modified", VIDEO_CONTAINER) + + -- 4. Ensure output directory exists + mp.command_native({"run", "mkdir", "-p", OUTPUT_DIR}) + + -- 5. Detect whether any adjustments are active + local has_adjustments = build_eq_filter() ~= nil + local ext = VIDEO_CONTAINER + + -- 6. Build ffmpeg arg lists + local normal_vf = build_vf_string(false) + local normal_args = build_ffmpeg_base_args(a, b, normal_vf) + -- encoder + table.insert(normal_args, "-c:v"); table.insert(normal_args, "libx264") + table.insert(normal_args, "-preset"); table.insert(normal_args, VIDEO_PRESET) + table.insert(normal_args, "-crf"); table.insert(normal_args, string.format("%d", VIDEO_CRF)) + table.insert(normal_args, "-pix_fmt"); table.insert(normal_args, "yuv420p") + table.insert(normal_args, "-c:a"); table.insert(normal_args, "copy") + table.insert(normal_args, normal_path) + + local modified_args + if has_adjustments then + local modified_vf = build_vf_string(true) + modified_args = build_ffmpeg_base_args(a, b, modified_vf) + table.insert(modified_args, "-c:v"); table.insert(modified_args, "libx264") + table.insert(modified_args, "-preset"); table.insert(modified_args, VIDEO_PRESET) + table.insert(modified_args, "-crf"); table.insert(modified_args, string.format("%d", VIDEO_CRF)) + table.insert(modified_args, "-pix_fmt"); table.insert(modified_args, "yuv420p") + table.insert(modified_args, "-c:a"); table.insert(modified_args, "copy") + table.insert(modified_args, modified_path) + end + + -- 7. Launch exports in parallel + local pending = has_adjustments and 2 or 1 + local successes = {} + local errors = {} + local names = {normal_path} + + if pending == 1 then + -- Only normal (no adjustments — skip making an identical duplicate) + osd_msg(string.format("Exporting loop (normal) ..."), 2) + + mp.command_native({ + name = "subprocess", args = normal_args, + playback_only = false, capture_stdout = false, capture_stderr = false, + }, function(success, result, err) + if success then + osd_msg(string.format("Loop exported: %s%s", normal_path, format_size(normal_path)), 4) + print("[export-loop] Saved: " .. normal_path) + else + osd_msg("Export failed. Check console.", 4) + print("[export-loop] ERROR: " .. (err or "unknown")) + end + end) + return + end + + names = {normal_path, modified_path} + osd_msg("Exporting normal and modified ...", 2) + print(string.format("[export-loop] Normal: %s", normal_path)) + print(string.format("[export-loop] Modified: %s", modified_path)) + + local function on_done() + if successes[1] and successes[2] then + osd_msg(string.format("Both exported: %s%s, %s%s", + names[1], format_size(names[1]), + names[2], format_size(names[2])), 5) + elseif successes[1] then + osd_msg(string.format("Normal exported%s. Modified failed. Check console.", + format_size(names[1])), 5) + elseif successes[2] then + osd_msg(string.format("Modified exported%s. Normal failed. Check console.", + format_size(names[2])), 5) + else + osd_msg("Both exports failed. Check console.", 5) + end + + local failures_found = false + for i = 1, 2 do + if errors[i] then + print(string.format("[export-loop] ERROR (job %d): %s", i, errors[i])) + failures_found = true + end + end + if not failures_found then + print(string.format("[export-loop] Both saved: %s, %s", names[1], names[2])) + end + end + + local completed = {false, false} + for i = 1, 2 do + local idx = i + local args = (idx == 1) and normal_args or modified_args + mp.command_native({ + name = "subprocess", args = args, + playback_only = false, capture_stdout = false, capture_stderr = false, + }, function(success, result, err) + completed[idx] = true + successes[idx] = success + if not success then errors[idx] = err or "unknown" end + if completed[1] and completed[2] then + on_done() + end + end) + end +end + +-- ╔══════════════════════════════════════════╗ +-- ║ KEYBINDING REGISTRATION ║ +-- ╚══════════════════════════════════════════╝ + +mp.add_key_binding(nil, "export", export_loop) diff --git a/scripts/screenshot-duo.lua b/scripts/screenshot-duo.lua new file mode 100644 index 0000000..6e7d54b --- /dev/null +++ b/scripts/screenshot-duo.lua @@ -0,0 +1,176 @@ +-- 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) diff --git a/scripts/transcribe-subtitles.lua b/scripts/transcribe-subtitles.lua new file mode 100644 index 0000000..8ebd17d --- /dev/null +++ b/scripts/transcribe-subtitles.lua @@ -0,0 +1,75 @@ +-- transcribe-subtitles.lua +-- Press T to generate subtitles for the current video via faster-whisper. +-- Spawns transcription in background, polls for SRT file, loads it when done. +-- +-- input.conf: T script-binding transcribe_subtitles/transcribe + +local mp = require("mp") +local utils = require("mp.utils") + +local function load_srt(srt_path) + mp.commandv("sub-add", srt_path) + local count = mp.get_property_number("track-list/count", 0) + for i = 0, count - 1 do + if mp.get_property("track-list/" .. i .. "/type") == "sub" + and mp.get_property("track-list/" .. i .. "/external") == "yes" + and mp.get_property("track-list/" .. i .. "/external-filename") == srt_path then + mp.set_property_number("sub-track-id", i + 1) + break + end + end + mp.osd_message("Subtitles loaded", 2) +end + +local function poll_srt(srt_path, elapsed, max_wait) + if elapsed >= max_wait then + mp.osd_message("Transcription timed out", 3) + return + end + + local f = io.open(srt_path, "r") + if f then + f:close() + load_srt(srt_path) + return + end + + mp.add_timeout(2, function() + poll_srt(srt_path, elapsed + 2, max_wait) + end) +end + +local function transcribe() + local file_path = mp.get_property("path") + if not file_path then mp.osd_message("No file loaded.", 2); return end + + local abs_path = mp.get_property("stream-path") + or utils.join_path(mp.get_property("working-directory", "."), file_path) + + local base = abs_path:gsub("%.[^./]+$", "") + local srt_path = base .. ".srt" + + -- If SRT already exists, just load it + local existing = io.open(srt_path, "r") + if existing then + existing:close() + load_srt(srt_path) + return + end + + local transcriber = os.getenv("HOME") .. "/.local/bin/transcribe-video" + mp.osd_message("Transcribing subtitles ...", 1) + + local shell_cmd = "export LD_LIBRARY_PATH=/opt/cuda/targets/x86_64-linux/lib; " + .. transcriber .. " --srt \"" .. abs_path .. "\" \"" .. srt_path .. "\" &>/dev/null &" + + mp.command_native({ + name = "subprocess", + args = { "bash", "-c", shell_cmd }, + playback_only = false, + }) + + poll_srt(srt_path, 0, 900) +end + +mp.add_key_binding(nil, "transcribe", transcribe) diff --git a/scripts/webp-anim-bridge.lua b/scripts/webp-anim-bridge.lua new file mode 100644 index 0000000..3167834 --- /dev/null +++ b/scripts/webp-anim-bridge.lua @@ -0,0 +1,144 @@ +-- webp-anim-bridge.lua +-- Play animated WebP files in mpv via ImageMagick frame extraction. +-- +-- Detects animated WebP at load time. If animated, extracts frames to a temp +-- directory and plays them as an image sequence. Falls through to normal mpv +-- handling for still WebP. +-- +-- No keybinding needed — runs automatically on file load. + +local mp = require("mp") +local utils = require("mp.utils") + +-- ╔══════════════════════════════════════════╗ +-- ║ CONFIGURATION ║ +-- ╚══════════════════════════════════════════╝ + +local TMP_BASE = "/tmp/mpv-webp-anim" +local FRAME_DELAY_MS = 100 -- default frame delay in ms (overridden by EXIF/detect) + +-- ╔══════════════════════════════════════════╗ +-- ║ ANIMATED WEBP DETECTION ║ +-- ╚══════════════════════════════════════════╝ + +local function read_u32_le(s, offset) + return s:byte(offset) + + s:byte(offset + 1) * 256 + + s:byte(offset + 2) * 65536 + + s:byte(offset + 3) * 16777216 +end + +local function is_animated_webp(path) + local f = io.open(path, "rb") + if not f then return false end + local header = f:read(200) + f:close() + if not header or #header < 24 then return false end + local riff = header:sub(1, 4) + local webp_id = header:sub(9, 12) + if riff ~= "RIFF" or webp_id ~= "WEBP" then return false end + -- Walk chunks looking for VP8X with animation flag + -- First chunk starts at byte 13 (1-based), after 12-byte RIFF header + local pos = 13 + while pos <= #header - 8 do + local ck_id = header:sub(pos, pos + 3) + local ck_size = read_u32_le(header, pos + 4) + if ck_id == "VP8X" and #header >= pos + 9 then + local flags = header:byte(pos + 8) + -- bit 1 (0x02) = animation flag + return flags % 4 >= 2 + end + if ck_size == 0 then break end + pos = pos + 8 + ck_size + if pos % 2 ~= 0 then pos = pos + 1 end + end + return false +end + +-- ╔══════════════════════════════════════════╗ +-- ║ FRAME EXTRACTION ║ +-- ╚══════════════════════════════════════════╝ + +local function extract_frames(path) + -- Create a temp dir unique to this file + local stamp = tostring(math.floor(mp.get_time() * 1000)) + local tmpdir = TMP_BASE .. "/" .. stamp + os.execute("mkdir -p " .. tmpdir) + + -- Use ImageMagick to extract frames as PNGs + local args = { + "magick", path, + "-coalesce", + tmpdir .. "/frame_%04d.png" + } + local es = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = false, + capture_stderr = false, + }) + if not es or not es.status or es.status ~= 0 then + print("[webp-anim-bridge] magick extraction failed") + os.execute("rm -rf " .. tmpdir) + return nil + end + + -- Get frame count and sort numerically + local handle = io.popen("ls " .. tmpdir .. "/frame_*.png 2>/dev/null | sort") + if not handle then + os.execute("rm -rf " .. tmpdir) + return nil + end + local frames = {} + for fname in handle:lines() do + table.insert(frames, fname) + end + handle:close() + + if #frames == 0 then + os.execute("rm -rf " .. tmpdir) + return nil + end + + return frames, tmpdir +end + +-- ╔══════════════════════════════════════════╗ +-- ║ MAIN HOOK ║ +-- ╚══════════════════════════════════════════╝ + +-- on_load hook fires before the file is opened by the demuxer. +-- Aborts the original load and replaces it with mf:// image sequence. +local function hook_on_load() + local path = mp.get_property("path", "") + if not path:lower():match("%.webp$") then return end + + -- Resolve absolute path + local resolved = mp.get_property("stream-path") or + utils.join_path(mp.get_property("working-directory", "."), path) + + -- Skip if already extracted (prevents recursion when we load mf://) + if resolved:find("/tmp/mpv-webp-anim/") then return end + + -- Check if animated + if not is_animated_webp(resolved) then return end + + -- Extract frames + local frames, tmpdir = extract_frames(resolved) + if not frames or #frames == 0 then + mp.msg.warn("[webp-anim-bridge] Failed to extract frames from " .. resolved) + return + end + + -- Build mf:// URL (image sequence) + local fps = math.max(1, math.floor(1000 / FRAME_DELAY_MS)) + + -- Abort original load, replace with mf:// sequence + mp.commandv("loadfile", "mf://" .. tmpdir .. "/frame_%04d.png", "replace") + mp.set_property("mf-fps", tostring(fps)) + mp.set_property("loop-file", "inf") + mp.set_property("keep-open", "yes") +end + +mp.add_hook("on_load", 50, hook_on_load)