diff options
| author | 2021-09-04 23:55:44 -0400 | |
|---|---|---|
| committer | 2021-09-04 23:55:44 -0400 | |
| commit | 34ecc9e688c6a9a04ef54c2584fe814890d3979a (patch) | |
| tree | d4d11e787389cb1a81aa608f3e2520b2eb8eae06 /bot/exts/evergreen | |
| parent | Move Hanukkah to Holidays folder (diff) | |
Update paths to new resource links
Additionally, this commit fixes an error with the pridepfp command.
The avatar image now uses discord.py's v.20 avatar.url instead
of avatar_url
Diffstat (limited to 'bot/exts/evergreen')
| -rw-r--r-- | bot/exts/evergreen/avatar_modification/__init__.py | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/avatar_modification/_effects.py | 296 | ||||
| -rw-r--r-- | bot/exts/evergreen/avatar_modification/avatar_modify.py | 372 |
3 files changed, 0 insertions, 668 deletions
diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py deleted file mode 100644 index e69de29b..00000000 --- a/bot/exts/evergreen/avatar_modification/__init__.py +++ /dev/null diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py deleted file mode 100644 index df741973..00000000 --- a/bot/exts/evergreen/avatar_modification/_effects.py +++ /dev/null @@ -1,296 +0,0 @@ -import math -import random -from io import BytesIO -from pathlib import Path -from typing import Callable, Optional - -import discord -from PIL import Image, ImageDraw, ImageOps - -from bot.constants import Colours - - -class PfpEffects: - """ - Implements various image modifying effects, for the PfpModify cog. - - All of these functions are slow, and blocking, so they should be ran in executors. - """ - - @staticmethod - def apply_effect(image_bytes: bytes, effect: Callable, filename: str, *args) -> discord.File: - """Applies the given effect to the image passed to it.""" - im = Image.open(BytesIO(image_bytes)) - im = im.convert("RGBA") - im = im.resize((1024, 1024)) - im = effect(im, *args) - - bufferedio = BytesIO() - im.save(bufferedio, format="PNG") - bufferedio.seek(0) - - return discord.File(bufferedio, filename=filename) - - @staticmethod - def closest(x: tuple[int, int, int]) -> tuple[int, int, int]: - """ - Finds the closest "easter" colour to a given pixel. - - Returns a merge between the original colour and the closest colour. - """ - r1, g1, b1 = x - - def distance(point: tuple[int, int, int]) -> int: - """Finds the difference between a pastel colour and the original pixel colour.""" - r2, g2, b2 = point - return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 - - closest_colours = sorted(Colours.easter_like_colours, key=distance) - r2, g2, b2 = closest_colours[0] - r = (r1 + r2) // 2 - g = (g1 + g2) // 2 - b = (b1 + b2) // 2 - - return r, g, b - - @staticmethod - def crop_avatar_circle(avatar: Image.Image) -> Image.Image: - """This crops the avatar given into a circle.""" - mask = Image.new("L", avatar.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + avatar.size, fill=255) - avatar.putalpha(mask) - return avatar - - @staticmethod - def crop_ring(ring: Image.Image, px: int) -> Image.Image: - """This crops the given ring into a circle.""" - mask = Image.new("L", ring.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + ring.size, fill=255) - draw.ellipse((px, px, 1024-px, 1024-px), fill=0) - ring.putalpha(mask) - return ring - - @staticmethod - def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image: - """Applies the given pride effect to the given image.""" - image = PfpEffects.crop_avatar_circle(image) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = PfpEffects.crop_ring(ring, pixels) - - image.alpha_composite(ring, (0, 0)) - return image - - @staticmethod - def eight_bitify_effect(image: Image.Image) -> Image.Image: - """ - Applies the 8bit effect to the given image. - - This is done by reducing the image to 32x32 and then back up to 1024x1024. - We then quantize the image before returning too. - """ - image = image.resize((32, 32), resample=Image.NEAREST) - image = image.resize((1024, 1024), resample=Image.NEAREST) - return image.quantize() - - @staticmethod - def flip_effect(image: Image.Image) -> Image.Image: - """ - Flips the image horizontally. - - This is done by just using ImageOps.mirror(). - """ - image = ImageOps.mirror(image) - - return image - - @staticmethod - def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image: - """ - Applies the easter effect to the given image. - - This is done by getting the closest "easter" colour to each pixel and changing the colour - to the half-way RGB value. - - We also then add an overlay image on top in middle right, a chocolate bunny by default. - """ - if overlay_image: - ratio = 64 / overlay_image.height - overlay_image = overlay_image.resize(( - round(overlay_image.width * ratio), - round(overlay_image.height * ratio) - )) - overlay_image = overlay_image.convert("RGBA") - else: - overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) - - alpha = image.getchannel("A").getdata() - image = image.convert("RGB") - image = ImageOps.posterize(image, 6) - - data = image.getdata() - data_set = set(data) - easterified_data_set = {} - - for x in data_set: - easterified_data_set[x] = PfpEffects.closest(x) - new_pixel_data = [ - (*easterified_data_set[x], alpha[i]) - if x in easterified_data_set else x - for i, x in enumerate(data) - ] - - im = Image.new("RGBA", image.size) - im.putdata(new_pixel_data) - im.alpha_composite( - overlay_image, - (im.width - overlay_image.width, (im.height - overlay_image.height) // 2) - ) - return im - - @staticmethod - def split_image(img: Image.Image, squares: int) -> list: - """ - Split an image into a selection of squares, specified by the squares argument. - - Explanation: - - 1. It gets the width and the height of the Image passed to the function. - - 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say - 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed - to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it - as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows - and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the - program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed. - - 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares, - the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares) - passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note: - x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width - in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width. - x_frac and y_frac are width and height of a single square (split piece). - - 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial - square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding - value to right and bottom, it's creating the initial square (split piece). - - 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is - True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between - them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list - where it stores them. The program keeps repeating this process till all 25 squares get added to the list. - - 6. It returns new_imgs, a list of squares (split pieces). - """ - width, heigth = img.size - - xy = math.sqrt(squares) - - x_frac = width // xy - y_frac = heigth // xy - - left, top, right, bottom, = 0, 0, x_frac, y_frac - - new_imgs = [] - - for index in range(squares): - new_img = img.crop((left, top, right, bottom)) - new_imgs.append(new_img) - - if (index + 1) % xy == 0: - top += y_frac - bottom += y_frac - left = 0 - right = x_frac - else: - left += x_frac - right += x_frac - - return new_imgs - - @staticmethod - def join_images(images: list[Image.Image]) -> Image.Image: - """ - Stitches all the image squares into a new image. - - Explanation: - - 1. Shuffles the passed images to randomize the pieces. - - 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width - and single_height as the square's height. - - 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier. - Program then proceeds to calculate total height and width of the new image that it's creating using the same - multiplier. - - 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width - and total_height. - - 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position - squares (split pieces) onto the new_image canvas. - - 6. Similar to how in the split_image function, the program gets the root of number of images in the list. - In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the - list that it got the square of here. - - 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly - position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece) - onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get - pasted in the same spot and the positioning would move accordingly. It makes sure to increase the - width_multiplier before the check, which checks if the end of a row has been reached, - - (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of - the row). If the check returns True, the height gets increased by a single square's (split piece) height to - lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will - then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were - positioned accordingly. - - 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the - original image - user's PFP. - """ - random.shuffle(images) - single_img = images[0] - - single_wdith = single_img.size[0] - single_height = single_img.size[1] - - multiplier = int(math.sqrt(len(images))) - - total_width = multiplier * single_wdith - total_height = multiplier * single_height - - new_image = Image.new("RGBA", (total_width, total_height), (250, 250, 250)) - - width_multiplier = 0 - height = 0 - - squares = math.sqrt(len(images)) - - for index, image in enumerate(images): - width = single_wdith * width_multiplier - - new_image.paste(image, (width, height)) - - width_multiplier += 1 - - if (index + 1) % squares == 0: - width_multiplier = 0 - height += single_height - - return new_image - - @staticmethod - def mosaic_effect(image: Image.Image, squares: int) -> Image.Image: - """ - Applies a mosaic effect to the given image. - - The "squares" argument specifies the number of squares to split - the image into. This should be a square number. - """ - img_squares = PfpEffects.split_image(image, squares) - new_img = PfpEffects.join_images(img_squares) - - return new_img diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py deleted file mode 100644 index 18202902..00000000 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ /dev/null @@ -1,372 +0,0 @@ -import asyncio -import json -import logging -import math -import string -import unicodedata -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from typing import Callable, Optional, TypeVar, Union - -import discord -from discord.ext import commands - -from bot.bot import Bot -from bot.constants import Colours, Emojis -from bot.exts.evergreen.avatar_modification._effects import PfpEffects -from bot.utils.extensions import invoke_help_command -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - -_EXECUTOR = ThreadPoolExecutor(10) - -FILENAME_STRING = "{effect}_{author}.png" - -MAX_SQUARES = 10_000 - -T = TypeVar("T") - -GENDER_OPTIONS = json.loads(Path("bot/resources/pride/gender_options.json").read_text("utf8")) - - -async def in_executor(func: Callable[..., T], *args) -> T: - """ - Runs the given synchronous function `func` in an executor. - - This is useful for running slow, blocking code within async - functions, so that they don't block the bot. - """ - log.trace(f"Running {func.__name__} in an executor.") - loop = asyncio.get_event_loop() - return await loop.run_in_executor(_EXECUTOR, func, *args) - - -def file_safe_name(effect: str, display_name: str) -> str: - """Returns a file safe filename based on the given effect and display name.""" - valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}" - - file_name = FILENAME_STRING.format(effect=effect, author=display_name) - - # Replace spaces - file_name = file_name.replace(" ", "_") - - # Normalize unicode characters - cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode() - - # Remove invalid filename characters - cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars) - return cleaned_filename - - -class AvatarModify(commands.Cog): - """Various commands for users to apply affects to their own avatars.""" - - def __init__(self, bot: Bot): - self.bot = bot - - async def _fetch_user(self, user_id: int) -> Optional[discord.User]: - """ - Fetches a user and handles errors. - - This helper function is required as the member cache doesn't always have the most up to date - profile picture. This can lead to errors if the image is deleted from the Discord CDN. - fetch_member can't be used due to the avatar url being part of the user object, and - some weird caching that D.py does - """ - try: - user = await self.bot.fetch_user(user_id) - except discord.errors.NotFound: - log.debug(f"User {user_id} could not be found.") - return None - except discord.HTTPException: - log.exception(f"Exception while trying to retrieve user {user_id} from Discord.") - return None - - return user - - @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod")) - async def avatar_modify(self, ctx: commands.Context) -> None: - """Groups all of the pfp modifying commands to allow a single concurrency limit.""" - if not ctx.invoked_subcommand: - await invoke_help_command(ctx) - - @avatar_modify.command(name="8bitify", root_aliases=("8bitify",)) - async def eightbit_command(self, ctx: commands.Context) -> None: - """Pixelates your avatar and changes the palette to an 8bit one.""" - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - image_bytes = await user.display_avatar.replace(size=1024).read() - file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.eight_bitify_effect, - file_name - ) - - embed = discord.Embed( - title="Your 8-bit avatar", - description="Here is your avatar. I think it looks all cool and 'retro'." - ) - - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(embed=embed, file=file) - - @avatar_modify.command(name="reverse", root_aliases=("reverse",)) - async def reverse(self, ctx: commands.Context, *, text: Optional[str]) -> None: - """ - Reverses the sent text. - - If no text is provided, the user's profile picture will be reversed. - """ - if text: - await ctx.send(f"> {text[::-1]}", allowed_mentions=discord.AllowedMentions.none()) - return - - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - image_bytes = await user.display_avatar.replace(size=1024).read() - filename = file_safe_name("reverse_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.flip_effect, - filename - ) - - embed = discord.Embed( - title="Your reversed avatar.", - description="Here is your reversed avatar. I think it is a spitting image of you." - ) - - embed.set_image(url=f"attachment://{filename}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(embed=embed, file=file) - - @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify")) - async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: - """ - This "Easterifies" the user's avatar. - - Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. - If colours are not given, a nice little chocolate bunny will sit in the corner. - Colours are split by spaces, unless you wrap the colour name in double quotes. - Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. - """ - async def send(*args, **kwargs) -> str: - """ - This replaces the original ctx.send. - - When invoking the egg decorating command, the egg itself doesn't print to to the channel. - Returns the message content so that if any errors occur, the error message can be output. - """ - if args: - return args[0] - - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - egg = None - if colours: - send_message = ctx.send - ctx.send = send # Assigns ctx.send to a fake send - egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) - if isinstance(egg, str): # When an error message occurs in eggdecorate. - await send_message(egg) - return - ctx.send = send_message # Reassigns ctx.send - - image_bytes = await user.display_avatar.replace(size=256).read() - file_name = file_safe_name("easterified_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.easterify_effect, - file_name, - egg - ) - - embed = discord.Embed( - title="Your Lovely Easterified Avatar!", - description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - @staticmethod - async def send_pride_image( - ctx: commands.Context, - image_bytes: bytes, - pixels: int, - flag: str, - option: str - ) -> None: - """Gets and sends the image in an embed. Used by the pride commands.""" - async with ctx.typing(): - file_name = file_safe_name("pride_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - PfpEffects.pridify_effect, - file_name, - pixels, - flag - ) - - embed = discord.Embed( - title="Your Lovely Pride Avatar!", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) - await ctx.send(file=file, embed=embed) - - @avatar_modify.group( - aliases=("avatarpride", "pridepfp", "prideprofile"), - root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"), - invoke_without_command=True - ) - async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: - """ - This surrounds an avatar with a border of a specified LGBT flag. - - This defaults to the LGBT rainbow flag if none is given. - The amount of pixels can be given which determines the thickness of the flag border. - This has a maximum of 512px and defaults to a 64px border. - The full image is 1024x1024. - """ - option = option.lower() - pixels = max(0, min(512, pixels)) - flag = GENDER_OPTIONS.get(option) - if flag is None: - await ctx.send("I don't have that flag!") - return - - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - image_bytes = await user.display_avatar.replace(size=1024).read() - await self.send_pride_image(ctx, image_bytes, pixels, flag, option) - - @prideavatar.command() - async def flags(self, ctx: commands.Context) -> None: - """This lists the flags that can be used with the prideavatar command.""" - choices = sorted(set(GENDER_OPTIONS.values())) - options = "• " + "\n• ".join(choices) - embed = discord.Embed( - title="I have the following flags:", - description=options, - colour=Colours.soft_red - ) - await ctx.send(embed=embed) - - @avatar_modify.command( - aliases=("savatar", "spookify"), - root_aliases=("spookyavatar", "spookify", "savatar"), - brief="Spookify an user's avatar." - ) - async def spookyavatar(self, ctx: commands.Context) -> None: - """This "spookifies" the user's avatar, with a random *spooky* effect.""" - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - async with ctx.typing(): - image_bytes = await user.display_avatar.replace(size=1024).read() - - file_name = file_safe_name("spooky_avatar", ctx.author.display_name) - - file = await in_executor( - PfpEffects.apply_effect, - image_bytes, - spookifications.get_random_effect, - file_name - ) - - embed = discord.Embed( - title="Is this you or am I just really paranoid?", - colour=Colours.soft_red - ) - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - @avatar_modify.command(name="mosaic", root_aliases=("mosaic",)) - async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None: - """Splits your avatar into x squares, randomizes them and stitches them back into a new image!""" - async with ctx.typing(): - user = await self._fetch_user(ctx.author.id) - if not user: - await ctx.send(f"{Emojis.cross_mark} Could not get user info.") - return - - if not 1 <= squares <= MAX_SQUARES: - raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") - - sqrt = math.sqrt(squares) - - if not sqrt.is_integer(): - squares = math.ceil(sqrt) ** 2 # Get the next perfect square - - file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) - - img_bytes = await user.display_avatar.replace(size=1024).read() - - file = await in_executor( - PfpEffects.apply_effect, - img_bytes, - PfpEffects.mosaic_effect, - file_name, - squares, - ) - - if squares == 1: - title = "Hooh... that was a lot of work" - description = "I present to you... Yourself!" - elif squares == MAX_SQUARES: - title = "Testing the limits I see..." - description = "What a masterpiece. :star:" - else: - title = "Your mosaic avatar" - description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares." - - embed = discord.Embed( - title=title, - description=description, - colour=Colours.blue - ) - - embed.set_image(url=f"attachment://{file_name}") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.display_avatar.url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: Bot) -> None: - """Load the AvatarModify cog.""" - bot.add_cog(AvatarModify(bot)) |