mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 14:38:11 +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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user