Ruby-Cogs/levelup/generator/styles/default.py
Valerie 9d7a65dec2
Some checks are pending
Run pre-commit / Run pre-commit (push) Waiting to run
yes
2025-05-26 22:34:54 -04:00

513 lines
22 KiB
Python

import importlib.util
import logging
import math
import sys
import typing as t
from io import BytesIO
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageSequence, UnidentifiedImageError
from redbot.core.i18n import Translator
from redbot.core.utils.chat_formatting import humanize_number
try:
# Loaded from cog
from .. import imgtools
from ..pilmojisrc.core import Pilmoji
except ImportError:
# Running in vscode "Run Python File in Terminal"
# Add parent directory to sys.path to enable imports
parent_dir = Path(__file__).parent.parent
sys.path.insert(0, str(parent_dir))
# Import imgtools directly
imgtools_path = parent_dir / "imgtools.py"
if imgtools_path.exists():
spec = importlib.util.spec_from_file_location("imgtools", imgtools_path)
imgtools = importlib.util.module_from_spec(spec)
sys.modules["imgtools"] = imgtools
spec.loader.exec_module(imgtools)
else:
raise ImportError(f"Could not find imgtools at {imgtools_path}")
# Set up pilmojisrc as a package
pilmoji_dir = parent_dir / "pilmojisrc"
if not pilmoji_dir.exists():
raise ImportError(f"Could not find pilmojisrc directory at {pilmoji_dir}")
# Create and register the pilmojisrc package
pilmojisrc_init = pilmoji_dir / "__init__.py"
if pilmojisrc_init.exists():
spec = importlib.util.spec_from_file_location("pilmojisrc", pilmojisrc_init)
pilmojisrc = importlib.util.module_from_spec(spec)
sys.modules["pilmojisrc"] = pilmojisrc
spec.loader.exec_module(pilmojisrc)
else:
# Create an empty module if __init__.py doesn't exist
pilmojisrc = type(sys)("pilmojisrc")
sys.modules["pilmojisrc"] = pilmojisrc
# Import helpers module first (since core depends on it)
helpers_path = pilmoji_dir / "helpers.py"
if helpers_path.exists():
spec = importlib.util.spec_from_file_location("pilmojisrc.helpers", helpers_path)
helpers = importlib.util.module_from_spec(spec)
pilmojisrc.helpers = helpers
sys.modules["pilmojisrc.helpers"] = helpers
spec.loader.exec_module(helpers)
else:
raise ImportError(f"Could not find helpers module at {helpers_path}")
# Now import core module
core_path = pilmoji_dir / "core.py"
if core_path.exists():
spec = importlib.util.spec_from_file_location("pilmojisrc.core", core_path)
core = importlib.util.module_from_spec(spec)
pilmojisrc.core = core
sys.modules["pilmojisrc.core"] = core
spec.loader.exec_module(core)
Pilmoji = core.Pilmoji
else:
raise ImportError(f"Could not find core module at {core_path}")
log = logging.getLogger("red.vrt.levelup.generator.styles.default")
_ = Translator("LevelUp", __file__)
def generate_default_profile(
background_bytes: t.Optional[t.Union[bytes, str]] = None,
avatar_bytes: t.Optional[t.Union[bytes, str]] = None,
username: str = "Spartan117",
status: str = "online",
level: int = 3,
messages: int = 420,
voicetime: int = 3600,
stars: int = 69,
prestige: int = 0,
prestige_emoji: t.Optional[t.Union[bytes, str]] = None,
balance: int = 0,
currency_name: str = "Credits",
previous_xp: int = 100,
current_xp: int = 125,
next_xp: int = 200,
position: int = 3,
role_icon: t.Optional[t.Union[bytes, str]] = None,
blur: bool = False,
base_color: t.Tuple[int, int, int] = (255, 255, 255),
user_color: t.Optional[t.Tuple[int, int, int]] = None,
stat_color: t.Optional[t.Tuple[int, int, int]] = None,
level_bar_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,
reraise: bool = False,
square: bool = False,
**kwargs,
) -> t.Tuple[bytes, bool]:
"""
Generate a full profile image with customizable parameters.
If the avatar is animated and not the background, the avatar will be rendered as a gif.
If the background is animated and not the avatar, the background will be rendered as a gif.
If both are animated, the avatar will be rendered as a gif and the background will be rendered as a static image.
To optimize performance, the profile will be generated in 3 layers, the background, the avatar, and the stats.
The stats layer will be generated as a separate image and then pasted onto the background.
Args:
background (t.Optional[bytes], optional): The background image as bytes. Defaults to None.
avatar (t.Optional[bytes], optional): The avatar image as bytes. Defaults to None.
username (t.Optional[str], optional): The username. Defaults to "Spartan117".
status (t.Optional[str], optional): The status. Defaults to "online".
level (t.Optional[int], optional): The level. Defaults to 1.
messages (t.Optional[int], optional): The number of messages. Defaults to 0.
voicetime (t.Optional[int], optional): The voicetime. Defaults to 3600.
stars (t.Optional[int], optional): The number of stars. Defaults to 0.
prestige (t.Optional[int], optional): The prestige level. Defaults to 0.
prestige_emoji (t.Optional[bytes], optional): The prestige emoji as bytes. Defaults to None.
balance (t.Optional[int], optional): The balance. Defaults to 0.
currency_name (t.Optional[str], optional): The name of the currency. Defaults to "Credits".
previous_xp (t.Optional[int], optional): The previous XP. Defaults to 0.
current_xp (t.Optional[int], optional): The current XP. Defaults to 0.
next_xp (t.Optional[int], optional): The next XP. Defaults to 0.
position (t.Optional[int], optional): The position. Defaults to 0.
role_icon (t.Optional[bytes, str], optional): The role icon as bytes or url. Defaults to None.
blur (t.Optional[bool], optional): Whether to blur the box behind the stats. Defaults to False.
user_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the user. Defaults to None.
base_color (t.Optional[t.Tuple[int, int, int]], optional): The base color. Defaults to None.
stat_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the stats. Defaults to None.
level_bar_color (t.Optional[t.Tuple[int, int, int]], optional): The color for the level bar. Defaults to None.
font_path (t.Optional[t.Union[str, Path], optional): The path to the font file. Defaults to None.
render_gif (t.Optional[bool], optional): Whether to render as gif if profile or background is one. Defaults to False.
debug (t.Optional[bool], optional): Whether to raise any errors rather than suppressing. Defaults to False.
reraise (t.Optional[bool], optional): Whether to raise any errors rather than suppressing. Defaults to False.
square (t.Optional[bool], optional): Whether to render the profile as a square. Defaults to False.
**kwargs: Additional keyword arguments.
Returns:
t.Tuple[bytes, bool]: The generated full profile image as bytes, and whether the image is animated.
"""
user_color = user_color or (155, 17, 30) # Default to ruby red
stat_color = stat_color or (155, 17, 30) # Default to ruby red
level_bar_color = level_bar_color or (155, 17, 30) # Default to ruby red
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)
if isinstance(avatar_bytes, str) and avatar_bytes.startswith("http"):
log.debug("Avatar image is a URL, attempting to download")
avatar_bytes = imgtools.download_image(avatar_bytes)
if isinstance(prestige_emoji, str) and prestige_emoji.startswith("http"):
log.debug("Prestige emoji is a URL, attempting to download")
prestige_emoji = imgtools.download_image(prestige_emoji)
if isinstance(role_icon, str) and role_icon.startswith("http"):
log.debug("Role icon is a URL, attempting to download")
role_icon_bytes = imgtools.download_image(role_icon)
else:
role_icon_bytes = role_icon
if background_bytes:
try:
card = Image.open(BytesIO(background_bytes))
except UnidentifiedImageError as e:
if reraise:
raise e
log.error(
f"Failed to open background image ({type(background_bytes)} - {len(background_bytes)})", exc_info=e
)
card = imgtools.get_random_background()
else:
card = imgtools.get_random_background()
if avatar_bytes:
pfp = Image.open(BytesIO(avatar_bytes))
else:
pfp = imgtools.DEFAULT_PFP
pfp_animated = getattr(pfp, "is_animated", False)
bg_animated = getattr(card, "is_animated", False)
log.debug(f"PFP animated: {pfp_animated}, BG animated: {bg_animated}")
# Setup
default_fill = (255, 255, 255) # Default fill color for text
stroke_width = 1 # Reduced stroke width for cleaner look
# Card dimensions and layout
if square:
desired_card_size = (450, 450)
else:
desired_card_size = (1050, 320)
# Define profile picture size and positions
pfp_size = (270, 270)
pfp_x = 15 # Slight padding from left
pfp_y = (desired_card_size[1] - pfp_size[1]) // 2
circle_x = pfp_x
circle_y = pfp_y
# Create the dark background layer that covers everything
dark_bg = Image.new("RGBA", desired_card_size, (0, 0, 0, 0))
dark_bg_draw = ImageDraw.Draw(dark_bg)
dark_bg_draw.rectangle((0, 0, desired_card_size[0], desired_card_size[1]), fill=(0, 0, 0, 180))
# Create the stats layer
stats_layer = Image.new("RGBA", desired_card_size, (0, 0, 0, 0))
draw = ImageDraw.Draw(stats_layer)
# Stats positioning - progress bar starts after profile, stats on right
left_edge = pfp_x + pfp_size[0] + 30 # Start progress bar after profile picture
stats_x = desired_card_size[0] - 400 # Right side stats position
<<<<<<< HEAD
top_padding = 20 # Reduced top padding for higher text placement
=======
top_padding = 40 # Consistent top padding for all text
>>>>>>> 3ecf002 (Refactor default.py to standardize top padding for text elements in profile card rendering, ensuring consistent alignment of balance and level text. Move level text positioning to improve layout aesthetics and readability.)
# Draw stats with consistent spacing
title_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 32)
value_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 32)
# Balance and Rank stats - back on the right side
balance_y = top_padding
draw.text((stats_x, balance_y), "BALANCE:", font=title_font, fill=(200, 200, 200))
draw.text((stats_x + 150, balance_y), f"{humanize_number(balance)} CREDITS", font=value_font, fill=stat_color or (255, 255, 255))
rank_y = balance_y + 50
draw.text((stats_x, rank_y), "RANK:", font=title_font, fill=(200, 200, 200))
draw.text((stats_x + 150, rank_y), f"#{humanize_number(position)}", font=value_font, fill=stat_color or (255, 255, 255))
# XP Text - back on the right
xp_y = rank_y + 50
xp_text = f"XP: {humanize_number(current_xp)} / {humanize_number(next_xp)}"
draw.text((stats_x, xp_y), xp_text, font=value_font, fill=(255, 255, 255))
# Level text - aligned with balance text
level_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 48)
level_text = f"LEVEL {level}"
if prestige > 0:
level_text = f"P{prestige}{level_text}"
draw.text((left_edge, top_padding), level_text, font=level_font, fill=user_color or (255, 255, 255))
# Progress bar - starts after profile picture
progress = (current_xp - previous_xp) / (next_xp - previous_xp) if next_xp > previous_xp else 0
progress = max(0, min(1, progress)) # Ensure progress is between 0 and 1
bar_width = desired_card_size[0] - left_edge - 15 # Width from after profile to right edge
bar_height = 40
bar_x = left_edge # Start after profile picture
bar_y = desired_card_size[1] - bar_height - 15 # Bottom padding
# Progress bar background
draw.rectangle((bar_x, bar_y, bar_x + bar_width, bar_y + bar_height), fill=(50, 50, 50))
# Progress bar fill
if progress > 0:
progress_width = int(bar_width * progress)
draw.rectangle((bar_x, bar_y, bar_x + progress_width, bar_y + bar_height), fill=level_bar_color or user_color)
# Progress percentage
percent_text = f"{int(progress * 100)}%"
percent_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 24)
percent_w = draw.textlength(percent_text, font=percent_font)
percent_x = bar_x + 10 # Offset from start of progress bar
percent_y = bar_y + (bar_height - 24) // 2 # Center in bar
# Draw percentage
draw.text((percent_x, percent_y), percent_text, font=percent_font, fill=(255, 255, 255))
# Composite the layers
if not render_gif or (not pfp_animated and not bg_animated):
if card.mode != "RGBA":
card = card.convert("RGBA")
if pfp.mode != "RGBA":
pfp = pfp.convert("RGBA")
# Fit the background to the desired size
card = imgtools.fit_aspect_ratio(card, desired_card_size)
# Create circular profile picture
pfp = pfp.resize(pfp_size, Image.Resampling.LANCZOS)
pfp = imgtools.make_profile_circle(pfp)
# Create final image
final_image = Image.new("RGBA", desired_card_size, (0, 0, 0, 0))
# Layer order: card -> dark background -> stats -> profile picture
final_image.paste(card, (0, 0), card)
final_image = Image.alpha_composite(final_image, dark_bg)
final_image = Image.alpha_composite(final_image, stats_layer)
final_image.paste(pfp, (pfp_x, pfp_y), pfp)
if debug:
final_image.show()
buffer = BytesIO()
final_image.save(buffer, format="WEBP", quality=95)
final_image.close()
return buffer.getvalue(), False
if pfp_animated and not bg_animated:
if card.mode != "RGBA":
log.debug(f"Converting card mode '{card.mode}' to RGBA")
card = card.convert("RGBA")
card = imgtools.fit_aspect_ratio(card, desired_card_size)
if blur:
blur_section = imgtools.blur_section(card, (stats_area[0], 0, card.width, card.height))
# Paste onto the stats
card.paste(blur_section, (stats_area[0], 0), blur_section)
card.paste(stats_layer, (0, 0), stats_layer)
avg_duration = imgtools.get_avg_duration(pfp)
log.debug(f"Rendering pfp as gif with avg duration of {avg_duration}ms")
frames: t.List[Image.Image] = []
for frame in range(pfp.n_frames):
pfp.seek(frame)
# Prepare copies of the card, stats, and pfp
card_frame = card.copy()
pfp_frame = pfp.copy()
if pfp_frame.mode != "RGBA":
pfp_frame = pfp_frame.convert("RGBA")
# Resize the profile image for each frame
pfp_frame = pfp_frame.resize(desired_card_size, Image.Resampling.NEAREST)
# Crop the profile image into a circle
pfp_frame = imgtools.make_profile_circle(pfp_frame, method=Image.Resampling.NEAREST)
# Paste the profile image onto the card
card_frame.paste(pfp_frame, (circle_x, circle_y), pfp_frame)
frames.append(card_frame)
buffer = BytesIO()
frames[0].save(
buffer,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=avg_duration,
loop=0,
quality=75,
optimize=True,
)
buffer.seek(0)
if debug:
Image.open(buffer).show()
return buffer.getvalue(), True
elif bg_animated and not pfp_animated:
avg_duration = imgtools.get_avg_duration(card)
log.debug(f"Rendering card as gif with avg duration of {avg_duration}ms")
frames: t.List[Image.Image] = []
if pfp.mode != "RGBA":
log.debug(f"Converting pfp mode '{pfp.mode}' to RGBA")
pfp = pfp.convert("RGBA")
pfp = pfp.resize(desired_card_size, Image.Resampling.LANCZOS)
# Crop the profile image into a circle
pfp = imgtools.make_profile_circle(pfp)
for frame in range(card.n_frames):
card.seek(frame)
# Prepare copies of the card and stats
card_frame = card.copy()
card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size)
if card_frame.mode != "RGBA":
card_frame = card_frame.convert("RGBA")
# Paste items onto the card
if blur:
blur_section = imgtools.blur_section(card_frame, (stats_area[0], 0, card_frame.width, card_frame.height))
card_frame.paste(blur_section, (stats_area[0], 0), blur_section)
card_frame.paste(pfp, (circle_x, circle_y), pfp)
card_frame.paste(stats_layer, (0, 0), stats_layer)
frames.append(card_frame)
buffer = BytesIO()
frames[0].save(
buffer,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=avg_duration,
loop=0,
quality=75,
optimize=True,
)
buffer.seek(0)
if debug:
Image.open(buffer).show()
return buffer.getvalue(), True
# If we're here, both the avatar and background are gifs
# Figure out how to merge the two frame counts and durations together
# Calculate frame durations based on the LCM
pfp_duration = imgtools.get_avg_duration(pfp) # example: 50ms
card_duration = imgtools.get_avg_duration(card) # example: 100ms
log.debug(f"PFP duration: {pfp_duration}ms, Card duration: {card_duration}ms")
# Figure out how to round the durations
# Favor the card's duration time over the pfp
# Round both durations to the nearest X ms based on what will get the closest to the LCM
pfp_duration = round(card_duration, -1) # Round to the nearest 10ms
card_duration = round(card_duration, -1) # Round to the nearest 10ms
log.debug(f"Modified PFP duration: {pfp_duration}ms, Card duration: {card_duration}ms")
combined_duration = math.lcm(pfp_duration, card_duration) # example: 100ms would be the LCM of 50 and 100
log.debug(f"Combined duration: {combined_duration}ms")
# The combined duration should be no more than 20% offset from the image with the highest duration
max_duration = max(pfp_duration, card_duration)
if combined_duration > max_duration * 1.2:
log.debug(f"Combined duration is more than 20% offset from the max duration ({max_duration}ms)")
combined_duration = max_duration
total_pfp_duration = pfp.n_frames * pfp_duration # example: 2250ms
total_card_duration = card.n_frames * card_duration # example: 3300ms
# Total duration for the combined animation cycle (LCM of 2250 and 3300)
total_duration = math.lcm(total_pfp_duration, total_card_duration) # example: 9900ms
num_combined_frames = total_duration // combined_duration
# The maximum frame count should be no more than 20% offset from the image with the highest frame count to avoid filesize bloat
max_frame_count = max(pfp.n_frames, card.n_frames) * 1.2
max_frame_count = min(round(max_frame_count), num_combined_frames)
log.debug(f"Max frame count: {max_frame_count}")
# Create a list to store the combined frames
combined_frames = []
for frame_num in range(max_frame_count):
time = frame_num * combined_duration
# Calculate the frame index for both the card and pfp
card_frame_index = (time // card_duration) % card.n_frames
pfp_frame_index = (time // pfp_duration) % pfp.n_frames
# Get the frames for the card and pfp
card_frame = ImageSequence.Iterator(card)[card_frame_index]
pfp_frame = ImageSequence.Iterator(pfp)[pfp_frame_index]
card_frame = imgtools.fit_aspect_ratio(card_frame, desired_card_size)
if card_frame.mode != "RGBA":
card_frame = card_frame.convert("RGBA")
if blur:
blur_section = imgtools.blur_section(card_frame, (stats_area[0], 0, card_frame.width, card_frame.height))
# Paste onto the stats
card_frame.paste(blur_section, (stats_area[0], 0), blur_section)
if pfp_frame.mode != "RGBA":
pfp_frame = pfp_frame.convert("RGBA")
pfp_frame = pfp_frame.resize(desired_card_size, Image.Resampling.NEAREST)
pfp_frame = imgtools.make_profile_circle(pfp_frame, method=Image.Resampling.NEAREST)
card_frame.paste(pfp_frame, (circle_x, circle_y), pfp_frame)
card_frame.paste(stats_layer, (0, 0), stats_layer)
combined_frames.append(card_frame)
buffer = BytesIO()
combined_frames[0].save(
buffer,
format="GIF",
save_all=True,
append_images=combined_frames[1:],
loop=0,
duration=combined_duration,
quality=75,
optimize=True,
)
buffer.seek(0)
if debug:
Image.open(buffer).show()
return buffer.getvalue(), True
if __name__ == "__main__":
# Setup console logging
logging.basicConfig(level=logging.DEBUG)
logging.getLogger("PIL").setLevel(logging.INFO)
test_banner = (imgtools.ASSETS / "tests" / "banner3.gif").read_bytes()
test_avatar = (imgtools.ASSETS / "tests" / "tree.gif").read_bytes()
test_icon = (imgtools.ASSETS / "tests" / "icon.png").read_bytes()
font_path = imgtools.ASSETS / "fonts" / "BebasNeue.ttf"
res, animated = generate_default_profile(
background_bytes=test_banner,
avatar_bytes=test_avatar,
username="Vertyco",
status="online",
level=999,
messages=420,
voicetime=399815,
stars=693333,
prestige=2,
prestige_emoji=test_icon,
balance=1000000,
currency_name="Coinz 💰",
previous_xp=1000,
current_xp=1258,
next_xp=5000,
role_icon=test_icon,
blur=True,
font_path=font_path,
render_gif=True,
debug=True,
)
result_path = imgtools.ASSETS / "tests" / "result.gif"
result_path.write_bytes(res)