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.

213 lines
7.8 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 os.path
  19. import subprocess
  20. import json
  21. import tempfile
  22. import mimetypes
  23. try:
  24. import magic
  25. except ImportError:
  26. print("[Warning] Magic is not installed, using file extensions to guess mime types")
  27. magic = None
  28. from PIL import Image, ImageSequence
  29. from . import matrix
  30. open_utf8 = partial(open, encoding='UTF-8')
  31. def guess_mime(data: bytes) -> str:
  32. mime = None
  33. if magic:
  34. try:
  35. return magic.Magic(mime=True).from_buffer(data)
  36. except Exception:
  37. pass
  38. else:
  39. with tempfile.NamedTemporaryFile() as temp:
  40. temp.write(data)
  41. temp.close()
  42. mime, _ = mimetypes.guess_type(temp.name)
  43. return mime or "image/png"
  44. def video_to_gif(data: bytes, mime: str) -> bytes:
  45. ext = mimetypes.guess_extension(mime)
  46. if mime.startswith("video/"):
  47. # run ffmpeg to fix duration
  48. with tempfile.NamedTemporaryFile(suffix=ext) as temp:
  49. temp.write(data)
  50. temp.flush()
  51. with tempfile.NamedTemporaryFile(suffix=ext) as temp_fixed:
  52. print(".", end="", flush=True)
  53. result = subprocess.run(["ffmpeg", "-y", "-i", temp.name, "-codec", "copy", temp_fixed.name],
  54. capture_output=True)
  55. if result.returncode != 0:
  56. raise RuntimeError(f"Run ffmpeg failed with code {result.returncode}, Error occurred:\n{result.stderr}")
  57. temp_fixed.seek(0)
  58. data = temp_fixed.read()
  59. with tempfile.NamedTemporaryFile(suffix=ext) as temp:
  60. temp.write(data)
  61. temp.flush()
  62. with tempfile.NamedTemporaryFile(suffix=".gif") as gif:
  63. from moviepy.editor import VideoFileClip
  64. clip = VideoFileClip(temp.name)
  65. clip.write_gif(gif.name, logger=None)
  66. gif.seek(0)
  67. return gif.read()
  68. def opermize_gif(data: bytes) -> bytes:
  69. with tempfile.NamedTemporaryFile() as gif:
  70. gif.write(data)
  71. gif.flush()
  72. # use gifsicle to optimize gif
  73. result = subprocess.run(["gifsicle", "--batch", "--optimize=3", "--colors=256", gif.name],
  74. capture_output=True)
  75. if result.returncode != 0:
  76. raise RuntimeError(f"Run gifsicle failed with code {result.returncode}, Error occurred:\n{result.stderr}")
  77. gif.seek(0)
  78. return gif.read()
  79. def _convert_image(data: bytes, mimetype: str) -> (bytes, int, int):
  80. image: Image.Image = Image.open(BytesIO(data))
  81. new_file = BytesIO()
  82. suffix = mimetypes.guess_extension(mimetype)
  83. if suffix:
  84. suffix = suffix[1:]
  85. # Determine if the image is a GIF
  86. is_animated = getattr(image, "is_animated", False)
  87. if is_animated:
  88. frames = [frame.convert("RGBA") for frame in ImageSequence.Iterator(image)]
  89. # Save the new GIF
  90. frames[0].save(
  91. new_file,
  92. format='GIF',
  93. save_all=True,
  94. append_images=frames[1:],
  95. loop=image.info.get('loop', 0), # Default loop to 0 if not present
  96. duration=image.info.get('duration', 100), # Set a default duration if not present
  97. transparency=image.info.get('transparency', 255), # Default to 255 if transparency is not present
  98. disposal=image.info.get('disposal', 2) # Default to disposal method 2 (restore to background)
  99. )
  100. # Get the size of the first frame to determine resizing
  101. w, h = frames[0].size
  102. else:
  103. image = image.convert("RGBA")
  104. image.save(new_file, format=suffix)
  105. w, h = image.size
  106. if w > 256 or h > 256:
  107. # Set the width and height to lower values so clients wouldn't show them as huge images
  108. if w > h:
  109. h = int(h / (w / 256))
  110. w = 256
  111. else:
  112. w = int(w / (h / 256))
  113. h = 256
  114. return new_file.getvalue(), w, h
  115. def _convert_sticker(data: bytes) -> (bytes, str, int, int):
  116. mimetype = guess_mime(data)
  117. if mimetype.startswith("video/"):
  118. data = video_to_gif(data, mimetype)
  119. print(".", end="", flush=True)
  120. mimetype = "image/gif"
  121. elif mimetype.startswith("application/gzip"):
  122. print(".", end="", flush=True)
  123. # unzip file
  124. import gzip
  125. with gzip.open(BytesIO(data), "rb") as f:
  126. data = f.read()
  127. mimetype = guess_mime(data)
  128. suffix = mimetypes.guess_extension(mimetype)
  129. with tempfile.NamedTemporaryFile(suffix=suffix) as temp:
  130. temp.write(data)
  131. with tempfile.NamedTemporaryFile(suffix=".gif") as gif:
  132. # run lottie_convert.py input output
  133. print(".", end="", flush=True)
  134. import subprocess
  135. cmd = ["lottie_convert.py", temp.name, gif.name]
  136. result = subprocess.run(cmd, capture_output=True, text=True)
  137. if result.returncode != 0:
  138. raise RuntimeError(f"Run {cmd} failed with code {retcode}, Error occurred:\n{result.stderr}")
  139. gif.seek(0)
  140. data = gif.read()
  141. mimetype = "image/gif"
  142. rlt = _convert_image(data, mimetype)
  143. data = rlt[0]
  144. if mimetype == "image/gif":
  145. print(".", end="", flush=True)
  146. data = opermize_gif(data)
  147. return data, mimetype, rlt[1], rlt[2]
  148. def convert_sticker(data: bytes) -> (bytes, str, int, int):
  149. try:
  150. return _convert_sticker(data)
  151. except Exception as e:
  152. mimetype = guess_mime(data)
  153. print(f"Error converting image, mimetype: {mimetype}")
  154. ext = mimetypes.guess_extension(mimetype)
  155. with tempfile.NamedTemporaryFile(delete=False, suffix=ext) as temp:
  156. temp.write(data)
  157. print(f"Saved to {temp.name}")
  158. raise e
  159. def add_to_index(name: str, output_dir: str) -> None:
  160. index_path = os.path.join(output_dir, "index.json")
  161. try:
  162. with open_utf8(index_path) as index_file:
  163. index_data = json.load(index_file)
  164. except (FileNotFoundError, json.JSONDecodeError):
  165. index_data = {"packs": []}
  166. if "homeserver_url" not in index_data and matrix.homeserver_url:
  167. index_data["homeserver_url"] = matrix.homeserver_url
  168. if name not in index_data["packs"]:
  169. index_data["packs"].append(name)
  170. with open_utf8(index_path, "w") as index_file:
  171. json.dump(index_data, index_file, indent=" ")
  172. print(f"Added {name} to {index_path}")
  173. def make_sticker(mxc: str, width: int, height: int, size: int,
  174. mimetype: str, body: str = "") -> matrix.StickerInfo:
  175. return {
  176. "body": body,
  177. "url": mxc,
  178. "info": {
  179. "w": width,
  180. "h": height,
  181. "size": size,
  182. "mimetype": mimetype,
  183. # Element iOS compatibility hack
  184. "thumbnail_url": mxc,
  185. "thumbnail_info": {
  186. "w": width,
  187. "h": height,
  188. "size": size,
  189. "mimetype": mimetype,
  190. },
  191. },
  192. "msgtype": "m.sticker",
  193. }