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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user