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

1#!/usr/bin/env python3 

2 

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. 

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

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 # 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 

110 

111 # ************************************************************************** 

112 

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" 

120 

121 # ************************************************************************** 

122 

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 

129 

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

134 

135 # ************************************************************************** 

136 

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" 

145 

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() 

149 

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

156 

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

164 

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] 

178 

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

188 

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) 

196 

197 # Create secure output directory ... 

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

199 

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

203 

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 ] 

214 

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 ) 

259 

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 

271 

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 ) 

280 

281 # Return path to output video ... 

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