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)
|
file = await asyncio.to_thread(_run)
|
||||||
await ctx.send(file=file)
|
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.
|
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.
|
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.
|
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:
|
Returns:
|
||||||
bytes: The generated image as bytes.
|
bytes: The generated image as bytes.
|
||||||
|
@ -24,8 +25,10 @@ from redbot.core.i18n import Translator
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from . import imgtools
|
from . import imgtools
|
||||||
|
from . import skia_generator
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import imgtools
|
import imgtools
|
||||||
|
import skia_generator
|
||||||
|
|
||||||
log = logging.getLogger("red.vrt.levelup.generator.levelalert")
|
log = logging.getLogger("red.vrt.levelup.generator.levelalert")
|
||||||
_ = Translator("LevelUp", __file__)
|
_ = Translator("LevelUp", __file__)
|
||||||
|
@ -39,8 +42,11 @@ def generate_level_img(
|
||||||
font_path: t.Optional[t.Union[str, Path]] = None,
|
font_path: t.Optional[t.Union[str, Path]] = None,
|
||||||
render_gif: bool = False,
|
render_gif: bool = False,
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
|
use_skia: bool = True,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> t.Tuple[bytes, bool]:
|
) -> t.Tuple[bytes, bool]:
|
||||||
|
"""Generate a level-up alert image."""
|
||||||
|
|
||||||
if isinstance(background_bytes, str) and background_bytes.startswith("http"):
|
if isinstance(background_bytes, str) and background_bytes.startswith("http"):
|
||||||
log.debug("Background image is a URL, attempting to download")
|
log.debug("Background image is a URL, attempting to download")
|
||||||
background_bytes = imgtools.download_image(background_bytes)
|
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")
|
log.debug("Avatar image is a URL, attempting to download")
|
||||||
avatar_bytes = imgtools.download_image(avatar_bytes)
|
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:
|
if background_bytes:
|
||||||
try:
|
try:
|
||||||
card = Image.open(BytesIO(background_bytes))
|
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",
|
"description": "Your friendly neighborhood leveling system",
|
||||||
"disabled": false,
|
"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.",
|
"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,
|
"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",
|
"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_bot_version": "3.5.0",
|
||||||
"min_python_version": [3, 9, 0],
|
"min_python_version": [
|
||||||
|
3,
|
||||||
|
9,
|
||||||
|
0
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"read_messages",
|
"read_messages",
|
||||||
"send_messages",
|
"send_messages",
|
||||||
|
@ -29,6 +36,7 @@
|
||||||
"python-dotenv",
|
"python-dotenv",
|
||||||
"psutil",
|
"psutil",
|
||||||
"requests",
|
"requests",
|
||||||
|
"skia-python>=87.5",
|
||||||
"tenacity",
|
"tenacity",
|
||||||
"ujson",
|
"ujson",
|
||||||
"uvicorn"
|
"uvicorn"
|
||||||
|
@ -45,4 +53,4 @@
|
||||||
"vert"
|
"vert"
|
||||||
],
|
],
|
||||||
"type": "COG"
|
"type": "COG"
|
||||||
}
|
}
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = 'black'
|
profile = 'black'
|
||||||
line_length = 99
|
line_length = 99
|
||||||
|
@ -8,3 +7,13 @@
|
||||||
line-length = 99
|
line-length = 99
|
||||||
target-version = ['py38']
|
target-version = ['py38']
|
||||||
include = '\.pyi?$'
|
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