394 lines
11 KiB
Markdown
Executable File
394 lines
11 KiB
Markdown
Executable File
# Session 2025-12-31 : Génération automatique de miniatures
|
||
|
||
## 🎯 Objectif
|
||
|
||
Générer automatiquement des miniatures (thumbnails) lors de l'upload de photos pour optimiser le chargement des listes de périphériques.
|
||
|
||
## 📊 État actuel
|
||
|
||
### Avant
|
||
- ✅ Photos stockées et redimensionnées
|
||
- ❌ Pas de miniatures générées
|
||
- ❌ Liste charge les images pleines résolution (lent)
|
||
- ❌ Pas de champ `thumbnail_path` en base de données
|
||
|
||
### Architecture actuelle
|
||
```
|
||
Upload photo → process_image() → Stockage image redimensionnée → BDD (stored_path)
|
||
```
|
||
|
||
## ✅ Implémentation
|
||
|
||
### 1. Migration base de données (009)
|
||
|
||
**Fichier** : `backend/migrations/009_add_thumbnail_path.sql`
|
||
|
||
```sql
|
||
ALTER TABLE peripheral_photos ADD COLUMN thumbnail_path TEXT;
|
||
```
|
||
|
||
**Script application** : `backend/apply_migration_009.py`
|
||
|
||
```bash
|
||
# En local
|
||
cd backend
|
||
python3 apply_migration_009.py
|
||
|
||
# Dans Docker
|
||
docker exec linux_benchtools_backend python3 -c "
|
||
import sqlite3
|
||
conn = sqlite3.connect('/app/data/peripherals.db')
|
||
cursor = conn.cursor()
|
||
cursor.execute('ALTER TABLE peripheral_photos ADD COLUMN thumbnail_path TEXT')
|
||
conn.commit()
|
||
conn.close()
|
||
"
|
||
```
|
||
|
||
### 2. Modèle de données
|
||
|
||
**Fichier** : `backend/app/models/peripheral.py` (ligne 147)
|
||
|
||
```python
|
||
class PeripheralPhoto(BasePeripherals):
|
||
id = Column(Integer, primary_key=True)
|
||
peripheral_id = Column(Integer, nullable=False, index=True)
|
||
filename = Column(String(255), nullable=False)
|
||
stored_path = Column(String(500), nullable=False)
|
||
thumbnail_path = Column(String(500)) # ← NOUVEAU
|
||
mime_type = Column(String(100))
|
||
size_bytes = Column(Integer)
|
||
uploaded_at = Column(DateTime, server_default=func.now())
|
||
description = Column(Text)
|
||
is_primary = Column(Boolean, default=False)
|
||
```
|
||
|
||
### 3. Schéma API
|
||
|
||
**Fichier** : `backend/app/schemas/peripheral.py` (ligne 202)
|
||
|
||
```python
|
||
class PeripheralPhotoSchema(PeripheralPhotoBase):
|
||
id: int
|
||
peripheral_id: int
|
||
filename: str
|
||
stored_path: str
|
||
thumbnail_path: Optional[str] # ← NOUVEAU
|
||
mime_type: Optional[str]
|
||
size_bytes: Optional[int]
|
||
uploaded_at: datetime
|
||
```
|
||
|
||
### 4. Endpoint upload (génération)
|
||
|
||
**Fichier** : `backend/app/api/endpoints/peripherals.py` (lignes 292-320)
|
||
|
||
```python
|
||
# Process image (main + thumbnail)
|
||
try:
|
||
# Process main image with level configuration
|
||
processed_path, file_size, original_path = ImageProcessor.process_image_with_level(
|
||
image_path=temp_path,
|
||
output_dir=upload_dir,
|
||
compression_level="medium", # Niveau medium par défaut
|
||
save_original=True
|
||
)
|
||
mime_type = ImageProcessor.get_mime_type(processed_path)
|
||
|
||
# Generate thumbnail
|
||
thumbnail_path, thumbnail_size = ImageProcessor.create_thumbnail_with_level(
|
||
image_path=temp_path,
|
||
output_dir=upload_dir,
|
||
compression_level="medium"
|
||
)
|
||
|
||
# Create database entry
|
||
photo = PeripheralPhoto(
|
||
peripheral_id=peripheral_id,
|
||
filename=os.path.basename(processed_path),
|
||
stored_path=processed_path,
|
||
thumbnail_path=thumbnail_path, # ← NOUVEAU
|
||
mime_type=mime_type,
|
||
size_bytes=file_size,
|
||
description=description,
|
||
is_primary=is_primary
|
||
)
|
||
```
|
||
|
||
### 5. Endpoint GET (conversion chemins web)
|
||
|
||
**Fichier** : `backend/app/api/endpoints/peripherals.py` (ligne 358)
|
||
|
||
```python
|
||
photo_dict = {
|
||
"id": photo.id,
|
||
"peripheral_id": photo.peripheral_id,
|
||
"filename": photo.filename,
|
||
"stored_path": photo.stored_path.replace('/app/uploads/', '/uploads/')
|
||
if photo.stored_path.startswith('/app/uploads/')
|
||
else photo.stored_path,
|
||
"thumbnail_path": photo.thumbnail_path.replace('/app/uploads/', '/uploads/')
|
||
if photo.thumbnail_path and photo.thumbnail_path.startswith('/app/uploads/')
|
||
else photo.thumbnail_path, # ← NOUVEAU
|
||
"mime_type": photo.mime_type,
|
||
# ...
|
||
}
|
||
```
|
||
|
||
## 📁 Structure des fichiers
|
||
|
||
### Arborescence créée lors de l'upload
|
||
|
||
```
|
||
uploads/peripherals/photos/{peripheral_id}/
|
||
├── original/
|
||
│ └── photo_originale.webp (fichier source non modifié)
|
||
├── photo_redimensionnee.png (1920x1080 @ 85% qualité)
|
||
└── thumbnail/
|
||
└── thumb_photo_redimensionnee.png (300x300 @ 75% qualité)
|
||
```
|
||
|
||
### Chemins stockés en base de données
|
||
|
||
| Champ | Valeur (filesystem) | Valeur (API web) |
|
||
|-------|---------------------|------------------|
|
||
| `stored_path` | `/app/uploads/peripherals/photos/3/image.png` | `/uploads/peripherals/photos/3/image.png` |
|
||
| `thumbnail_path` | `/app/uploads/peripherals/photos/3/thumbnail/thumb_image.png` | `/uploads/peripherals/photos/3/thumbnail/thumb_image.png` |
|
||
|
||
## ⚙️ Configuration compression
|
||
|
||
**Fichier** : `config/image_compression.yaml`
|
||
|
||
### Niveau "medium" (par défaut)
|
||
|
||
```yaml
|
||
medium:
|
||
enabled: true
|
||
quality: 85 # Image principale
|
||
max_width: 1920
|
||
max_height: 1080
|
||
thumbnail_size: 300 # Miniature 300x300px
|
||
thumbnail_quality: 75 # Qualité miniature
|
||
description: "Qualité moyenne - Usage général"
|
||
```
|
||
|
||
### Tous les niveaux disponibles
|
||
|
||
| Niveau | Image principale | Thumbnail | Usage |
|
||
|--------|------------------|-----------|-------|
|
||
| **high** | 2560×1920 @ 92% | 400px @ 85% | Photos importantes |
|
||
| **medium** | 1920×1080 @ 85% | 300px @ 75% | Usage général ⭐ |
|
||
| **low** | 1280×720 @ 75% | 200px @ 65% | Économie d'espace |
|
||
| **minimal** | 800×600 @ 65% | 150px @ 55% | Aperçu seulement |
|
||
|
||
## 🔄 Flux complet
|
||
|
||
```
|
||
1. User upload photo.jpg
|
||
↓
|
||
2. Backend reçoit le fichier
|
||
↓
|
||
3. ImageProcessor.process_image_with_level()
|
||
│ ├─> Copie originale → original/photo.jpg
|
||
│ └─> Resize + compress → photo.png (1920x1080)
|
||
↓
|
||
4. ImageProcessor.create_thumbnail_with_level()
|
||
└─> Resize + compress → thumbnail/thumb_photo.png (300x300)
|
||
↓
|
||
5. Stockage en BDD
|
||
├─> stored_path: /app/uploads/.../photo.png
|
||
└─> thumbnail_path: /app/uploads/.../thumbnail/thumb_photo.png
|
||
↓
|
||
6. API GET /peripherals/{id}/photos
|
||
├─> Conversion: /app/uploads/... → /uploads/...
|
||
└─> Retour JSON avec stored_path ET thumbnail_path
|
||
↓
|
||
7. Frontend charge la miniature dans la liste
|
||
└─> <img src="/uploads/.../thumbnail/thumb_photo.png">
|
||
```
|
||
|
||
## 📊 Gain de performance
|
||
|
||
### Avant (sans thumbnails)
|
||
- Liste 10 périphériques avec photos
|
||
- Charge 10 images × ~500 KB = **~5 MB**
|
||
- Temps de chargement : **2-3 secondes** (connexion moyenne)
|
||
|
||
### Après (avec thumbnails)
|
||
- Liste 10 périphériques avec miniatures
|
||
- Charge 10 thumbnails × ~20 KB = **~200 KB**
|
||
- Temps de chargement : **<300ms** (connexion moyenne)
|
||
|
||
**Gain** : **~96% de données en moins** pour l'affichage de la liste
|
||
|
||
## 🧪 Test de validation
|
||
|
||
### 1. Upload une nouvelle photo
|
||
|
||
```bash
|
||
# Via API (avec curl)
|
||
curl -X POST "http://10.0.0.50:8007/api/peripherals/3/photos" \
|
||
-H "X-API-Token: YOUR_TOKEN" \
|
||
-F "file=@test_image.jpg" \
|
||
-F "is_primary=false"
|
||
```
|
||
|
||
### 2. Vérifier les fichiers générés
|
||
|
||
```bash
|
||
# Dans le conteneur backend
|
||
docker exec linux_benchtools_backend ls -lh /app/uploads/peripherals/photos/3/
|
||
|
||
# Devrait afficher :
|
||
# - original/test_image.jpg
|
||
# - test_image.png (image redimensionnée)
|
||
# - thumbnail/thumb_test_image.png (miniature)
|
||
```
|
||
|
||
### 3. Vérifier l'API
|
||
|
||
```bash
|
||
curl http://10.0.0.50:8007/api/peripherals/3/photos | python3 -m json.tool
|
||
```
|
||
|
||
**Résultat attendu** :
|
||
```json
|
||
[
|
||
{
|
||
"id": 5,
|
||
"peripheral_id": 3,
|
||
"filename": "test_image.png",
|
||
"stored_path": "/uploads/peripherals/photos/3/test_image.png",
|
||
"thumbnail_path": "/uploads/peripherals/photos/3/thumbnail/thumb_test_image.png",
|
||
"mime_type": "image/png",
|
||
"size_bytes": 450000,
|
||
"uploaded_at": "2025-12-31T10:00:00"
|
||
}
|
||
]
|
||
```
|
||
|
||
### 4. Vérifier nginx sert la miniature
|
||
|
||
```bash
|
||
curl -I http://10.0.0.50:8087/uploads/peripherals/photos/3/thumbnail/thumb_test_image.png
|
||
```
|
||
|
||
**Résultat attendu** :
|
||
```
|
||
HTTP/1.1 200 OK
|
||
Content-Type: image/png
|
||
```
|
||
|
||
## 🎨 Frontend (à implémenter)
|
||
|
||
### Liste des périphériques
|
||
|
||
**Utiliser `thumbnail_path` au lieu de `stored_path`** :
|
||
|
||
```javascript
|
||
// peripherals.js
|
||
function renderPeripheralCard(peripheral, photo) {
|
||
const imageUrl = photo.thumbnail_path || photo.stored_path || '/img/no-image.png';
|
||
|
||
return `
|
||
<div class="peripheral-card">
|
||
<img src="${escapeHtml(imageUrl)}"
|
||
alt="${escapeHtml(peripheral.nom)}"
|
||
loading="lazy">
|
||
...
|
||
</div>
|
||
`;
|
||
}
|
||
```
|
||
|
||
### Page détail du périphérique
|
||
|
||
**Utiliser `stored_path` (pleine résolution)** :
|
||
|
||
```javascript
|
||
// peripheral-detail.js
|
||
function displayPhotos(photos) {
|
||
grid.innerHTML = photos.map(photo => `
|
||
<div class="photo-item">
|
||
<img src="${photo.stored_path}"
|
||
alt="${escapeHtml(photo.description || 'Photo')}"
|
||
data-thumbnail="${photo.thumbnail_path}">
|
||
</div>
|
||
`).join('');
|
||
}
|
||
```
|
||
|
||
## 📝 Notes techniques
|
||
|
||
### Formats supportés
|
||
|
||
**Entrée** (config) :
|
||
- jpg, jpeg, png, webp
|
||
|
||
**Sortie** (config) :
|
||
- png (par défaut, configurable dans `image_compression.yaml`)
|
||
|
||
### Nommage des fichiers
|
||
|
||
**Préfixe miniature** : `thumb_` (configurable dans `image_compression.yaml`)
|
||
|
||
**Exemples** :
|
||
- Image : `photo_20251231_120000.png`
|
||
- Thumbnail : `thumb_photo_20251231_120000.png`
|
||
|
||
### Rétrocompatibilité
|
||
|
||
Les anciennes photos (sans `thumbnail_path`) :
|
||
- Continueront de fonctionner
|
||
- Frontend utilise fallback : `thumbnail_path || stored_path`
|
||
- Possibilité de régénérer les miniatures via script migration
|
||
|
||
## 🔧 Script de migration (optionnel)
|
||
|
||
Pour régénérer les miniatures des anciennes photos :
|
||
|
||
```python
|
||
# regenerate_thumbnails.py
|
||
from app.models.peripheral import PeripheralPhoto
|
||
from app.utils.image_processor import ImageProcessor
|
||
from app.db.session import get_peripherals_db
|
||
|
||
db = next(get_peripherals_db())
|
||
photos = db.query(PeripheralPhoto).filter(
|
||
PeripheralPhoto.thumbnail_path.is_(None)
|
||
).all()
|
||
|
||
for photo in photos:
|
||
if os.path.exists(photo.stored_path):
|
||
upload_dir = os.path.dirname(photo.stored_path)
|
||
thumbnail_path, _ = ImageProcessor.create_thumbnail_with_level(
|
||
image_path=photo.stored_path,
|
||
output_dir=upload_dir,
|
||
compression_level="medium"
|
||
)
|
||
photo.thumbnail_path = thumbnail_path
|
||
db.commit()
|
||
print(f"✅ Thumbnail generated for photo {photo.id}")
|
||
```
|
||
|
||
## 📋 Fichiers modifiés/créés
|
||
|
||
### Créés
|
||
- ✅ `backend/migrations/009_add_thumbnail_path.sql`
|
||
- ✅ `backend/apply_migration_009.py`
|
||
- ✅ `docs/SESSION_2025-12-31_THUMBNAILS.md` (ce fichier)
|
||
|
||
### Modifiés
|
||
- ✅ `backend/app/models/peripheral.py` - Ajout champ `thumbnail_path`
|
||
- ✅ `backend/app/schemas/peripheral.py` - Ajout champ dans schema
|
||
- ✅ `backend/app/api/endpoints/peripherals.py` - Génération thumbnail + conversion web paths
|
||
|
||
### Configuration existante
|
||
- ✅ `config/image_compression.yaml` - Configuration déjà en place
|
||
|
||
---
|
||
|
||
**Date** : 31 décembre 2025
|
||
**Statut** : ✅ Backend implémenté et testé
|
||
**Reste à faire** : Frontend (utiliser `thumbnail_path` dans les listes)
|