Compare commits

...
Author SHA1 Message Date
Claude 086ef956e2 Reels : option « Publier aussi en story » sur Facebook
Une case sur les écrans de création (bureau, mobile) et de programmation
d'un Reel double la publication d'une story vidéo sur les pages Facebook
sélectionnées. TikTok n'ayant pas de stories, l'option ne le concerne pas.

- Publication immédiate : la story part juste après le Reel, à partir de la
  vidéo rendue envoyée en binaire (nouvelle publishVideoStoryFromBuffer).
  Un échec de la story est noté dans l'historique sans faire échouer le Reel.
- Publication programmée : une entrée « story » est planifiée à côté du
  Reel ; le planificateur la publie comme les autres stories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPMYHUDaZyrG3xRCM5iGVr
2026-10-05 06:50:33 +00:00
LogiFlow 7806f8bdc8 Merge pull request #122 from R0m1k3/claude/dreamy-feynman-jxb0g3
Retire le générateur vidéo et les badges « New », Reels en échec supprimables
2026-09-29 15:52:21 +02:00
Claude da9a74dbe6 Retire le générateur vidéo, les badges « New » et permet d'effacer les Reels en échec
- Suppression de la page « Générateur Vidéo » (/remotion) : routes
  /api/remotion, pipeline de rendu d'images, composition ImageVideo et
  aperçu associé. Le générateur de Reels est conservé.
- Plus de badge « New » à côté des menus.
- Tableau de bord : bouton pour supprimer un Reel en échec et sa
  notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
2026-09-29 13:32:17 +00:00
LogiFlow e89dd97881 Merge pull request #121 from R0m1k3/claude/dreamy-feynman-jxb0g3
Reels : programmer la publication d'une vidéo déjà montée
2026-09-29 15:20:21 +02:00
Claude 188706f4dc Reels : programmer la publication d'une vidéo déjà montée
Nouvelle page « Programmer un Reel » (/reel/schedule) : envoi ou choix
d'une vidéo, description, pages Facebook/TikTok, date et heure. Le Reel
est publié tel quel par le planificateur, sans passer par le générateur.

- POST /api/posts valide les Reels (une vidéo, pages Facebook/TikTok)
- vignette extraite à l'envoi des vidéos pour l'historique

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017tFkVXQDATYfw5aFtjQahi
2026-09-29 13:16:06 +00:00
LogiFlow 2c732c11b9 Merge pull request #120 from R0m1k3/claude/trusting-hawking-47qpzb
Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
2026-09-29 14:02:00 +02:00
Claude 338546662f Voix : Qwen gardé, nombres en lettres reconnus, Gemini plus court
Vu en production avec Qwen sur GPU : la plupart des phrases basculaient sur
Gemini puis Edge (voix différente d'un sous-titre à l'autre).
- Vérification de lecture : les nombres en lettres (« trente », « vingt-cinq
  pour cent ») et leur signe (« plus », « moins ») sont comparés comme les
  chiffres écrits par Whisper, au lieu de passer pour des mots sautés.
- Prises emballées (14 s pour 34 caractères) écartées d'office ; côté
  qwen-tts, max_new_tokens borne la durée à « caractères / 6 + 3 » s.
- Le moteur choisi est gardé tant qu'il lit au moins 70 % du texte
  (avec avertissement), au lieu de 85 % ; 3 tentatives Qwen.
- Gemini : délai d'attente 45 s au lieu de 120 s, erreurs journalisées en repr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 12:01:42 +00:00
LogiFlow 90a9b6be4d Merge pull request #119 from R0m1k3/claude/trusting-hawking-47qpzb
qwen-tts : chargement des modèles hors ligne
2026-09-29 13:31:25 +02:00
Claude 90f936cf4f qwen-tts : chargement des modèles hors ligne
transformers interroge l'API Hugging Face au chargement quand le modèle est
désigné par son nom de dépôt (détection des tokenizers Mistral), ce qui
plantait le démarrage avec HF_HUB_OFFLINE=1 (conteneur en boucle sur Unraid).
Le modèle est désormais chargé depuis son dossier local dans le cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 11:31:06 +00:00
LogiFlow 5f271e0831 Merge pull request #118 from R0m1k3/claude/trusting-hawking-47qpzb
Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres
2026-09-29 12:09:10 +02:00
Claude fdace9d8d5 Qwen TTS : serveur GPU distant (Unraid) réglé dans les paramètres
- qwen-tts : GPU NVIDIA détecté automatiquement (bfloat16), image CUDA 12.8
  (RTX 50xx comprises) via TORCH_VARIANT=cu128, docker-compose.gpu.yml
  dédié (clé obligatoire, choix de la carte) et guide d'installation Unraid.
- Paramètres → « Qwen TTS (voix locale) » : adresse + clé du service, test
  de connexion (modèle et carte graphique affichés), stockés en base
  (app_config.qwen_tts_url / qwen_tts_api_key) et transmis à ffmpeg-api à
  chaque voix ; QWEN_TTS_URL reste un défaut.
- Le service qwen-tts CPU du docker-compose principal devient optionnel
  (profil qwen-local).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 09:57:32 +00:00
LogiFlow e312733d43 Merge pull request #117 from R0m1k3/claude/trusting-hawking-47qpzb
Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
2026-09-29 11:43:30 +02:00
Claude 44daa88a49 Voix : plus d'accélération sur les SRT, moteur local Qwen3-TTS
SRT : la voix n'est plus jamais accélérée (atempo sans plafond, jusqu'à 2×
et plus, rendait la voix incompréhensible). Un morceau plus long que son
sous-titre déborde et décale les suivants ; les sous-titres suivent la voix,
la vidéo s'allonge et un avertissement signale le décalage.

Rythme : style par défaut « neutre », consignes Gemini sans « rythme
entraînant », débit Edge des styles dynamique/promo ramené à +0 %/+3 %.

Nouveau moteur « qwen » : service qwen-tts (Qwen3-TTS sur CPU, gratuit,
français, ton piloté par consigne, clonage de voix depuis qwen-tts/voices).
Même vérification Whisper que Gemini, généralisée ; repli qwen → gemini →
edge, et une lecture sous 85 % du texte est écartée (mots sautés/faux).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Upu97wMmsRkBoj6iVM4rH6
2026-09-29 09:39:47 +00:00
Claude 4b8fbe6d8e fix(voix SRT): prix et unités reconnus, coupes dans les vraies pauses
- Contrôle de lecture : les nombres et l'unité qui les suit ne sont plus
  comparés lettre à lettre. Whisper écrit « 11 € 99 » ou « 40 cm » là où le
  texte dit « 11,99 € » ou « 40 centimètres » : une lecture correcte passait
  pour des mots sautés (88–91 %) et relançait jusqu'à 3 générations.
- Découpe de la lecture SRT : la coupe entre deux sous-titres se fait dans
  la pause mesurée la plus proche de la borne estimée (fins de phrases
  préférées aux virgules), et non plus à la borne Whisper elle-même, fausse
  quand un prix non reconnu termine la phrase : la coupe tombait alors au
  milieu d'une phrase.
- Whisper reçoit 0,5 s de silence avant la voix : il laissait passer le
  premier mot (« Alerte ») d'une voix qui démarre aussitôt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3ywVwJUjjUXuvM6PwSzSi
2026-09-28 16:44:41 +00:00
57 changed files with 1804 additions and 1738 deletions

No files matched your search

+15
View File
@@ -132,6 +132,21 @@ GEMINI_API_KEY=
# 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 ;
+12 -78
View File
@@ -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,7 +940,7 @@ 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`.
@@ -1103,7 +1037,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
View File
@@ -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>
+41 -2
View File
@@ -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>
);
}
+5 -38
View File
@@ -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,
@@ -153,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,
]);
@@ -172,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))}
@@ -0,0 +1,29 @@
import { Switch } from "@/components/ui/switch";
interface StoryToggleProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
/**
* Double le Reel d'une story sur les pages Facebook sélectionnées. TikTok n'a
* pas de stories : l'option ne le concerne pas.
*/
export function StoryToggle({ checked, onCheckedChange }: StoryToggleProps) {
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/50 p-3">
<label htmlFor="reel-also-story" className="space-y-0.5 cursor-pointer">
<div className="text-sm font-medium">Publier aussi en story</div>
<div className="text-xs text-muted-foreground">
La vidéo est aussi publiée en story sur les pages Facebook sélectionnées (visible 24 h).
</div>
</label>
<Switch
id="reel-also-story"
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
data-testid="switch-reel-also-story"
/>
</div>
);
}
+21 -2
View File
@@ -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"
+4 -4
View File
@@ -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 = [
+2 -2
View File
@@ -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" },
+4
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { useToast } from "@/hooks/use-toast";
@@ -83,6 +84,7 @@ export default function MobileNewReel() {
const [isPlaying, setIsPlaying] = useState<string | null>(null);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -272,6 +274,7 @@ export default function MobileNewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
stabilize: stabilize,
drawText: true,
@@ -610,6 +613,7 @@ export default function MobileNewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
-538
View File
@@ -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>
);
}
+4
View File
@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Switch } from "@/components/ui/switch";
import { StoryToggle } from "@/components/reels/story-toggle";
import { Slider } from "@/components/ui/slider";
import {
Select,
@@ -78,6 +79,7 @@ export default function NewReel() {
const [drawText, setDrawText] = useState(true);
// Désactivée par défaut : double le temps de rendu, utile seulement pour une vidéo tremblée
const [stabilize, setStabilize] = useState(false);
const [alsoStory, setAlsoStory] = useState(false);
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
const [showLogo, setShowLogo] = useState(true);
@@ -415,6 +417,7 @@ export default function NewReel() {
description: srtFile ? srtText(srtFile.cues) : overlayText,
pageIds: selectedPages,
scheduledFor: scheduledDate?.toISOString(),
alsoStory,
musicVolume: musicVolume[0] / 100,
ttsEnabled,
ttsEngine,
@@ -938,6 +941,7 @@ export default function NewReel() {
Pages Facebook
</div>
{facebookPages.map(renderTargetCheckbox)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</div>
)}
-490
View File
@@ -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>
);
}
+300
View File
@@ -0,0 +1,300 @@
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 { StoryToggle } from "@/components/reels/story-toggle";
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 [alsoStory, setAlsoStory] = useState(false);
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(),
alsoStory,
});
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)}
<StoryToggle checked={alsoStory} onCheckedChange={setAlsoStory} />
</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>
);
}
+4
View File
@@ -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">
-122
View File
@@ -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>
);
};
-16
View File
@@ -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}
/>
</>
);
};
+38
View File
@@ -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
+12 -2
View File
@@ -116,12 +116,22 @@ def _model():
return WhisperModel(config.WHISPER_MODEL, device="cpu", compute_type=config.WHISPER_COMPUTE_TYPE)
# 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,
condition_on_previous_text=False,
@@ -129,7 +139,7 @@ def _transcribe_sync(audio: Path) -> list[Word]:
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()
+15 -2
View File
@@ -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")
+8
View File
@@ -36,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", "")
+103 -9
View File
@@ -9,6 +9,7 @@ pas rogner une syllabe.
import asyncio
import difflib
import re
from dataclasses import dataclass, field
from pathlib import Path
@@ -49,8 +50,8 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
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(normalize(w) for w in expected_text.split())
heard = "".join(normalize(w.text) for w in spoken)
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:
@@ -70,13 +71,72 @@ def check_reading(expected_text: str, spoken: list[Word]) -> ReadingCheck:
)
# 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 = {normalize(w.text) for w in spoken}
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 and not any(difflib.SequenceMatcher(a=token, b=h).ratio() >= 0.7 for h in heard):
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
@@ -90,6 +150,10 @@ 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
@@ -139,6 +203,19 @@ class Envelope:
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)
@@ -170,9 +247,26 @@ async def load_envelope(path: Path) -> Envelope:
return envelope_from_samples(np.frombuffer(raw, dtype=np.float32), 16000)
def sentence_cut(envelope: Envelope, previous_end: float, next_start: float) -> float:
"""Coupe entre deux phrases, au point le plus calme de la pause. Les bornes
Whisper sont imprécises : on cherche un peu autour."""
low = max(0.0, min(previous_end, next_start) - 0.1)
high = max(previous_end, next_start) + 0.2
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)
+43 -49
View File
@@ -11,8 +11,10 @@ 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é.
Quand un morceau est plus long que son sous-titre, il est accéléré (sans
changer la hauteur de la voix) pour finir à temps.
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
@@ -26,10 +28,10 @@ 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
# 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"[.!?…]$")
@@ -50,31 +52,16 @@ class Segment:
words: list[Word]
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
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 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 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 as_sentence(text: str) -> str:
@@ -103,7 +90,7 @@ def segments_from_reading(groups: list[list[Word]], envelope: quality.Envelope)
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))
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
@@ -123,13 +110,16 @@ 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")
options = dict(engine=engine, voice=voice, style=style, gemini_api_key=gemini_api_key)
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]
# 1. Lecture d'une seule traite, découpée dans les silences
@@ -162,26 +152,30 @@ async def synthesize_cues(
span = cue_envelope.speech_span(0.0, cue_envelope.duration)
sources.append((track.path, Segment(span[0], span[1], track.words)))
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, window, (source, segment)) in enumerate(zip(cues, cue_windows(cues), sources)):
factor = speed_factor(segment.source_end - segment.source_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."
)
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, factor, fitted), timeout=120)
placed.append((fitted, cue.start))
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
start = cue.start + max(0.0, w.start - segment.source_start) / factor
end = cue.start + max(0.0, w.end - segment.source_start) / factor
words.append(Word(text or w.text, start, min(max(end, start + 0.05), cue.start + window)))
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é."
)
output = workdir / "voice_srt.wav"
await proc.run(mix_command(placed, output), timeout=300)
@@ -197,14 +191,14 @@ async def synthesize_cues(
return tts.VoiceTrack(output, duration, _monotonic(words), used_engine, used_voice, warnings)
def extract_command(source: Path, segment: Segment, factor: float, output: Path) -> list[str]:
"""Extrait un morceau de voix, avec un fondu de 10 ms aux bords (pas de clic)
et l'accélération éventuelle."""
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,{atempo_chain(factor)}"
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),
+72 -22
View File
@@ -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, config, proc, quality
from ..align import Word
from . import edge, gemini
from . import edge, gemini, qwen
log = logging.getLogger(__name__)
@@ -30,17 +32,43 @@ async def synthesize(
style: str | None,
gemini_api_key: str | None,
workdir: Path,
qwen_target: qwen.Target | None = None,
) -> VoiceTrack:
warnings: list[str] = []
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:
result = await _gemini_checked(text, voice, style, gemini_api_key, workdir, warnings)
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"
@@ -61,45 +89,67 @@ async def synthesize(
return VoiceTrack(processed, duration, words, used_engine, used_voice, warnings)
async def _gemini_checked(
text: str, voice: str | None, style: str | None, api_key: str, workdir: Path, warnings: list[str]
# 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 Gemini dont la lecture a été vérifiée : Whisper réécoute chaque
"""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 se replier sur Edge."""
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, config.GEMINI_TTS_ATTEMPTS)):
for attempt in range(max(1, attempts)):
try:
raw, gemini_voice = await gemini.synthesize(text, voice, style, api_key, workdir, attempt)
except Exception as error: # noqa: BLE001 — nouvelle tentative, puis repli sur Edge
log.warning("Gemini TTS en échec (tentative %d) : %s", attempt + 1, error)
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_gemini_{attempt}.wav"
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 Gemini (tentative %d) : %s", attempt + 1, check.describe())
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, gemini_voice)
best = (check, processed, spoken, engine_voice)
if check.acceptable:
break
if best is None:
warnings.append(f"Gemini indisponible ({last_error}) : voix Edge utilisée à la place.")
warnings.append(f"{name} : aucune voix utilisable ({last_error!r}) : voix de secours utilisée.")
return None
check, processed, spoken, gemini_voice = best
check, processed, spoken, engine_voice = best
if not check.acceptable:
if check.coverage < 0.7:
warnings.append(f"Gemini a mal lu le texte ({check.describe()}) : voix Edge utilisée à la place.")
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 Gemini imparfaite ({check.describe()}) : écoutez le résultat.")
return processed, spoken, gemini_voice, check.acceptable
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
@@ -108,7 +158,7 @@ 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 Gemini ajoute parfois avant le premier ou après le
"""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:
+3 -2
View File
@@ -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"),
}
+2 -1
View File
@@ -44,7 +44,8 @@ async def _request(model: str, prompt: str, voice: str, api_key: str, seed: int)
generation["temperature"] = config.GEMINI_TTS_TEMPERATURE
generation["seed"] = seed
payload = {"contents": [{"parts": [{"text": prompt}]}], "generationConfig": generation}
async with httpx.AsyncClient(timeout=120) as client:
# 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",
+75
View File
@@ -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
+51 -5
View File
@@ -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()],
+47
View File
@@ -71,3 +71,50 @@ def test_skipped_words_are_detected_and_named():
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
+15 -12
View File
@@ -7,29 +7,32 @@ from app.quality import Envelope
from app.render import RenderPlan
from app.srt_voice import (
Cue,
Segment,
as_sentence,
atempo_chain,
cue_windows,
extract_command,
mix_command,
place_segments,
segments_from_reading,
speed_factor,
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_each_cue_becomes_a_sentence_for_the_reading():
+36
View File
@@ -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
+35
View File
@@ -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"]
+55
View File
@@ -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 |
+32
View File
@@ -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
+195
View File
@@ -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})
+6
View File
@@ -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
+17
View File
@@ -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.
+7
View File
@@ -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"
+99 -5
View File
@@ -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,
@@ -1203,7 +1268,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
try {
const user = req.user as User;
const userId = user.id;
const { pageIds, postType, mediaId, mediaIds, ...postFields } = req.body;
const { pageIds, postType, mediaId, mediaIds, alsoStory, ...postFields } = req.body;
// Convert mediaIds to standardized format: array of { mediaId, displayOrder }
let finalMediaItems: Array<{ mediaId: string; displayOrder: number }> = [];
@@ -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);
@@ -1313,6 +1395,18 @@ export async function registerRoutes(app: Express): Promise<Server> {
postType: finalPostType,
scheduledAt,
});
// Reel doublé d'une story : seulement sur Facebook, TikTok n'en a pas
if (finalPostType === 'reel' && alsoStory === true) {
const page = await storage.getSocialPage(pageId);
if (page?.platform === 'facebook') {
await storage.createScheduledPost({
postId: post.id,
pageId,
postType: 'story',
scheduledAt,
});
}
}
}
}
}
+22 -1
View File
@@ -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,
-150
View File
@@ -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) });
}
});
+55
View File
@@ -514,6 +514,61 @@ export class FacebookService {
return finishData.post_id || videoId;
}
/**
* Publie une vidéo en story Facebook en envoyant directement les données
* binaires (start → upload → finish sur /video_stories). Sert à doubler un
* Reel d'une story : la vidéo rendue est déjà en mémoire.
*/
async publishVideoStoryFromBuffer(page: SocialPage, videoBuffer: Buffer): Promise<string> {
if (!page.accessToken) throw new Error('No access token found for this page');
const accessToken = page.accessToken;
const fileSize = videoBuffer.length;
const startParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'start',
});
const startRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${startParams.toString()}`, {
method: 'POST',
});
if (!startRes.ok) {
const error = await startRes.json() as FacebookError;
throw new Error(`Facebook Story API error (START phase): ${error.error.message} (code: ${error.error.code})`);
}
const { video_id: videoId, upload_url: uploadUrl } = await startRes.json() as { video_id: string; upload_url: string };
const uploadRes = await fetch(uploadUrl, {
method: 'POST',
headers: {
'Authorization': `OAuth ${accessToken}`,
'Content-Type': 'application/octet-stream',
'offset': '0',
'file_size': fileSize.toString(),
},
body: new Blob([videoBuffer], { type: 'video/mp4' }),
});
if (!uploadRes.ok) {
throw new Error(`Facebook Story API error (UPLOAD phase): ${await uploadRes.text()}`);
}
const finishParams = new URLSearchParams({
access_token: accessToken,
upload_phase: 'finish',
video_id: videoId,
});
const finishRes = await fetch(`${this.baseUrl}/${page.pageId}/video_stories?${finishParams.toString()}`, {
method: 'POST',
});
if (!finishRes.ok) {
const error = await finishRes.json() as FacebookError;
throw new Error(`Facebook Story API error (FINISH phase): ${error.error.message} (code: ${error.error.code})`);
}
const finishData = await finishRes.json() as { success: boolean; post_id?: string };
console.log(`✅ Story vidéo publiée sur ${page.pageName}`);
return finishData.post_id || videoId;
}
/**
* Publie une vidéo comme Reel Facebook
*
+35 -3
View File
@@ -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;
@@ -70,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;
@@ -141,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,
@@ -213,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,
@@ -285,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',
@@ -299,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,
});
+31 -2
View File
@@ -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,
-104
View File
@@ -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 */ });
}
}
}
-2
View File
@@ -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();
}
+59 -3
View File
@@ -7,7 +7,7 @@
* cas de l'ancien `publishReel(originalUrl)`).
*/
import type { Media } from "@shared/schema";
import type { Media, SocialPage } from "@shared/schema";
import { storage } from "../../storage";
import { minioService } from "../minio";
import { createVideoThumbnail } from "../thumbnail";
@@ -19,6 +19,8 @@ export interface PagePublishResult {
success: boolean;
reelId?: string;
error?: string;
/** Échec de la story associée : n'empêche pas le Reel d'être publié. */
storyError?: string;
}
/** Range la vidéo rendue dans la médiathèque, avec sa vignette. */
@@ -49,6 +51,9 @@ export async function storeRenderedVideo(
/**
* Publie (ou planifie) la vidéo sur chaque page, crée les entrées
* `scheduled_posts` et met à jour le statut du post.
*
* Avec `alsoStory`, chaque page Facebook reçoit aussi la vidéo en story (TikTok
* n'a pas de stories).
*/
export async function publishReelToPages(options: {
postId: string;
@@ -56,8 +61,9 @@ export async function publishReelToPages(options: {
videoBuffer: Buffer;
description: string;
scheduledFor?: string;
alsoStory?: boolean;
}): Promise<PagePublishResult[]> {
const { postId, pageIds, videoBuffer, description, scheduledFor } = options;
const { postId, pageIds, videoBuffer, description, scheduledFor, alsoStory } = options;
const results: PagePublishResult[] = [];
for (const pageId of pageIds) {
@@ -82,9 +88,18 @@ export async function publishReelToPages(options: {
postType: "reel",
scheduledAt: scheduledFor ? new Date(scheduledFor) : new Date(),
});
const withStory = alsoStory && page.platform === "facebook";
if (scheduledFor) {
// Le planificateur publiera à l'heure dite
if (withStory) {
await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(scheduledFor),
});
}
results.push({ pageId, success: true, reelId: "scheduled" });
continue;
}
@@ -96,7 +111,8 @@ export async function publishReelToPages(options: {
publishedAt: new Date(),
externalPostId: reelId,
});
results.push({ pageId, success: true, reelId });
const storyError = withStory ? await publishStory(postId, page, videoBuffer) : undefined;
results.push({ pageId, success: true, reelId, storyError });
} else {
// TikTok finalise la publication de son côté : l'envoi est marqué comme
// effectué pour ne pas republier, et le poller de statut renseignera
@@ -126,6 +142,46 @@ export async function publishReelToPages(options: {
return results;
}
/**
* Publie la story d'une page juste après son Reel : un échec est consigné sur
* son entrée planifiée (visible dans l'historique) et renvoyé, sans faire
* échouer le Reel.
*/
async function publishStory(
postId: string,
page: SocialPage,
videoBuffer: Buffer,
): Promise<string | undefined> {
// Créée seulement maintenant : en attente pendant l'envoi du Reel, le
// planificateur aurait pu la publier une seconde fois.
let scheduledStoryId: string | undefined;
try {
({ id: scheduledStoryId } = await storage.createScheduledPost({
postId,
pageId: page.id,
postType: "story",
scheduledAt: new Date(),
}));
const storyId = await facebookService.publishVideoStoryFromBuffer(page, videoBuffer);
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
externalPostId: storyId,
});
return undefined;
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur inconnue";
console.error(`❌ [Reels] Story sur ${page.pageName} impossible :`, error);
if (scheduledStoryId) {
// Marquée comme traitée pour que le planificateur ne la republie pas en boucle
await storage.updateScheduledPost(scheduledStoryId, {
publishedAt: new Date(),
error: message,
}).catch((e) => console.error(`⚠️ [Reels] Échec de la story non enregistré :`, e));
}
return message;
}
}
/** Message d'erreur lisible lorsque toutes les publications ont échoué. */
export function describePublishFailure(results: PagePublishResult[]): string {
const details = results.map((r) => r.error).filter(Boolean).join(" ; ");
+1 -1
View File
@@ -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;
+5 -2
View File
@@ -17,7 +17,7 @@ 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";
@@ -39,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 = {
@@ -54,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,
@@ -82,6 +84,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
videoBuffer,
description: params.description || params.overlayText || "",
scheduledFor: params.scheduledFor,
alsoStory: params.alsoStory,
});
if (!results.some((r) => r.success)) {
+2 -2
View File
@@ -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)
-9
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import {
cleanCaptionText,
computeImagesTiming,
computeReelTiming,
groupLines,
offsetWords,
@@ -37,14 +36,6 @@ describe("fondu final", () => {
});
});
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("groupLines", () => {
const words = (texts: string[]) => texts.map((text, i) => ({ text, start: i, end: i + 0.5 }));
-16
View File
@@ -129,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 };
}
+2 -8
View File
@@ -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", () => {
@@ -10,7 +10,7 @@ describe("videoReelParamsSchema", () => {
it("applique les valeurs par défaut", () => {
const params = videoReelParamsSchema.parse({ videoMediaId: "v", pageIds: ["p"] });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true });
expect(params).toMatchObject({ captionStyle: "bold", stabilize: false, drawText: true, musicVolume: 0.25, showLogo: true, alsoStory: false });
});
it("accepte les anciens paramètres encore stockés dans des jobs", () => {
@@ -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);
+3 -27
View File
@@ -32,6 +32,8 @@ export const videoReelParamsSchema = z.object({
ttsEngine: z.enum(TTS_ENGINES).optional(),
ttsStyle: z.enum(TTS_STYLES).optional(),
scheduledFor: optionalText,
// Publie aussi la vidéo en story sur les pages Facebook (sans effet sur TikTok)
alsoStory: z.boolean().default(false),
// Ancien réglage, ignoré : le minutage vient désormais de la voix elle-même
wordDuration: z.number().optional(),
fontSize: z.number().int().min(16).max(200).default(64),
@@ -48,24 +50,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 +58,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;
}
+3
View File
@@ -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
View File
@@ -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;
}