Compare commits

..

2 Commits

Author SHA1 Message Date
f50c677baf ui: move Re-crop Current button under cropped avatar preview in tab11
Relocated the button from the top action row to below the '512x512
displayed as circle' label for more intuitive placement next to the
avatar it acts on.
2026-03-31 15:16:40 +03:00
56a70705b2 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
2026-03-31 15:09:57 +03:00
3 changed files with 1372 additions and 1 deletions

View File

@@ -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(...),

View File

@@ -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>
@@ -1781,7 +1882,6 @@
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;"> <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center;">
<button onclick="pfpChangeDanbooru()">🎨 Change (Danbooru)</button> <button onclick="pfpChangeDanbooru()">🎨 Change (Danbooru)</button>
<button onclick="pfpRestoreFallback()">🔄 Restore Original Avatar</button> <button onclick="pfpRestoreFallback()">🔄 Restore Original Avatar</button>
<button onclick="pfpRecrop()">✂️ Re-crop Current</button>
</div> </div>
<!-- Upload Section --> <!-- Upload Section -->
@@ -1795,6 +1895,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>
@@ -1838,6 +2002,7 @@
<span class="label">🎯 Current Avatar (cropped)</span> <span class="label">🎯 Current Avatar (cropped)</span>
<img id="pfp-preview-current" src="" alt="Current avatar" style="border-radius: 50%; max-width: 256px; max-height: 256px;"> <img id="pfp-preview-current" src="" alt="Current avatar" style="border-radius: 50%; max-width: 256px; max-height: 256px;">
<div style="font-size: 0.8rem; color: #666; margin-top: 0.3rem;">512×512 · displayed as circle</div> <div style="font-size: 0.8rem; color: #666; margin-top: 0.3rem;">512×512 · displayed as circle</div>
<button onclick="pfpRecrop()" style="margin-top: 0.5rem;">✂️ Re-crop Current</button>
</div> </div>
</div> </div>
@@ -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;

View File

@@ -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()