feat: add Minimax Speech as alternative TTS provider for Reels

- Add minimax_config table (migration + schema + storage CRUD)
- Add GET/POST /api/minimax/config routes
- Pass tts_provider + minimax_api_key through ffmpeg service
- Add generate_tts_minimax() in Python using Minimax T2A v2 API
- Fall back to ffsubsync for subtitle sync (no WordBoundary events)
- Add Minimax config card in Settings page
- Add provider toggle (Edge TTS / Minimax) + French voices in new-reel
This commit is contained in:
Michael committed 2026-05-18 11:55:07 +02:00
1 parent 9932561050
commit b7ef4940f7
9 files changed
+364 -29

No files matched your search

+12
View File
@@ -152,6 +152,18 @@ export async function migrate() {
);
`);
// minimax_config
await client.query(`
CREATE TABLE IF NOT EXISTS "minimax_config" (
"id" varchar PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" varchar NOT NULL REFERENCES "users"("id") ON DELETE cascade,
"api_key" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT minimax_config_user_id_unique UNIQUE ("user_id")
);
`);
console.log("[Migration] Safe migration completed.");
} catch (error) {
console.error("[Migration] Error during migration:", error);
+47 -1
View File
@@ -10,7 +10,7 @@ import passport from "./auth";
import { z } from "zod";
import { openRouterService } from "./services/openrouter";
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertFreesoundConfigSchema, updateFreesoundConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import { insertPostSchema, insertScheduledPostSchema, insertSocialPageSchema, insertAiGenerationSchema, insertCloudinaryConfigSchema, updateCloudinaryConfigSchema, insertOpenrouterConfigSchema, updateOpenrouterConfigSchema, insertFreesoundConfigSchema, updateFreesoundConfigSchema, updateMinimaxConfigSchema, insertMinimaxConfigSchema, insertUserSchema, postMedia, type SocialPage } from "@shared/schema";
import type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema";
import { freeSoundService } from "./services/freesound";
import { analyticsRouter } from "./routes/analytics";
@@ -477,6 +477,52 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// Minimax Configuration Routes
app.get("/api/minimax/config", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const config = await storage.getMinimaxConfig(user.id);
if (!config) {
return res.json(null);
}
return res.json({
...config,
apiKey: config.apiKey ? "********" : "",
});
} catch (error) {
console.error("Error fetching Minimax config:", error);
res.status(500).json({ error: "Failed to fetch configuration" });
}
});
app.post("/api/minimax/config", requireAuth, async (req, res) => {
try {
const user = req.user as User;
const data = updateMinimaxConfigSchema.parse(req.body);
let config = await storage.getMinimaxConfig(user.id);
const updateData: any = { ...data };
if (!updateData.apiKey || updateData.apiKey === "********") {
delete updateData.apiKey;
}
if (config) {
config = await storage.updateMinimaxConfig(user.id, updateData);
} else {
if (!data.apiKey) {
return res.status(400).json({ error: "API Key is required" });
}
config = await storage.createMinimaxConfig({ ...data as any, userId: user.id });
}
res.json({ success: true });
} catch (error) {
console.error("Error saving Minimax config:", error);
res.status(500).json({ error: "Failed to save configuration" });
}
});
// Reels & Music Routes
app.use("/api", requireAuth, reelsRouter);
app.use("/api/remotion", requireAuth, remotionRouter);
+36 -2
View File
@@ -298,6 +298,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
overlayText,
ttsEnabled,
ttsVoice,
ttsProvider,
wordDuration = 0.6,
fontSize = 64,
musicVolume = 0.25,
@@ -352,12 +353,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
const finalWordDuration = wordDuration;
let minimaxApiKey: string | undefined;
if (ttsEnabled && ttsProvider === 'minimax') {
const minimaxCfg = await storage.getMinimaxConfig(user.id);
if (!minimaxCfg?.apiKey) {
return res.status(400).json({ error: 'Clé API Minimax non configurée. Allez dans les Paramètres.' });
}
minimaxApiKey = minimaxCfg.apiKey;
}
// Traiter la vidéo via FFmpeg
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsProvider,
minimaxApiKey,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
@@ -389,13 +401,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
*/
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
try {
const { text, voice } = req.body;
const user = req.user as User;
const { text, voice, ttsProvider } = req.body;
if (!text) {
return res.status(400).json({ error: 'Texte requis' });
}
const result = await ffmpegService.previewTTS(text, voice);
let minimaxApiKey: string | undefined;
if (ttsProvider === 'minimax') {
const minimaxCfg = await storage.getMinimaxConfig(user.id);
if (!minimaxCfg?.apiKey) {
return res.status(400).json({ error: 'Clé API Minimax non configurée. Allez dans les Paramètres.' });
}
minimaxApiKey = minimaxCfg.apiKey;
}
const result = await ffmpegService.previewTTS(text, voice, ttsProvider, minimaxApiKey);
if (!result.success) {
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
@@ -487,6 +509,7 @@ async function processReelBackground(
description?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
pageIds: string[];
scheduledFor?: string;
wordDuration?: number;
@@ -506,6 +529,7 @@ async function processReelBackground(
description,
ttsEnabled,
ttsVoice,
ttsProvider,
pageIds,
scheduledFor,
wordDuration,
@@ -585,11 +609,21 @@ async function processReelBackground(
const finalWordDuration = wordDuration ?? 0.6;
let minimaxApiKeyBg: string | undefined;
if (ttsEnabled && ttsProvider === 'minimax') {
const minimaxCfg = await storage.getMinimaxConfig(userId);
if (minimaxCfg?.apiKey) {
minimaxApiKeyBg = minimaxCfg.apiKey;
}
}
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
text: overlayText,
musicUrl: finalMusicUrl,
ttsEnabled,
ttsVoice,
ttsProvider,
minimaxApiKey: minimaxApiKeyBg,
wordDuration: finalWordDuration,
fontSize,
musicVolume,
+16 -2
View File
@@ -13,6 +13,8 @@ interface FFmpegReelRequest {
music_url?: string; // OU URL directe de la musique
tts_enabled?: boolean; // Activation du TTS
tts_voice?: string; // Voix TTS (ex: fr-FR-VivienneNeural)
tts_provider?: string; // Provider TTS: "edge_tts" (défaut) ou "minimax"
minimax_api_key?: string; // Clé API Minimax (requis si tts_provider=minimax)
word_duration?: number; // Durée par mot (default: 0.6s)
font_size?: number; // Taille police (default: 24)
music_volume?: number; // Volume musique (default: 0.25)
@@ -71,6 +73,8 @@ export class FFmpegService {
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
minimaxApiKey?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
@@ -90,6 +94,8 @@ export class FFmpegService {
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled,
tts_voice: options.ttsVoice,
tts_provider: options.ttsProvider,
minimax_api_key: options.minimaxApiKey,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
@@ -172,6 +178,8 @@ export class FFmpegService {
musicUrl?: string;
ttsEnabled?: boolean;
ttsVoice?: string;
ttsProvider?: string;
minimaxApiKey?: string;
wordDuration?: number;
fontSize?: number;
musicVolume?: number;
@@ -191,6 +199,8 @@ export class FFmpegService {
music_url: options.musicUrl,
tts_enabled: options.ttsEnabled,
tts_voice: options.ttsVoice,
tts_provider: options.ttsProvider,
minimax_api_key: options.minimaxApiKey,
word_duration: options.wordDuration ?? 0.6,
font_size: options.fontSize ?? 64,
music_volume: options.musicVolume ?? 0.25,
@@ -285,7 +295,9 @@ export class FFmpegService {
}
async previewTTS(
text: string,
voice: string
voice: string,
ttsProvider?: string,
minimaxApiKey?: string
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
const config = this.ensureConfigured();
@@ -298,7 +310,9 @@ export class FFmpegService {
},
body: JSON.stringify({
text,
tts_voice: voice
tts_voice: voice,
tts_provider: ttsProvider,
minimax_api_key: minimaxApiKey,
}),
});
+21
View File
@@ -39,6 +39,9 @@ import {
type InsertAudioTrack,
appConfig,
type AppConfig,
minimaxConfig,
type MinimaxConfig,
type InsertMinimaxConfig,
} from "@shared/schema";
import { db } from "./db";
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
@@ -609,6 +612,24 @@ export class DatabaseStorage implements IStorage {
const [created] = await db.insert(appConfig).values(data).returning();
return created;
}
// Minimax Config
async getMinimaxConfig(userId: string): Promise<MinimaxConfig | undefined> {
const [config] = await db.select().from(minimaxConfig).where(eq(minimaxConfig.userId, userId));
return config || undefined;
}
async createMinimaxConfig(config: InsertMinimaxConfig): Promise<MinimaxConfig> {
const [newConfig] = await db.insert(minimaxConfig).values(config).returning();
return newConfig;
}
async updateMinimaxConfig(userId: string, config: Partial<InsertMinimaxConfig>): Promise<MinimaxConfig> {
const [updated] = await db.update(minimaxConfig)
.set({ ...config, updatedAt: new Date() })
.where(eq(minimaxConfig.userId, userId))
.returning();
return updated;
}
}
export const storage = new DatabaseStorage();