├── .gitignore ├── README.md └── main.lua /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | config.lua 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mpv-videocut 2 | 3 | This extension allows you to: 4 | 5 | - Quickly cut videos both losslessly and re-encoded-ly. 6 | 7 | - Specify custom actions in a `config.lua` file to support your own use cases 8 | without having to modify the script itself or write your own extension. 9 | 10 | - Bookmark timestamps to a `.book` file and load them as chapters. 11 | 12 | - Save cut information to a `.list` file for backup and make cuts later. 13 | 14 | - Choose meaningful channel names to organize the aforementioned actions. 15 | 16 | All directly in the fantastic media player [mpv](https://mpv.io/installation/). 17 | 18 | ## Requirements 19 | 20 | Besides mpv, you must have `ffmpeg` in your PATH. 21 | 22 | ## Installation 23 | 24 | #### Linux/MacOS 25 | 26 | ``` 27 | git clone -b release --single-branch "https://github.com/jankozik/mpvplugin-videocut.git" ~/.config/mpv/scripts/mpv-cut 28 | ``` 29 | 30 | #### Windows 31 | 32 | In 33 | `%AppData%\Roaming\mpv\scripts` or `Users\user\scoop\persist\mpv\scripts` run: 34 | 35 | ``` 36 | git clone -b release --single-branch "https://github.com/jankozik/mpvplugin-videocut.git" 37 | ``` 38 | 39 | That's all you have to do, next time you run mpv the script will be automatically loaded. 40 | 41 | ## Usage 42 | 43 | ### Cutting A Video Losslessly 44 | 45 | - Press `c` to begin a cut. 46 | 47 | - Seek to a later time in the video. 48 | 49 | - Press `c` again to make the cut. 50 | 51 | The resulting cut will be placed in the same directory as the source file. 52 | 53 | ### Actions 54 | 55 | You can press `a` to cycle between three default actions: 56 | 57 | - Copy (lossless cut, rounds to keyframes). 58 | 59 | - Encode (re-encoded cut, exact). 60 | 61 | - List (simply add the timestamps for the cut to a `.list` file). 62 | 63 | `mpv-cut` uses an extensible list of *actions* that you can modify in your 64 | `config.lua`. This makes it easy to change the `ffmpeg` command (or any command 65 | for that matter) to suit your specific situation. It should be possible to 66 | create custom actions with limited coding knowledge. More details in 67 | [config](#config). 68 | 69 | ### Bookmarking 70 | 71 | Press `i` to append the current timestamp to a `.book` file. This 72 | automatically reloads the timestamps as chapters in mpv. You can navigate 73 | between these chapters with the default mpv bindings, `!` and `@`. 74 | 75 | ### Channels 76 | 77 | The resulting cuts and bookmark files will be prefixed a channel number. This 78 | is to help you categorize cuts and bookmarks. You can press `-` to decrement 79 | the channel and `=` to increment the channel. 80 | 81 | You can configure a name for each channel as shown below. 82 | 83 | ### Making Cuts 84 | 85 | If you want to make all the cuts stored in a cut list, simply press `0`. 86 | 87 | ## Config 88 | 89 | You can configure settings by creating a `config.lua` file in the same 90 | directory as `main.lua`. 91 | 92 | You can include or omit any of the following: 93 | 94 | ```lua 95 | -- Key config 96 | KEY_CUT = "c" 97 | KEY_CYCLE_ACTION = "a" 98 | KEY_BOOKMARK_ADD = "i" 99 | KEY_CHANNEL_INC = "=" 100 | KEY_CHANNEL_DEC = "-" 101 | KEY_MAKE_CUTS = "0" 102 | 103 | -- The list of channel names, you can choose whatever you want. 104 | CHANNEL_NAMES[1] = "FUNNY" 105 | CHANNEL_NAMES[2] = "COOL" 106 | 107 | -- The default channel 108 | CHANNEL = 1 109 | 110 | -- The default action 111 | ACTION = ACTIONS.ENCODE 112 | 113 | -- The action to use when making cuts from a cut list 114 | MAKE_CUT = ACTIONS.COPY 115 | 116 | -- Delete a default action 117 | ACTIONS.LIST = nil 118 | 119 | -- Specify custom actions 120 | ACTIONS.ENCODE = function(d) 121 | local args = { 122 | "ffmpeg", 123 | "-nostdin", "-y", 124 | "-loglevel", "error", 125 | "-i", d.inpath, 126 | "-ss", d.start_time, 127 | "-t", d.duration, 128 | "-pix_fmt", "yuv420p", 129 | "-crf", "16", 130 | "-preset", "superfast", 131 | utils.join_path(d.indir, "ENCODE_" .. d.channel .. "_" .. d.infile_noext .. "_FROM_" .. d.start_time_hms .. "_TO_" .. d.end_time_hms .. d.ext) 132 | } 133 | mp.command_native_async({ 134 | name = "subprocess", 135 | args = args, 136 | playback_only = false, 137 | }, function() print("Done") end) 138 | end 139 | 140 | -- The table that gets passed to an action will have the following properties: 141 | -- inpath, indir, infile, infile_noext, ext 142 | -- channel 143 | -- start_time, end_time, duration 144 | -- start_time_hms, end_time_hms, duration_hms 145 | ``` 146 | 147 | ## Optimized MPV Input Config 148 | 149 | This is my `input.conf` file, and it is optimized for both normal playback and 150 | quickly editing videos. 151 | 152 | ``` 153 | RIGHT seek 2 exact 154 | LEFT seek -2 exact 155 | 156 | ] add speed 0.5 157 | [ add speed -0.5 158 | } add speed 0.25 159 | { add speed -0.25 160 | 161 | SPACE cycle pause; set speed 1 # Reset speed whenever pausing. 162 | BS script-binding osc/visibility # Make progress bar stay visible. 163 | UP seek 0.01 keyframes # Seek by keyframes only. 164 | DOWN seek -0.01 keyframes # Seek by keyframes only. 165 | ``` 166 | 167 | You may also want to change your key repeat delay and rate by tweaking 168 | `input-ar-delay` and `input-ar-rate` to your liking in `mpv.conf`. 169 | 170 | ## FAQ 171 | 172 | ### What Is The Point Of A Cut List? 173 | 174 | There are plenty of reasons, but to give some examples: 175 | 176 | - Video seems to be pretty complex, at least to me. One video file may cause 177 | certain issues, and another may not, which makes writing an ffmpeg command 178 | that accounts for all scenarios difficult. If you spend a ton of time making 179 | many cuts in a long movie only to find that the colors look off because of 180 | some 10-bit h265 dolby mega surround whatever the fuck, with a cut list it's 181 | trivial to edit the ffmpeg command and re-make the cuts. 182 | 183 | - Maybe you forget that the foreign language video you're cutting has softsubs 184 | rather than hardsubs, and you make a bunch of encode cuts resulting in cuts 185 | that have no subtitles. 186 | 187 | - You delete the source video for storage reasons, but still want to have a 188 | back up of the cut timestamps in the event you need to remake the cuts. 189 | 190 | ### Why Would I Bookmark Instead Of Cutting? 191 | 192 | Suppose you're watching a movie or show for your own enjoyment, but you also 193 | want to compile funny moments to post online or send to your friends. It would 194 | ruin your viewing experience to wait for a funny moment to be over in order to 195 | make a cut. Instead, you can quickly make a bookmark whenever you laugh, and 196 | once you're done watching you can go back and make actual cuts. 197 | 198 | ### Why Is Lossless Cutting Called "Copy"? 199 | 200 | This refers to ffmpeg's `-copy` flag which copies the input stream instead of 201 | re-encoding it, meaning that the cut will process extremely quickly and the 202 | resulting video will retain 100% of the original quality. The main drawback is 203 | that the cut may have some extra video at the beginning and end, and as a 204 | result of that there may be some slightly wonky behavior with video players and 205 | editors. 206 | 207 | ### Why Would I Re-Encode A Video? 208 | 209 | - As mentioned above, copying the input stream is very fast and lossless but 210 | the cuts are not exact. Sometimes you want a cut to be exact. 211 | 212 | - If you want to change the framerate. 213 | 214 | - If you want to encode hardsubs. 215 | 216 | - If the video's compression isn't efficient enough to upload to a messaging 217 | platform or something, you may want to compress it more. 218 | 219 | ### How Can I Merge (Concatenate) The Resulting Cuts Into One File? 220 | 221 | To concatenate videos with ffmpeg, you need to create a file with content like 222 | this: 223 | 224 | ``` 225 | file cut_1.mp4 226 | file cut_2.mp4 227 | file cut_3.mp4 228 | file cut_4.mp4 229 | ``` 230 | 231 | You can name the file whatever you want, here I named it `concat.txt`. 232 | 233 | Then run the command: 234 | 235 | ``` 236 | ffmpeg -f concat -safe 0 -i concat.txt -c copy out.mp4 237 | ``` 238 | 239 | That's annoying though, so you can skip manually creating the file by using 240 | bash. This command will concatenate all files in the current directory that 241 | begin with "COPY_": 242 | 243 | ``` 244 | ffmpeg -f concat -safe 0 -i <(printf 'file %q\n' "$PWD"/COPY_*) -c copy lol.mp4 245 | ``` 246 | 247 | - You need to escape apostrophes which is why we are using `printf %q 248 | "$string"`. 249 | 250 | - Instead of actually creating a file we just use process substitution 251 | `<(whatever)` to create a temporary file, which is why we need the `$PWD` in 252 | there for the absolute path. 253 | 254 | You can also do it in vim, among other things. 255 | 256 | ``` 257 | ls | vim - 258 | :%s/'/\\'/g 259 | :%norm Ifile 260 | :wq concat.txt 261 | ``` 262 | 263 | This substitution might not cover all cases, but whatever, if you're 264 | concatenating a file named `[{}1;']["!.mp4` you can figure it out yourself. 265 | 266 | ### Can I Make Seeking And Reverse Playback Faster? 267 | 268 | Depending on the encoding of the video file being played, the following may be 269 | quite slow: 270 | 271 | - The use of `exact` in `input.conf`. 272 | 273 | - The use of the `.` and `,` keys to go frame by frame. 274 | 275 | - The holding down of the `,` key to play the video in reverse. 276 | 277 | Long story short, if the video uses an encoding that is difficult for mpv to 278 | decode, exact seeking and backwards playback won't be smooth, which for normal 279 | playback is not a problem at all, since by default mpv very quickly seeks 280 | keyframe-wise when you press `left arrow` or `right arrow`. 281 | 282 | However if we are very intensively cutting a video, it may be useful to be able 283 | to quickly seek to an exact time, and to quickly play in reverse. In this case, 284 | it is useful to first make a proxy of the original video which is very easy to 285 | decode, generate a cut list with the proxy, and then apply the cut list to the 286 | original video. 287 | 288 | To create a proxy which will be very easy to decode, you can use this ffmpeg command: 289 | 290 | ``` 291 | ffmpeg -noautorotate -i input.mp4 -pix_fmt yuv420p -g 1 -sn -an -vf colormatrix=bt601:bt709,scale=w=1280:h=1280:force_original_aspect_ratio=decrease:force_divisible_by=2 -c:v libx264 -crf 16 -preset superfast -tune fastdecode proxy.mp4 292 | ``` 293 | 294 | The important options here are the `-g 1` and the scale filter. The other 295 | options are more or less irrelevant. The resulting video file should seek 296 | extremely quickly and play backwards just fine. 297 | 298 | Once you are done generating the cut list, simply open the `cut_list.txt` file, 299 | substitute the proxy file name for the original file name, and run `make_cuts` 300 | on it. 301 | -------------------------------------------------------------------------------- /main.lua: -------------------------------------------------------------------------------- 1 | utils = require "mp.utils" 2 | 3 | local function print(s) 4 | mp.msg.info(s) 5 | mp.osd_message(s) 6 | end 7 | 8 | local function table_to_str(o) 9 | if type(o) == 'table' then 10 | local s = '' 11 | for k,v in pairs(o) do 12 | if type(k) ~= 'number' then k = '"'..k..'"' end 13 | s = s .. '['..k..'] = ' .. table_to_str(v) .. '\n' 14 | end 15 | return s 16 | else 17 | return tostring(o) 18 | end 19 | end 20 | 21 | local function to_hms(seconds) 22 | local ms = math.floor((seconds - math.floor(seconds)) * 1000) 23 | local secs = math.floor(seconds) 24 | local mins = math.floor(secs / 60) 25 | secs = secs % 60 26 | local hours = math.floor(mins / 60) 27 | mins = mins % 60 28 | return string.format("%02d-%02d-%02d-%03d", hours, mins, secs, ms) 29 | end 30 | 31 | local function next_table_key(t, current) 32 | local keys = {} 33 | for k in pairs(t) do 34 | keys[#keys + 1] = k 35 | end 36 | table.sort(keys) 37 | for i = 1, #keys do 38 | if keys[i] == current then 39 | return keys[(i % #keys) + 1] 40 | end 41 | end 42 | return keys[1] 43 | end 44 | 45 | ACTIONS = {} 46 | 47 | ACTIONS.COPY = function(d) 48 | local args = { 49 | "ffmpeg", 50 | "-nostdin", "-y", 51 | "-loglevel", "error", 52 | "-ss", d.start_time, 53 | "-t", d.duration, 54 | "-i", d.inpath, 55 | "-pix_fmt", "yuv420p", 56 | "-c", "copy", 57 | "-map", "0", 58 | "-avoid_negative_ts", "make_zero", 59 | utils.join_path(d.indir, "COPY_" .. d.channel .. "_" .. d.infile_noext .. "_FROM_" .. d.start_time_hms .. "_TO_" .. d.end_time_hms .. d.ext) 60 | } 61 | mp.command_native_async({ 62 | name = "subprocess", 63 | args = args, 64 | playback_only = false, 65 | }, function() print("Done") end) 66 | end 67 | 68 | ACTIONS.ENCODE = function(d) 69 | local args = { 70 | "ffmpeg", 71 | "-nostdin", "-y", 72 | "-loglevel", "error", 73 | "-i", d.inpath, 74 | "-ss", d.start_time, 75 | "-t", d.duration, 76 | "-pix_fmt", "yuv420p", 77 | "-crf", "16", 78 | "-preset", "superfast", 79 | utils.join_path(d.indir, "ENCODE_" .. d.channel .. "_" .. d.infile_noext .. "_FROM_" .. d.start_time_hms .. "_TO_" .. d.end_time_hms .. d.ext) 80 | } 81 | mp.command_native_async({ 82 | name = "subprocess", 83 | args = args, 84 | playback_only = false, 85 | }, function() print("Done") end) 86 | end 87 | 88 | ACTIONS.LIST = function(d) 89 | local inpath = mp.get_property("path") 90 | local outpath = inpath .. ".list" 91 | local file = io.open(outpath, "a") 92 | if not file then print("Error writing to cut list") return end 93 | local filesize = file:seek("end") 94 | local s = "\n" .. d.channel 95 | .. ":" .. d.start_time 96 | .. ":" .. d.end_time 97 | file:write(s) 98 | local delta = file:seek("end") - filesize 99 | io.close(file) 100 | print("Δ " .. delta) 101 | end 102 | 103 | ACTION = "COPY" 104 | 105 | MAKE_CUT = ACTIONS.COPY 106 | 107 | CHANNEL = 1 108 | 109 | CHANNEL_NAMES = {} 110 | 111 | KEY_CUT = "c" 112 | KEY_CYCLE_ACTION = "a" 113 | KEY_BOOKMARK_ADD = "i" 114 | KEY_CHANNEL_INC = "=" 115 | KEY_CHANNEL_DEC = "-" 116 | KEY_MAKE_CUTS = "0" 117 | 118 | pcall(require, "config") 119 | 120 | mp.msg.info("MPV-CUT LOADED.") 121 | 122 | for i, v in ipairs(CHANNEL_NAMES) do 123 | CHANNEL_NAMES[i] = string.gsub(v, ":", "-") 124 | end 125 | 126 | if not ACTIONS[ACTION] then ACTION = next_table_key(ACTIONS, nil) end 127 | 128 | START_TIME = nil 129 | 130 | local function get_current_channel_name() 131 | return CHANNEL_NAMES[CHANNEL] or tostring(CHANNEL) 132 | end 133 | 134 | local function get_data() 135 | local d = {} 136 | d.inpath = mp.get_property("path") 137 | d.indir = utils.split_path(d.inpath) 138 | d.infile = mp.get_property("filename") 139 | d.infile_noext = mp.get_property("filename/no-ext") 140 | d.ext = mp.get_property("filename"):match("^.+(%..+)$") or ".mp4" 141 | d.channel = get_current_channel_name() 142 | return d 143 | end 144 | 145 | local function get_times(start_time, end_time) 146 | local d = {} 147 | d.start_time = tostring(start_time) 148 | d.end_time = tostring(end_time) 149 | d.duration = tostring(end_time - start_time) 150 | d.start_time_hms = tostring(to_hms(start_time)) 151 | d.end_time_hms = tostring(to_hms(end_time)) 152 | d.duration_hms = tostring(to_hms(end_time - start_time)) 153 | return d 154 | end 155 | 156 | text_overlay = mp.create_osd_overlay("ass-events") 157 | text_overlay.hidden = true 158 | text_overlay:update() 159 | 160 | local function text_overlay_off() 161 | -- https://github.com/mpv-player/mpv/issues/10227 162 | text_overlay:update() 163 | text_overlay.hidden = true 164 | text_overlay:update() 165 | end 166 | 167 | local function text_overlay_on() 168 | local channel = get_current_channel_name() 169 | text_overlay.data = string.format("%s in %s from %s", ACTION, channel, START_TIME) 170 | text_overlay.hidden = false 171 | text_overlay:update() 172 | end 173 | 174 | local function print_or_update_text_overlay(content) 175 | if START_TIME then text_overlay_on() else print(content) end 176 | end 177 | 178 | local function cycle_action() 179 | ACTION = next_table_key(ACTIONS, ACTION) 180 | print_or_update_text_overlay("ACTION: " .. ACTION) 181 | end 182 | 183 | local function make_cuts() 184 | print("MAKING CUTS") 185 | if not MAKE_CUT then print("MAKE_CUT function not found.") return end 186 | local inpath = mp.get_property("path") .. ".list" 187 | local file = io.open(inpath, "r") 188 | if not file then print("Error reading cut list") return end 189 | for line in file:lines() do 190 | if line ~= "" then 191 | local cut = {} 192 | for token in string.gmatch(line, "[^" .. ":" .. "]+") do 193 | table.insert(cut, token) 194 | end 195 | local d = get_data() 196 | d.channel = cut[1] 197 | local t = get_times(tonumber(cut[2]), tonumber(cut[3])) 198 | for k, v in pairs(t) do d[k] = v end 199 | mp.msg.info("MAKE_CUT") 200 | mp.msg.info(table_to_str(d)) 201 | MAKE_CUT(d) 202 | end 203 | end 204 | io.close(file) 205 | end 206 | 207 | local function cut(start_time, end_time) 208 | local d = get_data() 209 | local t = get_times(start_time, end_time) 210 | for k, v in pairs(t) do d[k] = v end 211 | mp.msg.info(ACTION) 212 | mp.msg.info(table_to_str(d)) 213 | ACTIONS[ACTION](d) 214 | end 215 | 216 | local function put_time() 217 | local time = mp.get_property_number("time-pos") 218 | if not START_TIME then 219 | START_TIME = time 220 | text_overlay_on() 221 | return 222 | end 223 | text_overlay_off() 224 | if time > START_TIME then 225 | cut(START_TIME, time) 226 | START_TIME = nil 227 | else 228 | print("INVALID") 229 | START_TIME = nil 230 | end 231 | end 232 | 233 | local function get_bookmark_file_path() 234 | local d = get_data() 235 | mp.msg.info(table_to_str(d)) 236 | local outfile = string.format("%s_%s.book", d.channel, d.infile) 237 | return utils.join_path(d.indir, outfile) 238 | end 239 | 240 | local function bookmarks_load() 241 | local inpath = get_bookmark_file_path() 242 | local file = io.open(inpath, "r") 243 | if not file then return end 244 | local arr = {} 245 | for line in file:lines() do 246 | if tonumber(line) then 247 | table.insert(arr, { 248 | time = tonumber(line), 249 | title = "chapter_" .. line 250 | }) 251 | end 252 | end 253 | file:close() 254 | table.sort(arr, function(a, b) return a.time < b.time end) 255 | mp.set_property_native("chapter-list", arr) 256 | end 257 | 258 | local function bookmark_add() 259 | local d = get_data() 260 | local outpath = get_bookmark_file_path() 261 | local file = io.open(outpath, "a") 262 | if not file then print("Failed to open bookmark file for writing") return end 263 | local out_string = mp.get_property_number("time-pos") .. "\n" 264 | local filesize = file:seek("end") 265 | file:write(out_string) 266 | local delta = file:seek("end") - filesize 267 | io.close(file) 268 | bookmarks_load() 269 | print(string.format("Δ %s, %s", delta, d.channel)) 270 | end 271 | 272 | local function channel_inc() 273 | CHANNEL = CHANNEL + 1 274 | bookmarks_load() 275 | print_or_update_text_overlay(get_current_channel_name()) 276 | end 277 | 278 | local function channel_dec() 279 | if CHANNEL >= 2 then CHANNEL = CHANNEL - 1 end 280 | bookmarks_load() 281 | print_or_update_text_overlay(get_current_channel_name()) 282 | end 283 | 284 | mp.add_key_binding(KEY_CUT, "cut", put_time) 285 | mp.add_key_binding(KEY_BOOKMARK_ADD, "bookmark_add", bookmark_add) 286 | mp.add_key_binding(KEY_CHANNEL_INC, "channel_inc", channel_inc) 287 | mp.add_key_binding(KEY_CHANNEL_DEC, "channel_dec", channel_dec) 288 | mp.add_key_binding(KEY_CYCLE_ACTION, "cycle_action", cycle_action) 289 | mp.add_key_binding(KEY_MAKE_CUTS, "make_cuts", make_cuts) 290 | 291 | mp.register_event('file-loaded', bookmarks_load) 292 | --------------------------------------------------------------------------------