From f092cadb9dd1bb9c516512c74d8f0f30abc68832 Mon Sep 17 00:00:00 2001 From: koko210Serve Date: Mon, 30 Mar 2026 15:10:19 +0300 Subject: [PATCH] feat: add Profile Picture Management tab with manual crop, description editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - profile_picture_manager.py: - Add ORIGINAL_PATH constant; save full-res original before every crop - Add skip_crop param to change_profile_picture() for manual crop workflow - Add manual_crop(x,y,w,h) method with Discord avatar update + role color sync - Add auto_crop_only() to re-run face-detection crop on stored original - Add update_description() with Cheshire Cat declarative memory re-injection - Add regenerate_description() via vision model - Skip crop step if image is already at/below 512x512 - api.py: - GET /profile-picture/image/original — serve full-res original (no-cache) - GET /profile-picture/image/current — serve current cropped avatar (no-cache) - POST /profile-picture/change-no-crop — acquire image, skip auto-crop - POST /profile-picture/manual-crop — apply crop coords {x,y,width,height} - POST /profile-picture/auto-crop — re-run intelligent crop on original - POST /profile-picture/description — save freeform description + Cat inject - POST /profile-picture/regenerate-description — re-generate via vision model - GET /profile-picture/description — fetch current description text - index.html: - Add new tab11 '🖼️ Profile Picture Management' - Remove PFP + role color sections from Actions tab (tab2) - Add Cropper.js 1.6.2 via CDN for manual square crop - Tab layout: action buttons, file upload, auto/manual crop toggle, Cropper.js interface, side-by-side original/cropped previews, role color management, freeform description editor, metadata box (bottom) - Wire switchTab hook for tab11 → loadPfpTab() - All new JS functions: pfpChangeDanbooru, pfpUploadCustom, pfpRestoreFallback, pfpShowCropInterface, pfpApplyManualCrop, pfpApplyAutoCrop, pfpSaveDescription, pfpRegenerateDescription, pfpRefreshPreviews, setCustomRoleColor, resetRoleColor --- bot/api.py | 168 +++++++ bot/static/index.html | 640 +++++++++++++++++++++------ bot/utils/profile_picture_manager.py | 329 +++++++++++++- 3 files changed, 991 insertions(+), 146 deletions(-) diff --git a/bot/api.py b/bot/api.py index fe5ebb6..ba1a20a 100644 --- a/bot/api.py +++ b/bot/api.py @@ -1121,6 +1121,174 @@ async def reset_role_color_to_fallback(): except Exception as e: return {"status": "error", "message": str(e)} +# === Profile Picture Image Serving === + +@app.get("/profile-picture/image/original") +async def serve_original_profile_picture(): + """Serve the full-resolution original profile picture""" + from utils.profile_picture_manager import profile_picture_manager + path = profile_picture_manager.ORIGINAL_PATH + if not os.path.exists(path): + return {"status": "error", "message": "No original image found"} + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) + +@app.get("/profile-picture/image/current") +async def serve_current_profile_picture(): + """Serve the current cropped profile picture""" + from utils.profile_picture_manager import profile_picture_manager + path = profile_picture_manager.CURRENT_PATH + if not os.path.exists(path): + return {"status": "error", "message": "No current image found"} + return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"}) + +# === Profile Picture Manual Crop Workflow === + +@app.post("/profile-picture/change-no-crop") +async def trigger_profile_picture_change_no_crop( + guild_id: int = None, + file: UploadFile = File(None) +): + """ + Change Miku's profile picture but skip auto-cropping. + Saves the full-resolution original for manual cropping later. + """ + if not globals.client or not globals.client.loop or not globals.client.loop.is_running(): + return {"status": "error", "message": "Bot not ready"} + + try: + from utils.profile_picture_manager import profile_picture_manager + from server_manager import server_manager + + mood = None + if guild_id is not None: + mood, _ = server_manager.get_server_mood(guild_id) + else: + mood = globals.DM_MOOD + + custom_image_bytes = None + if file: + custom_image_bytes = await file.read() + logger.info(f"Received custom image for manual crop ({len(custom_image_bytes)} bytes)") + + result = await profile_picture_manager.change_profile_picture( + mood=mood, + custom_image_bytes=custom_image_bytes, + debug=True, + skip_crop=True + ) + + if result["success"]: + return { + "status": "ok", + "message": "Image saved for manual cropping", + "source": result["source"], + "metadata": result.get("metadata", {}) + } + else: + return { + "status": "error", + "message": result.get("error", "Unknown error"), + "source": result.get("source") + } + except Exception as e: + logger.error(f"Error in change-no-crop API: {e}") + import traceback + traceback.print_exc() + return {"status": "error", "message": f"Unexpected error: {str(e)}"} + +class ManualCropRequest(BaseModel): + x: int + y: int + width: int + height: int + +@app.post("/profile-picture/manual-crop") +async def apply_manual_crop(req: ManualCropRequest): + """Apply a manual crop to the stored original image""" + if not globals.client or not globals.client.loop or not globals.client.loop.is_running(): + return {"status": "error", "message": "Bot not ready"} + + try: + from utils.profile_picture_manager import profile_picture_manager + result = await profile_picture_manager.manual_crop( + x=req.x, y=req.y, width=req.width, height=req.height, debug=True + ) + if result["success"]: + return { + "status": "ok", + "message": "Manual crop applied successfully", + "metadata": result.get("metadata", {}) + } + else: + return {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.post("/profile-picture/auto-crop") +async def apply_auto_crop(): + """Run intelligent auto-crop on the stored original image""" + if not globals.client or not globals.client.loop or not globals.client.loop.is_running(): + return {"status": "error", "message": "Bot not ready"} + + try: + from utils.profile_picture_manager import profile_picture_manager + result = await profile_picture_manager.auto_crop_only(debug=True) + if result["success"]: + return { + "status": "ok", + "message": "Auto-crop applied successfully", + "metadata": result.get("metadata", {}) + } + else: + return {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +class DescriptionUpdateRequest(BaseModel): + description: str + +@app.post("/profile-picture/description") +async def update_profile_picture_description(req: DescriptionUpdateRequest): + """Update the profile picture description (and optionally re-inject into Cat)""" + try: + from utils.profile_picture_manager import profile_picture_manager + result = await profile_picture_manager.update_description( + description=req.description, reinject_cat=True, debug=True + ) + if result["success"]: + return {"status": "ok", "message": "Description updated successfully"} + else: + return {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.post("/profile-picture/regenerate-description") +async def regenerate_profile_picture_description(): + """Re-generate the profile picture description using the vision model""" + try: + from utils.profile_picture_manager import profile_picture_manager + result = await profile_picture_manager.regenerate_description(debug=True) + if result["success"]: + return { + "status": "ok", + "message": "Description regenerated successfully", + "description": result["description"] + } + else: + return {"status": "error", "message": result.get("error", "Unknown error")} + except Exception as e: + return {"status": "error", "message": str(e)} + +@app.get("/profile-picture/description") +async def get_profile_picture_description(): + """Get the current profile picture description text""" + try: + from utils.profile_picture_manager import profile_picture_manager + description = profile_picture_manager.get_current_description() + return {"status": "ok", "description": description or ""} + except Exception as e: + return {"status": "error", "message": str(e)} + @app.post("/manual/send") async def manual_send( message: str = Form(...), diff --git a/bot/static/index.html b/bot/static/index.html index 8020173..771da2c 100644 --- a/bot/static/index.html +++ b/bot/static/index.html @@ -4,6 +4,8 @@ Miku Control Panel + + @@ -704,6 +776,7 @@ + @@ -921,49 +994,7 @@ -
-

🎨 Profile Picture

-

Change Miku's profile picture using Danbooru search or upload a custom image.

- -
- - -
- -
- - - -
- 💡 Supports static images (PNG, JPG) and animated GIFs
- ⚠️ Animated GIFs require Discord Nitro on the bot account -
-
- -
- - - - -
-

🎨 Role Color Management

-

Manually set Miku's role color or reset to fallback (#86cecb)

- -
-
- - -
- - -
- -
-
-
+

Figurine DM Subscribers

@@ -1740,6 +1771,113 @@
+ +
+
+

🖼️ Profile Picture Management

+

Change, crop, and manage Miku's profile picture. Edit descriptions and role colors.

+ + +
+ + + +
+ + +
+ + + +
+ 💡 Supports static images (PNG, JPG) and animated GIFs  |  + ⚠️ Animated GIFs require Discord Nitro on the bot account +
+
+ + +
+ Crop Mode: + + +
+ + +
+ + + + + +
+
+ 📷 Original (full resolution) + Original +
+
+
+ 🎯 Current Avatar (cropped) + Current avatar +
512×512 · displayed as circle
+
+
+ + +
+

🎨 Role Color Management

+

Manually set Miku's role color or reset to fallback (#86cecb)

+ +
+
+ + +
+ + +
+ +
+
+ + +
+

📝 Profile Picture Description

+

Edit the description used for context when users ask about Miku's avatar. Saved to Cheshire Cat memory.

+ +
+ + +
+
+
+ + + +
+
+ @@ -1888,6 +2026,10 @@ function switchTab(tabId) { showTabLoading('tab10'); loadDMUsers().finally(() => hideTabLoading('tab10')); } + if (tabId === 'tab11') { + console.log('🖼️ Loading Profile Picture tab'); + loadPfpTab(); + } } function showTabLoading(tabId) { @@ -2187,22 +2329,9 @@ function displayServers() { } async function loadProfilePictureMetadata() { - try { - const result = await apiCall('/profile-picture/metadata'); - - if (result.status === 'ok' && result.metadata) { - const metadataDiv = document.getElementById('pfp-metadata'); - const metadataContent = document.getElementById('pfp-metadata-content'); - - metadataContent.textContent = JSON.stringify(result.metadata, null, 2); - metadataDiv.style.display = 'block'; - - console.log('🎨 Loaded profile picture metadata:', result.metadata); - } else { - console.log('🎨 No profile picture metadata available'); - } - } catch (error) { - console.error('🎨 Failed to load profile picture metadata:', error); + // Delegated to PFP tab loader — only runs if tab11 is active + if (document.getElementById('tab11') && document.getElementById('tab11').classList.contains('active')) { + await loadPfpTab(); } } @@ -3333,52 +3462,113 @@ async function triggerShareTweet() { } } -// Profile Picture Management -async function changeProfilePicture() { - const selectedServer = document.getElementById('server-select').value; - const statusDiv = document.getElementById('pfp-status'); - const metadataDiv = document.getElementById('pfp-metadata'); - const metadataContent = document.getElementById('pfp-metadata-content'); +// ============================================================================ +// Profile Picture Tab (tab11) — Full Management +// ============================================================================ + +let pfpCropper = null; // Cropper.js instance + +function getPfpCropMode() { + const radio = document.querySelector('input[name="pfp-crop-mode"]:checked'); + return radio ? radio.value : 'auto'; +} + +function pfpSetStatus(text, color = '#61dafb') { + const el = document.getElementById('pfp-tab-status'); + if (el) { el.textContent = text; el.style.color = color; } +} + +function pfpRefreshPreviews() { + const t = Date.now(); + const origImg = document.getElementById('pfp-preview-original'); + const curImg = document.getElementById('pfp-preview-current'); + if (origImg) origImg.src = `/profile-picture/image/original?t=${t}`; + if (curImg) curImg.src = `/profile-picture/image/current?t=${t}`; +} + +async function loadPfpTab() { + // Load metadata + try { + const result = await apiCall('/profile-picture/metadata'); + if (result.status === 'ok' && result.metadata) { + const metaDiv = document.getElementById('pfp-tab-metadata'); + const metaContent = document.getElementById('pfp-tab-metadata-content'); + metaContent.textContent = JSON.stringify(result.metadata, null, 2); + metaDiv.style.display = 'block'; + + // Show original dimensions if available + const dimsEl = document.getElementById('pfp-original-dims'); + if (dimsEl && result.metadata.original_width) { + dimsEl.textContent = `${result.metadata.original_width}×${result.metadata.original_height}`; + } + } + } catch (e) { + console.error('Failed to load PFP metadata:', e); + } - statusDiv.textContent = '⏳ Searching Danbooru and changing profile picture...'; - statusDiv.style.color = '#61dafb'; + // Load description + try { + const result = await apiCall('/profile-picture/description'); + if (result.status === 'ok') { + document.getElementById('pfp-description-editor').value = result.description || ''; + } + } catch (e) { + console.error('Failed to load PFP description:', e); + } + + // Refresh preview images + pfpRefreshPreviews(); +} + +// --- Danbooru Change --- +async function pfpChangeDanbooru() { + const mode = getPfpCropMode(); + const selectedServer = document.getElementById('server-select').value; + pfpSetStatus('⏳ Searching Danbooru...'); try { - let endpoint = '/profile-picture/change'; + const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change'; const params = new URLSearchParams(); - - // Add guild_id parameter if a specific server is selected - if (selectedServer !== 'all') { - params.append('guild_id', selectedServer); - } - + if (selectedServer !== 'all') params.append('guild_id', selectedServer); const url = params.toString() ? `${endpoint}?${params.toString()}` : endpoint; const result = await apiCall(url, 'POST'); - statusDiv.textContent = `✅ ${result.message}`; - statusDiv.style.color = 'green'; - - // Display metadata if available - if (result.metadata) { - metadataContent.textContent = JSON.stringify(result.metadata, null, 2); - metadataDiv.style.display = 'block'; + if (result.status === 'ok') { + pfpSetStatus(`✅ ${result.message}`, 'green'); + showNotification('Profile picture changed!'); + + // Show metadata + const metaDiv = document.getElementById('pfp-tab-metadata'); + const metaContent = document.getElementById('pfp-tab-metadata-content'); + if (result.metadata) { + metaContent.textContent = JSON.stringify(result.metadata, null, 2); + metaDiv.style.display = 'block'; + } + + pfpRefreshPreviews(); + + // If manual mode, show crop interface + if (mode === 'manual') { + pfpShowCropInterface(); + } + } else { + throw new Error(result.message || 'Unknown error'); } - - showNotification('Profile picture changed successfully!'); } catch (error) { - console.error('Failed to change profile picture:', error); - statusDiv.textContent = `❌ Error: ${error.message}`; - statusDiv.style.color = 'red'; + console.error('PFP Danbooru error:', error); + pfpSetStatus(`❌ Error: ${error.message}`, 'red'); } } -async function uploadCustomPfp() { - const fileInput = document.getElementById('pfp-upload'); +// Keep old function names working (backwards compatibility for autonomous/API callers) +async function changeProfilePicture() { await pfpChangeDanbooru(); } + +// --- Custom Upload --- +async function pfpUploadCustom() { + const fileInput = document.getElementById('pfp-tab-upload'); + const mode = getPfpCropMode(); const selectedServer = document.getElementById('server-select').value; - const statusDiv = document.getElementById('pfp-status'); - const metadataDiv = document.getElementById('pfp-metadata'); - const metadataContent = document.getElementById('pfp-metadata-content'); if (!fileInput.files || fileInput.files.length === 0) { showNotification('Please select an image file first', 'error'); @@ -3386,88 +3576,249 @@ async function uploadCustomPfp() { } const file = fileInput.files[0]; - - // Validate file type if (!file.type.startsWith('image/')) { showNotification('Please select a valid image file', 'error'); return; } - statusDiv.textContent = '⏳ Uploading and processing custom image...'; - statusDiv.style.color = '#61dafb'; + pfpSetStatus('⏳ Uploading and processing...'); try { const formData = new FormData(); formData.append('file', file); - // Add guild_id parameter if a specific server is selected - let endpoint = '/profile-picture/change'; - if (selectedServer !== 'all') { - endpoint += `?guild_id=${selectedServer}`; - } - - const response = await fetch(endpoint, { - method: 'POST', - body: formData - }); + const endpoint = mode === 'manual' ? '/profile-picture/change-no-crop' : '/profile-picture/change'; + let url = endpoint; + if (selectedServer !== 'all') url += `?guild_id=${selectedServer}`; + const response = await fetch(url, { method: 'POST', body: formData }); const result = await response.json(); if (response.ok && result.status === 'ok') { - statusDiv.textContent = `✅ ${result.message}`; - statusDiv.style.color = 'green'; - - // Display metadata if available - if (result.metadata) { - metadataContent.textContent = JSON.stringify(result.metadata, null, 2); - metadataDiv.style.display = 'block'; - } - - // Clear file input + pfpSetStatus(`✅ ${result.message}`, 'green'); + showNotification('Image uploaded successfully!'); fileInput.value = ''; - showNotification('Custom profile picture applied successfully!'); + if (result.metadata) { + const metaDiv = document.getElementById('pfp-tab-metadata'); + const metaContent = document.getElementById('pfp-tab-metadata-content'); + metaContent.textContent = JSON.stringify(result.metadata, null, 2); + metaDiv.style.display = 'block'; + } + + pfpRefreshPreviews(); + + if (mode === 'manual') { + pfpShowCropInterface(); + } } else { - throw new Error(result.message || 'Failed to apply custom profile picture'); + throw new Error(result.message || 'Upload failed'); } } catch (error) { - console.error('Failed to upload custom profile picture:', error); - statusDiv.textContent = `❌ Error: ${error.message}`; - statusDiv.style.color = 'red'; - showNotification(error.message || 'Failed to upload custom profile picture', 'error'); + console.error('PFP upload error:', error); + pfpSetStatus(`❌ Error: ${error.message}`, 'red'); + showNotification(error.message, 'error'); } } -async function restoreFallbackPfp() { - const statusDiv = document.getElementById('pfp-status'); - const metadataDiv = document.getElementById('pfp-metadata'); +// Keep old function name working +async function uploadCustomPfp() { await pfpUploadCustom(); } + +// --- Restore Fallback --- +async function pfpRestoreFallback() { + if (!confirm('Are you sure you want to restore the original fallback avatar?')) return; - if (!confirm('Are you sure you want to restore the original fallback avatar?')) { - return; - } - - statusDiv.textContent = '⏳ Restoring original avatar...'; - statusDiv.style.color = '#61dafb'; + pfpSetStatus('⏳ Restoring original avatar...'); try { const result = await apiCall('/profile-picture/restore-fallback', 'POST'); - - statusDiv.textContent = `✅ ${result.message}`; - statusDiv.style.color = 'green'; - metadataDiv.style.display = 'none'; - - showNotification('Original avatar restored successfully!'); + pfpSetStatus(`✅ ${result.message}`, 'green'); + document.getElementById('pfp-tab-metadata').style.display = 'none'; + pfpRefreshPreviews(); + showNotification('Original avatar restored!'); } catch (error) { - console.error('Failed to restore fallback avatar:', error); - statusDiv.textContent = `❌ Error: ${error.message}`; - statusDiv.style.color = 'red'; + console.error('Restore fallback error:', error); + pfpSetStatus(`❌ Error: ${error.message}`, 'red'); } } -// Role Color Management +async function restoreFallbackPfp() { await pfpRestoreFallback(); } + +// --- Crop Interface --- +function pfpShowCropInterface() { + const section = document.getElementById('pfp-crop-section'); + const img = document.getElementById('pfp-crop-image'); + + // Destroy previous cropper if any + if (pfpCropper) { + pfpCropper.destroy(); + pfpCropper = null; + } + + // Load original image + img.src = `/profile-picture/image/original?t=${Date.now()}`; + section.style.display = 'block'; + + img.onload = function() { + pfpCropper = new Cropper(img, { + aspectRatio: 1, + viewMode: 2, + minCropBoxWidth: 64, + minCropBoxHeight: 64, + responsive: true, + autoCropArea: 0.8, + background: true, + guides: true, + center: true, + highlight: true, + cropBoxMovable: true, + cropBoxResizable: true, + toggleDragModeOnDblclick: false + }); + }; +} + +function pfpHideCropInterface() { + if (pfpCropper) { + pfpCropper.destroy(); + pfpCropper = null; + } + document.getElementById('pfp-crop-section').style.display = 'none'; +} + +// Re-crop: open crop interface on stored original +function pfpRecrop() { + pfpShowCropInterface(); +} + +async function pfpApplyManualCrop() { + if (!pfpCropper) { + showNotification('No crop region selected', 'error'); + return; + } + + const data = pfpCropper.getData(true); // rounded integers + pfpSetStatus('⏳ Applying manual crop...'); + + try { + const response = await fetch('/profile-picture/manual-crop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + x: Math.round(data.x), + y: Math.round(data.y), + width: Math.round(data.width), + height: Math.round(data.height) + }) + }); + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + pfpSetStatus(`✅ ${result.message}`, 'green'); + showNotification('Manual crop applied!'); + pfpHideCropInterface(); + pfpRefreshPreviews(); + + // Refresh metadata + if (result.metadata) { + const metaContent = document.getElementById('pfp-tab-metadata-content'); + const existing = metaContent.textContent ? JSON.parse(metaContent.textContent) : {}; + Object.assign(existing, result.metadata); + metaContent.textContent = JSON.stringify(existing, null, 2); + } + } else { + throw new Error(result.message || 'Crop failed'); + } + } catch (error) { + console.error('Manual crop error:', error); + pfpSetStatus(`❌ Error: ${error.message}`, 'red'); + } +} + +async function pfpApplyAutoCrop() { + pfpSetStatus('⏳ Running auto-crop (face detection)...'); + + try { + const result = await apiCall('/profile-picture/auto-crop', 'POST'); + + if (result.status === 'ok') { + pfpSetStatus(`✅ ${result.message}`, 'green'); + showNotification('Auto-crop applied!'); + pfpHideCropInterface(); + pfpRefreshPreviews(); + } else { + throw new Error(result.message || 'Auto-crop failed'); + } + } catch (error) { + console.error('Auto-crop error:', error); + pfpSetStatus(`❌ Error: ${error.message}`, 'red'); + } +} + +// --- Description --- +async function pfpSaveDescription() { + const descEl = document.getElementById('pfp-description-editor'); + const statusEl = document.getElementById('pfp-desc-status'); + const description = descEl.value.trim(); + + if (!description) { + statusEl.textContent = '⚠️ Description cannot be empty'; + statusEl.style.color = 'orange'; + return; + } + + statusEl.textContent = '⏳ Saving description...'; + statusEl.style.color = '#61dafb'; + + try { + const response = await fetch('/profile-picture/description', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ description }) + }); + const result = await response.json(); + + if (response.ok && result.status === 'ok') { + statusEl.textContent = '✅ Description saved & injected into Cat memory'; + statusEl.style.color = 'green'; + showNotification('Description saved!'); + } else { + throw new Error(result.message || 'Save failed'); + } + } catch (error) { + console.error('Save description error:', error); + statusEl.textContent = `❌ Error: ${error.message}`; + statusEl.style.color = 'red'; + } +} + +async function pfpRegenerateDescription() { + const statusEl = document.getElementById('pfp-desc-status'); + statusEl.textContent = '⏳ Regenerating description via vision model...'; + statusEl.style.color = '#61dafb'; + + try { + const result = await apiCall('/profile-picture/regenerate-description', 'POST'); + + if (result.status === 'ok' && result.description) { + document.getElementById('pfp-description-editor').value = result.description; + statusEl.textContent = '✅ Description regenerated & saved'; + statusEl.style.color = 'green'; + showNotification('Description regenerated!'); + } else { + throw new Error(result.message || 'Regeneration failed'); + } + } catch (error) { + console.error('Regenerate description error:', error); + statusEl.textContent = `❌ Error: ${error.message}`; + statusEl.style.color = 'red'; + } +} + +// --- Role Color (updated element IDs for tab11) --- async function setCustomRoleColor() { - const statusDiv = document.getElementById('role-color-status'); - const hexInput = document.getElementById('role-color-hex'); + const statusDiv = document.getElementById('pfp-tab-role-color-status'); + const hexInput = document.getElementById('pfp-tab-role-color-hex'); const hexColor = hexInput.value.trim(); if (!hexColor) { @@ -3506,7 +3857,7 @@ async function setCustomRoleColor() { } async function resetRoleColor() { - const statusDiv = document.getElementById('role-color-status'); + const statusDiv = document.getElementById('pfp-tab-role-color-status'); statusDiv.textContent = '⏳ Resetting to fallback color...'; statusDiv.style.color = '#61dafb'; @@ -3517,8 +3868,7 @@ async function resetRoleColor() { statusDiv.textContent = `✅ ${result.message}`; statusDiv.style.color = 'green'; - // Update the input to show fallback color - document.getElementById('role-color-hex').value = '#86cecb'; + document.getElementById('pfp-tab-role-color-hex').value = '#86cecb'; showNotification('Role color reset to fallback #86cecb'); } catch (error) { diff --git a/bot/utils/profile_picture_manager.py b/bot/utils/profile_picture_manager.py index d459b1a..ec18f16 100644 --- a/bot/utils/profile_picture_manager.py +++ b/bot/utils/profile_picture_manager.py @@ -37,6 +37,7 @@ class ProfilePictureManager: PROFILE_PIC_DIR = "memory/profile_pictures" FALLBACK_PATH = "memory/profile_pictures/fallback.png" CURRENT_PATH = "memory/profile_pictures/current.png" + ORIGINAL_PATH = "memory/profile_pictures/original.png" METADATA_PATH = "memory/profile_pictures/metadata.json" # Face detection API endpoint @@ -244,7 +245,8 @@ class ProfilePictureManager: mood: Optional[str] = None, custom_image_bytes: Optional[bytes] = None, debug: bool = False, - max_retries: int = 5 + max_retries: int = 5, + skip_crop: bool = False ) -> Dict: """ Main function to change Miku's profile picture. @@ -254,6 +256,7 @@ class ProfilePictureManager: custom_image_bytes: If provided, use this image instead of Danbooru debug: Enable debug output max_retries: Maximum number of attempts to find a valid Miku image (for Danbooru) + skip_crop: If True, save original but skip cropping/description (for manual crop workflow) Returns: Dict with status and metadata @@ -467,6 +470,24 @@ class ProfilePictureManager: return result # === NORMAL STATIC IMAGE PATH === + # Save full-resolution original for manual cropping later + with open(self.ORIGINAL_PATH, 'wb') as f: + f.write(image_bytes) + if debug: + logger.info(f"Saved full-resolution original ({len(image_bytes)} bytes, {image.size[0]}x{image.size[1]})") + result["metadata"]["original_width"] = image.size[0] + result["metadata"]["original_height"] = image.size[1] + + # If skip_crop requested (manual crop workflow), return early with original saved + if skip_crop: + result["success"] = True + result["metadata"]["changed_at"] = datetime.now().isoformat() + result["metadata"]["skip_crop"] = True + self._save_metadata(result["metadata"]) + if debug: + logger.info("Skipping auto-crop (manual crop workflow) - original saved") + return result + # Step 2: Generate description of the validated image if debug: logger.info("Generating image description...") @@ -1425,6 +1446,312 @@ Respond in JSON format: logger.error(f"Error reading description: {e}") return None + + async def manual_crop(self, x: int, y: int, width: int, height: int, target_size: int = 512, debug: bool = False) -> Dict: + """ + Manually crop the stored original image and apply it as the Discord avatar. + + Args: + x: Left edge of crop region (pixels) + y: Top edge of crop region (pixels) + width: Width of crop region (pixels) + height: Height of crop region (pixels) + target_size: Final resize target (default 512) + debug: Enable debug output + + Returns: + Dict with success status and metadata + """ + result = {"success": False, "error": None, "metadata": {}} + + try: + if not os.path.exists(self.ORIGINAL_PATH): + result["error"] = "No original image found. Upload or fetch an image first." + return result + + image = Image.open(self.ORIGINAL_PATH) + img_width, img_height = image.size + + # Validate crop region + if x < 0 or y < 0: + result["error"] = f"Crop coordinates must be non-negative (got x={x}, y={y})" + return result + if x + width > img_width or y + height > img_height: + result["error"] = f"Crop region ({x},{y},{width},{height}) exceeds image bounds ({img_width}x{img_height})" + return result + if width < 64 or height < 64: + result["error"] = f"Crop region too small (minimum 64x64, got {width}x{height})" + return result + + # Perform crop + cropped = image.crop((x, y, x + width, y + height)) + + # Resize to target + cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS) + + if debug: + logger.info(f"Manual crop: ({x},{y},{width},{height}) -> {target_size}x{target_size}") + + # Save cropped image + output_buffer = io.BytesIO() + cropped.save(output_buffer, format='PNG') + cropped_bytes = output_buffer.getvalue() + + with open(self.CURRENT_PATH, 'wb') as f: + f.write(cropped_bytes) + + # Extract dominant color + dominant_color = self._extract_dominant_color(cropped, debug=debug) + if dominant_color: + result["metadata"]["dominant_color"] = { + "rgb": dominant_color, + "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color) + } + + # Update Discord avatar + 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=cropped_bytes), + globals.client.loop + ) + future.result(timeout=10) + else: + await globals.client.user.edit(avatar=cropped_bytes) + + result["success"] = True + result["metadata"]["changed_at"] = datetime.now().isoformat() + result["metadata"]["crop_region"] = {"x": x, "y": y, "width": width, "height": height} + + # Update existing metadata + existing_meta = self.load_metadata() or {} + existing_meta.update(result["metadata"]) + existing_meta.pop("skip_crop", None) + self._save_metadata(existing_meta) + + logger.info("Manual crop applied and Discord avatar updated") + + # Update role colors + if dominant_color: + await self._update_role_colors(dominant_color, debug=debug) + + # Update bipolar webhook avatars + if globals.BIPOLAR_MODE: + try: + from utils.bipolar_mode import update_webhook_avatars + await update_webhook_avatars(globals.client) + except Exception as e: + logger.warning(f"Failed to update bipolar webhook avatars: {e}") + + except discord.HTTPException as e: + result["error"] = f"Discord API error: {e}" + except Exception as e: + result["error"] = f"Unexpected error: {e}" + else: + result["error"] = "Bot client not ready" + + except Exception as e: + result["error"] = f"Error in manual crop: {e}" + logger.error(f"Error in manual_crop: {e}") + + return result + + async def auto_crop_only(self, debug: bool = False) -> Dict: + """ + Run intelligent auto-crop on the stored original image and apply as Discord avatar. + + Returns: + Dict with success status and metadata + """ + result = {"success": False, "error": None, "metadata": {}} + + try: + if not os.path.exists(self.ORIGINAL_PATH): + result["error"] = "No original image found. Upload or fetch an image first." + return result + + # Load original + with open(self.ORIGINAL_PATH, 'rb') as f: + image_bytes = f.read() + image = Image.open(io.BytesIO(image_bytes)) + + if debug: + logger.info(f"Auto-cropping original image ({image.size[0]}x{image.size[1]})") + + # Run intelligent crop + target_size = 512 + width, height = image.size + if width <= target_size and height <= target_size: + if debug: + logger.info(f"Image already at/below target size, skipping crop") + cropped_image = image + else: + cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug) + + if not cropped_image: + result["error"] = "Intelligent crop failed" + return result + + # Save cropped + output_buffer = io.BytesIO() + cropped_image.save(output_buffer, format='PNG') + cropped_bytes = output_buffer.getvalue() + + with open(self.CURRENT_PATH, 'wb') as f: + f.write(cropped_bytes) + + if debug: + logger.info(f"Saved auto-cropped image ({len(cropped_bytes)} bytes)") + + # Extract dominant color + dominant_color = self._extract_dominant_color(cropped_image, debug=debug) + if dominant_color: + result["metadata"]["dominant_color"] = { + "rgb": dominant_color, + "hex": "#{:02x}{:02x}{:02x}".format(*dominant_color) + } + + # Update Discord avatar + 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=cropped_bytes), + globals.client.loop + ) + future.result(timeout=10) + else: + await globals.client.user.edit(avatar=cropped_bytes) + + result["success"] = True + result["metadata"]["changed_at"] = datetime.now().isoformat() + + # Update existing metadata + existing_meta = self.load_metadata() or {} + existing_meta.update(result["metadata"]) + existing_meta.pop("skip_crop", None) + existing_meta.pop("crop_region", None) + self._save_metadata(existing_meta) + + logger.info("Auto-crop applied and Discord avatar updated") + + if dominant_color: + await self._update_role_colors(dominant_color, debug=debug) + + if globals.BIPOLAR_MODE: + try: + from utils.bipolar_mode import update_webhook_avatars + await update_webhook_avatars(globals.client) + except Exception as e: + logger.warning(f"Failed to update bipolar webhook avatars: {e}") + + except discord.HTTPException as e: + result["error"] = f"Discord API error: {e}" + except Exception as e: + result["error"] = f"Unexpected error: {e}" + else: + result["error"] = "Bot client not ready" + + except Exception as e: + result["error"] = f"Error in auto_crop_only: {e}" + logger.error(f"Error in auto_crop_only: {e}") + + return result + + async def update_description(self, description: str, reinject_cat: bool = True, debug: bool = False) -> Dict: + """ + Update the profile picture description and optionally re-inject into Cheshire Cat memory. + + Args: + description: New description text + reinject_cat: Whether to store as a declarative fact in Cheshire Cat + debug: Enable debug output + + Returns: + Dict with success status + """ + result = {"success": False, "error": None} + + try: + # Save to description file + description_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt") + with open(description_path, 'w', encoding='utf-8') as f: + f.write(description) + + # Update metadata + metadata = self.load_metadata() or {} + metadata["description"] = description + self._save_metadata(metadata) + + if debug: + logger.info(f"Updated description ({len(description)} chars)") + + # Re-inject into Cheshire Cat as declarative memory + if reinject_cat and globals.USE_CHESHIRE_CAT: + try: + from utils.cat_client import cat_adapter + fact_content = f"Miku's current profile picture shows: {description}" + await cat_adapter.create_memory_point( + collection="declarative", + content=fact_content, + user_id="profile_picture_manager", + source="profile_picture_description", + metadata={"type": "profile_picture", "updated_at": datetime.now().isoformat()} + ) + if debug: + logger.info("Re-injected description into Cheshire Cat declarative memory") + except Exception as e: + logger.warning(f"Failed to re-inject description into Cat: {e}") + # Don't fail the whole operation + + result["success"] = True + + except Exception as e: + result["error"] = f"Error updating description: {e}" + logger.error(f"Error in update_description: {e}") + + return result + + async def regenerate_description(self, debug: bool = False) -> Dict: + """ + Re-generate the description from the current original image using the vision model. + + Returns: + Dict with success status and new description + """ + result = {"success": False, "error": None, "description": None} + + try: + # Try original first, fall back to current + image_path = self.ORIGINAL_PATH if os.path.exists(self.ORIGINAL_PATH) else self.CURRENT_PATH + if not os.path.exists(image_path): + result["error"] = "No image found to describe" + return result + + with open(image_path, 'rb') as f: + image_bytes = f.read() + + if debug: + logger.info("Regenerating image description via vision model...") + + description = await self._generate_image_description(image_bytes, debug=debug) + + if description: + # Save it + update_result = await self.update_description(description, reinject_cat=True, debug=debug) + result["success"] = update_result["success"] + result["description"] = description + if update_result.get("error"): + result["error"] = update_result["error"] + else: + result["error"] = "Vision model returned no description" + + except Exception as e: + result["error"] = f"Error regenerating description: {e}" + logger.error(f"Error in regenerate_description: {e}") + + return result # Global instance