diff --git a/bot/static/index.html b/bot/static/index.html
index 4f39d12..ee00329 100644
--- a/bot/static/index.html
+++ b/bot/static/index.html
@@ -592,6 +592,10 @@
+
+ 💡 Supports static images (PNG, JPG) and animated GIFs
+ ⚠️ Animated GIFs require Discord Nitro on the bot account
+
diff --git a/bot/utils/profile_picture_manager.py b/bot/utils/profile_picture_manager.py
index cd748f4..ea567ad 100644
--- a/bot/utils/profile_picture_manager.py
+++ b/bot/utils/profile_picture_manager.py
@@ -2,6 +2,13 @@
"""
Intelligent profile picture manager for Miku.
Handles searching, face detection, cropping, and Discord avatar updates.
+
+Supports both static images and animated GIFs:
+- Static images (PNG, JPG, etc.): Full processing with face detection, smart cropping, resizing,
+ and single-frame description generation
+- Animated GIFs: Fast path that preserves animation, extracts frames for multi-frame description,
+ and extracts dominant color from first frame
+ Note: Animated avatars require Discord Nitro on the bot account
"""
import os
@@ -240,6 +247,7 @@ class ProfilePictureManager:
# Step 1: Get and validate image (with retry for Danbooru)
image_bytes = None
image = None
+ is_animated_gif = False
if custom_image_bytes:
# Custom upload - no retry needed
@@ -253,6 +261,21 @@ class ProfilePictureManager:
image = Image.open(io.BytesIO(image_bytes))
if debug:
print(f"📐 Original image size: {image.size}")
+
+ # Check if it's an animated GIF
+ if image.format == 'GIF':
+ try:
+ # Check if GIF has multiple frames
+ image.seek(1)
+ is_animated_gif = True
+ image.seek(0) # Reset to first frame
+ if debug:
+ print("🎬 Detected animated GIF - will preserve animation")
+ except EOFError:
+ # Only one frame, treat as static image
+ if debug:
+ print("🖼️ Single-frame GIF - will process as static image")
+
except Exception as e:
result["error"] = f"Failed to open image: {e}"
return result
@@ -318,6 +341,89 @@ class ProfilePictureManager:
result["error"] = f"Could not find valid Miku image after {max_retries} attempts"
return result
+ # === ANIMATED GIF FAST PATH ===
+ # If this is an animated GIF, skip most processing and use raw bytes
+ if is_animated_gif:
+ if debug:
+ print("🎬 Using GIF fast path - skipping face detection and cropping")
+
+ # Generate description of the animated GIF
+ if debug:
+ print("📝 Generating GIF description using video analysis pipeline...")
+ description = await self._generate_gif_description(image_bytes, debug=debug)
+ if description:
+ # Save description to file
+ description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
+ try:
+ with open(description_path, 'w', encoding='utf-8') as f:
+ f.write(description)
+ result["metadata"]["description"] = description
+ if debug:
+ print(f"📝 Saved GIF description ({len(description)} chars)")
+ except Exception as e:
+ print(f"⚠️ Failed to save description file: {e}")
+ else:
+ if debug:
+ print("⚠️ GIF description generation returned None")
+
+ # Extract dominant color from first frame
+ dominant_color = self._extract_dominant_color(image, debug=debug)
+ if dominant_color:
+ result["metadata"]["dominant_color"] = {
+ "rgb": dominant_color,
+ "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
+ }
+ if debug:
+ print(f"🎨 Dominant color from first frame: RGB{dominant_color} (#{result['metadata']['dominant_color']['hex'][1:]})")
+
+ # Save the original GIF bytes
+ with open(self.CURRENT_PATH, 'wb') as f:
+ f.write(image_bytes)
+
+ if debug:
+ print(f"💾 Saved animated GIF ({len(image_bytes)} bytes)")
+
+ # Update Discord avatar with original GIF
+ if globals.client and globals.client.user:
+ try:
+ if globals.client.loop and globals.client.loop.is_running():
+ future = asyncio.run_coroutine_threadsafe(
+ globals.client.user.edit(avatar=image_bytes),
+ globals.client.loop
+ )
+ future.result(timeout=10)
+ else:
+ await globals.client.user.edit(avatar=image_bytes)
+
+ result["success"] = True
+ result["metadata"]["changed_at"] = datetime.now().isoformat()
+ result["metadata"]["animated"] = True
+
+ # Save metadata
+ self._save_metadata(result["metadata"])
+
+ print(f"✅ Animated profile picture updated successfully!")
+
+ # Update role colors if we have a dominant color
+ if dominant_color:
+ await self._update_role_colors(dominant_color, debug=debug)
+
+ return result
+
+ except discord.HTTPException as e:
+ result["error"] = f"Discord API error: {e}"
+ print(f"⚠️ Failed to update Discord avatar with GIF: {e}")
+ print(f" Note: Animated avatars require Discord Nitro")
+ return result
+ except Exception as e:
+ result["error"] = f"Unexpected error updating avatar: {e}"
+ print(f"⚠️ Unexpected error: {e}")
+ return result
+ else:
+ result["error"] = "Bot client not ready"
+ return result
+
+ # === NORMAL STATIC IMAGE PATH ===
# Step 2: Generate description of the validated image
if debug:
print("📝 Generating image description...")
@@ -385,6 +491,7 @@ class ProfilePictureManager:
result["success"] = True
result["metadata"]["changed_at"] = datetime.now().isoformat()
+ result["metadata"]["animated"] = False
# Save metadata
self._save_metadata(result["metadata"])
@@ -521,6 +628,55 @@ Keep the description conversational and in second-person (referring to Miku as "
return None
+ async def _generate_gif_description(self, gif_bytes: bytes, debug: bool = False) -> Optional[str]:
+ """
+ Generate a detailed description of an animated GIF using the video analysis pipeline.
+
+ Args:
+ gif_bytes: Raw GIF bytes
+ debug: Enable debug output
+
+ Returns:
+ Description string or None
+ """
+ try:
+ from utils.image_handling import extract_video_frames, analyze_video_with_vision
+
+ if debug:
+ print("🎬 Extracting frames from GIF...")
+
+ # Extract frames from the GIF (6 frames for good analysis)
+ frames = await extract_video_frames(gif_bytes, num_frames=6)
+
+ if not frames:
+ if debug:
+ print("⚠️ Failed to extract frames from GIF")
+ return None
+
+ if debug:
+ print(f"✅ Extracted {len(frames)} frames from GIF")
+ print(f"🌐 Analyzing GIF with vision model...")
+
+ # Use the existing analyze_video_with_vision function (no timeout issues)
+ # Note: This uses a generic prompt, but it works reliably
+ description = await analyze_video_with_vision(frames, media_type="gif")
+
+ if description and description.strip() and not description.startswith("Error"):
+ if debug:
+ print(f"✅ Generated GIF description: {description[:100]}...")
+ return description.strip()
+ else:
+ if debug:
+ print(f"⚠️ GIF description failed or empty: {description}")
+ return None
+
+ except Exception as e:
+ print(f"⚠️ Error generating GIF description: {e}")
+ import traceback
+ traceback.print_exc()
+
+ return None
+
async def _verify_and_locate_miku(self, image_bytes: bytes, debug: bool = False) -> Dict:
"""
Use vision model to verify image contains Miku and locate her if multiple characters.