feat: add PFP album/gallery system with batch upload, cropping, and disk management
- Backend: album storage in memory/profile_pictures/album/{uuid}/ with
original.png, cropped.png, and metadata.json per entry
- add_to_album/add_batch_to_album with efficient resource management
(vision model + face detector kept alive across batch)
- set_album_entry_as_current auto-archives current PFP before replacing
- manual/auto crop album entries without applying to Discord
- Disk usage tracking, single & bulk delete
- API: full CRUD endpoints under /profile-picture/album/*
- Frontend: collapsible album grid in tab11 with thumbnail cards,
multi-select checkboxes for bulk delete, detail panel with crop
interface (Cropper.js), description editor, set-as-current action
This commit is contained in:
227
bot/api.py
227
bot/api.py
@@ -1289,6 +1289,233 @@ async def get_profile_picture_description():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "message": str(e)}
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
# === Profile Picture Album / Gallery ===
|
||||||
|
|
||||||
|
@app.get("/profile-picture/album")
|
||||||
|
async def list_album_entries():
|
||||||
|
"""List all album entries (newest first)"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
entries = profile_picture_manager.get_album_entries()
|
||||||
|
return {"status": "ok", "entries": entries, "count": len(entries)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.get("/profile-picture/album/disk-usage")
|
||||||
|
async def get_album_disk_usage():
|
||||||
|
"""Get album disk usage statistics"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
usage = profile_picture_manager.get_album_disk_usage()
|
||||||
|
return {"status": "ok", **usage}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.get("/profile-picture/album/{entry_id}")
|
||||||
|
async def get_album_entry(entry_id: str):
|
||||||
|
"""Get metadata for a single album entry"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
meta = profile_picture_manager.get_album_entry(entry_id)
|
||||||
|
if meta:
|
||||||
|
return {"status": "ok", "entry": meta}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": "Album entry not found"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.get("/profile-picture/album/{entry_id}/image/{image_type}")
|
||||||
|
async def serve_album_image(entry_id: str, image_type: str):
|
||||||
|
"""Serve an album entry's image (original or cropped)"""
|
||||||
|
if image_type not in ("original", "cropped"):
|
||||||
|
return {"status": "error", "message": "image_type must be 'original' or 'cropped'"}
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
path = profile_picture_manager.get_album_image_path(entry_id, image_type)
|
||||||
|
if path:
|
||||||
|
return FileResponse(path, media_type="image/png", headers={"Cache-Control": "no-cache"})
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": f"No {image_type} image for this entry"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/add")
|
||||||
|
async def add_to_album(file: UploadFile = File(...)):
|
||||||
|
"""Add a single image to the album"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
image_bytes = await file.read()
|
||||||
|
logger.info(f"Adding image to album ({len(image_bytes)} bytes)")
|
||||||
|
result = await profile_picture_manager.add_to_album(
|
||||||
|
image_bytes=image_bytes,
|
||||||
|
source="custom_upload",
|
||||||
|
debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Image added to album",
|
||||||
|
"entry_id": result["entry_id"],
|
||||||
|
"metadata": result.get("metadata", {})
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding to album: {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/add-batch")
|
||||||
|
async def add_batch_to_album(files: List[UploadFile] = File(...)):
|
||||||
|
"""Batch-add multiple images to the album efficiently"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
images = []
|
||||||
|
for f in files:
|
||||||
|
data = await f.read()
|
||||||
|
images.append({"bytes": data, "source": "custom_upload"})
|
||||||
|
logger.info(f"Batch adding {len(images)} images to album")
|
||||||
|
result = await profile_picture_manager.add_batch_to_album(images=images, debug=True)
|
||||||
|
return {
|
||||||
|
"status": "ok" if result["success"] else "partial",
|
||||||
|
"message": f"Added {result['succeeded']}/{result['total']} images",
|
||||||
|
"succeeded": result["succeeded"],
|
||||||
|
"failed": result["failed"],
|
||||||
|
"total": result["total"],
|
||||||
|
"results": [
|
||||||
|
{"entry_id": r.get("entry_id"), "success": r["success"], "error": r.get("error")}
|
||||||
|
for r in result["results"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in batch album add: {e}")
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/{entry_id}/set-current")
|
||||||
|
async def set_album_entry_as_current(entry_id: str):
|
||||||
|
"""Set an album entry as the current Discord profile picture"""
|
||||||
|
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.set_album_entry_as_current(
|
||||||
|
entry_id=entry_id, archive_current=True, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry set as current profile picture",
|
||||||
|
"archived_entry_id": result.get("archived_entry_id")
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
class AlbumCropRequest(BaseModel):
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/{entry_id}/manual-crop")
|
||||||
|
async def manual_crop_album_entry(entry_id: str, req: AlbumCropRequest):
|
||||||
|
"""Manually crop an album entry's original image"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.manual_crop_album_entry(
|
||||||
|
entry_id=entry_id, x=req.x, y=req.y,
|
||||||
|
width=req.width, height=req.height, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry cropped",
|
||||||
|
"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/album/{entry_id}/auto-crop")
|
||||||
|
async def auto_crop_album_entry(entry_id: str):
|
||||||
|
"""Auto-crop an album entry using face/saliency detection"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.auto_crop_album_entry(
|
||||||
|
entry_id=entry_id, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Album entry auto-cropped",
|
||||||
|
"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 AlbumDescriptionRequest(BaseModel):
|
||||||
|
description: str
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/{entry_id}/description")
|
||||||
|
async def update_album_entry_description(entry_id: str, req: AlbumDescriptionRequest):
|
||||||
|
"""Update an album entry's description"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = await profile_picture_manager.update_album_entry_description(
|
||||||
|
entry_id=entry_id, description=req.description, debug=True
|
||||||
|
)
|
||||||
|
if result["success"]:
|
||||||
|
return {"status": "ok", "message": "Description updated"}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": result.get("error", "Unknown error")}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.delete("/profile-picture/album/{entry_id}")
|
||||||
|
async def delete_album_entry(entry_id: str):
|
||||||
|
"""Delete a single album entry"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
if profile_picture_manager.delete_album_entry(entry_id):
|
||||||
|
return {"status": "ok", "message": "Album entry deleted"}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": "Album entry not found"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
class BulkDeleteRequest(BaseModel):
|
||||||
|
entry_ids: List[str]
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/delete-bulk")
|
||||||
|
async def bulk_delete_album_entries(req: BulkDeleteRequest):
|
||||||
|
"""Bulk delete multiple album entries"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
result = profile_picture_manager.delete_album_entries(req.entry_ids)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"message": f"Deleted {result['deleted']}/{result['total']} entries",
|
||||||
|
**result
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
|
@app.post("/profile-picture/album/add-current")
|
||||||
|
async def add_current_to_album():
|
||||||
|
"""Archive the current profile picture into the album"""
|
||||||
|
try:
|
||||||
|
from utils.profile_picture_manager import profile_picture_manager
|
||||||
|
entry_id = await profile_picture_manager._save_current_to_album(debug=True)
|
||||||
|
if entry_id:
|
||||||
|
return {"status": "ok", "message": "Current PFP archived to album", "entry_id": entry_id}
|
||||||
|
else:
|
||||||
|
return {"status": "error", "message": "No current PFP to archive"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "error", "message": str(e)}
|
||||||
|
|
||||||
@app.post("/manual/send")
|
@app.post("/manual/send")
|
||||||
async def manual_send(
|
async def manual_send(
|
||||||
message: str = Form(...),
|
message: str = Form(...),
|
||||||
|
|||||||
@@ -738,6 +738,107 @@
|
|||||||
border-color: #61dafb;
|
border-color: #61dafb;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
/* Album / Gallery grid */
|
||||||
|
.album-section {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.album-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.album-header h4 { margin: 0; }
|
||||||
|
.album-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
.album-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
.album-card {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid #444;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.album-card:hover { border-color: #61dafb; }
|
||||||
|
.album-card.selected { border-color: #4CAF50; box-shadow: 0 0 8px rgba(76,175,80,0.4); }
|
||||||
|
.album-card.checked { border-color: #ff9800; }
|
||||||
|
.album-card img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.album-card .album-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 4px;
|
||||||
|
z-index: 2;
|
||||||
|
accent-color: #ff9800;
|
||||||
|
}
|
||||||
|
.album-card .album-card-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #ccc;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.album-card .color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #888;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
.album-detail {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #222;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.album-detail-previews {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.album-detail-previews .pfp-preview-box img {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
.album-disk-usage {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -1795,6 +1896,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Album / Gallery -->
|
||||||
|
<div class="album-section">
|
||||||
|
<div class="album-header" onclick="albumToggle()">
|
||||||
|
<h4><span id="album-toggle-icon">▶</span> 📚 Profile Picture Album <span id="album-count" style="color: #888; font-weight: normal;"></span></h4>
|
||||||
|
<span class="album-disk-usage" id="album-disk-usage"></span>
|
||||||
|
</div>
|
||||||
|
<div id="album-body" style="display: none;">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="album-toolbar">
|
||||||
|
<input type="file" id="album-upload" accept="image/*" multiple style="max-width: 220px;">
|
||||||
|
<button onclick="albumUpload()">📤 Add to Album</button>
|
||||||
|
<button onclick="albumAddCurrent()">📌 Archive Current PFP</button>
|
||||||
|
<button onclick="albumBulkDelete()" style="background: #c0392b;" id="album-bulk-delete-btn" disabled>🗑️ Delete Selected (<span id="album-selected-count">0</span>)</button>
|
||||||
|
<div id="album-status" style="font-size: 0.85rem; color: #61dafb; margin-left: 0.5rem;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Grid -->
|
||||||
|
<div class="album-grid" id="album-grid"></div>
|
||||||
|
<!-- Detail panel (shown when an entry is clicked) -->
|
||||||
|
<div class="album-detail" id="album-detail" style="display: none;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h4 style="margin: 0;">Selected Entry</h4>
|
||||||
|
<button onclick="albumCloseDetail()" style="background: #555; padding: 0.3rem 0.6rem;">✖ Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="album-detail-previews">
|
||||||
|
<div class="pfp-preview-box">
|
||||||
|
<span class="label">📷 Original</span>
|
||||||
|
<img id="album-detail-original" src="" alt="Original" style="cursor: pointer;" onclick="albumShowCropInterface()" title="Click to crop">
|
||||||
|
<div id="album-detail-dims" style="font-size: 0.8rem; color: #666; margin-top: 0.3rem;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pfp-preview-box">
|
||||||
|
<span class="label">🎯 Cropped</span>
|
||||||
|
<img id="album-detail-cropped" src="" alt="Cropped" style="border-radius: 50%; max-width: 256px; max-height: 256px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Album entry crop interface -->
|
||||||
|
<div id="album-crop-section" style="display: none; margin: 1rem 0; padding: 1rem; background: #1a1a2e; border: 1px solid #444; border-radius: 8px;">
|
||||||
|
<h4 style="margin-top: 0;">✂️ Crop Album Entry</h4>
|
||||||
|
<div class="pfp-crop-container">
|
||||||
|
<img id="album-crop-image" src="" alt="Crop source">
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.75rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button onclick="albumApplyManualCrop()" style="background: #4CAF50; color: #fff; font-weight: bold;">✂️ Apply Crop</button>
|
||||||
|
<button onclick="albumApplyAutoCrop()">🤖 Auto Crop</button>
|
||||||
|
<button onclick="albumHideCropInterface()" style="background: #666;">✖ Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Description -->
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<label style="color: #aaa;">📝 Description:</label>
|
||||||
|
<textarea id="album-detail-description" class="pfp-description-editor" style="min-height: 80px;"></textarea>
|
||||||
|
<div style="margin-top: 0.5rem; display: flex; gap: 0.5rem;">
|
||||||
|
<button onclick="albumSaveDescription()" style="background: #4CAF50; color: #fff;">💾 Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Actions -->
|
||||||
|
<div style="margin-top: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
<button onclick="albumSetAsCurrent()" style="background: #2196F3; color: #fff; font-weight: bold;">🖼️ Set as Current PFP</button>
|
||||||
|
<button onclick="albumDeleteSelected()" style="background: #c0392b; color: #fff;">🗑️ Delete Entry</button>
|
||||||
|
</div>
|
||||||
|
<div id="album-detail-meta" style="margin-top: 0.75rem; font-size: 0.8rem; color: #888;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Crop Mode Toggle -->
|
<!-- Crop Mode Toggle -->
|
||||||
<div class="crop-mode-toggle">
|
<div class="crop-mode-toggle">
|
||||||
<span style="color: #61dafb; font-weight: bold;">Crop Mode:</span>
|
<span style="color: #61dafb; font-weight: bold;">Crop Mode:</span>
|
||||||
@@ -3518,6 +3683,25 @@ async function loadPfpTab() {
|
|||||||
|
|
||||||
// Refresh preview images
|
// Refresh preview images
|
||||||
pfpRefreshPreviews();
|
pfpRefreshPreviews();
|
||||||
|
|
||||||
|
// Update album header counts (without opening)
|
||||||
|
try {
|
||||||
|
const [listRes, usageRes] = await Promise.all([
|
||||||
|
apiCall('/profile-picture/album'),
|
||||||
|
apiCall('/profile-picture/album/disk-usage')
|
||||||
|
]);
|
||||||
|
if (listRes.status === 'ok') {
|
||||||
|
albumEntries = listRes.entries || [];
|
||||||
|
document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
|
||||||
|
if (albumOpen) albumRenderGrid();
|
||||||
|
}
|
||||||
|
if (usageRes.status === 'ok') {
|
||||||
|
document.getElementById('album-disk-usage').textContent =
|
||||||
|
`${usageRes.human_readable} · ${usageRes.entry_count} entries`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load album info:', e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Danbooru Change ---
|
// --- Danbooru Change ---
|
||||||
@@ -3878,6 +4062,353 @@ async function resetRoleColor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Album / Gallery System
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
let albumEntries = [];
|
||||||
|
let albumSelectedId = null; // currently-viewed entry
|
||||||
|
let albumChecked = new Set(); // checked for bulk delete
|
||||||
|
let albumCropper = null;
|
||||||
|
let albumOpen = false;
|
||||||
|
|
||||||
|
function albumSetStatus(text, color = '#61dafb') {
|
||||||
|
const el = document.getElementById('album-status');
|
||||||
|
if (el) { el.textContent = text; el.style.color = color; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumToggle() {
|
||||||
|
albumOpen = !albumOpen;
|
||||||
|
document.getElementById('album-body').style.display = albumOpen ? 'block' : 'none';
|
||||||
|
document.getElementById('album-toggle-icon').textContent = albumOpen ? '▼' : '▶';
|
||||||
|
if (albumOpen) albumLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumLoad() {
|
||||||
|
try {
|
||||||
|
const [listRes, usageRes] = await Promise.all([
|
||||||
|
apiCall('/profile-picture/album'),
|
||||||
|
apiCall('/profile-picture/album/disk-usage')
|
||||||
|
]);
|
||||||
|
if (listRes.status === 'ok') {
|
||||||
|
albumEntries = listRes.entries || [];
|
||||||
|
document.getElementById('album-count').textContent = `(${albumEntries.length} entries)`;
|
||||||
|
albumRenderGrid();
|
||||||
|
}
|
||||||
|
if (usageRes.status === 'ok') {
|
||||||
|
document.getElementById('album-disk-usage').textContent =
|
||||||
|
`${usageRes.human_readable} · ${usageRes.entry_count} entries`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Album load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumRenderGrid() {
|
||||||
|
const grid = document.getElementById('album-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
if (albumEntries.length === 0) {
|
||||||
|
grid.innerHTML = '<div style="color: #888; padding: 1rem; grid-column: 1/-1;">No album entries yet. Upload images or archive the current PFP.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.innerHTML = albumEntries.map(e => {
|
||||||
|
const id = e.id;
|
||||||
|
const isSelected = id === albumSelectedId;
|
||||||
|
const isChecked = albumChecked.has(id);
|
||||||
|
const colorDot = e.dominant_color
|
||||||
|
? `<span class="color-dot" style="background:${e.dominant_color.hex}"></span>`
|
||||||
|
: '';
|
||||||
|
const label = (e.source || '').replace('custom_upload', 'upload').substring(0, 12);
|
||||||
|
return `<div class="album-card${isSelected ? ' selected' : ''}${isChecked ? ' checked' : ''}"
|
||||||
|
data-id="${id}" onclick="albumSelectEntry('${id}')">
|
||||||
|
<input type="checkbox" class="album-check" ${isChecked ? 'checked' : ''}
|
||||||
|
onclick="event.stopPropagation(); albumToggleCheck('${id}', this.checked)">
|
||||||
|
<img src="/profile-picture/album/${id}/image/cropped?t=${Date.now()}"
|
||||||
|
onerror="this.src='/profile-picture/album/${id}/image/original?t=${Date.now()}'"
|
||||||
|
loading="lazy" alt="">
|
||||||
|
<div class="album-card-info">${colorDot}${label}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumToggleCheck(id, checked) {
|
||||||
|
if (checked) albumChecked.add(id); else albumChecked.delete(id);
|
||||||
|
document.getElementById('album-selected-count').textContent = albumChecked.size;
|
||||||
|
document.getElementById('album-bulk-delete-btn').disabled = albumChecked.size === 0;
|
||||||
|
// update card class
|
||||||
|
const card = document.querySelector(`.album-card[data-id="${id}"]`);
|
||||||
|
if (card) card.classList.toggle('checked', checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumSelectEntry(id) {
|
||||||
|
albumSelectedId = id;
|
||||||
|
// highlight card
|
||||||
|
document.querySelectorAll('.album-card').forEach(c => c.classList.toggle('selected', c.dataset.id === id));
|
||||||
|
// show detail
|
||||||
|
const detail = document.getElementById('album-detail');
|
||||||
|
detail.style.display = 'block';
|
||||||
|
const t = Date.now();
|
||||||
|
document.getElementById('album-detail-original').src = `/profile-picture/album/${id}/image/original?t=${t}`;
|
||||||
|
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${id}/image/cropped?t=${t}`;
|
||||||
|
|
||||||
|
// load entry metadata
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`/profile-picture/album/${id}`);
|
||||||
|
if (res.status === 'ok' && res.entry) {
|
||||||
|
const e = res.entry;
|
||||||
|
document.getElementById('album-detail-dims').textContent =
|
||||||
|
e.original_width && e.original_height ? `${e.original_width}×${e.original_height}` : '';
|
||||||
|
document.getElementById('album-detail-description').value = e.description || '';
|
||||||
|
const metaLines = [];
|
||||||
|
if (e.added_at) metaLines.push(`Added: ${new Date(e.added_at).toLocaleString()}`);
|
||||||
|
if (e.source) metaLines.push(`Source: ${e.source}`);
|
||||||
|
if (e.dominant_color) metaLines.push(`Color: ${e.dominant_color.hex}`);
|
||||||
|
if (e.was_current) metaLines.push('📌 Previously active');
|
||||||
|
document.getElementById('album-detail-meta').textContent = metaLines.join(' · ');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Album entry load error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function albumCloseDetail() {
|
||||||
|
document.getElementById('album-detail').style.display = 'none';
|
||||||
|
albumSelectedId = null;
|
||||||
|
albumHideCropInterface();
|
||||||
|
document.querySelectorAll('.album-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album Upload ---
|
||||||
|
async function albumUpload() {
|
||||||
|
const input = document.getElementById('album-upload');
|
||||||
|
if (!input.files || input.files.length === 0) {
|
||||||
|
showNotification('Select image(s) to add to album', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(input.files);
|
||||||
|
albumSetStatus(`⏳ Adding ${files.length} image(s) to album...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (files.length === 1) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', files[0]);
|
||||||
|
const resp = await fetch('/profile-picture/album/add', { method: 'POST', body: formData });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
albumSetStatus(`✅ Added to album`, 'green');
|
||||||
|
showNotification('Image added to album!');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
files.forEach(f => formData.append('files', f));
|
||||||
|
const resp = await fetch('/profile-picture/album/add-batch', { method: 'POST', body: formData });
|
||||||
|
const result = await resp.json();
|
||||||
|
albumSetStatus(`✅ ${result.message}`, result.failed > 0 ? 'orange' : 'green');
|
||||||
|
showNotification(result.message);
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
await albumLoad();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Album upload error:', error);
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumAddCurrent() {
|
||||||
|
albumSetStatus('⏳ Archiving current PFP...');
|
||||||
|
try {
|
||||||
|
const result = await apiCall('/profile-picture/album/add-current', 'POST');
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
albumSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification('Current PFP archived to album!');
|
||||||
|
await albumLoad();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album Set as Current ---
|
||||||
|
async function albumSetAsCurrent() {
|
||||||
|
if (!albumSelectedId) return;
|
||||||
|
if (!confirm('Set this album entry as your current Discord profile picture?\nThe current PFP will be archived to the album.')) return;
|
||||||
|
|
||||||
|
albumSetStatus('⏳ Setting as current PFP...');
|
||||||
|
try {
|
||||||
|
const result = await apiCall(`/profile-picture/album/${albumSelectedId}/set-current`, 'POST');
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
albumSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification('Album entry set as current PFP!');
|
||||||
|
pfpRefreshPreviews();
|
||||||
|
loadPfpTab(); // refresh metadata + description
|
||||||
|
await albumLoad();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album Delete ---
|
||||||
|
async function albumDeleteSelected() {
|
||||||
|
if (!albumSelectedId) return;
|
||||||
|
if (!confirm('Delete this album entry permanently?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/profile-picture/album/${albumSelectedId}`, { method: 'DELETE' });
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
showNotification('Album entry deleted');
|
||||||
|
albumCloseDetail();
|
||||||
|
await albumLoad();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumBulkDelete() {
|
||||||
|
if (albumChecked.size === 0) return;
|
||||||
|
if (!confirm(`Delete ${albumChecked.size} selected album entries permanently?`)) return;
|
||||||
|
|
||||||
|
albumSetStatus(`⏳ Deleting ${albumChecked.size} entries...`);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/profile-picture/album/delete-bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ entry_ids: Array.from(albumChecked) })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
albumSetStatus(`✅ ${result.message}`, 'green');
|
||||||
|
showNotification(result.message);
|
||||||
|
albumChecked.clear();
|
||||||
|
document.getElementById('album-selected-count').textContent = '0';
|
||||||
|
document.getElementById('album-bulk-delete-btn').disabled = true;
|
||||||
|
if (albumSelectedId && !albumEntries.find(e => e.id === albumSelectedId)) {
|
||||||
|
albumCloseDetail();
|
||||||
|
}
|
||||||
|
await albumLoad();
|
||||||
|
} catch (error) {
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album Crop ---
|
||||||
|
function albumShowCropInterface() {
|
||||||
|
if (!albumSelectedId) return;
|
||||||
|
if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
|
||||||
|
|
||||||
|
const section = document.getElementById('album-crop-section');
|
||||||
|
const img = document.getElementById('album-crop-image');
|
||||||
|
img.src = `/profile-picture/album/${albumSelectedId}/image/original?t=${Date.now()}`;
|
||||||
|
section.style.display = 'block';
|
||||||
|
|
||||||
|
img.onload = function() {
|
||||||
|
albumCropper = 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 albumHideCropInterface() {
|
||||||
|
if (albumCropper) { albumCropper.destroy(); albumCropper = null; }
|
||||||
|
document.getElementById('album-crop-section').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumApplyManualCrop() {
|
||||||
|
if (!albumCropper || !albumSelectedId) return;
|
||||||
|
const data = albumCropper.getData(true);
|
||||||
|
albumSetStatus('⏳ Applying crop to album entry...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/profile-picture/album/${albumSelectedId}/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 resp.json();
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
albumSetStatus('✅ Crop applied', 'green');
|
||||||
|
showNotification('Album entry cropped!');
|
||||||
|
albumHideCropInterface();
|
||||||
|
// refresh detail images
|
||||||
|
const t = Date.now();
|
||||||
|
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
|
||||||
|
await albumLoad(); // refresh grid thumbnails
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function albumApplyAutoCrop() {
|
||||||
|
if (!albumSelectedId) return;
|
||||||
|
albumSetStatus('⏳ Running auto-crop on album entry...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiCall(`/profile-picture/album/${albumSelectedId}/auto-crop`, 'POST');
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
albumSetStatus('✅ Auto-crop applied', 'green');
|
||||||
|
showNotification('Album entry auto-cropped!');
|
||||||
|
albumHideCropInterface();
|
||||||
|
const t = Date.now();
|
||||||
|
document.getElementById('album-detail-cropped').src = `/profile-picture/album/${albumSelectedId}/image/cropped?t=${t}`;
|
||||||
|
await albumLoad();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
albumSetStatus(`❌ Error: ${error.message}`, 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Album Description ---
|
||||||
|
async function albumSaveDescription() {
|
||||||
|
if (!albumSelectedId) return;
|
||||||
|
const description = document.getElementById('album-detail-description').value.trim();
|
||||||
|
if (!description) { showNotification('Description cannot be empty', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/profile-picture/album/${albumSelectedId}/description`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description })
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
showNotification('Album entry description saved!');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(`Error: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle functions for custom prompt and manual message target selection
|
// Toggle functions for custom prompt and manual message target selection
|
||||||
function toggleCustomPromptTarget() {
|
function toggleCustomPromptTarget() {
|
||||||
const targetType = document.getElementById('custom-prompt-target-type').value;
|
const targetType = document.getElementById('custom-prompt-target-type').value;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Supports both static images and animated GIFs:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import io
|
import io
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
from PIL import Image, ImageDraw
|
from PIL import Image, ImageDraw
|
||||||
@@ -39,6 +41,7 @@ class ProfilePictureManager:
|
|||||||
CURRENT_PATH = "memory/profile_pictures/current.png"
|
CURRENT_PATH = "memory/profile_pictures/current.png"
|
||||||
ORIGINAL_PATH = "memory/profile_pictures/original.png"
|
ORIGINAL_PATH = "memory/profile_pictures/original.png"
|
||||||
METADATA_PATH = "memory/profile_pictures/metadata.json"
|
METADATA_PATH = "memory/profile_pictures/metadata.json"
|
||||||
|
ALBUM_DIR = "memory/profile_pictures/album"
|
||||||
|
|
||||||
# Face detection API endpoint
|
# Face detection API endpoint
|
||||||
FACE_DETECTOR_API = "http://anime-face-detector:6078/detect"
|
FACE_DETECTOR_API = "http://anime-face-detector:6078/detect"
|
||||||
@@ -63,6 +66,7 @@ class ProfilePictureManager:
|
|||||||
def _ensure_directories(self):
|
def _ensure_directories(self):
|
||||||
"""Ensure profile picture directory exists"""
|
"""Ensure profile picture directory exists"""
|
||||||
os.makedirs(self.PROFILE_PIC_DIR, exist_ok=True)
|
os.makedirs(self.PROFILE_PIC_DIR, exist_ok=True)
|
||||||
|
os.makedirs(self.ALBUM_DIR, exist_ok=True)
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
"""Initialize the profile picture manager (check API availability)"""
|
"""Initialize the profile picture manager (check API availability)"""
|
||||||
@@ -1753,6 +1757,615 @@ Respond in JSON format:
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Album / Gallery System
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
def _album_entry_dir(self, entry_id: str) -> str:
|
||||||
|
"""Return the directory path for an album entry."""
|
||||||
|
return os.path.join(self.ALBUM_DIR, entry_id)
|
||||||
|
|
||||||
|
def _save_album_metadata(self, entry_id: str, metadata: Dict):
|
||||||
|
"""Save metadata for an album entry."""
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
os.makedirs(entry_dir, exist_ok=True)
|
||||||
|
meta_path = os.path.join(entry_dir, "metadata.json")
|
||||||
|
with open(meta_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
def _load_album_metadata(self, entry_id: str) -> Optional[Dict]:
|
||||||
|
"""Load metadata for an album entry."""
|
||||||
|
meta_path = os.path.join(self._album_entry_dir(entry_id), "metadata.json")
|
||||||
|
try:
|
||||||
|
if os.path.exists(meta_path):
|
||||||
|
with open(meta_path, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading album metadata for {entry_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _save_current_to_album(self, debug: bool = False) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Archive the current PFP (original + cropped + metadata) into the album.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The new album entry ID, or None if nothing to archive.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(self.ORIGINAL_PATH) and not os.path.exists(self.CURRENT_PATH):
|
||||||
|
if debug:
|
||||||
|
logger.info("No current PFP to archive")
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry_id = str(uuid.uuid4())
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
os.makedirs(entry_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Copy images
|
||||||
|
if os.path.exists(self.ORIGINAL_PATH):
|
||||||
|
shutil.copy2(self.ORIGINAL_PATH, os.path.join(entry_dir, "original.png"))
|
||||||
|
if os.path.exists(self.CURRENT_PATH):
|
||||||
|
shutil.copy2(self.CURRENT_PATH, os.path.join(entry_dir, "cropped.png"))
|
||||||
|
|
||||||
|
# Build metadata from current
|
||||||
|
current_meta = self.load_metadata() or {}
|
||||||
|
description = self.get_current_description() or ""
|
||||||
|
|
||||||
|
album_meta = {
|
||||||
|
"id": entry_id,
|
||||||
|
"added_at": datetime.now().isoformat(),
|
||||||
|
"source": current_meta.get("source", "unknown"),
|
||||||
|
"description": description,
|
||||||
|
"dominant_color": current_meta.get("dominant_color"),
|
||||||
|
"original_width": current_meta.get("original_width"),
|
||||||
|
"original_height": current_meta.get("original_height"),
|
||||||
|
"crop_region": current_meta.get("crop_region"),
|
||||||
|
"was_current": True,
|
||||||
|
"archived_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
# Carry over Danbooru metadata if present
|
||||||
|
for key in ("danbooru_post_id", "danbooru_tags", "danbooru_artist",
|
||||||
|
"danbooru_rating", "danbooru_score", "danbooru_file_url"):
|
||||||
|
if key in current_meta:
|
||||||
|
album_meta[key] = current_meta[key]
|
||||||
|
|
||||||
|
self._save_album_metadata(entry_id, album_meta)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Archived current PFP to album entry {entry_id}")
|
||||||
|
return entry_id
|
||||||
|
|
||||||
|
async def add_to_album(
|
||||||
|
self,
|
||||||
|
image_bytes: bytes,
|
||||||
|
source: str = "custom_upload",
|
||||||
|
extra_metadata: Optional[Dict] = None,
|
||||||
|
generate_description: bool = True,
|
||||||
|
auto_crop: bool = True,
|
||||||
|
debug: bool = False,
|
||||||
|
_batch_mode: bool = False,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Add an image to the album gallery.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_bytes: Raw image bytes
|
||||||
|
source: Source label (e.g. "custom_upload", "danbooru")
|
||||||
|
extra_metadata: Additional metadata to store (Danbooru info, etc.)
|
||||||
|
generate_description: Whether to generate a vision-model description
|
||||||
|
auto_crop: Whether to auto-crop (face detection → saliency)
|
||||||
|
debug: Enable debug output
|
||||||
|
_batch_mode: Internal flag — if True, skip starting/stopping face detector
|
||||||
|
(caller manages detector lifecycle)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success, entry_id, metadata
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "entry_id": None, "metadata": {}}
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
if image.mode not in ("RGB", "RGBA"):
|
||||||
|
image = image.convert("RGB")
|
||||||
|
|
||||||
|
entry_id = str(uuid.uuid4())
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
os.makedirs(entry_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Save original
|
||||||
|
original_path = os.path.join(entry_dir, "original.png")
|
||||||
|
with open(original_path, 'wb') as f:
|
||||||
|
f.write(image_bytes)
|
||||||
|
|
||||||
|
album_meta: Dict = {
|
||||||
|
"id": entry_id,
|
||||||
|
"added_at": datetime.now().isoformat(),
|
||||||
|
"source": source,
|
||||||
|
"original_width": image.size[0],
|
||||||
|
"original_height": image.size[1],
|
||||||
|
}
|
||||||
|
if extra_metadata:
|
||||||
|
album_meta.update(extra_metadata)
|
||||||
|
|
||||||
|
# Generate description
|
||||||
|
if generate_description:
|
||||||
|
if debug:
|
||||||
|
logger.info(f"[Album {entry_id[:8]}] Generating description...")
|
||||||
|
description = await self._generate_image_description(image_bytes, debug=debug)
|
||||||
|
if description:
|
||||||
|
album_meta["description"] = description
|
||||||
|
if debug:
|
||||||
|
logger.info(f"[Album {entry_id[:8]}] Description: {description[:80]}...")
|
||||||
|
|
||||||
|
# Auto-crop
|
||||||
|
if auto_crop:
|
||||||
|
target_size = 512
|
||||||
|
w, h = image.size
|
||||||
|
if w <= target_size and h <= target_size:
|
||||||
|
cropped_image = image
|
||||||
|
else:
|
||||||
|
if _batch_mode:
|
||||||
|
# In batch mode the face detector is already running
|
||||||
|
cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
|
||||||
|
else:
|
||||||
|
cropped_image = await self._intelligent_crop(image, image_bytes, target_size=target_size, debug=debug)
|
||||||
|
|
||||||
|
if cropped_image:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
cropped_image.save(buf, format='PNG')
|
||||||
|
cropped_bytes = buf.getvalue()
|
||||||
|
cropped_path = os.path.join(entry_dir, "cropped.png")
|
||||||
|
with open(cropped_path, 'wb') as f:
|
||||||
|
f.write(cropped_bytes)
|
||||||
|
|
||||||
|
# Extract dominant color from cropped
|
||||||
|
dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
|
||||||
|
if dominant_color:
|
||||||
|
album_meta["dominant_color"] = {
|
||||||
|
"rgb": dominant_color,
|
||||||
|
"hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
if debug:
|
||||||
|
logger.warning(f"[Album {entry_id[:8]}] Auto-crop failed, using original resized")
|
||||||
|
# Fallback — resize original to square
|
||||||
|
cropped_image = image.copy()
|
||||||
|
cropped_image.thumbnail((target_size, target_size), Image.Resampling.LANCZOS)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
cropped_image.save(buf, format='PNG')
|
||||||
|
with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
|
||||||
|
self._save_album_metadata(entry_id, album_meta)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
result["entry_id"] = entry_id
|
||||||
|
result["metadata"] = album_meta
|
||||||
|
if debug:
|
||||||
|
logger.info(f"[Album] Added entry {entry_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error adding to album: {e}"
|
||||||
|
logger.error(f"Error in add_to_album: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def add_batch_to_album(
|
||||||
|
self,
|
||||||
|
images: List[Dict],
|
||||||
|
debug: bool = False,
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Batch-add multiple images to the album efficiently.
|
||||||
|
Keeps the vision model loaded and the face detector running across all images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
images: List of dicts, each with 'bytes' (raw image bytes) and optional 'source', 'extra_metadata'
|
||||||
|
debug: Enable debug output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with results list and summary counts
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
success_count = 0
|
||||||
|
fail_count = 0
|
||||||
|
face_detector_started = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Pre-start face detector for the whole batch
|
||||||
|
await self._ensure_vram_available(debug=debug)
|
||||||
|
if await self._start_face_detector(debug=debug):
|
||||||
|
face_detector_started = True
|
||||||
|
if debug:
|
||||||
|
logger.info(f"[Album Batch] Face detector started for {len(images)} images")
|
||||||
|
|
||||||
|
for i, img_info in enumerate(images):
|
||||||
|
if debug:
|
||||||
|
logger.info(f"[Album Batch] Processing image {i+1}/{len(images)}...")
|
||||||
|
|
||||||
|
entry_result = await self.add_to_album(
|
||||||
|
image_bytes=img_info["bytes"],
|
||||||
|
source=img_info.get("source", "custom_upload"),
|
||||||
|
extra_metadata=img_info.get("extra_metadata"),
|
||||||
|
generate_description=True,
|
||||||
|
auto_crop=True,
|
||||||
|
debug=debug,
|
||||||
|
_batch_mode=True,
|
||||||
|
)
|
||||||
|
results.append(entry_result)
|
||||||
|
if entry_result["success"]:
|
||||||
|
success_count += 1
|
||||||
|
else:
|
||||||
|
fail_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in batch album add: {e}")
|
||||||
|
finally:
|
||||||
|
# Clean up face detector after entire batch
|
||||||
|
if face_detector_started:
|
||||||
|
await self._stop_face_detector(debug=debug)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": fail_count == 0,
|
||||||
|
"total": len(images),
|
||||||
|
"succeeded": success_count,
|
||||||
|
"failed": fail_count,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_album_entries(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
List all album entries with their metadata, sorted newest-first.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of metadata dicts
|
||||||
|
"""
|
||||||
|
entries = []
|
||||||
|
if not os.path.exists(self.ALBUM_DIR):
|
||||||
|
return entries
|
||||||
|
|
||||||
|
for name in os.listdir(self.ALBUM_DIR):
|
||||||
|
entry_dir = os.path.join(self.ALBUM_DIR, name)
|
||||||
|
if not os.path.isdir(entry_dir):
|
||||||
|
continue
|
||||||
|
meta = self._load_album_metadata(name)
|
||||||
|
if meta:
|
||||||
|
# Add convenience flags
|
||||||
|
meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
|
||||||
|
meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
|
||||||
|
entries.append(meta)
|
||||||
|
|
||||||
|
# Sort newest first
|
||||||
|
entries.sort(key=lambda e: e.get("added_at", ""), reverse=True)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def get_album_entry(self, entry_id: str) -> Optional[Dict]:
|
||||||
|
"""Get metadata for a single album entry."""
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
if not os.path.isdir(entry_dir):
|
||||||
|
return None
|
||||||
|
meta = self._load_album_metadata(entry_id)
|
||||||
|
if meta:
|
||||||
|
meta["has_original"] = os.path.exists(os.path.join(entry_dir, "original.png"))
|
||||||
|
meta["has_cropped"] = os.path.exists(os.path.join(entry_dir, "cropped.png"))
|
||||||
|
return meta
|
||||||
|
|
||||||
|
def get_album_image_path(self, entry_id: str, image_type: str = "cropped") -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the file path for an album entry's image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_id: Album entry UUID
|
||||||
|
image_type: 'original' or 'cropped'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Absolute path string or None if not found
|
||||||
|
"""
|
||||||
|
filename = "original.png" if image_type == "original" else "cropped.png"
|
||||||
|
path = os.path.join(self._album_entry_dir(entry_id), filename)
|
||||||
|
return path if os.path.exists(path) else None
|
||||||
|
|
||||||
|
async def set_album_entry_as_current(self, entry_id: str, archive_current: bool = True, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Set an album entry as the current Discord profile picture.
|
||||||
|
Optionally archives the current PFP into the album first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry_id: Album entry UUID
|
||||||
|
archive_current: Whether to archive current PFP before replacing
|
||||||
|
debug: Enable debug output
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with success status
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "archived_entry_id": None}
|
||||||
|
|
||||||
|
try:
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
meta = self._load_album_metadata(entry_id)
|
||||||
|
if not meta:
|
||||||
|
result["error"] = f"Album entry {entry_id} not found"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Find the best image to use — prefer cropped, fall back to original
|
||||||
|
cropped_path = os.path.join(entry_dir, "cropped.png")
|
||||||
|
original_path = os.path.join(entry_dir, "original.png")
|
||||||
|
|
||||||
|
if not os.path.exists(cropped_path) and not os.path.exists(original_path):
|
||||||
|
result["error"] = "Album entry has no images"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Archive current PFP first
|
||||||
|
if archive_current and (os.path.exists(self.ORIGINAL_PATH) or os.path.exists(self.CURRENT_PATH)):
|
||||||
|
archived_id = await self._save_current_to_album(debug=debug)
|
||||||
|
result["archived_entry_id"] = archived_id
|
||||||
|
|
||||||
|
# Copy album entry images to current slots
|
||||||
|
if os.path.exists(original_path):
|
||||||
|
shutil.copy2(original_path, self.ORIGINAL_PATH)
|
||||||
|
if os.path.exists(cropped_path):
|
||||||
|
shutil.copy2(cropped_path, self.CURRENT_PATH)
|
||||||
|
elif os.path.exists(original_path):
|
||||||
|
# No cropped version — copy original as current too
|
||||||
|
shutil.copy2(original_path, self.CURRENT_PATH)
|
||||||
|
|
||||||
|
# Read the avatar bytes for Discord
|
||||||
|
avatar_path = self.CURRENT_PATH if os.path.exists(self.CURRENT_PATH) else self.ORIGINAL_PATH
|
||||||
|
with open(avatar_path, 'rb') as f:
|
||||||
|
avatar_bytes = f.read()
|
||||||
|
|
||||||
|
# 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=avatar_bytes),
|
||||||
|
globals.client.loop
|
||||||
|
)
|
||||||
|
future.result(timeout=10)
|
||||||
|
else:
|
||||||
|
await globals.client.user.edit(avatar=avatar_bytes)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
logger.info(f"Set album entry {entry_id} as current PFP")
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
result["error"] = f"Discord API error: {e}"
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Unexpected error updating avatar: {e}"
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
result["error"] = "Bot client not ready"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Update current metadata
|
||||||
|
new_meta = {
|
||||||
|
"source": meta.get("source", "album"),
|
||||||
|
"changed_at": datetime.now().isoformat(),
|
||||||
|
"album_entry_id": entry_id,
|
||||||
|
"original_width": meta.get("original_width"),
|
||||||
|
"original_height": meta.get("original_height"),
|
||||||
|
"dominant_color": meta.get("dominant_color"),
|
||||||
|
"crop_region": meta.get("crop_region"),
|
||||||
|
}
|
||||||
|
self._save_metadata(new_meta)
|
||||||
|
|
||||||
|
# Update description
|
||||||
|
description = meta.get("description", "")
|
||||||
|
if description:
|
||||||
|
desc_path = os.path.join(self.PROFILE_PIC_DIR, "current_description.txt")
|
||||||
|
with open(desc_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(description)
|
||||||
|
|
||||||
|
# Update role colors
|
||||||
|
dom_color = meta.get("dominant_color")
|
||||||
|
if dom_color and "rgb" in dom_color:
|
||||||
|
color_tuple = tuple(dom_color["rgb"])
|
||||||
|
await self._update_role_colors(color_tuple, debug=debug)
|
||||||
|
|
||||||
|
# Update bipolar webhooks
|
||||||
|
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 Exception as e:
|
||||||
|
result["error"] = f"Error setting album entry as current: {e}"
|
||||||
|
logger.error(f"Error in set_album_entry_as_current: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def delete_album_entry(self, entry_id: str) -> bool:
|
||||||
|
"""Delete a single album entry and its files."""
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
if os.path.isdir(entry_dir):
|
||||||
|
shutil.rmtree(entry_dir)
|
||||||
|
logger.info(f"Deleted album entry {entry_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete_album_entries(self, entry_ids: List[str]) -> Dict:
|
||||||
|
"""Bulk delete multiple album entries."""
|
||||||
|
deleted = 0
|
||||||
|
failed = 0
|
||||||
|
for eid in entry_ids:
|
||||||
|
if self.delete_album_entry(eid):
|
||||||
|
deleted += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
return {"deleted": deleted, "failed": failed, "total": len(entry_ids)}
|
||||||
|
|
||||||
|
def get_album_disk_usage(self) -> Dict:
|
||||||
|
"""Calculate total disk usage of the album directory."""
|
||||||
|
total_bytes = 0
|
||||||
|
entry_count = 0
|
||||||
|
if os.path.exists(self.ALBUM_DIR):
|
||||||
|
for name in os.listdir(self.ALBUM_DIR):
|
||||||
|
entry_dir = os.path.join(self.ALBUM_DIR, name)
|
||||||
|
if not os.path.isdir(entry_dir):
|
||||||
|
continue
|
||||||
|
entry_count += 1
|
||||||
|
for fname in os.listdir(entry_dir):
|
||||||
|
fpath = os.path.join(entry_dir, fname)
|
||||||
|
if os.path.isfile(fpath):
|
||||||
|
total_bytes += os.path.getsize(fpath)
|
||||||
|
|
||||||
|
# Human-readable
|
||||||
|
if total_bytes < 1024:
|
||||||
|
human = f"{total_bytes} B"
|
||||||
|
elif total_bytes < 1024 * 1024:
|
||||||
|
human = f"{total_bytes / 1024:.1f} KB"
|
||||||
|
elif total_bytes < 1024 * 1024 * 1024:
|
||||||
|
human = f"{total_bytes / (1024*1024):.1f} MB"
|
||||||
|
else:
|
||||||
|
human = f"{total_bytes / (1024*1024*1024):.2f} GB"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
"human_readable": human,
|
||||||
|
"entry_count": entry_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def update_album_entry_description(self, entry_id: str, description: str, debug: bool = False) -> Dict:
|
||||||
|
"""Update the description of an album entry."""
|
||||||
|
result = {"success": False, "error": None}
|
||||||
|
meta = self._load_album_metadata(entry_id)
|
||||||
|
if not meta:
|
||||||
|
result["error"] = f"Album entry {entry_id} not found"
|
||||||
|
return result
|
||||||
|
meta["description"] = description
|
||||||
|
self._save_album_metadata(entry_id, meta)
|
||||||
|
result["success"] = True
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Updated description for album entry {entry_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def manual_crop_album_entry(
|
||||||
|
self, entry_id: str, x: int, y: int, width: int, height: int,
|
||||||
|
target_size: int = 512, debug: bool = False
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Manually crop an album entry's original image.
|
||||||
|
Does NOT apply to Discord — only updates the album entry's cropped.png.
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "metadata": {}}
|
||||||
|
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
original_path = os.path.join(entry_dir, "original.png")
|
||||||
|
|
||||||
|
if not os.path.exists(original_path):
|
||||||
|
result["error"] = f"No original image for album entry {entry_id}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = Image.open(original_path)
|
||||||
|
img_w, img_h = image.size
|
||||||
|
|
||||||
|
if x < 0 or y < 0:
|
||||||
|
result["error"] = f"Crop coordinates must be non-negative"
|
||||||
|
return result
|
||||||
|
if x + width > img_w or y + height > img_h:
|
||||||
|
result["error"] = f"Crop region exceeds image bounds ({img_w}x{img_h})"
|
||||||
|
return result
|
||||||
|
if width < 64 or height < 64:
|
||||||
|
result["error"] = f"Crop region too small (min 64x64)"
|
||||||
|
return result
|
||||||
|
|
||||||
|
cropped = image.crop((x, y, x + width, y + height))
|
||||||
|
cropped = cropped.resize((target_size, target_size), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
cropped.save(buf, format='PNG')
|
||||||
|
cropped_bytes = buf.getvalue()
|
||||||
|
|
||||||
|
with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
|
||||||
|
f.write(cropped_bytes)
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
meta = self._load_album_metadata(entry_id) or {}
|
||||||
|
meta["crop_region"] = {"x": x, "y": y, "width": width, "height": height}
|
||||||
|
|
||||||
|
# Re-extract dominant color
|
||||||
|
dominant_color = self._extract_dominant_color(cropped, debug=debug)
|
||||||
|
if dominant_color:
|
||||||
|
meta["dominant_color"] = {
|
||||||
|
"rgb": dominant_color,
|
||||||
|
"hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
|
||||||
|
}
|
||||||
|
self._save_album_metadata(entry_id, meta)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
result["metadata"] = meta
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Manual crop applied to album entry {entry_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error cropping album entry: {e}"
|
||||||
|
logger.error(f"Error in manual_crop_album_entry: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def auto_crop_album_entry(self, entry_id: str, debug: bool = False) -> Dict:
|
||||||
|
"""
|
||||||
|
Auto-crop an album entry's original image (face detection → saliency).
|
||||||
|
Does NOT apply to Discord — only updates the album entry's cropped.png.
|
||||||
|
"""
|
||||||
|
result = {"success": False, "error": None, "metadata": {}}
|
||||||
|
|
||||||
|
entry_dir = self._album_entry_dir(entry_id)
|
||||||
|
original_path = os.path.join(entry_dir, "original.png")
|
||||||
|
|
||||||
|
if not os.path.exists(original_path):
|
||||||
|
result["error"] = f"No original image for album entry {entry_id}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(original_path, 'rb') as f:
|
||||||
|
image_bytes = f.read()
|
||||||
|
image = Image.open(io.BytesIO(image_bytes))
|
||||||
|
|
||||||
|
target_size = 512
|
||||||
|
w, h = image.size
|
||||||
|
if w <= target_size and h <= target_size:
|
||||||
|
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"] = "Auto-crop failed"
|
||||||
|
return result
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
cropped_image.save(buf, format='PNG')
|
||||||
|
with open(os.path.join(entry_dir, "cropped.png"), 'wb') as f:
|
||||||
|
f.write(buf.getvalue())
|
||||||
|
|
||||||
|
# Update metadata
|
||||||
|
meta = self._load_album_metadata(entry_id) or {}
|
||||||
|
meta.pop("crop_region", None) # auto-crop doesn't have user-defined region
|
||||||
|
|
||||||
|
dominant_color = self._extract_dominant_color(cropped_image, debug=debug)
|
||||||
|
if dominant_color:
|
||||||
|
meta["dominant_color"] = {
|
||||||
|
"rgb": dominant_color,
|
||||||
|
"hex": "#{:02x}{:02x}{:02x}".format(*dominant_color)
|
||||||
|
}
|
||||||
|
self._save_album_metadata(entry_id, meta)
|
||||||
|
|
||||||
|
result["success"] = True
|
||||||
|
result["metadata"] = meta
|
||||||
|
if debug:
|
||||||
|
logger.info(f"Auto-crop applied to album entry {entry_id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Error auto-cropping album entry: {e}"
|
||||||
|
logger.error(f"Error in auto_crop_album_entry: {e}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
profile_picture_manager = ProfilePictureManager()
|
profile_picture_manager = ProfilePictureManager()
|
||||||
|
|||||||
Reference in New Issue
Block a user