-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathmpvcut.lua
More file actions
602 lines (526 loc) · 19.9 KB
/
mpvcut.lua
File metadata and controls
602 lines (526 loc) · 19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
--[[
mpvcut.lua by zydezu
(https://github.com/zydezu/mpvconfig/blob/main/scripts/mpvcut.lua)
* Based on https://github.com/familyfriendlymikey/mpv-cut/blob/main/main.lua
Clip, compress and re-encode selected clips
--]]
mp.msg = require("mp.msg")
mp.utils = require("mp.utils")
local options = {
-- Save location
save_to_directory = true, -- save to 'save_directory' instead of the current folder of the file
save_directory = "~/Pictures/mpv/clips", -- required for web videos
-- Key config
key_cut = "a",
key_cancel_cut = "shift+a",
key_cycle_action = "A",
key_cycle_codec = "alt+a",
codecs_list = { "h264", "h265", "av1" },
-- The default action
action = "ENCODE", -- the default action, ENCODE, ENCODE_GIF, COMPRESS or CUT
-- File size targets
compress_size = 9.50, -- the target size for the COMPRESS action (in MB)
-- encoding options
encoding_type = "h265", -- h264, h265, or av1
animated_encoding_type = "avif", -- for encoding animated gifs, webps or avifs - gif, webp or avif
cap_resolution = true, -- whether to lower the resolution to the target resolution (COMPRESS/ENCODE_GIF only)
max_resolution = 1080, -- resolution to shrink to if video is above this resolution (COMPRESS only)
max_animated_resolution = 540, -- resolution to shrink to if gif/avif is above this resolution (ENCODE_GIF only)
h264_crf = 23, -- the crf value to use for h264 clips, lower numbers mean higher quality
h265_crf = 28, -- the crf value to use for h265 clips, lower numbers mean higher quality
av1_crf = 40, -- the crf value to use for av1 clips, lower numbers mean higher quality
webp_quality = 75, -- quality for animated .webps, 0-100 low-high
webp_compression_level = 6, -- compression effort, a trade-off between speed and size, lower numbers provide a higher speed
avif_crf = 42, -- the crf value to use for animated .avif clips, lower numbers mean higher quality
av1_preset = 6, -- av1 encoding preset, a trade-off between speed and size, higher numbers provide a higher speed
-- Web videos/cache
use_cache_for_web_videos = true, -- whether to cut web videos using the player's cache (experimental)
}
require("mp.options").read_options(options)
local function print(s)
mp.msg.info(s)
mp.osd_message(s)
end
local function is_url(s)
local url_pattern = "^[%w]+://[%w%.%-_]+%.[%a]+[-%w%.%-%_/?&=]*"
return string.match(s, url_pattern) ~= nil
end
local result = mp.command_native({ name = "subprocess", args = { "ffmpeg" }, playback_only = false, capture_stdout = true, capture_stderr = true })
if result.status ~= 1 then
mp.osd_message("FFmpeg failed to run")
end
local full_path = mp.command_native({ "expand-path", options.save_directory })
local full_path_save = ""
local web_ext = ".mkv"
local average_bitrate = -1
local avg_count = 1
local function get_bitrate()
local video_bitrate = mp.get_property_number("video-bitrate")
if video_bitrate then
video_bitrate = video_bitrate / 1000
avg_count = avg_count + 1
if average_bitrate == -1 then
average_bitrate = video_bitrate
else
average_bitrate = ((avg_count - 1) * average_bitrate + video_bitrate) / avg_count
end
end
end
local function init()
-- Set save directory path
if full_path then
full_path_save = mp.command_native({ "expand-path", options.save_directory ..
"/" .. mp.get_property("media-title") })
if (options.use_cache_for_web_videos and is_url(mp.get_property("path"))) then
local video = mp.get_property("video-format", "none")
local audio = mp.get_property("audio-codec-name", "none")
local webm_codecs = { vp8 = true, vp9 = true }
local webm_audio = { opus = true, vorbis = true }
local mp4_video = { h264 = true, hevc = true, av1 = true }
local mp4_audio = { opus = true, mp3 = true, flac = true, aac = true }
local function contains(tbl, val)
return tbl[val] or false
end
if contains(webm_codecs, video) and contains(webm_audio, audio) then
web_ext = ".webm"
elseif contains(mp4_video, video) and contains(mp4_audio, audio) then
web_ext = ".mp4"
else
web_ext = ".mkv"
end
local youtube_ID = ""
local _, _, videoID = string.find(mp.get_property("filename"), "([%w_-]+)%?si=")
local videoIDMatch = mp.get_property("filename"):match("[?&]v=([^&]+)")
if (videoIDMatch) then
youtube_ID = " [" .. videoIDMatch .. "]"
elseif (videoID) then
youtube_ID = " [" .. videoID .. "]"
end
full_path_save = mp.command_native({ "expand-path", options.save_directory .. "/" ..
(string.gsub(mp.get_property("media-title"):sub(1, 100), "^%s*(.-)%s*$:", "%1") .. youtube_ID):gsub(
'[\\/:*?"<>|]', "") })
end
end
-- Reset average bitrate
average_bitrate = -1
avg_count = 1
end
mp.register_event("file-loaded", init)
mp.add_periodic_timer(2, get_bitrate)
local function to_hms(seconds)
local ms = math.floor((seconds - math.floor(seconds)) * 1000)
local secs = math.floor(seconds)
local mins = math.floor(secs / 60)
secs = secs % 60
local hours = math.floor(mins / 60)
mins = mins % 60
return string.format("%02d-%02d-%02d-%03d", hours, mins, secs, ms)
end
local function next_table_key(t, current)
local keys = {}
for k in pairs(t) do
keys[#keys + 1] = k
end
table.sort(keys)
for i = 1, #keys do
if keys[i] == current then
return keys[(i % #keys) + 1]
end
end
return keys[1]
end
local function create_folder(path)
local args
if package.config:sub(1, 1) == '\\' then
-- Windows: normalize slashes and use 'mkdir' with '/S' for nested folders
local win_path = path:gsub("/", "\\")
args = { "cmd", "/c", "mkdir", win_path }
else
-- Unix/macOS/Linux
args = { "mkdir", "-p", path }
end
local res = mp.utils.subprocess({ args = args })
if res.status == 0 then
mp.msg.info("Successfully created folder: " .. path)
else
mp.msg.error("Failed to create folder: " .. path)
end
end
local function check_paths(d, suffix, web_path_save, new_ext)
local result_path = mp.utils.join_path(full_path .. "/", d.infile_noext .. suffix .. (new_ext or ".mp4"))
if (mp.utils.readdir(full_path) == nil) then
create_folder(full_path)
end
if web_path_save then return web_path_save .. " " .. suffix .. web_ext end
return result_path
end
ACTIONS = {}
ACTIONS.ENCODE = function(d)
local file_extra_suffix = "_FROM_" ..
d.start_time_hms .. "_TO_" .. d.end_time_hms .. " (" .. options.encoding_type .. "encode)"
local result_path = mp.utils.join_path(d.indir, d.infile_noext .. file_extra_suffix .. ".mp4")
if (options.save_to_directory) then result_path = check_paths(d, file_extra_suffix) end
local selected_audio_id = mp.get_property_number("aid")
local ff_audio_index = nil
local count = 0
for _, track in ipairs(mp.get_property_native("track-list") or {}) do
if track.type == "audio" then
if track.id == selected_audio_id then
ff_audio_index = count
break
end
count = count + 1
end
end
if ff_audio_index == nil then
ff_audio_index = 0
end
-- Start with common args
local args = {
"ffmpeg", "-nostdin", "-y", "-loglevel", "error",
"-ss", d.start_time,
"-t", d.duration,
"-i", d.inpath,
"-map", "0:v:0",
"-map_chapters", "-1",
"-map", "0:a:" .. ff_audio_index .. "?",
}
if options.encoding_type == "av1" then
-- AV1 using libsvtav1
table.insert(args, "-c:v")
table.insert(args, "libsvtav1")
table.insert(args, "-crf")
table.insert(args, tostring(options.av1_crf or 40))
table.insert(args, "-preset")
table.insert(args, tostring(options.av1_preset or 6))
table.insert(args, "-c:a")
table.insert(args, "copy")
elseif options.encoding_type == "h265" then
-- H.265 using libx265
table.insert(args, "-c:v")
table.insert(args, "libx265")
table.insert(args, "-vtag")
table.insert(args, "hvc1")
table.insert(args, "-pix_fmt")
table.insert(args, "yuv420p")
table.insert(args, "-crf")
table.insert(args, tostring(options.h265_crf or 28))
table.insert(args, "-c:a")
table.insert(args, "copy")
else
-- Default to x264
table.insert(args, "-c:v")
table.insert(args, "libx264")
table.insert(args, "-pix_fmt")
table.insert(args, "yuv420p")
table.insert(args, "-crf")
table.insert(args, tostring(options.h264_crf or 23))
table.insert(args, "-c:a")
table.insert(args, "copy")
end
-- Output path
table.insert(args, result_path)
print("Saving clip...")
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
}, function()
print("Saved clip!")
end)
end
ACTIONS.ENCODE_GIF = function(d)
local file_extra_suffix = "_FROM_" .. d.start_time_hms .. "_TO_" .. d.end_time_hms .. " (clip)"
local result_path = mp.utils.join_path(d.indir,
d.infile_noext .. file_extra_suffix .. "." .. options.animated_encoding_type)
if (options.save_to_directory) then
result_path = check_paths(d, file_extra_suffix, nil,
"." .. options.animated_encoding_type)
end
local video_height = mp.get_property_number("height")
-- Start with common args
local args = {
"ffmpeg", "-nostdin", "-y", "-loglevel", "error",
"-ss", d.start_time,
"-t", d.duration,
"-i", d.inpath
}
if video_height and options.cap_resolution and video_height > options.max_animated_resolution then
local res_line = "scale=trunc(oh*a/2)*2:" .. options.max_animated_resolution
table.insert(args, "-vf")
table.insert(args, res_line)
end
if options.animated_encoding_type == "avif" then
-- AV1 (avif) using libsvtav1
table.insert(args, "-c:v")
table.insert(args, "libsvtav1")
table.insert(args, "-crf")
table.insert(args, tostring(options.avif_crf or 42))
table.insert(args, "-preset")
table.insert(args, tostring(options.av1_preset or 6))
elseif options.animated_encoding_type == ".webp" then
-- webp using libwebp_anim
table.insert(args, "-c:v")
table.insert(args, "libwebp_anim")
table.insert(args, "-quality")
table.insert(args, tostring(options.webp_quality or 75))
table.insert(args, "-compression_level")
table.insert(args, tostring(options.webp_compression_level or 6))
table.insert(args, "-loop")
table.insert(args, "0")
else
-- Default to gif
end
-- Output path
table.insert(args, result_path)
print(result_path)
print("Saving clip...")
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
}, function()
print("Saved clip!")
end)
end
ACTIONS.COMPRESS = function(d)
if options.encoding_type == "av1" then options.compress_size = options.compress_size * 1.2 end
local target_bitrate = ((options.compress_size * 8192) / d.duration * 0.9) -- Video bitrate (KB)
mp.msg.info("Theoretical bitrate: " .. target_bitrate)
local max_bitrate = target_bitrate
local video_bitrate = average_bitrate
if video_bitrate and video_bitrate ~= -1 then -- the average bitrate system is to stop small cuts from becoming too big
max_bitrate = video_bitrate
mp.msg.info("Average bitrate: " .. max_bitrate)
if target_bitrate > max_bitrate then
target_bitrate = max_bitrate
end
end
if target_bitrate > 128 then
target_bitrate = target_bitrate - 128 -- minus audio bitrate
end
mp.msg.info("Using bitrate: " .. target_bitrate)
local file_extra_suffix = "_FROM_" ..
d.start_time_hms .. "_TO_" .. d.end_time_hms .. " (" .. options.encoding_type .. "compress)"
local result_path = mp.utils.join_path(d.indir, d.infile_noext .. file_extra_suffix .. ".mp4")
if options.save_to_directory then
result_path = check_paths(d, file_extra_suffix)
end
local video_height = mp.get_property_number("height")
local selected_audio_id = mp.get_property_number("aid")
local ff_audio_index = nil
local count = 0
for _, track in ipairs(mp.get_property_native("track-list") or {}) do
if track.type == "audio" then
if track.id == selected_audio_id then
ff_audio_index = count
break
end
count = count + 1
end
end
if ff_audio_index == nil then
ff_audio_index = 0
end
-- Start with common args
local args = {
"ffmpeg", "-nostdin", "-y", "-loglevel", "error",
"-ss", d.start_time,
"-t", d.duration,
"-i", d.inpath,
"-map", "0:v:0",
"-map_chapters", "-1",
"-map", "0:a:" .. ff_audio_index .. "?",
}
if video_height and options.cap_resolution and video_height > options.max_resolution then
local res_line = "scale=trunc(oh*a/2)*2:" .. options.max_resolution
table.insert(args, "-vf")
table.insert(args, res_line)
end
if options.encoding_type == "av1" then
-- AV1 using libsvtav1
table.insert(args, "-c:v")
table.insert(args, "libsvtav1")
table.insert(args, "-b:v")
table.insert(args, target_bitrate .. "k")
table.insert(args, "-svtav1-params")
table.insert(args, "rc=1")
table.insert(args, "-preset")
table.insert(args, tostring(options.av1_preset or 6))
table.insert(args, "-c:a")
table.insert(args, "copy")
elseif options.encoding_type == "h265" then
-- H.265 using libx265
table.insert(args, "-c:v")
table.insert(args, "libx265")
table.insert(args, "-b:v")
table.insert(args, target_bitrate .. "k")
table.insert(args, "-vtag")
table.insert(args, "hvc1")
table.insert(args, "-pix_fmt")
table.insert(args, "yuv420p")
table.insert(args, "-c:a")
table.insert(args, "copy")
else
-- Default to x264
table.insert(args, "-pix_fmt")
table.insert(args, "yuv420p")
table.insert(args, "-c:v")
table.insert(args, "libx264")
table.insert(args, "-b:v")
table.insert(args, target_bitrate .. "k")
table.insert(args, "-c:a")
table.insert(args, "copy")
end
-- Output path
table.insert(args, result_path)
print("Saving clip...")
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
}, function()
print("Saved clip!")
end)
end
ACTIONS.COPY = function(d)
local file_extra_suffix = "_FROM_" .. d.start_time_hms .. "_TO_" .. d.end_time_hms .. " (cut)"
local result_path = mp.utils.join_path(d.indir, d.infile_noext .. file_extra_suffix .. d.ext)
if options.save_to_directory then
result_path = check_paths(d, file_extra_suffix)
end
-- Fast copy with accurate start (may be slightly off if not on keyframe)
local args = {
"ffmpeg",
"-nostdin", "-y",
"-loglevel", "error",
"-i", d.inpath,
"-ss", d.start_time, -- output seek for better accuracy
"-t", d.duration,
"-c", "copy", -- fast copy
"-map", "0:v", -- video only
"-map_chapters", "-1",
"-map", "0:a?", -- audio if exists
"-dn", -- drop data streams
"-avoid_negative_ts", "make_zero",
result_path
}
print("Saving clip...")
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
}, function()
print("Saved clip!")
end)
end
RUN_WEB_CACHE = function(d)
local command = {
filename = check_paths(d, "(cache)", full_path_save)
}
command["name"] = "dump-cache"
command["start"] = d.start_time
command["end"] = d.end_time
mp.command_native_async(command, function()
print("Saved clip!")
end)
end
ACTION = options.action
if not ACTIONS[ACTION] then ACTION = next_table_key(ACTIONS, nil) end
START_TIME = nil
local function get_data()
local d = {}
d.inpath = mp.get_property("path")
d.indir = mp.utils.split_path(d.inpath)
d.infile = mp.get_property("filename")
d.infile_noext = mp.get_property("filename/no-ext")
d.ext = mp.get_property("filename"):match("^.+(%..+)$") or ".mp4"
return d
end
local function get_times(start_time, end_time)
local d = {}
d.start_time = tostring(start_time)
d.end_time = tostring(end_time)
d.duration = tostring(end_time - start_time)
d.start_time_hms = tostring(to_hms(start_time))
d.end_time_hms = tostring(to_hms(end_time))
d.duration_hms = tostring(to_hms(end_time - start_time))
return d
end
local function seconds_to_hms(seconds)
local hours = math.floor(seconds / 3600)
local minutes = math.floor((seconds % 3600) / 60)
local secs = math.floor(seconds % 60)
local ms = math.floor((seconds - math.floor(seconds)) * 1000)
local time_string = ""
if hours > 0 then
time_string = string.format("%02d:%02d:%02d.%03d", hours, minutes, secs, ms)
else
time_string = string.format("%02d:%02d.%03d", minutes, secs, ms)
end
return time_string
end
local function text_overlay_on()
print(string.format("%s from %s", ACTION, seconds_to_hms(START_TIME)))
end
local function print_or_update_text_overlay(content)
if START_TIME then text_overlay_on() else print(content) end
end
local function cycle_action()
ACTION = next_table_key(ACTIONS, ACTION)
print_or_update_text_overlay("Action: " .. ACTION)
end
local function cycle_codec()
local current_index = nil
for i, codec in ipairs(options.codecs_list) do
if codec == options.encoding_type then
current_index = i
break
end
end
local next_index = current_index + 1
if next_index > #options.codecs_list then
next_index = 1
end
options.encoding_type = options.codecs_list[next_index]
print_or_update_text_overlay("Encoding codec: " .. options.encoding_type)
end
local function cut(start_time, end_time)
local d = get_data()
local t = get_times(start_time, end_time)
for k, v in pairs(t) do d[k] = v end
if is_url(d.inpath) then
if options.use_cache_for_web_videos then
mp.msg.info("Using web cache")
RUN_WEB_CACHE(d)
else
mp.msg.error("Can't cut on a web video (use_cache_for_web_videos is set to false)")
end
else
ACTIONS[ACTION](d)
end
end
local function put_time()
local time = mp.get_property_number("time-pos")
if not START_TIME then
START_TIME = time
text_overlay_on()
return
end
if time > START_TIME then
print(string.format("%s to %s", ACTION, seconds_to_hms(time)))
cut(START_TIME, time)
START_TIME = nil
else
print("Invalid selection")
START_TIME = nil
end
end
local function cancel_cut()
START_TIME = nil
print("Cleared selection")
end
mp.add_key_binding(options.key_cut, "cut", put_time)
mp.add_key_binding(options.key_cancel_cut, "cancel_cut", cancel_cut)
mp.add_key_binding(options.key_cycle_action, "cycle_action", cycle_action)
mp.add_key_binding(options.key_cycle_codec, "cycle_codec", cycle_codec)