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.