diff --git a/levelup/commands/owner.py b/levelup/commands/owner.py index 8ac4e88..dc07aba 100644 --- a/levelup/commands/owner.py +++ b/levelup/commands/owner.py @@ -297,3 +297,23 @@ class Owner(MixinMeta): file = await asyncio.to_thread(_run) await ctx.send(file=file) + + @lvlowner.command(name="skia") + async def toggle_skia(self, ctx: commands.Context, enabled: bool = None): + """Toggle the use of Skia for image generation. + + This will enable higher quality image generation with better performance, + but requires the skia-python package to be installed. + + If no value is provided, shows the current setting. + """ + if enabled is None: + current = await self.db.get_use_skia() + return await ctx.send( + f"Skia image generation is currently {'enabled' if current else 'disabled'}." + ) + + await self.db.set_use_skia(enabled) + await ctx.send( + f"Skia image generation has been {'enabled' if enabled else 'disabled'}." + ) diff --git a/levelup/common/settings.py b/levelup/common/settings.py new file mode 100644 index 0000000..c69e4b9 --- /dev/null +++ b/levelup/common/settings.py @@ -0,0 +1,107 @@ +"""Settings for the LevelUp cog.""" + +import typing as t +from pathlib import Path + +from redbot.core import Config +from redbot.core.bot import Red + +from ..generator import imgtools + +Cog = t.TypeVar("Cog") +GuildSettings = t.TypeVar("GuildSettings") + + +class Settings: + """Settings for the LevelUp cog.""" + + def __init__(self, bot: Red, cog: Cog) -> None: + self.bot = bot + self.cog = cog + self.data = Config.get_conf(cog, identifier=1234567890) + + default_global = { + "render_gifs": True, + "use_skia": True, # New setting to control Skia usage + "external_api_url": None, + "internal_api_port": None, + } + + default_guild = { + "active": False, + "message_xp": (3, 6), # Min/Max XP per message + "voice_xp": (3, 6), # Min/Max XP per minute in voice + "xp_cooldown": 60, + "ignore_voice_users": [], + "ignore_voice_channels": [], + "ignore_text_users": [], + "ignore_text_channels": [], + "ignored_roles": [], + "xp_roles": {}, + "levelup_messages": [], + "levelup_channel": None, + "levelup_ping": False, + "mention": True, + "stacking": False, + "stack_roles": False, + "remove_role": False, + "test_message": None, + "show_prestige": False, + "prestige_level": 100, + "prestige_role": None, + "prestige_req": None, + "prestige_cost": 0, + "base_xp": 100, + "xp_per_level": 1.2, + "voice_prestige": 0, + "msg_prestige": 0, + "emoji": {}, + "auto_remove": [], + "notify": True, + "level_algorithm": "flat", + "prestige_notify": True, + "prestige_channel": None, + "prestige_message": None, + "dm_levelup": False, + "dm_prestige": False, + "dm_message": None, + "dm_prestige_message": None, + "priority_roles": [], + "stream_bonus": 0.3, + "weekly_reset": False, + "weekly_timer": 0, + "weekly_message": None, + "weekly_channel": None, + "weekly_roles": {}, + "weekly_role_persist": False, + "weekly_autoreset": True, + "weekly_bonus": 0.2, + "weekly_vc_only": False, + "weekly_vc_reset": False, + "weekly_vc_timer": 0, + "weekly_vc_message": None, + "weekly_vc_channel": None, + "weekly_vc_roles": {}, + "weekly_vc_role_persist": False, + "weekly_vc_autoreset": True, + "weekly_vc_bonus": 0.2, + } + + self.data.register_global(**default_global) + self.data.register_guild(**default_guild) + + async def get_global(self) -> t.Dict[str, t.Any]: + """Get the global settings.""" + return await self.data.all() + + async def get_guild(self, guild_id: int) -> t.Dict[str, t.Any]: + """Get the guild settings.""" + return await self.data.guild_from_id(guild_id).all() + + async def set_use_skia(self, value: bool) -> None: + """Set whether to use Skia for image generation.""" + await self.data.use_skia.set(value) + + async def get_use_skia(self) -> bool: + """Get whether to use Skia for image generation.""" + return await self.data.use_skia() \ No newline at end of file diff --git a/levelup/generator/levelalert.py b/levelup/generator/levelalert.py index a9e8728..f968992 100644 --- a/levelup/generator/levelalert.py +++ b/levelup/generator/levelalert.py @@ -8,6 +8,7 @@ Args: font (t.Optional[t.Union[str, Path]], optional): The path to the font file or the name of the font. Defaults to None. render_gif (t.Optional[bool], optional): Whether to render the image as a GIF. Defaults to False. debug (t.Optional[bool], optional): Whether to show the generated image for debugging purposes. Defaults to False. + use_skia (bool, optional): Whether to use the Skia-based generator. Defaults to True. Returns: bytes: The generated image as bytes. @@ -24,8 +25,10 @@ from redbot.core.i18n import Translator try: from . import imgtools + from . import skia_generator except ImportError: import imgtools + import skia_generator log = logging.getLogger("red.vrt.levelup.generator.levelalert") _ = Translator("LevelUp", __file__) @@ -39,8 +42,11 @@ def generate_level_img( font_path: t.Optional[t.Union[str, Path]] = None, render_gif: bool = False, debug: bool = False, + use_skia: bool = True, **kwargs, ) -> t.Tuple[bytes, bool]: + """Generate a level-up alert image.""" + if isinstance(background_bytes, str) and background_bytes.startswith("http"): log.debug("Background image is a URL, attempting to download") background_bytes = imgtools.download_image(background_bytes) @@ -49,6 +55,23 @@ def generate_level_img( log.debug("Avatar image is a URL, attempting to download") avatar_bytes = imgtools.download_image(avatar_bytes) + try: + if use_skia: + # Try using the new Skia-based generator + return skia_generator.generate_level_img( + background_bytes=background_bytes, + avatar_bytes=avatar_bytes, + level=level, + color=color, + font_path=font_path, + render_gif=render_gif, + debug=debug, + ) + except Exception as e: + log.warning("Failed to generate image with Skia, falling back to PIL", exc_info=e) + use_skia = False + + # Fall back to the original PIL-based implementation if Skia fails or is disabled if background_bytes: try: card = Image.open(BytesIO(background_bytes)) diff --git a/levelup/generator/skia_generator.py b/levelup/generator/skia_generator.py new file mode 100644 index 0000000..5fdbc4e --- /dev/null +++ b/levelup/generator/skia_generator.py @@ -0,0 +1,185 @@ +"""Modern image generation using Skia for better quality and performance.""" + +import logging +import math +import typing as t +from io import BytesIO +from pathlib import Path + +import skia + +try: + from . import imgtools +except ImportError: + import imgtools + +log = logging.getLogger("red.vrt.levelup.generator.skia_generator") + +def create_surface(width: int, height: int) -> t.Tuple[skia.Surface, skia.Canvas]: + """Create a new Skia surface and canvas.""" + surface = skia.Surface(width, height) + canvas = surface.getCanvas() + return surface, canvas + +def create_rounded_rect_path(rect: skia.Rect, radius: float) -> skia.Path: + """Create a rounded rectangle path.""" + path = skia.Path() + path.addRoundRect(rect, radius, radius) + return path + +def create_glass_effect(canvas: skia.Canvas, rect: skia.Rect, radius: float, alpha: int = 128): + """Create a modern glass effect with blur.""" + # Create semi-transparent background + paint = skia.Paint( + Color=skia.Color4f(0, 0, 0, alpha/255), + AntiAlias=True, + ) + + # Create blur effect + blur_mask = skia.Surface(int(rect.width()), int(rect.height())) + blur_canvas = blur_mask.getCanvas() + blur_paint = skia.Paint( + Color=skia.ColorWHITE, + AntiAlias=True, + ) + blur_path = create_rounded_rect_path( + skia.Rect(0, 0, rect.width(), rect.height()), + radius + ) + blur_canvas.drawPath(blur_path, blur_paint) + + # Apply gaussian blur + blur_filter = skia.ImageFilters.Blur(10, 10) + paint.setImageFilter(blur_filter) + + # Draw the glass effect + canvas.save() + canvas.translate(rect.left(), rect.top()) + canvas.drawPath(create_rounded_rect_path(rect, radius), paint) + canvas.restore() + +def generate_level_img( + background_bytes: t.Optional[bytes] = None, + avatar_bytes: t.Optional[bytes] = None, + level: int = 1, + color: t.Optional[t.Tuple[int, int, int]] = None, + font_path: t.Optional[t.Union[str, Path]] = None, + render_gif: bool = False, + debug: bool = False, +) -> t.Tuple[bytes, bool]: + """Generate a modern level-up alert image using Skia.""" + + # Default color if none provided + if color is None: + color = (155, 17, 30) # Ruby red + + # Card dimensions + width, height = 200, 70 + + # Create surface and canvas + surface, canvas = create_surface(width, height) + + # Load and draw background + if background_bytes: + try: + background_data = skia.Data.makeWithCopy(background_bytes) + background_image = skia.Image.MakeFromEncoded(background_data) + if background_image: + # Scale background to fit + scale = max(width / background_image.width(), height / background_image.height()) + scaled_width = int(background_image.width() * scale) + scaled_height = int(background_image.height() * scale) + + # Center the background + x = (width - scaled_width) // 2 + y = (height - scaled_height) // 2 + + canvas.drawImageRect( + background_image, + skia.Rect(x, y, x + scaled_width, y + scaled_height), + skia.Paint(AntiAlias=True) + ) + except Exception as e: + log.error("Failed to load background image", exc_info=e) + + # Create glass effect background + glass_rect = skia.Rect(0, 0, width, height) + create_glass_effect(canvas, glass_rect, 20) + + # Load and draw avatar + if avatar_bytes: + try: + avatar_data = skia.Data.makeWithCopy(avatar_bytes) + avatar_image = skia.Image.MakeFromEncoded(avatar_data) + if avatar_image: + # Create circular clip for avatar + avatar_size = height + avatar_path = skia.Path() + avatar_path.addCircle(avatar_size/2, height/2, avatar_size/2) + + canvas.save() + canvas.clipPath(avatar_path, antiAlias=True) + + # Draw avatar + canvas.drawImageRect( + avatar_image, + skia.Rect(0, 0, avatar_size, avatar_size), + skia.Paint(AntiAlias=True) + ) + canvas.restore() + except Exception as e: + log.error("Failed to load avatar image", exc_info=e) + + # Load font + font_size = 30 + if font_path: + typeface = skia.Typeface.MakeFromFile(str(font_path)) + else: + typeface = skia.Typeface.MakeDefault() + font = skia.Font(typeface, font_size) + + # Draw level text with modern styling + text = f"Level {level}" + text_paint = skia.Paint( + AntiAlias=True, + Color=skia.Color(*color), + ) + + # Add text shadow + shadow_paint = skia.Paint( + AntiAlias=True, + Color=skia.Color(0, 0, 0, 100), + ) + + # Calculate text position + text_blob = skia.TextBlob(text, font) + text_x = height + ((width - height) / 2) - (text_blob.bounds().width() / 2) + text_y = height/2 + font_size/3 + + # Draw shadow + canvas.drawTextBlob(text_blob, text_x + 2, text_y + 2, shadow_paint) + # Draw text + canvas.drawTextBlob(text_blob, text_x, text_y, text_paint) + + # Convert to bytes + image = surface.makeImageSnapshot() + data = image.encodeToData(skia.kPNG) + + if debug: + # Save debug image + with open("debug_level.png", "wb") as f: + f.write(data.bytes()) + + return data.bytes(), False # False since we don't support GIF yet + +if __name__ == "__main__": + # Test the generator + logging.basicConfig(level=logging.DEBUG) + test_avatar = (imgtools.ASSETS / "tests" / "tree.gif").read_bytes() + res, animated = generate_level_img( + avatar_bytes=test_avatar, + level=10, + debug=True, + ) + result_path = imgtools.ASSETS / "tests" / "level_skia.png" + result_path.write_bytes(res) \ No newline at end of file diff --git a/levelup/info.json b/levelup/info.json index 414983a..991f814 100644 --- a/levelup/info.json +++ b/levelup/info.json @@ -1,12 +1,19 @@ { - "author": ["Vertyco"], + "author": [ + "Vertyco", + "Valerie" + ], "description": "Your friendly neighborhood leveling system", "disabled": false, "end_user_data_statement": "This cog stores Discord IDs, counts of user messages, and their time spent in voice channels. No private info is stored about users.", "hidden": false, "install_msg": "Thank you for installing LevelUp! To enable leveling in this server type `[p]lset toggle`.\n\nDOCUMENTATION: https://github.com/vertyco/vrt-cogs/blob/main/levelup/README.md", "min_bot_version": "3.5.0", - "min_python_version": [3, 9, 0], + "min_python_version": [ + 3, + 9, + 0 + ], "permissions": [ "read_messages", "send_messages", @@ -29,6 +36,7 @@ "python-dotenv", "psutil", "requests", + "skia-python>=87.5", "tenacity", "ujson", "uvicorn" @@ -45,4 +53,4 @@ "vert" ], "type": "COG" -} +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a7950b9..8185a6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,3 @@ - [tool.isort] profile = 'black' line_length = 99 @@ -8,3 +7,13 @@ line-length = 99 target-version = ['py38'] include = '\.pyi?$' + +[tool.poetry] +name = "Ava-Cogs" +version = "0.1.0" +description = "A collection of cogs for Red-DiscordBot" +authors = ["Your Name "] + +[tool.poetry.dependencies] +python = "^3.8" +skia-python = "^87.5"