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.
|
||||
# WHISPER_MODEL=base
|
||||
#
|
||||
# Voix locale Qwen3-TTS (service qwen-tts) : gratuite, française, sans quota,
|
||||
# mais générée sur CPU (5 à 7 fois la durée de la voix sur 4 cœurs).
|
||||
# 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.
|
||||
|
||||
@@ -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}
|
||||
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 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")}
|
||||
>
|
||||
Qwen (locale)
|
||||
|
||||
@@ -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">
|
||||
|
||||
+8
-4
@@ -86,17 +86,21 @@ services:
|
||||
environment:
|
||||
API_KEY: ${FFMPEG_API_KEY:-socialflow-secret-ffmpeg-key}
|
||||
GEMINI_TTS_MODEL: ${GEMINI_TTS_MODEL:-gemini-2.5-flash-preview-tts}
|
||||
# Moteur de voix local (service qwen-tts ci-dessous)
|
||||
QWEN_TTS_URL: http://qwen-tts:8001
|
||||
# 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 locale Qwen3-TTS (gratuite, française, sur CPU). Plus lente que
|
||||
# Gemini : compter 5 à 7 fois la durée de la voix sur 4 cœurs.
|
||||
# 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
|
||||
|
||||
@@ -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(await tts.qwen.list_voices())
|
||||
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")
|
||||
|
||||
@@ -37,7 +37,8 @@ VOICE_DELAY = 2.0
|
||||
|
||||
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("/")
|
||||
# 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"))
|
||||
|
||||
@@ -110,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
|
||||
|
||||
@@ -32,6 +32,7 @@ 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
|
||||
@@ -42,7 +43,7 @@ async def synthesize(
|
||||
if engine == "qwen":
|
||||
result = await _checked(
|
||||
"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,
|
||||
text,
|
||||
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
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
@@ -15,24 +16,39 @@ class QwenError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _headers() -> dict[str, str]:
|
||||
return {"x-api-key": config.QWEN_TTS_API_KEY} if config.QWEN_TTS_API_KEY else {}
|
||||
@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, workdir: Path, attempt: int = 0
|
||||
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 not config.QWEN_TTS_URL:
|
||||
raise QwenError("service Qwen TTS non configuré (QWEN_TTS_URL)")
|
||||
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 : 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:
|
||||
async with httpx.AsyncClient(timeout=config.QWEN_TTS_TIMEOUT) as client:
|
||||
response = await client.post(
|
||||
f"{config.QWEN_TTS_URL}/synthesize", json=payload, headers=_headers()
|
||||
)
|
||||
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:
|
||||
@@ -42,15 +58,18 @@ async def synthesize(
|
||||
return raw, response.headers.get("x-voice", qwen_voice)
|
||||
|
||||
|
||||
async def list_voices() -> list[dict] | None:
|
||||
"""Voix proposées par le service (clonées comprises) ; None s'il ne répond pas."""
|
||||
if not config.QWEN_TTS_URL:
|
||||
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=5) as client:
|
||||
response = await client.get(f"{config.QWEN_TTS_URL}/voices", headers=_headers())
|
||||
response.raise_for_status()
|
||||
return response.json()["voices"]
|
||||
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 indisponible : %s", error)
|
||||
log.warning("Service Qwen TTS %s indisponible : %s", target.url, error)
|
||||
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():
|
||||
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
|
||||
+5
-3
@@ -6,10 +6,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# PyTorch CPU (pas de CUDA : image bien plus légère). torch et torchaudio
|
||||
# doivent avoir la même version, sinon torchaudio ne se charge pas.
|
||||
# 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/cpu
|
||||
--index-url https://download.pytorch.org/whl/${TORCH_VARIANT}
|
||||
COPY 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 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.
|
||||
Une seule génération à la fois : chacune occupe tous les cœurs (ou le GPU).
|
||||
"""
|
||||
|
||||
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")
|
||||
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"))
|
||||
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
|
||||
API_KEY = os.environ.get("API_KEY", "")
|
||||
|
||||
@@ -57,11 +65,17 @@ def _load(name: str):
|
||||
from qwen_tts import Qwen3TTSModel
|
||||
|
||||
started = time.monotonic()
|
||||
_models[name] = Qwen3TTSModel.from_pretrained(name, device_map="cpu", dtype=DTYPE)
|
||||
log.info("Modèle %s chargé en %.1f s (%d threads)", name, time.monotonic() - started, THREADS)
|
||||
_models[name] = Qwen3TTSModel.from_pretrained(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 _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 = {}
|
||||
@@ -135,7 +149,7 @@ class SynthesisRequest(BaseModel):
|
||||
|
||||
@app.get("/health", dependencies=[Depends(require_key)])
|
||||
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)])
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 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";
|
||||
@@ -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);
|
||||
|
||||
|
||||
+11
-4
@@ -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';
|
||||
|
||||
@@ -277,11 +283,11 @@ reelsRouter.post('/reels/generate-text', async (req: Request, res: Response) =>
|
||||
*/
|
||||
reelsRouter.get('/reels/voices', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const catalog = await ffmpegService.listVoices();
|
||||
res.json({ qwenAvailable: catalog.qwen_available, qwen: catalog.qwen });
|
||||
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: [] });
|
||||
res.json({ qwenAvailable: false, qwen: [], qwenStatus: null });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -302,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,
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface ReelRenderOptions {
|
||||
ttsEngine?: TtsEngine;
|
||||
ttsStyle?: TtsStyle;
|
||||
geminiApiKey?: string;
|
||||
qwen?: QwenTarget;
|
||||
fontSize?: number;
|
||||
musicVolume?: number;
|
||||
drawText?: boolean;
|
||||
@@ -71,9 +72,17 @@ 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[];
|
||||
}
|
||||
@@ -149,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,
|
||||
@@ -221,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,
|
||||
@@ -294,15 +307,18 @@ export class FFmpegService {
|
||||
}
|
||||
|
||||
/** Voix proposées par moteur ; `qwen_available` indique si le service local répond. */
|
||||
async listVoices(): Promise<VoiceCatalog> {
|
||||
const response = await this.call('/voices', { method: 'GET', timeoutMs: 15_000 });
|
||||
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',
|
||||
@@ -313,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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -49,6 +50,31 @@ export async function resolveGeminiApiKey(ttsEngine?: string): Promise<string |
|
||||
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,
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { resolveGeminiApiKey, resolveLogoPath, resolveQwenTarget } from "./assets";
|
||||
import { REMOTION_TEMP_DIR, localHttpUrl, tempFileUrl, toDataUrl } from "./remotionAssets";
|
||||
import { renderReelComposition } from "./remotionRenderer";
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function runImagesReelJob({ job, progress }: JobContext): Promise<I
|
||||
engine: params.ttsEngine,
|
||||
style: params.ttsStyle,
|
||||
geminiApiKey: await resolveGeminiApiKey(params.ttsEngine),
|
||||
qwen: await resolveQwenTarget(params.ttsEngine),
|
||||
});
|
||||
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 { 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,
|
||||
|
||||
+2
-2
@@ -210,7 +210,7 @@ export interface IStorage {
|
||||
|
||||
// App Config
|
||||
getAppConfig(): Promise<AppConfig | undefined>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig>;
|
||||
upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@@ -795,7 +795,7 @@ export class DatabaseStorage implements IStorage {
|
||||
return { ...created, clientSecret: decrypt(created.clientSecret) };
|
||||
}
|
||||
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey'>>): Promise<AppConfig> {
|
||||
async upsertAppConfig(data: Partial<Pick<AppConfig, 'externalApiKey' | 'geminiApiKey' | 'qwenTtsUrl' | 'qwenTtsApiKey'>>): Promise<AppConfig> {
|
||||
const existing = await this.getAppConfig();
|
||||
if (existing) {
|
||||
const [updated] = await db.update(appConfig)
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user