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.
@@ -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")
@@ -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))