You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

314 lines
11 KiB

  1. # maunium-stickerpicker - A fast and simple Matrix sticker picker widget.
  2. # Copyright (C) 2020 Tulir Asokan
  3. #
  4. # This program is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU Affero General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU Affero General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU Affero General Public License
  15. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. from functools import partial
  17. from io import BytesIO
  18. import numpy as np
  19. import os.path
  20. import subprocess
  21. import json
  22. import tempfile
  23. import mimetypes
  24. try:
  25. import magic
  26. except ImportError:
  27. print("[Warning] Magic is not installed, using file extensions to guess mime types")
  28. magic = None
  29. from PIL import Image, ImageSequence, ImageFilter
  30. from . import matrix
  31. open_utf8 = partial(open, encoding='UTF-8')
  32. def guess_mime(data: bytes) -> str:
  33. mime = None
  34. if magic:
  35. try:
  36. return magic.Magic(mime=True).from_buffer(data)
  37. except Exception:
  38. pass
  39. else:
  40. with tempfile.NamedTemporaryFile() as temp:
  41. temp.write(data)
  42. temp.close()
  43. mime, _ = mimetypes.guess_type(temp.name)
  44. return mime or "image/png"
  45. def _video_to_webp(data: bytes) -> bytes:
  46. mime = guess_mime(data)
  47. ext = mimetypes.guess_extension(mime)
  48. with tempfile.NamedTemporaryFile(suffix=ext) as video:
  49. video.write(data)
  50. video.flush()
  51. with tempfile.NamedTemporaryFile(suffix=".webp") as webp:
  52. print(".", end="", flush=True)
  53. ffmpeg_encoder_args = []
  54. if mime == "video/webm":
  55. encode = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", video.name], capture_output=True, text=True).stdout.strip()
  56. ffmpeg_encoder = None
  57. if encode == "vp8":
  58. ffmpeg_encoder = "libvpx"
  59. elif encode == "vp9":
  60. ffmpeg_encoder = "libvpx-vp9"
  61. if ffmpeg_encoder:
  62. ffmpeg_encoder_args = ["-c:v", ffmpeg_encoder]
  63. result = subprocess.run(["ffmpeg", "-y", "-threads", "auto", *ffmpeg_encoder_args, "-i", video.name, "-lossless", "1", webp.name],
  64. capture_output=True)
  65. if result.returncode != 0:
  66. raise RuntimeError(f"Run ffmpeg failed with code {result.returncode}, Error occurred:\n{result.stderr}")
  67. webp.seek(0)
  68. return webp.read()
  69. def video_to_webp(data: bytes) -> bytes:
  70. mime = guess_mime(data)
  71. ext = mimetypes.guess_extension(mime)
  72. # run ffmpeg to fix duration
  73. with tempfile.NamedTemporaryFile(suffix=ext) as temp:
  74. temp.write(data)
  75. temp.flush()
  76. with tempfile.NamedTemporaryFile(suffix=ext) as temp_fixed:
  77. print(".", end="", flush=True)
  78. result = subprocess.run(["ffmpeg", "-y", "-threads", "auto", "-i", temp.name, "-codec", "copy", temp_fixed.name],
  79. capture_output=True)
  80. if result.returncode != 0:
  81. raise RuntimeError(f"Run ffmpeg failed with code {result.returncode}, Error occurred:\n{result.stderr}")
  82. temp_fixed.seek(0)
  83. data = temp_fixed.read()
  84. return _video_to_webp(data)
  85. def process_frame(frame):
  86. """
  87. Process GIF frame, repair edges, ensure no white or semi-transparent pixels, while keeping color information intact.
  88. """
  89. frame = frame.convert('RGBA')
  90. # Decompose Alpha channel
  91. alpha = frame.getchannel('A')
  92. # Process Alpha channel with threshold, remove semi-transparent pixels
  93. # Threshold can be adjusted as needed (0-255), 128 is the middle value
  94. threshold = 128
  95. alpha = alpha.point(lambda x: 255 if x >= threshold else 0)
  96. # Process Alpha channel with MinFilter, remove edge noise
  97. alpha = alpha.filter(ImageFilter.MinFilter(3))
  98. # Process Alpha channel with MaxFilter, repair edges
  99. alpha = alpha.filter(ImageFilter.MaxFilter(3))
  100. # Apply processed Alpha channel back to image
  101. frame.putalpha(alpha)
  102. return frame
  103. def webp_to_others(data: bytes, mimetype: str) -> bytes:
  104. with tempfile.NamedTemporaryFile(suffix=".webp") as webp:
  105. webp.write(data)
  106. webp.flush()
  107. ext = mimetypes.guess_extension(mimetype)
  108. with tempfile.NamedTemporaryFile(suffix=ext) as img:
  109. print(".", end="", flush=True)
  110. im = Image.open(webp.name)
  111. im.info.pop('background', None)
  112. if mimetype == "image/gif":
  113. frames = []
  114. duration = []
  115. for frame in ImageSequence.Iterator(im):
  116. frame = process_frame(frame)
  117. frames.append(frame)
  118. duration.append(frame.info.get('duration', 100))
  119. frames[0].save(img.name, save_all=True, lossless=True, quality=100, method=6,
  120. append_images=frames[1:], loop=0, duration=duration, disposal=2)
  121. else:
  122. im.save(img.name, save_all=True, lossless=True, quality=100, method=6)
  123. img.seek(0)
  124. return img.read()
  125. def is_uniform_animated_webp(data: bytes) -> bool:
  126. img = Image.open(BytesIO(data))
  127. if img.n_frames <= 1:
  128. return False
  129. first_frame = np.array(img)
  130. for frame_number in range(1, img.n_frames):
  131. img.seek(frame_number)
  132. current_frame = np.array(img)
  133. if not np.array_equal(first_frame, current_frame):
  134. return False
  135. return True
  136. def webp_to_gif_or_png(data: bytes) -> bytes:
  137. # check if the webp is animated
  138. image: Image.Image = Image.open(BytesIO(data))
  139. is_animated = getattr(image, "is_animated", False)
  140. if is_animated and not is_uniform_animated_webp(data):
  141. return webp_to_others(data, "image/gif")
  142. else:
  143. # convert to png
  144. return webp_to_others(data, "image/png")
  145. def opermize_gif(data: bytes) -> bytes:
  146. with tempfile.NamedTemporaryFile() as gif:
  147. gif.write(data)
  148. gif.flush()
  149. # use gifsicle to optimize gif
  150. result = subprocess.run(["gifsicle", "--batch", "--optimize=3", "--colors=256", gif.name],
  151. capture_output=True)
  152. if result.returncode != 0:
  153. raise RuntimeError(f"Run gifsicle failed with code {result.returncode}, Error occurred:\n{result.stderr}")
  154. gif.seek(0)
  155. return gif.read()
  156. def _convert_image(data: bytes, mimetype: str) -> (bytes, int, int):
  157. image: Image.Image = Image.open(BytesIO(data))
  158. new_file = BytesIO()
  159. # Determine if the image is a GIF
  160. is_animated = getattr(image, "is_animated", False)
  161. if is_animated:
  162. frames = [frame.convert("RGBA") for frame in ImageSequence.Iterator(image)]
  163. # Save the new GIF
  164. frames[0].save(
  165. new_file,
  166. format='GIF',
  167. save_all=True,
  168. append_images=frames[1:],
  169. loop=image.info.get('loop', 0), # Default loop to 0 if not present
  170. duration=image.info.get('duration', 100), # Set a default duration if not present
  171. disposal=image.info.get('disposal', 2) # Default to disposal method 2 (restore to background)
  172. )
  173. # Get the size of the first frame to determine resizing
  174. w, h = frames[0].size
  175. else:
  176. suffix = mimetypes.guess_extension(mimetype)
  177. if suffix:
  178. suffix = suffix[1:]
  179. image = image.convert("RGBA")
  180. image.save(new_file, format=suffix)
  181. w, h = image.size
  182. if w > 256 or h > 256:
  183. # Set the width and height to lower values so clients wouldn't show them as huge images
  184. if w > h:
  185. h = int(h / (w / 256))
  186. w = 256
  187. else:
  188. w = int(w / (h / 256))
  189. h = 256
  190. return new_file.getvalue(), w, h
  191. def _convert_sticker(data: bytes) -> (bytes, str, int, int):
  192. mimetype = guess_mime(data)
  193. if mimetype.startswith("video/"):
  194. data = video_to_webp(data)
  195. print(".", end="", flush=True)
  196. elif mimetype.startswith("application/gzip"):
  197. print(".", end="", flush=True)
  198. # unzip file
  199. import gzip
  200. with gzip.open(BytesIO(data), "rb") as f:
  201. data = f.read()
  202. mimetype = guess_mime(data)
  203. suffix = mimetypes.guess_extension(mimetype)
  204. with tempfile.NamedTemporaryFile(suffix=suffix) as temp:
  205. temp.write(data)
  206. with tempfile.NamedTemporaryFile(suffix=".webp") as gif:
  207. # run lottie_convert.py input output
  208. print(".", end="", flush=True)
  209. import subprocess
  210. cmd = ["lottie_convert.py", temp.name, gif.name]
  211. result = subprocess.run(cmd, capture_output=True, text=True)
  212. retcode = result.returncode
  213. if retcode != 0:
  214. raise RuntimeError(f"Run {cmd} failed with code {retcode}, Error occurred:\n{result.stderr}")
  215. gif.seek(0)
  216. data = gif.read()
  217. mimetype = guess_mime(data)
  218. if mimetype == "image/webp":
  219. data = webp_to_gif_or_png(data)
  220. mimetype = guess_mime(data)
  221. rlt = _convert_image(data, mimetype)
  222. data = rlt[0]
  223. if mimetype == "image/gif":
  224. print(".", end="", flush=True)
  225. data = opermize_gif(data)
  226. return data, mimetype, rlt[1], rlt[2]
  227. def convert_sticker(data: bytes) -> (bytes, str, int, int):
  228. try:
  229. return _convert_sticker(data)
  230. except Exception as e:
  231. mimetype = guess_mime(data)
  232. print(f"Error converting image, mimetype: {mimetype}")
  233. ext = mimetypes.guess_extension(mimetype)
  234. with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp:
  235. temp.write(data)
  236. print(f"Saved to {temp.name}")
  237. raise e
  238. def add_to_index(name: str, output_dir: str) -> None:
  239. index_path = os.path.join(output_dir, "index.json")
  240. try:
  241. with open_utf8(index_path) as index_file:
  242. index_data = json.load(index_file)
  243. except (FileNotFoundError, json.JSONDecodeError):
  244. index_data = {"packs": []}
  245. if "homeserver_url" not in index_data and matrix.homeserver_url:
  246. index_data["homeserver_url"] = matrix.homeserver_url
  247. if name not in index_data["packs"]:
  248. index_data["packs"].append(name)
  249. with open_utf8(index_path, "w") as index_file:
  250. json.dump(index_data, index_file, indent=" ")
  251. print(f"Added {name} to {index_path}")
  252. def make_sticker(mxc: str, width: int, height: int, size: int,
  253. mimetype: str, body: str = "") -> matrix.StickerInfo:
  254. return {
  255. "body": body,
  256. "url": mxc,
  257. "info": {
  258. "w": width,
  259. "h": height,
  260. "size": size,
  261. "mimetype": mimetype,
  262. # Element iOS compatibility hack
  263. "thumbnail_url": mxc,
  264. "thumbnail_info": {
  265. "w": width,
  266. "h": height,
  267. "size": size,
  268. "mimetype": mimetype,
  269. },
  270. },
  271. "msgtype": "m.sticker",
  272. }