-
Notifications
You must be signed in to change notification settings - Fork 34
/
video-concat-xfade
executable file
·113 lines (96 loc) · 4.9 KB
/
video-concat-xfade
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
#!/usr/bin/env python
import os, sys, json, subprocess as sp
def ffmpeg_concat_cmd(
files, out_file, enc_opts, norm_opts,
xfade='fade', xfade_td=1.0, video_size=None, audio=True ):
files_len, (w, h) = dict(), video_size or (0, 0)
for p in files:
if p in files_len: continue
probe = json.loads(sp.run([
'ffprobe', '-v', 'error', '-select_streams', 'v',
'-show_entries', 'stream=width,height,duration:format=duration',
'-print_format', 'json', p ], check=True, stdout=sp.PIPE).stdout)
if not w: w = int(probe['streams'][0]['width'])
if not h: h = int(probe['streams'][0]['height'])
files_len[p] = float(probe['format']['duration'])
files_len = list(files_len[p] for p in files)
video_fades, audio_fades, normalize = list(), list(), list()
fade_out, audio_out, video_len = '0v', '0:a', 0
norm_filter = norm_opts.format(w=w, h=h).strip()
for n in range(len(files)):
if norm_filter: normalize.append(f'[{n}:v]{norm_filter}[{n}v];')
if not n: continue
video_len += files_len[n - 1] - xfade_td # xfades get cut from next video
fade_out_last, fade_out = fade_out, f'v{n-1}-{n}'
video_fades.append(
f'[{fade_out_last}][{n}v]xfade=transition={xfade}:'
f'duration={xfade_td}:offset={video_len:.3f}[{fade_out}];' )
if audio:
audio_out_last, audio_out = audio_out, f'a{n-1}-{n}'
audio_fades.append(
f'[{audio_out_last}][{n}:a]acrossfade=d={xfade_td}[{audio_out}];' )
video_fades.append(f'[{fade_out}]format=pix_fmts=yuv420p[final];')
video_fades, audio_fades, normalize = (
''.join(v) for v in [video_fades, audio_fades, normalize] )
ffmpeg_args = list()
for p in files: ffmpeg_args.extend(['-i', p])
cmd = ['ffmpeg', *ffmpeg_args]
proc = normalize + video_fades
if audio_fades: proc += audio_fades[:-1]
cmd.extend(['-filter_complex', proc, '-map', '[final]'])
if audio_fades: cmd.extend(['-map', f'[{audio_out}]'])
cmd.extend([*enc_opts, out_file])
return cmd
def main(args=None):
enc_opts = '-c:a aac -c:v libx264 -movflags +faststart -preset slow -f mp4 -y'
import argparse, textwrap, re
dd = lambda text: re.sub( r' \t+', ' ',
textwrap.dedent(text).strip('\n') + '\n' ).replace('\t', ' ')
parser = argparse.ArgumentParser(
formatter_class=argparse.RawTextHelpFormatter,
description='Concatenate multiple video files with crossfade using ffmpeg.')
parser.add_argument('files', nargs='+', help=dd('''
List of files to concatenate (merge) together, in the same order as specified.
Video width/height will be determined by the first one, rest will be aspect-scaled.'''))
parser.add_argument('-o', '--out-file', metavar='file',
help='Output file to create. Must be set, unless --dry-run option is used.')
parser.add_argument('-e', '--enc-params',
action='append', metavar='ffmpeg-opts', help=dd(f'''
ffmpeg output encoding parameters, i.e. codecs and such.
Will be split on spaces, unless option is used multiple times.
Default: {enc_opts}'''))
parser.add_argument('-s', '--video-size', metavar='WxH', help=dd('''
Resulting output video size, in pixels. For example: 1280x720
Default is to ffprobe first specified file and use its video dimensions.'''))
parser.add_argument('-x', '--xfade-time',
metavar='seconds', type=float, default=1.0,
help='Audio/video crossfade duration, in seconds. Default: %(default)s')
parser.add_argument('-X', '--xfade-effect',
metavar='transition', default='fade', help=dd('''
"tranistion" parameter value for ffmpeg "xfade" filter. Default: %(default)s
See https://ffmpeg.org/ffmpeg-filters.html#xfade for the full list of those.'''))
parser.add_argument('-N', '--normalize-filters', metavar='filters',
default=( 'settb=AVTB,setsar=sar=1,fps=30,scale=w={w}:h={h}'
':force_original_aspect_ratio=1,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2' ), help=dd('''
Filters to "normalize" input videos to have same parameters, including scaling.
Can be empty. w/h str.format vars are from first video by default.
Default: %(default)s'''))
parser.add_argument('-A', '--no-audio', action='store_true',
help='Disable default-enabled audio crossfade/processing filters.')
parser.add_argument('-n', '--dry-run', action='store_true',
help='Print final ffmpeg command that will be used, but do not run it.')
opts = parser.parse_args(sys.argv[1:] if args is None else args)
if len(opts.files) < 2: parser.error('Need more than one file to merge together')
if not opts.out_file:
if opts.dry_run: opts.out_file = 'out.mp4'
else: parser.error('Output file path must be specified')
video_size = opts.video_size and tuple(map(int, opts.video_size.split('x')))
enc_opts = opts.enc_params or [enc_opts]
if len(enc_opts) == 1: enc_opts = enc_opts[0].split()
cmd = ffmpeg_concat_cmd(
opts.files, opts.out_file, enc_opts, opts.normalize_filters,
video_size=video_size, audio=not opts.no_audio,
xfade_td=opts.xfade_time, xfade=opts.xfade_effect )
if opts.dry_run: print(' '.join(cmd))
else: os.execvp(cmd[0], cmd)
if __name__ == '__main__': sys.exit(main())