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:
parent
258ba40f01
commit
914e7a725b
6 changed files with 356 additions and 4 deletions
|
@ -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
107
levelup/common/settings.py
Normal 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()
|
|
@ -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))
|
||||
|
|
185
levelup/generator/skia_generator.py
Normal file
185
levelup/generator/skia_generator.py
Normal 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)
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue