653 lines
27 KiB
Python
653 lines
27 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 = 2 # Width of the stroke around text
|
|
|
|
if square:
|
|
desired_card_size = (450, 450)
|
|
else:
|
|
# Slightly increase height to accommodate larger fonts
|
|
desired_card_size = (1050, 320)
|
|
|
|
# Define profile picture size and positions
|
|
pfp_size = (270, 270) # Slightly smaller profile picture
|
|
pfp_x = 55
|
|
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
|
|
stats_area = (
|
|
380, # x1 - Start after profile picture
|
|
15, # y1 - Start near top
|
|
1020, # x2 - End near right edge
|
|
305 # y2 - Adjusted height
|
|
)
|
|
|
|
# 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 for stats
|
|
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=(255, 255, 255, 25)) # Lighter, more modern glass
|
|
|
|
# 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(35 * (1 - i/40)) # Reduced opacity for 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)
|
|
|
|
# Add a subtle border glow
|
|
border_draw = ImageDraw.Draw(stats_layer)
|
|
for i in range(3):
|
|
border_draw.rounded_rectangle(
|
|
(stats_area[0]-i, stats_area[1]-i, stats_area[2]+i, stats_area[3]+i),
|
|
radius=25,
|
|
outline=(255, 255, 255, 80-i*25), # Lighter border
|
|
width=1
|
|
)
|
|
|
|
# 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), 56) # Increased size
|
|
level_y = stats_area[1] + 15
|
|
|
|
# Add subtle text shadow for depth
|
|
shadow_offset = 2
|
|
draw.text(
|
|
(stats_area[0] + 20 + shadow_offset, level_y + shadow_offset),
|
|
level_text,
|
|
font=level_font,
|
|
fill=(0, 0, 0, 80) # Reduced shadow opacity
|
|
)
|
|
draw.text(
|
|
(stats_area[0] + 20, level_y),
|
|
level_text,
|
|
font=level_font,
|
|
fill=user_color
|
|
)
|
|
|
|
# Stats section with improved layout and larger fonts
|
|
title_font_size = 28 # Increased from 26
|
|
value_font_size = 32 # Increased from 30
|
|
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 = 45 # Increased spacing
|
|
|
|
# Starting positions
|
|
start_x = stats_area[0] + 20
|
|
start_y = level_y + 75
|
|
|
|
# Helper function for stat rendering with improved spacing
|
|
def draw_stat(x_pos, y_pos, icon, title, value, color=stat_color):
|
|
# Draw icon with glow effect
|
|
for offset in [(1,1), (-1,-1), (1,-1), (-1,1)]:
|
|
draw.text((x_pos + offset[0], y_pos + offset[1] + 2), icon, font=value_font, fill=(255, 255, 255, 30))
|
|
draw.text((x_pos, y_pos + 2), icon, font=value_font, fill=color)
|
|
|
|
# Draw title with modern styling
|
|
title_x = x_pos + 40 # Increased spacing after icon
|
|
draw.text((title_x, y_pos), f"{title}:", font=title_font, fill=(220, 220, 220))
|
|
|
|
# Calculate value position
|
|
title_width = draw.textlength(f"{title}:", font=title_font)
|
|
value_x = title_x + title_width + 10 # Increased spacing
|
|
|
|
# Draw value with subtle shadow
|
|
draw.text((value_x + 1, y_pos + 1), value, font=value_font, fill=(0, 0, 0, 60))
|
|
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 = stats_area[0] + 340 # Adjusted for larger text
|
|
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
|
|
bar_width = stats_area[2] - stats_area[0] - 40 # Slightly narrower
|
|
bar_height = 30 # Taller bar
|
|
bar_y = stats_area[3] - 65 # Moved up slightly
|
|
progress = (current_xp - previous_xp) / (next_xp - previous_xp)
|
|
|
|
# XP text above progress bar
|
|
xp_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 26) # Increased size
|
|
xp_text = f"EXP: {humanize_number(current_xp)} / {humanize_number(next_xp)}"
|
|
xp_x = start_x + (bar_width - draw.textlength(xp_text, font=xp_font)) // 2
|
|
|
|
# Draw XP text with improved shadow
|
|
draw.text(
|
|
(xp_x + 1, bar_y - 30 + 1),
|
|
xp_text,
|
|
font=xp_font,
|
|
fill=(0, 0, 0, 60)
|
|
)
|
|
draw.text(
|
|
(xp_x, bar_y - 30),
|
|
xp_text,
|
|
font=xp_font,
|
|
fill=(255, 255, 255)
|
|
)
|
|
|
|
# Create progress bar with modern gradient
|
|
bar_bg = Image.new("RGBA", (bar_width, bar_height), (0, 0, 0, 40)) # More transparent background
|
|
progress_width = max(1, int(bar_width * progress))
|
|
bar_progress = Image.new("RGBA", (progress_width, bar_height), level_bar_color + (230,))
|
|
|
|
# Add shine effect to progress bar
|
|
gradient = Image.new("RGBA", bar_progress.size, (0, 0, 0, 0))
|
|
gradient_draw = ImageDraw.Draw(gradient)
|
|
for i in range(bar_height):
|
|
opacity = int(120 * (1 - abs(i - bar_height/2)/(bar_height/2))) # Reduced shine intensity
|
|
gradient_draw.rectangle(
|
|
(0, i, bar_progress.width, i+1),
|
|
fill=(255, 255, 255, opacity)
|
|
)
|
|
bar_progress = Image.alpha_composite(bar_progress, gradient)
|
|
|
|
# Round the corners more
|
|
bar_mask = Image.new("L", (bar_width, bar_height))
|
|
bar_mask_draw = ImageDraw.Draw(bar_mask)
|
|
bar_mask_draw.rounded_rectangle((0, 0, bar_width-1, bar_height-1), radius=bar_height//2, fill=255)
|
|
|
|
# Apply mask to background and progress bar
|
|
bar_bg.putalpha(bar_mask)
|
|
bar_progress_masked = Image.new("RGBA", (bar_width, bar_height), (0, 0, 0, 0))
|
|
bar_progress_masked.paste(bar_progress, (0, 0))
|
|
bar_progress_masked.putalpha(bar_mask)
|
|
|
|
# Paste progress bar onto stats layer
|
|
stats_layer.paste(bar_bg, (start_x, bar_y), bar_bg)
|
|
stats_layer.paste(bar_progress_masked, (start_x, bar_y), bar_progress_masked)
|
|
|
|
# Progress percentage with improved positioning and styling
|
|
percent_text = f"{int(progress * 100)}%"
|
|
percent_font = ImageFont.truetype(str(font_path or imgtools.DEFAULT_FONT), 24) # Dedicated font size
|
|
percent_width = draw.textlength(percent_text, font=percent_font)
|
|
|
|
# Position percentage on the progress bar itself
|
|
percent_x = start_x + progress_width - (percent_width // 2)
|
|
percent_x = min(max(start_x + 10, percent_x), start_x + bar_width - percent_width - 10) # Keep within bar bounds
|
|
percent_y = bar_y + (bar_height - 24) // 2 # Center vertically in bar
|
|
|
|
# Draw percentage with improved visibility
|
|
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) # White text for better contrast
|
|
)
|
|
|
|
# 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)
|