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 desired_card_size = (1050, 320) # Keep consistent size # Define profile picture size and positions - moved to right side pfp_size = (220, 220) # Slightly smaller profile picture pfp_x = desired_card_size[0] - pfp_size[0] - 50 # Right side positioning pfp_y = (desired_card_size[1] - pfp_size[1]) // 2 circle_x = pfp_x circle_y = pfp_y # Define the stats area with a modern glass effect - adjusted for left side stats_area = ( 25, # x1 - Start from left edge with small padding 20, # y1 - Start near top desired_card_size[0] - pfp_size[0] - 100, # x2 - End before profile picture desired_card_size[1] - 20 # y2 - End near bottom ) # Create the stats layer with glass effect stats_layer = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) # Create a modern glass morphism effect glass = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) glass_draw = ImageDraw.Draw(glass) glass_draw.rounded_rectangle(stats_area, radius=25, fill=(0, 0, 0, 80)) # Lighter, more modern transparency # Add a subtle gradient overlay for depth gradient = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) gradient_draw = ImageDraw.Draw(gradient) for i in range(40): opacity = int(25 * (1 - i/40)) # Even subtler effect gradient_draw.rounded_rectangle( (stats_area[0], stats_area[1]+i, stats_area[2], stats_area[3]), radius=25, fill=(255, 255, 255, opacity) ) # Composite the glass effect stats_layer = Image.alpha_composite(stats_layer, glass) stats_layer = Image.alpha_composite(stats_layer, gradient) # Draw stats with improved styling draw = ImageDraw.Draw(stats_layer) # Add level text with enhanced modern styling level_text = f"LEVEL {level}" if prestige > 0: level_text = f"P{prestige} • {level_text}" # Use larger font for level display level_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 64) # Increased size level_y = stats_area[1] + 20 # Add subtle text shadow for depth shadow_offset = 2 level_x = stats_area[0] + 30 # Move level text to left side # Draw level text with improved shadow draw.text( (level_x + shadow_offset, level_y + shadow_offset), level_text, font=level_font, fill=(0, 0, 0, 60) # More subtle shadow ) draw.text( (level_x, level_y), level_text, font=level_font, fill=user_color ) # Stats section with improved layout title_font_size = 26 # Slightly smaller for better hierarchy value_font_size = 30 # Kept larger for emphasis title_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), title_font_size) value_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), value_font_size) spacing = 50 # Increased spacing between stats # Starting positions - moved down and left start_x = stats_area[0] + 40 start_y = level_y + 100 # More space after level text # Helper function for stat rendering with improved spacing def draw_stat(x_pos, y_pos, icon, title, value, color=stat_color): # Draw icon with subtle glow draw.text((x_pos, y_pos), icon, font=value_font, fill=color) # Draw title with modern styling title_x = x_pos + 40 draw.text((title_x, y_pos), f"{title}:", font=title_font, fill=(200, 200, 200)) # Slightly dimmer # Calculate value position title_width = draw.textlength(f"{title}:", font=title_font) value_x = title_x + title_width + 15 # Draw value with subtle shadow draw.text((value_x + 1, y_pos + 1), value, font=value_font, fill=(0, 0, 0, 40)) draw.text((value_x, y_pos), value, font=value_font, fill=color) return spacing # Draw stats in two columns with better spacing left_column_x = start_x right_column_x = start_x + 380 # Increased column separation current_y = start_y # Left column stats current_y += draw_stat(left_column_x, current_y, "💬", "Messages", f"{humanize_number(messages)}") current_y += draw_stat(left_column_x, current_y, "🎤", "Voice", imgtools.abbreviate_time(voicetime)) current_y += draw_stat(left_column_x, current_y, "⭐", "Stars", humanize_number(stars)) # Right column stats current_y = start_y if balance is not None: current_y += draw_stat(right_column_x, current_y, "💰", "Balance", f"{humanize_number(balance)} {currency_name}") current_y += draw_stat(right_column_x, current_y, "🏆", "Rank", f"#{humanize_number(position)}") # Progress bar with modern design - moved to bottom bar_width = stats_area[2] - stats_area[0] - 60 # Full width progress bar bar_height = 25 # Slightly thinner bar_x = stats_area[0] + 30 bar_y = stats_area[3] - 60 # Move up from bottom # Progress bar background with modern blur effect draw.rounded_rectangle( (bar_x, bar_y, bar_x + bar_width, bar_y + bar_height), radius=bar_height//2, fill=(0, 0, 0, 40) # Very subtle background ) # Calculate progress 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 # Progress bar fill with gradient effect if progress > 0: progress_width = int(bar_width * progress) draw.rounded_rectangle( (bar_x, bar_y, bar_x + progress_width, bar_y + bar_height), radius=bar_height//2, fill=level_bar_color or user_color ) # XP Text with improved visibility xp_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 22) xp_text = f"XP: {humanize_number(current_xp)} / {humanize_number(next_xp)}" xp_w = draw.textlength(xp_text, font=xp_font) xp_x = bar_x + (bar_width - xp_w) / 2 xp_y = bar_y - 30 # Draw XP text with improved shadow draw.text((xp_x + 1, xp_y + 1), xp_text, font=xp_font, fill=(0, 0, 0, 80)) draw.text((xp_x, xp_y), xp_text, font=xp_font, fill=(255, 255, 255)) # Progress percentage with improved visibility percent_text = f"{int(progress * 100)}%" percent_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 20) percent_w = draw.textlength(percent_text, font=percent_font) percent_x = bar_x + progress_width - percent_w - 10 percent_y = bar_y + 2 # Draw percentage with improved shadow draw.text((percent_x + 1, percent_y + 1), percent_text, font=percent_font, fill=(0, 0, 0, 80)) 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) # Round the card corners card = imgtools.round_image_corners(card, 20) # Create circular profile picture pfp = pfp.resize(pfp_size, Image.Resampling.LANCZOS) pfp = imgtools.make_profile_circle(pfp) # Position profile picture on the left pfp_x = 55 pfp_y = (desired_card_size[1] - pfp_size[1]) // 2 # Define circle positions for animated profiles circle_x = pfp_x circle_y = pfp_y # Create a new image for final composition final_image = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) # Add blur effect if enabled if blur: blur_section = imgtools.blur_section(card, (stats_area[0], 0, card.width, card.height)) card.paste(blur_section, (stats_area[0], 0), blur_section) # Paste layers in correct order final_image.paste(card, (0, 0), card) final_image.paste(stats_layer, (0, 0), 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)