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:
+
+
+
+
+
+
+
+
+
+
โ๏ธ Manual Crop
+
+ Drag to select a square crop region. Discord avatars are displayed as circles, so keep the subject centered.
+
+
+
![Crop source]()
+
+
+
+
+
+
+
+
+
+
+
+
๐ท Original (full resolution)
+
![Click to re-crop 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