From 130fbc015b66cc97f6cc5e70fb2d3326f2205e30 Mon Sep 17 00:00:00 2001 From: Valerie Date: Sat, 24 May 2025 07:00:13 -0400 Subject: [PATCH] Add glass effect to profile stats layer and improve layout. Introduce create_glass_effect function for modern styling, update stats drawing with icons, and enhance XP progress bar. Refactor profile image handling for better visual presentation. --- levelup/generator/imgtools.py | 17 ++ levelup/generator/styles/default.py | 315 +++++++++------------------- 2 files changed, 118 insertions(+), 214 deletions(-) diff --git a/levelup/generator/imgtools.py b/levelup/generator/imgtools.py index c851660..fe8686f 100644 --- a/levelup/generator/imgtools.py +++ b/levelup/generator/imgtools.py @@ -388,5 +388,22 @@ def get_avg_duration(image: Image.Image) -> int: return 0 +def create_glass_effect(image: Image.Image, opacity: float = 0.3, blur_radius: int = 10) -> Image.Image: + """Create a modern glass effect with blur and transparency.""" + # Create a copy of the image to blur + glass = image.copy() + + # Apply gaussian blur + glass = glass.filter(ImageFilter.GaussianBlur(blur_radius)) + + # Create a semi-transparent white overlay + overlay = Image.new('RGBA', image.size, (255, 255, 255, int(255 * opacity))) + + # Composite the blurred image with the overlay + glass = Image.alpha_composite(glass.convert('RGBA'), overlay) + + return glass + + if __name__ == "__main__": print(calc_aspect_ratio(200, 70)) diff --git a/levelup/generator/styles/default.py b/levelup/generator/styles/default.py index e38f163..0305ccd 100644 --- a/levelup/generator/styles/default.py +++ b/levelup/generator/styles/default.py @@ -237,220 +237,107 @@ def generate_default_profile( star_icon_x = 850 # Left bound of star icon star_icon_y = 35 # Top bound of star icon - # Establish layer for all text and accents - stats = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) + # Create the stats layer with glass effect + stats_layer = Image.new("RGBA", desired_card_size, (0, 0, 0, 0)) + stats_area = ( + stats_layer.width // 4, # x1 + stats_layer.height // 3, # y1 + stats_layer.width - (stats_layer.width // 4), # x2 + stats_layer.height - (stats_layer.height // 4), # y2 + ) + + # Create glass effect for stats area + stats_bg = card.crop(stats_area) + glass_bg = imgtools.create_glass_effect(stats_bg, opacity=0.2, blur_radius=15) + stats_layer.paste(glass_bg, stats_area) + + # Add rounded corners to the glass effect + stats_mask = imgtools.get_rounded_corner_mask(glass_bg, radius=20) + stats_layer.putalpha(stats_mask) - # Setup progress bar + # Draw stats with improved styling + draw = ImageDraw.Draw(stats_layer) + font_size = 24 + font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), font_size) + + # Add stats with icons and modern layout + icon_size = font_size + 4 + spacing = icon_size + 10 + + # Messages stat with icon + msg_icon = "💬" + draw.text( + (stats_area[0] + 10, stats_area[1] + 10), + msg_icon, + font=font, + fill=stat_color, + ) + draw.text( + (stats_area[0] + 10 + spacing, stats_area[1] + 10), + f"{humanize_number(messages)}", + font=font, + fill=stat_color, + ) + + # Voice time stat with icon + voice_icon = "🎤" + draw.text( + (stats_area[0] + 10, stats_area[1] + 10 + spacing), + voice_icon, + font=font, + fill=stat_color, + ) + draw.text( + (stats_area[0] + 10 + spacing, stats_area[1] + 10 + spacing), + imgtools.abbreviate_time(voicetime), + font=font, + fill=stat_color, + ) + + # Stars stat with icon + star_icon = "⭐" + draw.text( + (stats_area[0] + 10, stats_area[1] + 10 + spacing * 2), + star_icon, + font=font, + fill=stat_color, + ) + draw.text( + (stats_area[0] + 10 + spacing, stats_area[1] + 10 + spacing * 2), + f"{humanize_number(stars)}", + font=font, + fill=stat_color, + ) + + # Add XP progress bar with glass effect progress = (current_xp - previous_xp) / (next_xp - previous_xp) - level_bar = imgtools.make_progress_bar( + bar_width = stats_area[2] - stats_area[0] - 20 + bar_height = 20 + progress_bar = imgtools.make_progress_bar( bar_width, bar_height, progress, - level_bar_color, + color=level_bar_color, + background_color=(100, 100, 100, 128) ) - stats.paste(level_bar, (bar_start, bar_top), level_bar) + stats_layer.paste(progress_bar, (stats_area[0] + 10, stats_area[3] - 30), progress_bar) - # Establish font - font_path = font_path or imgtools.DEFAULT_FONT - if isinstance(font_path, str): - font_path = Path(font_path) - if not font_path.exists(): - # Hosted api on another server? Check if we have it - if (imgtools.DEFAULT_FONTS / font_path.name).exists(): - font_path = imgtools.DEFAULT_FONTS / font_path.name - else: - font_path = imgtools.DEFAULT_FONT - # Convert back to string - font_path = str(font_path) + # Add level text with modern styling + level_text = f"LEVEL {level}" + if prestige > 0: + level_text = f"P{prestige} • {level_text}" + + level_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 32) + draw.text( + (stats_area[0] + 10, stats_area[1] - 40), + level_text, + font=level_font, + fill=user_color, + stroke_width=2, + stroke_fill=(0, 0, 0) + ) - draw = ImageDraw.Draw(stats) - # ---------------- Username text ---------------- - fontsize = 60 - font = ImageFont.truetype(font_path, fontsize) - with Pilmoji(stats) as pilmoji: - # Ensure text doesnt pass star_icon_x - while pilmoji.getsize(username, font)[0] + stat_start > star_icon_x - 10: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - pilmoji.text( - xy=(stat_start, name_y), - text=username, - fill=user_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Prestige text ---------------- - if prestige: - text = _("(Prestige {})").format(f"{humanize_number(prestige)}") - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass stat_end - while font.getlength(text) + stat_start > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_start, name_y + 70), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - if prestige_emoji: - prestige_icon = Image.open(BytesIO(prestige_emoji)).resize((50, 50), Image.Resampling.LANCZOS) - if prestige_icon.mode != "RGBA": - prestige_icon = prestige_icon.convert("RGBA") - placement = (round(stat_start + font.getlength(text) + 10), name_y + 65) - stats.paste(prestige_icon, placement, prestige_icon) - # ---------------- Stars text ---------------- - text = humanize_number(stars) - fontsize = 60 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass stat_end - while font.getlength(text) + star_text_x > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(star_text_x, star_text_y), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - stats.paste(imgtools.STAR, (star_icon_x, star_icon_y), imgtools.STAR) - # ---------------- Rank text ---------------- - text = _("Rank: {}").format(f"#{humanize_number(position)}") - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass stat_split point - while font.getlength(text) + stat_start > stat_split - 5: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_start, stats_y), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Level text ---------------- - text = _("Level: {}").format(humanize_number(level)) - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass the stat_split point - while font.getlength(text) + stat_start > stat_split - 5: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_start, stats_y + stat_offset), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Messages text ---------------- - text = _("Messages: {}").format(humanize_number(messages)) - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass the stat_end - while font.getlength(text) + stat_split > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_split, stats_y), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Voice text ---------------- - text = _("Voice: {}").format(imgtools.abbreviate_time(voicetime)) - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass the stat_end - while font.getlength(text) + stat_split > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_split, stats_y + stat_offset), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Balance text ---------------- - if balance: - text = _("Balance: {}").format(f"{humanize_number(balance)} {currency_name}") - font = ImageFont.truetype(font_path, 40) - with Pilmoji(stats) as pilmoji: - # Ensure text doesnt pass the stat_end - while pilmoji.getsize(text, font)[0] + stat_start > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - placement = (stat_start, stat_bottom - stat_offset * 2) - pilmoji.text( - xy=placement, - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Experience text ---------------- - current = current_xp - previous_xp - goal = next_xp - previous_xp - text = _("Exp: {} ({} total)").format( - f"{humanize_number(current)}/{humanize_number(goal)}", humanize_number(current_xp) - ) - fontsize = 40 - font = ImageFont.truetype(font_path, fontsize) - # Ensure text doesnt pass the stat_end - while font.getlength(text) + stat_start > stat_end: - fontsize -= 1 - font = ImageFont.truetype(font_path, fontsize) - draw.text( - xy=(stat_start, stat_bottom - stat_offset), - text=text, - fill=stat_color, - stroke_width=stroke_width, - stroke_fill=default_fill, - font=font, - ) - # ---------------- Profile Accents ---------------- - # Draw a circle outline around where the avatar is - # Calculate the circle outline's placement around the avatar - circle = imgtools.make_circle_outline(thickness=5, color=user_color) - outline_size = (380, 380) - circle = circle.resize(outline_size, Image.Resampling.LANCZOS) - placement = (circle_x - 25, circle_y - 25) - stats.paste(circle, placement, circle) - # Place status icon - status_icon = imgtools.STATUS[status].resize((75, 75), Image.Resampling.LANCZOS) - stats.paste(status_icon, (circle_x + 260, circle_y + 260), status_icon) - # Paste role icon on top left of profile circle - if role_icon_bytes: - try: - role_icon_img = Image.open(BytesIO(role_icon_bytes)).resize((70, 70), Image.Resampling.LANCZOS) - stats.paste(role_icon_img, (10, 10), role_icon_img) - except ValueError as e: - if reraise: - raise e - err = ( - f"Failed to paste role icon image for {username}" - if isinstance(role_icon, bytes) - else f"Failed to paste role icon image for {username}: {role_icon}" - ) - log.error(err, exc_info=e) - - # ---------------- Start finalizing the image ---------------- - # Resize the profile image - desired_pfp_size = (330, 330) + # Composite the layers if not render_gif or (not pfp_animated and not bg_animated): if card.mode != "RGBA": log.debug(f"Converting card mode '{card.mode}' to RGBA") @@ -464,11 +351,11 @@ def generate_default_profile( # Paste onto the stats card.paste(blur_section, (blur_edge, 0), blur_section) card = imgtools.round_image_corners(card, 45) - pfp = pfp.resize(desired_pfp_size, Image.Resampling.LANCZOS) + pfp = pfp.resize(desired_card_size, Image.Resampling.LANCZOS) # Crop the profile image into a circle pfp = imgtools.make_profile_circle(pfp) # Paste the items onto the card - card.paste(stats, (0, 0), stats) + card.paste(stats_layer, (0, 0), stats_layer) card.paste(pfp, (circle_x, circle_y), pfp) if debug: card.show() @@ -487,7 +374,7 @@ def generate_default_profile( # Paste onto the stats card.paste(blur_section, (blur_edge, 0), blur_section) - card.paste(stats, (0, 0), stats) + 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") @@ -500,7 +387,7 @@ def generate_default_profile( if pfp_frame.mode != "RGBA": pfp_frame = pfp_frame.convert("RGBA") # Resize the profile image for each frame - pfp_frame = pfp_frame.resize(desired_pfp_size, Image.Resampling.NEAREST) + 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 @@ -530,7 +417,7 @@ def generate_default_profile( if pfp.mode != "RGBA": log.debug(f"Converting pfp mode '{pfp.mode}' to RGBA") pfp = pfp.convert("RGBA") - pfp = pfp.resize(desired_pfp_size, Image.Resampling.LANCZOS) + 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): @@ -547,7 +434,7 @@ def generate_default_profile( card_frame.paste(blur_section, (blur_edge, 0), blur_section) card_frame.paste(pfp, (circle_x, circle_y), pfp) - card_frame.paste(stats, (0, 0), stats) + card_frame.paste(stats_layer, (0, 0), stats_layer) frames.append(card_frame) @@ -622,11 +509,11 @@ def generate_default_profile( if pfp_frame.mode != "RGBA": pfp_frame = pfp_frame.convert("RGBA") - pfp_frame = pfp_frame.resize(desired_pfp_size, Image.Resampling.NEAREST) + 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, (0, 0), stats) + card_frame.paste(stats_layer, (0, 0), stats_layer) combined_frames.append(card_frame)