|
| 1 | +local msg = require "mp.msg" |
| 2 | +local utils = require "mp.utils" |
| 3 | +local options = require "mp.options" |
| 4 | + |
| 5 | +local cut_pos = nil |
| 6 | +local copy_audio = true |
| 7 | +local ext_map = { |
| 8 | + ["mpegts"] = "ts", |
| 9 | +} |
| 10 | +local o = { |
| 11 | + ffmpeg_path = "ffmpeg", |
| 12 | + target_dir = "~~/cutfragments", |
| 13 | + overwrite = false, -- whether to overwrite exist files |
| 14 | + vcodec = "copy", |
| 15 | + acodec = "copy", |
| 16 | + debug = false, |
| 17 | +} |
| 18 | + |
| 19 | +options.read_options(o) |
| 20 | + |
| 21 | +Command = {} |
| 22 | + |
| 23 | +local function is_protocol(path) |
| 24 | + return type(path) == 'string' and (path:find('^%a[%w.+-]-://') ~= nil or path:find('^%a[%w.+-]-:%?') ~= nil) |
| 25 | +end |
| 26 | + |
| 27 | +function Command:new(name) |
| 28 | + local o = {} |
| 29 | + setmetatable(o, self) |
| 30 | + self.__index = self |
| 31 | + o.name = "" |
| 32 | + o.args = { "" } |
| 33 | + if name then |
| 34 | + o.name = name |
| 35 | + o.args[1] = name |
| 36 | + end |
| 37 | + return o |
| 38 | +end |
| 39 | +function Command:arg(...) |
| 40 | + for _, v in ipairs({...}) do |
| 41 | + self.args[#self.args + 1] = v |
| 42 | + end |
| 43 | + return self |
| 44 | +end |
| 45 | +function Command:as_str() |
| 46 | + return table.concat(self.args, " ") |
| 47 | +end |
| 48 | +function Command:run() |
| 49 | + local res, err = mp.command_native({ |
| 50 | + name = "subprocess", |
| 51 | + args = self.args, |
| 52 | + capture_stdout = true, |
| 53 | + capture_stderr = true, |
| 54 | + }) |
| 55 | + return res, err |
| 56 | +end |
| 57 | + |
| 58 | +local function file_format() |
| 59 | + local fmt = mp.get_property("file-format") |
| 60 | + if not fmt:find(',') then |
| 61 | + return fmt |
| 62 | + end |
| 63 | + local path = mp.get_property('path') |
| 64 | + if is_protocol(path) then |
| 65 | + return nil |
| 66 | + end |
| 67 | + local filename = mp.get_property('filename') |
| 68 | + return filename:match('%.([^.]+)$') |
| 69 | +end |
| 70 | + |
| 71 | +local function get_ext() |
| 72 | + local fmt = file_format() |
| 73 | + if fmt and ext_map[fmt] ~= nil then |
| 74 | + return ext_map[fmt] |
| 75 | + else |
| 76 | + return fmt |
| 77 | + end |
| 78 | +end |
| 79 | + |
| 80 | +local function timestamp(duration) |
| 81 | + local hours = math.floor(duration / 3600) |
| 82 | + local minutes = math.floor(duration % 3600 / 60) |
| 83 | + local seconds = duration % 60 |
| 84 | + return string.format("%02d:%02d:%06.3f", hours, minutes, seconds) |
| 85 | +end |
| 86 | + |
| 87 | +local function osd(str) |
| 88 | + return mp.osd_message(str, 3) |
| 89 | +end |
| 90 | + |
| 91 | +local function info(s) |
| 92 | + msg.info(s) |
| 93 | + osd(s) |
| 94 | +end |
| 95 | + |
| 96 | +local function get_outname(path, shift, endpos) |
| 97 | + local name = mp.get_property("filename/no-ext") |
| 98 | + if is_protocol(path) then |
| 99 | + name = mp.get_property("media-title") |
| 100 | + end |
| 101 | + local ext = get_ext() or "mkv" |
| 102 | + name = string.format("%s_%s-%s.%s", name, timestamp(shift), timestamp(endpos), ext) |
| 103 | + return name:gsub(":", "-") |
| 104 | +end |
| 105 | + |
| 106 | +local function cut(shift, endpos) |
| 107 | + local duration = endpos - shift |
| 108 | + local path = mp.get_property("path") |
| 109 | + local inpath = mp.get_property("stream-open-filename") |
| 110 | + local outpath = utils.join_path( |
| 111 | + o.target_dir, |
| 112 | + get_outname(path, shift, endpos) |
| 113 | + ) |
| 114 | + |
| 115 | + local cache = mp.get_property_native("cache") |
| 116 | + local cache_state = mp.get_property_native("demuxer-cache-state") |
| 117 | + local cache_ranges = cache_state and cache_state["seekable-ranges"] or {} |
| 118 | + if path and is_protocol(path) or cache == "auto" and #cache_ranges > 0 then |
| 119 | + local pid = mp.get_property_native('pid') |
| 120 | + local temp_path = os.getenv("TEMP") or "/tmp/" |
| 121 | + local temp_video_file = utils.join_path(temp_path, "mpv_dump_" .. pid .. ".mkv") |
| 122 | + mp.commandv("dump-cache", shift, endpos, temp_video_file) |
| 123 | + shift = 0 |
| 124 | + inpath = temp_video_file |
| 125 | + end |
| 126 | + |
| 127 | + local cmds = Command:new(o.ffmpeg_path) |
| 128 | + :arg("-v", "warning") |
| 129 | + :arg(o.overwrite and "-y" or "-n") |
| 130 | + :arg("-stats") |
| 131 | + cmds:arg("-ss", tostring(shift)) |
| 132 | + cmds:arg("-accurate_seek") |
| 133 | + cmds:arg("-i", inpath) |
| 134 | + cmds:arg("-t", tostring(duration)) |
| 135 | + cmds:arg("-c:v", o.vcodec) |
| 136 | + cmds:arg("-c:a", o.acodec) |
| 137 | + cmds:arg("-c:s", "copy") |
| 138 | + cmds:arg("-map", string.format("v:%s?", math.max(mp.get_property_number("current-tracks/video/id", 0) - 1, 0))) |
| 139 | + cmds:arg("-map", string.format("a:%s?", math.max(mp.get_property_number("current-tracks/audio/id", 0) - 1, 0))) |
| 140 | + cmds:arg("-map", string.format("s:%s?", math.max(mp.get_property_number("current-tracks/sub/id", 0) - 1, 0))) |
| 141 | + cmds:arg(not copy_audio and "-an" or nil) |
| 142 | + cmds:arg("-avoid_negative_ts", "make_zero") |
| 143 | + cmds:arg("-async", "1") |
| 144 | + cmds:arg(outpath) |
| 145 | + msg.info("Run commands: " .. cmds:as_str()) |
| 146 | + local screenx, screeny, aspect = mp.get_osd_size() |
| 147 | + mp.set_osd_ass(screenx, screeny, "{\\an9}● ") |
| 148 | + local res, err = cmds:run() |
| 149 | + mp.set_osd_ass(screenx, screeny, "") |
| 150 | + if err then |
| 151 | + msg.error(utils.to_string(err)) |
| 152 | + mp.osd_message("Failed. Refer console for details.") |
| 153 | + elseif res.status ~= 0 then |
| 154 | + if res.stderr ~= "" or res.stdout ~= "" then |
| 155 | + msg.info("stderr: " .. (res.stderr:gsub("^%s*(.-)%s*$", "%1"))) -- trim stderr |
| 156 | + msg.info("stdout: " .. (res.stdout:gsub("^%s*(.-)%s*$", "%1"))) -- trim stdout |
| 157 | + mp.osd_message("Failed. Refer console for details.") |
| 158 | + end |
| 159 | + elseif res.status == 0 then |
| 160 | + if o.debug and (res.stderr ~= "" or res.stdout ~= "") then |
| 161 | + msg.info("stderr: " .. (res.stderr:gsub("^%s*(.-)%s*$", "%1"))) -- trim stderr |
| 162 | + msg.info("stdout: " .. (res.stdout:gsub("^%s*(.-)%s*$", "%1"))) -- trim stdout |
| 163 | + end |
| 164 | + msg.info("Trim file successfully created: " .. outpath) |
| 165 | + mp.add_timeout(1, function() |
| 166 | + mp.osd_message("Trim file successfully created!") |
| 167 | + end) |
| 168 | + end |
| 169 | +end |
| 170 | + |
| 171 | +local function toggle_mark() |
| 172 | + local pos, err = mp.get_property_number("time-pos") |
| 173 | + if not pos then |
| 174 | + osd("Failed to get timestamp") |
| 175 | + msg.error("Failed to get timestamp: " .. err) |
| 176 | + return |
| 177 | + end |
| 178 | + if cut_pos then |
| 179 | + local shift, endpos = cut_pos, pos |
| 180 | + if shift > endpos then |
| 181 | + shift, endpos = endpos, shift |
| 182 | + elseif shift == endpos then |
| 183 | + osd("Cut fragment is empty") |
| 184 | + return |
| 185 | + end |
| 186 | + cut_pos = nil |
| 187 | + info(string.format("Cut fragment: %s-%s", timestamp(shift), timestamp(endpos))) |
| 188 | + cut(shift, endpos) |
| 189 | + else |
| 190 | + cut_pos = pos |
| 191 | + info(string.format("Marked %s as start position", timestamp(pos))) |
| 192 | + end |
| 193 | +end |
| 194 | + |
| 195 | +local function toggle_audio() |
| 196 | + copy_audio = not copy_audio |
| 197 | + info("Audio capturing is " .. (copy_audio and "enabled" or "disabled")) |
| 198 | +end |
| 199 | + |
| 200 | +local function clear_toggle_mark() |
| 201 | + cut_pos = nil |
| 202 | + info("Cleared cut fragment") |
| 203 | +end |
| 204 | + |
| 205 | +o.target_dir = o.target_dir:gsub('"', "") |
| 206 | +local file, _ = utils.file_info(mp.command_native({ "expand-path", o.target_dir })) |
| 207 | +if not file then |
| 208 | + --create target_dir if it doesn't exist |
| 209 | + local savepath = mp.command_native({ "expand-path", o.target_dir }) |
| 210 | + local is_windows = package.config:sub(1, 1) == "\\" |
| 211 | + local windows_args = { 'powershell', '-NoProfile', '-Command', 'mkdir', string.format("\"%s\"", savepath) } |
| 212 | + local unix_args = { 'mkdir', '-p', savepath } |
| 213 | + local args = is_windows and windows_args or unix_args |
| 214 | + local res = mp.command_native({name = "subprocess", capture_stdout = true, playback_only = false, args = args}) |
| 215 | + if res.status ~= 0 then |
| 216 | + msg.error("Failed to create target_dir save directory "..savepath..". Error: "..(res.error or "unknown")) |
| 217 | + return |
| 218 | + end |
| 219 | +elseif not file.is_dir then |
| 220 | + osd("target_dir is a file") |
| 221 | + msg.warn(string.format("target_dir `%s` is a file", o.target_dir)) |
| 222 | +end |
| 223 | +o.target_dir = mp.command_native({ "expand-path", o.target_dir }) |
| 224 | + |
| 225 | +mp.add_key_binding("c", "slicing_mark", toggle_mark) |
| 226 | +mp.add_key_binding("a", "slicing_audio", toggle_audio) |
| 227 | +mp.add_key_binding("C", "clear_slicing_mark", clear_toggle_mark) |
0 commit comments