diff --git a/cogs/emoji.py b/cogs/emoji.py index ddf7339..e6c43a2 100644 --- a/cogs/emoji.py +++ b/cogs/emoji.py @@ -21,6 +21,7 @@ import discord from discord.ext import commands import utils +import utils.image from utils import errors class Emotes: @@ -144,82 +145,12 @@ class Emotes: # resize_until_small is normally blocking, because wand is. # run_in_executor is magic that makes it non blocking somehow. # also, None as the executor arg means "use the loop's default executor" - image_data = await self.bot.loop.run_in_executor(None, self.resize_until_small, image_data) + image_data = await self.bot.loop.run_in_executor(None, utils.image.resize_until_small, image_data) return await guild.create_custom_emoji( name=name, image=image_data.read(), reason=f'Created by {utils.format_user(self.bot, author_id)}') - @staticmethod - def is_animated(image_data: bytes): - """Return whether the image data is animated, or raise InvalidImageError if it's not an image.""" - type = imghdr.what(None, image_data) - if type == 'gif': - return True - elif type in ('png', 'jpeg'): - return False - else: - raise errors.InvalidImageError - - @classmethod - def size(cls, data: io.BytesIO): - """return the size, in bytes, of the data a BytesIO object represents""" - with cls.preserve_position(data): - data.seek(0, io.SEEK_END) - return data.tell() - - class preserve_position(contextlib.AbstractContextManager): - def __init__(self, fp): - self.fp = fp - self.old_pos = fp.tell() - - def __exit__(self, *excinfo): - self.fp.seek(self.old_pos) - - @classmethod - def resize_until_small(cls, image_data: io.BytesIO): - """If the image_data is bigger than 256KB, resize it until it's not""" - # It's important that we only attempt to resize the image when we have to, - # ie when it exceeds the Discord limit of 256KiB. - # Apparently some <256KiB images become larger when we attempt to resize them, - # so resizing sometimes does more harm than good. - max_resolution = 128 # pixels - size = cls.size(image_data) - while size > 256 * 2**10 and max_resolution >= 32: # don't resize past 32x32 or 256KiB - logger.debug('image size too big (%s bytes)', size) - logger.debug('attempting resize to %s*%s pixels', max_resolution, max_resolution) - image_data = cls.thumbnail(image_data, (max_resolution, max_resolution)) - size = cls.size(image_data) - max_resolution //= 2 - return image_data - - @classmethod - def thumbnail(cls, image_data: io.BytesIO, max_size=(128, 128)): - """Resize an image in place to no more than max_size pixels, preserving aspect ratio.""" - # Credit to @Liara#0001 (ID 136900814408122368) - # https://gitlab.com/Pandentia/element-zero/blob/47bc8eeeecc7d353ec66e1ef5235adab98ca9635/element_zero/cogs/emoji.py#L243-247 - image = Image(blob=image_data) - image.resize(*cls.scale_resolution((image.width, image.height), max_size)) - # we create a new buffer here because there's wand errors otherwise. - # specific error: - # MissingDelegateError: no decode delegate for this image format `' @ error/blob.c/BlobToImage/353 - out = io.BytesIO() - image.save(file=out) - out.seek(0) - return out - - @staticmethod - def scale_resolution(old_res, new_res): - # https://stackoverflow.com/a/6565988 - """Resize a resolution, preserving aspect ratio. Returned w,h will be <= new_res""" - old_width, old_height = old_res - new_width, new_height = new_res - old_ratio = old_width / old_height - new_ratio = new_width / new_height - if new_ratio > old_ratio: - return (old_width * new_height//old_height, new_height) - return new_width, old_height * new_width//old_width - @commands.command() async def remove(self, context, name): emote = await self.disambiguate(context, name) diff --git a/utils/image.py b/utils/image.py new file mode 100644 index 0000000..022c167 --- /dev/null +++ b/utils/image.py @@ -0,0 +1,67 @@ +import io +import logging +import contextlib + +logger = logging.getLogger(__name__) + +try: + from wand.image import Image +except ImportError: + logger.warn('Failed to import wand.image. Image manipulation functions will be unavailable.') + +from utils import errors + +def resize_until_small(image_data: io.BytesIO): + """If the image_data is bigger than 256KB, resize it until it's not""" + # It's important that we only attempt to resize the image when we have to, + # ie when it exceeds the Discord limit of 256KiB. + # Apparently some <256KiB images become larger when we attempt to resize them, + # so resizing sometimes does more harm than good. + max_resolution = 128 # pixels + image_size = size(image_data) + while image_size > 256 * 2**10 and max_resolution >= 32: # don't resize past 32x32 or 256KiB + logger.debug('image size too big (%s bytes)', image_size) + logger.debug('attempting resize to %s*%s pixels', max_resolution, max_resolution) + image_data = thumbnail(image_data, (max_resolution, max_resolution)) + image_size = size(image_data) + max_resolution //= 2 + return image_data + +def size(data: io.BytesIO): + """return the size, in bytes, of the data a BytesIO object represents""" + with preserve_position(data): + data.seek(0, io.SEEK_END) + return data.tell() + +def thumbnail(image_data: io.BytesIO, max_size=(128, 128)): + """Resize an image in place to no more than max_size pixels, preserving aspect ratio.""" + # Credit to @Liara#0001 (ID 136900814408122368) + # https://gitlab.com/Pandentia/element-zero/blob/47bc8eeeecc7d353ec66e1ef5235adab98ca9635/element_zero/cogs/emoji.py#L243-247 + image = Image(blob=image_data) + image.resize(*scale_resolution((image.width, image.height), max_size)) + # we create a new buffer here because there's wand errors otherwise. + # specific error: + # MissingDelegateError: no decode delegate for this image format `' @ error/blob.c/BlobToImage/353 + out = io.BytesIO() + image.save(file=out) + out.seek(0) + return out + +def scale_resolution(old_res, new_res): + # https://stackoverflow.com/a/6565988 + """Resize a resolution, preserving aspect ratio. Returned w,h will be <= new_res""" + old_width, old_height = old_res + new_width, new_height = new_res + old_ratio = old_width / old_height + new_ratio = new_width / new_height + if new_ratio > old_ratio: + return (old_width * new_height//old_height, new_height) + return new_width, old_height * new_width//old_width + +class preserve_position(contextlib.AbstractContextManager): + def __init__(self, fp): + self.fp = fp + self.old_pos = fp.tell() + + def __exit__(self, *excinfo): + self.fp.seek(self.old_pos)