Add Skia support for image generation and update project metadata

Introduce a new command to toggle Skia-based image generation in the LevelUp cog, enhancing image quality and performance. Update the pyproject.toml file to include project metadata and dependencies, including skia-python. Modify the levelalert generator to support Skia, with fallback to the original implementation if necessary. Additionally, update the author list in info.json to include a new contributor.
This commit is contained in:
Valerie 2025-05-26 09:06:08 -04:00
parent 258ba40f01
commit 914e7a725b
6 changed files with 356 additions and 4 deletions

View file

@ -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'}."
)

107
levelup/common/settings.py Normal file
View file

@ -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()

View file

@ -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))

View file

@ -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)

View file

@ -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"
}
}

View file

@ -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 <your.email@example.com>"]
[tool.poetry.dependencies]
python = "^3.8"
skia-python = "^87.5"