mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0b4d3f20 | ||
|
|
7806f8bdc8 | ||
|
|
da9a74dbe6 | ||
|
|
e89dd97881 | ||
|
|
188706f4dc | ||
|
|
2c732c11b9 | ||
|
|
338546662f | ||
|
|
90a9b6be4d | ||
|
|
90f936cf4f | ||
|
|
5f271e0831 | ||
|
|
fdace9d8d5 | ||
|
|
e312733d43 | ||
|
|
44daa88a49 | ||
|
|
4b8fbe6d8e | ||
|
|
7c38ec459f | ||
|
|
d4c544e467 | ||
|
|
44899055b2 | ||
|
|
f3113a0f5a |
No files matched your search
@@ -120,11 +120,33 @@ GEMINI_API_KEY=
|
||||
# sur celui-ci si le modèle choisi n'existe pas).
|
||||
# GEMINI_TTS_MODEL=
|
||||
#
|
||||
# Stabilité de la voix Gemini : température basse et graine fixe donnent la
|
||||
# même intonation d'une génération à l'autre ; chaque lecture est réécoutée et
|
||||
# régénérée (jusqu'à GEMINI_TTS_ATTEMPTS fois) si des mots manquent.
|
||||
# GEMINI_TTS_TEMPERATURE=0.3
|
||||
# GEMINI_TTS_SEED=20260928
|
||||
# GEMINI_TTS_ATTEMPTS=3
|
||||
#
|
||||
# Modèle Whisper utilisé pour caler les sous-titres mot à mot sur la voix
|
||||
# (tiny, base, small). Choisi à la construction de l'image : changer la valeur
|
||||
# demande de reconstruire ffmpeg-api.
|
||||
# WHISPER_MODEL=base
|
||||
#
|
||||
# Voix Qwen3-TTS : gratuite, française, sans quota. Le plus simple : l'installer
|
||||
# sur un serveur avec carte graphique NVIDIA (qwen-tts/docker-compose.gpu.yml,
|
||||
# ex. Unraid) puis régler l'adresse dans Paramètres → Qwen TTS.
|
||||
# Sinon, service CPU sur ce serveur (lent : 5 à 7 fois la durée de la voix) :
|
||||
# docker compose --profile qwen-local up -d avec QWEN_TTS_URL=http://qwen-tts:8001
|
||||
# QWEN_TTS_URL=
|
||||
# Modèle choisi à la construction de l'image :
|
||||
# - Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice (défaut) : ton et émotion pilotés ;
|
||||
# - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif.
|
||||
# QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice
|
||||
# Clonage des voix déposées dans qwen-tts/voices (vide pour désactiver).
|
||||
# QWEN_TTS_CLONE_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-Base
|
||||
# QWEN_TTS_THREADS=0
|
||||
# QWEN_TTS_MEMORY=10g
|
||||
#
|
||||
# Moteur de rendu des Reels vidéo :
|
||||
# - remotion (défaut) : sous-titres animés, logo et effet de fin identiques à
|
||||
# l'aperçu affiché dans l'application ; ~30 à 60 s pour un Reel de 30 s ;
|
||||
|
||||
@@ -42,13 +42,12 @@ La clé externe peut être configurée par un administrateur depuis l'interface
|
||||
5. [Gestion de l'Audio (Musique de fond)](#5-gestion-de-laudio-musique-de-fond)
|
||||
6. [Publications & Planification](#6-publications--planification)
|
||||
7. [Reels & Génération Vidéo (FFmpeg)](#7-reels--génération-vidéo-ffmpeg)
|
||||
8. [Génération Vidéo Avancée (Remotion)](#8-génération-vidéo-avancée-remotion)
|
||||
9. [IA & Modèles (OpenRouter)](#9-ia--modèles-openrouter)
|
||||
10. [Statistiques & Paramètres Système](#10-statistiques--paramètres-système)
|
||||
11. [Console SQL (Admin uniquement)](#11-console-sql-admin-uniquement)
|
||||
12. [API d'Analyse (Analytics)](#12-api-danalyse-analytics)
|
||||
13. [API Développeur Externe (v1)](#13-api-développeur-externe-v1)
|
||||
14. [Microservice Interne FFmpeg (Python)](#14-microservice-interne-ffmpeg-python)
|
||||
8. [IA & Modèles (OpenRouter)](#8-ia--modèles-openrouter)
|
||||
9. [Statistiques & Paramètres Système](#9-statistiques--paramètres-système)
|
||||
10. [Console SQL (Admin uniquement)](#10-console-sql-admin-uniquement)
|
||||
11. [API d'Analyse (Analytics)](#11-api-danalyse-analytics)
|
||||
12. [API Développeur Externe (v1)](#12-api-développeur-externe-v1)
|
||||
13. [Microservice Interne FFmpeg (Python)](#13-microservice-interne-ffmpeg-python)
|
||||
|
||||
---
|
||||
|
||||
@@ -727,72 +726,7 @@ GET /api/reels/status/:postId
|
||||
|
||||
---
|
||||
|
||||
## 8. Génération Vidéo Avancée (Remotion)
|
||||
|
||||
Remotion permet d'assembler des diaporamas complexes et des vidéos à partir d'images à l'aide d'un navigateur Chromium sans tête (headless).
|
||||
|
||||
### Lancer un rendu de vidéo diaporama Remotion
|
||||
```http
|
||||
POST /api/remotion/render
|
||||
```
|
||||
**Body (Multipart Form) :**
|
||||
- `existingImageUrls` : (Facultatif) Tableau d'URL d'images déjà enregistrées en base (converties directement en base64 pour contourner les limitations de requêtes réseau Chromium sous Docker).
|
||||
- `images` : Fichiers images téléversés en direct.
|
||||
- `music` : Fichier de musique téléversé.
|
||||
- `musicTrackUrl` : (Facultatif) URL de musique existante.
|
||||
- `overlayText` : Texte de sous-titre pour la synthèse vocale intégrée.
|
||||
- `musicVolume` : Volume sonore (ex: `0.3`).
|
||||
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"jobId": "remotion_job_171691",
|
||||
"message": "Rendu démarré en arrière-plan"
|
||||
}
|
||||
```
|
||||
|
||||
### Vérifier le statut du rendu Remotion
|
||||
```http
|
||||
GET /api/remotion/render/status/:jobId
|
||||
```
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"status": "done",
|
||||
"url": "/uploads/temp/remotion-171691.mp4",
|
||||
"thumbnailUrl": "/uploads/temp/remotion-171691-thumb.jpg",
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Publier le diaporama généré comme Reel
|
||||
Téléverse le fichier MP4 finalisé vers le stockage puis publie la vidéo sur les pages cibles.
|
||||
```http
|
||||
POST /api/remotion/publish
|
||||
```
|
||||
**Body (JSON) :**
|
||||
```json
|
||||
{
|
||||
"videoUrl": "/uploads/temp/remotion-171691.mp4",
|
||||
"pageIds": ["page_999"],
|
||||
"description": "Mon diaporama animé créé avec Remotion !",
|
||||
"scheduledFor": "2026-06-01T12:00:00Z"
|
||||
}
|
||||
```
|
||||
**Réponse (200 OK) :**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"postId": "pst_remotion_999",
|
||||
"results": [
|
||||
{ "pageId": "page_999", "success": true, "reelId": "scheduled" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. IA & Modèles (OpenRouter)
|
||||
## 8. IA & Modèles (OpenRouter)
|
||||
|
||||
### Liste des modèles d'écriture IA disponibles
|
||||
```http
|
||||
@@ -839,7 +773,7 @@ GET /api/ai/generations
|
||||
|
||||
---
|
||||
|
||||
## 10. Statistiques & Paramètres Système
|
||||
## 9. Statistiques & Paramètres Système
|
||||
|
||||
### Récupérer les statistiques du tableau de bord
|
||||
Calcule les publications planifiées, le nombre de pages connectées, le total de textes générés, l'espace de stockage des médias et les tendances d'évolution.
|
||||
@@ -888,7 +822,7 @@ GET /api/stats
|
||||
|
||||
---
|
||||
|
||||
## 11. Console SQL (Admin uniquement)
|
||||
## 10. Console SQL (Admin uniquement)
|
||||
|
||||
Ces outils exclusifs aux administrateurs permettent de déboguer ou de faire des opérations de maintenance directement sur la base de données PostgreSQL.
|
||||
|
||||
@@ -931,7 +865,7 @@ GET /api/sql/tables
|
||||
|
||||
---
|
||||
|
||||
## 12. API d'Analyse (Analytics)
|
||||
## 11. API d'Analyse (Analytics)
|
||||
|
||||
Récupération et mise à jour des statistiques de performance des publications et des pages (portée, clics, croissance d'abonnés).
|
||||
|
||||
@@ -1006,13 +940,47 @@ POST /api/analytics/tokens/check
|
||||
|
||||
---
|
||||
|
||||
## 13. API Développeur Externe (v1)
|
||||
## 12. API Développeur Externe (v1)
|
||||
|
||||
Ces routes sont optimisées pour les appels de scripts d'intégration tierce.
|
||||
Toutes les requêtes doivent contenir le header `X-API-Key`.
|
||||
|
||||
### Publier un message avec téléchargement d'image
|
||||
Crée une publication en téléchargeant automatiquement l'image depuis une URL publique fournie (ex: depuis votre ERP ou flux e-commerce), et programme sa publication.
|
||||
Les requêtes JSON sous `/api/v1` peuvent peser jusqu'à 15 Mo (contre 100 Ko ailleurs), pour transporter une image en base64.
|
||||
|
||||
### Envoyer une image dans la médiathèque
|
||||
Envoie une image directement, sans hébergeur public intermédiaire. L'`id` renvoyé se passe en `mediaId` à `/publish` ou à `PATCH /posts/:id`.
|
||||
Formats acceptés : JPEG, PNG, WebP, GIF (reconnus par leur contenu, pas par l'extension) — 10 Mo maximum.
|
||||
```http
|
||||
POST /api/v1/media
|
||||
```
|
||||
**Body (multipart/form-data) :** champ `file` contenant l'image.
|
||||
```bash
|
||||
curl -X POST https://socialflow.example/api/v1/media \
|
||||
-H "X-API-Key: $SOCIALFLOW_API_KEY" \
|
||||
-F "file=@galette-chaise.jpg"
|
||||
```
|
||||
**Ou body (JSON) :** l'image en base64, brute ou en data URL.
|
||||
```json
|
||||
{ "imageData": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }
|
||||
```
|
||||
**Réponse (201 Created) :**
|
||||
```json
|
||||
{
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg",
|
||||
"type": "image",
|
||||
"fileName": "external-1791141548234.jpg",
|
||||
"fileSize": 2483120
|
||||
}
|
||||
```
|
||||
**Erreurs :** `400` (aucune image, format non reconnu), `401` (clé absente ou invalide), `413` (image de plus de 10 Mo).
|
||||
|
||||
### Publier un message avec image
|
||||
Crée une publication et programme sa publication. L'image peut être fournie de quatre façons (une seule par requête) :
|
||||
- `mediaId` : image déjà envoyée via `POST /api/v1/media` ;
|
||||
- `imageData` : image en base64 (ou data URL) ;
|
||||
- `imageUrl` : URL publique que Socialflow télécharge lui-même ;
|
||||
- en multipart/form-data, un fichier dans le champ `image` (les autres champs en texte, `pageIds` en tableau JSON ou séparés par des virgules).
|
||||
```http
|
||||
POST /api/v1/publish
|
||||
```
|
||||
@@ -1020,7 +988,7 @@ POST /api/v1/publish
|
||||
```json
|
||||
{
|
||||
"content": "Découvrez cet article exceptionnel !",
|
||||
"imageUrl": "https://mon-site.com/images/produit.jpg",
|
||||
"mediaId": "med_ext_123",
|
||||
"pageIds": ["page_999"],
|
||||
"scheduledAt": "2026-06-02T08:00:00.000Z",
|
||||
"postType": "feed"
|
||||
@@ -1040,8 +1008,8 @@ POST /api/v1/publish
|
||||
{ "pageId": "page_999", "pageName": "Ma Page", "scheduledPostId": "sch_ext_001" }
|
||||
],
|
||||
"media": {
|
||||
"id": "med_ext_999",
|
||||
"url": "/uploads/media/external-17169.jpg"
|
||||
"id": "med_ext_123",
|
||||
"url": "/uploads/media/external-1791141548234.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1084,7 +1052,7 @@ PATCH /api/v1/posts/:id
|
||||
"imageUrl": "https://mon-site.com/images/produit-rectifie.jpg"
|
||||
}
|
||||
```
|
||||
*Note : Tous les champs sont facultatifs. L'envoi d'une nouvelle URL d'image écrase et remplace le média précédent.*
|
||||
*Note : Tous les champs sont facultatifs. Une nouvelle image (`imageUrl`, `imageData`, `mediaId` ou fichier multipart `image`) remplace le média précédent.*
|
||||
|
||||
**Réponse (200 OK) :**
|
||||
*(Retourne la publication modifiée avec sa liste de planification actualisée).*
|
||||
@@ -1103,7 +1071,7 @@ DELETE /api/v1/posts/:id
|
||||
|
||||
---
|
||||
|
||||
## 14. Microservice Interne FFmpeg (Python)
|
||||
## 13. Microservice Interne FFmpeg (Python)
|
||||
|
||||
Ce service s'exécute sur le port `8000` et gère le pipeline de rendu lourd FFmpeg.
|
||||
|
||||
|
||||
+5
-4
@@ -81,9 +81,10 @@ const NewReel = createResponsiveRoute(
|
||||
() => import("@/pages/mobile/new-reel") // Uses same component for now
|
||||
);
|
||||
|
||||
const RemotionVideo = createResponsiveRoute(
|
||||
() => import("@/pages/remotion-video"),
|
||||
() => import("@/pages/mobile/remotion-video")
|
||||
// Reel déjà monté : même page responsive sur mobile et desktop
|
||||
const ScheduleReel = createResponsiveRoute(
|
||||
() => import("@/pages/schedule-reel"),
|
||||
() => import("@/pages/schedule-reel")
|
||||
);
|
||||
|
||||
function ProtectedRoute({ component: Component, adminOnly = false }: { component: React.ComponentType; adminOnly?: boolean }) {
|
||||
@@ -136,7 +137,7 @@ function Router() {
|
||||
<Route path="/">{() => <ProtectedRoute component={Dashboard} />}</Route>
|
||||
<Route path="/new">{() => <ProtectedRoute component={NewPost} />}</Route>
|
||||
<Route path="/reel">{() => <ProtectedRoute component={NewReel} />}</Route>
|
||||
<Route path="/remotion">{() => <ProtectedRoute component={RemotionVideo} />}</Route>
|
||||
<Route path="/reel/schedule">{() => <ProtectedRoute component={ScheduleReel} />}</Route>
|
||||
<Route path="/calendar">{() => <ProtectedRoute component={Calendar} />}</Route>
|
||||
<Route path="/media">{() => <ProtectedRoute component={Media} />}</Route>
|
||||
<Route path="/image-editor">{() => <ProtectedRoute component={ImageEditor} />}</Route>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { Clapperboard, Loader2, CheckCircle2, XCircle, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Post } from "@shared/schema";
|
||||
|
||||
type OngoingReel = Post & { generationStep?: string | null };
|
||||
@@ -35,6 +38,27 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
(query.state.data ?? []).some((p) => p.generationStatus !== "failed") ? 3000 : 30000,
|
||||
});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Un Reel en échec n'a rien à publier : le supprimer retire aussi la notification
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: async (postId: string) => (await apiRequest("DELETE", `/api/reels/${postId}`)).json(),
|
||||
onSuccess: (_data, postId) => {
|
||||
queryClient.setQueryData<OngoingReel[]>(["/api/reels/ongoing"], (posts) =>
|
||||
posts?.filter((p) => p.id !== postId),
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/reels/ongoing"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de supprimer la notification"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Nothing to show — render nothing (not even a card)
|
||||
if (ongoingPosts.length === 0) {
|
||||
return null;
|
||||
@@ -112,6 +136,21 @@ export default function OngoingReels({ compact = false }: OngoingReelsProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFailed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => dismissMutation.mutate(post.id)}
|
||||
disabled={dismissMutation.isPending && dismissMutation.variables === post.id}
|
||||
title="Supprimer"
|
||||
aria-label="Supprimer ce Reel en échec"
|
||||
data-testid={`button-dismiss-reel-${post.id}`}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Cpu, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, getErrorMessage, queryClient } from "@/lib/queryClient";
|
||||
|
||||
interface QwenConfig {
|
||||
url: string;
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
interface QwenTestResult {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
model?: string;
|
||||
device?: string;
|
||||
voices?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réglages du service Qwen3-TTS : voix locale gratuite, idéalement sur un
|
||||
* serveur avec carte graphique (ex. Unraid). C'est le service FFmpeg qui
|
||||
* l'appelle : le test vérifie donc la connexion depuis celui-ci.
|
||||
*/
|
||||
export function QwenSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
const [url, setUrl] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [test, setTest] = useState<QwenTestResult | null>(null);
|
||||
|
||||
const { data: config } = useQuery<QwenConfig>({ queryKey: ["/api/settings/qwen"] });
|
||||
|
||||
useEffect(() => {
|
||||
if (config) setUrl(config.url);
|
||||
}, [config]);
|
||||
|
||||
const refresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/settings/qwen"] });
|
||||
// Le choix du moteur « Qwen » dépend de la disponibilité du service
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/reels/voices"] });
|
||||
};
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => apiRequest("POST", "/api/settings/qwen", { url, apiKey }),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
setApiKey("");
|
||||
toast({ title: "Configuration sauvegardée", description: "Service Qwen TTS enregistré" });
|
||||
},
|
||||
onError: (error) =>
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de sauvegarder la configuration Qwen"),
|
||||
variant: "destructive",
|
||||
}),
|
||||
});
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => apiRequest("DELETE", "/api/settings/qwen"),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
setUrl("");
|
||||
setApiKey("");
|
||||
setTest(null);
|
||||
toast({ title: "Configuration supprimée", description: "Service Qwen TTS retiré" });
|
||||
},
|
||||
});
|
||||
|
||||
const runTest = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await apiRequest("POST", "/api/settings/qwen/test", { url, apiKey });
|
||||
return (await response.json()) as QwenTestResult;
|
||||
},
|
||||
onSuccess: setTest,
|
||||
onError: (error) => setTest({ ok: false, error: getErrorMessage(error, "Test impossible") }),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
<CardHeader className="p-6">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5" />
|
||||
Qwen TTS (voix locale)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Voix française gratuite et expressive, générée par votre propre serveur (idéalement avec carte
|
||||
graphique NVIDIA, ex. Unraid). Voir qwen-tts/docker-compose.gpu.yml.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="qwenTtsUrl">Adresse du service</Label>
|
||||
<Input
|
||||
id="qwenTtsUrl"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="http://192.168.1.20:8001"
|
||||
data-testid="input-qwen-url"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="qwenTtsApiKey">Clé d'accès (API_KEY du service)</Label>
|
||||
<Input
|
||||
id="qwenTtsApiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={config?.hasApiKey ? "••••••••••••••••" : "Clé définie sur le serveur Qwen"}
|
||||
data-testid="input-qwen-api-key"
|
||||
/>
|
||||
{config?.hasApiKey && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">✓ Clé enregistrée (laisser vide pour la garder)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{test && (
|
||||
<p className={`text-sm ${test.ok ? "text-green-600 dark:text-green-400" : "text-destructive"}`}>
|
||||
{test.ok
|
||||
? `✓ Connecté : ${test.device ?? "?"} — ${test.model ?? ""} (${test.voices ?? 0} voix)`
|
||||
: `✗ ${test.error}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => runTest.mutate()}
|
||||
disabled={runTest.isPending || (!url && !config?.url)}
|
||||
data-testid="button-test-qwen"
|
||||
>
|
||||
{runTest.isPending && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Tester la connexion
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => save.mutate()}
|
||||
disabled={save.isPending || !url}
|
||||
data-testid="button-save-qwen"
|
||||
>
|
||||
{save.isPending ? "Enregistrement..." : "Enregistrer Qwen TTS"}
|
||||
</Button>
|
||||
{config?.url && (
|
||||
<Button variant="outline" onClick={() => remove.mutate()} disabled={remove.isPending}>
|
||||
Retirer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
REEL_WIDTH,
|
||||
VOICE_DELAY,
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
offsetWords,
|
||||
spreadWords,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from "@shared/captions";
|
||||
import { srtEnd, srtWords, type SrtCue } from "@shared/srt";
|
||||
import { ReelVideo, type ReelVideoProps } from "@/remotion/ReelVideo";
|
||||
import { ImageComposition, type ImageCompositionProps } from "@/remotion/ImageComposition";
|
||||
import type { VoicePreviewResult } from "./voice-picker";
|
||||
|
||||
/** Débit moyen d'une voix de synthèse française (estimation avant génération). */
|
||||
@@ -40,9 +38,7 @@ interface CommonProps {
|
||||
srtCues?: SrtCue[] | null;
|
||||
}
|
||||
|
||||
type ReelPreviewProps =
|
||||
| (CommonProps & { kind: "video"; videoUrl: string })
|
||||
| (CommonProps & { kind: "images"; images: string[] });
|
||||
type ReelPreviewProps = CommonProps & { kind: "video"; videoUrl: string };
|
||||
|
||||
/** Durée d'un média lue dans ses métadonnées (vidéo de la médiathèque). */
|
||||
function useVideoDuration(url: string | undefined): number | null {
|
||||
@@ -75,12 +71,12 @@ function estimatedVoiceDuration(text: string): number {
|
||||
* Sans voix générée, le minutage des mots est estimé.
|
||||
*/
|
||||
export function ReelPreview(props: ReelPreviewProps) {
|
||||
const videoDuration = useVideoDuration(props.kind === "video" ? props.videoUrl : undefined);
|
||||
const videoDuration = useVideoDuration(props.videoUrl);
|
||||
const { text, showCaptions, captionStyle, ttsEnabled, voice, musicUrl, musicVolume, endingEffect, showWatermark } =
|
||||
props;
|
||||
const logoUrl = props.logoUrl ?? undefined;
|
||||
const storeName = endingEffect ? props.storeName || undefined : undefined;
|
||||
const srtCues = props.kind === "video" && props.srtCues?.length ? props.srtCues : null;
|
||||
const srtCues = props.srtCues?.length ? props.srtCues : null;
|
||||
const cleanText = cleanCaptionText(text);
|
||||
const voiceDuration = srtCues
|
||||
? ttsEnabled ? srtEnd(srtCues) : 0
|
||||
@@ -88,34 +84,6 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
const estimated = !srtCues && ttsEnabled && Boolean(cleanText) && !voice;
|
||||
|
||||
const composition = useMemo(() => {
|
||||
if (props.kind === "images") {
|
||||
if (props.images.length === 0) return null;
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: props.images.length,
|
||||
voiceDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
const contentEnd = total - endingSeconds;
|
||||
let words: TimedWord[] = [];
|
||||
if (showCaptions && cleanText) {
|
||||
words = voice ? voice.words : spreadWords(text, 0, voiceDuration || Math.min(contentEnd - 0.5, 8));
|
||||
}
|
||||
const inputProps: ImageCompositionProps = {
|
||||
images: props.images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle,
|
||||
audioUrl: voice?.audioUrl,
|
||||
musicUrl,
|
||||
musicVolume,
|
||||
logoUrl,
|
||||
showWatermark,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
};
|
||||
return { component: ImageComposition, inputProps, total };
|
||||
}
|
||||
|
||||
if (videoDuration == null) return null;
|
||||
const timing = computeReelTiming({
|
||||
videoDuration,
|
||||
@@ -144,6 +112,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
storeName,
|
||||
logoStart: timing.logoStart,
|
||||
fadeStart: timing.fadeStart,
|
||||
fadeDuration: timing.fadeDuration,
|
||||
voiceUrl: srtCues ? undefined : voice?.audioUrl,
|
||||
voiceDelay: VOICE_DELAY,
|
||||
musicUrl,
|
||||
@@ -152,8 +121,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
return { component: ReelVideo, inputProps, total: timing.total };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
props.kind,
|
||||
props.kind === "video" ? props.videoUrl : props.images.join("|"),
|
||||
props.videoUrl,
|
||||
videoDuration, text, cleanText, srtCues, showCaptions, captionStyle, ttsEnabled, voice, voiceDuration,
|
||||
musicUrl, musicVolume, logoUrl, showWatermark, storeName, endingEffect,
|
||||
]);
|
||||
@@ -171,7 +139,7 @@ export function ReelPreview(props: ReelPreviewProps) {
|
||||
<div className="overflow-hidden rounded-lg bg-black">
|
||||
<Player
|
||||
// Nouvelle instance quand la durée change : le lecteur repart du début
|
||||
key={`${props.kind}-${composition.total}`}
|
||||
key={composition.total}
|
||||
component={composition.component as React.ComponentType<Record<string, unknown>>}
|
||||
inputProps={composition.inputProps as unknown as Record<string, unknown>}
|
||||
durationInFrames={Math.max(1, Math.round(composition.total * REEL_FPS))}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2, Play, Square } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
voicesFor,
|
||||
type TtsEngine,
|
||||
type TtsStyle,
|
||||
type VoiceOption,
|
||||
} from "@shared/voices";
|
||||
import type { TimedWord } from "@shared/captions";
|
||||
|
||||
@@ -76,7 +78,13 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
|
||||
useEffect(() => () => audioRef.current?.pause(), []);
|
||||
|
||||
const voices = voicesFor(value.engine);
|
||||
// Voix Qwen réellement installées (voix clonées comprises) ; moteur grisé si le service local ne répond pas
|
||||
const { data: qwenCatalog } = useQuery<{ qwenAvailable: boolean; qwen: VoiceOption[] }>({
|
||||
queryKey: ["/api/reels/voices"],
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const qwenAvailable = qwenCatalog?.qwenAvailable ?? false;
|
||||
const voices = value.engine === "qwen" && qwenCatalog?.qwen.length ? qwenCatalog.qwen : voicesFor(value.engine);
|
||||
const groups = [
|
||||
{ label: "Voix féminines", items: voices.filter((v) => v.gender === "female") },
|
||||
{ label: "Voix masculines", items: voices.filter((v) => v.gender === "male") },
|
||||
@@ -133,7 +141,18 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className={`${labelClass} w-14 shrink-0`}>Moteur</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className={compact ? "h-7 text-xs" : undefined}
|
||||
variant={value.engine === "qwen" ? "default" : "outline"}
|
||||
disabled={!qwenAvailable && value.engine !== "qwen"}
|
||||
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non configuré ou injoignable (Paramètres → Qwen TTS)"}
|
||||
onClick={() => setEngine("qwen")}
|
||||
>
|
||||
Qwen (locale)
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, PlusCircle, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||
import { Home, PlusCircle, CalendarClock, Calendar, Images, Users, Bot, Clock, Settings, Database, UserCog, LogOut, ChevronLeft, ChevronRight, ChevronDown, Wand2, BarChart3, Video, Music, Shield } from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, AUTH_MUTATION, queryClient } from "@/lib/queryClient";
|
||||
@@ -70,12 +70,12 @@ export default function Sidebar({ onLinkClick }: SidebarProps = {}) {
|
||||
const navItems = [
|
||||
{ icon: Home, label: "Tableau de bord", href: "/", badge: null },
|
||||
{ icon: PlusCircle, label: "Nouvelle publication", href: "/new", badge: null },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: "New" },
|
||||
{ icon: Video, label: "Générateur Vidéo", href: "/remotion", badge: "New" },
|
||||
{ icon: Video, label: "Nouveau Reel", href: "/reel", badge: null },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule", badge: null },
|
||||
{ icon: Calendar, label: "Calendrier", href: "/calendar", badge: null },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media", badge: null },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor", badge: null },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: "New" },
|
||||
{ icon: BarChart3, label: "Analytics", href: "/analytics", badge: null },
|
||||
];
|
||||
|
||||
const statsItems = [
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Users,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Film
|
||||
CalendarClock
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,7 +29,7 @@ export default function DashboardMobile() {
|
||||
];
|
||||
|
||||
const secondaryMenu = [
|
||||
{ icon: Film, label: "Générateur Vidéo", href: "/remotion" },
|
||||
{ icon: CalendarClock, label: "Programmer un Reel", href: "/reel/schedule" },
|
||||
{ icon: Images, label: "Médiathèque", href: "/media" },
|
||||
{ icon: Wand2, label: "Éditeur d'images", href: "/image-editor" },
|
||||
{ icon: Clock, label: "Historique", href: "/history" },
|
||||
|
||||
@@ -1,538 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { UploadCloud, Video, Loader2, Menu, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send, Camera, ImagePlus } from "lucide-react";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
interface AudioTrack { id: string; title: string; fileName: string; url: string; duration: number; }
|
||||
|
||||
|
||||
function stripForTTS(text: string) {
|
||||
return text.replace(/#\w+/g, '').replace(/[\uD800-\uDFFF]/g, '').replace(/[\u2600-\u27BF]/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
|
||||
export default function MobileRemotionVideoPage() {
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const cameraInputRef = useRef<HTMLInputElement>(null);
|
||||
const galleryInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`page-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`page-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => { setGeneratedVariants(data.variants || []); toast({ title: "Textes générés" }); },
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
|
||||
// Corriger les noms de fichiers pour les fichiers venant de la galerie mobile
|
||||
const processedFiles = filesArray.slice(0, remaining).map(file => {
|
||||
if (!file.name || file.name === 'blob' || file.name === '' || file.name === 'image') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
return new File([file], `gallery-${Date.now()}-${Math.random().toString(36).substr(2, 9)}.${ext}`, { type: file.type });
|
||||
}
|
||||
return file;
|
||||
});
|
||||
|
||||
setImages(prev => [...prev, ...processedFiles].slice(0, 4 - selectedLibraryImages.length));
|
||||
}
|
||||
// Reset input pour permettre de resélectionner les mêmes fichiers
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleCameraCapture = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const remaining = 4 - selectedLibraryImages.length - images.length;
|
||||
if (remaining <= 0) {
|
||||
toast({ title: "4 images max.", variant: "destructive" });
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
let fileToAdd = file;
|
||||
if (!file.name || file.name === 'blob' || file.name === '') {
|
||||
const ext = file.type.split('/')[1] || 'jpg';
|
||||
fileToAdd = new File([file], `camera-${Date.now()}.${ext}`, { type: file.type });
|
||||
}
|
||||
setImages(prev => [...prev, fileToAdd].slice(0, 4 - selectedLibraryImages.length));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images max.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background pb-24">
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-50 bg-background/95 backdrop-blur border-b p-4 flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="-ml-2"><Menu className="h-6 w-6" /></Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-[80%]"><Sidebar /></SheetContent>
|
||||
</Sheet>
|
||||
<h1 className="font-semibold text-lg">Générateur Vidéo</h1>
|
||||
</div>
|
||||
|
||||
<main className="p-4 space-y-4">
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">1. Images</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Input caché pour la caméra */}
|
||||
<input
|
||||
ref={cameraInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={handleCameraCapture}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Input caché pour la galerie - séparé pour meilleure compatibilité mobile */}
|
||||
<input
|
||||
ref={galleryInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => galleryInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<ImagePlus className="h-6 w-6 text-primary" />
|
||||
<span className="text-xs font-medium">Galerie</span>
|
||||
<span className="text-[10px] text-muted-foreground">{images.length} choisie(s)</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto py-4 px-4 flex flex-col items-center gap-1"
|
||||
onClick={() => cameraInputRef.current?.click()}
|
||||
disabled={totalSelected >= 4}
|
||||
>
|
||||
<Camera className="h-6 w-6" />
|
||||
<span className="text-xs">Photo</span>
|
||||
</Button>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="flex overflow-x-auto gap-2 pb-1">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`flex-shrink-0 w-16 h-16 relative rounded overflow-hidden border-2 cursor-pointer ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<MediaThumbnail src={media.originalUrl} alt="" type="image" />
|
||||
{isSelected && <div className="absolute top-0.5 right-0.5 bg-primary text-white p-0.5 rounded-full"><Check className="w-2.5 h-2.5" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-primary font-medium">{totalSelected} / 4 sélectionnée(s)</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte & Voix */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea placeholder="Décrivez votre produit…" value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} className="text-sm" />
|
||||
<Button variant="secondary" size="sm" className="w-full"
|
||||
disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-3 h-3 animate-spin" /> : <Sparkles className="mr-2 w-3 h-3" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Appliqué" }); }}
|
||||
className={`text-xs p-2 rounded border cursor-pointer ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Texte overlay…" value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={2} className="text-sm" />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} compact />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo-m" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo-m" className="text-sm">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts-m" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts-m" className="text-sm">Voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} compact />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="max-h-40 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-4"><Loader2 className="w-5 h-5 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-xs text-muted-foreground">Aucune musique disponible.<br />Un administrateur peut en ajouter via la Bibliothèque Audio.</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-2 p-2.5 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-3.5 h-3.5" /> : <Play className="w-3.5 h-3.5 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{track.title}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative border-2 border-dashed border-border rounded p-3 flex items-center gap-2">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-xs text-muted-foreground truncate">{musicFile ? musicFile.name : "Ou uploadez votre fichier audio"}</p>
|
||||
{musicFile && <Check className="w-3 h-3 text-primary shrink-0" />}
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-1">
|
||||
<Label className="flex items-center gap-2 text-xs">
|
||||
<Volume2 className="w-3 h-3" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2"><Send className="w-4 h-4" /> 4. Destinations</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[260px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full h-12" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo MP4</>}
|
||||
</Button>
|
||||
|
||||
{videoUrl && (
|
||||
<div className="space-y-3">
|
||||
<p className="font-semibold text-sm text-green-600">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-md shadow bg-black" />
|
||||
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{facebookPages.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Aucune page Facebook connectée.</p>
|
||||
) : selectedPageIds.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">Sélectionnez une page ci-dessus.</p>
|
||||
) : (
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -694,7 +694,7 @@ export default function NewReel() {
|
||||
setSelectedTrack(null);
|
||||
setCurrentStep('text');
|
||||
}}>
|
||||
Passer
|
||||
Passer (garder le son de la vidéo)
|
||||
</Button>
|
||||
<Button onClick={() => setCurrentStep('text')}>
|
||||
Continuer
|
||||
@@ -1052,7 +1052,7 @@ export default function NewReel() {
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Musique</span>
|
||||
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span>
|
||||
<span>{selectedTrack ? selectedTrack.title : 'Son de la vidéo'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Texte</span>
|
||||
|
||||
@@ -1,490 +0,0 @@
|
||||
import { useEffect, useMemo, useState, useRef } from "react";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { VoicePicker, isVoicePreviewCurrent, type VoicePreviewResult, type VoiceSettings } from "@/components/reels/voice-picker";
|
||||
import { CaptionStylePicker } from "@/components/reels/caption-style-picker";
|
||||
import { ReelPreview } from "@/components/reels/reel-preview";
|
||||
import { DEFAULT_CAPTION_STYLE, type CaptionStyle } from "@shared/captions";
|
||||
import { DEFAULT_TTS_STYLE, DEFAULT_VOICE } from "@shared/voices";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { UploadCloud, Video, Loader2, Check, Sparkles, Mic, Volume2, Music, Play, Pause, Send } from "lucide-react";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
import type { Media, SocialPage } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
|
||||
|
||||
interface AudioTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export default function RemotionVideoPage() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [selectedLibraryImages, setSelectedLibraryImages] = useState<Media[]>([]);
|
||||
const [overlayText, setOverlayText] = useState("");
|
||||
const [productInfo, setProductInfo] = useState("");
|
||||
const [generatedVariants, setGeneratedVariants] = useState<any[]>([]);
|
||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||
const [voiceSettings, setVoiceSettings] = useState<VoiceSettings>({
|
||||
engine: 'gemini',
|
||||
voice: DEFAULT_VOICE.gemini,
|
||||
style: DEFAULT_TTS_STYLE,
|
||||
});
|
||||
const [musicFile, setMusicFile] = useState<File | null>(null);
|
||||
const [selectedTrack, setSelectedTrack] = useState<AudioTrack | null>(null);
|
||||
const [musicVolume, setMusicVolume] = useState(0.3);
|
||||
const [isPlaying, setIsPlaying] = useState<string | null>(null);
|
||||
const [isRendering, setIsRendering] = useState(false);
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [publishDescription, setPublishDescription] = useState("");
|
||||
const [isPublishing, setIsPublishing] = useState(false);
|
||||
const [captionStyle, setCaptionStyle] = useState<CaptionStyle>(DEFAULT_CAPTION_STYLE);
|
||||
const [showLogo, setShowLogo] = useState(true);
|
||||
const [voicePreview, setVoicePreview] = useState<VoicePreviewResult | null>(null);
|
||||
const [renderProgress, setRenderProgress] = useState(0);
|
||||
const currentVoice = isVoicePreviewCurrent(voicePreview, overlayText, voiceSettings) ? voicePreview : null;
|
||||
const { data: reelConfig } = useQuery<{ logoUrl: string | null }>({ queryKey: ['/api/reels/config'] });
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ['/api/media'] });
|
||||
const { data: audioTracks = [], isLoading: tracksLoading } = useQuery<AudioTrack[]>({ queryKey: ['/api/audio-tracks'] });
|
||||
const { data: socialPages = [] } = useQuery<SocialPage[]>({ queryKey: ['/api/pages'] });
|
||||
|
||||
// Aperçu : URL locales des fichiers choisis, libérées quand ils changent
|
||||
const uploadedImageUrls = useMemo(() => images.map((file) => URL.createObjectURL(file)), [images]);
|
||||
useEffect(() => () => uploadedImageUrls.forEach((url) => URL.revokeObjectURL(url)), [uploadedImageUrls]);
|
||||
const musicFileUrl = useMemo(() => (musicFile ? URL.createObjectURL(musicFile) : undefined), [musicFile]);
|
||||
useEffect(() => () => { if (musicFileUrl) URL.revokeObjectURL(musicFileUrl); }, [musicFileUrl]);
|
||||
const previewImages = [...selectedLibraryImages.map((m) => m.originalUrl), ...uploadedImageUrls];
|
||||
|
||||
const facebookPages = socialPages.filter(p => p.platform === 'facebook');
|
||||
const tiktokAccounts = socialPages.filter(p => p.platform === 'tiktok');
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center gap-3">
|
||||
<Checkbox
|
||||
id={`pre-${page.id}`}
|
||||
checked={selectedPageIds.includes(page.id)}
|
||||
onCheckedChange={checked =>
|
||||
setSelectedPageIds(prev => checked ? [...prev, page.id] : prev.filter(id => id !== page.id))
|
||||
}
|
||||
/>
|
||||
<label htmlFor={`pre-${page.id}`} className="text-sm font-medium cursor-pointer">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
const imageMediaList = allMedia.filter(m => m.type === 'image').slice(0, 20);
|
||||
const totalSelected = images.length + selectedLibraryImages.length;
|
||||
|
||||
const generateTextMutation = useMutation({
|
||||
mutationFn: async (text: string) => (await apiRequest('POST', '/api/reels/generate-text', { productInfo: text })).json(),
|
||||
onSuccess: (data: any) => {
|
||||
setGeneratedVariants(data.variants || []);
|
||||
toast({ title: "Textes générés", description: "Cliquez pour appliquer." });
|
||||
},
|
||||
onError: (error: unknown) => toast({ title: "Erreur IA", description: getErrorMessage(error, "Impossible de générer le texte"), variant: "destructive" }),
|
||||
});
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) setImages(Array.from(e.target.files).slice(0, 4 - selectedLibraryImages.length));
|
||||
};
|
||||
|
||||
const toggleLibraryImage = (media: Media) => {
|
||||
setSelectedLibraryImages(prev => {
|
||||
if (prev.find(m => m.id === media.id)) return prev.filter(m => m.id !== media.id);
|
||||
if (images.length + prev.length >= 4) { toast({ title: "4 images maximum.", variant: "destructive" }); return prev; }
|
||||
return [...prev, media];
|
||||
});
|
||||
};
|
||||
|
||||
const togglePlayPreview = (track: AudioTrack) => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying === track.id) { audioRef.current.pause(); setIsPlaying(null); }
|
||||
else { audioRef.current.src = track.url; audioRef.current.play(); setIsPlaying(track.id); }
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (totalSelected < 3) { toast({ title: "Minimum 3 images requises.", variant: "destructive" }); return; }
|
||||
setIsRendering(true);
|
||||
setRenderProgress(0);
|
||||
setVideoUrl(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
images.forEach(img => formData.append("images", img));
|
||||
selectedLibraryImages.forEach(m => formData.append("existingImageUrls", m.originalUrl));
|
||||
if (overlayText) formData.append("overlayText", overlayText);
|
||||
formData.append("ttsEnabled", String(ttsEnabled));
|
||||
formData.append("ttsEngine", voiceSettings.engine);
|
||||
formData.append("ttsVoice", voiceSettings.voice);
|
||||
formData.append("ttsStyle", voiceSettings.style);
|
||||
formData.append("captionStyle", captionStyle);
|
||||
formData.append("showLogo", String(showLogo));
|
||||
if (selectedPageIds[0]) formData.append("selectedPageId", selectedPageIds[0]);
|
||||
if (musicFile) { formData.append("music", musicFile); formData.append("musicVolume", String(musicVolume)); }
|
||||
else if (selectedTrack) { formData.append("musicTrackUrl", selectedTrack.url); formData.append("musicVolume", String(musicVolume)); }
|
||||
const response = await fetch("/api/remotion/render", { method: "POST", body: formData, credentials: "include" });
|
||||
if (!response.ok) { if (response.status === 401) handleUnauthorized("/api/remotion/render"); const e = await response.json().catch(() => ({})); throw new Error(e.error || "Erreur"); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.jobId) {
|
||||
toast({ title: "Rendu démarré", description: "Veuillez patienter (~1-2 min)..." });
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/remotion/render/status/${data.jobId}`, { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized(`/api/remotion/render/status/${data.jobId}`);
|
||||
throw new Error("Erreur de suivi du rendu");
|
||||
}
|
||||
const job = await res.json();
|
||||
|
||||
if (job.status === 'done') {
|
||||
setVideoUrl(job.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
} else if (job.status === 'error') {
|
||||
toast({ title: "Erreur de rendu", description: job.error || "Échec du rendu", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
} else {
|
||||
setRenderProgress(job.progress ?? 0);
|
||||
setTimeout(checkStatus, 3000);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast({ title: "Erreur de connexion", description: e.message, variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
};
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
// Fallback for older sync backend
|
||||
setVideoUrl(data.url);
|
||||
if (overlayText && !publishDescription) setPublishDescription(overlayText);
|
||||
toast({ title: "Vidéo générée !", description: "Votre vidéo est prête à publier." });
|
||||
setIsRendering(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur", description: err.message || "Impossible de créer la vidéo.", variant: "destructive" });
|
||||
setIsRendering(false);
|
||||
}
|
||||
// Note: removed finally block so isRendering stays true during polling
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!videoUrl) return;
|
||||
if (selectedPageIds.length === 0) { toast({ title: "Sélectionnez au moins une page.", variant: "destructive" }); return; }
|
||||
setIsPublishing(true);
|
||||
try {
|
||||
const response = await apiRequest('POST', '/api/remotion/publish', {
|
||||
videoUrl, pageIds: selectedPageIds,
|
||||
scheduledFor: scheduledDate?.toISOString(),
|
||||
description: publishDescription || undefined,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.success) throw new Error(data.results?.find((r: any) => r.error)?.error || "Erreur");
|
||||
toast({
|
||||
title: scheduledDate ? "Publication planifiée !" : "Vidéo publiée !",
|
||||
description: scheduledDate ? `Sera publiée le ${scheduledDate.toLocaleString('fr-FR')}` : "Publiée sur Facebook.",
|
||||
});
|
||||
await queryClient.invalidateQueries({ queryKey: ['/api/scheduled-posts'], refetchType: 'all' });
|
||||
} catch (err: any) {
|
||||
toast({ title: "Erreur de publication", description: err.message, variant: "destructive" });
|
||||
} finally { setIsPublishing(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
<div className={`fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out ${sidebarOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-2xl mx-auto space-y-6">
|
||||
|
||||
<audio ref={audioRef} onEnded={() => setIsPlaying(null)} />
|
||||
|
||||
{/* 1. Images */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Images</CardTitle>
|
||||
<CardDescription>Sélectionnez 3 à 4 images (bibliothèque ou upload).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-6 flex flex-col items-center justify-center relative hover:bg-muted/50 transition-colors">
|
||||
<input type="file" multiple accept="image/*" onChange={handleFileChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground mb-3" />
|
||||
<p className="font-medium text-center">Glissez ou cliquez</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">{images.length} uploadée(s)</p>
|
||||
</div>
|
||||
{imageMediaList.length > 0 && (
|
||||
<div className="pt-3 border-t border-border">
|
||||
<p className="font-medium mb-2 text-sm">Bibliothèque :</p>
|
||||
<div className="grid grid-cols-5 gap-2 max-h-44 overflow-y-auto">
|
||||
{imageMediaList.map(media => {
|
||||
const isSelected = selectedLibraryImages.some(m => m.id === media.id);
|
||||
return (
|
||||
<div key={media.id} onClick={() => toggleLibraryImage(media)}
|
||||
className={`relative aspect-square rounded overflow-hidden border-2 cursor-pointer transition-all ${isSelected ? 'border-primary' : 'border-transparent'}`}>
|
||||
<img src={media.originalUrl} className="w-full h-full object-cover" />
|
||||
{isSelected && <div className="absolute top-1 right-1 bg-primary text-white p-0.5 rounded-full"><Check className="w-3 h-3" /></div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-primary font-medium">Total : {totalSelected} / 4</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Texte + Voix */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Mic className="w-4 h-4" /> 2. Texte & Voix</CardTitle>
|
||||
<CardDescription>La voix lira votre texte. Les hashtags s'affichent mais ne sont pas lus.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Textarea placeholder="Décrivez votre produit pour générer un texte IA..." value={productInfo}
|
||||
onChange={e => setProductInfo(e.target.value)} rows={2} />
|
||||
<Button variant="secondary" className="w-full" disabled={!productInfo || generateTextMutation.isPending}
|
||||
onClick={() => generateTextMutation.mutate(productInfo)}>
|
||||
{generateTextMutation.isPending ? <Loader2 className="mr-2 w-4 h-4 animate-spin" /> : <Sparkles className="mr-2 w-4 h-4" />}
|
||||
Générer avec l'IA
|
||||
</Button>
|
||||
{generatedVariants.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Cliquez pour appliquer :</p>
|
||||
{generatedVariants.map((v: any, i: number) => (
|
||||
<div key={i} onClick={() => { setOverlayText(v.text); toast({ title: "Texte appliqué" }); }}
|
||||
className={`text-sm p-2 rounded border cursor-pointer hover:bg-accent transition-colors ${overlayText === v.text ? 'border-primary bg-primary/5' : 'border-border'}`}>
|
||||
{v.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Textarea placeholder="Votre texte overlay..." value={overlayText}
|
||||
onChange={e => setOverlayText(e.target.value)} rows={3} />
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm font-medium">Style des sous-titres</Label>
|
||||
<CaptionStylePicker value={captionStyle} onChange={setCaptionStyle} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="show-logo" checked={showLogo} onCheckedChange={setShowLogo} />
|
||||
<Label htmlFor="show-logo">Afficher le logo sur la vidéo</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="tts" checked={ttsEnabled} onCheckedChange={setTtsEnabled} />
|
||||
<Label htmlFor="tts">Activer la voix TTS</Label>
|
||||
</div>
|
||||
{ttsEnabled && (
|
||||
<div className="space-y-3 p-3 bg-muted/30 rounded-lg border">
|
||||
<p className="text-sm text-muted-foreground">TTS — voix activée</p>
|
||||
|
||||
<VoicePicker value={voiceSettings} onChange={setVoiceSettings} sampleText={overlayText} onPreview={setVoicePreview} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Musique */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Music className="w-4 h-4" /> 3. Musique de fond</CardTitle>
|
||||
<CardDescription>Choisissez une musique de la bibliothèque ou uploadez la vôtre.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5">
|
||||
{tracksLoading ? (
|
||||
<div className="flex justify-center py-6"><Loader2 className="w-6 h-6 animate-spin text-primary" /></div>
|
||||
) : audioTracks.length === 0 ? (
|
||||
<div className="text-center py-6 border-2 border-dashed border-border rounded-xl">
|
||||
<Music className="w-8 h-8 mx-auto text-muted-foreground mb-2 opacity-40" />
|
||||
<p className="text-sm text-muted-foreground">Aucune musique disponible</p>
|
||||
</div>
|
||||
) : (
|
||||
audioTracks.map(track => {
|
||||
const isSelected = selectedTrack?.id === track.id;
|
||||
const playing = isPlaying === track.id;
|
||||
return (
|
||||
<div key={track.id} onClick={() => { setSelectedTrack(isSelected ? null : track); setMusicFile(null); }}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all ${isSelected ? 'bg-primary/10 border border-primary' : 'bg-muted/50 hover:bg-muted'}`}>
|
||||
<button onClick={e => { e.stopPropagation(); togglePlayPreview(track); }}
|
||||
className="w-9 h-9 rounded-full bg-primary/20 flex items-center justify-center hover:bg-primary/30 shrink-0">
|
||||
{playing ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4 ml-0.5" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm truncate">{track.title}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.floor(track.duration / 60)}:{String(track.duration % 60).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-3 flex items-center gap-3 hover:bg-muted/50 transition-colors cursor-pointer">
|
||||
<input type="file" accept="audio/*"
|
||||
onChange={e => { setMusicFile(e.target.files?.[0] ?? null); setSelectedTrack(null); }}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
|
||||
<UploadCloud className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{musicFile ? musicFile.name : "Ou uploadez votre propre fichier audio"}
|
||||
</p>
|
||||
{musicFile && <Check className="w-4 h-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
{(musicFile || selectedTrack) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2 text-sm">
|
||||
<Volume2 className="w-4 h-4" /> Volume : {Math.round(musicVolume * 100)}%
|
||||
</Label>
|
||||
<Slider min={0} max={1} step={0.05} value={[musicVolume]}
|
||||
onValueChange={vals => setMusicVolume(vals[0] ?? musicVolume)} />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 4. Destinations */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><Send className="w-4 h-4" /> 4. Page Facebook</CardTitle>
|
||||
<CardDescription>Pages Facebook et comptes TikTok cibles (le nom de la première destination apparaîtra en fin de vidéo).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ni compte TikTok connecté.</p>
|
||||
) : (
|
||||
<>
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" />
|
||||
Pages Facebook
|
||||
</div>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
<SiTiktok className="w-4 h-4" />
|
||||
Comptes TikTok
|
||||
</div>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Aperçu en direct */}
|
||||
{previewImages.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Aperçu</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-w-[300px] mx-auto">
|
||||
<ReelPreview
|
||||
kind="images"
|
||||
images={previewImages}
|
||||
text={overlayText}
|
||||
showCaptions
|
||||
captionStyle={captionStyle}
|
||||
ttsEnabled={ttsEnabled}
|
||||
voice={currentVoice}
|
||||
musicUrl={musicFileUrl ?? selectedTrack?.url}
|
||||
musicVolume={musicVolume}
|
||||
logoUrl={reelConfig?.logoUrl}
|
||||
showWatermark={showLogo}
|
||||
storeName={socialPages.find((p) => p.id === selectedPageIds[0])?.pageName ?? socialPages[0]?.pageName}
|
||||
endingEffect
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Générer */}
|
||||
<Button onClick={handleGenerate} disabled={isRendering || totalSelected < 3} className="w-full" size="lg">
|
||||
{isRendering
|
||||
? <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> {renderProgress > 0 ? `Rendu en cours… ${renderProgress} %` : "Préparation (voix, images)…"}</>
|
||||
: <><Video className="mr-2 h-4 w-4" /> Générer la vidéo</>}
|
||||
</Button>
|
||||
|
||||
{/* Vidéo générée */}
|
||||
{videoUrl && (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-semibold text-green-600 mb-2">Vidéo prête :</p>
|
||||
<video src={videoUrl} controls className="w-full rounded-lg shadow-lg" />
|
||||
</div>
|
||||
|
||||
{/* Publication */}
|
||||
<Card className="border-primary/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Send className="w-4 h-4" /> Publier sur Facebook
|
||||
</CardTitle>
|
||||
<CardDescription>Sélectionnez la page et planifiez (optionnel).</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{selectedPageIds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Sélectionnez une page ci-dessus avant de générer.</p>
|
||||
) : (
|
||||
<p className="text-sm text-primary font-medium">
|
||||
Destinations : {[...facebookPages, ...tiktokAccounts].filter(p => selectedPageIds.includes(p.id)).map(p => p.pageName).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<Textarea rows={2} placeholder="Description (optionnel)" value={publishDescription}
|
||||
onChange={e => setPublishDescription(e.target.value)} />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm text-muted-foreground">Date de publication</Label>
|
||||
<DateTimePicker value={scheduledDate} onChange={setScheduledDate}
|
||||
occupiedDates={[]} placeholder="Publier immédiatement" />
|
||||
</div>
|
||||
<Button className="w-full" onClick={handlePublish}
|
||||
disabled={isPublishing || selectedPageIds.length === 0}>
|
||||
{isPublishing
|
||||
? <><Loader2 className="mr-2 w-4 h-4 animate-spin" /> Publication...</>
|
||||
: <><Send className="mr-2 w-4 h-4" /> {scheduledDate ? "Planifier" : "Publier maintenant"}</>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useLocation } from "wouter";
|
||||
import { CalendarClock, Upload, Loader2, Check, Video } from "lucide-react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import Sidebar from "@/components/sidebar";
|
||||
import TopBar from "@/components/topbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient, handleUnauthorized, getErrorMessage } from "@/lib/queryClient";
|
||||
import type { SocialPage, Media } from "@shared/schema";
|
||||
import { SiFacebook, SiTiktok } from "react-icons/si";
|
||||
import { MediaThumbnail } from "@/components/media-thumbnail";
|
||||
import { DateTimePicker } from "@/components/datetime-picker";
|
||||
|
||||
/**
|
||||
* Programmation d'un Reel déjà monté : pas de rendu, la vidéo envoyée est
|
||||
* publiée telle quelle par le planificateur à la date choisie.
|
||||
*/
|
||||
export default function ScheduleReel() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [selectedVideo, setSelectedVideo] = useState<Media | null>(null);
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
|
||||
const { data: pages = [] } = useQuery<SocialPage[]>({ queryKey: ["/api/pages"] });
|
||||
const { data: allMedia = [] } = useQuery<Media[]>({ queryKey: ["/api/media"] });
|
||||
|
||||
// Destinations acceptant un Reel
|
||||
const facebookPages = pages.filter((p) => p.platform === "facebook");
|
||||
const tiktokAccounts = pages.filter((p) => p.platform === "tiktok");
|
||||
const videoList = allMedia.filter((m) => m.type === "video").slice(0, 12);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) handleUnauthorized("/api/media/upload");
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
return response.json() as Promise<Media>;
|
||||
},
|
||||
onSuccess: (media) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/media"] });
|
||||
setSelectedVideo(media);
|
||||
toast({ title: "Vidéo envoyée", description: media.fileName });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Erreur", description: "Impossible d'envoyer la vidéo", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const onDrop = useCallback((files: File[]) => {
|
||||
if (files[0]) uploadMutation.mutate(files[0]);
|
||||
}, [uploadMutation]);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: { "video/*": [".mp4", ".mov", ".webm"] },
|
||||
maxFiles: 1,
|
||||
disabled: uploadMutation.isPending,
|
||||
});
|
||||
|
||||
const scheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await apiRequest("POST", "/api/posts", {
|
||||
content: description,
|
||||
postType: "reel",
|
||||
mediaIds: [selectedVideo!.id],
|
||||
pageIds: selectedPages,
|
||||
scheduledFor: scheduledDate!.toISOString(),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/scheduled-posts"], refetchType: "all" });
|
||||
await queryClient.invalidateQueries({ queryKey: ["/api/posts"] });
|
||||
toast({ title: "Reel programmé", description: "Il sera publié à la date choisie." });
|
||||
navigate("/calendar");
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: getErrorMessage(error, "Impossible de programmer le Reel"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isPast = !!scheduledDate && scheduledDate.getTime() <= Date.now();
|
||||
const canSchedule = !!selectedVideo && selectedPages.length > 0 && !!scheduledDate && !isPast;
|
||||
|
||||
const togglePage = (id: string, checked: boolean) => {
|
||||
setSelectedPages((prev) => (checked ? [...prev, id] : prev.filter((p) => p !== id)));
|
||||
};
|
||||
|
||||
const renderTargetCheckbox = (page: SocialPage) => (
|
||||
<div key={page.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`schedule-reel-page-${page.id}`}
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onCheckedChange={(checked) => togglePage(page.id, checked === true)}
|
||||
/>
|
||||
<label htmlFor={`schedule-reel-page-${page.id}`} className="text-sm font-medium leading-none flex-1">
|
||||
{page.pageName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className={`
|
||||
fixed lg:static inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out
|
||||
${sidebarOpen ? "translate-x-0" : "-translate-x-full lg:translate-x-0"}
|
||||
`}>
|
||||
<Sidebar onLinkClick={() => setSidebarOpen(false)} />
|
||||
</div>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<TopBar onMenuClick={() => setSidebarOpen(!sidebarOpen)} />
|
||||
|
||||
<div className="p-4 sm:p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<CalendarClock className="w-8 h-8 text-primary" />
|
||||
Programmer un Reel
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Publiez une vidéo déjà montée, telle quelle, à la date et l'heure choisies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 1. Vidéo */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Video className="w-5 h-5" /> Vidéo
|
||||
</CardTitle>
|
||||
<CardDescription>Format vertical 9:16 recommandé</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-colors ${
|
||||
isDragActive ? "border-primary bg-primary/5" : "border-border hover:border-primary/50"
|
||||
}`}
|
||||
data-testid="dropzone-schedule-reel"
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{uploadMutation.isPending ? (
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin" /> Envoi en cours...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Upload className="w-8 h-8 mx-auto text-muted-foreground" />
|
||||
<p className="font-medium">Déposez votre Reel ici ou cliquez pour choisir</p>
|
||||
<p className="text-xs text-muted-foreground">MP4, MOV ou WebM</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{videoList.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Ou choisissez une vidéo de la médiathèque</Label>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 gap-2">
|
||||
{videoList.map((media) => {
|
||||
const selected = selectedVideo?.id === media.id;
|
||||
return (
|
||||
<button
|
||||
key={media.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedVideo(media)}
|
||||
className={`relative aspect-[9/16] rounded-lg overflow-hidden border-2 ${
|
||||
selected ? "border-primary" : "border-transparent"
|
||||
}`}
|
||||
>
|
||||
<MediaThumbnail
|
||||
src={media.originalUrl}
|
||||
alt={media.fileName}
|
||||
thumbnailUrl={media.thumbnailUrl ?? undefined}
|
||||
type="video"
|
||||
/>
|
||||
{selected && (
|
||||
<div className="absolute inset-0 bg-primary/30 flex items-center justify-center">
|
||||
<Check className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedVideo && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sélectionnée : <span className="font-medium text-foreground">{selectedVideo.fileName}</span>
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 2. Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Description</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Texte publié avec le Reel (optionnel)"
|
||||
rows={4}
|
||||
data-testid="textarea-schedule-reel-description"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 3. Destinations et date */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Publication</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{facebookPages.length === 0 && tiktokAccounts.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">Aucune page Facebook ou compte TikTok connecté.</p>
|
||||
)}
|
||||
{facebookPages.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<SiFacebook className="w-4 h-4 text-[#1877F2]" /> Pages Facebook
|
||||
</Label>
|
||||
{facebookPages.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
{tiktokAccounts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<SiTiktok className="w-4 h-4" /> Comptes TikTok
|
||||
</Label>
|
||||
{tiktokAccounts.map(renderTargetCheckbox)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Date et heure de parution</Label>
|
||||
<DateTimePicker
|
||||
value={scheduledDate}
|
||||
onChange={setScheduledDate}
|
||||
placeholder="Choisir la date et l'heure"
|
||||
/>
|
||||
{isPast && (
|
||||
<p className="text-sm text-destructive">La date doit être dans le futur.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
size="lg"
|
||||
disabled={!canSchedule || scheduleMutation.isPending}
|
||||
onClick={() => scheduleMutation.mutate()}
|
||||
data-testid="button-schedule-reel"
|
||||
>
|
||||
{scheduleMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<CalendarClock className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Programmer le Reel
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { ModelCombobox } from "@/components/model-combobox";
|
||||
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
|
||||
import { SiTiktok } from "react-icons/si";
|
||||
|
||||
export default function Settings() {
|
||||
@@ -695,6 +696,9 @@ export default function Settings() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
||||
{isAdmin && <QwenSettingsCard />}
|
||||
|
||||
|
||||
{isAdmin && (
|
||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Html5Audio, Img, Sequence, interpolate, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import type { CaptionStyle, TimedWord } from "@shared/captions";
|
||||
import { Captions } from "./components/Captions";
|
||||
import { Outro, Watermark } from "./components/Branding";
|
||||
|
||||
export type ImageCompositionProps = {
|
||||
images: string[];
|
||||
totalDuration: number;
|
||||
/** Mots minutés depuis le début de la vidéo (la voix démarre à 0). */
|
||||
words: TimedWord[];
|
||||
captionStyle: CaptionStyle;
|
||||
audioUrl?: string;
|
||||
musicUrl?: string;
|
||||
musicVolume?: number;
|
||||
logoUrl?: string;
|
||||
/** Petit logo affiché pendant la vidéo (le grand logo de fin reste sur la diapositive de fin). */
|
||||
showWatermark?: boolean;
|
||||
storeName?: string;
|
||||
/** Durée de la diapositive de fin (logo + nom du magasin), en secondes. */
|
||||
endingSeconds?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ken Burns presets — deterministic per image index.
|
||||
* Each entry: starting transform → ending transform over the slide duration.
|
||||
* Scale > 1 to avoid black edges when translating.
|
||||
*/
|
||||
const KB_PRESETS = [
|
||||
// zoom in, center
|
||||
{ fromScale: 1.0, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift right
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "-3%", toX: "3%", fromY: "0%", toY: "0%" },
|
||||
// zoom in + drift left
|
||||
{ fromScale: 1.06, toScale: 1.18, fromX: "3%", toX: "-3%", fromY: "0%", toY: "0%" },
|
||||
// zoom out, center
|
||||
{ fromScale: 1.16, toScale: 1.04, fromX: "0%", toX: "0%", fromY: "0%", toY: "0%" },
|
||||
// pan up + slight zoom
|
||||
{ fromScale: 1.1, toScale: 1.14, fromX: "0%", toX: "0%", fromY: "3%", toY: "-3%" },
|
||||
// pan down + zoom in
|
||||
{ fromScale: 1.06, toScale: 1.16, fromX: "0%", toX: "0%", fromY: "-3%", toY: "3%" },
|
||||
// diagonal drift bottom-right
|
||||
{ fromScale: 1.08, toScale: 1.18, fromX: "-2%", toX: "2%", fromY: "-2%", toY: "2%" },
|
||||
// diagonal drift top-left + zoom out
|
||||
{ fromScale: 1.18, toScale: 1.06, fromX: "2%", toX: "-2%", fromY: "2%", toY: "-2%" },
|
||||
];
|
||||
|
||||
/**
|
||||
* ImageSlide with Ken Burns effect (slow zoom / pan).
|
||||
* The effect index is derived from the image index for deterministic behaviour in Remotion.
|
||||
*/
|
||||
const ImageSlide: React.FC<{ src: string; effectIndex: number }> = ({ src, effectIndex }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { durationInFrames } = useVideoConfig();
|
||||
|
||||
const preset = KB_PRESETS[effectIndex % KB_PRESETS.length];
|
||||
// progress: 0 → 1 over the slide duration, using easeInOut via interpolate extrapolate clamp
|
||||
const progress = interpolate(frame, [0, durationInFrames], [0, 1], { extrapolateRight: "clamp" });
|
||||
|
||||
const scale = interpolate(progress, [0, 1], [preset.fromScale, preset.toScale]);
|
||||
const transX = interpolate(progress, [0, 1], [parseFloat(preset.fromX), parseFloat(preset.toX)]);
|
||||
const transY = interpolate(progress, [0, 1], [parseFloat(preset.fromY), parseFloat(preset.toY)]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ overflow: "hidden", backgroundColor: "black" }}>
|
||||
<Img
|
||||
src={src}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
transform: `scale(${scale}) translate(${transX}%, ${transY}%)`,
|
||||
transformOrigin: "center center",
|
||||
willChange: "transform",
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Reel à partir d'images : diaporama animé, voix, sous-titres et diapositive de fin. */
|
||||
export const ImageComposition: React.FC<ImageCompositionProps> = ({
|
||||
images, words, captionStyle, audioUrl, musicUrl, musicVolume = 0.3, logoUrl, showWatermark = true, storeName, endingSeconds = 3,
|
||||
}) => {
|
||||
const { fps, durationInFrames } = useVideoConfig();
|
||||
const hasEnding = Boolean(logoUrl || storeName) && endingSeconds > 0;
|
||||
const endingFrames = hasEnding ? Math.round(endingSeconds * fps) : 0;
|
||||
const contentFrames = durationInFrames - endingFrames;
|
||||
const perImage = Math.floor(contentFrames / Math.max(images.length, 1));
|
||||
const endingStart = contentFrames / fps;
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||
{images.map((imgUrl, index) => (
|
||||
<Sequence key={index} from={index * perImage} durationInFrames={perImage}>
|
||||
<ImageSlide src={imgUrl} effectIndex={index} />
|
||||
</Sequence>
|
||||
))}
|
||||
|
||||
{audioUrl && <Html5Audio src={audioUrl} />}
|
||||
{musicUrl && (
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) =>
|
||||
(words.some((w) => frame / fps >= w.start - 0.2 && frame / fps <= w.end + 0.3)
|
||||
? musicVolume * 0.35
|
||||
: musicVolume) *
|
||||
interpolate(frame, [durationInFrames - fps, durationInFrames], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Captions words={words} style={captionStyle} hideAfter={hasEnding ? endingStart : null} />
|
||||
{logoUrl && showWatermark && <Watermark logoUrl={logoUrl} until={hasEnding ? endingStart : null} />}
|
||||
{hasEnding && <Outro start={endingStart} logoUrl={logoUrl} storeName={storeName} opaque />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export type ReelVideoProps = {
|
||||
storeName?: string;
|
||||
logoStart?: number | null;
|
||||
fadeStart?: number | null;
|
||||
fadeDuration?: number | null;
|
||||
/** Rendu final : piste son déjà mixée (voix, musique baissée sous la voix, niveau normalisé). */
|
||||
mixedAudioUrl?: string;
|
||||
/** Aperçu : pistes séparées, mixées dans le navigateur. */
|
||||
@@ -31,14 +32,21 @@ export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
||||
const { fps } = useVideoConfig();
|
||||
const {
|
||||
videoUrl, videoDuration, totalDuration, words, captionStyle, logoUrl, showWatermark = true, storeName,
|
||||
logoStart = null, fadeStart = null, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
|
||||
logoStart = null, fadeStart = null, fadeDuration = FADE_SECONDS, mixedAudioUrl, voiceUrl, voiceDelay = 2, musicUrl, musicVolume = 0.25,
|
||||
} = props;
|
||||
|
||||
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
|
||||
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
|
||||
const hasAddedAudio = Boolean(mixedAudioUrl || voiceUrl || musicUrl);
|
||||
// Le son d'origine est remplacé par la musique choisie ; sans musique, il reste
|
||||
// en fond (baissé sous la voix). Au rendu final, il est déjà dans la piste mixée.
|
||||
const keepVideoSound = !mixedAudioUrl && !musicUrl;
|
||||
const video = (
|
||||
<OffthreadVideo src={videoUrl} muted={hasAddedAudio} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||
<OffthreadVideo
|
||||
src={videoUrl}
|
||||
muted={!keepVideoSound}
|
||||
volume={keepVideoSound && voiceUrl ? (frame) => previewMusicVolume(frame / fps, words, 1, fadeStart, fadeDuration ?? FADE_SECONDS) : 1}
|
||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -64,7 +72,7 @@ export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
||||
<Html5Audio
|
||||
src={musicUrl}
|
||||
loop
|
||||
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart)}
|
||||
volume={(frame) => previewMusicVolume(frame / fps, words, musicVolume, fadeStart, fadeDuration ?? FADE_SECONDS)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -73,18 +81,24 @@ export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
||||
{logoStart != null && (logoUrl || storeName) && (
|
||||
<Outro start={logoStart} logoUrl={logoUrl} storeName={storeName} />
|
||||
)}
|
||||
{fadeStart != null && <FadeOut start={fadeStart} duration={FADE_SECONDS} />}
|
||||
{fadeStart != null && <FadeOut start={fadeStart} duration={fadeDuration ?? FADE_SECONDS} />}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/** Aperçu : la musique baisse pendant la parole et s'éteint avec le fondu final. */
|
||||
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): number {
|
||||
/** Aperçu : le fond sonore (musique ou son d'origine) baisse pendant la parole et s'éteint avec le fondu final. */
|
||||
function previewMusicVolume(
|
||||
time: number,
|
||||
words: TimedWord[],
|
||||
base: number,
|
||||
fadeStart: number | null,
|
||||
fadeDuration: number,
|
||||
): number {
|
||||
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
|
||||
const fade =
|
||||
fadeStart == null
|
||||
? 1
|
||||
: interpolate(time, [fadeStart, fadeStart + FADE_SECONDS], [1, 0], {
|
||||
: interpolate(time, [fadeStart, fadeStart + fadeDuration], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Composition, type CalculateMetadataFunction } from "remotion";
|
||||
import { DEFAULT_CAPTION_STYLE, REEL_FPS, REEL_HEIGHT, REEL_WIDTH } from "@shared/captions";
|
||||
import { ImageComposition, type ImageCompositionProps } from "./ImageComposition";
|
||||
import { ReelVideo, type ReelVideoProps } from "./ReelVideo";
|
||||
|
||||
/** La durée vient des propriétés : chaque Reel a la sienne. */
|
||||
@@ -27,21 +26,6 @@ export const RemotionRoot: React.FC = () => {
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ReelVideoProps}
|
||||
/>
|
||||
<Composition
|
||||
id="ImageVideo"
|
||||
component={ImageComposition}
|
||||
fps={REEL_FPS}
|
||||
width={REEL_WIDTH}
|
||||
height={REEL_HEIGHT}
|
||||
durationInFrames={REEL_FPS * 25}
|
||||
calculateMetadata={byTotalDuration<ImageCompositionProps>()}
|
||||
defaultProps={{
|
||||
images: ["https://placehold.co/1080x1920?text=Image+1"],
|
||||
totalDuration: 25,
|
||||
words: [],
|
||||
captionStyle: DEFAULT_CAPTION_STYLE,
|
||||
} as ImageCompositionProps}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -86,11 +86,49 @@ services:
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||
# Service Qwen TTS par défaut. Normalement réglé dans l'application
|
||||
# (Paramètres → Qwen TTS, ex. serveur GPU Unraid) ; pour le service local
|
||||
# ci-dessous : QWEN_TTS_URL=http://qwen-tts:8001
|
||||
QWEN_TTS_URL: ${QWEN_TTS_URL:-}
|
||||
QWEN_TTS_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8000"
|
||||
|
||||
# Voix Qwen3-TTS sur ce serveur, en CPU (optionnel, lent : 5 à 7 fois la
|
||||
# durée de la voix sur 4 cœurs). Sur un serveur GPU, préférez
|
||||
# qwen-tts/docker-compose.gpu.yml. Démarrage : docker compose --profile qwen-local up -d
|
||||
qwen-tts:
|
||||
profiles: ["qwen-local"]
|
||||
build:
|
||||
context: ./qwen-tts
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# 1.7B : ton et émotion pilotés par consigne ; 0.6B : ~30 % plus rapide
|
||||
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||
# Clonage des voix déposées dans qwen-tts/voices (vide : désactivé)
|
||||
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-0.6B-Base}
|
||||
image: socialflow-qwen-tts:latest
|
||||
pull_policy: build
|
||||
container_name: socialflow-qwen-tts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
# Threads de calcul (0 : tous les cœurs)
|
||||
QWEN_TTS_THREADS: ${QWEN_TTS_THREADS:-0}
|
||||
volumes:
|
||||
# Voix clonées ajoutables sans reconstruire l'image
|
||||
- ./qwen-tts/voices:/app/voices:ro
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8001"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${QWEN_TTS_MEMORY:-10g}
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
@@ -30,6 +30,8 @@ ENV WHISPER_MODEL=${WHISPER_MODEL} \
|
||||
HOME=/tmp \
|
||||
XDG_CACHE_HOME=/tmp/.cache
|
||||
RUN python -c "from faster_whisper import WhisperModel; WhisperModel('${WHISPER_MODEL}', device='cpu', compute_type='int8')"
|
||||
# Modèle déjà présent : plus aucune requête vers Hugging Face au démarrage
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
|
||||
COPY main.py .
|
||||
COPY app ./app
|
||||
|
||||
@@ -116,23 +116,36 @@ def _model():
|
||||
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
|
||||
|
||||
|
||||
def _transcribe_sync(audio: Path, hint: str | None) -> list[Word]:
|
||||
# Silence ajouté avant la voix pour Whisper : sur une voix qui démarre
|
||||
# aussitôt, il laissait souvent passer le premier mot (« Alerte »).
|
||||
LEAD_SILENCE = 0.5
|
||||
|
||||
|
||||
def _transcribe_sync(audio: Path) -> list[Word]:
|
||||
import numpy as np
|
||||
from faster_whisper.audio import decode_audio
|
||||
|
||||
samples = decode_audio(str(audio), sampling_rate=16000)
|
||||
padded = np.concatenate([np.zeros(int(16000 * LEAD_SILENCE), dtype=samples.dtype), samples])
|
||||
# Pas de texte attendu en amorce (initial_prompt) ni d'enchaînement sur le
|
||||
# segment précédent : les deux font halluciner Whisper, qui répète la phrase
|
||||
# et masque une fin tronquée. On veut entendre ce qui est réellement dit.
|
||||
segments, _ = _model().transcribe(
|
||||
str(audio),
|
||||
padded,
|
||||
language="fr",
|
||||
word_timestamps=True,
|
||||
initial_prompt=hint or None,
|
||||
condition_on_previous_text=False,
|
||||
vad_filter=False,
|
||||
beam_size=5,
|
||||
)
|
||||
return [
|
||||
Word(w.word.strip(), float(w.start), float(w.end))
|
||||
Word(w.word.strip(), max(0.0, w.start - LEAD_SILENCE), max(0.0, w.end - LEAD_SILENCE))
|
||||
for segment in segments
|
||||
for w in (segment.words or [])
|
||||
if w.word.strip()
|
||||
]
|
||||
|
||||
|
||||
async def transcribe(audio: Path, hint: str | None = None) -> list[Word]:
|
||||
async def transcribe(audio: Path) -> list[Word]:
|
||||
"""Mots prononcés et leurs instants (Whisper, exécuté hors de la boucle async)."""
|
||||
return await asyncio.to_thread(_transcribe_sync, audio, hint)
|
||||
return await asyncio.to_thread(_transcribe_sync, audio)
|
||||
@@ -56,6 +56,9 @@ class TtsRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
# Service Qwen TTS réglé dans les paramètres (sinon : variables d'environnement)
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
|
||||
|
||||
class SrtCue(BaseModel):
|
||||
@@ -80,6 +83,8 @@ class ReelRequest(BaseModel):
|
||||
tts_engine: str | None = "gemini"
|
||||
tts_style: str | None = None
|
||||
gemini_api_key: str | None = None
|
||||
qwen_tts_url: str | None = None
|
||||
qwen_tts_api_key: str | None = None
|
||||
draw_text: bool = True
|
||||
stabilize: bool = False
|
||||
enable_ending_effect: bool = True
|
||||
@@ -99,8 +104,14 @@ async def health():
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def list_voices():
|
||||
return voices.catalog()
|
||||
async def list_voices(x_qwen_url: str | None = Header(None), x_qwen_key: str | None = Header(None)):
|
||||
"""Voix par moteur ; l'adresse du service Qwen réglée dans l'application
|
||||
arrive en en-têtes (une clé n'a rien à faire dans une URL)."""
|
||||
qwen_status = await tts.qwen.status(tts.qwen.Target.resolve(x_qwen_url, x_qwen_key))
|
||||
return {
|
||||
**voices.catalog(qwen_status["voices"] if qwen_status else None),
|
||||
"qwen_status": {k: v for k, v in (qwen_status or {}).items() if k != "voices"} or None,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/preview-tts", dependencies=[Depends(require_key)])
|
||||
@@ -187,6 +198,7 @@ async def _synthesize(text: str, display_source: str | None, request, workdir: P
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix impossible à générer")
|
||||
@@ -212,6 +224,7 @@ async def _synthesize_srt(cues: list[srt_voice.Cue], request, workdir: Path) ->
|
||||
style=request.tts_style,
|
||||
gemini_api_key=request.gemini_api_key,
|
||||
workdir=workdir,
|
||||
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||
)
|
||||
except Exception as error:
|
||||
log.exception("Voix SRT impossible à générer")
|
||||
@@ -407,6 +420,8 @@ async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: fl
|
||||
"total_duration": plan.total_duration,
|
||||
"video_duration": info.duration,
|
||||
"logo_start": plan.logo_start if plan.has_outro else None,
|
||||
"fade_start": plan.fade_start if plan.ending_effect else None,
|
||||
"fade_duration": plan.fade_duration if plan.ending_effect else None,
|
||||
"words": [w.to_dict() for w in words],
|
||||
**_voice_info(track),
|
||||
"processing_stats": clock.summary(),
|
||||
@@ -418,7 +433,7 @@ async def _caption_words_without_voice(display: str, video: Path, info, plan: re
|
||||
sinon texte réparti sur la durée (hors effet de fin)."""
|
||||
if info.has_audio:
|
||||
try:
|
||||
spoken = await align.transcribe(video, hint=display)
|
||||
spoken = await align.transcribe(video)
|
||||
if len(spoken) >= max(2, len(display.split()) // 3):
|
||||
return align.align_words(display, spoken, info.duration)
|
||||
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
|
||||
|
||||
+59
-25
@@ -1,42 +1,76 @@
|
||||
"""Traitement de la voix : filtrage, compression et niveau sonore constant."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from . import proc
|
||||
|
||||
# Coupe les basses inutiles, lisse la dynamique et amène la voix à -16 LUFS :
|
||||
# elle reste intelligible par-dessus la musique sans saturer.
|
||||
VOICE_CHAIN = (
|
||||
# Nettoyage de la voix : coupe les basses inutiles, retire le souffle de fond
|
||||
# (réduction douce, sans gate qui hacherait les fins de phrases) et arrondit
|
||||
# les pics. Pas de gain ajouté : c'est la normalisation qui règle le niveau.
|
||||
CLEAN_CHAIN = (
|
||||
"highpass=f=80,"
|
||||
"acompressor=threshold=0.1:ratio=3:attack=5:release=120:makeup=2,"
|
||||
"loudnorm=I=-16:TP=-1.5:LRA=7,"
|
||||
"aresample=48000"
|
||||
"afftdn=nr=10:nf=-50:tn=1,"
|
||||
"acompressor=threshold=0.125:ratio=2.5:attack=5:release=150:makeup=1"
|
||||
)
|
||||
# Niveau cible de la voix : intelligible par-dessus la musique sans saturer
|
||||
TARGET = "I=-16:TP=-1.5:LRA=11"
|
||||
# Fondu de 5 ms aux extrémités : pas de clic au début ni à la fin
|
||||
EDGE_FADE = 0.005
|
||||
|
||||
|
||||
async def process_voice(source: Path, target: Path) -> None:
|
||||
"""Produit un WAV 48 kHz mono prêt à mixer."""
|
||||
await proc.run(
|
||||
"""Produit un WAV 48 kHz mono prêt à mixer.
|
||||
|
||||
Normalisation en deux passes, en mode linéaire : un seul gain appliqué à
|
||||
toute la voix. Le mode dynamique (une passe) relevait les passages calmes
|
||||
et amplifiait souffles et bruits entre les phrases."""
|
||||
measure = await proc.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(source),
|
||||
"-af",
|
||||
VOICE_CHAIN,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(target),
|
||||
"ffmpeg", "-hide_banner", "-nostats", "-i", str(source),
|
||||
"-af", f"{CLEAN_CHAIN},loudnorm={TARGET}:print_format=json", "-f", "null", "-",
|
||||
],
|
||||
timeout=120,
|
||||
)
|
||||
stderr_output=True,
|
||||
) # fmt: skip
|
||||
stats = _loudnorm_stats(measure)
|
||||
if stats:
|
||||
loudnorm = (
|
||||
f"loudnorm={TARGET}:linear=true:"
|
||||
f"measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:"
|
||||
f"measured_LRA={stats['input_lra']}:measured_thresh={stats['input_thresh']}:"
|
||||
f"offset={stats['target_offset']}"
|
||||
)
|
||||
else:
|
||||
loudnorm = f"loudnorm={TARGET}"
|
||||
duration = (await proc.probe(source)).duration
|
||||
fades = f"afade=t=in:d={EDGE_FADE},afade=t=out:st={max(0.0, duration - EDGE_FADE):.3f}:d={EDGE_FADE}"
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
"-af", f"{CLEAN_CHAIN},{loudnorm},aresample=48000,{fades}",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(target),
|
||||
],
|
||||
timeout=120,
|
||||
) # fmt: skip
|
||||
|
||||
|
||||
def _loudnorm_stats(ffmpeg_log: str) -> dict[str, str] | None:
|
||||
"""Mesures de la première passe (bloc JSON écrit par loudnorm)."""
|
||||
start, end = ffmpeg_log.rfind("{"), ffmpeg_log.rfind("}")
|
||||
if start < 0 or end <= start:
|
||||
return None
|
||||
try:
|
||||
stats = json.loads(ffmpeg_log[start : end + 1])
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
keys = ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")
|
||||
if not all(k in stats for k in keys):
|
||||
return None
|
||||
# Voix quasi muette : les mesures valent -inf, la normalisation simple suffit
|
||||
if any("inf" in str(stats[k]) for k in keys):
|
||||
return None
|
||||
return {k: stats[k] for k in keys}
|
||||
|
||||
|
||||
async def encode_preview(source: Path, target: Path) -> None:
|
||||
|
||||
@@ -14,6 +14,12 @@ TEMP_DIR = Path(os.environ.get("TEMP_DIR", "/tmp/ffmpeg_processing"))
|
||||
FILE_TTL_SECONDS = int(os.environ.get("FILE_TTL_SECONDS", "3600"))
|
||||
|
||||
GEMINI_TTS_MODEL = os.environ.get("GEMINI_TTS_MODEL", "gemini-2.5-flash-preview-tts")
|
||||
# Gemini tire sa lecture au hasard : une température basse et une graine fixe
|
||||
# donnent une intonation stable d'une génération à l'autre.
|
||||
GEMINI_TTS_TEMPERATURE = float(os.environ.get("GEMINI_TTS_TEMPERATURE", "0.3"))
|
||||
GEMINI_TTS_SEED = int(os.environ.get("GEMINI_TTS_SEED", "20260928"))
|
||||
# Générations tentées quand la lecture ne correspond pas au texte
|
||||
GEMINI_TTS_ATTEMPTS = int(os.environ.get("GEMINI_TTS_ATTEMPTS", "3"))
|
||||
|
||||
# Modèle Whisper utilisé pour caler les sous-titres sur la voix.
|
||||
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "base")
|
||||
@@ -30,3 +36,11 @@ FPS = 30
|
||||
VOICE_DELAY = 2.0
|
||||
|
||||
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
||||
|
||||
# Service Qwen3-TTS par défaut (service qwen-tts, local ou serveur GPU) ; l'adresse
|
||||
# réglée dans les paramètres de l'application est prioritaire. Vide : aucun.
|
||||
QWEN_TTS_URL = os.environ.get("QWEN_TTS_URL", "").rstrip("/")
|
||||
# Sur CPU, la génération prend plusieurs fois la durée de la voix
|
||||
QWEN_TTS_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
||||
QWEN_TTS_ATTEMPTS = int(os.environ.get("QWEN_TTS_ATTEMPTS", "3"))
|
||||
QWEN_TTS_API_KEY = os.environ.get("QWEN_TTS_API_KEY", "")
|
||||
@@ -20,8 +20,9 @@ class CommandError(RuntimeError):
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
async def run(cmd: list[str], timeout: float = 900) -> str:
|
||||
"""Lance une commande et renvoie sa sortie standard."""
|
||||
async def run(cmd: list[str], timeout: float = 900, *, stderr_output: bool = False) -> str:
|
||||
"""Lance une commande et renvoie sa sortie standard (ou d'erreur, où FFmpeg
|
||||
écrit les mesures de ses filtres d'analyse)."""
|
||||
log.debug("exec: %s", " ".join(cmd))
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
@@ -34,7 +35,7 @@ async def run(cmd: list[str], timeout: float = 900) -> str:
|
||||
raise CommandError(cmd, -1, f"délai de {timeout:.0f} s dépassé") from error
|
||||
if process.returncode != 0:
|
||||
raise CommandError(cmd, process.returncode, stderr.decode(errors="replace"))
|
||||
return stdout.decode(errors="replace")
|
||||
return (stderr if stderr_output else stdout).decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Contrôles de la voix générée : texte réellement lu et silences.
|
||||
|
||||
Gemini lit parfois mal : mots sautés, fin tronquée, consigne de style lue à
|
||||
voix haute. On compare donc ce que Whisper entend au texte attendu, et on
|
||||
découpe la voix d'après son niveau sonore réel (mesuré toutes les 10 ms)
|
||||
plutôt qu'aux bornes des mots estimées par Whisper, trop imprécises pour ne
|
||||
pas rogner une syllabe.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import difflib
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import proc
|
||||
from .align import Word, normalize
|
||||
|
||||
MIN_COVERAGE = 0.92
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReadingCheck:
|
||||
coverage: float # part des mots attendus effectivement entendus
|
||||
extra: float # mots entendus en trop (consigne lue, ajouts), rapportés au texte
|
||||
ending_ok: bool # la fin du texte est bien lue
|
||||
missing: list[str] = field(default_factory=list) # mots attendus non entendus (diagnostic)
|
||||
|
||||
@property
|
||||
def acceptable(self) -> bool:
|
||||
# Comparaison lettre à lettre : une lecture complète atteint ~97 %
|
||||
# (petites erreurs de Whisper) ; sous 92 %, des mots ont été sautés.
|
||||
return self.coverage >= MIN_COVERAGE and self.extra <= 0.25 and self.ending_ok
|
||||
|
||||
def describe(self) -> str:
|
||||
parts = [f"{self.coverage:.0%} des mots lus"]
|
||||
if not self.ending_ok:
|
||||
parts.append("fin tronquée")
|
||||
if self.extra > 0.25:
|
||||
parts.append("mots ajoutés")
|
||||
if self.missing and self.coverage < MIN_COVERAGE:
|
||||
parts.append("non entendus : " + " ".join(self.missing[:12]))
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
|
||||
"""Compare le texte attendu à ce qui a été entendu (Whisper), lettre à
|
||||
lettre : les petites erreurs de reconnaissance (« petit » pour « petits »,
|
||||
« week -end » découpé) ne comptent pas comme des mots manquants, alors
|
||||
qu'un mot sauté, une fin tronquée ou une phrase ajoutée se voient."""
|
||||
expected = "".join(comparable_tokens(expected_text.split()))
|
||||
heard = "".join(comparable_tokens([w.text for w in spoken]))
|
||||
if not expected:
|
||||
return ReadingCheck(1.0, 0.0, True)
|
||||
if not heard:
|
||||
return ReadingCheck(0.0, 0.0, False)
|
||||
|
||||
matcher = difflib.SequenceMatcher(a=expected, b=heard, autojunk=False)
|
||||
blocks = [b for b in matcher.get_matching_blocks() if b.size]
|
||||
matched = sum(b.size for b in blocks)
|
||||
# Fin lue : une bonne partie des dernières lettres du texte est reconnue
|
||||
tail_start = max(0, len(expected) - 8)
|
||||
tail_matched = sum(max(0, min(b.a + b.size, len(expected)) - max(b.a, tail_start)) for b in blocks)
|
||||
return ReadingCheck(
|
||||
coverage=matched / len(expected),
|
||||
extra=max(0, len(heard) - matched) / len(expected),
|
||||
ending_ok=tail_matched >= min(4, len(expected) - tail_start),
|
||||
missing=_missing_words(expected_text, spoken),
|
||||
)
|
||||
|
||||
|
||||
# Unités écrites de plusieurs façons par Whisper (« 40 cm » pour « 40 centimètres »)
|
||||
UNITS = {
|
||||
"e", "eur", "euro", "euros", "centime", "centimes", "cm", "centimetre", "centimetres",
|
||||
"mm", "millimetre", "millimetres", "m", "metre", "metres", "km", "kilometre", "kilometres",
|
||||
"g", "gramme", "grammes", "kg", "kilo", "kilos", "kilogramme", "kilogrammes",
|
||||
"l", "litre", "litres", "cl", "centilitre", "centilitres", "ml", "millilitre", "millilitres",
|
||||
"pourcent", "pourcents", "x", "fois",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
# Nombres écrits en lettres : Whisper les écrit en chiffres (« trente » → « 30 »).
|
||||
# « un »/« une » n'y sont pas : ce sont le plus souvent des articles.
|
||||
NUMBER_WORDS = {
|
||||
"zero", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf", "dix",
|
||||
"onze", "douze", "treize", "quatorze", "quinze", "seize", "vingt", "vingts",
|
||||
"trente", "quarante", "cinquante", "soixante", "septante", "octante", "nonante",
|
||||
"cent", "cents", "mille", "million", "millions", "milliard", "milliards",
|
||||
} # fmt: skip
|
||||
# Signe avant un nombre : « +30 » est lu « plus trente »
|
||||
SIGNS = {"plus", "moins"}
|
||||
|
||||
|
||||
# Nombres composés, écrits avec traits d'union puis normalisés (« trentecinq »)
|
||||
_COMPOUND_NUMBER = re.compile(
|
||||
"^(?:" + "|".join(sorted(NUMBER_WORDS | {"et", "un", "une"}, key=len, reverse=True)) + ")+$"
|
||||
)
|
||||
|
||||
|
||||
def _is_number(token: str) -> bool:
|
||||
if any(c.isdigit() for c in token) or token in NUMBER_WORDS:
|
||||
return True
|
||||
return token not in {"un", "une", "et"} and bool(_COMPOUND_NUMBER.match(token))
|
||||
|
||||
|
||||
def comparable_tokens(words: list[str]) -> list[str]:
|
||||
"""Mots normalisés, sans les nombres (en chiffres ou en lettres), leur signe
|
||||
ni l'unité qui les suit : Whisper écrit un prix ou une mesure à sa façon
|
||||
(« 11 € 99 », « 11,99 euros », « 40 cm », « 30 » pour « trente »), ce qui
|
||||
passait pour des mots sautés et relançait la génération."""
|
||||
normalized = [t for t in (normalize(w) for w in words) if t]
|
||||
tokens: list[str] = []
|
||||
after_number = False
|
||||
for index, token in enumerate(normalized):
|
||||
if _is_number(token):
|
||||
after_number = True
|
||||
continue
|
||||
following = normalized[index + 1] if index + 1 < len(normalized) else ""
|
||||
if token in SIGNS and _is_number(following):
|
||||
continue
|
||||
if after_number and token == "pour" and following in {"cent", "cents"}:
|
||||
continue # « trente pour cent » ↔ « 30 % »
|
||||
if after_number and token in UNITS:
|
||||
continue
|
||||
after_number = False
|
||||
tokens.append(token)
|
||||
return tokens
|
||||
|
||||
|
||||
def _missing_words(expected_text: str, spoken: list[Word]) -> list[str]:
|
||||
"""Mots attendus sans équivalent proche dans ce qui a été entendu."""
|
||||
heard = set(comparable_tokens([w.text for w in spoken]))
|
||||
kept = set(comparable_tokens(expected_text.split()))
|
||||
missing = []
|
||||
for word in expected_text.split():
|
||||
token = normalize(word)
|
||||
if token in kept and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
|
||||
missing.append(word.strip(".,!?;:…"))
|
||||
return missing
|
||||
|
||||
|
||||
# Enveloppe sonore : niveau de la voix toutes les 10 ms
|
||||
FRAME = 0.01
|
||||
# Est « audible » ce qui dépasse le niveau de crête moins 38 dB : un seuil
|
||||
# relatif respecte les fins de phrases douces (« s » final, voix qui retombe),
|
||||
# qu'un seuil absolu prenait pour du silence.
|
||||
ACTIVE_RANGE_DB = 38.0
|
||||
# Marges gardées autour de la parole
|
||||
LEAD_PAD = 0.08
|
||||
TAIL_PAD = 0.30
|
||||
# Pause entre deux phrases : au moins 80 ms de silence, cherchée jusqu'à 1,2 s
|
||||
# de la borne estimée par Whisper
|
||||
MIN_PAUSE = 0.08
|
||||
PAUSE_SEARCH = 1.2
|
||||
|
||||
|
||||
@dataclass
|
||||
class Envelope:
|
||||
db: np.ndarray # niveau (dBFS) de chaque tranche de 10 ms
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return len(self.db) * FRAME
|
||||
|
||||
@property
|
||||
def threshold(self) -> float:
|
||||
if not len(self.db):
|
||||
return 0.0
|
||||
return float(np.percentile(self.db, 95)) - ACTIVE_RANGE_DB
|
||||
|
||||
def _frames(self, start: float, end: float) -> tuple[int, int]:
|
||||
first = max(0, int(start / FRAME))
|
||||
last = min(len(self.db), int(np.ceil(end / FRAME)))
|
||||
return first, max(first, last)
|
||||
|
||||
def quietest(self, start: float, end: float) -> float:
|
||||
"""Milieu de la zone la plus calme d'un intervalle : la coupe tombe au
|
||||
cœur de la pause, loin des deux phrases qu'elle sépare."""
|
||||
first, last = self._frames(start, end)
|
||||
if last - first < 1:
|
||||
return (start + end) / 2
|
||||
smooth = np.convolve(self.db, np.ones(5) / 5, mode="same")[first:last]
|
||||
quiet = smooth <= smooth.min() + 3.0 # à 3 dB du minimum
|
||||
best_start, best_length, run_start = 0, 0, None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if i - run_start > best_length:
|
||||
best_start, best_length = run_start, i - run_start
|
||||
run_start = None
|
||||
return (first + best_start + best_length / 2) * FRAME
|
||||
|
||||
def first_sound(self, start: float, end: float) -> float | None:
|
||||
first, last = self._frames(start, end)
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[0])) * FRAME if len(active) else None
|
||||
|
||||
def last_sound(self, start: float, end: float) -> float | None:
|
||||
first, last = self._frames(start, end)
|
||||
active = np.nonzero(self.db[first:last] > self.threshold)[0]
|
||||
return (first + int(active[-1]) + 1) * FRAME if len(active) else None
|
||||
|
||||
def pauses(self, min_length: float = MIN_PAUSE) -> list[tuple[float, float]]:
|
||||
"""Silences (sous le seuil d'audibilité) d'au moins min_length secondes."""
|
||||
quiet = self.db <= self.threshold
|
||||
runs, run_start = [], None
|
||||
for i, is_quiet in enumerate([*quiet, False]):
|
||||
if is_quiet and run_start is None:
|
||||
run_start = i
|
||||
elif not is_quiet and run_start is not None:
|
||||
if (i - run_start) * FRAME >= min_length:
|
||||
runs.append((run_start * FRAME, i * FRAME))
|
||||
run_start = None
|
||||
return runs
|
||||
|
||||
def speech_span(self, start: float, end: float) -> tuple[float, float]:
|
||||
"""Parole contenue dans [start, end], avec ses marges, sans en sortir."""
|
||||
onset, offset = self.first_sound(start, end), self.last_sound(start, end)
|
||||
if onset is None or offset is None:
|
||||
return start, end
|
||||
return max(start, onset - LEAD_PAD), min(end, offset + TAIL_PAD)
|
||||
|
||||
|
||||
def envelope_from_samples(samples: np.ndarray, rate: int) -> Envelope:
|
||||
per_frame = int(rate * FRAME)
|
||||
count = len(samples) // per_frame
|
||||
if count == 0:
|
||||
return Envelope(np.array([-120.0]))
|
||||
frames = samples[: count * per_frame].reshape(count, per_frame).astype(np.float64)
|
||||
rms = np.sqrt(np.mean(frames**2, axis=1))
|
||||
return Envelope(20 * np.log10(np.maximum(rms, 1e-6)))
|
||||
|
||||
|
||||
async def load_envelope(path: Path) -> Envelope:
|
||||
"""Décode l'audio (mono 16 kHz) et mesure son niveau toutes les 10 ms."""
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", str(path),
|
||||
"-ac", "1", "-ar", "16000", "-f", "f32le", "-",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
) # fmt: skip
|
||||
raw, err = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise proc.CommandError(["ffmpeg"], process.returncode, err.decode(errors="replace"))
|
||||
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
|
||||
|
||||
|
||||
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float, after: float = 0.0) -> float:
|
||||
"""Coupe entre deux phrases, au cœur d'une vraie pause.
|
||||
|
||||
Les bornes Whisper sont imprécises, et fausses quand un mot n'a pas été
|
||||
reconnu (un prix en fin de phrase : sa place est alors estimée) ; couper
|
||||
à la borne estimée tombait parfois au milieu d'une phrase. On choisit donc
|
||||
la pause mesurée la plus proche de la borne estimée, les longues pauses
|
||||
(fin de phrase) étant préférées aux courtes (virgule)."""
|
||||
low = max(after, min(previous_end, next_start) - 0.1)
|
||||
high = max(low + 0.05, max(previous_end, next_start) + 0.2)
|
||||
best: tuple[float, float, float] | None = None
|
||||
for start, end in envelope.pauses():
|
||||
if end <= after + 0.05 or start <= 0.0 or end >= envelope.duration:
|
||||
continue # silences avant la première et après la dernière phrase exclus
|
||||
distance = max(0.0, low - end, start - high)
|
||||
if distance > PAUSE_SEARCH:
|
||||
continue
|
||||
score = distance - (end - start)
|
||||
if best is None or score < best[0]:
|
||||
best = (score, max(start, after), end)
|
||||
if best:
|
||||
return envelope.quietest(best[1], best[2])
|
||||
return envelope.quietest(low, high)
|
||||
@@ -14,6 +14,8 @@ from . import config
|
||||
from .subtitles import filter_path
|
||||
|
||||
FADE_SECONDS = 2.0
|
||||
# Fondu raccourci quand la voix finit tard, mais jamais sec
|
||||
MIN_FADE = 0.3
|
||||
LOGO_SECONDS = 5.0
|
||||
# Silence laissé après la dernière phrase avant la fin de la vidéo
|
||||
VOICE_TAIL = 0.8
|
||||
@@ -76,7 +78,16 @@ class RenderPlan:
|
||||
|
||||
@property
|
||||
def fade_start(self) -> float:
|
||||
return max(0.0, self.total_duration - FADE_SECONDS)
|
||||
"""Le fondu final ne commence jamais avant la fin de la voix : il
|
||||
rognait la dernière phrase quand la vidéo finissait peu après."""
|
||||
start = max(0.0, self.total_duration - FADE_SECONDS)
|
||||
if self.voice:
|
||||
start = max(start, min(self.speech_end + 0.15, self.total_duration - MIN_FADE))
|
||||
return round(start, 3)
|
||||
|
||||
@property
|
||||
def fade_duration(self) -> float:
|
||||
return round(max(MIN_FADE, self.total_duration - self.fade_start), 3)
|
||||
|
||||
@property
|
||||
def freeze_duration(self) -> float:
|
||||
@@ -102,34 +113,47 @@ def video_filters(plan: RenderPlan) -> list[str]:
|
||||
return chain
|
||||
|
||||
|
||||
def uses_original_audio(plan: RenderPlan) -> bool:
|
||||
"""Sans musique choisie, la bande son d'origine de la vidéo sert de fond sonore."""
|
||||
return plan.music is None and plan.keep_original_audio
|
||||
|
||||
|
||||
def audio_graph(
|
||||
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
|
||||
plan: RenderPlan, music_idx: int | None, voice_idx: int | None, original_idx: int | None = None
|
||||
) -> tuple[list[str], str | None]:
|
||||
"""Graphe audio : voix décalée, musique bouclée et baissée sous la voix,
|
||||
niveau final ~ -14 LUFS. Renvoie les filtres et l'étiquette de sortie."""
|
||||
"""Graphe audio : voix décalée sur un fond sonore (musique bouclée, ou à
|
||||
défaut bande son d'origine) baissé sous la voix, niveau final ~ -14 LUFS.
|
||||
Renvoie les filtres et l'étiquette de sortie."""
|
||||
graph: list[str] = []
|
||||
if voice_idx is not None:
|
||||
delay_ms = int(plan.voice_delay * 1000)
|
||||
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
||||
if music_idx is not None:
|
||||
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[music]")
|
||||
|
||||
if voice_idx is not None and music_idx is not None:
|
||||
# La musique baisse automatiquement quand la voix parle (ducking)
|
||||
bed = None
|
||||
if music_idx is not None:
|
||||
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[bed]")
|
||||
bed = "bed"
|
||||
elif original_idx is not None:
|
||||
# Son d'origine conservé à son niveau ; il se tait après la fin de la vidéo
|
||||
graph.append(f"[{original_idx}:a:0]aresample=48000,apad[bed]")
|
||||
bed = "bed"
|
||||
|
||||
if voice_idx is not None and bed:
|
||||
# Le fond sonore baisse automatiquement quand la voix parle (ducking)
|
||||
graph.append("[voice]asplit=2[vmix][vkey]")
|
||||
graph.append("[music][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
|
||||
graph.append(f"[{bed}][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
|
||||
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
|
||||
mix = "mix"
|
||||
elif voice_idx is not None:
|
||||
mix = "voice"
|
||||
elif music_idx is not None:
|
||||
mix = "music"
|
||||
elif bed:
|
||||
mix = bed
|
||||
else:
|
||||
return graph, None
|
||||
|
||||
tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
|
||||
tail = ["loudnorm=I=-14:TP=-1.5:LRA=20", "aresample=48000"]
|
||||
if plan.ending_effect:
|
||||
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
|
||||
graph.append(f"[{mix}]{','.join(tail)}[aout]")
|
||||
return graph, "aout"
|
||||
|
||||
@@ -202,18 +226,15 @@ def build_command(plan: RenderPlan) -> list[str]:
|
||||
|
||||
tail = []
|
||||
if plan.ending_effect:
|
||||
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={plan.fade_duration}")
|
||||
tail.append("format=yuv420p")
|
||||
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
||||
|
||||
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||
# La vidéo (entrée 0) fournit la bande son d'origine si aucune musique n'est choisie
|
||||
original_idx = 0 if uses_original_audio(plan) else None
|
||||
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||
graph += audio_filters
|
||||
if audio_out:
|
||||
audio_map = ["-map", f"[{audio_out}]"]
|
||||
elif plan.keep_original_audio:
|
||||
audio_map = ["-map", "0:a:0"]
|
||||
else:
|
||||
audio_map = []
|
||||
audio_map = ["-map", f"[{audio_out}]"] if audio_out else []
|
||||
|
||||
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
||||
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
||||
@@ -236,21 +257,14 @@ def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
|
||||
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
|
||||
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
|
||||
total = f"{plan.total_duration:.3f}"
|
||||
if not plan.music and not plan.voice:
|
||||
if not plan.keep_original_audio:
|
||||
return None
|
||||
# Son d'origine seul : même niveau final que les autres Reels
|
||||
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
|
||||
if plan.ending_effect:
|
||||
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
|
||||
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
|
||||
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
|
||||
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
|
||||
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 0)
|
||||
original_idx = None
|
||||
if uses_original_audio(plan):
|
||||
audio_args += ["-i", str(plan.video)]
|
||||
original_idx = index
|
||||
graph, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||
if not audio_out:
|
||||
return None
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
||||
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
||||
|
||||
+158
-112
@@ -1,28 +1,39 @@
|
||||
"""Voix calée sur un fichier SRT.
|
||||
|
||||
Chaque sous-titre est lu séparément puis posé à son instant de début. Si la
|
||||
lecture est plus longue que le sous-titre, elle est accélérée (sans changer
|
||||
la hauteur de la voix) pour finir à temps : la voix respecte le minutage du
|
||||
fichier, au lieu d'un texte lu d'une traite.
|
||||
Tout le texte est lu d'une seule traite, puis la voix est découpée phrase par
|
||||
phrase au point le plus calme de chaque pause, et chaque morceau est posé à l'instant de son
|
||||
sous-titre. Une lecture unique garde la même intonation, le même rythme et le
|
||||
même niveau d'un bout à l'autre ; lire chaque sous-titre séparément donnait
|
||||
une voix différente à chaque phrase, et le découpage aux bornes estimées par
|
||||
Whisper rognait des syllabes.
|
||||
|
||||
Si la lecture unique ne peut pas être répartie entre les sous-titres, on lit
|
||||
chaque sous-titre séparément (même voix, même graine), en découpant là aussi
|
||||
au niveau sonore mesuré.
|
||||
|
||||
La voix n'est jamais accélérée : quand un morceau est plus long que son
|
||||
sous-titre, il déborde et les morceaux suivants sont décalés d'autant (le
|
||||
minutage SRT n'est qu'un instant de départ au plus tôt). Les sous-titres
|
||||
affichés suivent la voix et la vidéo s'allonge si besoin.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from . import proc, tts
|
||||
from .align import Word
|
||||
from . import proc, quality, tts
|
||||
from .align import Word, display_tokens
|
||||
from .text import clean_text
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Débordement toléré sur le silence qui suit un sous-titre avant d'accélérer
|
||||
OVERFLOW_TOLERANCE = 0.25
|
||||
# Marges gardées autour des mots lors du retrait des silences de la voix
|
||||
TRIM_LEAD = 0.05
|
||||
TRIM_TAIL = 0.12
|
||||
# Au-delà, l'accélération s'entend nettement : l'utilisateur est prévenu
|
||||
AUDIBLE_SPEEDUP = 1.35
|
||||
# Silence ajouté entre deux morceaux décalés (en plus des marges de chaque morceau)
|
||||
MIN_GAP = 0.05
|
||||
# Au-delà de ce retard cumulé sur le minutage SRT, l'utilisateur est prévenu
|
||||
DRIFT_WARNING = 1.0
|
||||
|
||||
_SENTENCE_END = re.compile(r"[.!?…]$")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,40 +43,63 @@ class Cue:
|
||||
text: str
|
||||
|
||||
|
||||
def cue_windows(cues: list[Cue]) -> list[float]:
|
||||
"""Durée disponible pour lire chaque sous-titre sans empiéter sur le suivant."""
|
||||
windows = []
|
||||
for i, cue in enumerate(cues):
|
||||
limit = cue.end + OVERFLOW_TOLERANCE
|
||||
if i + 1 < len(cues):
|
||||
limit = min(limit, max(cue.end, cues[i + 1].start))
|
||||
windows.append(max(0.1, limit - cue.start))
|
||||
return windows
|
||||
@dataclass
|
||||
class Segment:
|
||||
"""Morceau de la voix lue, à poser à l'instant d'un sous-titre."""
|
||||
|
||||
source_start: float
|
||||
source_end: float
|
||||
words: list[Word]
|
||||
|
||||
|
||||
def speed_factor(duration: float, window: float) -> float:
|
||||
"""Accélération nécessaire pour tenir dans la fenêtre (1 = vitesse normale)."""
|
||||
return max(1.0, duration / window) if window > 0 else 1.0
|
||||
def place_segments(cues: list[Cue], durations: list[float]) -> list[float]:
|
||||
"""Instant de départ de chaque morceau : celui de son sous-titre, ou juste
|
||||
après le morceau précédent s'il déborde (vitesse naturelle conservée)."""
|
||||
starts: list[float] = []
|
||||
previous_end = 0.0
|
||||
for cue, duration in zip(cues, durations):
|
||||
start = cue.start if not starts else max(cue.start, previous_end + MIN_GAP)
|
||||
starts.append(start)
|
||||
previous_end = start + duration
|
||||
return starts
|
||||
|
||||
|
||||
def atempo_chain(factor: float) -> str:
|
||||
"""Filtre atempo ; découpé en étapes ≤ 2 pour les anciennes versions de FFmpeg."""
|
||||
steps = []
|
||||
remaining = factor
|
||||
while remaining > 2.0:
|
||||
steps.append(2.0)
|
||||
remaining /= 2.0
|
||||
steps.append(remaining)
|
||||
return ",".join(f"atempo={s:.4f}" for s in steps)
|
||||
def as_sentence(text: str) -> str:
|
||||
"""Chaque sous-titre finit par une ponctuation : la voix y marque une pause,
|
||||
ce qui laisse un silence où couper."""
|
||||
text = clean_text(text)
|
||||
return text if _SENTENCE_END.search(text) else f"{text}."
|
||||
|
||||
|
||||
def spoken_span(words: list[Word], duration: float) -> tuple[float, float]:
|
||||
"""Partie parlée de la voix : les silences de début et de fin sont retirés."""
|
||||
if not words:
|
||||
return 0.0, duration
|
||||
start = max(0.0, words[0].start - TRIM_LEAD)
|
||||
end = min(duration, words[-1].end + TRIM_TAIL)
|
||||
return (start, end) if end > start else (0.0, duration)
|
||||
def split_by_cue(words: list[Word], cue_texts: list[str]) -> list[list[Word]] | None:
|
||||
"""Répartit les mots de la lecture complète entre les sous-titres
|
||||
(les mots affichés suivent l'ordre du texte). None si les comptes divergent."""
|
||||
counts = [len(display_tokens(t)) for t in cue_texts]
|
||||
if sum(counts) != len(words) or 0 in counts:
|
||||
return None
|
||||
groups, index = [], 0
|
||||
for count in counts:
|
||||
groups.append(words[index : index + count])
|
||||
index += count
|
||||
return groups
|
||||
|
||||
|
||||
def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope) -> list[Segment] | None:
|
||||
"""Découpe la lecture complète entre les phrases, au point le plus calme de
|
||||
chaque pause, puis retire le silence autour de chaque phrase en gardant une
|
||||
marge (300 ms après le dernier son : les fins de phrases ne sont pas rognées)."""
|
||||
cuts = [0.0]
|
||||
for previous, following in zip(groups, groups[1:]):
|
||||
cuts.append(quality.sentence_cut(envelope, previous[-1].end, following[0].start, after=cuts[-1]))
|
||||
cuts.append(envelope.duration)
|
||||
if any(b <= a for a, b in zip(cuts, cuts[1:])):
|
||||
return None
|
||||
|
||||
segments = []
|
||||
for i, group in enumerate(groups):
|
||||
seg_start, seg_end = envelope.speech_span(cuts[i], cuts[i + 1])
|
||||
segments.append(Segment(seg_start, seg_end, group))
|
||||
return segments
|
||||
|
||||
|
||||
async def synthesize_cues(
|
||||
@@ -76,81 +110,102 @@ async def synthesize_cues(
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
qwen_target: tts.qwen.Target | None = None,
|
||||
) -> tts.VoiceTrack:
|
||||
"""Voix complète : mots minutés en secondes depuis le début de la vidéo."""
|
||||
cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start)
|
||||
if not cues:
|
||||
raise ValueError("Aucun sous-titre lisible dans le fichier SRT")
|
||||
|
||||
warnings: list[str] = []
|
||||
windows = cue_windows(cues)
|
||||
segments: list[tuple[Path, float]] = []
|
||||
words: list[Word] = []
|
||||
used_engine, used_voice = engine or "gemini", voice or ""
|
||||
options = dict(
|
||||
engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key, qwen_target=qwen_target
|
||||
)
|
||||
texts = [as_sentence(c.text) for c in cues]
|
||||
|
||||
for index, (cue, window) in enumerate(zip(cues, windows)):
|
||||
cue_dir = workdir / f"cue_{index:03d}"
|
||||
cue_dir.mkdir(exist_ok=True)
|
||||
text = clean_text(cue.text)
|
||||
track = await tts.synthesize(
|
||||
text=text,
|
||||
display_text=text,
|
||||
engine=used_engine,
|
||||
voice=voice,
|
||||
style=style,
|
||||
gemini_api_key=gemini_api_key,
|
||||
workdir=cue_dir,
|
||||
)
|
||||
for warning in track.warnings:
|
||||
if warning not in warnings:
|
||||
warnings.append(warning)
|
||||
# Après un repli sur Edge, on reste sur Edge : une seule voix du début à la fin
|
||||
used_engine, used_voice = track.engine, track.voice
|
||||
# 1. Lecture d'une seule traite, découpée dans les silences
|
||||
script = " ".join(texts)
|
||||
reading = await tts.synthesize(text=script, display_text=script, workdir=workdir, **options)
|
||||
envelope = await quality.load_envelope(reading.path)
|
||||
groups = split_by_cue(reading.words, texts)
|
||||
segments = segments_from_reading(groups, envelope) if groups else None
|
||||
|
||||
span_start, span_end = spoken_span(track.words, track.duration)
|
||||
factor = speed_factor(span_end - span_start, window)
|
||||
if factor > AUDIBLE_SPEEDUP:
|
||||
warnings.append(
|
||||
f"Sous-titre {index + 1} (« {cue.text[:40]} ») lu {factor:.1f}× plus vite "
|
||||
"pour tenir dans son minutage : raccourcissez-le ou allongez sa durée."
|
||||
if segments:
|
||||
sources = [(reading.path, seg) for seg in segments]
|
||||
warnings = list(reading.warnings)
|
||||
used_engine, used_voice = reading.engine, reading.voice
|
||||
else:
|
||||
# 2. Repli : chaque sous-titre lu séparément, avec la voix de la lecture complète
|
||||
log.warning("Lecture SRT non répartie entre les sous-titres : lecture phrase par phrase")
|
||||
sources, warnings = [], list(reading.warnings)
|
||||
used_engine, used_voice = reading.engine, reading.voice
|
||||
for index, text in enumerate(texts):
|
||||
cue_dir = workdir / f"cue_{index:03d}"
|
||||
cue_dir.mkdir(exist_ok=True)
|
||||
track = await tts.synthesize(
|
||||
text=text,
|
||||
display_text=text,
|
||||
workdir=cue_dir,
|
||||
**{**options, "engine": used_engine, "voice": used_voice or voice},
|
||||
)
|
||||
warnings += [w for w in track.warnings if w not in warnings]
|
||||
cue_envelope = await quality.load_envelope(track.path)
|
||||
span = cue_envelope.speech_span(0.0, cue_envelope.duration)
|
||||
sources.append((track.path, Segment(span[0], span[1], track.words)))
|
||||
|
||||
fitted = cue_dir / "fitted.wav"
|
||||
trim = f"atrim=start={span_start:.3f}:end={span_end:.3f}"
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(track.path),
|
||||
"-af",
|
||||
f"{trim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(fitted),
|
||||
],
|
||||
timeout=120,
|
||||
durations = [segment.source_end - segment.source_start for _, segment in sources]
|
||||
starts = place_segments(cues, durations)
|
||||
placed: list[tuple[Path, float]] = []
|
||||
words: list[Word] = []
|
||||
for index, (cue, start, (source, segment)) in enumerate(zip(cues, starts, sources)):
|
||||
fitted = workdir / f"segment_{index:03d}.wav"
|
||||
await proc.run(extract_command(source, segment, fitted), timeout=120)
|
||||
placed.append((fitted, start))
|
||||
added_period = not _SENTENCE_END.search(clean_text(cue.text))
|
||||
for position, w in enumerate(segment.words):
|
||||
text = w.text
|
||||
if added_period and position == len(segment.words) - 1:
|
||||
text = text.rstrip(".") # point ajouté pour la lecture, pas à afficher
|
||||
word_start = start + max(0.0, w.start - segment.source_start)
|
||||
word_end = start + max(0.0, w.end - segment.source_start)
|
||||
words.append(Word(text or w.text, word_start, max(word_end, word_start + 0.05)))
|
||||
|
||||
drift = max((s - c.start for s, c in zip(starts, cues)), default=0.0)
|
||||
if drift > DRIFT_WARNING:
|
||||
warnings.append(
|
||||
f"Le texte lu dépasse le minutage du SRT : les derniers sous-titres sont décalés "
|
||||
f"de {drift:.1f} s (voix gardée à vitesse normale). Raccourcissez le texte "
|
||||
"ou allongez les durées pour rester calé."
|
||||
)
|
||||
segments.append((fitted, cue.start))
|
||||
for w in track.words:
|
||||
start = cue.start + max(0.0, w.start - span_start) / factor
|
||||
end = cue.start + max(0.0, w.end - span_start) / factor
|
||||
words.append(Word(w.text, start, min(max(end, start + 0.05), cue.start + window)))
|
||||
|
||||
output = workdir / "voice.wav"
|
||||
await proc.run(mix_command(segments, output), timeout=300)
|
||||
output = workdir / "voice_srt.wav"
|
||||
await proc.run(mix_command(placed, output), timeout=300)
|
||||
duration = (await proc.probe(output)).duration
|
||||
log.info("Voix SRT prête : %s/%s, %d sous-titres, %.1f s", used_engine, used_voice, len(cues), duration)
|
||||
log.info(
|
||||
"Voix SRT prête : %s/%s, %d sous-titres, %.1f s (%s)",
|
||||
used_engine,
|
||||
used_voice,
|
||||
len(cues),
|
||||
duration,
|
||||
"lecture unique" if segments else "phrase par phrase",
|
||||
)
|
||||
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
def extract_command(source: Path, segment: Segment, output: Path) -> list[str]:
|
||||
"""Extrait un morceau de voix, à vitesse normale, avec un fondu de 10 ms
|
||||
aux bords (pas de clic)."""
|
||||
length = segment.source_end - segment.source_start
|
||||
fade_out = max(0.0, length - 0.01)
|
||||
filters = (
|
||||
f"atrim=start={segment.source_start:.3f}:end={segment.source_end:.3f},asetpts=PTS-STARTPTS,"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01"
|
||||
)
|
||||
return [
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(source),
|
||||
"-af", filters, "-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def mix_command(segments: list[tuple[Path, float]], output: Path) -> list[str]:
|
||||
"""Pose chaque segment de voix à son instant, sur une seule piste."""
|
||||
command = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
|
||||
@@ -162,18 +217,9 @@ def mix_command(segments: list[tuple[Path, float]], output: Path) -> list[str]:
|
||||
f"{labels}amix=inputs={len(segments)}:duration=longest:dropout_transition=0:normalize=0[voice]"
|
||||
)
|
||||
return command + [
|
||||
"-filter_complex",
|
||||
";".join(graph),
|
||||
"-map",
|
||||
"[voice]",
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
"48000",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
str(output),
|
||||
]
|
||||
"-filter_complex", ";".join(graph), "-map", "[voice]",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(output),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _monotonic(words: list[Word]) -> list[Word]:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Synthèse vocale : moteur au choix, repli sur Edge, voix traitée et mots minutés."""
|
||||
"""Synthèse vocale : moteur au choix (Qwen local, Gemini, Edge), replis
|
||||
successifs, voix traitée et mots minutés."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .. import align, audio, proc
|
||||
from .. import align, audio, config, proc, quality
|
||||
from ..align import Word
|
||||
from . import edge, gemini
|
||||
from . import edge, gemini, qwen
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,34 +32,160 @@ async def synthesize(
|
||||
style: str | None,
|
||||
gemini_api_key: str | None,
|
||||
workdir: Path,
|
||||
qwen_target: qwen.Target | None = None,
|
||||
) -> VoiceTrack:
|
||||
warnings: list[str] = []
|
||||
raw: Path | None = None
|
||||
processed: Path | None = None
|
||||
spoken: list[Word] = []
|
||||
used_engine, used_voice = "edge", ""
|
||||
engine = engine or "gemini"
|
||||
|
||||
if (engine or "gemini") == "gemini":
|
||||
if engine == "qwen":
|
||||
result = await _checked(
|
||||
"Qwen",
|
||||
lambda attempt: qwen.synthesize(text, voice, style, qwen_target, workdir, attempt),
|
||||
config.QWEN_TTS_ATTEMPTS,
|
||||
text,
|
||||
workdir,
|
||||
warnings,
|
||||
)
|
||||
if result:
|
||||
processed, spoken, used_voice, reading_ok = result
|
||||
used_engine = "qwen"
|
||||
if reading_ok:
|
||||
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
|
||||
elif gemini_api_key:
|
||||
engine = "gemini" # repli sur Gemini quand une clé est disponible
|
||||
|
||||
if engine == "gemini" and processed is None:
|
||||
if not gemini_api_key:
|
||||
warnings.append("Clé Gemini absente : voix Edge utilisée à la place.")
|
||||
else:
|
||||
try:
|
||||
raw, used_voice = await gemini.synthesize(text, voice, style, gemini_api_key, workdir)
|
||||
result = await _checked(
|
||||
"Gemini",
|
||||
lambda attempt: gemini.synthesize(text, voice, style, gemini_api_key, workdir, attempt),
|
||||
config.GEMINI_TTS_ATTEMPTS,
|
||||
text,
|
||||
workdir,
|
||||
warnings,
|
||||
)
|
||||
if result:
|
||||
processed, spoken, used_voice, reading_ok = result
|
||||
used_engine = "gemini"
|
||||
except Exception as error: # noqa: BLE001 — repli sur Edge
|
||||
log.warning("Gemini TTS en échec, repli sur Edge : %s", error)
|
||||
warnings.append(f"Gemini indisponible ({error}) : voix Edge utilisée à la place.")
|
||||
if reading_ok:
|
||||
processed, spoken = await _trim_stray_sounds(processed, spoken, workdir)
|
||||
|
||||
if raw is None:
|
||||
if processed is None:
|
||||
raw, spoken, used_voice = await edge.synthesize(text, voice, style, workdir)
|
||||
processed = workdir / "voice.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
|
||||
processed = workdir / "voice.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
duration = (await proc.probe(processed)).duration
|
||||
|
||||
if not spoken:
|
||||
# Gemini ne donne pas le minutage : Whisper le retrouve dans l'audio
|
||||
spoken = await align.transcribe(processed, hint=text)
|
||||
spoken = await align.transcribe(processed)
|
||||
|
||||
words = align.align_words(display_text, spoken, duration)
|
||||
log.info("Voix prête : %s/%s, %.1f s, %d mots", used_engine, used_voice, duration, len(words))
|
||||
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
|
||||
|
||||
|
||||
# En dessous, une lecture imparfaite est écartée au profit du moteur suivant.
|
||||
# Au-dessus, on garde la voix choisie (avec un avertissement) : changer de moteur
|
||||
# pour un mot douteux donnait une voix différente d'une phrase à l'autre.
|
||||
KEEP_IMPERFECT_COVERAGE = 0.7
|
||||
|
||||
|
||||
def is_runaway(duration: float, text: str) -> bool:
|
||||
"""Prise emballée : le modèle a continué à produire du son bien après la fin
|
||||
du texte (vu avec Qwen : 14 s pour une phrase de 34 caractères)."""
|
||||
return duration > max(3.0, len(text) / 6) + 1.5
|
||||
|
||||
|
||||
async def _checked(
|
||||
name: str,
|
||||
generate: Callable[[int], Awaitable[tuple[Path, str]]],
|
||||
attempts: int,
|
||||
text: str,
|
||||
workdir: Path,
|
||||
warnings: list[str],
|
||||
) -> tuple[Path, list[Word], str, bool] | None:
|
||||
"""Voix dont la lecture a été vérifiée : Whisper réécoute chaque
|
||||
génération ; mots sautés, fin tronquée ou consigne lue à voix haute
|
||||
déclenchent une nouvelle génération. Renvoie None pour passer au moteur suivant."""
|
||||
best: tuple[quality.ReadingCheck, Path, list[Word], str] | None = None
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(max(1, attempts)):
|
||||
try:
|
||||
raw, engine_voice = await generate(attempt)
|
||||
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis moteur suivant
|
||||
log.warning("%s TTS en échec (tentative %d) : %r", name, attempt + 1, error)
|
||||
last_error = error
|
||||
continue
|
||||
|
||||
processed = workdir / f"voice_{name.lower()}_{attempt}.wav"
|
||||
await audio.process_voice(raw, processed)
|
||||
duration = (await proc.probe(processed)).duration
|
||||
if is_runaway(duration, text):
|
||||
log.warning("%s : prise emballée (%.1f s pour %d car.), écartée", name, duration, len(text))
|
||||
last_error = RuntimeError(f"voix de {duration:.0f} s pour {len(text)} caractères")
|
||||
continue
|
||||
spoken = await align.transcribe(processed)
|
||||
check = quality.check_reading(text, spoken)
|
||||
log.info("Lecture %s (tentative %d) : %s", name, attempt + 1, check.describe())
|
||||
|
||||
if best is None or _score(check) > _score(best[0]):
|
||||
best = (check, processed, spoken, engine_voice)
|
||||
if check.acceptable:
|
||||
break
|
||||
|
||||
if best is None:
|
||||
warnings.append(f"{name} : aucune voix utilisable ({last_error!r}) : voix de secours utilisée.")
|
||||
return None
|
||||
|
||||
check, processed, spoken, engine_voice = best
|
||||
if not check.acceptable:
|
||||
if check.coverage < KEEP_IMPERFECT_COVERAGE:
|
||||
warnings.append(f"{name} a mal lu le texte ({check.describe()}) : voix de secours utilisée.")
|
||||
return None
|
||||
warnings.append(f"Lecture {name} imparfaite ({check.describe()}) : écoutez le résultat.")
|
||||
return processed, spoken, engine_voice, check.acceptable
|
||||
|
||||
|
||||
# Au-delà de la fin du dernier mot, ce n'est plus de la parole
|
||||
STRAY_AFTER_LAST_WORD = 0.6
|
||||
STRAY_BEFORE_FIRST_WORD = 0.4
|
||||
|
||||
|
||||
async def _trim_stray_sounds(path: Path, spoken: list[Word], workdir: Path) -> tuple[Path, list[Word]]:
|
||||
"""Retire les bruits que le modèle ajoute parfois avant le premier ou après le
|
||||
dernier mot. Appelé seulement quand la lecture a été vérifiée : le dernier
|
||||
mot est bien reconnu, on ne coupe donc jamais de parole."""
|
||||
if not spoken:
|
||||
return path, spoken
|
||||
envelope = await quality.load_envelope(path)
|
||||
duration = envelope.duration
|
||||
window_start = max(0.0, spoken[0].start - STRAY_BEFORE_FIRST_WORD)
|
||||
window_end = min(duration, spoken[-1].end + STRAY_AFTER_LAST_WORD)
|
||||
start, end = envelope.speech_span(window_start, window_end)
|
||||
if start < 0.02 and end > duration - 0.02:
|
||||
return path, spoken
|
||||
|
||||
trimmed = workdir / "voice_trimmed.wav"
|
||||
fade_out = max(0.0, end - start - 0.01)
|
||||
await proc.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(path),
|
||||
"-af", f"atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS,"
|
||||
f"afade=t=in:d=0.01,afade=t=out:st={fade_out:.3f}:d=0.01",
|
||||
"-ac", "1", "-ar", "48000", "-c:a", "pcm_s16le", str(trimmed),
|
||||
],
|
||||
timeout=60,
|
||||
) # fmt: skip
|
||||
log.info("Voix recadrée sur la parole : %.2f–%.2f s (sur %.2f s)", start, end, duration)
|
||||
shifted = [Word(w.text, w.start - start, w.end - start) for w in spoken if w.start < end]
|
||||
return trimmed, shifted
|
||||
|
||||
|
||||
def _score(check: quality.ReadingCheck) -> float:
|
||||
return check.coverage - check.extra + (0.2 if check.ending_ok else 0.0)
|
||||
@@ -13,8 +13,9 @@ log = logging.getLogger(__name__)
|
||||
|
||||
# Réglages de lecture par style (Edge n'interprète pas de consigne en texte)
|
||||
STYLE_PROSODY = {
|
||||
"dynamic": ("+8%", "+2Hz"),
|
||||
"promo": ("+10%", "+3Hz"),
|
||||
# Débit à peine relevé : au-delà, la voix devient difficile à suivre
|
||||
"dynamic": ("+0%", "+2Hz"),
|
||||
"promo": ("+3%", "+3Hz"),
|
||||
"calm": ("-8%", "-2Hz"),
|
||||
"warm": ("-2%", "+0Hz"),
|
||||
}
|
||||
|
||||
@@ -21,47 +21,68 @@ class GeminiError(RuntimeError):
|
||||
|
||||
|
||||
def build_prompt(text: str, style: str | None) -> str:
|
||||
"""Texte envoyé au modèle : la consigne de style précède le texte à lire."""
|
||||
"""Texte envoyé au modèle : la consigne de style précède le texte à lire.
|
||||
La consigne demande une lecture intégrale, sans ajout : le modèle sautait
|
||||
parfois des mots ou tronquait la fin."""
|
||||
instruction = style_instruction(style)
|
||||
return f"{instruction} :\n{text}" if instruction else text
|
||||
if not instruction:
|
||||
return text
|
||||
return f"{instruction}. Lis tout le texte, mot pour mot, sans rien ajouter :\n{text}"
|
||||
|
||||
|
||||
async def _request(model: str, prompt: str, voice: str, api_key: str) -> dict:
|
||||
payload = {
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
|
||||
},
|
||||
# Paramètres de tirage refusés par un modèle : on ne les renvoie plus
|
||||
_unsupported_sampling: set[str] = set()
|
||||
|
||||
|
||||
async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int) -> dict:
|
||||
generation: dict = {
|
||||
"responseModalities": ["AUDIO"],
|
||||
"speechConfig": {"voiceConfig": {"prebuiltVoiceConfig": {"voiceName": voice}}},
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
if model not in _unsupported_sampling:
|
||||
# Intonation stable d'une génération à l'autre
|
||||
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
|
||||
generation["seed"] = seed
|
||||
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
|
||||
# 45 s : une génération normale prend 2 à 5 s ; au-delà, Gemini ne répondra plus
|
||||
async with httpx.AsyncClient(timeout=45) as client:
|
||||
# Clé en en-tête : dans l'URL, elle finissait dans les journaux
|
||||
response = await client.post(
|
||||
f"{API_ROOT}/{model}:generateContent",
|
||||
json=payload,
|
||||
headers={"x-goog-api-key": api_key},
|
||||
)
|
||||
rejected_sampling = response.status_code == 400 and any(
|
||||
word in response.text.lower() for word in ("seed", "temperature", "unknown name", "invalid json")
|
||||
)
|
||||
if rejected_sampling and "temperature" in generation:
|
||||
# Modèle qui refuse température ou graine : même requête sans eux
|
||||
log.warning("Gemini %s refuse les paramètres de tirage : %s", model, response.text[:200])
|
||||
_unsupported_sampling.add(model)
|
||||
return await _request(model, prompt, voice, api_key, seed)
|
||||
if response.status_code != 200:
|
||||
raise GeminiError(f"Gemini {model} : HTTP {response.status_code} {response.text[:300]}")
|
||||
return response.json()
|
||||
|
||||
|
||||
async def synthesize(
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path
|
||||
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, attempt: int = 0
|
||||
) -> tuple[Path, str]:
|
||||
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée."""
|
||||
"""Génère la voix ; renvoie un WAV brut et le nom de la voix utilisée.
|
||||
Chaque nouvelle tentative change de graine pour obtenir une autre lecture."""
|
||||
seed = config.GEMINI_TTS_SEED + attempt
|
||||
gemini_voice = resolve_gemini_voice(voice).id
|
||||
prompt = build_prompt(text, style)
|
||||
model = config.GEMINI_TTS_MODEL
|
||||
log.info("Gemini TTS : modèle=%s voix=%s style=%s (%d car.)", model, gemini_voice, style, len(text))
|
||||
|
||||
try:
|
||||
data = await _request(model, prompt, gemini_voice, api_key)
|
||||
data = await _request(model, prompt, gemini_voice, api_key, seed)
|
||||
except GeminiError as error:
|
||||
if model == FALLBACK_MODEL or "HTTP 404" not in str(error):
|
||||
raise
|
||||
log.warning("Modèle %s indisponible, repli sur %s", model, FALLBACK_MODEL)
|
||||
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key)
|
||||
data = await _request(FALLBACK_MODEL, prompt, gemini_voice, api_key, seed)
|
||||
|
||||
try:
|
||||
part = next(p for p in data["candidates"][0]["content"]["parts"] if "inlineData" in p)["inlineData"]
|
||||
@@ -70,14 +91,14 @@ async def synthesize(
|
||||
|
||||
audio = base64.b64decode(part["data"])
|
||||
mime = part.get("mimeType", "")
|
||||
raw = workdir / "gemini_raw.wav"
|
||||
raw = workdir / f"gemini_raw_{attempt}.wav"
|
||||
|
||||
if "wav" in mime:
|
||||
raw.write_bytes(audio)
|
||||
else:
|
||||
# PCM brut 16 bits (« audio/L16;codec=pcm;rate=24000 »)
|
||||
rate_match = re.search(r"rate=(\d+)", mime)
|
||||
pcm = workdir / "gemini.pcm"
|
||||
pcm = workdir / f"gemini_{attempt}.pcm"
|
||||
pcm.write_bytes(audio)
|
||||
await proc.run(
|
||||
[
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Synthèse vocale Qwen3-TTS (service qwen-tts, local ou sur un serveur GPU)."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from .. import config
|
||||
from ..voices import qwen_instruction, resolve_qwen_voice
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QwenError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
"""Service Qwen à appeler : réglé dans les paramètres de l'application,
|
||||
sinon par les variables d'environnement."""
|
||||
|
||||
url: str
|
||||
api_key: str = ""
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, url: str | None = None, api_key: str | None = None) -> "Target | None":
|
||||
if url and url.strip():
|
||||
return cls(url.strip().rstrip("/"), (api_key or "").strip())
|
||||
if config.QWEN_TTS_URL:
|
||||
return cls(config.QWEN_TTS_URL, config.QWEN_TTS_API_KEY)
|
||||
return None
|
||||
|
||||
@property
|
||||
def headers(self) -> dict[str, str]:
|
||||
return {"x-api-key": self.api_key} if self.api_key else {}
|
||||
|
||||
|
||||
async def synthesize(
|
||||
text: str, voice: str | None, style: str | None, target: Target | None, workdir: Path, attempt: int = 0
|
||||
) -> tuple[Path, str]:
|
||||
"""Génère la voix ; renvoie un WAV brut et la voix utilisée."""
|
||||
if target is None:
|
||||
raise QwenError("service Qwen TTS non configuré (Paramètres → Qwen TTS)")
|
||||
qwen_voice = resolve_qwen_voice(voice)
|
||||
payload = {"text": text, "voice": qwen_voice, "instruct": qwen_instruction(style), "language": "French"}
|
||||
log.info("Qwen TTS (%s) : voix=%s style=%s (%d car.)", target.url, qwen_voice, style, len(text))
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client:
|
||||
response = await client.post(f"{target.url}/synthesize", json=payload, headers=target.headers)
|
||||
except httpx.HTTPError as error:
|
||||
raise QwenError(f"service Qwen TTS injoignable : {error!r}") from error
|
||||
if response.status_code != 200:
|
||||
raise QwenError(f"Qwen TTS : HTTP {response.status_code} {response.text[:300]}")
|
||||
raw = workdir / f"qwen_raw_{attempt}.wav"
|
||||
raw.write_bytes(response.content)
|
||||
return raw, response.headers.get("x-voice", qwen_voice)
|
||||
|
||||
|
||||
async def status(target: Target | None) -> dict | None:
|
||||
"""État du service (modèle, carte graphique) et voix proposées, clonées
|
||||
comprises ; None s'il n'est pas configuré ou ne répond pas."""
|
||||
if target is None:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
health = await client.get(f"{target.url}/health", headers=target.headers)
|
||||
health.raise_for_status()
|
||||
listed = await client.get(f"{target.url}/voices", headers=target.headers)
|
||||
listed.raise_for_status()
|
||||
return {**health.json(), "voices": listed.json()["voices"]}
|
||||
except Exception as error: # noqa: BLE001 — moteur simplement signalé indisponible
|
||||
log.warning("Service Qwen TTS %s indisponible : %s", target.url, error)
|
||||
return None
|
||||
@@ -52,13 +52,27 @@ EDGE_VOICES: tuple[Voice, ...] = (
|
||||
Voice("fr-FR-HenriNeural", "Henri", "male"),
|
||||
)
|
||||
|
||||
# Voix prédéfinies de Qwen3-TTS (service local). Les voix clonées
|
||||
# (« clone:nom ») sont listées par le service lui-même.
|
||||
QWEN_VOICES: tuple[Voice, ...] = (
|
||||
Voice("serena", "Serena — douce et chaleureuse", "female"),
|
||||
Voice("vivian", "Vivian — vive et lumineuse", "female"),
|
||||
Voice("sohee", "Sohee — expressive", "female"),
|
||||
Voice("ono_anna", "Anna — espiègle", "female"),
|
||||
Voice("aiden", "Aiden — solaire", "male"),
|
||||
Voice("ryan", "Ryan — dynamique", "male"),
|
||||
Voice("uncle_fu", "Fu — grave et posée", "male"),
|
||||
Voice("dylan", "Dylan — jeune et naturelle", "male"),
|
||||
Voice("eric", "Eric — légèrement voilée", "male"),
|
||||
)
|
||||
|
||||
# Consignes de lecture ajoutées au texte envoyé à Gemini.
|
||||
STYLES: dict[str, tuple[str, str]] = {
|
||||
"neutral": ("Neutre", ""),
|
||||
"dynamic": (
|
||||
"Dynamique",
|
||||
"Lis ce texte en français sur un ton dynamique et enthousiaste, avec un rythme "
|
||||
"entraînant, comme une vidéo courte sur les réseaux sociaux",
|
||||
"Lis ce texte en français sur un ton dynamique et enthousiaste, comme une vidéo "
|
||||
"courte sur les réseaux sociaux, à un débit naturel, sans accélérer, en articulant bien",
|
||||
),
|
||||
"warm": (
|
||||
"Chaleureux",
|
||||
@@ -71,12 +85,25 @@ STYLES: dict[str, tuple[str, str]] = {
|
||||
"promo": (
|
||||
"Promo",
|
||||
"Lis ce texte en français comme une annonce promotionnelle énergique, en "
|
||||
"insistant sur les offres et les prix",
|
||||
"insistant sur les offres et les prix, sans accélérer, en articulant bien",
|
||||
),
|
||||
}
|
||||
|
||||
_GEMINI_BY_ID = {v.id.lower(): v for v in GEMINI_VOICES}
|
||||
_EDGE_BY_ID = {v.id: v for v in EDGE_VOICES}
|
||||
_QWEN_BY_ID = {v.id: v for v in QWEN_VOICES}
|
||||
|
||||
# Consignes de ton pour Qwen (le modèle comprend l'anglais mieux que le français)
|
||||
QWEN_STYLES: dict[str, str] = {
|
||||
"neutral": "Speak natural, native French with clear articulation and a relaxed, natural pace.",
|
||||
"dynamic": "Speak native French in an upbeat, enthusiastic and smiling tone, like a social media video, "
|
||||
"at a natural pace without rushing, clearly articulated.",
|
||||
"warm": "Speak native French in a warm, friendly and caring tone, as if advising a friend, "
|
||||
"at a relaxed pace.",
|
||||
"calm": "Speak native French in a calm, soothing and reassuring tone, slowly and clearly.",
|
||||
"promo": "Speak native French like an energetic promotional announcement, emphasizing offers and prices, "
|
||||
"at a natural pace without rushing, clearly articulated.",
|
||||
}
|
||||
|
||||
|
||||
def resolve_gemini_voice(voice: str | None) -> Voice:
|
||||
@@ -87,7 +114,7 @@ def resolve_gemini_voice(voice: str | None) -> Voice:
|
||||
"""
|
||||
if voice and voice.lower() in _GEMINI_BY_ID:
|
||||
return _GEMINI_BY_ID[voice.lower()]
|
||||
if voice and voice.endswith(("-B", "-D")) or voice == "male":
|
||||
if voice and voice.endswith(("-B", "-D")) or voice_gender(voice) == "male":
|
||||
return _GEMINI_BY_ID["charon"]
|
||||
return _GEMINI_BY_ID["kore"]
|
||||
|
||||
@@ -100,6 +127,8 @@ def voice_gender(voice: str | None) -> str:
|
||||
return _GEMINI_BY_ID[voice.lower()].gender
|
||||
if voice in _EDGE_BY_ID:
|
||||
return _EDGE_BY_ID[voice].gender
|
||||
if voice.lower() in _QWEN_BY_ID:
|
||||
return _QWEN_BY_ID[voice.lower()].gender
|
||||
if voice == "male" or voice.endswith(("-B", "-D")):
|
||||
return "male"
|
||||
return "male" if any(name in voice for name in ("Remy", "Henri", "Paul")) else "female"
|
||||
@@ -113,6 +142,20 @@ def resolve_edge_voice(voice: str | None) -> Voice:
|
||||
return next(v for v in EDGE_VOICES if v.gender == gender)
|
||||
|
||||
|
||||
def resolve_qwen_voice(voice: str | None) -> str:
|
||||
"""Voix Qwen à utiliser : voix clonée ou prédéfinie ; une voix d'un autre
|
||||
moteur est remplacée par une voix Qwen du même genre."""
|
||||
if voice and voice.startswith("clone:"):
|
||||
return voice
|
||||
if voice and voice.lower() in _QWEN_BY_ID:
|
||||
return voice.lower()
|
||||
return "aiden" if voice_gender(voice) == "male" else "serena"
|
||||
|
||||
|
||||
def qwen_instruction(style: str | None) -> str:
|
||||
return QWEN_STYLES.get(style or "neutral", QWEN_STYLES["neutral"])
|
||||
|
||||
|
||||
def edge_fallbacks(primary: Voice) -> list[Voice]:
|
||||
"""Voix françaises de secours du même genre (jamais d'anglais)."""
|
||||
same = [v for v in EDGE_VOICES if v.gender == primary.gender and v != primary]
|
||||
@@ -123,8 +166,11 @@ def style_instruction(style: str | None) -> str:
|
||||
return STYLES.get(style or "neutral", STYLES["neutral"])[1]
|
||||
|
||||
|
||||
def catalog() -> dict:
|
||||
def catalog(qwen_voices: list[dict] | None = None) -> dict:
|
||||
"""Voix par moteur ; `qwen_voices` vient du service Qwen (None s'il ne répond pas)."""
|
||||
return {
|
||||
"qwen": qwen_voices or [v.__dict__ for v in QWEN_VOICES],
|
||||
"qwen_available": qwen_voices is not None,
|
||||
"gemini": [v.__dict__ for v in GEMINI_VOICES],
|
||||
"edge": [v.__dict__ for v in EDGE_VOICES],
|
||||
"styles": [{"id": k, "label": v[0]} for k, v in STYLES.items()],
|
||||
|
||||
@@ -5,3 +5,4 @@ httpx==0.28.1
|
||||
edge-tts==7.2.8
|
||||
emoji==2.14.1
|
||||
faster-whisper==1.2.1
|
||||
numpy>=1.26
|
||||
@@ -0,0 +1,120 @@
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word
|
||||
from app.quality import FRAME, Envelope, check_reading, envelope_from_samples, sentence_cut
|
||||
|
||||
|
||||
def _heard(text: str) -> list[Word]:
|
||||
return [Word(t, i * 0.3, i * 0.3 + 0.25) for i, t in enumerate(text.split())]
|
||||
|
||||
|
||||
def test_complete_reading_is_accepted():
|
||||
check = check_reading(
|
||||
"Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés en magasin")
|
||||
)
|
||||
assert check.acceptable and check.coverage == 1.0
|
||||
|
||||
|
||||
def test_truncated_ending_is_rejected():
|
||||
check = check_reading("Découvrez nos nouveautés en magasin", _heard("découvrez nos nouveautés"))
|
||||
assert not check.ending_ok and not check.acceptable
|
||||
|
||||
|
||||
def test_instruction_read_aloud_is_rejected():
|
||||
heard = _heard("lis ce texte sur un ton dynamique découvrez nos nouveautés en magasin")
|
||||
assert not check_reading("Découvrez nos nouveautés en magasin", heard).acceptable
|
||||
|
||||
|
||||
def _envelope(levels):
|
||||
"""Enveloppe synthétique : liste de (durée en s, niveau en dB)."""
|
||||
db = []
|
||||
for seconds, level in levels:
|
||||
db += [level] * int(round(seconds / FRAME))
|
||||
return Envelope(np.array(db, dtype=float))
|
||||
|
||||
|
||||
def test_soft_sentence_endings_are_kept():
|
||||
# Parole à -20 dB, fin de phrase douce à -50 dB (relatif : -30 dB, audible),
|
||||
# puis vrai silence à -90 dB
|
||||
env = _envelope([(0.2, -90), (1.0, -20), (0.3, -50), (0.6, -90)])
|
||||
start, end = env.speech_span(0.0, env.duration)
|
||||
assert round(start, 2) == 0.12 # 80 ms avant la parole
|
||||
assert round(end, 2) == 1.8 # 300 ms après la fin douce (à 1,5 s)
|
||||
|
||||
|
||||
def test_cut_falls_in_the_quietest_part_of_the_pause():
|
||||
env = _envelope([(1.0, -20), (0.15, -60), (0.2, -85), (0.15, -60), (1.0, -20)])
|
||||
cut = sentence_cut(env, previous_end=0.95, next_start=1.55)
|
||||
assert 1.15 <= cut <= 1.35
|
||||
|
||||
|
||||
def test_envelope_from_samples_measures_level():
|
||||
rate = 16000
|
||||
samples = np.concatenate([np.zeros(rate // 2), 0.5 * np.ones(rate // 2)]).astype(np.float32)
|
||||
env = envelope_from_samples(samples, rate)
|
||||
assert env.db[0] < -100 and abs(env.db[-1] - 20 * np.log10(0.5)) < 0.1
|
||||
|
||||
|
||||
def test_small_recognition_errors_are_not_missing_words():
|
||||
expected = "Découvrez nos plantes de saison à petits prix. On vous attend ce week-end."
|
||||
heard = _heard("Découvrez nos plans de saison à petit prix. On vous attend ce week -end.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
|
||||
|
||||
def test_skipped_words_are_detected_and_named():
|
||||
expected = (
|
||||
"Pour Halloween découvrez nos costumes nos bonbons et toute notre décoration effrayante en magasin."
|
||||
)
|
||||
heard = _heard("Pour Halloween découvrez nos costumes et toute notre décoration en magasin.")
|
||||
check = check_reading(expected, heard)
|
||||
assert not check.acceptable
|
||||
assert "bonbons" in check.missing and "effrayante" in check.missing
|
||||
assert "non entendus" in check.describe()
|
||||
|
||||
|
||||
def test_prices_and_units_written_differently_are_not_missing_words():
|
||||
expected = "Alerte léopard ! Le coussin de 40 centimètres à 11,99 €. Le cabas à 13,99 €."
|
||||
heard = _heard("Alerte léopard ! Le coussin de 40 cm à 11 € 99. Le cabas à 13,99 euros.")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
assert check.missing == []
|
||||
|
||||
|
||||
def test_skipped_word_next_to_a_price_is_still_detected():
|
||||
expected = "Le coussin léopard à 11,99 €. Le cabas doux et chaud à 13,99 €."
|
||||
heard = _heard("Le coussin à 11,99 €. Le cabas à 13,99 €.")
|
||||
check = check_reading(expected, heard)
|
||||
assert not check.acceptable
|
||||
assert "léopard" in check.missing
|
||||
|
||||
|
||||
def test_cut_uses_the_real_pause_when_word_timing_is_estimated():
|
||||
# Phrase 1 jusqu'à 2,0 s (dont un prix non reconnu), virgule à 1,0 s,
|
||||
# pause de fin de phrase 2,0–2,4 s. Whisper place la fin de la phrase
|
||||
# et le début de la suivante vers 2,9 s (mots interpolés).
|
||||
env = _envelope([(0.3, -90), (0.7, -20), (0.1, -90), (0.9, -20), (0.4, -90), (1.5, -20), (0.3, -90)])
|
||||
cut = sentence_cut(env, previous_end=2.9, next_start=2.95)
|
||||
assert 2.0 <= cut <= 2.4
|
||||
|
||||
|
||||
def test_cut_prefers_the_sentence_pause_over_a_comma():
|
||||
env = _envelope([(0.3, -90), (1.0, -20), (0.12, -90), (0.8, -20), (0.45, -90), (1.0, -20), (0.3, -90)])
|
||||
# Borne estimée entre la virgule (1,3 s) et la fin de phrase (2,22 s)
|
||||
cut = sentence_cut(env, previous_end=1.7, next_start=1.75)
|
||||
assert 2.22 <= cut <= 2.67
|
||||
|
||||
|
||||
def test_numbers_written_in_letters_are_not_missing_words():
|
||||
expected = "Plus trente secondes chrono, le compteur s'affole : moins vingt-cinq pour cent !"
|
||||
heard = _heard("+30 secondes chrono, le compteur s'affole : -25% !")
|
||||
check = check_reading(expected, heard)
|
||||
assert check.acceptable, check
|
||||
assert check.missing == []
|
||||
|
||||
|
||||
def test_skipped_word_next_to_a_number_in_letters_is_still_detected():
|
||||
expected = "Trente secondes chrono, le compteur s'affole vraiment !"
|
||||
heard = _heard("30 secondes, le compteur s'affole vraiment !")
|
||||
check = check_reading(expected, heard)
|
||||
assert "chrono" in check.missing
|
||||
@@ -51,7 +51,50 @@ def test_original_audio_kept_when_nothing_added():
|
||||
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
|
||||
)
|
||||
cmd = build_command(plan)
|
||||
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "0:a:0"
|
||||
assert "[0:a:0]aresample=48000,apad[bed]" in _graph(cmd)
|
||||
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "[aout]"
|
||||
|
||||
|
||||
def test_original_audio_stays_under_the_voice_when_no_music_is_chosen():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=4.0,
|
||||
keep_original_audio=True,
|
||||
)
|
||||
graph = _graph(build_command(plan))
|
||||
assert "[0:a:0]aresample=48000,apad[bed]" in graph
|
||||
assert "[bed][vkey]sidechaincompress" in graph
|
||||
|
||||
|
||||
def test_chosen_music_replaces_the_original_audio():
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
music=Path("m.mp3"),
|
||||
keep_original_audio=True,
|
||||
)
|
||||
assert "0:a:0" not in _graph(build_command(plan))
|
||||
|
||||
|
||||
def test_prepared_audio_mix_keeps_original_audio_under_voice():
|
||||
from app.render import build_audio_mix_command
|
||||
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=10.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=4.0,
|
||||
keep_original_audio=True,
|
||||
)
|
||||
cmd = build_audio_mix_command(plan, Path("a.wav"))
|
||||
# Entrées : voix (0) puis vidéo d'origine (1)
|
||||
assert cmd[cmd.index("in.mp4") - 1] == "-i"
|
||||
assert "[1:a:0]aresample=48000,apad[bed]" in " ".join(cmd)
|
||||
|
||||
|
||||
def test_encoding_targets_social_networks():
|
||||
@@ -111,3 +154,18 @@ def test_audio_mix_absent_without_any_sound():
|
||||
assert build_audio_mix_command(plan, Path("a.wav")) is None
|
||||
plan.keep_original_audio = True
|
||||
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
|
||||
|
||||
|
||||
def test_final_fade_never_starts_before_the_voice_ends():
|
||||
# Vidéo courte sans logo : 2 s + 5 s de voix, la vidéo finit 0,8 s après.
|
||||
plan = RenderPlan(
|
||||
video=Path("in.mp4"),
|
||||
video_duration=4.0,
|
||||
output=Path("out.mp4"),
|
||||
voice=Path("v.wav"),
|
||||
voice_duration=5.0,
|
||||
)
|
||||
assert plan.total_duration == 7.8
|
||||
assert plan.fade_start >= plan.speech_end
|
||||
assert plan.fade_duration >= 0.3
|
||||
assert f"afade=t=out:st={plan.fade_start:.3f}" in _graph(build_command(plan))
|
||||
@@ -1,29 +1,62 @@
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.align import Word, spread_words
|
||||
from app.quality import Envelope
|
||||
from app.render import RenderPlan
|
||||
from app.srt_voice import Cue, atempo_chain, cue_windows, mix_command, speed_factor, spoken_span
|
||||
from app.srt_voice import (
|
||||
Cue,
|
||||
Segment,
|
||||
as_sentence,
|
||||
extract_command,
|
||||
mix_command,
|
||||
place_segments,
|
||||
segments_from_reading,
|
||||
split_by_cue,
|
||||
)
|
||||
|
||||
|
||||
def test_window_stops_at_next_cue_and_tolerates_a_short_overflow():
|
||||
cues = [Cue(0.0, 2.0, "a"), Cue(2.1, 4.0, "b"), Cue(6.0, 7.0, "c")]
|
||||
assert cue_windows(cues) == [2.1, 2.15, 1.25]
|
||||
def test_segments_start_at_their_cue_when_they_fit():
|
||||
cues = [Cue(0.0, 2.0, "a"), Cue(2.5, 4.0, "b"), Cue(6.0, 7.0, "c")]
|
||||
assert place_segments(cues, [2.0, 1.0, 1.0]) == [0.0, 2.5, 6.0]
|
||||
|
||||
|
||||
def test_voice_is_sped_up_only_when_too_long():
|
||||
assert speed_factor(1.5, 2.0) == 1.0
|
||||
assert speed_factor(3.0, 2.0) == 1.5
|
||||
def test_overflowing_segment_pushes_the_next_ones_instead_of_speeding_up():
|
||||
cues = [Cue(0.0, 1.0, "a"), Cue(1.0, 2.0, "b"), Cue(5.0, 6.0, "c")]
|
||||
starts = place_segments(cues, [2.0, 2.0, 1.0])
|
||||
assert starts[0] == 0.0
|
||||
assert abs(starts[1] - 2.05) < 1e-9 # après la fin du premier, à vitesse normale
|
||||
assert starts[2] == 5.0 # rattrape le minutage dès que possible
|
||||
|
||||
|
||||
def test_atempo_is_split_into_supported_steps():
|
||||
assert atempo_chain(1.25) == "atempo=1.2500"
|
||||
assert atempo_chain(3.0) == "atempo=2.0000,atempo=1.5000"
|
||||
def test_segments_are_never_sped_up():
|
||||
cmd = extract_command(Path("in.wav"), Segment(1.0, 4.0, []), Path("out.wav"))
|
||||
assert "atempo" not in cmd[cmd.index("-af") + 1]
|
||||
|
||||
|
||||
def test_spoken_span_trims_silences_around_words():
|
||||
words = [Word("Bonjour", 0.4, 0.9), Word("!", 0.9, 1.2)]
|
||||
assert [round(t, 3) for t in spoken_span(words, 2.0)] == [0.35, 1.32]
|
||||
assert spoken_span([], 2.0) == (0.0, 2.0)
|
||||
def test_each_cue_becomes_a_sentence_for_the_reading():
|
||||
assert as_sentence("Venez vite") == "Venez vite."
|
||||
assert as_sentence("Promo !") == "Promo !"
|
||||
|
||||
|
||||
def test_words_of_the_single_reading_are_split_between_cues():
|
||||
words = [Word(t, i, i + 0.5) for i, t in enumerate(["Bonjour.", "Venez", "vite."])]
|
||||
groups = split_by_cue(words, ["Bonjour.", "Venez vite."])
|
||||
assert [[w.text for w in g] for g in groups] == [["Bonjour."], ["Venez", "vite."]]
|
||||
assert split_by_cue(words, ["Bonjour."]) is None
|
||||
|
||||
|
||||
def test_reading_is_cut_in_the_pause_without_clipping_sentence_endings():
|
||||
# Whisper situe mal la fin de « Bonjour » (0,9 s) : la voix retombe
|
||||
# doucement jusqu'à 1,05 s, puis vraie pause jusqu'à 1,6 s.
|
||||
db = [-90.0] * 15 + [-20.0] * 75 + [-50.0] * 15 + [-90.0] * 55 + [-20.0] * 100 + [-90.0] * 40
|
||||
env = Envelope(np.array(db))
|
||||
groups = [[Word("Bonjour.", 0.2, 0.9)], [Word("Venez", 1.7, 2.1), Word("vite.", 2.1, 2.6)]]
|
||||
first, second = segments_from_reading(groups, env)
|
||||
assert first.source_end >= 1.05 + 0.25 # fin douce gardée, avec sa marge
|
||||
assert first.source_end <= second.source_start
|
||||
assert 1.5 <= second.source_start < 1.6
|
||||
|
||||
|
||||
def test_each_segment_is_placed_at_its_cue_start():
|
||||
|
||||
@@ -21,3 +21,39 @@ def test_edge_fallbacks_are_french_and_same_gender():
|
||||
def test_style_prompt_precedes_text():
|
||||
assert build_prompt("Salut", "neutral") == "Salut"
|
||||
assert build_prompt("Salut", "dynamic").endswith(":\nSalut")
|
||||
|
||||
|
||||
def test_qwen_voice_keeps_clones_and_matches_gender():
|
||||
from app.voices import qwen_instruction, resolve_qwen_voice
|
||||
|
||||
assert resolve_qwen_voice("clone:camille") == "clone:camille"
|
||||
assert resolve_qwen_voice("Ryan") == "ryan"
|
||||
assert resolve_qwen_voice("Charon") == "aiden" # voix Gemini masculine
|
||||
assert resolve_qwen_voice(None) == "serena"
|
||||
assert "French" in qwen_instruction("dynamic")
|
||||
|
||||
|
||||
def test_qwen_voice_falls_back_to_same_gender_elsewhere():
|
||||
assert resolve_edge_voice("uncle_fu").gender == "male"
|
||||
assert resolve_gemini_voice("uncle_fu").id == "Charon"
|
||||
|
||||
|
||||
def test_qwen_target_prefers_app_settings(monkeypatch):
|
||||
from app import config
|
||||
from app.tts.qwen import Target
|
||||
|
||||
monkeypatch.setattr(config, "QWEN_TTS_URL", "http://qwen-tts:8001")
|
||||
monkeypatch.setattr(config, "QWEN_TTS_API_KEY", "env-key")
|
||||
assert Target.resolve("http://unraid:8001/ ", "k") == Target("http://unraid:8001", "k")
|
||||
assert Target.resolve(None, None) == Target("http://qwen-tts:8001", "env-key")
|
||||
monkeypatch.setattr(config, "QWEN_TTS_URL", "")
|
||||
assert Target.resolve("", None) is None
|
||||
|
||||
|
||||
def test_runaway_take_is_detected():
|
||||
from app.tts import is_runaway
|
||||
|
||||
text = "Christelle fonce dans les rayons !" # 34 caractères
|
||||
assert not is_runaway(3.0, text)
|
||||
assert not is_runaway(6.5, text) # lecture lente, mais plausible
|
||||
assert is_runaway(14.2, text) # vu en production : le modèle continue sans fin
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsndfile1 sox ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch : « cpu » (image légère) ou « cu128 » pour les GPU NVIDIA, cartes
|
||||
# RTX 50xx (Blackwell) comprises. torch et torchaudio doivent avoir la même
|
||||
# version, sinon torchaudio ne se charge pas.
|
||||
ARG TORCH_VARIANT=cpu
|
||||
RUN pip install --no-cache-dir torch==2.8.0 torchaudio==2.8.0 \
|
||||
--index-url https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Modèles téléchargés au build : aucun accès réseau au premier rendu.
|
||||
# CustomVoice : voix prédéfinies pilotées par une consigne (ton, émotion).
|
||||
# Base : clonage d'une voix de référence (voices/*.wav), chargé seulement s'il y en a.
|
||||
ARG QWEN_TTS_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
|
||||
ARG QWEN_TTS_CLONE_MODEL=Qwen/Qwen3-TTS-12Hz-0.6B-Base
|
||||
ENV QWEN_TTS_MODEL=${QWEN_TTS_MODEL} \
|
||||
QWEN_TTS_CLONE_MODEL=${QWEN_TTS_CLONE_MODEL} \
|
||||
HF_HOME=/opt/hf-cache \
|
||||
HOME=/tmp
|
||||
RUN python -c "from huggingface_hub import snapshot_download as d; d('${QWEN_TTS_MODEL}'); d('${QWEN_TTS_CLONE_MODEL}') if '${QWEN_TTS_CLONE_MODEL}' else None"
|
||||
ENV HF_HUB_OFFLINE=1
|
||||
|
||||
COPY main.py .
|
||||
COPY voices ./voices
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Qwen TTS : voix locale sur un serveur GPU (ex. Unraid)
|
||||
|
||||
Service de synthèse vocale Qwen3-TTS appelé par SocialFlow (via ffmpeg-api).
|
||||
Sur une carte graphique NVIDIA, le gros modèle 1.7B (ton et émotion pilotés)
|
||||
génère une voix bien plus vite qu'en temps réel ; sur CPU, il est 5 à 7 fois plus lent.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
1. **Pilote NVIDIA** : Apps → installer le plugin **Nvidia Driver**, redémarrer,
|
||||
puis vérifier dans un terminal : `nvidia-smi -L` (liste les cartes et leur numéro).
|
||||
2. **Récupérer ce dossier** sur le serveur, par exemple :
|
||||
```bash
|
||||
cd /mnt/user/appdata
|
||||
git clone https://github.com/R0m1k3/Socialflow.git
|
||||
cd Socialflow/qwen-tts
|
||||
```
|
||||
3. **Choisir la carte et la clé** dans un fichier `.env` à côté de `docker-compose.gpu.yml` :
|
||||
```bash
|
||||
QWEN_TTS_API_KEY=une-longue-cle-secrete # openssl rand -hex 32
|
||||
QWEN_TTS_GPU=0 # numéro ou UUID donné par nvidia-smi -L
|
||||
# QWEN_TTS_PORT=8001
|
||||
```
|
||||
Une RTX 3060 12 Go suffit largement (le modèle 1.7B occupe ~5–6 Go) : autant
|
||||
la dédier à la voix et garder la plus grosse carte pour le reste.
|
||||
4. **Construire et lancer** (le premier build télécharge les modèles, quelques Go) :
|
||||
```bash
|
||||
docker compose -f docker-compose.gpu.yml up -d --build
|
||||
docker logs -f socialflow-qwen-tts # « Modèle … chargé sur NVIDIA GeForce … »
|
||||
```
|
||||
5. **Dans SocialFlow** : Paramètres → **Qwen TTS (voix locale)** → adresse
|
||||
`http://IP-DE-L-UNRAID:8001` + la clé → **Tester la connexion** → **Enregistrer**.
|
||||
Le moteur « Qwen (locale) » devient disponible dans le choix des voix.
|
||||
|
||||
## Sécurité
|
||||
|
||||
Le service ne doit pas être exposé sur Internet. Si SocialFlow tourne sur un
|
||||
autre réseau que l'Unraid, reliez les deux par **Tailscale** (ou WireGuard)
|
||||
et utilisez l'adresse Tailscale de l'Unraid (`http://100.x.y.z:8001`).
|
||||
La clé d'accès est obligatoire.
|
||||
|
||||
## Voix clonées (voix française native)
|
||||
|
||||
Déposez dans `voices/` un extrait de 5 à 15 s et sa transcription (voir
|
||||
`voices/README.md`), puis `docker restart socialflow-qwen-tts`. Utilisez
|
||||
uniquement des voix dont vous avez les droits.
|
||||
|
||||
## Réglages
|
||||
|
||||
| Variable | Défaut | Rôle |
|
||||
| --- | --- | --- |
|
||||
| `QWEN_TTS_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Modèle des voix prédéfinies (build) |
|
||||
| `QWEN_TTS_CLONE_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Modèle de clonage, vide pour désactiver (build) |
|
||||
| `QWEN_TTS_GPU` | `0` | Carte NVIDIA utilisée |
|
||||
| `QWEN_TTS_DEVICE` | `auto` | `cuda:0`, `cpu`… (auto : GPU si disponible) |
|
||||
| `QWEN_TTS_DTYPE` | `bfloat16` sur GPU, `float32` sur CPU | Précision du calcul |
|
||||
@@ -0,0 +1,32 @@
|
||||
# Qwen3-TTS sur un serveur avec carte graphique NVIDIA (ex. Unraid).
|
||||
#
|
||||
# Prérequis : pilote NVIDIA + NVIDIA Container Toolkit (sur Unraid : plugin
|
||||
# « Nvidia Driver »). Puis, dans ce dossier :
|
||||
# QWEN_TTS_API_KEY=une-cle-secrete docker compose -f docker-compose.gpu.yml up -d --build
|
||||
# et dans SocialFlow : Paramètres → Qwen TTS → http://IP-DU-SERVEUR:8001 + la clé.
|
||||
services:
|
||||
qwen-tts:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
TORCH_VARIANT: cu128
|
||||
# Sur GPU, le gros modèle : ton et émotion pilotés par consigne
|
||||
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||
# Clonage des voix déposées dans ./voices (vide : désactivé)
|
||||
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-1.7B-Base}
|
||||
image: socialflow-qwen-tts:gpu
|
||||
container_name: socialflow-qwen-tts
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Obligatoire : le service est joignable depuis le réseau
|
||||
API_KEY: ${QWEN_TTS_API_KEY:?Définissez QWEN_TTS_API_KEY}
|
||||
# GPU utilisé dans le conteneur (voir NVIDIA_VISIBLE_DEVICES ci-dessous)
|
||||
QWEN_TTS_DEVICE: cuda:0
|
||||
# Choix de la carte : numéro (0, 1) ou UUID donné par nvidia-smi -L
|
||||
NVIDIA_VISIBLE_DEVICES: ${QWEN_TTS_GPU:-0}
|
||||
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||
runtime: nvidia
|
||||
ports:
|
||||
- "${QWEN_TTS_PORT:-8001}:8001"
|
||||
volumes:
|
||||
- ./voices:/app/voices:ro
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Synthèse vocale Qwen3-TTS, appelée par le service ffmpeg-api.
|
||||
|
||||
Tourne sur carte graphique NVIDIA si elle est disponible (bien plus rapide,
|
||||
gros modèle 1.7B), sinon sur CPU. Peut être installé sur un autre serveur
|
||||
(ex. Unraid avec GPU) : l'adresse se règle dans les paramètres de SocialFlow.
|
||||
|
||||
- Voix prédéfinies (modèle CustomVoice) : le ton est donné par une consigne.
|
||||
- Voix clonées : un extrait de référence dans voices/ (modèle Base, chargé
|
||||
seulement si au moins une voix y est déposée).
|
||||
|
||||
Une seule génération à la fois : chacune occupe tous les cœurs (ou le GPU).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
log = logging.getLogger("qwen-tts")
|
||||
|
||||
MODEL = os.environ.get("QWEN_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
||||
CLONE_MODEL = os.environ.get("QWEN_TTS_CLONE_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-Base")
|
||||
VOICES_DIR = Path(os.environ.get("QWEN_TTS_VOICES_DIR", Path(__file__).parent / "voices"))
|
||||
# « auto » : premier GPU NVIDIA s'il y en a un, sinon CPU (ou « cuda:1 », « cpu »…)
|
||||
_DEVICE_SETTING = os.environ.get("QWEN_TTS_DEVICE", "auto")
|
||||
DEVICE = ("cuda:0" if torch.cuda.is_available() else "cpu") if _DEVICE_SETTING == "auto" else _DEVICE_SETTING
|
||||
# bfloat16 sur GPU (moitié moins de mémoire, plus rapide) ; float32 sur CPU
|
||||
DTYPE = getattr(torch, os.environ.get("QWEN_TTS_DTYPE") or ("bfloat16" if DEVICE.startswith("cuda") else "float32"))
|
||||
THREADS = int(os.environ.get("QWEN_TTS_THREADS", "0")) or os.cpu_count() or 1
|
||||
API_KEY = os.environ.get("API_KEY", "")
|
||||
|
||||
# Voix prédéfinies du modèle CustomVoice (toutes parlent français)
|
||||
PRESETS: dict[str, tuple[str, str]] = {
|
||||
"serena": ("Serena — douce et chaleureuse", "female"),
|
||||
"vivian": ("Vivian — vive et lumineuse", "female"),
|
||||
"sohee": ("Sohee — expressive", "female"),
|
||||
"ono_anna": ("Anna — espiègle", "female"),
|
||||
"aiden": ("Aiden — solaire", "male"),
|
||||
"ryan": ("Ryan — dynamique", "male"),
|
||||
"uncle_fu": ("Fu — grave et posée", "male"),
|
||||
"dylan": ("Dylan — jeune et naturelle", "male"),
|
||||
"eric": ("Eric — légèrement voilée", "male"),
|
||||
}
|
||||
DEFAULT_PRESET = "serena"
|
||||
|
||||
torch.set_num_threads(THREADS)
|
||||
_lock = asyncio.Lock()
|
||||
_models: dict[str, object] = {}
|
||||
_clone_prompts: dict[str, object] = {}
|
||||
|
||||
|
||||
def _load(name: str):
|
||||
if name not in _models:
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
started = time.monotonic()
|
||||
_models[name] = Qwen3TTSModel.from_pretrained(_local_path(name), device_map=DEVICE, dtype=DTYPE)
|
||||
log.info("Modèle %s chargé sur %s en %.1f s", name, _device_label(), time.monotonic() - started)
|
||||
return _models[name]
|
||||
|
||||
|
||||
def _local_path(name: str) -> str:
|
||||
"""Dossier du modèle téléchargé au build. Avec un nom de dépôt, transformers
|
||||
interroge l'API Hugging Face au chargement (détection des tokenizers Mistral),
|
||||
ce qui échoue en mode hors ligne ; avec un chemin local, non."""
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
try:
|
||||
return snapshot_download(name, local_files_only=True)
|
||||
except Exception: # noqa: BLE001 — absent du cache : téléchargement normal
|
||||
return name
|
||||
|
||||
|
||||
def _device_label() -> str:
|
||||
if DEVICE.startswith("cuda"):
|
||||
return f"{torch.cuda.get_device_name(torch.device(DEVICE))} ({DEVICE}, {DTYPE})"
|
||||
return f"CPU ({THREADS} threads, {DTYPE})"
|
||||
|
||||
|
||||
def clones() -> dict[str, dict]:
|
||||
"""Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json)."""
|
||||
found = {}
|
||||
for wav in sorted(VOICES_DIR.glob("*.wav")):
|
||||
transcript = wav.with_suffix(".txt")
|
||||
if not transcript.exists():
|
||||
log.warning("Voix %s ignorée : transcription %s absente", wav.name, transcript.name)
|
||||
continue
|
||||
meta_file = wav.with_suffix(".json")
|
||||
meta = json.loads(meta_file.read_text()) if meta_file.exists() else {}
|
||||
found[wav.stem] = {
|
||||
"id": f"clone:{wav.stem}",
|
||||
"label": meta.get("label", wav.stem.replace("_", " ").title()),
|
||||
"gender": meta.get("gender", "female"),
|
||||
"wav": wav,
|
||||
"text": transcript.read_text().strip(),
|
||||
}
|
||||
return found
|
||||
|
||||
|
||||
def max_tokens(text: str) -> int:
|
||||
"""Plafond de codes audio (12 par seconde de son) : une phrase ne peut plus
|
||||
durer plus de « caractères / 6 + 3 » secondes. Sans lui, le modèle continuait
|
||||
parfois bien après la fin du texte (14 s pour 34 caractères)."""
|
||||
return int(12 * (len(text) / 6 + 3))
|
||||
|
||||
|
||||
def _generate(text: str, voice: str, instruct: str, language: str) -> tuple[bytes, str]:
|
||||
if voice.startswith("clone:") and CLONE_MODEL:
|
||||
name = voice.removeprefix("clone:")
|
||||
clone = clones().get(name)
|
||||
if clone is None:
|
||||
raise HTTPException(status_code=404, detail=f"Voix clonée inconnue : {name}")
|
||||
model = _load(CLONE_MODEL)
|
||||
if name not in _clone_prompts:
|
||||
_clone_prompts[name] = model.create_voice_clone_prompt(
|
||||
ref_audio=str(clone["wav"]), ref_text=clone["text"], x_vector_only_mode=False
|
||||
)
|
||||
wavs, rate = model.generate_voice_clone(
|
||||
text=text,
|
||||
language=language,
|
||||
voice_clone_prompt=_clone_prompts[name],
|
||||
max_new_tokens=max_tokens(text),
|
||||
)
|
||||
used = clone["id"]
|
||||
else:
|
||||
speaker = voice.lower() if voice.lower() in PRESETS else DEFAULT_PRESET
|
||||
kwargs = {"text": text, "language": language, "speaker": speaker, "max_new_tokens": max_tokens(text)}
|
||||
if instruct:
|
||||
kwargs["instruct"] = instruct
|
||||
wavs, rate = _load(MODEL).generate_custom_voice(**kwargs)
|
||||
used = speaker
|
||||
|
||||
buffer = io.BytesIO()
|
||||
sf.write(buffer, wavs[0], rate, format="WAV", subtype="PCM_16")
|
||||
return buffer.getvalue(), used
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
# Chargé au démarrage : la première voix n'attend pas le chargement
|
||||
await asyncio.to_thread(_load, MODEL)
|
||||
log.info("Service prêt, %d voix clonée(s)", len(clones()))
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="SocialFlow Qwen TTS", lifespan=lifespan)
|
||||
|
||||
|
||||
def require_key(x_api_key: str | None = Header(None)) -> None:
|
||||
if API_KEY and x_api_key != API_KEY:
|
||||
raise HTTPException(status_code=401, detail="Invalid API Key")
|
||||
|
||||
|
||||
class SynthesisRequest(BaseModel):
|
||||
text: str
|
||||
voice: str | None = None
|
||||
instruct: str | None = None
|
||||
language: str = "French"
|
||||
|
||||
|
||||
@app.get("/health", dependencies=[Depends(require_key)])
|
||||
async def health():
|
||||
return {"status": "ok", "model": MODEL, "device": _device_label(), "busy": _lock.locked()}
|
||||
|
||||
|
||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||
async def voices():
|
||||
presets = [{"id": k, "label": v[0], "gender": v[1]} for k, v in PRESETS.items()]
|
||||
cloned = [{k: c[k] for k in ("id", "label", "gender")} for c in clones().values()] if CLONE_MODEL else []
|
||||
return {"voices": [*cloned, *presets]}
|
||||
|
||||
|
||||
@app.post("/synthesize", dependencies=[Depends(require_key)])
|
||||
async def synthesize(request: SynthesisRequest):
|
||||
text = request.text.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=400, detail="Texte vide")
|
||||
async with _lock:
|
||||
started = time.monotonic()
|
||||
audio, used = await asyncio.to_thread(
|
||||
_generate, text, request.voice or DEFAULT_PRESET, (request.instruct or "").strip(), request.language
|
||||
)
|
||||
log.info("Voix %s : %d car. en %.1f s", used, len(text), time.monotonic() - started)
|
||||
return Response(audio, media_type="audio/wav", headers={"X-Voice": used})
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.115.12
|
||||
uvicorn[standard]==0.34.2
|
||||
pydantic==2.11.4
|
||||
qwen-tts==0.1.1
|
||||
soundfile==0.13.1
|
||||
numpy>=1.26
|
||||
@@ -0,0 +1,17 @@
|
||||
# Voix clonées (Qwen3-TTS)
|
||||
|
||||
Pour une voix française native, déposez ici un court extrait (5 à 15 s,
|
||||
une seule personne, sans musique) et sa transcription exacte :
|
||||
|
||||
```
|
||||
voices/
|
||||
camille.wav # extrait de référence
|
||||
camille.txt # texte prononcé dans l'extrait
|
||||
camille.json # optionnel : {"label": "Camille — chaleureuse", "gender": "female"}
|
||||
```
|
||||
|
||||
La voix apparaît alors dans le choix des voix Qwen sous l'identifiant
|
||||
`clone:camille`. Le dossier peut aussi être monté en volume
|
||||
(`QWEN_TTS_VOICES_DIR`) pour ajouter des voix sans reconstruire l'image.
|
||||
|
||||
Utilisez uniquement des voix dont vous avez les droits.
|
||||
+9
-5
@@ -1,4 +1,5 @@
|
||||
import express, { type Request, Response, NextFunction } from "express";
|
||||
import type { IncomingMessage } from "http";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import session from "express-session";
|
||||
@@ -87,11 +88,14 @@ declare module 'http' {
|
||||
rawBody: unknown
|
||||
}
|
||||
}
|
||||
app.use(express.json({
|
||||
verify: (req, _res, buf) => {
|
||||
req.rawBody = buf;
|
||||
}
|
||||
}));
|
||||
const keepRawBody = (req: IncomingMessage, _res: unknown, buf: Buffer) => {
|
||||
req.rawBody = buf;
|
||||
};
|
||||
// L'API externe accepte des images en base64 (imageData) : 10 MB d'image font
|
||||
// ~13,4 MB une fois encodés. Ce parseur passe avant le parseur global (100 KB),
|
||||
// qui ignore ensuite un corps déjà lu.
|
||||
app.use('/api/v1', express.json({ limit: '15mb', verify: keepRawBody }));
|
||||
app.use(express.json({ verify: keepRawBody }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
|
||||
// Validation renforcée du SESSION_SECRET
|
||||
|
||||
@@ -161,6 +161,13 @@ export async function migrate() {
|
||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
||||
`);
|
||||
|
||||
// app_config.qwen_tts_url / qwen_tts_api_key (service Qwen3-TTS)
|
||||
await client.query(`
|
||||
ALTER TABLE "app_config"
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_url" text,
|
||||
ADD COLUMN IF NOT EXISTS "qwen_tts_api_key" text;
|
||||
`);
|
||||
|
||||
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
|
||||
await client.query(`
|
||||
ALTER TABLE "media"
|
||||
|
||||
+86
-4
@@ -14,12 +14,13 @@ import { minioService as cloudinaryService, buildMinioUrl } from "./services/min
|
||||
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
||||
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
||||
import { analyticsRouter } from "./routes/analytics";
|
||||
import { normalizeQwenUrl } from "./services/reels/assets";
|
||||
import { reelsRouter } from "./routes/reels";
|
||||
import { remotionRouter } from "./routes/remotion";
|
||||
import { externalRouter } from "./routes/external";
|
||||
import { tiktokRouter } from "./routes/tiktok";
|
||||
import { facebookRouter } from "./routes/facebook";
|
||||
import { legalRouter } from "./routes/legal";
|
||||
import { createVideoThumbnail } from "./services/thumbnail";
|
||||
import { insertAudioTrackSchema } from "@shared/schema";
|
||||
import * as musicMetadata from "music-metadata";
|
||||
|
||||
@@ -473,6 +474,64 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Qwen3-TTS : service local ou serveur GPU distant (ex. Unraid)
|
||||
app.get("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
res.json({ url: config?.qwenTtsUrl ?? "", hasApiKey: !!config?.qwenTtsApiKey });
|
||||
} catch (error) {
|
||||
console.error("Error fetching Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la récupération de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const url = normalizeQwenUrl(req.body?.url);
|
||||
if (!url) {
|
||||
return res.status(400).json({ error: "Adresse invalide (ex. http://192.168.1.20:8001)" });
|
||||
}
|
||||
const { apiKey } = req.body;
|
||||
await storage.upsertAppConfig({
|
||||
qwenTtsUrl: url,
|
||||
// Clé laissée vide : on garde celle déjà enregistrée
|
||||
...(typeof apiKey === "string" && apiKey.trim() ? { qwenTtsApiKey: apiKey.trim() } : {}),
|
||||
});
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error saving Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la sauvegarde de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/settings/qwen", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await storage.upsertAppConfig({ qwenTtsUrl: null, qwenTtsApiKey: null });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting Qwen config:", error);
|
||||
res.status(500).json({ error: "Erreur lors de la suppression de la configuration" });
|
||||
}
|
||||
});
|
||||
|
||||
// Teste la connexion depuis le service FFmpeg (c'est lui qui appelle Qwen)
|
||||
app.post("/api/settings/qwen/test", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const config = await storage.getAppConfig();
|
||||
const url = normalizeQwenUrl(req.body?.url) ?? config?.qwenTtsUrl ?? undefined;
|
||||
const apiKey = (typeof req.body?.apiKey === "string" && req.body.apiKey.trim()) || config?.qwenTtsApiKey || undefined;
|
||||
const { ffmpegService } = await import("./services/ffmpeg");
|
||||
const catalog = await ffmpegService.listVoices(url ? { url, apiKey } : undefined);
|
||||
if (!catalog.qwen_available || !catalog.qwen_status) {
|
||||
return res.json({ ok: false, error: "Service Qwen injoignable (adresse, port, clé ou pare-feu)" });
|
||||
}
|
||||
res.json({ ok: true, ...catalog.qwen_status, voices: catalog.qwen.length });
|
||||
} catch (error) {
|
||||
console.error("Error testing Qwen config:", error);
|
||||
res.json({ ok: false, error: error instanceof Error ? error.message : "Test impossible" });
|
||||
}
|
||||
});
|
||||
|
||||
// Analytics Routes
|
||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||
|
||||
@@ -494,7 +553,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
|
||||
// Reels & Music Routes
|
||||
app.use("/api", requireAuth, reelsRouter);
|
||||
app.use("/api/remotion", requireAuth, remotionRouter);
|
||||
|
||||
|
||||
// Route SQL (réservée aux admins) - DÉSACTIVÉE EN PRODUCTION sauf si explicitement autorisée
|
||||
@@ -662,13 +720,20 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
req.file.mimetype
|
||||
);
|
||||
|
||||
// Détection basique basée sur le mimetype ou l'extension
|
||||
const isVideo = req.file.mimetype.startsWith("video/") || /\.(mp4|mov|avi|mkv|webm)$/i.test(req.file.originalname);
|
||||
|
||||
// Vignette extraite tant que la vidéo est sur le disque : elle survit à la
|
||||
// suppression du fichier après publication (historique illustré)
|
||||
const thumbnailUrl = isVideo ? await createVideoThumbnail(req.file.path) : null;
|
||||
|
||||
// Clean up temp file immediately after upload to free disk space
|
||||
await fs.promises.unlink(req.file.path).catch(err => console.error("Failed to cleanup temp file:", err));
|
||||
|
||||
const mediaItem = await storage.createMedia({
|
||||
userId,
|
||||
// Détection basique basée sur le mimetype ou le résultat Cloudinary
|
||||
type: req.file.mimetype.startsWith("video/") || req.file.originalname.match(/\.(mp4|mov|avi|mkv)$/i) ? "video" : "image",
|
||||
type: isVideo ? "video" : "image",
|
||||
thumbnailUrl,
|
||||
cloudinaryPublicId: uploadResult.publicId,
|
||||
originalUrl: uploadResult.originalUrl,
|
||||
facebookFeedUrl: uploadResult.facebookFeedUrl,
|
||||
@@ -1238,6 +1303,23 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
return res.status(400).json({ error: "Les stories nécessitent au moins un média (image ou vidéo)" });
|
||||
}
|
||||
|
||||
// Reel déjà monté : une seule vidéo, publiée telle quelle sur Facebook ou TikTok
|
||||
if (postType === 'reel') {
|
||||
if (finalMediaItems.length !== 1) {
|
||||
return res.status(400).json({ error: "Un Reel nécessite exactement une vidéo" });
|
||||
}
|
||||
const reelMedia = await storage.getMediaById(finalMediaItems[0].mediaId);
|
||||
if (!reelMedia || reelMedia.type !== 'video' || (user.role !== 'admin' && reelMedia.userId !== userId)) {
|
||||
return res.status(400).json({ error: "Le média d'un Reel doit être une de vos vidéos" });
|
||||
}
|
||||
for (const pageId of Array.isArray(pageIds) ? pageIds : []) {
|
||||
const page = await storage.getSocialPage(pageId);
|
||||
if (!page || (page.platform !== 'facebook' && page.platform !== 'tiktok')) {
|
||||
return res.status(400).json({ error: "Les Reels ne peuvent être publiés que sur Facebook ou TikTok" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Security: Verify user has access to all specified pages (unless admin)
|
||||
if (user.role !== 'admin' && pageIds && Array.isArray(pageIds) && pageIds.length > 0) {
|
||||
const accessiblePages = await storage.getUserAccessiblePages(userId);
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { AddressInfo } from "net";
|
||||
import type { Server } from "http";
|
||||
|
||||
const API_KEY = "cle-de-test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
storage: {
|
||||
getAppConfig: vi.fn(),
|
||||
getAllUsers: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
getSocialPage: vi.fn(),
|
||||
getMediaById: vi.fn(),
|
||||
createMedia: vi.fn(),
|
||||
createPost: vi.fn(),
|
||||
createScheduledPost: vi.fn(),
|
||||
},
|
||||
insertValues: vi.fn(),
|
||||
uploadMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../storage", () => ({ storage: mocks.storage }));
|
||||
vi.mock("../db", () => ({ db: { insert: () => ({ values: mocks.insertValues }) } }));
|
||||
vi.mock("../services/minio", () => ({ minioService: { uploadMedia: mocks.uploadMedia } }));
|
||||
|
||||
const { externalRouter } = await import("./external");
|
||||
|
||||
// Un PNG valide (signature) de la taille voulue
|
||||
function png(size: number): Buffer {
|
||||
const header = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
return Buffer.concat([header, Buffer.alloc(Math.max(0, size - header.length))]);
|
||||
}
|
||||
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Même montage que server/index.ts : parseur large pour /api/v1, puis global
|
||||
const app = express();
|
||||
app.use("/api/v1", express.json({ limit: "15mb" }));
|
||||
app.use(express.json());
|
||||
app.use("/api/v1", externalRouter);
|
||||
server = app.listen(0);
|
||||
await new Promise((resolve) => server.once("listening", resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}/api/v1`;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.storage.getAppConfig.mockResolvedValue({ externalApiKey: API_KEY });
|
||||
mocks.storage.getAllUsers.mockResolvedValue([{ id: "admin-1", role: "admin" }]);
|
||||
mocks.storage.getSocialPage.mockImplementation(async (id: string) =>
|
||||
id === "page-1" ? { id, pageName: "Ma Page" } : undefined
|
||||
);
|
||||
mocks.uploadMedia.mockImplementation(async (_buffer: Buffer, fileName: string) => ({
|
||||
publicId: `media/${fileName}`,
|
||||
originalUrl: `/uploads/media/${fileName}`,
|
||||
facebookFeedUrl: null,
|
||||
instagramFeedUrl: null,
|
||||
instagramStoryUrl: null,
|
||||
}));
|
||||
mocks.storage.createMedia.mockImplementation(async (data: Record<string, unknown>) => ({ id: "media-1", ...data }));
|
||||
mocks.storage.createPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: "post-1", ...data }));
|
||||
mocks.storage.createScheduledPost.mockImplementation(async (data: Record<string, unknown>) => ({ id: `sp-${data.postType}`, ...data }));
|
||||
});
|
||||
|
||||
function form(fields: Record<string, string>, file?: { field: string; buffer: Buffer; name: string; type?: string }) {
|
||||
const body = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) body.append(key, value);
|
||||
if (file) body.append(file.field, new Blob([file.buffer], { type: file.type ?? "image/png" }), file.name);
|
||||
return body;
|
||||
}
|
||||
|
||||
describe("POST /api/v1/media", () => {
|
||||
it("refuse sans clé API", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, { method: "POST", body: form({}, { field: "file", buffer: png(100), name: "a.png" }) });
|
||||
expect(res.status).toBe(401);
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepte un fichier multipart et le range dans la médiathèque de l'admin", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: png(3 * 1024 * 1024), name: "galette.png", type: "application/octet-stream" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body).toMatchObject({ id: "media-1", type: "image", fileSize: 3 * 1024 * 1024 });
|
||||
expect(body.url).toMatch(/^\/uploads\/media\/external-\d+\.png$/);
|
||||
expect(mocks.storage.createMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: "admin-1" }));
|
||||
});
|
||||
|
||||
it("accepte une image base64 bien au-delà de 100 KB", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ imageData: `data:image/png;base64,${png(2 * 1024 * 1024).toString("base64")}` }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(mocks.uploadMedia.mock.calls[0][0].length).toBe(2 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it("renvoie 413 au-delà de 10 MB", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: png(10 * 1024 * 1024 + 1), name: "enorme.png" }),
|
||||
});
|
||||
expect(res.status).toBe(413);
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuse un fichier qui n'est pas une image", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({}, { field: "file", buffer: Buffer.from("<html></html>"), name: "page.png" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toMatch(/Format/);
|
||||
});
|
||||
|
||||
it("refuse une requête sans image", async () => {
|
||||
const res = await fetch(`${baseUrl}/media`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/publish", () => {
|
||||
it("programme un post avec un mediaId déjà envoyé", async () => {
|
||||
mocks.storage.getMediaById.mockResolvedValue({ id: "media-9", originalUrl: "/uploads/media/x.png", type: "image" });
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", pageIds: ["page-1"], scheduledAt: "2026-10-05T12:15:00+02:00", postType: "both" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.post.media).toEqual({ id: "media-9", url: "/uploads/media/x.png" });
|
||||
expect(body.post.pages).toHaveLength(2);
|
||||
expect(mocks.insertValues).toHaveBeenCalledWith({ postId: "post-1", mediaId: "media-9", displayOrder: 0 });
|
||||
expect(mocks.uploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepte l'image en multipart avec pageIds en texte", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY },
|
||||
body: form({ content: "Bonjour", pageIds: "page-1", scheduledAt: "2026-10-05T10:15:00Z" }, { field: "image", buffer: png(500_000), name: "chaise.png" }),
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.post.media.id).toBe("media-1");
|
||||
expect(body.post.pages).toEqual([{ pageId: "page-1", pageName: "Ma Page", scheduledPostId: "sp-feed" }]);
|
||||
});
|
||||
|
||||
it("refuse un mediaId inconnu sans créer de post", async () => {
|
||||
mocks.storage.getMediaById.mockResolvedValue(undefined);
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "absent", pageIds: ["page-1"] }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(mocks.storage.createPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuse plusieurs sources d'image", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", mediaId: "media-9", imageUrl: "https://exemple.fr/a.png", pageIds: ["page-1"] }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(mocks.storage.createPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exige une image pour une story", async () => {
|
||||
const res = await fetch(`${baseUrl}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: "Bonjour", pageIds: ["page-1"], postType: "story" }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
+215
-73
@@ -1,20 +1,60 @@
|
||||
import { Router } from "express";
|
||||
import { Router, type Request, type Response as ExpressResponse, type NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import path from "path";
|
||||
import multer from "multer";
|
||||
import { storage } from "../storage";
|
||||
import { db } from "../db";
|
||||
import { postMedia } from "@shared/schema";
|
||||
import { postMedia, type Media } from "@shared/schema";
|
||||
import { minioService } from "../services/minio";
|
||||
import { requireApiKey } from "../middleware/apiKey";
|
||||
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, validateImage } from "../services/imageData";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(requireApiKey);
|
||||
|
||||
/** Erreur imputable à la requête : renvoyée telle quelle avec son code HTTP. */
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier gardé en mémoire : 10 MB max, le contenu est validé par sa signature
|
||||
const imageUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_EXTERNAL_IMAGE_SIZE, files: 1 },
|
||||
});
|
||||
|
||||
/**
|
||||
* Accepte un fichier image en multipart/form-data sous le champ `field`.
|
||||
* Sans effet sur les requêtes JSON. Les erreurs de multer deviennent des
|
||||
* réponses JSON explicites au lieu de remonter au gestionnaire global.
|
||||
*/
|
||||
function acceptImageFile(field: string) {
|
||||
const middleware = imageUpload.single(field);
|
||||
return (req: Request, res: ExpressResponse, next: NextFunction) => {
|
||||
middleware(req, res, (err: unknown) => {
|
||||
if (!err) return next();
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
return res.status(413).json({ error: "Image trop volumineuse (max 10 MB)" });
|
||||
}
|
||||
if (err.code === "LIMIT_UNEXPECTED_FILE") {
|
||||
return res.status(400).json({ error: `Fichier attendu dans le champ « ${field} »` });
|
||||
}
|
||||
}
|
||||
const message = err instanceof Error ? err.message : "Requête multipart invalide";
|
||||
return res.status(400).json({ error: message });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const publishSchema = z.object({
|
||||
content: z.string().min(1, "Le contenu est requis"),
|
||||
imageUrl: z.string().url("URL d'image invalide").optional(),
|
||||
pageIds: z.array(z.string()).min(1, "Au moins une page est requise"),
|
||||
imageData: z.string().min(1).optional(),
|
||||
mediaId: z.string().min(1).optional(),
|
||||
pageIds: z.preprocess(normalizePageIds, z.array(z.string()).min(1, "Au moins une page est requise")),
|
||||
scheduledAt: z.string().datetime({ offset: true }).optional(),
|
||||
postType: z.enum(["feed", "story", "both"]).default("feed"),
|
||||
userId: z.string().optional(),
|
||||
@@ -59,6 +99,151 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
|
||||
return { buffer, ext, mimeType: contentType };
|
||||
}
|
||||
|
||||
/** Enregistre une image dans la médiathèque de `ownerId`. */
|
||||
async function storeImage(buffer: Buffer, ownerId: string, prefix: string): Promise<Media> {
|
||||
let detected: { mimeType: string; ext: string };
|
||||
try {
|
||||
detected = validateImage(buffer);
|
||||
} catch (error) {
|
||||
const tooLarge = buffer.length > MAX_EXTERNAL_IMAGE_SIZE;
|
||||
throw new ApiError(tooLarge ? 413 : 400, error instanceof Error ? error.message : "Image invalide");
|
||||
}
|
||||
|
||||
const fileName = `${prefix}-${Date.now()}${detected.ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, detected.mimeType);
|
||||
return storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: "image",
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
}
|
||||
|
||||
interface ImageSources {
|
||||
file?: Express.Multer.File;
|
||||
imageUrl?: string;
|
||||
imageData?: string;
|
||||
mediaId?: string;
|
||||
}
|
||||
|
||||
function countImageSources({ file, imageUrl, imageData, mediaId }: ImageSources): number {
|
||||
return [file, imageUrl, imageData, mediaId].filter((source) => source !== undefined).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Résout l'image d'une publication, quelle que soit la façon dont elle a été
|
||||
* transmise : fichier multipart, base64 (`imageData`), média déjà envoyé via
|
||||
* POST /api/v1/media (`mediaId`) ou URL publique à télécharger (`imageUrl`).
|
||||
*/
|
||||
async function resolveImage(sources: ImageSources, ownerId: string, prefix: string): Promise<Media | null> {
|
||||
if (countImageSources(sources) > 1) {
|
||||
throw new ApiError(400, "Une seule source d'image à la fois : fichier, imageData, mediaId ou imageUrl");
|
||||
}
|
||||
for (const key of ["imageUrl", "imageData", "mediaId"] as const) {
|
||||
if (sources[key] !== undefined && typeof sources[key] !== "string") {
|
||||
throw new ApiError(400, `${key} doit être une chaîne`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sources.mediaId !== undefined) {
|
||||
const media = await storage.getMediaById(sources.mediaId);
|
||||
if (!media) {
|
||||
throw new ApiError(400, `Média introuvable: ${sources.mediaId}`);
|
||||
}
|
||||
return media;
|
||||
}
|
||||
if (sources.file) {
|
||||
return storeImage(sources.file.buffer, ownerId, prefix);
|
||||
}
|
||||
if (sources.imageData !== undefined) {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = decodeImageData(sources.imageData);
|
||||
} catch (error) {
|
||||
throw new ApiError(400, error instanceof Error ? error.message : "imageData invalide");
|
||||
}
|
||||
return storeImage(buffer, ownerId, prefix);
|
||||
}
|
||||
if (sources.imageUrl !== undefined) {
|
||||
const { buffer } = await downloadImage(sources.imageUrl);
|
||||
return storeImage(buffer, ownerId, prefix);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Propriétaire des médias et posts créés : `userId` fourni, sinon le premier admin. */
|
||||
async function resolveOwnerId(userId: string | undefined): Promise<string> {
|
||||
if (userId) {
|
||||
const user = await storage.getUser(userId);
|
||||
if (!user) {
|
||||
throw new ApiError(400, `Utilisateur introuvable: ${userId}`);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
throw new ApiError(500, "Aucun utilisateur admin trouvé");
|
||||
}
|
||||
return admin.id;
|
||||
}
|
||||
|
||||
function sendError(res: ExpressResponse, error: unknown, context: string) {
|
||||
if (error instanceof ApiError) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
console.error(`[external API] ${context} error:`, error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/media
|
||||
*
|
||||
* Envoie une image dans la médiathèque, sans passer par un hébergeur public.
|
||||
* Renvoie un `id` à passer en `mediaId` à /publish ou PATCH /posts/:id.
|
||||
*
|
||||
* Deux formats :
|
||||
* multipart/form-data champ `file` (+ `userId` optionnel)
|
||||
* application/json { "imageData": "<base64 ou data URL>", "userId"?: "..." }
|
||||
*
|
||||
* Formats : JPEG, PNG, WebP, GIF — 10 MB max.
|
||||
*/
|
||||
router.post("/media", acceptImageFile("file"), async (req, res) => {
|
||||
try {
|
||||
const { imageData, userId } = (req.body ?? {}) as { imageData?: unknown; userId?: unknown };
|
||||
if (imageData !== undefined && typeof imageData !== "string") {
|
||||
return res.status(400).json({ error: "imageData doit être une chaîne base64" });
|
||||
}
|
||||
if (!req.file && imageData === undefined) {
|
||||
return res.status(400).json({
|
||||
error: "Aucune image reçue : envoyez un fichier (multipart, champ « file ») ou imageData (base64)",
|
||||
});
|
||||
}
|
||||
if (req.file && imageData !== undefined) {
|
||||
return res.status(400).json({ error: "Envoyez soit un fichier, soit imageData, pas les deux" });
|
||||
}
|
||||
|
||||
const ownerId = await resolveOwnerId(typeof userId === "string" && userId ? userId : undefined);
|
||||
const media = await resolveImage({ file: req.file, imageData }, ownerId, "external");
|
||||
|
||||
return res.status(201).json({
|
||||
id: media!.id,
|
||||
url: media!.originalUrl,
|
||||
type: media!.type,
|
||||
fileName: media!.fileName,
|
||||
fileSize: media!.fileSize,
|
||||
});
|
||||
} catch (error) {
|
||||
return sendError(res, error, "POST /media");
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/publish
|
||||
*
|
||||
@@ -71,12 +256,18 @@ async function downloadImage(url: string): Promise<{ buffer: Buffer; ext: string
|
||||
* Body:
|
||||
* content string Texte de la publication
|
||||
* imageUrl string? URL publique de l'image à télécharger
|
||||
* imageData string? Image en base64 (ou data URL)
|
||||
* mediaId string? Image déjà envoyée via POST /api/v1/media
|
||||
* pageIds string[] IDs des pages cibles (social_pages.id)
|
||||
* scheduledAt ISO8601? Date/heure de publication (absent = immédiat)
|
||||
* postType feed|story|both Type de publication (défaut: feed)
|
||||
* userId string? ID utilisateur propriétaire (défaut: premier admin)
|
||||
*
|
||||
* Accepte aussi multipart/form-data : mêmes champs en texte (pageIds en
|
||||
* tableau JSON ou séparés par des virgules) et l'image dans le champ `image`.
|
||||
* Une seule source d'image par requête.
|
||||
*/
|
||||
router.post("/publish", async (req, res) => {
|
||||
router.post("/publish", acceptImageFile("image"), async (req, res) => {
|
||||
try {
|
||||
const parsed = publishSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
@@ -86,23 +277,10 @@ router.post("/publish", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { content, imageUrl, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
const { content, imageUrl, imageData, mediaId, pageIds, scheduledAt, postType, userId: bodyUserId } = parsed.data;
|
||||
const imageSources: ImageSources = { file: req.file, imageUrl, imageData, mediaId };
|
||||
|
||||
// Résoudre l'utilisateur propriétaire
|
||||
let ownerId = bodyUserId;
|
||||
if (!ownerId) {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u) => u.role === "admin");
|
||||
if (!admin) {
|
||||
return res.status(500).json({ error: "Aucun utilisateur admin trouvé" });
|
||||
}
|
||||
ownerId = admin.id;
|
||||
} else {
|
||||
const user = await storage.getUser(ownerId);
|
||||
if (!user) {
|
||||
return res.status(400).json({ error: `Utilisateur introuvable: ${ownerId}` });
|
||||
}
|
||||
}
|
||||
const ownerId = await resolveOwnerId(bodyUserId);
|
||||
|
||||
// Vérifier que toutes les pages existent
|
||||
const resolvedPages: Array<{ id: string; pageName: string }> = [];
|
||||
@@ -115,31 +293,11 @@ router.post("/publish", async (req, res) => {
|
||||
}
|
||||
|
||||
// Validation story → image obligatoire
|
||||
if ((postType === "story" || postType === "both") && !imageUrl) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (imageUrl requis)" });
|
||||
if ((postType === "story" || postType === "both") && countImageSources(imageSources) === 0) {
|
||||
return res.status(400).json({ error: "Les stories nécessitent une image (fichier, imageData, mediaId ou imageUrl)" });
|
||||
}
|
||||
|
||||
// Télécharger l'image si fournie
|
||||
let mediaRecord: { id: string; originalUrl: string } | null = null;
|
||||
if (imageUrl) {
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
|
||||
const fileName = `external-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
}
|
||||
const mediaRecord = await resolveImage(imageSources, ownerId, "external");
|
||||
|
||||
// Créer le post
|
||||
const scheduledFor = scheduledAt ? new Date(scheduledAt) : null;
|
||||
@@ -209,9 +367,7 @@ router.post("/publish", async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] Error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
return sendError(res, error, "POST /publish");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -363,11 +519,15 @@ router.get("/posts", async (req, res) => {
|
||||
* content string? Nouveau texte
|
||||
* scheduledAt ISO8601? Nouvelle date/heure de publication
|
||||
* imageUrl string? URL d'une nouvelle image (remplace l'existante)
|
||||
* imageData string? Nouvelle image en base64 (remplace l'existante)
|
||||
* mediaId string? Image déjà envoyée via POST /api/v1/media
|
||||
*
|
||||
* Accepte aussi multipart/form-data avec l'image dans le champ `image`.
|
||||
*/
|
||||
router.patch("/posts/:id", async (req, res) => {
|
||||
router.patch("/posts/:id", acceptImageFile("image"), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { content, scheduledAt, imageUrl } = req.body;
|
||||
const { content, scheduledAt, imageUrl, imageData, mediaId } = req.body ?? {};
|
||||
|
||||
// Vérifier que le post existe et est modifiable
|
||||
const post = await storage.getPost(id);
|
||||
@@ -378,6 +538,9 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
return res.status(400).json({ error: "Impossible de modifier un post déjà publié" });
|
||||
}
|
||||
|
||||
// Image résolue d'abord : une image refusée ne laisse pas le post à moitié modifié
|
||||
const mediaRecord = await resolveImage({ file: req.file, imageUrl, imageData, mediaId }, post.userId, "external-edit");
|
||||
|
||||
// Mettre à jour le contenu si fourni
|
||||
if (content !== undefined) {
|
||||
await storage.updatePost(id, { content });
|
||||
@@ -397,27 +560,8 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Remplacer le média si une nouvelle imageUrl est fournie
|
||||
if (imageUrl !== undefined) {
|
||||
// Résoudre le userId pour le stockage du média
|
||||
const ownerId = post.userId;
|
||||
const { buffer, ext, mimeType } = await downloadImage(imageUrl);
|
||||
const fileName = `external-edit-${Date.now()}${ext}`;
|
||||
const uploaded = await minioService.uploadMedia(buffer, fileName, ownerId, mimeType);
|
||||
const mediaType: "image" | "video" = mimeType.startsWith("video/") ? "video" : "image";
|
||||
const mediaRecord = await storage.createMedia({
|
||||
userId: ownerId,
|
||||
type: mediaType,
|
||||
cloudinaryPublicId: uploaded.publicId,
|
||||
originalUrl: uploaded.originalUrl,
|
||||
facebookFeedUrl: uploaded.facebookFeedUrl,
|
||||
instagramFeedUrl: uploaded.instagramFeedUrl,
|
||||
instagramStoryUrl: uploaded.instagramStoryUrl,
|
||||
fileName,
|
||||
fileSize: buffer.length,
|
||||
});
|
||||
|
||||
// Remplacer les médias existants par le nouveau
|
||||
// Remplacer les médias existants par la nouvelle image
|
||||
if (mediaRecord) {
|
||||
await storage.updatePostMedia(id, [mediaRecord.id]);
|
||||
}
|
||||
|
||||
@@ -461,9 +605,7 @@ router.patch("/posts/:id", async (req, res) => {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[external API] PATCH /posts/:id error:", error);
|
||||
const message = error instanceof Error ? error.message : "Erreur interne";
|
||||
return res.status(500).json({ error: message });
|
||||
return sendError(res, error, "PATCH /posts/:id");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+22
-1
@@ -9,7 +9,13 @@ import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||
import { srtText } from '@shared/srt';
|
||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveStoreName } from '../services/reels/assets';
|
||||
import {
|
||||
qwenTargetFromConfig,
|
||||
resolveGeminiApiKey,
|
||||
resolveLogoPath,
|
||||
resolveQwenTarget,
|
||||
resolveStoreName,
|
||||
} from '../services/reels/assets';
|
||||
import { configuredRenderer } from '../services/reels/videoPipeline';
|
||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||
|
||||
@@ -271,6 +277,20 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Voix disponibles (dont les voix clonées Qwen) et disponibilité du moteur local
|
||||
* GET /api/reels/voices
|
||||
*/
|
||||
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig()));
|
||||
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status });
|
||||
} catch (error) {
|
||||
console.error('❌ Error listing voices:', error);
|
||||
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Prévisualiser la voix TTS
|
||||
* POST /api/reels/tts-preview
|
||||
@@ -288,6 +308,7 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||
engine: ttsEngine,
|
||||
style: ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||
qwen: await resolveQwenTarget(ttsEngine),
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import type { User } from "@shared/schema";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { storage as dbStorage } from "../storage";
|
||||
import { enqueueReelJob, getReelJob } from "../services/reels/queue";
|
||||
import { REMOTION_TEMP_DIR } from "../services/reels/imagesPipeline";
|
||||
import { resolveStoreName } from "../services/reels/assets";
|
||||
import { publishReelToPages, storeRenderedVideo } from "../services/reels/publish";
|
||||
|
||||
export const remotionRouter = Router();
|
||||
|
||||
const uploadDir = REMOTION_TEMP_DIR;
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: uploadDir,
|
||||
filename: (_req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
|
||||
cb(null, `remotion-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/remotion/render/status/:jobId
|
||||
* État d'un rendu, lu dans la file persistante (survit à un redémarrage).
|
||||
*/
|
||||
remotionRouter.get("/render/status/:jobId", async (req, res) => {
|
||||
const user = req.user as User;
|
||||
const job = await getReelJob(req.params.jobId);
|
||||
if (!job || job.kind !== "images" || (job.userId !== user.id && user.role !== "admin")) {
|
||||
return res.status(404).json({ error: "Job introuvable" });
|
||||
}
|
||||
|
||||
if (job.status === "completed") {
|
||||
const result = job.result as ImagesReelResult;
|
||||
return res.json({ status: "done", url: result.url, thumbnailUrl: result.thumbnailUrl });
|
||||
}
|
||||
if (job.status === "failed") {
|
||||
return res.json({ status: "error", error: "Erreur lors du rendu: " + (job.error ?? "inconnue") });
|
||||
}
|
||||
res.json({ status: "processing", progress: job.progress, step: job.step, queued: job.status === "pending" });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/remotion/render
|
||||
* Met en file le rendu d'un Reel à partir d'images (1 à 4).
|
||||
*/
|
||||
remotionRouter.post("/render", upload.fields([{ name: "images", maxCount: 4 }, { name: "music", maxCount: 1 }]), async (req, res) => {
|
||||
const fields = (req.files ?? {}) as Record<string, Express.Multer.File[]>;
|
||||
const uploaded = [...(fields["images"] ?? []), ...(fields["music"] ?? [])];
|
||||
|
||||
try {
|
||||
const user = req.user as User;
|
||||
const existing = req.body.existingImageUrls;
|
||||
const libraryUrls: string[] = existing ? (Array.isArray(existing) ? existing : [existing]) : [];
|
||||
const uploadedImageUrls = (fields["images"] ?? []).map((f) => `/uploads/temp/${path.basename(f.path)}`);
|
||||
|
||||
const musicFile = fields["music"]?.[0];
|
||||
const musicVolume = parseFloat(req.body.musicVolume ?? "0.3");
|
||||
|
||||
const parsed = imagesReelParamsSchema.safeParse({
|
||||
imageUrls: [...libraryUrls, ...uploadedImageUrls],
|
||||
overlayText: req.body.overlayText,
|
||||
musicUrl: musicFile ? `/uploads/temp/${path.basename(musicFile.path)}` : req.body.musicTrackUrl,
|
||||
musicVolume: Number.isFinite(musicVolume) ? musicVolume : undefined,
|
||||
ttsEnabled: req.body.ttsEnabled !== "false",
|
||||
ttsEngine: req.body.ttsEngine || undefined,
|
||||
ttsVoice: req.body.ttsVoice,
|
||||
ttsStyle: req.body.ttsStyle || undefined,
|
||||
captionStyle: req.body.captionStyle || undefined,
|
||||
showLogo: req.body.showLogo !== "false",
|
||||
storeName: await resolveStoreName(user.id, req.body.selectedPageId),
|
||||
tempFiles: uploaded.map((f) => f.path),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
await removeFiles(uploaded);
|
||||
return res.status(400).json({ error: parsed.error.issues[0]?.message ?? "Paramètres invalides" });
|
||||
}
|
||||
|
||||
const job = await enqueueReelJob({ kind: "images", userId: user.id, params: parsed.data });
|
||||
res.json({ jobId: job.id, message: "Rendu démarré en arrière-plan" });
|
||||
} catch (err) {
|
||||
console.error("❌ Remotion pre-render error:", err);
|
||||
await removeFiles(uploaded);
|
||||
res.status(500).json({ error: "Erreur lors du lancement du rendu: " + (err instanceof Error ? err.message : err) });
|
||||
}
|
||||
});
|
||||
|
||||
async function removeFiles(files: Express.Multer.File[]): Promise<void> {
|
||||
await Promise.all(files.map((f) => fs.promises.unlink(f.path).catch(() => { /* déjà absent */ })));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/remotion/publish
|
||||
* Range la vidéo rendue dans la médiathèque puis la publie (ou la planifie).
|
||||
*/
|
||||
remotionRouter.post("/publish", async (req, res) => {
|
||||
try {
|
||||
const user = req.user as User;
|
||||
|
||||
const { videoUrl, pageIds, scheduledFor, description } = req.body as {
|
||||
videoUrl: string;
|
||||
pageIds: string[];
|
||||
scheduledFor?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
if (!videoUrl) return res.status(400).json({ error: "videoUrl requis" });
|
||||
if (!pageIds?.length) return res.status(400).json({ error: "Au moins une page requise" });
|
||||
|
||||
// Chemin local du rendu à partir de son URL temporaire
|
||||
const filename = path.basename(videoUrl.split("?")[0]);
|
||||
const localPath = path.join(uploadDir, filename);
|
||||
if (!fs.existsSync(localPath)) {
|
||||
return res.status(400).json({ error: "Fichier vidéo introuvable (expiré ?)" });
|
||||
}
|
||||
|
||||
const videoBuffer = await fs.promises.readFile(localPath);
|
||||
const mediaRecord = await storeRenderedVideo(user.id, videoBuffer, filename);
|
||||
|
||||
const post = await dbStorage.createPost({
|
||||
userId: user.id,
|
||||
content: description || "",
|
||||
aiGenerated: "false",
|
||||
status: scheduledFor ? "scheduled" : "draft",
|
||||
scheduledFor: scheduledFor ? new Date(scheduledFor) : undefined,
|
||||
});
|
||||
await dbStorage.updatePostMedia(post.id, [mediaRecord.id]);
|
||||
|
||||
const results = await publishReelToPages({
|
||||
postId: post.id,
|
||||
pageIds,
|
||||
videoBuffer,
|
||||
description: description || "",
|
||||
scheduledFor,
|
||||
});
|
||||
|
||||
res.json({ success: results.some((r) => r.success), results, postId: post.id });
|
||||
} catch (err) {
|
||||
console.error("❌ Remotion publish error:", err);
|
||||
res.status(500).json({ error: "Erreur lors de la publication: " + (err instanceof Error ? err.message : err) });
|
||||
}
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import { Readable } from 'stream';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import type { ReadableStream as WebReadableStream } from 'stream/web';
|
||||
import type { SrtCue } from '@shared/srt';
|
||||
import type { TtsEngine, TtsStyle } from '@shared/voices';
|
||||
import type { TtsEngine, TtsStyle, VoiceOption } from '@shared/voices';
|
||||
|
||||
/** Un rendu long (stabilisation + encodage) peut dépasser plusieurs minutes. */
|
||||
const PROCESS_TIMEOUT_MS = 20 * 60_000;
|
||||
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
||||
const TTS_TIMEOUT_MS = 3 * 60_000;
|
||||
// Qwen (local, CPU) met plusieurs fois la durée de la voix à la générer
|
||||
const TTS_TIMEOUT_MS = 15 * 60_000;
|
||||
const HEALTH_TIMEOUT_MS = 5_000;
|
||||
|
||||
export interface ReelRenderOptions {
|
||||
@@ -29,6 +30,7 @@ export interface ReelRenderOptions {
|
||||
ttsEngine?: TtsEngine;
|
||||
ttsStyle?: TtsStyle;
|
||||
geminiApiKey?: string;
|
||||
qwen?: QwenTarget;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
@@ -55,6 +57,9 @@ export interface PreparedReel {
|
||||
totalDuration: number;
|
||||
videoDuration: number;
|
||||
logoStart: number | null;
|
||||
/** Fondu final, calculé par le service pour ne jamais rogner la voix. */
|
||||
fadeStart: number | null;
|
||||
fadeDuration: number | null;
|
||||
words: TimedWord[];
|
||||
ttsEngine?: string | null;
|
||||
ttsVoice?: string | null;
|
||||
@@ -67,6 +72,21 @@ export interface TimedWord {
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Service Qwen TTS (local ou serveur GPU) réglé dans les paramètres. */
|
||||
export interface QwenTarget {
|
||||
url: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export interface VoiceCatalog {
|
||||
qwen: VoiceOption[];
|
||||
qwen_available: boolean;
|
||||
/** Modèle et carte graphique du service Qwen, null s'il ne répond pas. */
|
||||
qwen_status: { model?: string; device?: string; busy?: boolean } | null;
|
||||
gemini: VoiceOption[];
|
||||
edge: VoiceOption[];
|
||||
}
|
||||
|
||||
export interface VoicePreview {
|
||||
audio: Buffer;
|
||||
duration: number;
|
||||
@@ -138,6 +158,8 @@ export class FFmpegService {
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
font_size: options.fontSize ?? 64,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
@@ -210,6 +232,8 @@ export class FFmpegService {
|
||||
tts_engine: options.ttsEngine,
|
||||
tts_style: options.ttsStyle,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
music_volume: options.musicVolume ?? 0.25,
|
||||
draw_text: options.drawText ?? true,
|
||||
stabilize: options.stabilize ?? false,
|
||||
@@ -226,6 +250,8 @@ export class FFmpegService {
|
||||
total_duration: number;
|
||||
video_duration: number;
|
||||
logo_start: number | null;
|
||||
fade_start?: number | null;
|
||||
fade_duration?: number | null;
|
||||
words: TimedWord[];
|
||||
tts_engine?: string | null;
|
||||
tts_voice?: string | null;
|
||||
@@ -248,6 +274,8 @@ export class FFmpegService {
|
||||
totalDuration: data.total_duration,
|
||||
videoDuration: data.video_duration,
|
||||
logoStart: data.logo_start,
|
||||
fadeStart: data.fade_start ?? null,
|
||||
fadeDuration: data.fade_duration ?? null,
|
||||
words: data.words ?? [],
|
||||
ttsEngine: data.tts_engine,
|
||||
ttsVoice: data.tts_voice,
|
||||
@@ -278,10 +306,19 @@ export class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
|
||||
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (qwen?.url) headers['X-Qwen-Url'] = qwen.url;
|
||||
if (qwen?.apiKey) headers['X-Qwen-Key'] = qwen.apiKey;
|
||||
const response = await this.call('/voices', { method: 'GET', headers, timeoutMs: 30_000 });
|
||||
return await response.json() as VoiceCatalog;
|
||||
}
|
||||
|
||||
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
|
||||
async previewVoice(
|
||||
text: string,
|
||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {},
|
||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {},
|
||||
): Promise<VoicePreview> {
|
||||
const response = await this.call('/preview-tts', {
|
||||
method: 'POST',
|
||||
@@ -292,6 +329,8 @@ export class FFmpegService {
|
||||
tts_engine: options.engine,
|
||||
tts_style: options.style,
|
||||
gemini_api_key: options.geminiApiKey,
|
||||
qwen_tts_url: options.qwen?.url,
|
||||
qwen_tts_api_key: options.qwen?.apiKey,
|
||||
}),
|
||||
timeoutMs: TTS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MAX_EXTERNAL_IMAGE_SIZE, decodeImageData, normalizePageIds, sniffImageMime, validateImage } from "./imageData";
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0]);
|
||||
const GIF = Buffer.from("GIF89a\0\0", "latin1");
|
||||
const WEBP = Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBPVP8 ")]);
|
||||
|
||||
describe("sniffImageMime", () => {
|
||||
it("reconnaît les formats acceptés par leur signature", () => {
|
||||
expect(sniffImageMime(PNG)).toBe("image/png");
|
||||
expect(sniffImageMime(JPEG)).toBe("image/jpeg");
|
||||
expect(sniffImageMime(GIF)).toBe("image/gif");
|
||||
expect(sniffImageMime(WEBP)).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("refuse le reste", () => {
|
||||
expect(sniffImageMime(Buffer.from("<html></html>"))).toBeNull();
|
||||
expect(sniffImageMime(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("decodeImageData", () => {
|
||||
it("décode le base64 brut et les data URL", () => {
|
||||
const base64 = PNG.toString("base64");
|
||||
expect(decodeImageData(base64).equals(PNG)).toBe(true);
|
||||
expect(decodeImageData(`data:image/png;base64,${base64}`).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolère les retours à la ligne du base64", () => {
|
||||
const base64 = JPEG.toString("base64");
|
||||
expect(decodeImageData(`${base64.slice(0, 4)}\n${base64.slice(4)}`).equals(JPEG)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuse ce qui n'est pas du base64", () => {
|
||||
expect(() => decodeImageData("https://exemple.fr/image.jpg")).toThrow(/base64/);
|
||||
expect(() => decodeImageData("")).toThrow(/base64/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateImage", () => {
|
||||
it("renvoie type et extension", () => {
|
||||
expect(validateImage(JPEG)).toEqual({ mimeType: "image/jpeg", ext: ".jpg" });
|
||||
});
|
||||
|
||||
it("refuse une image au-delà de 10 MB", () => {
|
||||
const big = Buffer.concat([PNG, Buffer.alloc(MAX_EXTERNAL_IMAGE_SIZE)]);
|
||||
expect(() => validateImage(big)).toThrow(/trop volumineuse/);
|
||||
});
|
||||
|
||||
it("refuse un format non reconnu", () => {
|
||||
expect(() => validateImage(Buffer.from("pas une image"))).toThrow(/Format/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePageIds", () => {
|
||||
it("laisse un tableau intact", () => {
|
||||
expect(normalizePageIds(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("accepte un tableau JSON ou une liste séparée par des virgules", () => {
|
||||
expect(normalizePageIds('["a","b"]')).toEqual(["a", "b"]);
|
||||
expect(normalizePageIds("a, b,")).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
// Décodage et identification des images reçues par l'API externe : le type
|
||||
// annoncé par le client (multipart, data URL) n'est pas fiable, iOS et bien des
|
||||
// scripts envoient « application/octet-stream », donc on lit les octets.
|
||||
|
||||
export const MAX_EXTERNAL_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
export const IMAGE_EXTENSIONS: Record<string, string> = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
};
|
||||
|
||||
/** Type MIME d'après la signature du fichier, ou null si ce n'est pas une image acceptée. */
|
||||
export function sniffImageMime(buffer: Buffer): string | null {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
return "image/png";
|
||||
}
|
||||
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString("latin1"))) {
|
||||
return "image/gif";
|
||||
}
|
||||
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("latin1") === "RIFF" && buffer.subarray(8, 12).toString("latin1") === "WEBP") {
|
||||
return "image/webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Décode une image transmise en base64, brute ou sous forme de data URL
|
||||
* (`data:image/png;base64,...`).
|
||||
*/
|
||||
export function decodeImageData(data: string): Buffer {
|
||||
const match = /^data:[^;,]*;base64,([\s\S]*)$/.exec(data.trim());
|
||||
const base64 = (match ? match[1] : data).replace(/\s+/g, "");
|
||||
|
||||
if (base64.length === 0 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(base64)) {
|
||||
throw new Error("imageData doit être une image encodée en base64 (ou une data URL)");
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
/** Vérifie taille et format ; renvoie le type MIME et l'extension détectés. */
|
||||
export function validateImage(buffer: Buffer): { mimeType: string; ext: string } {
|
||||
if (buffer.length > MAX_EXTERNAL_IMAGE_SIZE) {
|
||||
throw new Error(`Image trop volumineuse (${(buffer.length / 1024 / 1024).toFixed(1)} MB, max 10 MB)`);
|
||||
}
|
||||
const mimeType = sniffImageMime(buffer);
|
||||
if (!mimeType) {
|
||||
throw new Error("Format d'image non reconnu. Formats acceptés : JPEG, PNG, WebP, GIF");
|
||||
}
|
||||
return { mimeType, ext: IMAGE_EXTENSIONS[mimeType] };
|
||||
}
|
||||
|
||||
/**
|
||||
* `pageIds` arrive en tableau en JSON, mais en texte en multipart :
|
||||
* on accepte un tableau JSON (`["a","b"]`) ou une liste séparée par des virgules.
|
||||
*/
|
||||
export function normalizePageIds(value: unknown): unknown {
|
||||
if (typeof value !== "string") return value;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return trimmed.split(",").map((id) => id.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* magasin), jusqu'ici recopiée dans chaque route.
|
||||
*/
|
||||
|
||||
import type { QwenTarget } from "../ffmpeg";
|
||||
import { storage } from "../../storage";
|
||||
import { buildMinioUrl, resolveInternalUrl } from "../minio";
|
||||
|
||||
@@ -39,13 +40,41 @@ export async function resolveLogoPath(): Promise<string | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Clé Gemini : configuration de l'application, sinon variable d'environnement. */
|
||||
/**
|
||||
* Clé Gemini : configuration de l'application, sinon variable d'environnement.
|
||||
* Aussi transmise avec Qwen, qui se replie sur Gemini si le service local échoue.
|
||||
*/
|
||||
export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string | undefined> {
|
||||
if (ttsEngine !== "gemini") return undefined;
|
||||
if (ttsEngine !== "gemini" && ttsEngine !== "qwen") return undefined;
|
||||
const appConfig = await storage.getAppConfig();
|
||||
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
||||
}
|
||||
|
||||
/** Service Qwen TTS réglé dans les paramètres (sinon ffmpeg-api prend QWEN_TTS_URL). */
|
||||
export async function resolveQwenTarget(ttsEngine?: string): Promise<QwenTarget | undefined> {
|
||||
if (ttsEngine !== "qwen") return undefined;
|
||||
return qwenTargetFromConfig(await storage.getAppConfig());
|
||||
}
|
||||
|
||||
/** Adresse http(s) du service Qwen saisie dans les paramètres, sans « / » final ; null si invalide. */
|
||||
export function normalizeQwenUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function qwenTargetFromConfig(
|
||||
appConfig: { qwenTtsUrl?: string | null; qwenTtsApiKey?: string | null } | undefined,
|
||||
): QwenTarget | undefined {
|
||||
if (!appConfig?.qwenTtsUrl) return undefined;
|
||||
return { url: appConfig.qwenTtsUrl, apiKey: appConfig.qwenTtsApiKey ?? undefined };
|
||||
}
|
||||
|
||||
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
|
||||
export async function resolveStoreName(
|
||||
userId: string,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Rendu d'un Reel à partir d'images avec Remotion.
|
||||
* Exécuté par la file `reel_jobs` ; la publication reste une étape distincte,
|
||||
* déclenchée par l'utilisateur après l'aperçu.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { cleanCaptionText, computeImagesTiming } from "@shared/captions";
|
||||
import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
||||
import { ffmpegService } from "../ffmpeg";
|
||||
import { generateVideoThumbnail } from "../thumbnail";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
export { REMOTION_TEMP_DIR };
|
||||
|
||||
export async function runImagesReelJob({ job, progress }: JobContext): Promise<ImagesReelResult> {
|
||||
const params = imagesReelParamsSchema.parse(job.params);
|
||||
const jobTempFiles: string[] = [];
|
||||
|
||||
try {
|
||||
await progress(5, "prepare");
|
||||
const images = await Promise.all(params.imageUrls.map(toDataUrl));
|
||||
const logoPath = await resolveLogoPath();
|
||||
const logoUrl = logoPath ? await toDataUrl(logoPath) : undefined;
|
||||
const musicUrl = params.musicUrl ? localHttpUrl(params.musicUrl) : undefined;
|
||||
const { overlayText, storeName } = params;
|
||||
|
||||
// --- Voix et minutage réel des mots ---
|
||||
let audioUrl: string | undefined;
|
||||
let words: { text: string; start: number; end: number }[] = [];
|
||||
let audioDuration = 0;
|
||||
|
||||
const spokenText = overlayText ? cleanCaptionText(overlayText) : "";
|
||||
if (params.ttsEnabled && spokenText) {
|
||||
await progress(15, "voice");
|
||||
const voice = await ffmpegService.previewVoice(spokenText, {
|
||||
voice: params.ttsVoice,
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||
});
|
||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||
|
||||
const audioPath = path.join(REMOTION_TEMP_DIR, `tts-${job.id}.mp3`);
|
||||
await fs.promises.writeFile(audioPath, voice.audio);
|
||||
jobTempFiles.push(audioPath);
|
||||
audioUrl = tempFileUrl(audioPath);
|
||||
audioDuration = voice.duration;
|
||||
words = voice.words;
|
||||
}
|
||||
|
||||
// --- Durée : 25 à 30 s (mêmes règles que l'aperçu) ---
|
||||
const { total, endingSeconds } = computeImagesTiming({
|
||||
imageCount: images.length,
|
||||
voiceDuration: audioDuration,
|
||||
hasEnding: Boolean(logoUrl || storeName),
|
||||
});
|
||||
|
||||
await progress(25, "render");
|
||||
const outputLocation = path.join(REMOTION_TEMP_DIR, `out-${job.id}.mp4`);
|
||||
let lastReported = 25;
|
||||
await renderReelComposition({
|
||||
compositionId: "ImageVideo",
|
||||
outputLocation,
|
||||
inputProps: {
|
||||
images,
|
||||
totalDuration: total,
|
||||
words,
|
||||
captionStyle: params.captionStyle,
|
||||
audioUrl,
|
||||
musicUrl,
|
||||
musicVolume: params.musicVolume,
|
||||
logoUrl,
|
||||
showWatermark: params.showLogo,
|
||||
storeName,
|
||||
endingSeconds,
|
||||
},
|
||||
onProgress: (ratio) => {
|
||||
const pct = 25 + Math.floor(ratio * 70);
|
||||
if (pct >= lastReported + 5) {
|
||||
lastReported = pct;
|
||||
progress(pct, "render").catch(() => { /* non bloquant */ });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const thumbnailPath = outputLocation.replace(/\.mp4$/, "-thumb.jpg");
|
||||
const thumbnailOk = await generateVideoThumbnail(outputLocation, thumbnailPath, 2);
|
||||
|
||||
return {
|
||||
url: `/uploads/temp/${path.basename(outputLocation)}`,
|
||||
thumbnailUrl: thumbnailOk ? `/uploads/temp/${path.basename(thumbnailPath)}` : null,
|
||||
};
|
||||
} finally {
|
||||
// Images, musique importée et voix ne servent plus une fois le rendu fini
|
||||
for (const file of [...params.tempFiles, ...jobTempFiles]) {
|
||||
await fs.promises.unlink(file).catch(() => { /* déjà absent */ });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,8 @@
|
||||
|
||||
import { registerReelJobHandler, startReelWorker } from "./queue";
|
||||
import { runVideoReelJob } from "./videoPipeline";
|
||||
import { runImagesReelJob } from "./imagesPipeline";
|
||||
|
||||
export function startReelProcessing(): Promise<void> {
|
||||
registerReelJobHandler("video", runVideoReelJob);
|
||||
registerReelJobHandler("images", runImagesReelJob);
|
||||
return startReelWorker();
|
||||
}
|
||||
@@ -45,7 +45,7 @@ function renderConcurrency(): number {
|
||||
}
|
||||
|
||||
export async function renderReelComposition(options: {
|
||||
compositionId: "ReelVideo" | "ImageVideo";
|
||||
compositionId: "ReelVideo";
|
||||
inputProps: Record<string, unknown>;
|
||||
outputLocation: string;
|
||||
onProgress?: (ratio: number) => void;
|
||||
|
||||
@@ -12,13 +12,12 @@
|
||||
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { FADE_SECONDS } from "@shared/captions";
|
||||
import { videoReelParamsSchema, type VideoReelParams } from "@shared/reel";
|
||||
import { storage } from "../../storage";
|
||||
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
||||
import { resolveInternalUrl } from "../minio";
|
||||
import type { JobContext } from "./queue";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl } from "./assets";
|
||||
import { resolveGeminiApiKey, resolveLogoPath, resolveMusicUrl, resolveQwenTarget } from "./assets";
|
||||
import { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
||||
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
@@ -40,10 +39,11 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
throw new Error("Vidéo source introuvable ou invalide");
|
||||
}
|
||||
|
||||
const [musicUrl, logoPath, geminiApiKey] = await Promise.all([
|
||||
const [musicUrl, logoPath, geminiApiKey, qwen] = await Promise.all([
|
||||
resolveMusicUrl(params.musicTrackId, params.musicUrl),
|
||||
resolveLogoPath(),
|
||||
resolveGeminiApiKey(params.ttsEngine),
|
||||
resolveQwenTarget(params.ttsEngine),
|
||||
]);
|
||||
|
||||
const options: ReelRenderOptions = {
|
||||
@@ -55,6 +55,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
||||
ttsEngine: params.ttsEngine,
|
||||
ttsStyle: params.ttsStyle,
|
||||
geminiApiKey,
|
||||
qwen,
|
||||
fontSize: params.fontSize,
|
||||
musicVolume: params.musicVolume,
|
||||
drawText: params.drawText,
|
||||
@@ -139,7 +140,8 @@ async function renderWithRemotion(
|
||||
showWatermark: params.showLogo,
|
||||
storeName: params.enableEndingEffect ? params.storeName : undefined,
|
||||
logoStart: prepared.logoStart,
|
||||
fadeStart: params.enableEndingEffect ? Math.max(0, prepared.totalDuration - FADE_SECONDS) : null,
|
||||
fadeStart: prepared.fadeStart,
|
||||
fadeDuration: prepared.fadeDuration,
|
||||
mixedAudioUrl: prepared.audioPath ? tempFileUrl(prepared.audioPath) : undefined,
|
||||
},
|
||||
onProgress: (ratio) => {
|
||||
|
||||
+2
-2
@@ -210,7 +210,7 @@ export interface IStorage {
|
||||
|
||||
// App Config
|
||||
getAppConfig(): Promise<AppConfig | undefined>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage {
|
||||
return { ...created, clientSecret: decrypt(created.clientSecret) };
|
||||
}
|
||||
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
|
||||
const existing = await this.getAppConfig();
|
||||
if (existing) {
|
||||
const [updated] = await db.update(appConfig)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
cleanCaptionText,
|
||||
computeImagesTiming,
|
||||
computeReelTiming,
|
||||
groupLines,
|
||||
offsetWords,
|
||||
@@ -24,15 +23,16 @@ describe("computeReelTiming", () => {
|
||||
|
||||
it("n'a ni logo ni fondu sans effet de fin", () => {
|
||||
const timing = computeReelTiming({ videoDuration: 12, hasOutro: true, endingEffect: false });
|
||||
expect(timing).toEqual({ total: 12, logoStart: null, fadeStart: null });
|
||||
expect(timing).toEqual({ total: 12, logoStart: null, fadeStart: null, fadeDuration: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeImagesTiming", () => {
|
||||
it("garde un Reel d'images entre 25 et 30 s", () => {
|
||||
expect(computeImagesTiming({ imageCount: 3, hasEnding: true })).toEqual({ total: 25, endingSeconds: 3 });
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 40, hasEnding: true }).total).toBe(30);
|
||||
expect(computeImagesTiming({ imageCount: 4, voiceDuration: 24, hasEnding: false }).total).toBe(25);
|
||||
describe("fondu final", () => {
|
||||
it("ne commence jamais avant la fin de la voix (mêmes valeurs que Python)", () => {
|
||||
const timing = computeReelTiming({ videoDuration: 4, voiceDuration: 5, hasOutro: false, endingEffect: true });
|
||||
expect(timing.total).toBe(7.8);
|
||||
expect(timing.fadeStart).toBeGreaterThanOrEqual(7);
|
||||
expect(timing.fadeDuration).toBeGreaterThanOrEqual(0.3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+9
-17
@@ -32,12 +32,15 @@ const VOICE_TAIL = 0.8;
|
||||
const OUTRO_MIN = 2.5;
|
||||
const LOGO_SECONDS = 5;
|
||||
export const FADE_SECONDS = 2;
|
||||
/** Fondu raccourci quand la voix finit tard, mais jamais sec. */
|
||||
const MIN_FADE = 0.3;
|
||||
|
||||
export interface ReelTiming {
|
||||
total: number;
|
||||
/** Début de l'effet de fin (grand logo), ou null sans effet de fin. */
|
||||
logoStart: number | null;
|
||||
fadeStart: number | null;
|
||||
fadeDuration: number | null;
|
||||
}
|
||||
|
||||
/** Durée finale : la vidéo s'allonge (dernière image figée) si la voix dépasse. */
|
||||
@@ -57,10 +60,15 @@ export function computeReelTiming(input: {
|
||||
if (hasOutro) total = Math.max(total, speechEnd + OUTRO_MIN);
|
||||
}
|
||||
total = Math.round(total * 1000) / 1000;
|
||||
// Le fondu final ne commence jamais avant la fin de la voix
|
||||
let fadeStart = Math.max(0, total - FADE_SECONDS);
|
||||
if (input.voiceDuration) fadeStart = Math.max(fadeStart, Math.min(speechEnd + 0.15, total - MIN_FADE));
|
||||
fadeStart = Math.round(fadeStart * 1000) / 1000;
|
||||
return {
|
||||
total,
|
||||
logoStart: hasOutro ? Math.max(0, total - LOGO_SECONDS, speechEnd) : null,
|
||||
fadeStart: input.endingEffect ? Math.max(0, total - FADE_SECONDS) : null,
|
||||
fadeStart: input.endingEffect ? fadeStart : null,
|
||||
fadeDuration: input.endingEffect ? Math.max(MIN_FADE, total - fadeStart) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,19 +129,3 @@ export function spreadWords(text: string, start: number, end: number): TimedWord
|
||||
export function offsetWords(words: TimedWord[], offset: number): TimedWord[] {
|
||||
return words.map((w) => ({ ...w, start: w.start + offset, end: w.end + offset }));
|
||||
}
|
||||
|
||||
const IMAGES_ENDING_SECONDS = 3;
|
||||
const IMAGES_MIN_CONTENT = 22;
|
||||
const IMAGES_MAX_CONTENT = 27;
|
||||
const SECONDS_PER_IMAGE = 3;
|
||||
|
||||
/** Reel d'images : 25 à 30 s, diapositive de fin comprise. */
|
||||
export function computeImagesTiming(input: { imageCount: number; voiceDuration?: number; hasEnding: boolean }): {
|
||||
total: number;
|
||||
endingSeconds: number;
|
||||
} {
|
||||
const natural = Math.max((input.voiceDuration ?? 0) + 1, input.imageCount * SECONDS_PER_IMAGE);
|
||||
const content = Math.min(Math.max(natural, IMAGES_MIN_CONTENT), IMAGES_MAX_CONTENT);
|
||||
const endingSeconds = input.hasEnding ? IMAGES_ENDING_SECONDS : 0;
|
||||
return { total: content + endingSeconds, endingSeconds };
|
||||
}
|
||||
+1
-7
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imagesReelParamsSchema, ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||
import { ttsPreviewSchema, videoReelParamsSchema } from "./reel";
|
||||
|
||||
describe("videoReelParamsSchema", () => {
|
||||
it("exige une vidéo et une page, avec des messages lisibles", () => {
|
||||
@@ -22,12 +22,6 @@ describe("videoReelParamsSchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("imagesReelParamsSchema", () => {
|
||||
it("limite à 4 images", () => {
|
||||
expect(imagesReelParamsSchema.safeParse({ imageUrls: ["a", "b", "c", "d", "e"] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ttsPreviewSchema", () => {
|
||||
it("refuse un texte vide", () => {
|
||||
expect(ttsPreviewSchema.safeParse({ text: " " }).success).toBe(false);
|
||||
|
||||
+1
-27
@@ -48,24 +48,6 @@ export const videoReelParamsSchema = z.object({
|
||||
|
||||
export type VideoReelParams = z.infer<typeof videoReelParamsSchema>;
|
||||
|
||||
/** Reel construit à partir d'images (rendu Remotion). */
|
||||
export const imagesReelParamsSchema = z.object({
|
||||
// Chemins relatifs /uploads/... ou URL absolues
|
||||
imageUrls: z.array(z.string().min(1)).min(1, "Aucune image fournie").max(4),
|
||||
overlayText: optionalText,
|
||||
musicUrl: optionalText,
|
||||
musicVolume: z.number().min(0).max(2).default(0.3),
|
||||
ttsEnabled: z.boolean().default(true),
|
||||
ttsEngine: z.enum(TTS_ENGINES).optional(),
|
||||
ttsVoice: optionalText,
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
captionStyle: z.enum(CAPTION_STYLES).default(DEFAULT_CAPTION_STYLE),
|
||||
showLogo: z.boolean().default(true),
|
||||
storeName: z.string().optional(),
|
||||
// Fichiers temporaires à supprimer une fois le rendu terminé
|
||||
tempFiles: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Aperçu de la voix. */
|
||||
export const ttsPreviewSchema = z.object({
|
||||
text: z.string({ required_error: "Texte requis" }).trim().min(1, "Texte requis").max(2000),
|
||||
@@ -74,14 +56,6 @@ export const ttsPreviewSchema = z.object({
|
||||
ttsStyle: z.enum(TTS_STYLES).optional(),
|
||||
});
|
||||
|
||||
export type ImagesReelParams = z.infer<typeof imagesReelParamsSchema>;
|
||||
|
||||
export type ReelJobKind = "video" | "images";
|
||||
export type ReelJobKind = "video";
|
||||
|
||||
export type ReelJobStatus = "pending" | "processing" | "completed" | "failed";
|
||||
|
||||
/** Résultat d'un rendu d'images, consulté par le client pour l'aperçu. */
|
||||
export interface ImagesReelResult {
|
||||
url: string;
|
||||
thumbnailUrl: string | null;
|
||||
}
|
||||
@@ -260,6 +260,9 @@ export const appConfig = pgTable("app_config", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
externalApiKey: text("external_api_key"),
|
||||
geminiApiKey: text("gemini_api_key"),
|
||||
// Service Qwen3-TTS (local ou serveur GPU distant) : adresse et clé d'accès
|
||||
qwenTtsUrl: text("qwen_tts_url"),
|
||||
qwenTtsApiKey: text("qwen_tts_api_key"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
+18
-3
@@ -3,7 +3,7 @@
|
||||
* Même catalogue que le service Python (ffmpeg-service/app/voices.py).
|
||||
*/
|
||||
|
||||
export const TTS_ENGINES = ["gemini", "edge"] as const;
|
||||
export const TTS_ENGINES = ["qwen", "gemini", "edge"] as const;
|
||||
export type TtsEngine = (typeof TTS_ENGINES)[number];
|
||||
|
||||
export const TTS_STYLES = ["neutral", "dynamic", "warm", "calm", "promo"] as const;
|
||||
@@ -16,7 +16,7 @@ export interface VoiceOption {
|
||||
}
|
||||
|
||||
export const TTS_STYLE_OPTIONS: { id: TtsStyle; label: string; description: string }[] = [
|
||||
{ id: "dynamic", label: "Dynamique", description: "Enthousiaste, rythme entraînant" },
|
||||
{ id: "dynamic", label: "Dynamique", description: "Enthousiaste, débit naturel" },
|
||||
{ id: "warm", label: "Chaleureux", description: "Souriant et proche" },
|
||||
{ id: "promo", label: "Promo", description: "Annonce énergique des offres" },
|
||||
{ id: "calm", label: "Calme", description: "Posé et rassurant" },
|
||||
@@ -64,13 +64,28 @@ export const EDGE_VOICES: VoiceOption[] = [
|
||||
{ id: "fr-FR-HenriNeural", label: "Henri", gender: "male" },
|
||||
];
|
||||
|
||||
/** Voix prédéfinies de Qwen3-TTS (local). Les voix clonées sont listées par le service. */
|
||||
export const QWEN_VOICES: VoiceOption[] = [
|
||||
{ id: "serena", label: "Serena — douce et chaleureuse", gender: "female" },
|
||||
{ id: "vivian", label: "Vivian — vive et lumineuse", gender: "female" },
|
||||
{ id: "sohee", label: "Sohee — expressive", gender: "female" },
|
||||
{ id: "ono_anna", label: "Anna — espiègle", gender: "female" },
|
||||
{ id: "aiden", label: "Aiden — solaire", gender: "male" },
|
||||
{ id: "ryan", label: "Ryan — dynamique", gender: "male" },
|
||||
{ id: "uncle_fu", label: "Fu — grave et posée", gender: "male" },
|
||||
{ id: "dylan", label: "Dylan — jeune et naturelle", gender: "male" },
|
||||
{ id: "eric", label: "Eric — légèrement voilée", gender: "male" },
|
||||
];
|
||||
|
||||
export const DEFAULT_VOICE: Record<TtsEngine, string> = {
|
||||
qwen: "serena",
|
||||
gemini: "Kore",
|
||||
edge: "fr-FR-VivienneMultilingualNeural",
|
||||
};
|
||||
|
||||
export const DEFAULT_TTS_STYLE: TtsStyle = "dynamic";
|
||||
export const DEFAULT_TTS_STYLE: TtsStyle = "neutral";
|
||||
|
||||
export function voicesFor(engine: TtsEngine): VoiceOption[] {
|
||||
if (engine === "qwen") return QWEN_VOICES;
|
||||
return engine === "gemini" ? GEMINI_VOICES : EDGE_VOICES;
|
||||
}
|
||||
Reference in new issue
Block a user