-- Usage: -- -- w - set start timestamp -- e - set end timestamp -- -- ctrl+z - cut audio fragment -- ctrl+x - cut video fragment (with softsub subtitles) -- ctrl+c - cut video fragment with hardsub subtitles -- -- ctrl+w - replay from the start timestamp -- ctrl+e - replay the last n seconds until the end timestamp -- ctrl+r - reset timestamps -- -- Note: -- If the end timestamp is set and ctrl+w or ctrl+e is pressed, a small circle will appear at the top-left part of the screen and -- video will be automatically paused at the end timestamp. -- Note: -- Export video with softsub or hardsub subtitles requires external *.srt subtitles. -- Note: -- The default video encoding preset is "ultrafast". It can be replaced with "medium" (~2 times more slower but ~2 times less filesize). -- Note: -- On Windows update mpv_path (and maybe ffmpeg_path) in the script options below if it isn't in the PATH environment variable, -- for example, by replacing [[mpv]] with [[C:\Programs\mpv\mpv.exe]]. -- Note: -- This script relies on mpv to encode audio or video (with softsub subtitles), but FFmpeg is required to be installed -- in order to encode video with hardsub subtitles. -- - Windows - https://ffmpeg.zeranoe.com -- - Linux - sudo apt-get install ffmpeg (or maybe something else) -- - macOS - install it via brew (https://brew.sh): brew install ffmpeg -- Status: -- Experimental. local utils = require "mp.utils" ------- Script Options ------- mpv_path = [[mpv]] ffmpeg_path = [[ffmpeg]] srt_file_extensions = {".srt", ".en.srt", ".eng.srt"} video_encoding_preset = [[ultrafast]] last_n_seconds_to_replay = 2.25 youtube_title = true ------------------------------ function srt_time_to_seconds(time) local major, minor = time:match("(%d%d:%d%d:%d%d),(%d%d%d)") local hours, mins, secs = major:match("(%d%d):(%d%d):(%d%d)") return hours * 3600 + mins * 60 + secs + minor / 1000 end function seconds_to_srt_time(time) local hours = math.floor(time / 3600) local mins = math.floor(time / 60) % 60 local secs = math.floor(time % 60) local milliseconds = (time * 1000) % 1000 return string.format("%02d:%02d:%02d,%03d", hours, mins, secs, milliseconds) end function seconds_to_time_string(duration, flag) local hours = math.floor(duration / 3600) local minutes = math.floor(duration / 60 % 60) local seconds = math.floor(duration % 60) local milliseconds = (duration * 1000) % 1000 if not flag then return string.format("%02d:%02d:%02d.%03d", hours, minutes, seconds, milliseconds) else return string.format("%02d.%02d.%02d", hours, minutes, seconds) end end function open_subtitles_file() local video_path = mp.get_property("path") local srt_filename = video_path:gsub('\\','/'):match("^(.+)/.+$") .. "/" .. mp.get_property("filename/no-ext") for i, ext in ipairs(srt_file_extensions) do local f, err = io.open(srt_filename .. ext, "r") if f then return f end end return false end function read_subtitles() local f = open_subtitles_file() if not f then return false end local data = f:read("*all") data = string.gsub(data, "\r\n", "\n") f:close() subs = {} subs_start = {} subs_end = {} for start_time, end_time, text in string.gmatch(data, "(%d%d:%d%d:%d%d,%d%d%d) %-%-> (%d%d:%d%d:%d%d,%d%d%d)\n(.-)\n\n") do table.insert(subs, text) table.insert(subs_start, srt_time_to_seconds(start_time)) table.insert(subs_end, srt_time_to_seconds(end_time)) end return true end function set_start_timestamp() start_timestamp = mp.get_property_number("time-pos") mp.osd_message("Start: " .. seconds_to_time_string(start_timestamp), 1) end function set_end_timestamp() end_timestamp = mp.get_property_number("time-pos") mp.osd_message("End: " .. seconds_to_time_string(end_timestamp), 1) end function reset_timestamps() start_timestamp = nil end_timestamp = nil player_state = nil if timer ~= nil then timer:kill() end if periodic_timer ~= nil then periodic_timer:kill() end local ass_start = mp.get_property_osd("osd-ass-cc/0") mp.osd_message(ass_start .. "{\\1c&HE6E6E6&}●", 0.5) end function replay_from_the_start_timestamp() if start_timestamp ~= nil then mp.commandv("seek", start_timestamp, "absolute+exact") mp.set_property("pause", "no") player_state = "replay the first seconds" end end function replay_the_last_n_seconds() if end_timestamp ~= nil then mp.commandv("seek", end_timestamp - last_n_seconds_to_replay, "absolute+exact") mp.set_property("pause", "no") player_state = "replay the last seconds" end end function stop_playback() player_state = nil mp.set_property("pause", "yes") if periodic_timer ~= nil then periodic_timer:kill() end end function playback_osd_message() mp.osd_message("●", 0.25) end function on_pause_change(name, value) if player_state == "replay" then if value == true then timer:stop() else timer:resume() end end end function on_seek() if timer ~= nil then timer:kill() end if periodic_timer ~= nil then periodic_timer:kill() end if player_state == "replay the last seconds" then periodic_timer = mp.add_periodic_timer(0.05, playback_osd_message) elseif player_state == "replay the first seconds" and end_timestamp ~= nil and end_timestamp > start_timestamp then periodic_timer = mp.add_periodic_timer(0.05, playback_osd_message) else player_state = nil end end function on_playback_restart() if player_state == "replay the first seconds" and end_timestamp == nil then player_state = nil elseif player_state == "replay the first seconds" and end_timestamp ~= nil and end_timestamp > start_timestamp then timer = mp.add_timeout(end_timestamp - start_timestamp, stop_playback) player_state = "replay" elseif player_state == "replay the last seconds" then timer = mp.add_timeout(last_n_seconds_to_replay, stop_playback) player_state = "replay" end end function format_filename(filename) local valid_characters = "-_.() abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" local t = {} for i = 1, #filename do local c = filename:sub(i,i) if string.find(valid_characters, c, 1, true) then t[#t+1] = c else t[#t+1] = "#" end end local str = table.concat(t, "") str = str:gsub("^%s*(.-)%s*$", "%1") str = str:gsub("^[# -]*(.-)[# ]*$", "%1") str = str:gsub("#", "_") str = str:gsub("__+", "_") return str end function write_subtitles_fragment(srt_filename, clip_start, clip_end) ret = read_subtitles() if ret == false or #subs == 0 then return end local i = 1 local f = assert(io.open(srt_filename, "w")) for idx, sub_content in ipairs(subs) do sub_start = subs_start[idx] sub_end = subs_end[idx] if sub_end > clip_start and sub_start < clip_end then f:write(i, "\n") f:write(seconds_to_srt_time(sub_start - clip_start) .. " --> " .. seconds_to_srt_time(sub_end - clip_start), "\n") f:write(sub_content, "\n\n") i = i + 1 end if sub_start > clip_end then break end end f:close() end function cut_audio_fragment() working_dir = mp.get_property("working-directory") video_path = mp.get_property("path") video_filename = mp.get_property("filename/no-ext") if start_timestamp ~= nil and end_timestamp ~= nil and start_timestamp < end_timestamp then mp.osd_message("Encoding Audio from " .. seconds_to_time_string(start_timestamp) .. " to " .. seconds_to_time_string(end_timestamp), 2) aid = mp.get_property("aid") d = 0.2 t = end_timestamp - start_timestamp if string.find(video_path, "youtube.com") then video_filename = string.gsub(video_filename, "watch%?v=", "") end if video_path:sub(1, 4) == "http" and youtube_title == true then media_title = mp.get_property("media-title") if string.find(video_path, "youtube.com") or string.find(video_path, "youtu.be") then video_filename = format_filename(media_title .. "-" .. video_filename) else video_filename = format_filename(media_title) end end filename = table.concat{ working_dir, "/", video_filename, ".", seconds_to_time_string(start_timestamp, true), "-", seconds_to_time_string(end_timestamp, true), ".m4a" } args = { mpv_path, video_path, "--start", start_timestamp, "--end", end_timestamp, "--aid", aid, "--video=no", -- "--af=afade=t=in:st=" .. start_timestamp .. ":d=" .. d .. ",afade=t=out:st=" .. (end_timestamp - d) .. ":d=" .. d, "--o=" .. filename } if video_path:sub(1, 4) == "http" and mp.get_property("ytdl-format") ~= "" then table.insert(args, #args, "--ytdl-format=" .. mp.get_property("ytdl-format")) end utils.subprocess_detached({ args = args, cancellable = false }) end end function cut_video_fragment() working_dir = mp.get_property("working-directory") video_path = mp.get_property("path") video_filename = mp.get_property("filename/no-ext") if start_timestamp ~= nil and end_timestamp ~= nil and start_timestamp < end_timestamp then mp.osd_message("Encoding Video from " .. seconds_to_time_string(start_timestamp) .. " to " .. seconds_to_time_string(end_timestamp), 2) aid = mp.get_property("aid") d = 0.2 t = end_timestamp - start_timestamp if string.find(video_path, "youtube.com") then video_filename = string.gsub(video_filename, "watch%?v=", "") end if video_path:sub(1, 4) == "http" and youtube_title == true then media_title = mp.get_property("media-title") if string.find(video_path, "youtube.com") or string.find(video_path, "youtu.be") then video_filename = format_filename(media_title .. "-" .. video_filename) else video_filename = format_filename(media_title) end end filename = table.concat{ working_dir, "/", video_filename, ".", seconds_to_time_string(start_timestamp, true), "-", seconds_to_time_string(end_timestamp, true) } if video_path:sub(1, 4) == "http" then args = { mpv_path, video_path, "--start", start_timestamp, "--end", end_timestamp, "--aid", aid, "--sub=no", -- "--af=afade=t=in:st=" .. start_timestamp .. ":d=" .. d .. ",afade=t=out:st=" .. (end_timestamp - d) .. ":d=" .. d, "--ovc=libx264", "--ovcopts-add=profile=high,level=41", "--ovcopts-add=preset=" .. video_encoding_preset, "--o=" .. filename .. ".mp4" } else local i = 0 local ff_aid = 0 local tracks_count = mp.get_property_number("track-list/count") while i < tracks_count do local track_type = mp.get_property(string.format("track-list/%d/type", i)) local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i)) local track_selected = mp.get_property(string.format("track-list/%d/selected", i)) local track_lang = mp.get_property(string.format("track-list/%d/lang", i)) local track_external = mp.get_property(string.format("track-list/%d/external", i)) i = i + 1 end args = { ffmpeg_path, "-y", "-ss", start_timestamp, "-i", video_path, "-t", t, "-map", "0:v:0", "-map", "0:a:" .. ff_aid, "-af", "afade=t=in:st=0:d=" .. d .. ",afade=t=out:st=" .. (t - d) .. ":d=" .. d, "-c:v", "libx264", "-preset", video_encoding_preset, "-c:a", "aac", "-ac", "2", filename .. ".mp4" } end if video_path:sub(1, 4) == "http" and mp.get_property("ytdl-format") ~= "" then table.insert(args, #args, "--ytdl-format=" .. mp.get_property("ytdl-format")) end utils.subprocess_detached({ args = args, cancellable = false }) write_subtitles_fragment(filename .. ".srt", start_timestamp, end_timestamp) end end function cut_video_fragment_with_subtitles() working_dir = mp.get_property("working-directory") video_path = mp.get_property("path") video_file = mp.get_property("filename") video_filename = mp.get_property("filename/no-ext") if start_timestamp ~= nil and end_timestamp ~= nil and start_timestamp < end_timestamp then mp.osd_message("Encoding Video with Subtitles from " .. seconds_to_time_string(start_timestamp) .. " to " .. seconds_to_time_string(end_timestamp), 2) local i = 0 local ff_aid = 0 local tracks_count = mp.get_property_number("track-list/count") while i < tracks_count do local track_type = mp.get_property(string.format("track-list/%d/type", i)) local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i)) local track_selected = mp.get_property(string.format("track-list/%d/selected", i)) local track_lang = mp.get_property(string.format("track-list/%d/lang", i)) local track_external = mp.get_property(string.format("track-list/%d/external", i)) i = i + 1 end d = 0.2 t = end_timestamp - start_timestamp subtitle_filename = video_path:gsub('\\','/'):match("^(.+)/.+$") .. "/" .. video_filename for i, ext in ipairs(srt_file_extensions) do f = io.open(subtitle_filename .. ext, "r") if f ~= nil then subtitle_filename = subtitle_filename .. ext io.close(f) break end end if f == nil then mp.osd_message("Encoding Failed", 2) return end if subtitle_filename:sub(2,2) == ":" then subtitle_filename = string.gsub(subtitle_filename, "\\", "\\\\\\\\") subtitle_filename = string.gsub(subtitle_filename, ":", "\\\\:") subtitle_filename = string.gsub(subtitle_filename, ",", "\\\\\\,") subtitle_filename = string.gsub(subtitle_filename, "'", "\\\\\\'") end filename = table.concat{ working_dir, "/", video_filename, ".", seconds_to_time_string(start_timestamp, true), "-", seconds_to_time_string(end_timestamp, true), ".sub.mp4" } vf = "subtitles=" .. subtitle_filename .. ":force_style=\'FontName=Arial,FontSize=22\',setpts=PTS-STARTPTS" af = "afade=t=in:st=" .. start_timestamp .. ":d=" .. d .. ",afade=t=out:st=" .. (end_timestamp - d) .. ":d=" .. d .. ",asetpts=PTS-STARTPTS" args = { ffmpeg_path, "-y", "-ss", start_timestamp, "-i", video_path, "-t", t, "-map", "0:v:0", "-map", "0:a:" .. ff_aid, "-c:v", "libx264", "-preset", video_encoding_preset, "-c:a", "aac", "-ac", "2", "-vf", vf, "-af", af, "-copyts", filename } utils.subprocess_detached({ args = args, cancellable = false }) end end mp.register_event("seek", on_seek) mp.register_event("playback-restart", on_playback_restart) mp.observe_property("pause", "bool", on_pause_change) ------------------------------ mp.add_key_binding("w", "set-start-timestamp", set_start_timestamp) mp.add_key_binding("e", "set-end-timestamp", set_end_timestamp) mp.add_key_binding("ctrl+w", "replay-from-the-start-timestamp", replay_from_the_start_timestamp) mp.add_key_binding("ctrl+e", "replay-the-last-n-seconds-until-the-end-timestamp", replay_the_last_n_seconds) mp.add_key_binding("ctrl+r", "reset-timestamps", reset_timestamps) mp.add_key_binding("ctrl+z", "cut-audio-fragment", cut_audio_fragment) mp.add_key_binding("ctrl+x", "cut-video-fragment", cut_video_fragment) mp.add_key_binding("ctrl+c", "cut-video-fragment-with-subtitles", cut_video_fragment_with_subtitles)