Coverage for pyguymer3/media/images2mp4.py: 1%
94 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-16 08:31 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-16 08:31 +0000
1#!/usr/bin/env python3
3# Define function ...
4def images2mp4(
5 imgs,
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 imgs : list of PIL.Image.Image or list of str
36 the list of input PIL Images or list of paths to the input 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 # Import special modules ...
103 try:
104 import PIL
105 import PIL.Image
106 PIL.Image.MAX_IMAGE_PIXELS = 1024 * 1024 * 1024 # [px]
107 except:
108 raise Exception("\"PIL\" is not installed; run \"pip install --user Pillow\"") from None
110 # Load sub-functions ...
111 from .optimise_MP4 import optimise_MP4
112 from .return_video_bit_depth import return_video_bit_depth
113 from .return_x264_crf import return_x264_crf
114 from .return_x264_level import return_x264_level
115 from .return_x264_profile import return_x264_profile
116 from ..find_program_version import find_program_version
117 from ..image import return_image_size
119 # **************************************************************************
121 # Try to find the paths if the user did not provide them ...
122 if ffmpegPath is None:
123 ffmpegPath = shutil.which("ffmpeg")
124 if ffprobePath is None:
125 ffprobePath = shutil.which("ffprobe")
126 assert ffmpegPath is not None, "\"ffmpeg\" is not installed"
127 assert ffprobePath is not None, "\"ffprobe\" is not installed"
129 # **************************************************************************
131 # Check if the user wants to scale the input images down to fit within a
132 # screen size ...
133 if screenWidth >= 100 and screenHeight >= 100:
134 # Check input ...
135 if screenWidth % 2 != 0 or screenHeight % 2 != 0:
136 raise Exception("the dimensions of the screen must be even") from None
138 # Set aspect ratio ...
139 screenRatio = float(screenWidth) / float(screenHeight) # [px/px]
140 if debug:
141 print(f"INFO: The input images will be downscaled to fit within {screenWidth:,d}x{screenHeight:,d} ({screenRatio:.5f}:1).")
143 # **************************************************************************
145 # Set package names based on OS ...
146 # NOTE: This is a bit of a hack. The package name is required to find the
147 # version number of the software and the package name changes
148 # depending on the package manager used.
149 ffmpeg = "ffmpeg"
150 libx264 = "libx264"
151 if platform.system() == "Darwin":
152 libx264 = "x264"
154 # Find the extension of the input images (assuming that they are all the
155 # same extension) ...
156 if isinstance(imgs[0], str):
157 ext = os.path.splitext(imgs[0])[1].lower()
158 else:
159 ext = ".png"
161 # Find the dimensions (and aspect ratio) of the input images (assuming that
162 # they are all the same dimensions) ...
163 if isinstance(imgs[0], str):
164 inputWidth, inputHeight = return_image_size(
165 imgs[0],
166 compressed = False,
167 ) # [px], [px]
168 elif isinstance(imgs[0], PIL.Image.Image):
169 inputWidth, inputHeight = imgs[0].size # [px], [px]
170 else:
171 raise TypeError(f"\"imgs[0]\" is an unexpected type ({repr(type(imgs[0]))})") from None
172 inputRatio = float(inputWidth) / float(inputHeight) # [px/px]
173 if debug:
174 print(f"INFO: The input images are {inputWidth:,d}x{inputHeight:,d} ({inputRatio:.5f}:1).")
176 # Find the dimensions (and aspect ratio) of the cropped input images ...
177 # NOTE: x264 requires that the dimensions are multiples of 2.
178 cropWidth = 2 * (inputWidth // 2) # [px]
179 cropHeight = 2 * (inputHeight // 2) # [px]
180 cropRatio = float(cropWidth) / float(cropHeight) # [px/px]
181 if debug:
182 print(f"INFO: The cropped input images are {cropWidth:,d}x{cropHeight:,d} ({cropRatio:.5f}:1).")
184 # Check if the user wants to scale the input images down to fit within a
185 # screen size ...
186 if screenWidth >= 100 and screenHeight >= 100:
187 # Check if the cropped input images are wider/taller than the screen
188 # size ...
189 if cropRatio > screenRatio:
190 # Find the dimensions of the output video ...
191 outputWidth = screenWidth # [px]
192 outputHeight = 2 * (round(float(screenWidth) / cropRatio) // 2) # [px]
193 else:
194 # Find the dimensions of the output video ...
195 outputWidth = 2 * (round(float(screenHeight) * cropRatio) // 2) # [px]
196 outputHeight = screenHeight # [px]
198 # Find the aspect ratio of the output video ...
199 outputRatio = float(outputWidth) / float(outputHeight) # [px/px]
200 else:
201 # Find the dimensions (and aspect ratio) of the output video ...
202 outputWidth = cropWidth # [px]
203 outputHeight = cropHeight # [px]
204 outputRatio = cropRatio # [px/px]
205 if debug:
206 print(f"INFO: The output video will be {outputWidth:,d}x{outputHeight:,d} ({outputRatio:.5f}:1).")
208 # Find CRF, level and profile of the output video (if required) ...
209 if crf < 0.0:
210 crf = return_x264_crf(outputWidth, outputHeight)
211 if level == "ERROR":
212 level = return_x264_level(outputWidth, outputHeight)
213 if profile == "ERROR":
214 profile = return_x264_profile(outputWidth, outputHeight)
216 # Create secure output directory ...
217 tmpname = tempfile.mkdtemp(prefix = "images2mp4.")
219 # Make symbolic links to the input images for ease ...
220 for i, img in enumerate(imgs):
221 if isinstance(img, str):
222 os.symlink(
223 os.path.abspath(img),
224 f"{tmpname}/frame{i:06d}{ext}",
225 )
226 elif isinstance(img, PIL.Image.Image):
227 img.convert("RGB").save(
228 f"{tmpname}/frame{i:06d}{ext}",
229 compress_level = 0, # Don't waste time saving a temporary image.
230 optimise = False, # Don't waste time saving a temporary image.
231 )
232 else:
233 raise TypeError(f"\"imgs[{i:d}]\" is an unexpected type ({repr(type(img))})") from None
235 # Determine output video filter parameters ...
236 filterParams = []
237 if inputWidth != cropWidth or inputHeight != cropHeight:
238 filterParams += [
239 f"crop={cropWidth:d}:{cropHeight:d}:{(inputWidth - cropWidth) // 2:d}:{(inputHeight - cropHeight) // 2:d}",
240 ]
241 if cropWidth != outputWidth or cropHeight != outputHeight:
242 filterParams += [
243 f"scale={outputWidth:d}:{outputHeight:d}",
244 ]
246 # Convert the input images to the output video ...
247 # NOTE: Audio and subtitle streams are explicitly disabled just to be safe.
248 cmd = [
249 ffmpegPath,
250 "-hide_banner",
251 "-probesize", "1G",
252 "-analyzeduration", "1800M",
253 "-f", "image2",
254 "-framerate", f"{fps:.1f}",
255 "-i", f"{tmpname}/frame%06d{ext}",
256 "-pix_fmt", "yuv420p",
257 "-an",
258 "-sn",
259 "-c:v", "libx264",
260 "-profile:v", profile,
261 "-preset", "veryslow",
262 "-level", level,
263 "-crf", f"{crf:.1f}",
264 ]
265 if len(filterParams) > 0:
266 cmd += [
267 "-vf", ",".join(filterParams),
268 ]
269 cmd += [
270 "-f", form,
271 "-map_chapters", "-1",
272 "-map_metadata", "-1",
273 "-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}).",
274 "-threads", f"{max(1, (os.cpu_count() or 1) - 1):d}",
275 f"{tmpname}/video.mp4",
276 ]
277 if debug:
278 print(f'INFO: {" ".join(cmd)}')
279 with open(f"{tmpname}/ffmpeg.err", "wt", encoding = "utf-8") as fObjErr:
280 with open(f"{tmpname}/ffmpeg.out", "wt", encoding = "utf-8") as fObjOut:
281 subprocess.run(
282 cmd,
283 check = True,
284 cwd = cwd,
285 encoding = "utf-8",
286 stderr = fObjErr,
287 stdout = fObjOut,
288 timeout = None,
289 )
291 # Check libx264 bit-depth ...
292 if return_video_bit_depth(
293 f"{tmpname}/video.mp4",
294 cwd = cwd,
295 debug = debug,
296 ensureNFC = ensureNFC,
297 ffprobePath = ffprobePath,
298 playlist = -1,
299 timeout = timeout,
300 ) != 8:
301 raise Exception(f"successfully converted the input images to a not-8-bit MP4; see \"{tmpname}\" for clues") from None
303 # Optimise output video ...
304 optimise_MP4(
305 f"{tmpname}/video.mp4",
306 chunksize = chunksize,
307 debug = debug,
308 mp4filePath = mp4filePath,
309 timeout = timeout,
310 )
312 # Return path to output video ...
313 return f"{tmpname}/video.mp4"