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)