mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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
This commit is contained in:
24 files changed
+494
-51
No files matched your search
+6
-2
@@ -132,8 +132,12 @@ GEMINI_API_KEY=
|
|||||||
# demande de reconstruire ffmpeg-api.
|
# demande de reconstruire ffmpeg-api.
|
||||||
# WHISPER_MODEL=base
|
# WHISPER_MODEL=base
|
||||||
#
|
#
|
||||||
# Voix locale Qwen3-TTS (service qwen-tts) : gratuite, française, sans quota,
|
# Voix Qwen3-TTS : gratuite, française, sans quota. Le plus simple : l'installer
|
||||||
# mais générée sur CPU (5 à 7 fois la durée de la voix sur 4 cœurs).
|
# 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 :
|
# 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-1.7B-CustomVoice (défaut) : ton et émotion pilotés ;
|
||||||
# - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif.
|
# - Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice : ~30 % plus rapide, moins expressif.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -148,7 +148,7 @@ export function VoicePicker({ value, onChange, sampleText, compact = false, onPr
|
|||||||
className={compact ? "h-7 text-xs" : undefined}
|
className={compact ? "h-7 text-xs" : undefined}
|
||||||
variant={value.engine === "qwen" ? "default" : "outline"}
|
variant={value.engine === "qwen" ? "default" : "outline"}
|
||||||
disabled={!qwenAvailable && value.engine !== "qwen"}
|
disabled={!qwenAvailable && value.engine !== "qwen"}
|
||||||
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non démarré"}
|
title={qwenAvailable ? "Voix locale, gratuite, plus lente à générer" : "Service Qwen TTS non configuré ou injoignable (Paramètres → Qwen TTS)"}
|
||||||
onClick={() => setEngine("qwen")}
|
onClick={() => setEngine("qwen")}
|
||||||
>
|
>
|
||||||
Qwen (locale)
|
Qwen (locale)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||||
import { ModelCombobox } from "@/components/model-combobox";
|
import { ModelCombobox } from "@/components/model-combobox";
|
||||||
|
import { QwenSettingsCard } from "@/components/reels/qwen-settings-card";
|
||||||
import { SiTiktok } from "react-icons/si";
|
import { SiTiktok } from "react-icons/si";
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
@@ -695,6 +696,9 @@ export default function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Qwen3-TTS (serveur local ou GPU distant) */}
|
||||||
|
{isAdmin && <QwenSettingsCard />}
|
||||||
|
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
|
|||||||
+8
-4
@@ -86,17 +86,21 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||||
# Moteur de voix local (service qwen-tts ci-dessous)
|
# Service Qwen TTS par défaut. Normalement réglé dans l'application
|
||||||
QWEN_TTS_URL: http://qwen-tts:8001
|
# (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}
|
QWEN_TTS_API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
|
||||||
# Voix locale Qwen3-TTS (gratuite, française, sur CPU). Plus lente que
|
# Voix Qwen3-TTS sur ce serveur, en CPU (optionnel, lent : 5 à 7 fois la
|
||||||
# Gemini : compter 5 à 7 fois la durée de la voix sur 4 cœurs.
|
# 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:
|
qwen-tts:
|
||||||
|
profiles: ["qwen-local"]
|
||||||
build:
|
build:
|
||||||
context: ./qwen-tts
|
context: ./qwen-tts
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class TtsRequest(BaseModel):
|
|||||||
tts_engine: str | None = "gemini"
|
tts_engine: str | None = "gemini"
|
||||||
tts_style: str | None = None
|
tts_style: str | None = None
|
||||||
gemini_api_key: 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):
|
class SrtCue(BaseModel):
|
||||||
@@ -80,6 +83,8 @@ class ReelRequest(BaseModel):
|
|||||||
tts_engine: str | None = "gemini"
|
tts_engine: str | None = "gemini"
|
||||||
tts_style: str | None = None
|
tts_style: str | None = None
|
||||||
gemini_api_key: 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
|
draw_text: bool = True
|
||||||
stabilize: bool = False
|
stabilize: bool = False
|
||||||
enable_ending_effect: bool = True
|
enable_ending_effect: bool = True
|
||||||
@@ -99,8 +104,14 @@ async def health():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||||
async def list_voices():
|
async def list_voices(x_qwen_url: str | None = Header(None), x_qwen_key: str | None = Header(None)):
|
||||||
return voices.catalog(await tts.qwen.list_voices())
|
"""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)])
|
@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,
|
style=request.tts_style,
|
||||||
gemini_api_key=request.gemini_api_key,
|
gemini_api_key=request.gemini_api_key,
|
||||||
workdir=workdir,
|
workdir=workdir,
|
||||||
|
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
log.exception("Voix impossible à générer")
|
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,
|
style=request.tts_style,
|
||||||
gemini_api_key=request.gemini_api_key,
|
gemini_api_key=request.gemini_api_key,
|
||||||
workdir=workdir,
|
workdir=workdir,
|
||||||
|
qwen_target=tts.qwen.Target.resolve(request.qwen_tts_url, request.qwen_tts_api_key),
|
||||||
)
|
)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
log.exception("Voix SRT impossible à générer")
|
log.exception("Voix SRT impossible à générer")
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ VOICE_DELAY = 2.0
|
|||||||
|
|
||||||
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
SUBTITLE_FONT = os.environ.get("SUBTITLE_FONT", "Montserrat")
|
||||||
|
|
||||||
# Moteur local Qwen3-TTS (service qwen-tts). Vide : moteur indisponible.
|
# 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("/")
|
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
|
# 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_TIMEOUT = float(os.environ.get("QWEN_TTS_TIMEOUT", "900"))
|
||||||
|
|||||||
@@ -110,13 +110,16 @@ async def synthesize_cues(
|
|||||||
style: str | None,
|
style: str | None,
|
||||||
gemini_api_key: str | None,
|
gemini_api_key: str | None,
|
||||||
workdir: Path,
|
workdir: Path,
|
||||||
|
qwen_target: tts.qwen.Target | None = None,
|
||||||
) -> tts.VoiceTrack:
|
) -> tts.VoiceTrack:
|
||||||
"""Voix complète : mots minutés en secondes depuis le début de la vidéo."""
|
"""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)
|
cues = sorted((c for c in cues if clean_text(c.text)), key=lambda c: c.start)
|
||||||
if not cues:
|
if not cues:
|
||||||
raise ValueError("Aucun sous-titre lisible dans le fichier SRT")
|
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]
|
texts = [as_sentence(c.text) for c in cues]
|
||||||
|
|
||||||
# 1. Lecture d'une seule traite, découpée dans les silences
|
# 1. Lecture d'une seule traite, découpée dans les silences
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async def synthesize(
|
|||||||
style: str | None,
|
style: str | None,
|
||||||
gemini_api_key: str | None,
|
gemini_api_key: str | None,
|
||||||
workdir: Path,
|
workdir: Path,
|
||||||
|
qwen_target: qwen.Target | None = None,
|
||||||
) -> VoiceTrack:
|
) -> VoiceTrack:
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
processed: Path | None = None
|
processed: Path | None = None
|
||||||
@@ -42,7 +43,7 @@ async def synthesize(
|
|||||||
if engine == "qwen":
|
if engine == "qwen":
|
||||||
result = await _checked(
|
result = await _checked(
|
||||||
"Qwen",
|
"Qwen",
|
||||||
lambda attempt: qwen.synthesize(text, voice, style, workdir, attempt),
|
lambda attempt: qwen.synthesize(text, voice, style, qwen_target, workdir, attempt),
|
||||||
config.QWEN_TTS_ATTEMPTS,
|
config.QWEN_TTS_ATTEMPTS,
|
||||||
text,
|
text,
|
||||||
workdir,
|
workdir,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Synthèse vocale locale Qwen3-TTS (service qwen-tts, sans clé ni quota)."""
|
"""Synthèse vocale Qwen3-TTS (service qwen-tts, local ou sur un serveur GPU)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,24 +16,39 @@ class QwenError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _headers() -> dict[str, str]:
|
@dataclass(frozen=True)
|
||||||
return {"x-api-key": config.QWEN_TTS_API_KEY} if config.QWEN_TTS_API_KEY else {}
|
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(
|
async def synthesize(
|
||||||
text: str, voice: str | None, style: str | None, workdir: Path, attempt: int = 0
|
text: str, voice: str | None, style: str | None, target: Target | None, workdir: Path, attempt: int = 0
|
||||||
) -> tuple[Path, str]:
|
) -> tuple[Path, str]:
|
||||||
"""Génère la voix ; renvoie un WAV brut et la voix utilisée."""
|
"""Génère la voix ; renvoie un WAV brut et la voix utilisée."""
|
||||||
if not config.QWEN_TTS_URL:
|
if target is None:
|
||||||
raise QwenError("service Qwen TTS non configuré (QWEN_TTS_URL)")
|
raise QwenError("service Qwen TTS non configuré (Paramètres → Qwen TTS)")
|
||||||
qwen_voice = resolve_qwen_voice(voice)
|
qwen_voice = resolve_qwen_voice(voice)
|
||||||
payload = {"text": text, "voice": qwen_voice, "instruct": qwen_instruction(style), "language": "French"}
|
payload = {"text": text, "voice": qwen_voice, "instruct": qwen_instruction(style), "language": "French"}
|
||||||
log.info("Qwen TTS : voix=%s style=%s (%d car.)", qwen_voice, style, len(text))
|
log.info("Qwen TTS (%s) : voix=%s style=%s (%d car.)", target.url, qwen_voice, style, len(text))
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client:
|
async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client:
|
||||||
response = await client.post(
|
response = await client.post(f"{target.url}/synthesize", json=payload, headers=target.headers)
|
||||||
f"{config.QWEN_TTS_URL}/synthesize", json=payload, headers=_headers()
|
|
||||||
)
|
|
||||||
except httpx.HTTPError as error:
|
except httpx.HTTPError as error:
|
||||||
raise QwenError(f"service Qwen TTS injoignable : {error!r}") from error
|
raise QwenError(f"service Qwen TTS injoignable : {error!r}") from error
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
@@ -42,15 +58,18 @@ async def synthesize(
|
|||||||
return raw, response.headers.get("x-voice", qwen_voice)
|
return raw, response.headers.get("x-voice", qwen_voice)
|
||||||
|
|
||||||
|
|
||||||
async def list_voices() -> list[dict] | None:
|
async def status(target: Target | None) -> dict | None:
|
||||||
"""Voix proposées par le service (clonées comprises) ; None s'il ne répond pas."""
|
"""État du service (modèle, carte graphique) et voix proposées, clonées
|
||||||
if not config.QWEN_TTS_URL:
|
comprises ; None s'il n'est pas configuré ou ne répond pas."""
|
||||||
|
if target is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5) as client:
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
response = await client.get(f"{config.QWEN_TTS_URL}/voices", headers=_headers())
|
health = await client.get(f"{target.url}/health", headers=target.headers)
|
||||||
response.raise_for_status()
|
health.raise_for_status()
|
||||||
return response.json()["voices"]
|
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
|
except Exception as error: # noqa: BLE001 — moteur simplement signalé indisponible
|
||||||
log.warning("Service Qwen TTS indisponible : %s", error)
|
log.warning("Service Qwen TTS %s indisponible : %s", target.url, error)
|
||||||
return None
|
return None
|
||||||
@@ -36,3 +36,15 @@ def test_qwen_voice_keeps_clones_and_matches_gender():
|
|||||||
def test_qwen_voice_falls_back_to_same_gender_elsewhere():
|
def test_qwen_voice_falls_back_to_same_gender_elsewhere():
|
||||||
assert resolve_edge_voice("uncle_fu").gender == "male"
|
assert resolve_edge_voice("uncle_fu").gender == "male"
|
||||||
assert resolve_gemini_voice("uncle_fu").id == "Charon"
|
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
|
||||||
+5
-3
@@ -6,10 +6,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# PyTorch CPU (pas de CUDA : image bien plus légère). torch et torchaudio
|
# PyTorch : « cpu » (image légère) ou « cu128 » pour les GPU NVIDIA, cartes
|
||||||
# doivent avoir la même version, sinon torchaudio ne se charge pas.
|
# 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 \
|
RUN pip install --no-cache-dir torch==2.8.0 torchaudio==2.8.0 \
|
||||||
--index-url https://download.pytorch.org/whl/cpu
|
--index-url https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Qwen TTS : voix locale sur un serveur GPU (ex. Unraid)
|
||||||
|
|
||||||
|
Service de synthèse vocale Qwen3-TTS appelé par SocialFlow (via ffmpeg-api).
|
||||||
|
Sur une carte graphique NVIDIA, le gros modèle 1.7B (ton et émotion pilotés)
|
||||||
|
génère une voix bien plus vite qu'en temps réel ; sur CPU, il est 5 à 7 fois plus lent.
|
||||||
|
|
||||||
|
## Installation sur Unraid
|
||||||
|
|
||||||
|
1. **Pilote NVIDIA** : Apps → installer le plugin **Nvidia Driver**, redémarrer,
|
||||||
|
puis vérifier dans un terminal : `nvidia-smi -L` (liste les cartes et leur numéro).
|
||||||
|
2. **Récupérer ce dossier** sur le serveur, par exemple :
|
||||||
|
```bash
|
||||||
|
cd /mnt/user/appdata
|
||||||
|
git clone https://github.com/R0m1k3/Socialflow.git
|
||||||
|
cd Socialflow/qwen-tts
|
||||||
|
```
|
||||||
|
3. **Choisir la carte et la clé** dans un fichier `.env` à côté de `docker-compose.gpu.yml` :
|
||||||
|
```bash
|
||||||
|
QWEN_TTS_API_KEY=une-longue-cle-secrete # openssl rand -hex 32
|
||||||
|
QWEN_TTS_GPU=0 # numéro ou UUID donné par nvidia-smi -L
|
||||||
|
# QWEN_TTS_PORT=8001
|
||||||
|
```
|
||||||
|
Une RTX 3060 12 Go suffit largement (le modèle 1.7B occupe ~5–6 Go) : autant
|
||||||
|
la dédier à la voix et garder la plus grosse carte pour le reste.
|
||||||
|
4. **Construire et lancer** (le premier build télécharge les modèles, quelques Go) :
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.gpu.yml up -d --build
|
||||||
|
docker logs -f socialflow-qwen-tts # « Modèle … chargé sur NVIDIA GeForce … »
|
||||||
|
```
|
||||||
|
5. **Dans SocialFlow** : Paramètres → **Qwen TTS (voix locale)** → adresse
|
||||||
|
`http://IP-DE-L-UNRAID:8001` + la clé → **Tester la connexion** → **Enregistrer**.
|
||||||
|
Le moteur « Qwen (locale) » devient disponible dans le choix des voix.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
Le service ne doit pas être exposé sur Internet. Si SocialFlow tourne sur un
|
||||||
|
autre réseau que l'Unraid, reliez les deux par **Tailscale** (ou WireGuard)
|
||||||
|
et utilisez l'adresse Tailscale de l'Unraid (`http://100.x.y.z:8001`).
|
||||||
|
La clé d'accès est obligatoire.
|
||||||
|
|
||||||
|
## Voix clonées (voix française native)
|
||||||
|
|
||||||
|
Déposez dans `voices/` un extrait de 5 à 15 s et sa transcription (voir
|
||||||
|
`voices/README.md`), puis `docker restart socialflow-qwen-tts`. Utilisez
|
||||||
|
uniquement des voix dont vous avez les droits.
|
||||||
|
|
||||||
|
## Réglages
|
||||||
|
|
||||||
|
| Variable | Défaut | Rôle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `QWEN_TTS_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` | Modèle des voix prédéfinies (build) |
|
||||||
|
| `QWEN_TTS_CLONE_MODEL` | `Qwen/Qwen3-TTS-12Hz-1.7B-Base` | Modèle de clonage, vide pour désactiver (build) |
|
||||||
|
| `QWEN_TTS_GPU` | `0` | Carte NVIDIA utilisée |
|
||||||
|
| `QWEN_TTS_DEVICE` | `auto` | `cuda:0`, `cpu`… (auto : GPU si disponible) |
|
||||||
|
| `QWEN_TTS_DTYPE` | `bfloat16` sur GPU, `float32` sur CPU | Précision du calcul |
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Qwen3-TTS sur un serveur avec carte graphique NVIDIA (ex. Unraid).
|
||||||
|
#
|
||||||
|
# Prérequis : pilote NVIDIA + NVIDIA Container Toolkit (sur Unraid : plugin
|
||||||
|
# « Nvidia Driver »). Puis, dans ce dossier :
|
||||||
|
# QWEN_TTS_API_KEY=une-cle-secrete docker compose -f docker-compose.gpu.yml up -d --build
|
||||||
|
# et dans SocialFlow : Paramètres → Qwen TTS → http://IP-DU-SERVEUR:8001 + la clé.
|
||||||
|
services:
|
||||||
|
qwen-tts:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
TORCH_VARIANT: cu128
|
||||||
|
# Sur GPU, le gros modèle : ton et émotion pilotés par consigne
|
||||||
|
QWEN_TTS_MODEL: ${QWEN_TTS_MODEL:-Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice}
|
||||||
|
# Clonage des voix déposées dans ./voices (vide : désactivé)
|
||||||
|
QWEN_TTS_CLONE_MODEL: ${QWEN_TTS_CLONE_MODEL-Qwen/Qwen3-TTS-12Hz-1.7B-Base}
|
||||||
|
image: socialflow-qwen-tts:gpu
|
||||||
|
container_name: socialflow-qwen-tts
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Obligatoire : le service est joignable depuis le réseau
|
||||||
|
API_KEY: ${QWEN_TTS_API_KEY:?Définissez QWEN_TTS_API_KEY}
|
||||||
|
# GPU utilisé dans le conteneur (voir NVIDIA_VISIBLE_DEVICES ci-dessous)
|
||||||
|
QWEN_TTS_DEVICE: cuda:0
|
||||||
|
# Choix de la carte : numéro (0, 1) ou UUID donné par nvidia-smi -L
|
||||||
|
NVIDIA_VISIBLE_DEVICES: ${QWEN_TTS_GPU:-0}
|
||||||
|
NVIDIA_DRIVER_CAPABILITIES: compute,utility
|
||||||
|
runtime: nvidia
|
||||||
|
ports:
|
||||||
|
- "${QWEN_TTS_PORT:-8001}:8001"
|
||||||
|
volumes:
|
||||||
|
- ./voices:/app/voices:ro
|
||||||
+20
-6
@@ -1,10 +1,14 @@
|
|||||||
"""Synthèse vocale locale Qwen3-TTS (CPU), appelée par le service ffmpeg-api.
|
"""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 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é
|
- 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).
|
seulement si au moins une voix y est déposée).
|
||||||
|
|
||||||
Une seule génération à la fois : chacune occupe tous les cœurs.
|
Une seule génération à la fois : chacune occupe tous les cœurs (ou le GPU).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -28,7 +32,11 @@ log = logging.getLogger("qwen-tts")
|
|||||||
MODEL = os.environ.get("QWEN_TTS_MODEL", "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
|
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")
|
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"))
|
VOICES_DIR = Path(os.environ.get("QWEN_TTS_VOICES_DIR", Path(__file__).parent / "voices"))
|
||||||
DTYPE = getattr(torch, os.environ.get("QWEN_TTS_DTYPE", "float32"))
|
# « 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
|
THREADS = int(os.environ.get("QWEN_TTS_THREADS", "0")) or os.cpu_count() or 1
|
||||||
API_KEY = os.environ.get("API_KEY", "")
|
API_KEY = os.environ.get("API_KEY", "")
|
||||||
|
|
||||||
@@ -57,11 +65,17 @@ def _load(name: str):
|
|||||||
from qwen_tts import Qwen3TTSModel
|
from qwen_tts import Qwen3TTSModel
|
||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
_models[name] = Qwen3TTSModel.from_pretrained(name, device_map="cpu", dtype=DTYPE)
|
_models[name] = Qwen3TTSModel.from_pretrained(name, device_map=DEVICE, dtype=DTYPE)
|
||||||
log.info("Modèle %s chargé en %.1f s (%d threads)", name, time.monotonic() - started, THREADS)
|
log.info("Modèle %s chargé sur %s en %.1f s", name, _device_label(), time.monotonic() - started)
|
||||||
return _models[name]
|
return _models[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]:
|
def clones() -> dict[str, dict]:
|
||||||
"""Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json)."""
|
"""Voix de référence déposées dans voices/ : nom.wav + nom.txt (+ nom.json)."""
|
||||||
found = {}
|
found = {}
|
||||||
@@ -135,7 +149,7 @@ class SynthesisRequest(BaseModel):
|
|||||||
|
|
||||||
@app.get("/health", dependencies=[Depends(require_key)])
|
@app.get("/health", dependencies=[Depends(require_key)])
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok", "model": MODEL, "busy": _lock.locked()}
|
return {"status": "ok", "model": MODEL, "device": _device_label(), "busy": _lock.locked()}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/voices", dependencies=[Depends(require_key)])
|
@app.get("/voices", dependencies=[Depends(require_key)])
|
||||||
|
|||||||
@@ -161,6 +161,13 @@ export async function migrate() {
|
|||||||
ADD COLUMN IF NOT EXISTS "gemini_api_key" text;
|
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)
|
// media.thumbnail_url (vignette conservée après suppression de la vidéo)
|
||||||
await client.query(`
|
await client.query(`
|
||||||
ALTER TABLE "media"
|
ALTER TABLE "media"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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 { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
|
||||||
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
import type { User, InsertUser, ScheduledPost } from "@shared/schema";
|
||||||
import { analyticsRouter } from "./routes/analytics";
|
import { analyticsRouter } from "./routes/analytics";
|
||||||
|
import { normalizeQwenUrl } from "./services/reels/assets";
|
||||||
import { reelsRouter } from "./routes/reels";
|
import { reelsRouter } from "./routes/reels";
|
||||||
import { remotionRouter } from "./routes/remotion";
|
import { remotionRouter } from "./routes/remotion";
|
||||||
import { externalRouter } from "./routes/external";
|
import { externalRouter } from "./routes/external";
|
||||||
@@ -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
|
// Analytics Routes
|
||||||
app.use("/api/analytics", requireAuth, analyticsRouter);
|
app.use("/api/analytics", requireAuth, analyticsRouter);
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -9,7 +9,13 @@ import { ffmpegService, FFmpegServiceError } from '../services/ffmpeg';
|
|||||||
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
import { ttsPreviewSchema, videoReelParamsSchema, type VideoReelParams } from '@shared/reel';
|
||||||
import { srtText } from '@shared/srt';
|
import { srtText } from '@shared/srt';
|
||||||
import { enqueueReelJob, countActiveReelJobs } from '../services/reels/queue';
|
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 { configuredRenderer } from '../services/reels/videoPipeline';
|
||||||
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
import { openRouterService, describeGenerationError } from '../services/openrouter';
|
||||||
|
|
||||||
@@ -277,11 +283,11 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
|||||||
*/
|
*/
|
||||||
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const catalog = await ffmpegService.listVoices();
|
const catalog = await ffmpegService.listVoices(qwenTargetFromConfig(await storage.getAppConfig()));
|
||||||
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen });
|
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen, qwenStatus: catalog.qwen_status });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error listing voices:', error);
|
console.error('❌ Error listing voices:', error);
|
||||||
res.json({ qwenAvailable: false, qwen: [] });
|
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,6 +308,7 @@ reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
|||||||
engine: ttsEngine,
|
engine: ttsEngine,
|
||||||
style: ttsStyle,
|
style: ttsStyle,
|
||||||
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
geminiApiKey: await resolveGeminiApiKey(ttsEngine),
|
||||||
|
qwen: await resolveQwenTarget(ttsEngine),
|
||||||
});
|
});
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface ReelRenderOptions {
|
|||||||
ttsEngine?: TtsEngine;
|
ttsEngine?: TtsEngine;
|
||||||
ttsStyle?: TtsStyle;
|
ttsStyle?: TtsStyle;
|
||||||
geminiApiKey?: string;
|
geminiApiKey?: string;
|
||||||
|
qwen?: QwenTarget;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
drawText?: boolean;
|
drawText?: boolean;
|
||||||
@@ -71,9 +72,17 @@ export interface TimedWord {
|
|||||||
end: number;
|
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 {
|
export interface VoiceCatalog {
|
||||||
qwen: VoiceOption[];
|
qwen: VoiceOption[];
|
||||||
qwen_available: boolean;
|
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[];
|
gemini: VoiceOption[];
|
||||||
edge: VoiceOption[];
|
edge: VoiceOption[];
|
||||||
}
|
}
|
||||||
@@ -149,6 +158,8 @@ export class FFmpegService {
|
|||||||
tts_engine: options.ttsEngine,
|
tts_engine: options.ttsEngine,
|
||||||
tts_style: options.ttsStyle,
|
tts_style: options.ttsStyle,
|
||||||
gemini_api_key: options.geminiApiKey,
|
gemini_api_key: options.geminiApiKey,
|
||||||
|
qwen_tts_url: options.qwen?.url,
|
||||||
|
qwen_tts_api_key: options.qwen?.apiKey,
|
||||||
font_size: options.fontSize ?? 64,
|
font_size: options.fontSize ?? 64,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
draw_text: options.drawText ?? true,
|
draw_text: options.drawText ?? true,
|
||||||
@@ -221,6 +232,8 @@ export class FFmpegService {
|
|||||||
tts_engine: options.ttsEngine,
|
tts_engine: options.ttsEngine,
|
||||||
tts_style: options.ttsStyle,
|
tts_style: options.ttsStyle,
|
||||||
gemini_api_key: options.geminiApiKey,
|
gemini_api_key: options.geminiApiKey,
|
||||||
|
qwen_tts_url: options.qwen?.url,
|
||||||
|
qwen_tts_api_key: options.qwen?.apiKey,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
draw_text: options.drawText ?? true,
|
draw_text: options.drawText ?? true,
|
||||||
stabilize: options.stabilize ?? false,
|
stabilize: options.stabilize ?? false,
|
||||||
@@ -294,15 +307,18 @@ export class FFmpegService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
|
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
|
||||||
async listVoices(): Promise<VoiceCatalog> {
|
async listVoices(qwen?: QwenTarget): Promise<VoiceCatalog> {
|
||||||
const response = await this.call('/voices', { method: 'GET', timeoutMs: 15_000 });
|
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;
|
return await response.json() as VoiceCatalog;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
|
/** Génère la voix seule (aperçu), avec le minutage de chaque mot. */
|
||||||
async previewVoice(
|
async previewVoice(
|
||||||
text: string,
|
text: string,
|
||||||
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string } = {},
|
options: { voice?: string; engine?: TtsEngine; style?: TtsStyle; geminiApiKey?: string; qwen?: QwenTarget } = {},
|
||||||
): Promise<VoicePreview> {
|
): Promise<VoicePreview> {
|
||||||
const response = await this.call('/preview-tts', {
|
const response = await this.call('/preview-tts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -313,6 +329,8 @@ export class FFmpegService {
|
|||||||
tts_engine: options.engine,
|
tts_engine: options.engine,
|
||||||
tts_style: options.style,
|
tts_style: options.style,
|
||||||
gemini_api_key: options.geminiApiKey,
|
gemini_api_key: options.geminiApiKey,
|
||||||
|
qwen_tts_url: options.qwen?.url,
|
||||||
|
qwen_tts_api_key: options.qwen?.apiKey,
|
||||||
}),
|
}),
|
||||||
timeoutMs: TTS_TIMEOUT_MS,
|
timeoutMs: TTS_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* magasin), jusqu'ici recopiée dans chaque route.
|
* magasin), jusqu'ici recopiée dans chaque route.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { QwenTarget } from "../ffmpeg";
|
||||||
import { storage } from "../../storage";
|
import { storage } from "../../storage";
|
||||||
import { buildMinioUrl, resolveInternalUrl } from "../minio";
|
import { buildMinioUrl, resolveInternalUrl } from "../minio";
|
||||||
|
|
||||||
@@ -49,6 +50,31 @@ export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string |
|
|||||||
return appConfig?.geminiApiKey ?? process.env.GEMINI_API_KEY ?? undefined;
|
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. */
|
/** Nom affiché dans l'outro : celui de la page choisie, sinon de la première page de l'utilisateur. */
|
||||||
export async function resolveStoreName(
|
export async function resolveStoreName(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { imagesReelParamsSchema, type ImagesReelResult } from "@shared/reel";
|
|||||||
import { ffmpegService } from "../ffmpeg";
|
import { ffmpegService } from "../ffmpeg";
|
||||||
import { generateVideoThumbnail } from "../thumbnail";
|
import { generateVideoThumbnail } from "../thumbnail";
|
||||||
import type { JobContext } from "./queue";
|
import type { JobContext } from "./queue";
|
||||||
import { resolveGeminiApiKey, resolveLogoPath } from "./assets";
|
import { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
|
||||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||||
import { renderReelComposition } from "./remotionRenderer";
|
import { renderReelComposition } from "./remotionRenderer";
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
|||||||
engine: params.ttsEngine,
|
engine: params.ttsEngine,
|
||||||
style: params.ttsStyle,
|
style: params.ttsStyle,
|
||||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||||
|
qwen: await resolveQwenTarget(params.ttsEngine),
|
||||||
});
|
});
|
||||||
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
for (const warning of voice.warnings) console.warn(`⚠️ [Reels] ${warning}`);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { storage } from "../../storage";
|
|||||||
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
import { ffmpegService, type ReelRenderOptions } from "../ffmpeg";
|
||||||
import { resolveInternalUrl } from "../minio";
|
import { resolveInternalUrl } from "../minio";
|
||||||
import type { JobContext } from "./queue";
|
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 { describePublishFailure, publishReelToPages, storeRenderedVideo } from "./publish";
|
||||||
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
import { REMOTION_TEMP_DIR, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||||
import { renderReelComposition } from "./remotionRenderer";
|
import { renderReelComposition } from "./remotionRenderer";
|
||||||
@@ -39,10 +39,11 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
|||||||
throw new Error("Vidéo source introuvable ou invalide");
|
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),
|
resolveMusicUrl(params.musicTrackId, params.musicUrl),
|
||||||
resolveLogoPath(),
|
resolveLogoPath(),
|
||||||
resolveGeminiApiKey(params.ttsEngine),
|
resolveGeminiApiKey(params.ttsEngine),
|
||||||
|
resolveQwenTarget(params.ttsEngine),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const options: ReelRenderOptions = {
|
const options: ReelRenderOptions = {
|
||||||
@@ -54,6 +55,7 @@ export async function runVideoReelJob({ job, progress }: JobContext) {
|
|||||||
ttsEngine: params.ttsEngine,
|
ttsEngine: params.ttsEngine,
|
||||||
ttsStyle: params.ttsStyle,
|
ttsStyle: params.ttsStyle,
|
||||||
geminiApiKey,
|
geminiApiKey,
|
||||||
|
qwen,
|
||||||
fontSize: params.fontSize,
|
fontSize: params.fontSize,
|
||||||
musicVolume: params.musicVolume,
|
musicVolume: params.musicVolume,
|
||||||
drawText: params.drawText,
|
drawText: params.drawText,
|
||||||
|
|||||||
+2
-2
@@ -210,7 +210,7 @@ export interface IStorage {
|
|||||||
|
|
||||||
// App Config
|
// App Config
|
||||||
getAppConfig(): Promise<AppConfig | undefined>;
|
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 {
|
export class DatabaseStorage implements IStorage {
|
||||||
@@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return { ...created, clientSecret: decrypt(created.clientSecret) };
|
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();
|
const existing = await this.getAppConfig();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [updated] = await db.update(appConfig)
|
const [updated] = await db.update(appConfig)
|
||||||
|
|||||||
@@ -260,6 +260,9 @@ export const appConfig = pgTable("app_config", {
|
|||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
externalApiKey: text("external_api_key"),
|
externalApiKey: text("external_api_key"),
|
||||||
geminiApiKey: text("gemini_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(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user