Merge feature/ExifExtraction FEATURE_PLAN
This commit is contained in:
commit
19a813bbb7
480
docs/FEATURE_PLAN-exif-extraction.md
Normal file
480
docs/FEATURE_PLAN-exif-extraction.md
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
# Feature Plan: EXIF-Daten Extraktion & Capture-Date Sortierung
|
||||
|
||||
**Status**: In Planung
|
||||
**Branch**: `feature/ExifExtraction`
|
||||
**Erstellt**: 09. November 2025
|
||||
|
||||
## Ziel
|
||||
|
||||
Automatische Extraktion von EXIF-Metadaten (insbesondere Aufnahmedatum) aus hochgeladenen Bildern und Nutzung dieser Informationen für eine präzisere chronologische Sortierung der Slideshow-Gruppen.
|
||||
|
||||
## Motivation
|
||||
|
||||
### Aktuelles Verhalten
|
||||
- Slideshow-Gruppen werden nach **Jahr** (Benutzereingabe) und **Upload-Datum** sortiert
|
||||
- Aufnahmedatum der Bilder wird nicht berücksichtigt
|
||||
- Bei Bildern aus verschiedenen Jahren in einer Gruppe: Unklare Sortierung
|
||||
|
||||
### Gewünschtes Verhalten
|
||||
- Automatische Extraktion des **Aufnahmedatums** (EXIF `DateTimeOriginal`) beim Upload
|
||||
- Nutzung des **ältesten Aufnahmedatums** einer Gruppe als primäres Sortier-Kriterium
|
||||
- Fallback auf Jahr/Upload-Datum wenn EXIF-Daten fehlen
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Use Case 1: Alte Fotos digitalisieren
|
||||
**Szenario**: Benutzer scannt alte Fotos von 1995 und lädt sie 2025 hoch
|
||||
- **Problem**: Gruppe würde aktuell in 2025 einsortiert
|
||||
- **Lösung**: EXIF-Datum (falls eingescannt mit Datum) oder Jahr-Eingabe nutzen
|
||||
|
||||
### Use Case 2: Gemischte Jahre in einer Gruppe
|
||||
**Szenario**: Benutzer lädt Bilder von einem Urlaub hoch, der über Jahreswechsel ging
|
||||
- 5 Bilder vom 28.12.2023
|
||||
- 8 Bilder vom 02.01.2024
|
||||
- **Lösung**: Gruppe wird nach ältestem Aufnahmedatum sortiert (28.12.2023)
|
||||
|
||||
### Use Case 3: Bilder ohne EXIF
|
||||
**Szenario**: Screenshots, bearbeitete Bilder, oder alte digitalisierte Fotos ohne Metadaten
|
||||
- **Lösung**: Fallback auf Jahr-Eingabe (wie bisher)
|
||||
|
||||
## Technische Lösung
|
||||
|
||||
### A. EXIF-Extraktion (Backend)
|
||||
|
||||
#### 1. Dependency
|
||||
**Library**: `exif-parser` oder `exifr` (npm)
|
||||
```bash
|
||||
npm install exifr --save
|
||||
```
|
||||
|
||||
**Warum `exifr`?**
|
||||
- ✅ Modern, aktiv maintained
|
||||
- ✅ Unterstützt viele Formate (JPEG, TIFF, HEIC, etc.)
|
||||
- ✅ Promise-based API
|
||||
- ✅ Klein und performant
|
||||
- ✅ Keine Native Dependencies (Pure JavaScript)
|
||||
|
||||
#### 2. Datenbank-Schema Migration
|
||||
|
||||
**Neue Felder in `images` Tabelle**:
|
||||
```sql
|
||||
ALTER TABLE images ADD COLUMN exif_date_taken DATETIME DEFAULT NULL;
|
||||
ALTER TABLE images ADD COLUMN exif_camera_model TEXT DEFAULT NULL;
|
||||
ALTER TABLE images ADD COLUMN exif_location_lat REAL DEFAULT NULL;
|
||||
ALTER TABLE images ADD COLUMN exif_location_lon REAL DEFAULT NULL;
|
||||
```
|
||||
|
||||
**Neues berechnetes Feld in `groups` Tabelle**:
|
||||
```sql
|
||||
ALTER TABLE groups ADD COLUMN capture_date DATETIME DEFAULT NULL;
|
||||
-- Wird beim Upload berechnet: MIN(exif_date_taken) aller Bilder der Gruppe
|
||||
```
|
||||
|
||||
**Index für Performance**:
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_groups_capture_date ON groups(capture_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_exif_date_taken ON images(exif_date_taken);
|
||||
```
|
||||
|
||||
#### 3. EXIF Service (Backend)
|
||||
|
||||
**Datei**: `backend/src/services/ExifService.js`
|
||||
|
||||
```javascript
|
||||
const exifr = require('exifr');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
class ExifService {
|
||||
/**
|
||||
* Extrahiere EXIF-Daten aus einem Bild
|
||||
* @param {string} filePath - Absoluter Pfad zum Bild
|
||||
* @returns {Promise<Object>} EXIF-Daten oder null
|
||||
*/
|
||||
async extractExifData(filePath) {
|
||||
try {
|
||||
const exifData = await exifr.parse(filePath, {
|
||||
pick: [
|
||||
'DateTimeOriginal', // Aufnahmedatum
|
||||
'CreateDate', // Fallback
|
||||
'Make', // Kamera-Hersteller
|
||||
'Model', // Kamera-Modell
|
||||
'latitude', // GPS-Koordinaten
|
||||
'longitude'
|
||||
]
|
||||
});
|
||||
|
||||
if (!exifData) return null;
|
||||
|
||||
return {
|
||||
dateTaken: exifData.DateTimeOriginal || exifData.CreateDate || null,
|
||||
cameraModel: exifData.Model ? `${exifData.Make || ''} ${exifData.Model}`.trim() : null,
|
||||
location: (exifData.latitude && exifData.longitude)
|
||||
? { lat: exifData.latitude, lon: exifData.longitude }
|
||||
: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[ExifService] Failed to extract EXIF from ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finde das älteste Aufnahmedatum in einer Gruppe
|
||||
* @param {Array} images - Array von Bild-Objekten mit exif_date_taken
|
||||
* @returns {Date|null} Ältestes Datum oder null
|
||||
*/
|
||||
getEarliestCaptureDate(images) {
|
||||
const dates = images
|
||||
.map(img => img.exif_date_taken)
|
||||
.filter(date => date !== null)
|
||||
.map(date => new Date(date));
|
||||
|
||||
if (dates.length === 0) return null;
|
||||
|
||||
return new Date(Math.min(...dates));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ExifService();
|
||||
```
|
||||
|
||||
#### 4. Upload-Route Integration
|
||||
|
||||
**Datei**: `backend/src/routes/upload.js`
|
||||
|
||||
```javascript
|
||||
const ExifService = require('../services/ExifService');
|
||||
|
||||
// Nach dem Speichern des Bildes:
|
||||
router.post('/upload', async (req, res) => {
|
||||
// ... existing upload logic ...
|
||||
|
||||
// EXIF-Extraktion für jedes Bild
|
||||
for (const file of uploadedFiles) {
|
||||
const filePath = path.join(UPLOAD_DIR, file.filename);
|
||||
const exifData = await ExifService.extractExifData(filePath);
|
||||
|
||||
// In Datenbank speichern
|
||||
await db.run(`
|
||||
UPDATE images
|
||||
SET exif_date_taken = ?,
|
||||
exif_camera_model = ?,
|
||||
exif_location_lat = ?,
|
||||
exif_location_lon = ?
|
||||
WHERE file_name = ?
|
||||
`, [
|
||||
exifData?.dateTaken || null,
|
||||
exifData?.cameraModel || null,
|
||||
exifData?.location?.lat || null,
|
||||
exifData?.location?.lon || null,
|
||||
file.filename
|
||||
]);
|
||||
}
|
||||
|
||||
// Berechne capture_date für Gruppe
|
||||
const images = await db.all('SELECT exif_date_taken FROM images WHERE group_id = ?', [groupId]);
|
||||
const captureDate = ExifService.getEarliestCaptureDate(images);
|
||||
|
||||
await db.run(`
|
||||
UPDATE groups
|
||||
SET capture_date = ?
|
||||
WHERE group_id = ?
|
||||
`, [captureDate?.toISOString() || null, groupId]);
|
||||
|
||||
// ... rest of upload logic ...
|
||||
});
|
||||
```
|
||||
|
||||
### B. Frontend-Änderungen
|
||||
|
||||
#### 1. Slideshow-Sortierung
|
||||
|
||||
**Datei**: `frontend/src/Components/Pages/SlideshowPage.js`
|
||||
|
||||
**Neue Sortier-Logik**:
|
||||
```javascript
|
||||
const sortedGroups = [...groupsData.groups].sort((a, b) => {
|
||||
// 1. Priorität: capture_date (EXIF-basiert)
|
||||
if (a.captureDate && b.captureDate) {
|
||||
return new Date(a.captureDate) - new Date(b.captureDate);
|
||||
}
|
||||
|
||||
// 2. Priorität: Wenn nur eine Gruppe EXIF hat, diese zuerst
|
||||
if (a.captureDate && !b.captureDate) return -1;
|
||||
if (!a.captureDate && b.captureDate) return 1;
|
||||
|
||||
// 3. Fallback: Jahr (Benutzereingabe)
|
||||
if (a.year !== b.year) {
|
||||
return a.year - b.year;
|
||||
}
|
||||
|
||||
// 4. Fallback: Upload-Datum
|
||||
return new Date(a.uploadDate) - new Date(b.uploadDate);
|
||||
});
|
||||
```
|
||||
|
||||
#### 2. Metadaten-Anzeige (Optional)
|
||||
|
||||
**Datei**: `frontend/src/Components/Pages/SlideshowPage.js`
|
||||
|
||||
**Erweiterte Info-Box**:
|
||||
```jsx
|
||||
<Typography sx={metaTextSx}>
|
||||
{currentGroup.captureDate && (
|
||||
<>Aufnahme: {new Date(currentGroup.captureDate).toLocaleDateString('de-DE')} • </>
|
||||
)}
|
||||
Bild {currentImageIndex + 1} von {currentGroup.images.length} •
|
||||
Slideshow {currentGroupIndex + 1} von {allGroups.length}
|
||||
</Typography>
|
||||
```
|
||||
|
||||
### C. Batch-Migration (Bestehende Bilder)
|
||||
|
||||
**Datei**: `backend/src/scripts/migrate-exif.js`
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* Einmaliges Skript zum Extrahieren von EXIF-Daten aus bestehenden Bildern
|
||||
*/
|
||||
const ExifService = require('../services/ExifService');
|
||||
const db = require('../database/DatabaseManager');
|
||||
const path = require('path');
|
||||
|
||||
async function migrateExistingImages() {
|
||||
console.log('[EXIF Migration] Starting...');
|
||||
|
||||
const images = await db.all('SELECT id, file_name, group_id FROM images WHERE exif_date_taken IS NULL');
|
||||
console.log(`[EXIF Migration] Found ${images.length} images without EXIF data`);
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const image of images) {
|
||||
const filePath = path.join(__dirname, '../data/images', image.file_name);
|
||||
const exifData = await ExifService.extractExifData(filePath);
|
||||
|
||||
if (exifData && exifData.dateTaken) {
|
||||
await db.run(`
|
||||
UPDATE images
|
||||
SET exif_date_taken = ?,
|
||||
exif_camera_model = ?,
|
||||
exif_location_lat = ?,
|
||||
exif_location_lon = ?
|
||||
WHERE id = ?
|
||||
`, [
|
||||
exifData.dateTaken,
|
||||
exifData.cameraModel,
|
||||
exifData.location?.lat,
|
||||
exifData.location?.lon,
|
||||
image.id
|
||||
]);
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update capture_date für alle Gruppen
|
||||
const groups = await db.all('SELECT group_id FROM groups WHERE capture_date IS NULL');
|
||||
for (const group of groups) {
|
||||
const groupImages = await db.all('SELECT exif_date_taken FROM images WHERE group_id = ?', [group.group_id]);
|
||||
const captureDate = ExifService.getEarliestCaptureDate(groupImages);
|
||||
|
||||
if (captureDate) {
|
||||
await db.run('UPDATE groups SET capture_date = ? WHERE group_id = ?', [
|
||||
captureDate.toISOString(),
|
||||
group.group_id
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[EXIF Migration] Complete! Success: ${successCount}, Failed: ${failCount}`);
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrateExistingImages().catch(console.error);
|
||||
```
|
||||
|
||||
## Implementierungs-Plan
|
||||
|
||||
### Phase 1: Backend - EXIF Extraktion (3-4 Stunden)
|
||||
|
||||
1. **Dependencies installieren** (10 min)
|
||||
- [ ] `npm install exifr` im Backend
|
||||
- [ ] Package-Lock aktualisieren
|
||||
|
||||
2. **Datenbank-Migration** (30 min)
|
||||
- [ ] Migration-Script erstellen (`migration-005-exif.sql`)
|
||||
- [ ] Neue Felder in `images` Tabelle
|
||||
- [ ] Neues Feld `capture_date` in `groups` Tabelle
|
||||
- [ ] Indizes erstellen
|
||||
- [ ] Migration testen
|
||||
|
||||
3. **ExifService implementieren** (60 min)
|
||||
- [ ] `services/ExifService.js` erstellen
|
||||
- [ ] `extractExifData()` Methode
|
||||
- [ ] `getEarliestCaptureDate()` Methode
|
||||
- [ ] Error-Handling
|
||||
- [ ] Unit-Tests (optional)
|
||||
|
||||
4. **Upload-Route erweitern** (60 min)
|
||||
- [ ] EXIF-Extraktion in Upload-Flow integrieren
|
||||
- [ ] Datenbank-Updates
|
||||
- [ ] `capture_date` Berechnung
|
||||
- [ ] Testing mit verschiedenen Bildtypen
|
||||
|
||||
5. **Migration-Script** (30 min)
|
||||
- [ ] `scripts/migrate-exif.js` erstellen
|
||||
- [ ] Batch-Processing für bestehende Bilder
|
||||
- [ ] Logging und Progress-Anzeige
|
||||
- [ ] Test mit Development-Daten
|
||||
|
||||
### Phase 2: Frontend - Sortierung & Anzeige (1-2 Stunden)
|
||||
|
||||
1. **Slideshow-Sortierung** (30 min)
|
||||
- [ ] `SlideshowPage.js` anpassen
|
||||
- [ ] Neue Sortier-Logik mit `captureDate`
|
||||
- [ ] Fallback-Logik testen
|
||||
|
||||
2. **Metadaten-Anzeige** (30 min, Optional)
|
||||
- [ ] Aufnahmedatum in Info-Box anzeigen
|
||||
- [ ] Kamera-Modell anzeigen (optional)
|
||||
- [ ] Responsive Design
|
||||
|
||||
3. **Groups-Overview** (30 min, Optional)
|
||||
- [ ] EXIF-Daten in Gruppen-Übersicht anzeigen
|
||||
- [ ] Filter nach Kamera-Modell (optional)
|
||||
|
||||
### Phase 3: Testing & Dokumentation (1 Stunde)
|
||||
|
||||
1. **Testing** (30 min)
|
||||
- [ ] Upload mit EXIF-Daten
|
||||
- [ ] Upload ohne EXIF-Daten (Screenshots)
|
||||
- [ ] Sortierung mit gemischten Gruppen
|
||||
- [ ] Migration-Script auf bestehenden Daten
|
||||
- [ ] Performance-Test (viele Bilder)
|
||||
|
||||
2. **Dokumentation** (30 min)
|
||||
- [ ] README.md aktualisieren
|
||||
- [ ] CHANGELOG.md Entry
|
||||
- [ ] API-Dokumentation (falls vorhanden)
|
||||
- [ ] Migration-Anleitung
|
||||
|
||||
## Technische Details
|
||||
|
||||
### EXIF-Datenformate
|
||||
|
||||
**EXIF DateTimeOriginal**:
|
||||
- Format: `"YYYY:MM:DD HH:MM:SS"` (z.B. `"2023:12:28 15:30:45"`)
|
||||
- Konvertierung zu ISO-8601: `new Date("2023-12-28T15:30:45").toISOString()`
|
||||
|
||||
**GPS-Koordinaten**:
|
||||
- `latitude`: -90 bis +90 (Süd zu Nord)
|
||||
- `longitude`: -180 bis +180 (West zu Ost)
|
||||
|
||||
### Performance-Überlegungen
|
||||
|
||||
**EXIF-Extraktion ist I/O-intensiv**:
|
||||
- Pro Bild: ~10-50ms (je nach Größe)
|
||||
- 10 Bilder: ~100-500ms zusätzlich beim Upload
|
||||
- **Mitigation**: Async/Parallel processing mit `Promise.all()`
|
||||
|
||||
**Speicherverbrauch**:
|
||||
- EXIF-Daten: ~100-500 bytes pro Bild in DB
|
||||
- Vernachlässigbar bei 1000+ Bildern
|
||||
|
||||
## Erwartete Verbesserungen
|
||||
|
||||
### Sortierung
|
||||
- ✅ Präzise chronologische Sortierung nach tatsächlichem Aufnahmedatum
|
||||
- ✅ Automatisch, keine manuelle Eingabe nötig
|
||||
- ✅ Funktioniert auch bei Bildern aus verschiedenen Jahren in einer Gruppe
|
||||
|
||||
### User Experience
|
||||
- ✅ Mehr Kontext durch Anzeige von Kamera-Modell
|
||||
- ✅ Potenziell: Geo-Tagging für Location-basierte Features (Zukunft)
|
||||
- ✅ Bessere Archivierung alter Fotos
|
||||
|
||||
### Daten-Qualität
|
||||
- ✅ Strukturierte Metadaten in Datenbank
|
||||
- ✅ Basis für zukünftige Features (z.B. Kamera-Filter, Geo-Map)
|
||||
|
||||
## Risiken & Mitigationen
|
||||
|
||||
### Risiko 1: Bilder ohne EXIF
|
||||
**Problem**: Screenshots, bearbeitete Bilder, oder alte Scans haben keine EXIF-Daten
|
||||
**Mitigation**: Fallback auf Jahr-Eingabe und Upload-Datum (wie bisher)
|
||||
|
||||
### Risiko 2: Falsche EXIF-Daten
|
||||
**Problem**: Kamera-Uhrzeit falsch eingestellt
|
||||
**Mitigation**: User-Eingabe (Jahr) hat Vorrang, EXIF nur als Ergänzung
|
||||
|
||||
### Risiko 3: Performance beim Upload
|
||||
**Problem**: EXIF-Extraktion verlangsamt Upload
|
||||
**Mitigation**:
|
||||
- Parallel-Processing mit `Promise.all()`
|
||||
- Timeout (max 100ms pro Bild)
|
||||
- Falls Timeout: Upload trotzdem erfolgreich, EXIF später nachholen
|
||||
|
||||
### Risiko 4: HEIC/HEIF Support
|
||||
**Problem**: Apple-Formate nicht von allen Libraries unterstützt
|
||||
**Mitigation**: `exifr` unterstützt HEIC nativ
|
||||
|
||||
## Alternativen
|
||||
|
||||
### Alternative 1: Nur Jahr-Feld nutzen (Status Quo)
|
||||
**Nachteil**: Ungenau bei gemischten Jahren, manuelle Eingabe erforderlich
|
||||
|
||||
### Alternative 2: Server-Side Image Processing (Sharp + EXIF)
|
||||
```javascript
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(filePath).metadata();
|
||||
```
|
||||
**Nachteil**: `sharp` hat native dependencies (schwierigeres Deployment)
|
||||
|
||||
### Alternative 3: Frontend EXIF-Extraktion
|
||||
**Nachteil**: Mehr Client-Traffic, nicht bei allen Browsern zuverlässig
|
||||
|
||||
## Erfolgs-Kriterien
|
||||
|
||||
✅ **Must-Have**:
|
||||
1. EXIF-Daten werden beim Upload automatisch extrahiert
|
||||
2. `capture_date` wird korrekt berechnet (ältestes Bild der Gruppe)
|
||||
3. Slideshow sortiert nach `capture_date` (mit Fallback)
|
||||
4. Migration-Script funktioniert für bestehende Bilder
|
||||
5. Upload-Performance bleibt akzeptabel (<500ms zusätzlich für 10 Bilder)
|
||||
|
||||
✅ **Nice-to-Have**:
|
||||
1. Anzeige von Aufnahmedatum in Slideshow
|
||||
2. Anzeige von Kamera-Modell
|
||||
3. GPS-Koordinaten gespeichert (für zukünftige Features)
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
- [ ] Soll Aufnahmedatum editierbar sein (falls EXIF fehlt oder falsch)?
|
||||
- [ ] Soll Kamera-Modell in der Gruppen-Übersicht angezeigt werden?
|
||||
- [ ] Sollen GPS-Koordinaten für Geo-Tagging genutzt werden (Map-View)?
|
||||
- [ ] Soll EXIF-Extraktion synchron (beim Upload) oder asynchron (Background-Job) erfolgen?
|
||||
|
||||
## Rollout-Plan
|
||||
|
||||
1. **Development** (feature/ExifExtraction Branch)
|
||||
- Implementierung & Unit-Tests
|
||||
- Migration-Script testen
|
||||
- Code Review
|
||||
|
||||
2. **Staging/Testing**
|
||||
- Migration auf Dev-Environment
|
||||
- Test-Uploads mit verschiedenen Bildtypen
|
||||
- Performance-Messungen
|
||||
|
||||
3. **Production**
|
||||
1. Datenbank-Backup
|
||||
2. Migration-Script ausführen
|
||||
3. Deployment via Docker Compose
|
||||
4. Monitoring für 24h
|
||||
|
||||
---
|
||||
|
||||
**Erstellt von**: GitHub Copilot
|
||||
**Review durch**: @lotzm
|
||||
Loading…
Reference in New Issue
Block a user