Coverage for pyguymer3/media/images2mp4.py: 1%
78 statements
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-08 18:47 +0000
« prev ^ index » next coverage.py v7.9.2, created at 2025-07-08 18:47 +0000
1#!/usr/bin/env python3
3# Define function ...
4def images2mp4(
5 frames,
6 /,
7 *,
8 chunksize = 1048576,
9 crf = -1.0,
10 cwd = None,
11 debug = __debug__,
12 ensureNFC = True,
13 ffmpegPath = None,
14 ffprobePath = None,
15 form = "mp4",
16 fps = 25.0,
17 level = "ERROR",
18 mp4filePath = None,
19 pkgPath = None,
20 portPath = None,
21 profile = "ERROR",
22 screenHeight = -1,
23 screenWidth = -1,
24 timeout = 60.0,
25 zypperPath = None,
26):
27 """Convert a sequence of images to a MP4 video.
29 This function makes a MP4 video from a list of file paths. The user is able
30 to set the format and the framerate, as well as optionally downscaling the
31 images to fit withing a screen size.
33 Parameters
34 ----------
35 frames : list of str
36 the list of strings which are the paths to the images
37 crf : float, optional
38 the CRF to be passed to libx264, default -1.0 (which means choose one
39 using the function :func:`return_x264_crf`)
40 debug : bool, optional
41 print debug messages
42 ffmpegPath : str, optional
43 the path to the "ffmpeg" binary (if not provided then Python will
44 attempt to find the binary itself)
45 ffprobePath : str, optional
46 the path to the "ffprobe" binary (if not provided then Python will
47 attempt to find the binary itself)
48 form : str, optional
49 the format to be passed to ffmpeg, default "mp4" (the only two sensible
50 options are "ipod" and "mp4")
51 fps : float, optional
52 the framerate, default 25.0
53 level : str, optional
54 the level to be passed to libx264, default "ERROR" (which means choose
55 one using the function :func:`return_x264_level`)
56 mp4filePath : str, optional
57 the path to the "mp4file" binary (if not provided then Python will
58 attempt to find the binary itself)
59 pkgPath : str, optional
60 the path to the "pkg" binary (if not provided then Python will
61 attempt to find the binary itself)
62 portPath : str, optional
63 the path to the "port" binary (if not provided then Python will
64 attempt to find the binary itself)
65 profile : str, optional
66 the profile to be passed to libx264, default "ERROR" (which means choose
67 one using the function :func:`return_x264_profile`)
68 screenHeight : int, optional
69 the height of the screen to downscale the input images to fit within,
70 default -1 (integers less than 100 imply no downscaling)
71 screenWidth : int, optional
72 the width of the screen to downscale the input images to fit within,
73 default -1 (integers less than 100 imply no downscaling)
74 timeout : float, optional
75 the timeout for any requests/subprocess calls
76 zypperPath : str, optional
77 the path to the "zypper" binary (if not provided then Python will
78 attempt to find the binary itself)
80 Returns
81 -------
82 path : str
83 the path to the MP4 in a temporary directory (to be copied/moved by the
84 user themselves)
86 Notes
87 -----
88 Copyright 2017 Thomas Guymer [1]_
90 References
91 ----------
92 .. [1] PyGuymer3, https://github.com/Guymer/PyGuymer3
93 """
95 # Import standard modules ...
96 import os
97 import platform
98 import shutil
99 import subprocess
100 import tempfile
102 # Load sub-functions ...
103 from .optimise_MP4 import optimise_MP4
104 from .return_video_bit_depth import return_video_bit_depth
105 from .return_x264_crf import return_x264_crf
106 from .return_x264_level import return_x264_level
107 from .return_x264_profile import return_x264_profile
108 from ..find_program_version import find_program_version
109 from ..image import return_image_size
111 # **************************************************************************
113 # Try to find the paths if the user did not provide them ...
114 if ffmpegPath is None:
115 ffmpegPath = shutil.which("ffmpeg")
116 if ffprobePath is None:
117 ffprobePath = shutil.which("ffprobe")
118 assert ffmpegPath is not None, "\"ffmpeg\" is not installed"
119 assert ffprobePath is not None, "\"ffprobe\" is not installed"
121 # **************************************************************************
123 # Check if the user wants to scale the input images down to fit within a
124 # screen size ...
125 if screenWidth >= 100 and screenHeight >= 100:
126 # Check input ...
127 if screenWidth % 2 != 0 or screenHeight % 2 != 0:
128 raise Exception("the dimensions of the screen must be even") from None
130 # Set aspect ratio ...
131 screenRatio = float(screenWidth) / float(screenHeight) # [px/px]
132 if debug:
133 print(f"INFO: The input images will be downscaled to fit within {screenWidth:,d}x{screenHeight:,d} ({screenRatio:.5f}:1).")
135 # **************************************************************************
137 # Set package names based on OS ...
138 # NOTE: This is a bit of a hack. The package name is required to find the
139 # version number of the software and the package name changes
140 # depending on the package manager used.
141 ffmpeg = "ffmpeg"
142 libx264 = "libx264"
143 if platform.system() == "Darwin":
144 libx264 = "x264"
146 # Find the extension of the input images (assuming that they are all the
147 # same extension) ...
148 ext = os.path.splitext(frames[0])[1].lower()
150 # Find the dimensions (and aspect ratio) of the input images (assuming that
151 # they are all the same dimensions) ...
152 inputWidth, inputHeight = return_image_size(frames[0], compressed = False) # [px], [px]
153 inputRatio = float(inputWidth) / float(inputHeight) # [px/px]
154 if debug:
155 print(f"INFO: The input images are {inputWidth:,d}x{inputHeight:,d} ({inputRatio:.5f}:1).")
157 # Find the dimensions (and aspect ratio) of the cropped input images ...
158 # NOTE: x264 requires that the dimensions are multiples of 2.
159 cropWidth = 2 * (inputWidth // 2) # [px]
160 cropHeight = 2 * (inputHeight // 2) # [px]
161 cropRatio = float(cropWidth) / float(cropHeight) # [px/px]
162 if debug:
163 print(f"INFO: The cropped input images are {cropWidth:,d}x{cropHeight:,d} ({cropRatio:.5f}:1).")
165 # Check if the user wants to scale the input images down to fit within a
166 # screen size ...
167 if screenWidth >= 100 and screenHeight >= 100:
168 # Check if the cropped input images are wider/taller than the screen
169 # size ...
170 if cropRatio > screenRatio:
171 # Find the dimensions of the output video ...
172 outputWidth = screenWidth # [px]
173 outputHeight = 2 * (round(float(screenWidth) / cropRatio) // 2) # [px]
174 else:
175 # Find the dimensions of the output video ...
176 outputWidth = 2 * (round(float(screenHeight) * cropRatio) // 2) # [px]
177 outputHeight = screenHeight # [px]
179 # Find the aspect ratio of the output video ...
180 outputRatio = float(outputWidth) / float(outputHeight) # [px/px]
181 else:
182 # Find the dimensions (and aspect ratio) of the output video ...
183 outputWidth = cropWidth # [px]
184 outputHeight = cropHeight # [px]
185 outputRatio = cropRatio # [px/px]
186 if debug:
187 print(f"INFO: The output video will be {outputWidth:,d}x{outputHeight:,d} ({outputRatio:.5f}:1).")
189 # Find CRF, level and profile of the output video (if required) ...
190 if crf < 0.0:
191 crf = return_x264_crf(outputWidth, outputHeight)
192 if level == "ERROR":
193 level = return_x264_level(outputWidth, outputHeight)
194 if profile == "ERROR":
195 profile = return_x264_profile(outputWidth, outputHeight)
197 # Create secure output directory ...
198 tmpname = tempfile.mkdtemp(prefix = "images2mp4.")
200 # Make symbolic links to the input images for ease ...
201 for i, frame in enumerate(frames):
202 os.symlink(os.path.abspath(frame), f"{tmpname}/frame{i:06d}{ext}")
204 # Determine output video filter parameters ...
205 filterParams = []
206 if inputWidth != cropWidth or inputHeight != cropHeight:
207 filterParams += [
208 f"crop={cropWidth:d}:{cropHeight:d}:{(inputWidth - cropWidth) // 2:d}:{(inputHeight - cropHeight) // 2:d}",
209 ]
210 if cropWidth != outputWidth or cropHeight != outputHeight:
211 filterParams += [
212 f"scale={outputWidth:d}:{outputHeight:d}",
213 ]
215 # Convert the input images to the output video ...
216 # NOTE: Audio and subtitle streams are explicitly disabled just to be safe.
217 cmd = [
218 ffmpegPath,
219 "-hide_banner",
220 "-probesize", "1G",
221 "-analyzeduration", "1800M",
222 "-f", "image2",
223 "-framerate", f"{fps:.1f}",
224 "-i", f"{tmpname}/frame%06d{ext}",
225 "-pix_fmt", "yuv420p",
226 "-an",
227 "-sn",
228 "-c:v", "libx264",
229 "-profile:v", profile,
230 "-preset", "veryslow",
231 "-level", level,
232 "-crf", f"{crf:.1f}",
233 ]
234 if len(filterParams) > 0:
235 cmd += [
236 "-vf", ",".join(filterParams),
237 ]
238 cmd += [
239 "-f", form,
240 "-map_chapters", "-1",
241 "-map_metadata", "-1",
242 "-metadata", f"comment=Converted to a {form.upper()} using ffmpeg (version {find_program_version(ffmpeg, pkgPath = pkgPath, portPath = portPath, timeout = timeout, zypperPath = zypperPath)}) which used libx264 (version {find_program_version(libx264, pkgPath = pkgPath, portPath = portPath, timeout = timeout, zypperPath = zypperPath)}) using a CRF of {crf:.1f} for libx264 (which adhered to the {profile} profile and level {level}).",
243 "-threads", f"{max(1, (os.cpu_count() or 1) - 1):d}",
244 f"{tmpname}/video.mp4",
245 ]
246 if debug:
247 print(f'INFO: {" ".join(cmd)}')
248 with open(f"{tmpname}/ffmpeg.err", "wt", encoding = "utf-8") as fObjErr:
249 with open(f"{tmpname}/ffmpeg.out", "wt", encoding = "utf-8") as fObjOut:
250 subprocess.run(
251 cmd,
252 check = True,
253 cwd = cwd,
254 encoding = "utf-8",
255 stderr = fObjErr,
256 stdout = fObjOut,
257 timeout = None,
258 )
260 # Check libx264 bit-depth ...
261 if return_video_bit_depth(
262 f"{tmpname}/video.mp4",
263 cwd = cwd,
264 debug = debug,
265 ensureNFC = ensureNFC,
266 ffprobePath = ffprobePath,
267 playlist = -1,
268 timeout = timeout,
269 ) != 8:
270 raise Exception(f"successfully converted the input images to a not-8-bit MP4; see \"{tmpname}\" for clues") from None
272 # Optimise output video ...
273 optimise_MP4(
274 f"{tmpname}/video.mp4",
275 chunksize = chunksize,
276 debug = debug,
277 mp4filePath = mp4filePath,
278 timeout = timeout,
279 )
281 # Return path to output video ...
282 return f"{tmpname}/video.mp4"