feat: add Profile Picture Management tab with manual crop, description editor
- 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
This commit is contained in:
168
bot/api.py
168
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(...),
|
||||
|
||||
Reference in New Issue
Block a user