WebPImagePlugin.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. from __future__ import annotations
  2. from io import BytesIO
  3. from . import Image, ImageFile
  4. try:
  5. from . import _webp
  6. SUPPORTED = True
  7. except ImportError:
  8. SUPPORTED = False
  9. _VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True}
  10. _VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True}
  11. _VP8_MODES_BY_IDENTIFIER = {
  12. b"VP8 ": "RGB",
  13. b"VP8X": "RGBA",
  14. b"VP8L": "RGBA", # lossless
  15. }
  16. def _accept(prefix):
  17. is_riff_file_format = prefix[:4] == b"RIFF"
  18. is_webp_file = prefix[8:12] == b"WEBP"
  19. is_valid_vp8_mode = prefix[12:16] in _VP8_MODES_BY_IDENTIFIER
  20. if is_riff_file_format and is_webp_file and is_valid_vp8_mode:
  21. if not SUPPORTED:
  22. return (
  23. "image file could not be identified because WEBP support not installed"
  24. )
  25. return True
  26. class WebPImageFile(ImageFile.ImageFile):
  27. format = "WEBP"
  28. format_description = "WebP image"
  29. __loaded = 0
  30. __logical_frame = 0
  31. def _open(self):
  32. if not _webp.HAVE_WEBPANIM:
  33. # Legacy mode
  34. data, width, height, self._mode, icc_profile, exif = _webp.WebPDecode(
  35. self.fp.read()
  36. )
  37. if icc_profile:
  38. self.info["icc_profile"] = icc_profile
  39. if exif:
  40. self.info["exif"] = exif
  41. self._size = width, height
  42. self.fp = BytesIO(data)
  43. self.tile = [("raw", (0, 0) + self.size, 0, self.mode)]
  44. self.n_frames = 1
  45. self.is_animated = False
  46. return
  47. # Use the newer AnimDecoder API to parse the (possibly) animated file,
  48. # and access muxed chunks like ICC/EXIF/XMP.
  49. self._decoder = _webp.WebPAnimDecoder(self.fp.read())
  50. # Get info from decoder
  51. width, height, loop_count, bgcolor, frame_count, mode = self._decoder.get_info()
  52. self._size = width, height
  53. self.info["loop"] = loop_count
  54. bg_a, bg_r, bg_g, bg_b = (
  55. (bgcolor >> 24) & 0xFF,
  56. (bgcolor >> 16) & 0xFF,
  57. (bgcolor >> 8) & 0xFF,
  58. bgcolor & 0xFF,
  59. )
  60. self.info["background"] = (bg_r, bg_g, bg_b, bg_a)
  61. self.n_frames = frame_count
  62. self.is_animated = self.n_frames > 1
  63. self._mode = "RGB" if mode == "RGBX" else mode
  64. self.rawmode = mode
  65. self.tile = []
  66. # Attempt to read ICC / EXIF / XMP chunks from file
  67. icc_profile = self._decoder.get_chunk("ICCP")
  68. exif = self._decoder.get_chunk("EXIF")
  69. xmp = self._decoder.get_chunk("XMP ")
  70. if icc_profile:
  71. self.info["icc_profile"] = icc_profile
  72. if exif:
  73. self.info["exif"] = exif
  74. if xmp:
  75. self.info["xmp"] = xmp
  76. # Initialize seek state
  77. self._reset(reset=False)
  78. def _getexif(self):
  79. if "exif" not in self.info:
  80. return None
  81. return self.getexif()._get_merged_dict()
  82. def getxmp(self):
  83. """
  84. Returns a dictionary containing the XMP tags.
  85. Requires defusedxml to be installed.
  86. :returns: XMP tags in a dictionary.
  87. """
  88. return self._getxmp(self.info["xmp"]) if "xmp" in self.info else {}
  89. def seek(self, frame):
  90. if not self._seek_check(frame):
  91. return
  92. # Set logical frame to requested position
  93. self.__logical_frame = frame
  94. def _reset(self, reset=True):
  95. if reset:
  96. self._decoder.reset()
  97. self.__physical_frame = 0
  98. self.__loaded = -1
  99. self.__timestamp = 0
  100. def _get_next(self):
  101. # Get next frame
  102. ret = self._decoder.get_next()
  103. self.__physical_frame += 1
  104. # Check if an error occurred
  105. if ret is None:
  106. self._reset() # Reset just to be safe
  107. self.seek(0)
  108. msg = "failed to decode next frame in WebP file"
  109. raise EOFError(msg)
  110. # Compute duration
  111. data, timestamp = ret
  112. duration = timestamp - self.__timestamp
  113. self.__timestamp = timestamp
  114. # libwebp gives frame end, adjust to start of frame
  115. timestamp -= duration
  116. return data, timestamp, duration
  117. def _seek(self, frame):
  118. if self.__physical_frame == frame:
  119. return # Nothing to do
  120. if frame < self.__physical_frame:
  121. self._reset() # Rewind to beginning
  122. while self.__physical_frame < frame:
  123. self._get_next() # Advance to the requested frame
  124. def load(self):
  125. if _webp.HAVE_WEBPANIM:
  126. if self.__loaded != self.__logical_frame:
  127. self._seek(self.__logical_frame)
  128. # We need to load the image data for this frame
  129. data, timestamp, duration = self._get_next()
  130. self.info["timestamp"] = timestamp
  131. self.info["duration"] = duration
  132. self.__loaded = self.__logical_frame
  133. # Set tile
  134. if self.fp and self._exclusive_fp:
  135. self.fp.close()
  136. self.fp = BytesIO(data)
  137. self.tile = [("raw", (0, 0) + self.size, 0, self.rawmode)]
  138. return super().load()
  139. def load_seek(self, pos):
  140. pass
  141. def tell(self):
  142. if not _webp.HAVE_WEBPANIM:
  143. return super().tell()
  144. return self.__logical_frame
  145. def _save_all(im, fp, filename):
  146. encoderinfo = im.encoderinfo.copy()
  147. append_images = list(encoderinfo.get("append_images", []))
  148. # If total frame count is 1, then save using the legacy API, which
  149. # will preserve non-alpha modes
  150. total = 0
  151. for ims in [im] + append_images:
  152. total += getattr(ims, "n_frames", 1)
  153. if total == 1:
  154. _save(im, fp, filename)
  155. return
  156. background = (0, 0, 0, 0)
  157. if "background" in encoderinfo:
  158. background = encoderinfo["background"]
  159. elif "background" in im.info:
  160. background = im.info["background"]
  161. if isinstance(background, int):
  162. # GifImagePlugin stores a global color table index in
  163. # info["background"]. So it must be converted to an RGBA value
  164. palette = im.getpalette()
  165. if palette:
  166. r, g, b = palette[background * 3 : (background + 1) * 3]
  167. background = (r, g, b, 255)
  168. else:
  169. background = (background, background, background, 255)
  170. duration = im.encoderinfo.get("duration", im.info.get("duration", 0))
  171. loop = im.encoderinfo.get("loop", 0)
  172. minimize_size = im.encoderinfo.get("minimize_size", False)
  173. kmin = im.encoderinfo.get("kmin", None)
  174. kmax = im.encoderinfo.get("kmax", None)
  175. allow_mixed = im.encoderinfo.get("allow_mixed", False)
  176. verbose = False
  177. lossless = im.encoderinfo.get("lossless", False)
  178. quality = im.encoderinfo.get("quality", 80)
  179. method = im.encoderinfo.get("method", 0)
  180. icc_profile = im.encoderinfo.get("icc_profile") or ""
  181. exif = im.encoderinfo.get("exif", "")
  182. if isinstance(exif, Image.Exif):
  183. exif = exif.tobytes()
  184. xmp = im.encoderinfo.get("xmp", "")
  185. if allow_mixed:
  186. lossless = False
  187. # Sensible keyframe defaults are from gif2webp.c script
  188. if kmin is None:
  189. kmin = 9 if lossless else 3
  190. if kmax is None:
  191. kmax = 17 if lossless else 5
  192. # Validate background color
  193. if (
  194. not isinstance(background, (list, tuple))
  195. or len(background) != 4
  196. or not all(0 <= v < 256 for v in background)
  197. ):
  198. msg = f"Background color is not an RGBA tuple clamped to (0-255): {background}"
  199. raise OSError(msg)
  200. # Convert to packed uint
  201. bg_r, bg_g, bg_b, bg_a = background
  202. background = (bg_a << 24) | (bg_r << 16) | (bg_g << 8) | (bg_b << 0)
  203. # Setup the WebP animation encoder
  204. enc = _webp.WebPAnimEncoder(
  205. im.size[0],
  206. im.size[1],
  207. background,
  208. loop,
  209. minimize_size,
  210. kmin,
  211. kmax,
  212. allow_mixed,
  213. verbose,
  214. )
  215. # Add each frame
  216. frame_idx = 0
  217. timestamp = 0
  218. cur_idx = im.tell()
  219. try:
  220. for ims in [im] + append_images:
  221. # Get # of frames in this image
  222. nfr = getattr(ims, "n_frames", 1)
  223. for idx in range(nfr):
  224. ims.seek(idx)
  225. ims.load()
  226. # Make sure image mode is supported
  227. frame = ims
  228. rawmode = ims.mode
  229. if ims.mode not in _VALID_WEBP_MODES:
  230. alpha = (
  231. "A" in ims.mode
  232. or "a" in ims.mode
  233. or (ims.mode == "P" and "A" in ims.im.getpalettemode())
  234. )
  235. rawmode = "RGBA" if alpha else "RGB"
  236. frame = ims.convert(rawmode)
  237. if rawmode == "RGB":
  238. # For faster conversion, use RGBX
  239. rawmode = "RGBX"
  240. # Append the frame to the animation encoder
  241. enc.add(
  242. frame.tobytes("raw", rawmode),
  243. round(timestamp),
  244. frame.size[0],
  245. frame.size[1],
  246. rawmode,
  247. lossless,
  248. quality,
  249. method,
  250. )
  251. # Update timestamp and frame index
  252. if isinstance(duration, (list, tuple)):
  253. timestamp += duration[frame_idx]
  254. else:
  255. timestamp += duration
  256. frame_idx += 1
  257. finally:
  258. im.seek(cur_idx)
  259. # Force encoder to flush frames
  260. enc.add(None, round(timestamp), 0, 0, "", lossless, quality, 0)
  261. # Get the final output from the encoder
  262. data = enc.assemble(icc_profile, exif, xmp)
  263. if data is None:
  264. msg = "cannot write file as WebP (encoder returned None)"
  265. raise OSError(msg)
  266. fp.write(data)
  267. def _save(im, fp, filename):
  268. lossless = im.encoderinfo.get("lossless", False)
  269. quality = im.encoderinfo.get("quality", 80)
  270. icc_profile = im.encoderinfo.get("icc_profile") or ""
  271. exif = im.encoderinfo.get("exif", b"")
  272. if isinstance(exif, Image.Exif):
  273. exif = exif.tobytes()
  274. if exif.startswith(b"Exif\x00\x00"):
  275. exif = exif[6:]
  276. xmp = im.encoderinfo.get("xmp", "")
  277. method = im.encoderinfo.get("method", 4)
  278. exact = 1 if im.encoderinfo.get("exact") else 0
  279. if im.mode not in _VALID_WEBP_LEGACY_MODES:
  280. im = im.convert("RGBA" if im.has_transparency_data else "RGB")
  281. data = _webp.WebPEncode(
  282. im.tobytes(),
  283. im.size[0],
  284. im.size[1],
  285. lossless,
  286. float(quality),
  287. im.mode,
  288. icc_profile,
  289. method,
  290. exact,
  291. exif,
  292. xmp,
  293. )
  294. if data is None:
  295. msg = "cannot write file as WebP (encoder returned None)"
  296. raise OSError(msg)
  297. fp.write(data)
  298. Image.register_open(WebPImageFile.format, WebPImageFile, _accept)
  299. if SUPPORTED:
  300. Image.register_save(WebPImageFile.format, _save)
  301. if _webp.HAVE_WEBPANIM:
  302. Image.register_save_all(WebPImageFile.format, _save_all)
  303. Image.register_extension(WebPImageFile.format, ".webp")
  304. Image.register_mime(WebPImageFile.format, "image/webp")