Add animated GIF support for profile pictures

- Detect animated GIFs and preserve animation frames during upload
- Extract dominant color from first frame for role color syncing
- Generate multi-frame descriptions using existing video analysis pipeline
- Skip face detection/cropping for GIFs to maintain original animation
- Update UI to inform users about GIF support and Nitro requirement
- Add metadata flag to distinguish animated vs static profile pictures
This commit is contained in:
2025-12-07 23:48:12 +02:00
parent 782d8e4f84
commit 9009e9fc80
2 changed files with 160 additions and 0 deletions

View File

@@ -592,6 +592,10 @@
<label for="pfp-upload">Upload Custom Image:</label> <label for="pfp-upload">Upload Custom Image:</label>
<input type="file" id="pfp-upload" accept="image/*" style="margin-left: 0.5rem;"> <input type="file" id="pfp-upload" accept="image/*" style="margin-left: 0.5rem;">
<button onclick="uploadCustomPfp()">📤 Upload & Apply</button> <button onclick="uploadCustomPfp()">📤 Upload & Apply</button>
<div style="font-size: 0.8rem; color: #888; margin-top: 0.3rem; margin-left: 0.5rem;">
💡 Supports static images (PNG, JPG) and animated GIFs<br>
⚠️ Animated GIFs require Discord Nitro on the bot account
</div>
</div> </div>
<div id="pfp-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div> <div id="pfp-status" style="margin-top: 0.5rem; font-size: 0.9rem; color: #61dafb;"></div>

View File

@@ -2,6 +2,13 @@
""" """
Intelligent profile picture manager for Miku. Intelligent profile picture manager for Miku.
Handles searching, face detection, cropping, and Discord avatar updates. 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 import os
@@ -240,6 +247,7 @@ class ProfilePictureManager:
# Step 1: Get and validate image (with retry for Danbooru) # Step 1: Get and validate image (with retry for Danbooru)
image_bytes = None image_bytes = None
image = None image = None
is_animated_gif = False
if custom_image_bytes: if custom_image_bytes:
# Custom upload - no retry needed # Custom upload - no retry needed
@@ -253,6 +261,21 @@ class ProfilePictureManager:
image = Image.open(io.BytesIO(image_bytes)) image = Image.open(io.BytesIO(image_bytes))
if debug: if debug:
print(f"📐 Original image size: {image.size}") 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: except Exception as e:
result["error"] = f"Failed to open image: {e}" result["error"] = f"Failed to open image: {e}"
return result return result
@@ -318,6 +341,89 @@ class ProfilePictureManager:
result["error"] = f"Could not find valid Miku image after {max_retries} attempts" result["error"] = f"Could not find valid Miku image after {max_retries} attempts"
return result 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 # Step 2: Generate description of the validated image
if debug: if debug:
print("📝 Generating image description...") print("📝 Generating image description...")
@@ -385,6 +491,7 @@ class ProfilePictureManager:
result["success"] = True result["success"] = True
result["metadata"]["changed_at"] = datetime.now().isoformat() result["metadata"]["changed_at"] = datetime.now().isoformat()
result["metadata"]["animated"] = False
# Save metadata # Save metadata
self._save_metadata(result["metadata"]) self._save_metadata(result["metadata"])
@@ -521,6 +628,55 @@ Keep the description conversational and in second-person (referring to Miku as "
return None 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: 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. Use vision model to verify image contains Miku and locate her if multiple characters.