mixvideoconcat 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mixvideoconcat/__init__.py +67 -0
- mixvideoconcat/concat.py +253 -0
- mixvideoconcat/log.py +40 -0
- mixvideoconcat-1.0.0.dist-info/LICENSE +674 -0
- mixvideoconcat-1.0.0.dist-info/METADATA +638 -0
- mixvideoconcat-1.0.0.dist-info/RECORD +9 -0
- mixvideoconcat-1.0.0.dist-info/WHEEL +5 -0
- mixvideoconcat-1.0.0.dist-info/entry_points.txt +2 -0
- mixvideoconcat-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/python3
|
|
2
|
+
# pylint: disable=broad-exception-caught
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
MixVideoConcat Script
|
|
6
|
+
|
|
7
|
+
This script provides a command-line interface for concatenating video files into
|
|
8
|
+
a single video file.
|
|
9
|
+
|
|
10
|
+
Example:
|
|
11
|
+
Concatenate video files 'video1.mp4', 'video2.mov', 'video3.avi'
|
|
12
|
+
into a single video file 'output.mp4':
|
|
13
|
+
$ mixvideoconcat video1.mp4 video2.mov video3.avi output.mp4
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import logging
|
|
19
|
+
import argparse
|
|
20
|
+
import tempfile
|
|
21
|
+
|
|
22
|
+
from .concat import *
|
|
23
|
+
from .log import init_logger
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __args_parse():
|
|
27
|
+
parser = argparse.ArgumentParser()
|
|
28
|
+
parser.add_argument("sources", nargs="+", help="Source files")
|
|
29
|
+
parser.add_argument("destination", help="Destination file")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-t",
|
|
32
|
+
"--tmpdir",
|
|
33
|
+
help="Directory for temprary files (they can be huge!)",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument('-l', '--logfile', help='Log file', default=None)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
'-f', '--force', help='Overwrite existing', action='store_true'
|
|
38
|
+
)
|
|
39
|
+
return parser.parse_args()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main():
|
|
43
|
+
"""
|
|
44
|
+
Main function for executing the concatenation process.
|
|
45
|
+
"""
|
|
46
|
+
args = __args_parse()
|
|
47
|
+
init_logger(args.logfile, logging.DEBUG)
|
|
48
|
+
|
|
49
|
+
if os.path.exists(args.destination) and not args.force:
|
|
50
|
+
print(f"{args.destination} already exists")
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
if args.tmpdir is None:
|
|
54
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
55
|
+
concat(args.sources, args.destination, tmpdir)
|
|
56
|
+
else:
|
|
57
|
+
os.makedirs(args.tmpdir, exist_ok=True)
|
|
58
|
+
concat(args.sources, args.destination, args.tmpdir)
|
|
59
|
+
|
|
60
|
+
logging.info("Done.")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
try:
|
|
65
|
+
main()
|
|
66
|
+
except Exception:
|
|
67
|
+
logging.exception("Main failed")
|
mixvideoconcat/concat.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# pylint: disable=line-too-long
|
|
2
|
+
"""
|
|
3
|
+
MixVideoConcat Module
|
|
4
|
+
|
|
5
|
+
This module provides functions for manipulating and concatenating video files.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import logging
|
|
10
|
+
import subprocess
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
FFMPEG_BINARY = "ffmpeg"
|
|
14
|
+
FFMPEG_CODEC = "libx264"
|
|
15
|
+
REENCODE_FPS = "25"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def __unlink(filename):
|
|
19
|
+
try:
|
|
20
|
+
os.unlink(filename)
|
|
21
|
+
except FileNotFoundError:
|
|
22
|
+
logging.exception("unlink failed")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_video_info(filename):
|
|
26
|
+
"""
|
|
27
|
+
Retrieve information about a video file.
|
|
28
|
+
"""
|
|
29
|
+
command = [
|
|
30
|
+
'ffprobe',
|
|
31
|
+
'-v',
|
|
32
|
+
'error',
|
|
33
|
+
'-show_format',
|
|
34
|
+
'-show_streams',
|
|
35
|
+
'-print_format',
|
|
36
|
+
'json',
|
|
37
|
+
filename,
|
|
38
|
+
]
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False
|
|
41
|
+
)
|
|
42
|
+
if result.returncode != 0:
|
|
43
|
+
error_msg = result.stderr.decode('utf-8').strip()
|
|
44
|
+
raise SystemError(error_msg)
|
|
45
|
+
|
|
46
|
+
output = result.stdout.decode('utf-8')
|
|
47
|
+
data = json.loads(output)
|
|
48
|
+
|
|
49
|
+
video_stream = next(
|
|
50
|
+
(
|
|
51
|
+
stream
|
|
52
|
+
for stream in data['streams']
|
|
53
|
+
if stream['codec_type'] == 'video'
|
|
54
|
+
),
|
|
55
|
+
None,
|
|
56
|
+
)
|
|
57
|
+
if video_stream is None:
|
|
58
|
+
raise RuntimeWarning("File has no video steam")
|
|
59
|
+
|
|
60
|
+
info = {
|
|
61
|
+
"width": int(video_stream.get('width', 0)),
|
|
62
|
+
"height": int(video_stream.get('height', 0)),
|
|
63
|
+
"duration": float(data['format']['duration']),
|
|
64
|
+
"orientation": int(
|
|
65
|
+
video_stream.get('side_data_list', [{}])[0].get('rotation', 0)
|
|
66
|
+
),
|
|
67
|
+
"interlaced": (
|
|
68
|
+
video_stream.get('field_order', 'unknown') != "progressive"
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
logging.info("%s: %s", filename, info)
|
|
72
|
+
return info
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def apply_video_filters(in_file, out_file, filters, add_params=None):
|
|
76
|
+
"""
|
|
77
|
+
Apply filters to a video file.
|
|
78
|
+
"""
|
|
79
|
+
cmd = [FFMPEG_BINARY, "-y"] # overwrite existing
|
|
80
|
+
cmd += ("-i", in_file) # input file
|
|
81
|
+
cmd += ("-vf", ",".join(filters)) # filters
|
|
82
|
+
if out_file is None:
|
|
83
|
+
cmd += ("-f", "null", "-")
|
|
84
|
+
else:
|
|
85
|
+
cmd += ("-qp", "0") # lossless
|
|
86
|
+
cmd += ("-preset", "ultrafast") # maimum speed, big file
|
|
87
|
+
cmd += ("-acodec", "copy") # copy audio as is
|
|
88
|
+
cmd += ("-c:v", FFMPEG_CODEC) # video codec
|
|
89
|
+
if add_params is not None:
|
|
90
|
+
cmd += add_params
|
|
91
|
+
cmd += (out_file,)
|
|
92
|
+
logging.debug(cmd)
|
|
93
|
+
res = subprocess.run(cmd, check=False).returncode
|
|
94
|
+
if res != 0:
|
|
95
|
+
raise SystemError(f"apply_video_filters failed: {res}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def deinterlace(in_file, out_file):
|
|
99
|
+
"""
|
|
100
|
+
Deinterlace a video file.
|
|
101
|
+
"""
|
|
102
|
+
filters = [
|
|
103
|
+
"yadif",
|
|
104
|
+
"format=yuv420p",
|
|
105
|
+
]
|
|
106
|
+
logging.info("start deinterlace")
|
|
107
|
+
apply_video_filters(in_file, out_file, filters)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def stabilize(in_file, out_file, tmpdirname):
|
|
111
|
+
"""
|
|
112
|
+
Stabilize a video file.
|
|
113
|
+
"""
|
|
114
|
+
trffile = os.path.join(tmpdirname, 'transforms.txt')
|
|
115
|
+
try:
|
|
116
|
+
filters = [
|
|
117
|
+
f"vidstabdetect=stepsize=32:shakiness=10:accuracy=10:result={trffile}", # noqa
|
|
118
|
+
]
|
|
119
|
+
logging.info("start stab prep")
|
|
120
|
+
apply_video_filters(in_file, None, filters)
|
|
121
|
+
|
|
122
|
+
filters = [
|
|
123
|
+
f"vidstabtransform=input={trffile}:zoom=0:smoothing=10,unsharp=5:5:0.8:3:3:0.4", # noqa
|
|
124
|
+
]
|
|
125
|
+
logging.info("start stab")
|
|
126
|
+
apply_video_filters(in_file, out_file, filters)
|
|
127
|
+
finally:
|
|
128
|
+
__unlink(trffile)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resize_and_resample(in_file, out_file, w, h):
|
|
132
|
+
"""
|
|
133
|
+
Resize and resample a video file.
|
|
134
|
+
"""
|
|
135
|
+
filters = [
|
|
136
|
+
"format=yuv420p",
|
|
137
|
+
f"scale=w='if(gt(a,{w}/{h}),{w},trunc(oh*a/2)*2)':h='if(gt(a,{w}/{h}),trunc(ow/a/2)*2,{h})'", # noqa
|
|
138
|
+
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:black",
|
|
139
|
+
]
|
|
140
|
+
add_params = ["-r", REENCODE_FPS]
|
|
141
|
+
logging.info("start resize")
|
|
142
|
+
apply_video_filters(in_file, out_file, filters, add_params)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def concat_uniform(filenames, out_file, tmpdirname):
|
|
146
|
+
"""
|
|
147
|
+
Concatenate video files with uniform properties into a single video file.
|
|
148
|
+
"""
|
|
149
|
+
if len(filenames) == 0:
|
|
150
|
+
logging.warning("empty filenames list")
|
|
151
|
+
return
|
|
152
|
+
listfile = os.path.join(tmpdirname, 'list.txt')
|
|
153
|
+
with open(listfile, "w", encoding="utf-8") as f:
|
|
154
|
+
for fname in filenames:
|
|
155
|
+
f.write(f"file '{fname}'\n")
|
|
156
|
+
|
|
157
|
+
cmd = [FFMPEG_BINARY, "-y"] # overwrite existing
|
|
158
|
+
cmd += ("-f", "concat")
|
|
159
|
+
cmd += ("-safe", "0")
|
|
160
|
+
cmd += ("-i", listfile)
|
|
161
|
+
cmd += ("-c:v", FFMPEG_CODEC)
|
|
162
|
+
cmd += ("-crf", "17") # produce a visually lossless file.
|
|
163
|
+
cmd += ("-bf", "2") # limit consecutive B-frames to 2
|
|
164
|
+
cmd += ("-use_editlist", "0") # avoids writing edit lists
|
|
165
|
+
# places moov atom/box at front of the output file.
|
|
166
|
+
cmd += ("-movflags", "+faststart")
|
|
167
|
+
# use the native encoder to produce an AAC audio stream.
|
|
168
|
+
cmd += ("-c:a", "aac")
|
|
169
|
+
cmd += ("-q:a", "1") # sets the highest quality for the audio.
|
|
170
|
+
cmd += ("-ac", "2") # rematrixes audio to stereo.
|
|
171
|
+
cmd += ("-ar", "48000") # resamples audio to 48000 Hz.
|
|
172
|
+
cmd += (out_file,)
|
|
173
|
+
logging.info("start concatenate")
|
|
174
|
+
logging.debug(cmd)
|
|
175
|
+
try:
|
|
176
|
+
res = subprocess.run(cmd, check=False).returncode
|
|
177
|
+
if res != 0:
|
|
178
|
+
raise SystemError(f"concatenate failed: {res}")
|
|
179
|
+
logging.info("file saved: %s", out_file)
|
|
180
|
+
finally:
|
|
181
|
+
__unlink(listfile)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def __get_info_and_size(filenames):
|
|
185
|
+
max_height = 0
|
|
186
|
+
max_width = 0
|
|
187
|
+
fileinfos = []
|
|
188
|
+
for f in filenames:
|
|
189
|
+
info = get_video_info(f)
|
|
190
|
+
w = info["width"]
|
|
191
|
+
h = info["height"]
|
|
192
|
+
if info["orientation"] not in (0, 180, -180):
|
|
193
|
+
w, h = h, w
|
|
194
|
+
if w > max_width:
|
|
195
|
+
max_width = w
|
|
196
|
+
max_height = h
|
|
197
|
+
info["name"] = f
|
|
198
|
+
fileinfos.append(info)
|
|
199
|
+
|
|
200
|
+
logging.info("Result video: width=%s, height=%s", max_width, max_height)
|
|
201
|
+
|
|
202
|
+
return fileinfos, max_width, max_height
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def concat(filenames, outputfile, tmpdirname="/tmp", dry_run=False):
|
|
206
|
+
"""
|
|
207
|
+
Concatenate video files into a single video file.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
filenames (list of str): List of paths to the input video files.
|
|
211
|
+
outputfile (str): Path to the output concatenated video file.
|
|
212
|
+
tmpdirname (str, optional): Directory for temporary files. Defaults to "/tmp".
|
|
213
|
+
dry_run (bool, optional): If True, performs a dry run without actually
|
|
214
|
+
concatenating the videos. Defaults to False.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
list: Information about the concatenated video files.
|
|
218
|
+
"""
|
|
219
|
+
fileinfos, max_width, max_height = __get_info_and_size(filenames)
|
|
220
|
+
|
|
221
|
+
if dry_run:
|
|
222
|
+
return fileinfos
|
|
223
|
+
|
|
224
|
+
tmpfilenames = []
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
for i, finfo in enumerate(fileinfos):
|
|
228
|
+
fname = os.path.join(tmpdirname, f"{i}.mp4")
|
|
229
|
+
tfname = os.path.join(tmpdirname, f"{i}_tmp.mp4")
|
|
230
|
+
src_name = finfo["name"]
|
|
231
|
+
|
|
232
|
+
logging.info("convert '%s' to '%s'", src_name, fname)
|
|
233
|
+
|
|
234
|
+
if finfo["interlaced"]:
|
|
235
|
+
deinterlace(src_name, tfname)
|
|
236
|
+
os.rename(tfname, fname)
|
|
237
|
+
src_name = fname
|
|
238
|
+
|
|
239
|
+
stabilize(src_name, tfname, tmpdirname)
|
|
240
|
+
os.rename(tfname, fname)
|
|
241
|
+
|
|
242
|
+
resize_and_resample(fname, tfname, max_width, max_height)
|
|
243
|
+
os.rename(tfname, fname)
|
|
244
|
+
|
|
245
|
+
tmpfilenames.append(fname)
|
|
246
|
+
|
|
247
|
+
concat_uniform(tmpfilenames, outputfile, tmpdirname)
|
|
248
|
+
|
|
249
|
+
finally:
|
|
250
|
+
for f in tmpfilenames:
|
|
251
|
+
__unlink(f)
|
|
252
|
+
|
|
253
|
+
return fileinfos
|
mixvideoconcat/log.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Log utilites
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
LOGFMT = '[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s'
|
|
10
|
+
DATEFMT = '%Y-%m-%d %H:%M:%S'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def init_logger(filename=None, level=logging.INFO):
|
|
14
|
+
"""
|
|
15
|
+
Initialize the logger.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
filename (str, optional): Path to the log file. If None, logs will be printed to console.
|
|
19
|
+
Defaults to None.
|
|
20
|
+
level (int, optional): Logging level. Defaults to logging.INFO.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
if filename is not None:
|
|
24
|
+
try:
|
|
25
|
+
os.makedirs(os.path.split(filename)[0])
|
|
26
|
+
except OSError:
|
|
27
|
+
pass
|
|
28
|
+
mode = 'a' if os.path.isfile(filename) else 'w'
|
|
29
|
+
fh = logging.FileHandler(filename, mode)
|
|
30
|
+
else:
|
|
31
|
+
fh = logging.StreamHandler()
|
|
32
|
+
|
|
33
|
+
fmt = logging.Formatter(LOGFMT, DATEFMT)
|
|
34
|
+
fh.setFormatter(fmt)
|
|
35
|
+
logging.getLogger().addHandler(fh)
|
|
36
|
+
|
|
37
|
+
logging.getLogger().setLevel(level)
|
|
38
|
+
|
|
39
|
+
logging.info('Log file: %s', str(filename))
|
|
40
|
+
logging.debug(str(sys.argv))
|