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

1#!/usr/bin/env python3 

2 

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. 

28 

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. 

32 

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) 

79 

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) 

85 

86 Notes 

87 ----- 

88 Copyright 2017 Thomas Guymer [1]_ 

89 

90 References 

91 ---------- 

92 .. [1] PyGuymer3, https://github.com/Guymer/PyGuymer3 

93 """ 

94 

95 # Import standard modules ... 

96 import os 

97 import platform 

98 import shutil 

99 import subprocess 

100 import tempfile 

101 

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 

109 

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 

118 

119 # ************************************************************************** 

120 

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" 

128 

129 # ************************************************************************** 

130 

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 

137 

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

142 

143 # ************************************************************************** 

144 

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" 

153 

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" 

160 

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

175 

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

183 

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] 

197 

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

207 

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) 

215 

216 # Create secure output directory ... 

217 tmpname = tempfile.mkdtemp(prefix = "images2mp4.") 

218 

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 

234 

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 ] 

245 

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 ) 

290 

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 

302 

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 ) 

311 

312 # Return path to output video ... 

313 return f"{tmpname}/video.mp4"