mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
feat: Implement a new FastAPI service for video processing with FFmpeg, TTS, and subtitle generation, including font management and diagnostic endpoints.
This commit is contained in:
1 parent
838d168239
commit
1f7d0d1675
8 files changed
+494
-56
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:pixabay.com)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -505,6 +505,230 @@ CREATE TABLE instagram_accounts (
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Evolution REEL - Amélioration Qualité Vidéo & Musique
|
||||||
|
|
||||||
|
### Contexte
|
||||||
|
|
||||||
|
Suite à l'implémentation initiale des Reels Facebook, deux axes d'amélioration majeurs ont été identifiés :
|
||||||
|
1. **Qualité vidéo** : Stabilisation et optimisation pour Facebook
|
||||||
|
2. **Système musique** : Remplacement de FreeSound par un vrai catalogue musical avec favoris
|
||||||
|
|
||||||
|
### Phase 1 : Amélioration Qualité Vidéo & Stabilisation
|
||||||
|
|
||||||
|
#### Objectif
|
||||||
|
Produire des Reels de qualité professionnelle optimisés pour Facebook avec stabilisation avancée et texte style TikTok.
|
||||||
|
|
||||||
|
#### Modifications Techniques Implémentées
|
||||||
|
|
||||||
|
**1. Stabilisation Vidéo VidStab (2 passes)**
|
||||||
|
|
||||||
|
Fichier : `ffmpeg-service/main.py`
|
||||||
|
|
||||||
|
| Paramètre | Valeur | Justification |
|
||||||
|
|-----------|--------|---------------|
|
||||||
|
| **Pass 1 : Détection** | | |
|
||||||
|
| `shakiness` | 10 | Sensibilité maximale aux tremblements |
|
||||||
|
| `accuracy` | 15 | Haute précision d'analyse |
|
||||||
|
| `stepsize` | 32 | Grande fenêtre de recherche pour gros tremblements |
|
||||||
|
| **Pass 2 : Transformation** | | |
|
||||||
|
| `smoothing` | 30 | Lissage lourd pour effet professionnel |
|
||||||
|
| `relative` | 1 | Transformations relatives au frame précédent |
|
||||||
|
| `zoom` | 5 | Zoom fixe 5% pour éviter les bords noirs |
|
||||||
|
| `unsharp` | 5:5:1.0:5:5:0.0 | Netteté renforcée pour compenser le lissage |
|
||||||
|
|
||||||
|
**2. Optimisation Encodage Facebook**
|
||||||
|
|
||||||
|
| Paramètre | Avant | Après | Raison |
|
||||||
|
|-----------|-------|-------|--------|
|
||||||
|
| CRF | 17 | 18 | Sweet spot qualité/taille pour Facebook |
|
||||||
|
| Level H.264 | *(absent)* | 4.1 | Compatibilité profil High |
|
||||||
|
| Audio bitrate | 192k | 128k | Standard Facebook |
|
||||||
|
| Brightness/Contrast | *(absent)* | `eq=brightness=0.05:contrast=1.1` | Rehausse les vidéos ternes |
|
||||||
|
|
||||||
|
**3. Texte Style TikTok Karaoke**
|
||||||
|
|
||||||
|
Implémentation d'un effet karaoke avec surbrillance mot par mot :
|
||||||
|
|
||||||
|
- **Style visuel** :
|
||||||
|
- Couleur avant : Blanc (`&H00FFFFFF`)
|
||||||
|
- Couleur highlight : Jaune (`&H0000FFFF`)
|
||||||
|
- Contour noir épais (3px) pour lisibilité
|
||||||
|
- Ombre portée pour profondeur
|
||||||
|
|
||||||
|
- **Synchronisation** :
|
||||||
|
- Tags ASS `\kf` (karaoke fill) par mot
|
||||||
|
- Timing proportionnel à la longueur du mot
|
||||||
|
- Chunks de 3 mots max pour lisibilité mobile
|
||||||
|
|
||||||
|
- **Résultat** : Chaque mot se remplit progressivement de blanc vers jaune au rythme de la voix TTS, exactement comme TikTok.
|
||||||
|
|
||||||
|
**Code snippet (main.py:276-362)** :
|
||||||
|
```python
|
||||||
|
# ASS Karaoke Style
|
||||||
|
# PrimaryColour = Yellow (highlighted/spoken)
|
||||||
|
# SecondaryColour = White (before highlight)
|
||||||
|
Style: Default,Sans,{font_size},&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,5,50,50,0,1
|
||||||
|
|
||||||
|
# Karaoke tags per word
|
||||||
|
for word in chunk_words:
|
||||||
|
word_dur_cs = int((len(word) / chunk_chars) * chunk_duration * 100)
|
||||||
|
karaoke_parts.append(f"{{\\kf{word_dur_cs}}}{sanitized}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Résultat Final
|
||||||
|
|
||||||
|
**Chaîne de traitement vidéo complète** :
|
||||||
|
```
|
||||||
|
[Input Video]
|
||||||
|
→ VidStab Pass 1 (Analyse tremblements)
|
||||||
|
→ VidStab Pass 2 (Stabilisation + unsharp)
|
||||||
|
→ Scale/Crop 1080x1920
|
||||||
|
→ Brightness/Contrast enhancement
|
||||||
|
→ Texte Karaoke TikTok (ASS)
|
||||||
|
→ Encodage H.264 High 4.1, CRF 18
|
||||||
|
→ Audio AAC 128k
|
||||||
|
→ [Output Reel Facebook-ready]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2 : Système Musique Jamendo & Favoris
|
||||||
|
|
||||||
|
#### Objectif
|
||||||
|
Remplacer FreeSound (banque de sons) par Jamendo (vraie musique avec genres) et ajouter un système de favoris utilisateur.
|
||||||
|
|
||||||
|
#### Architecture Implémentée
|
||||||
|
|
||||||
|
**1. Remplacement FreeSound → Jamendo**
|
||||||
|
|
||||||
|
Fichiers modifiés :
|
||||||
|
- `server/routes/reels.ts` : Import `jamendoService` au lieu de `freeSoundService`
|
||||||
|
- `server/index.ts` : Configuration Jamendo avec client ID
|
||||||
|
|
||||||
|
**Service Jamendo** (`server/services/jamendo.ts`) :
|
||||||
|
- API : `https://api.jamendo.com/v3.0`
|
||||||
|
- Format audio : MP3 320kbps
|
||||||
|
- Genres supportés : pop, rock, electronic, hiphop, jazz, classical, ambient, chill, dance, indie
|
||||||
|
- License : Creative Commons
|
||||||
|
- Tri : `popularity_total` ou `popularity_week`
|
||||||
|
|
||||||
|
**2. Base de Données : Table Favoris**
|
||||||
|
|
||||||
|
Nouvelle table `music_favorites` :
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE music_favorites (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
album_name TEXT,
|
||||||
|
duration INTEGER NOT NULL,
|
||||||
|
preview_url TEXT NOT NULL,
|
||||||
|
download_url TEXT NOT NULL,
|
||||||
|
image_url TEXT,
|
||||||
|
license TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Fichiers modifiés :
|
||||||
|
- `shared/schema.ts` : Définition table + relations + types TypeScript
|
||||||
|
- `server/migrate.ts` : Migration automatique
|
||||||
|
- `server/storage.ts` : 4 nouvelles méthodes CRUD
|
||||||
|
|
||||||
|
**3. API Endpoints Favoris**
|
||||||
|
|
||||||
|
Routes dans `server/routes/reels.ts` :
|
||||||
|
|
||||||
|
| Endpoint | Méthode | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `/api/music/favorites` | GET | Liste les favoris de l'utilisateur |
|
||||||
|
| `/api/music/favorites/check/:trackId` | GET | Vérifie si un track est en favori |
|
||||||
|
| `/api/music/favorites` | POST | Ajoute un track aux favoris |
|
||||||
|
| `/api/music/favorites/:trackId` | DELETE | Supprime un favori |
|
||||||
|
|
||||||
|
**Ordre des routes** (critique pour le routing Express) :
|
||||||
|
1. `/api/music/search`
|
||||||
|
2. `/api/music/popular`
|
||||||
|
3. `/api/music/favorites` ← avant le wildcard
|
||||||
|
4. `/api/music/favorites/check/:trackId`
|
||||||
|
5. `/api/music/:trackId` ← wildcard en dernier
|
||||||
|
|
||||||
|
**4. Méthodes Storage**
|
||||||
|
|
||||||
|
Interface `IStorage` étendue :
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IStorage {
|
||||||
|
// Music Favorites
|
||||||
|
getMusicFavorites(userId: string): Promise<MusicFavorite[]>;
|
||||||
|
addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite>;
|
||||||
|
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
||||||
|
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implémentation utilise Drizzle ORM avec tri par `createdAt DESC`.
|
||||||
|
|
||||||
|
#### Functional Requirements Ajoutés
|
||||||
|
|
||||||
|
**Qualité Vidéo :**
|
||||||
|
- **FR-REEL-1** : Le système doit stabiliser automatiquement les vidéos tremblantes avec VidStab 2-pass
|
||||||
|
- **FR-REEL-2** : Le système doit optimiser l'encodage vidéo pour Facebook (H.264 High 4.1, CRF 18, 128k audio)
|
||||||
|
- **FR-REEL-3** : Le système doit rehausser légèrement la luminosité/contraste des vidéos ternes
|
||||||
|
- **FR-REEL-4** : Le texte doit s'afficher en style karaoke TikTok (highlight mot par mot synchronisé avec TTS)
|
||||||
|
|
||||||
|
**Musique & Favoris :**
|
||||||
|
- **FR-REEL-5** : L'utilisateur peut rechercher de la musique par genre (pop, rock, chill, etc.) via Jamendo
|
||||||
|
- **FR-REEL-6** : L'utilisateur peut ajouter des tracks Jamendo à ses favoris
|
||||||
|
- **FR-REEL-7** : L'utilisateur peut voir la liste de ses musiques favorites
|
||||||
|
- **FR-REEL-8** : L'utilisateur peut supprimer un favori
|
||||||
|
- **FR-REEL-9** : Le système affiche visuellement si un track est en favori (icône cœur)
|
||||||
|
- **FR-REEL-10** : Les favoris sont persistés par utilisateur (isolation des données)
|
||||||
|
|
||||||
|
#### Non-Functional Requirements
|
||||||
|
|
||||||
|
**Performance :**
|
||||||
|
- **NFR-REEL-1** : La stabilisation 2-pass ne doit pas dépasser 3x la durée de la vidéo
|
||||||
|
- **NFR-REEL-2** : Les favoris doivent se charger en < 500ms
|
||||||
|
|
||||||
|
**Qualité :**
|
||||||
|
- **NFR-REEL-3** : Les vidéos stabilisées doivent avoir un score de stabilité > 80% (métrique VidStab)
|
||||||
|
- **NFR-REEL-4** : Le texte karaoke doit être synchronisé avec une précision de ±100ms par mot
|
||||||
|
|
||||||
|
**Sécurité :**
|
||||||
|
- **NFR-REEL-5** : Un utilisateur ne peut accéder qu'à ses propres favoris
|
||||||
|
- **NFR-REEL-6** : Les URLs de téléchargement Jamendo ne doivent jamais être exposées côté client
|
||||||
|
|
||||||
|
#### Impact Utilisateur
|
||||||
|
|
||||||
|
**Avant** :
|
||||||
|
- Vidéos tremblantes
|
||||||
|
- Musique limitée (sons FreeSound, pas de vraie musique)
|
||||||
|
- Pas de favoris → recherche répétitive
|
||||||
|
- Texte basique statique
|
||||||
|
|
||||||
|
**Après** :
|
||||||
|
- Vidéos stables et professionnelles
|
||||||
|
- Catalogue musical riche (Jamendo) avec genres
|
||||||
|
- Système de favoris pour retrouver facilement les musiques aimées
|
||||||
|
- Texte dynamique style TikTok avec effet karaoke
|
||||||
|
|
||||||
|
#### Technical Debt & Future Work
|
||||||
|
|
||||||
|
**Optimisations futures** :
|
||||||
|
1. **Cache Jamendo** : Mettre en cache les résultats de recherche (30 min TTL)
|
||||||
|
2. **UI Favoris** : Implémenter l'interface client (bouton cœur, onglet favoris)
|
||||||
|
3. **Playlist** : Permettre de créer des playlists de favoris
|
||||||
|
4. **Recommandations** : Suggérer des musiques basées sur l'historique
|
||||||
|
|
||||||
|
**Monitoring** :
|
||||||
|
- Logs FFmpeg : Durée de stabilisation, taux de succès
|
||||||
|
- API Jamendo : Rate limits, temps de réponse
|
||||||
|
- Favoris : Nombre moyen par utilisateur, tracks les plus favoris
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Non-Functional Requirements
|
## Non-Functional Requirements
|
||||||
|
|
||||||
### Security & Data Protection
|
### Security & Data Protection
|
||||||
|
|||||||
+54
-44
@@ -276,24 +276,31 @@ async def generate_tts_with_subs(
|
|||||||
def generate_simple_ass(
|
def generate_simple_ass(
|
||||||
text: str, ass_path: Path, font_size: int = 65, total_duration: float = None
|
text: str, ass_path: Path, font_size: int = 65, total_duration: float = None
|
||||||
):
|
):
|
||||||
"""Generate a high-quality ASS subtitle file with embedded styling."""
|
"""Generate TikTok-style ASS subtitle file with karaoke highlight effect.
|
||||||
# Split text into chunks
|
|
||||||
words = text.split()
|
Each word fills from white to yellow as it is spoken, with thick outline
|
||||||
chunks = []
|
for readability on any background.
|
||||||
|
"""
|
||||||
|
# Split text into word lists (3 words max per chunk for TikTok readability)
|
||||||
|
all_words = text.split()
|
||||||
|
chunks = [] # Each chunk is a list of words
|
||||||
current_chunk = []
|
current_chunk = []
|
||||||
|
|
||||||
for word in words:
|
for word in all_words:
|
||||||
current_chunk.append(word)
|
current_chunk.append(word)
|
||||||
if len(current_chunk) >= 5 or word.endswith((".", "!", "?", ":")):
|
if len(current_chunk) >= 3 or word.endswith((".", "!", "?", ":")):
|
||||||
chunks.append(" ".join(current_chunk))
|
chunks.append(current_chunk)
|
||||||
current_chunk = []
|
current_chunk = []
|
||||||
|
|
||||||
if current_chunk:
|
if current_chunk:
|
||||||
chunks.append(" ".join(current_chunk))
|
chunks.append(current_chunk)
|
||||||
|
|
||||||
# ASS Header with explicit resolution and style
|
# ASS Header — TikTok Karaoke Style
|
||||||
# Alignment 5 = Middle Center
|
# PrimaryColour = Yellow (highlighted/spoken) &H0000FFFF (ASS BGR: 00,FF,FF = RGB FF,FF,00)
|
||||||
# Font: Priority to Noto Color Emoji for emojis support
|
# SecondaryColour = White (before highlight) &H00FFFFFF
|
||||||
|
# OutlineColour = Black &H00000000
|
||||||
|
# BackColour = Semi-transparent black &H80000000
|
||||||
|
# Bold=-1, Outline=3, Shadow=1, Alignment=5 (center middle)
|
||||||
header = f"""[Script Info]
|
header = f"""[Script Info]
|
||||||
ScriptType: v4.00+
|
ScriptType: v4.00+
|
||||||
PlayResX: 1080
|
PlayResX: 1080
|
||||||
@@ -302,7 +309,7 @@ ScaledBorderAndShadow: yes
|
|||||||
|
|
||||||
[V4+ Styles]
|
[V4+ Styles]
|
||||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
||||||
Style: Default,Sans,{font_size},&H00FFFFFF,&H000000FF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,0,5,50,50,0,1
|
Style: Default,Sans,{font_size},&H0000FFFF,&H00FFFFFF,&H00000000,&H80000000,-1,0,0,0,100,100,0,0,1,3,1,5,50,50,0,1
|
||||||
|
|
||||||
[Events]
|
[Events]
|
||||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
||||||
@@ -311,47 +318,47 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
|||||||
events = ""
|
events = ""
|
||||||
current_time = 0.0
|
current_time = 0.0
|
||||||
|
|
||||||
# Calculate timing strategy
|
# Calculate total characters across all chunks for proportional timing
|
||||||
|
total_chars = sum(len(w) for chunk in chunks for w in chunk)
|
||||||
|
if total_chars == 0:
|
||||||
|
total_chars = 1
|
||||||
|
|
||||||
if total_duration:
|
if total_duration:
|
||||||
# Dynamic timing based on characters count (more accurate than word count)
|
|
||||||
total_chars = sum(len(chunk) for chunk in chunks)
|
|
||||||
# Avoid division by zero
|
|
||||||
if total_chars == 0:
|
|
||||||
total_chars = 1
|
|
||||||
time_per_char = total_duration / total_chars
|
time_per_char = total_duration / total_chars
|
||||||
else:
|
else:
|
||||||
# Fallback static timing using word count
|
# Fallback: ~80ms per character
|
||||||
time_per_char = None
|
time_per_char = 0.08
|
||||||
|
|
||||||
for i, chunk in enumerate(chunks):
|
for chunk_words in chunks:
|
||||||
if time_per_char:
|
# Calculate chunk duration from its characters
|
||||||
# Duration proportional to character length including spaces
|
chunk_chars = sum(len(w) for w in chunk_words)
|
||||||
# Ensure minimum readability of 0.5s per chunk
|
chunk_duration = chunk_chars * time_per_char
|
||||||
chunk_len = len(chunk)
|
|
||||||
duration = chunk_len * time_per_char
|
|
||||||
# Optional: Enforce min duration, but we must respect total_duration
|
|
||||||
# So purely proportional is safer for sync
|
|
||||||
else:
|
|
||||||
# Fallback: 0.4s per word
|
|
||||||
word_count = len(chunk.split())
|
|
||||||
duration = word_count * 0.4
|
|
||||||
|
|
||||||
start_time = format_ass_time(current_time)
|
start_time = format_ass_time(current_time)
|
||||||
end_time = format_ass_time(current_time + duration)
|
end_time = format_ass_time(current_time + chunk_duration)
|
||||||
|
|
||||||
# Escape special characters if any
|
# Build karaoke text with \kf tags per word
|
||||||
sanitized_chunk = chunk.replace("{", "(").replace("}", ")")
|
# \kf = smooth fill from SecondaryColour (white) to PrimaryColour (yellow)
|
||||||
|
karaoke_parts = []
|
||||||
|
for word in chunk_words:
|
||||||
|
# Word duration in centiseconds, proportional to character length
|
||||||
|
word_dur_cs = int((len(word) / chunk_chars) * chunk_duration * 100)
|
||||||
|
word_dur_cs = max(word_dur_cs, 10) # Min 0.1s per word
|
||||||
|
sanitized = word.replace("{", "(").replace("}", ")")
|
||||||
|
karaoke_parts.append(f"{{\\kf{word_dur_cs}}}{sanitized}")
|
||||||
|
|
||||||
|
karaoke_text = " ".join(karaoke_parts)
|
||||||
events += (
|
events += (
|
||||||
f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{sanitized_chunk}\n"
|
f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{karaoke_text}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
current_time += duration
|
current_time += chunk_duration
|
||||||
|
|
||||||
with open(ass_path, "w", encoding="utf-8") as f:
|
with open(ass_path, "w", encoding="utf-8") as f:
|
||||||
f.write(header + events)
|
f.write(header + events)
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"📄 Generated ASS file: {ass_path.stat().st_size} bytes with {len(chunks)} chunks"
|
f"📄 Generated ASS file (karaoke): {ass_path.stat().st_size} bytes, {len(chunks)} chunks, {len(all_words)} words"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -544,10 +551,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
"✅ Stabilization Pass 1 complete. Integrating Pass 2 into main filter chain."
|
"✅ Stabilization Pass 1 complete. Integrating Pass 2 into main filter chain."
|
||||||
)
|
)
|
||||||
# We will add vidstabtransform to the video chain below
|
# We will add vidstabtransform to the video chain below
|
||||||
# optzoom=2 -> Adaptive zoom to avoid black borders
|
|
||||||
# zoom=0 -> Auto zoom
|
|
||||||
# smoothing=30 -> Heavy smoothing (default is 10) for handheld feel
|
# smoothing=30 -> Heavy smoothing (default is 10) for handheld feel
|
||||||
vidstab_filter = f"vidstabtransform=input={transforms_path}:smoothing=30:optzoom=2:zoom=0:interpol=bicubic,unsharp=5:5:0.8:3:3:0.4,"
|
# relative=1 -> Transforms relative to previous frame
|
||||||
|
# zoom=5 -> Fixed 5% zoom to avoid black borders from stabilization
|
||||||
|
vidstab_filter = f"vidstabtransform=input={transforms_path}:smoothing=30:relative=1:zoom=5,unsharp=5:5:1.0:5:5:0.0,"
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f"⚠️ Stabilization Pass 1 failed: {detect_proc.stderr.decode()[:500]}"
|
f"⚠️ Stabilization Pass 1 failed: {detect_proc.stderr.decode()[:500]}"
|
||||||
@@ -613,7 +620,8 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
# Note: vidstabtransform output is same res as input
|
# Note: vidstabtransform output is same res as input
|
||||||
|
|
||||||
# Scale & Crop to Fill 1080x1920 (Vertical Reel)
|
# Scale & Crop to Fill 1080x1920 (Vertical Reel)
|
||||||
v_chain += "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920"
|
# Then enhance brightness/contrast slightly for Facebook optimization
|
||||||
|
v_chain += "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,eq=brightness=0.05:contrast=1.1"
|
||||||
|
|
||||||
# 2. Text Overlay
|
# 2. Text Overlay
|
||||||
if request.text and request.draw_text:
|
if request.text and request.draw_text:
|
||||||
@@ -712,8 +720,10 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
"30",
|
"30",
|
||||||
"-preset",
|
"-preset",
|
||||||
"slow",
|
"slow",
|
||||||
|
"-level",
|
||||||
|
"4.1",
|
||||||
"-crf",
|
"-crf",
|
||||||
"17",
|
"18",
|
||||||
"-b:v",
|
"-b:v",
|
||||||
"10M",
|
"10M",
|
||||||
"-maxrate",
|
"-maxrate",
|
||||||
@@ -723,7 +733,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
"-c:a",
|
"-c:a",
|
||||||
"aac",
|
"aac",
|
||||||
"-b:a",
|
"-b:a",
|
||||||
"192k",
|
"128k",
|
||||||
"-pix_fmt",
|
"-pix_fmt",
|
||||||
"yuv420p",
|
"yuv420p",
|
||||||
"-movflags",
|
"-movflags",
|
||||||
|
|||||||
+3
-4
@@ -183,10 +183,9 @@ app.use((req, res, next) => {
|
|||||||
// Initialiser l'utilisateur admin par défaut avant de démarrer le serveur
|
// Initialiser l'utilisateur admin par défaut avant de démarrer le serveur
|
||||||
await ensureAdminUserExists();
|
await ensureAdminUserExists();
|
||||||
|
|
||||||
// Configurer le service FreeSound pour les Reels
|
// Configurer le service Jamendo pour les Reels
|
||||||
const freeSoundClientId = process.env.FREESOUND_CLIENT_ID || 'uTn5gEjjJRzPvyGP1eAN';
|
const jamendoClientId = process.env.JAMENDO_CLIENT_ID || '5b6bba49';
|
||||||
const freeSoundClientSecret = process.env.FREESOUND_CLIENT_SECRET || 'QGUgFJFcDwXPfVOflMTXBBJ8C5l4UuKf4t9KAYBX';
|
jamendoService.configure(jamendoClientId);
|
||||||
freeSoundService.configure(freeSoundClientId, freeSoundClientSecret);
|
|
||||||
|
|
||||||
// Configurer le service FFmpeg pour le traitement vidéo (si configuré)
|
// Configurer le service FFmpeg pour le traitement vidéo (si configuré)
|
||||||
if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) {
|
if (process.env.FFMPEG_API_URL && process.env.FFMPEG_API_KEY) {
|
||||||
|
|||||||
@@ -63,6 +63,24 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "last_token_check" timestamp;
|
ADD COLUMN IF NOT EXISTS "last_token_check" timestamp;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// music_favorites
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS "music_favorites" (
|
||||||
|
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
|
||||||
|
"track_id" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"artist" text NOT NULL,
|
||||||
|
"album_name" text,
|
||||||
|
"duration" integer NOT NULL,
|
||||||
|
"preview_url" text NOT NULL,
|
||||||
|
"download_url" text NOT NULL,
|
||||||
|
"image_url" text,
|
||||||
|
"license" text,
|
||||||
|
"created_at" timestamp DEFAULT now()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
console.log("[Migration] Safe migration completed.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Migration] Error during migration:", error);
|
console.error("[Migration] Error during migration:", error);
|
||||||
|
|||||||
+118
-8
@@ -5,7 +5,7 @@
|
|||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import type { User, Media, SocialPage } from '@shared/schema';
|
import type { User, Media, SocialPage } from '@shared/schema';
|
||||||
import { storage } from '../storage';
|
import { storage } from '../storage';
|
||||||
import { freeSoundService, type MusicTrack } from '../services/freesound';
|
import { jamendoService, type MusicTrack } from '../services/jamendo';
|
||||||
import { ffmpegService } from '../services/ffmpeg';
|
import { ffmpegService } from '../services/ffmpeg';
|
||||||
import { facebookService } from '../services/facebook';
|
import { facebookService } from '../services/facebook';
|
||||||
import { cloudinaryService } from '../services/cloudinary';
|
import { cloudinaryService } from '../services/cloudinary';
|
||||||
@@ -32,7 +32,7 @@ reelsRouter.get('/music/search', async (req: Request, res: Response) => {
|
|||||||
search,
|
search,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const tracks = await freeSoundService.searchMusicByDuration({
|
const tracks = await jamendoService.searchMusicByDuration({
|
||||||
minDuration: parseInt(minDuration as string),
|
minDuration: parseInt(minDuration as string),
|
||||||
maxDuration: parseInt(maxDuration as string),
|
maxDuration: parseInt(maxDuration as string),
|
||||||
genre: genre as string | undefined,
|
genre: genre as string | undefined,
|
||||||
@@ -70,7 +70,7 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => {
|
|||||||
search,
|
search,
|
||||||
} = req.query;
|
} = req.query;
|
||||||
|
|
||||||
const tracks = await freeSoundService.searchMusicByDuration({
|
const tracks = await jamendoService.searchMusicByDuration({
|
||||||
minDuration: parseInt(minDuration as string),
|
minDuration: parseInt(minDuration as string),
|
||||||
maxDuration: parseInt(maxDuration as string),
|
maxDuration: parseInt(maxDuration as string),
|
||||||
genre: genre as string | undefined,
|
genre: genre as string | undefined,
|
||||||
@@ -100,7 +100,7 @@ reelsRouter.get('/music/more', async (req: Request, res: Response) => {
|
|||||||
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
|
reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit as string) || 10;
|
const limit = parseInt(req.query.limit as string) || 10;
|
||||||
const tracks = await freeSoundService.getPopularTracks(limit);
|
const tracks = await jamendoService.getPopularTracks(limit);
|
||||||
res.json({ tracks });
|
res.json({ tracks });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error fetching popular music:', error);
|
console.error('❌ Error fetching popular music:', error);
|
||||||
@@ -108,6 +108,116 @@ reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ROUTES FAVORIS MUSIQUE
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtenir les favoris de l'utilisateur
|
||||||
|
* GET /api/music/favorites
|
||||||
|
*/
|
||||||
|
reelsRouter.get('/music/favorites', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const favorites = await storage.getMusicFavorites(user.id);
|
||||||
|
|
||||||
|
// Convertir en format MusicTrack pour le client
|
||||||
|
const tracks: MusicTrack[] = favorites.map(f => ({
|
||||||
|
id: f.trackId,
|
||||||
|
title: f.title,
|
||||||
|
artist: f.artist,
|
||||||
|
albumName: f.albumName || '',
|
||||||
|
duration: f.duration,
|
||||||
|
previewUrl: f.previewUrl,
|
||||||
|
downloadUrl: f.downloadUrl,
|
||||||
|
imageUrl: f.imageUrl || '',
|
||||||
|
license: f.license || '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ tracks, isFavorites: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error fetching music favorites:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de la récupération des favoris' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifier si un track est en favori
|
||||||
|
* GET /api/music/favorites/check/:trackId
|
||||||
|
*/
|
||||||
|
reelsRouter.get('/music/favorites/check/:trackId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { trackId } = req.params;
|
||||||
|
|
||||||
|
const isFavorite = await storage.isMusicFavorite(user.id, trackId);
|
||||||
|
res.json({ isFavorite });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error checking music favorite:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de la vérification du favori' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajouter un favori
|
||||||
|
* POST /api/music/favorites
|
||||||
|
*/
|
||||||
|
reelsRouter.post('/music/favorites', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { trackId, title, artist, albumName, duration, previewUrl, downloadUrl, imageUrl, license } = req.body;
|
||||||
|
|
||||||
|
if (!trackId || !title) {
|
||||||
|
return res.status(400).json({ error: 'trackId et title requis' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier si déjà en favori
|
||||||
|
const alreadyFavorite = await storage.isMusicFavorite(user.id, trackId);
|
||||||
|
if (alreadyFavorite) {
|
||||||
|
return res.json({ success: true, message: 'Déjà en favori' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.addMusicFavorite({
|
||||||
|
userId: user.id,
|
||||||
|
trackId,
|
||||||
|
title,
|
||||||
|
artist: artist || '',
|
||||||
|
albumName: albumName || null,
|
||||||
|
duration: duration || 0,
|
||||||
|
previewUrl: previewUrl || '',
|
||||||
|
downloadUrl: downloadUrl || '',
|
||||||
|
imageUrl: imageUrl || null,
|
||||||
|
license: license || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error adding music favorite:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de l\'ajout du favori' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprimer un favori
|
||||||
|
* DELETE /api/music/favorites/:trackId
|
||||||
|
*/
|
||||||
|
reelsRouter.delete('/music/favorites/:trackId', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = req.user as User;
|
||||||
|
const { trackId } = req.params;
|
||||||
|
|
||||||
|
await storage.removeMusicFavorite(user.id, trackId);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error removing music favorite:', error);
|
||||||
|
res.status(500).json({ error: 'Erreur lors de la suppression du favori' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// ROUTE DÉTAILS TRACK (après les routes /music/favorites et /music/popular)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détails d'un track
|
* Détails d'un track
|
||||||
* GET /api/music/:trackId
|
* GET /api/music/:trackId
|
||||||
@@ -115,7 +225,7 @@ reelsRouter.get('/music/popular', async (req: Request, res: Response) => {
|
|||||||
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
|
reelsRouter.get('/music/:trackId', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { trackId } = req.params;
|
const { trackId } = req.params;
|
||||||
const track = await freeSoundService.getMusicDetails(trackId);
|
const track = await jamendoService.getMusicDetails(trackId);
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return res.status(404).json({ error: 'Musique non trouvée' });
|
return res.status(404).json({ error: 'Musique non trouvée' });
|
||||||
@@ -204,7 +314,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
// Récupérer l'URL de la musique si trackId fourni
|
// Récupérer l'URL de la musique si trackId fourni
|
||||||
let finalMusicUrl = musicUrl;
|
let finalMusicUrl = musicUrl;
|
||||||
if (musicTrackId && !musicUrl) {
|
if (musicTrackId && !musicUrl) {
|
||||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
const track = await jamendoService.getMusicDetails(musicTrackId);
|
||||||
if (track) {
|
if (track) {
|
||||||
finalMusicUrl = track.downloadUrl;
|
finalMusicUrl = track.downloadUrl;
|
||||||
}
|
}
|
||||||
@@ -309,7 +419,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
|||||||
// Récupérer l'URL de la musique si trackId fourni
|
// Récupérer l'URL de la musique si trackId fourni
|
||||||
let finalMusicUrl = musicUrl;
|
let finalMusicUrl = musicUrl;
|
||||||
if (musicTrackId && !musicUrl) {
|
if (musicTrackId && !musicUrl) {
|
||||||
const track = await freeSoundService.getMusicDetails(musicTrackId);
|
const track = await jamendoService.getMusicDetails(musicTrackId);
|
||||||
if (track) {
|
if (track) {
|
||||||
finalMusicUrl = track.downloadUrl;
|
finalMusicUrl = track.downloadUrl;
|
||||||
}
|
}
|
||||||
@@ -460,7 +570,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
reelsRouter.get('/reels/config', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const jamendoConfigured = await freeSoundService.testConnection().catch(() => false);
|
const jamendoConfigured = await jamendoService.testConnection().catch(() => false);
|
||||||
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
const ffmpegConfigured = await ffmpegService.healthCheck().catch(() => false);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
type InsertOpenrouterConfig,
|
type InsertOpenrouterConfig,
|
||||||
type UserPagePermission,
|
type UserPagePermission,
|
||||||
type InsertUserPagePermission,
|
type InsertUserPagePermission,
|
||||||
|
musicFavorites,
|
||||||
|
type MusicFavorite,
|
||||||
|
type InsertMusicFavorite,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
||||||
@@ -98,6 +101,12 @@ export interface IStorage {
|
|||||||
deletePagePermission(userId: string, pageId: string): Promise<void>;
|
deletePagePermission(userId: string, pageId: string): Promise<void>;
|
||||||
deleteAllUserPagePermissions(userId: string): Promise<void>;
|
deleteAllUserPagePermissions(userId: string): Promise<void>;
|
||||||
getUserAccessiblePages(userId: string): Promise<SocialPage[]>;
|
getUserAccessiblePages(userId: string): Promise<SocialPage[]>;
|
||||||
|
|
||||||
|
// Music Favorites
|
||||||
|
getMusicFavorites(userId: string): Promise<MusicFavorite[]>;
|
||||||
|
addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite>;
|
||||||
|
removeMusicFavorite(userId: string, trackId: string): Promise<void>;
|
||||||
|
isMusicFavorite(userId: string, trackId: string): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@@ -449,6 +458,37 @@ export class DatabaseStorage implements IStorage {
|
|||||||
|
|
||||||
return permissions.map(p => p.social_pages);
|
return permissions.map(p => p.social_pages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Music Favorites
|
||||||
|
async getMusicFavorites(userId: string): Promise<MusicFavorite[]> {
|
||||||
|
return db.select().from(musicFavorites)
|
||||||
|
.where(eq(musicFavorites.userId, userId))
|
||||||
|
.orderBy(desc(musicFavorites.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMusicFavorite(favorite: InsertMusicFavorite): Promise<MusicFavorite> {
|
||||||
|
const [created] = await db.insert(musicFavorites).values(favorite).returning();
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMusicFavorite(userId: string, trackId: string): Promise<void> {
|
||||||
|
await db.delete(musicFavorites).where(
|
||||||
|
and(
|
||||||
|
eq(musicFavorites.userId, userId),
|
||||||
|
eq(musicFavorites.trackId, trackId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isMusicFavorite(userId: string, trackId: string): Promise<boolean> {
|
||||||
|
const [fav] = await db.select().from(musicFavorites).where(
|
||||||
|
and(
|
||||||
|
eq(musicFavorites.userId, userId),
|
||||||
|
eq(musicFavorites.trackId, trackId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return !!fav;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new DatabaseStorage();
|
export const storage = new DatabaseStorage();
|
||||||
@@ -185,6 +185,21 @@ export const aiGenerations = pgTable("ai_generations", {
|
|||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const musicFavorites = pgTable("music_favorites", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
trackId: text("track_id").notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
artist: text("artist").notNull(),
|
||||||
|
albumName: text("album_name"),
|
||||||
|
duration: integer("duration").notNull(),
|
||||||
|
previewUrl: text("preview_url").notNull(),
|
||||||
|
downloadUrl: text("download_url").notNull(),
|
||||||
|
imageUrl: text("image_url"),
|
||||||
|
license: text("license"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const userPagePermissions = pgTable("user_page_permissions", {
|
export const userPagePermissions = pgTable("user_page_permissions", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -292,6 +307,13 @@ export const aiGenerationsRelations = relations(aiGenerations, ({ one }) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const musicFavoritesRelations = relations(musicFavorites, ({ one }) => ({
|
||||||
|
user: one(users, {
|
||||||
|
fields: [musicFavorites.userId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
export const userPagePermissionsRelations = relations(userPagePermissions, ({ one }) => ({
|
export const userPagePermissionsRelations = relations(userPagePermissions, ({ one }) => ({
|
||||||
user: one(users, {
|
user: one(users, {
|
||||||
fields: [userPagePermissions.userId],
|
fields: [userPagePermissions.userId],
|
||||||
@@ -365,6 +387,11 @@ export const insertPostMediaSchema = createInsertSchema(postMedia).omit({
|
|||||||
id: true,
|
id: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertMusicFavoriteSchema = createInsertSchema(musicFavorites).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||||
@@ -395,4 +422,7 @@ export type UpdateOpenrouterConfig = z.infer<typeof updateOpenrouterConfigSchema
|
|||||||
export type PostMedia = typeof postMedia.$inferSelect;
|
export type PostMedia = typeof postMedia.$inferSelect;
|
||||||
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
|
export type InsertPostMedia = z.infer<typeof insertPostMediaSchema>;
|
||||||
|
|
||||||
|
export type MusicFavorite = typeof musicFavorites.$inferSelect;
|
||||||
|
export type InsertMusicFavorite = z.infer<typeof insertMusicFavoriteSchema>;
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user