# 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 └─> ``` ## 📊 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 `
${escapeHtml(peripheral.nom)} ...
`; } ``` ### 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 => `
${escapeHtml(photo.description || 'Photo')}
`).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)