mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 15:08:13 +02:00
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:
1 parent
9932561050
commit
b7ef4940f7
9 files changed
+364
-29
No files matched your search
@@ -66,6 +66,7 @@ export default function NewReel() {
|
|||||||
const [musicVolume, setMusicVolume] = useState([25]);
|
const [musicVolume, setMusicVolume] = useState([25]);
|
||||||
const [ttsEnabled, setTtsEnabled] = useState(true);
|
const [ttsEnabled, setTtsEnabled] = useState(true);
|
||||||
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
|
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
|
||||||
|
const [ttsProvider, setTtsProvider] = useState<"edge_tts" | "minimax">("edge_tts");
|
||||||
const [drawText, setDrawText] = useState(true);
|
const [drawText, setDrawText] = useState(true);
|
||||||
const [stabilize, setStabilize] = useState(true); // default to true
|
const [stabilize, setStabilize] = useState(true); // default to true
|
||||||
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
|
const [enableEndingEffect, setEnableEndingEffect] = useState(true);
|
||||||
@@ -87,9 +88,9 @@ export default function NewReel() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-calculate TTS sync when text or voice changes
|
// Auto-calculate TTS sync when text or voice changes (Edge TTS only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ttsEnabled || !overlayText.trim()) {
|
if (!ttsEnabled || !overlayText.trim() || ttsProvider !== "edge_tts") {
|
||||||
setSyncInfo(null);
|
setSyncInfo(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -103,7 +104,7 @@ export default function NewReel() {
|
|||||||
.catch(() => setSyncInfo(null));
|
.catch(() => setSyncInfo(null));
|
||||||
}, 800);
|
}, 800);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [overlayText, ttsVoice, ttsEnabled]);
|
}, [overlayText, ttsVoice, ttsEnabled, ttsProvider]);
|
||||||
|
|
||||||
|
|
||||||
// État audio preview
|
// État audio preview
|
||||||
@@ -362,6 +363,7 @@ export default function NewReel() {
|
|||||||
musicVolume: musicVolume[0] / 100,
|
musicVolume: musicVolume[0] / 100,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
drawText,
|
drawText,
|
||||||
stabilize: stabilize,
|
stabilize: stabilize,
|
||||||
enableEndingEffect,
|
enableEndingEffect,
|
||||||
@@ -764,27 +766,54 @@ export default function NewReel() {
|
|||||||
<div className="mt-4 space-y-2 ml-12 p-4 bg-muted/30 rounded-lg border border-border/50">
|
<div className="mt-4 space-y-2 ml-12 p-4 bg-muted/30 rounded-lg border border-border/50">
|
||||||
<Label className="flex items-center gap-2">
|
<Label className="flex items-center gap-2">
|
||||||
<Mic className="w-4 h-4" />
|
<Mic className="w-4 h-4" />
|
||||||
|
Moteur TTS
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTtsProvider("edge_tts"); setTtsVoice("fr-FR-VivienneMultilingualNeural"); }}
|
||||||
|
className={`p-2 rounded-md border-2 text-sm font-medium transition-all ${ttsProvider === "edge_tts" ? "border-primary bg-primary/10" : "border-transparent bg-muted/30 hover:bg-accent"}`}
|
||||||
|
>
|
||||||
|
Edge TTS (local)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTtsProvider("minimax"); setTtsVoice("French_Female_News"); }}
|
||||||
|
className={`p-2 rounded-md border-2 text-sm font-medium transition-all ${ttsProvider === "minimax" ? "border-primary bg-primary/10" : "border-transparent bg-muted/30 hover:bg-accent"}`}
|
||||||
|
>
|
||||||
|
Minimax (premium)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Label className="flex items-center gap-2 mt-3">
|
||||||
Voix du narrateur
|
Voix du narrateur
|
||||||
</Label>
|
</Label>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{[
|
{(ttsProvider === "edge_tts" ? [
|
||||||
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme (Défaut)", style: "bg-pink-500/10 border-pink-500/50" },
|
{ id: "fr-FR-VivienneMultilingualNeural", label: "Vivienne", type: "Femme (Défaut)", style: "bg-pink-500/10 border-pink-500/50" },
|
||||||
{ id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme", style: "bg-blue-500/10 border-blue-500/50" },
|
{ id: "fr-FR-RemyMultilingualNeural", label: "Rémy", type: "Homme", style: "bg-blue-500/10 border-blue-500/50" },
|
||||||
{ id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme (Calme)", style: "bg-purple-500/10 border-purple-500/50" },
|
{ id: "fr-FR-DeniseNeural", label: "Denise", type: "Femme (Calme)", style: "bg-purple-500/10 border-purple-500/50" },
|
||||||
{ id: "fr-FR-HenriNeural", label: "Henri", type: "Homme (Grave)", style: "bg-slate-500/10 border-slate-500/50" },
|
{ id: "fr-FR-HenriNeural", label: "Henri", type: "Homme (Grave)", style: "bg-slate-500/10 border-slate-500/50" },
|
||||||
{ id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant", style: "bg-orange-500/10 border-orange-500/50" }
|
{ id: "fr-FR-EloiseNeural", label: "Éloïse", type: "Enfant", style: "bg-orange-500/10 border-orange-500/50" }
|
||||||
].map((voice) => (
|
] : [
|
||||||
|
{ id: "French_Female_News", label: "News Féminin", type: "Femme (Défaut)", style: "bg-pink-500/10 border-pink-500/50" },
|
||||||
|
{ id: "French_Male_Speech_New", label: "Speech Masculin", type: "Homme", style: "bg-blue-500/10 border-blue-500/50" },
|
||||||
|
{ id: "French_MovieLeadFemale", label: "Cinéma Femme", type: "Femme (Dramatique)", style: "bg-purple-500/10 border-purple-500/50" },
|
||||||
|
{ id: "French_CasualMan", label: "Casual Homme", type: "Homme (Décontracté)", style: "bg-slate-500/10 border-slate-500/50" },
|
||||||
|
{ id: "French_FemaleAnchor", label: "Présentatrice", type: "Femme (Média)", style: "bg-rose-500/10 border-rose-500/50" },
|
||||||
|
{ id: "French_MaleNarrator", label: "Narrateur", type: "Homme (Narration)", style: "bg-indigo-500/10 border-indigo-500/50" }
|
||||||
|
]).map((voice) => (
|
||||||
<div
|
<div
|
||||||
key={voice.id}
|
key={voice.id}
|
||||||
onClick={() => setTtsVoice(voice.id)}
|
onClick={() => setTtsVoice(voice.id)}
|
||||||
className={`cursor-pointer p-3 rounded-md border-2 transition-all hover:bg-accent ${ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural')
|
className={`cursor-pointer p-3 rounded-md border-2 transition-all hover:bg-accent ${ttsVoice === voice.id
|
||||||
? `border-primary ${voice.style}`
|
? `border-primary ${voice.style}`
|
||||||
: 'border-transparent bg-muted/30'
|
: 'border-transparent bg-muted/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-semibold flex items-center justify-between">
|
<div className="font-semibold flex items-center justify-between">
|
||||||
{voice.label}
|
{voice.label}
|
||||||
{(ttsVoice === voice.id || (ttsVoice === 'female' && voice.id === 'fr-FR-VivienneMultilingualNeural')) && <Check className="w-3 h-3 text-primary" />}
|
{ttsVoice === voice.id && <Check className="w-3 h-3 text-primary" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">{voice.type}</div>
|
<div className="text-xs text-muted-foreground">{voice.type}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -802,7 +831,8 @@ export default function NewReel() {
|
|||||||
try {
|
try {
|
||||||
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
const response = await apiRequest('POST', '/api/reels/tts-preview', {
|
||||||
text: textToTest,
|
text: textToTest,
|
||||||
voice: ttsVoice
|
voice: ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success && data.audioBase64) {
|
if (data.success && data.audioBase64) {
|
||||||
@@ -823,7 +853,15 @@ export default function NewReel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{syncInfo && (
|
{ttsProvider === "minimax" && (
|
||||||
|
<div className="p-3 rounded-lg border mt-3 bg-blue-500/10 border-blue-500/30">
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
Minimax utilise ffsubsync pour la synchronisation des sous-titres. Configurez votre clé API dans Paramètres.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ttsProvider === "edge_tts" && syncInfo && (
|
||||||
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
|
<div className={`p-3 rounded-lg border mt-3 ${syncInfo.isHealthy ? 'bg-green-500/10 border-green-500/30' : 'bg-yellow-500/10 border-yellow-500/30'}`}>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium">Sync texte/voix</span>
|
<span className="font-medium">Sync texte/voix</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug } from "lucide-react";
|
import { Settings as SettingsIcon, Bell, Key, Shield, Cloud, Brain, Image, Upload, X, Video, Plug, Mic } from "lucide-react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import TopBar from "@/components/topbar";
|
import TopBar from "@/components/topbar";
|
||||||
@@ -30,6 +30,7 @@ export default function Settings() {
|
|||||||
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
const [ffmpegApiUrl, setFfmpegApiUrl] = useState("");
|
||||||
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
const [ffmpegApiKey, setFfmpegApiKey] = useState("");
|
||||||
const [externalApiKey, setExternalApiKey] = useState("");
|
const [externalApiKey, setExternalApiKey] = useState("");
|
||||||
|
const [minimaxApiKey, setMinimaxApiKey] = useState("");
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
const { data: session } = useQuery<{ id: string; username: string; role: string }>({
|
||||||
@@ -60,6 +61,10 @@ export default function Settings() {
|
|||||||
enabled: isAdmin,
|
enabled: isAdmin,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: minimaxConfig } = useQuery({
|
||||||
|
queryKey: ['/api/minimax/config'],
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cloudinaryConfig) {
|
if (cloudinaryConfig) {
|
||||||
setCloudName((cloudinaryConfig as any).cloudName || "");
|
setCloudName((cloudinaryConfig as any).cloudName || "");
|
||||||
@@ -258,6 +263,32 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
const hasExistingExternalApiConfig = !!(externalApiConfig as any)?.configured;
|
||||||
|
const hasExistingMinimaxConfig = !!(minimaxConfig as any)?.apiKey;
|
||||||
|
|
||||||
|
const saveMinimaxMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const payload: any = {};
|
||||||
|
if (minimaxApiKey && minimaxApiKey.trim() !== "") {
|
||||||
|
payload.apiKey = minimaxApiKey;
|
||||||
|
}
|
||||||
|
return apiRequest('POST', '/api/minimax/config', payload);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['/api/minimax/config'] });
|
||||||
|
setMinimaxApiKey("");
|
||||||
|
toast({
|
||||||
|
title: "Configuration sauvegardée",
|
||||||
|
description: "Votre clé API Minimax a été enregistrée",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: "Erreur",
|
||||||
|
description: "Impossible de sauvegarder la configuration Minimax",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const saveExternalApiMutation = useMutation({
|
const saveExternalApiMutation = useMutation({
|
||||||
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
mutationFn: () => apiRequest('POST', '/api/settings/external-api', { apiKey: externalApiKey }),
|
||||||
@@ -620,6 +651,57 @@ export default function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
|
<CardHeader className="p-6">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Mic className="w-5 h-5" />
|
||||||
|
Configuration Minimax Speech (TTS)
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configurez l'API Minimax pour générer des voix françaises premium dans les Reels
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="minimaxApiKey">Clé API Minimax</Label>
|
||||||
|
<Input
|
||||||
|
id="minimaxApiKey"
|
||||||
|
type="password"
|
||||||
|
value={minimaxApiKey}
|
||||||
|
onChange={(e) => setMinimaxApiKey(e.target.value)}
|
||||||
|
placeholder={hasExistingMinimaxConfig ? "••••••••••••••••" : "eyJhbGci..."}
|
||||||
|
data-testid="input-minimax-api-key"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{hasExistingMinimaxConfig ? (
|
||||||
|
<span className="text-green-600 dark:text-green-400">✓ Clé API configurée - Laissez vide pour conserver</span>
|
||||||
|
) : (
|
||||||
|
"Votre clé API depuis platform.minimaxi.com"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3 space-y-1">
|
||||||
|
<p className="text-xs font-medium">Voix françaises disponibles :</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-xs text-muted-foreground">
|
||||||
|
<span>• French_Female_News</span>
|
||||||
|
<span>• French_Male_Speech_New</span>
|
||||||
|
<span>• French_MovieLeadFemale</span>
|
||||||
|
<span>• French_CasualMan</span>
|
||||||
|
<span>• French_FemaleAnchor</span>
|
||||||
|
<span>• French_MaleNarrator</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => saveMinimaxMutation.mutate()}
|
||||||
|
disabled={saveMinimaxMutation.isPending || (!minimaxApiKey.trim() && !hasExistingMinimaxConfig)}
|
||||||
|
data-testid="button-save-minimax"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{saveMinimaxMutation.isPending ? "Enregistrement..." : "Enregistrer Minimax"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Card className="rounded-2xl border-border/50 shadow-lg">
|
<Card className="rounded-2xl border-border/50 shadow-lg">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-6">
|
||||||
|
|||||||
+78
-14
@@ -159,6 +159,8 @@ class ReelRequest(BaseModel):
|
|||||||
music_volume: float = 0.25
|
music_volume: float = 0.25
|
||||||
tts_enabled: bool = False
|
tts_enabled: bool = False
|
||||||
tts_voice: str = "fr-FR-VivienneMultilingualNeural"
|
tts_voice: str = "fr-FR-VivienneMultilingualNeural"
|
||||||
|
tts_provider: str = "edge_tts" # "edge_tts" or "minimax"
|
||||||
|
minimax_api_key: Optional[str] = None
|
||||||
draw_text: bool = True
|
draw_text: bool = True
|
||||||
stabilize: bool = False # Stabilisation vidéo via vidstab
|
stabilize: bool = False # Stabilisation vidéo via vidstab
|
||||||
enable_ending_effect: bool = True
|
enable_ending_effect: bool = True
|
||||||
@@ -192,6 +194,34 @@ def clean_text_for_tts(text: str) -> str:
|
|||||||
return " ".join(text.split())
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
|
def generate_tts_minimax(text: str, voice_id: str, api_key: str, audio_path: Path) -> None:
|
||||||
|
"""Generate TTS audio using Minimax Speech API (T2A v2)."""
|
||||||
|
url = "https://api.minimax.io/v1/t2a_v2"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"model": "speech-2.8-hd",
|
||||||
|
"text": text,
|
||||||
|
"voice_setting": {"voice_id": voice_id},
|
||||||
|
"audio_setting": {"format": "mp3", "sample_rate": 24000, "bitrate": 128},
|
||||||
|
}
|
||||||
|
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
base_resp = data.get("base_resp", {})
|
||||||
|
if base_resp.get("status_code") != 0:
|
||||||
|
raise Exception(f"Minimax API error: {base_resp.get('status_msg', 'unknown error')}")
|
||||||
|
|
||||||
|
audio_hex = data["data"]["audio"]
|
||||||
|
audio_bytes = bytes.fromhex(audio_hex)
|
||||||
|
with open(audio_path, "wb") as f:
|
||||||
|
f.write(audio_bytes)
|
||||||
|
print(f"✅ Minimax TTS audio saved: {audio_path.stat().st_size} bytes")
|
||||||
|
|
||||||
|
|
||||||
async def generate_tts_with_subs(
|
async def generate_tts_with_subs(
|
||||||
text: str,
|
text: str,
|
||||||
voice: str,
|
voice: str,
|
||||||
@@ -199,13 +229,44 @@ async def generate_tts_with_subs(
|
|||||||
ass_path: Path,
|
ass_path: Path,
|
||||||
display_text: Optional[str] = None,
|
display_text: Optional[str] = None,
|
||||||
delay: float = 0.0,
|
delay: float = 0.0,
|
||||||
|
tts_provider: str = "edge_tts",
|
||||||
|
minimax_api_key: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Generate TTS audio with word-level synchronized subtitles.
|
"""Generate TTS audio with word-level synchronized subtitles.
|
||||||
|
|
||||||
Uses edge_tts.Communicate.stream() to capture WordBoundary events,
|
Uses edge_tts.Communicate.stream() to capture WordBoundary events,
|
||||||
providing millisecond-accurate subtitle timing instead of linear estimation.
|
providing millisecond-accurate subtitle timing instead of linear estimation.
|
||||||
|
For Minimax provider, falls back to ffsubsync (no word boundaries available).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Minimax branch: no WordBoundary events, use ffsubsync for subtitle sync
|
||||||
|
if tts_provider == "minimax":
|
||||||
|
if not minimax_api_key:
|
||||||
|
raise Exception("Minimax API key required but not provided")
|
||||||
|
text_to_display = display_text if display_text else text
|
||||||
|
print(f"🔊 Minimax TTS with voice: {voice}")
|
||||||
|
generate_tts_minimax(text, voice, minimax_api_key, audio_path)
|
||||||
|
|
||||||
|
audio_duration = None
|
||||||
|
try:
|
||||||
|
dur_cmd = [
|
||||||
|
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1", str(audio_path),
|
||||||
|
]
|
||||||
|
dur_proc = subprocess.run(dur_cmd, stdout=subprocess.PIPE, text=True)
|
||||||
|
audio_duration = float(dur_proc.stdout.strip())
|
||||||
|
print(f"⏱️ Minimax TTS Duration: {audio_duration:.2f}s")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Could not measure Minimax TTS duration: {e}")
|
||||||
|
|
||||||
|
unsynced_srt_path = audio_path.with_suffix(".unsynced.srt")
|
||||||
|
synced_srt_path = audio_path.with_suffix(".synced.srt")
|
||||||
|
generate_unsynced_srt(text_to_display, unsynced_srt_path, total_duration=audio_duration)
|
||||||
|
run_ffsubsync(audio_path, unsynced_srt_path, synced_srt_path)
|
||||||
|
convert_srt_to_ass(synced_srt_path, ass_path, font_size=65, delay=delay)
|
||||||
|
print("✅ Minimax TTS synchronisation completed with ffsubsync")
|
||||||
|
return
|
||||||
|
|
||||||
# Determine gender of requested voice to choose appropriate fallbacks
|
# Determine gender of requested voice to choose appropriate fallbacks
|
||||||
is_male = any(name in voice for name in ["Remi", "Henri", "Paul"])
|
is_male = any(name in voice for name in ["Remi", "Henri", "Paul"])
|
||||||
|
|
||||||
@@ -796,15 +857,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
print(f"🔊 TTS enabled. Original: '{request.text}'")
|
print(f"🔊 TTS enabled. Original: '{request.text}'")
|
||||||
print(f"🔊 TTS cleaned: '{tts_clean_text}'")
|
print(f"🔊 TTS cleaned: '{tts_clean_text}'")
|
||||||
|
|
||||||
# Check for male/female voice map
|
# Check for male/female voice map (edge_tts only)
|
||||||
voice = request.tts_voice
|
voice = request.tts_voice
|
||||||
if voice == "male":
|
if request.tts_provider != "minimax":
|
||||||
voice = "fr-FR-RemyMultilingualNeural"
|
if voice == "male":
|
||||||
elif voice == "female":
|
voice = "fr-FR-RemyMultilingualNeural"
|
||||||
voice = "fr-FR-VivienneMultilingualNeural"
|
elif voice == "female":
|
||||||
elif not voice or "Neural" not in voice:
|
voice = "fr-FR-VivienneMultilingualNeural"
|
||||||
# Default if invalid
|
elif not voice or "Neural" not in voice:
|
||||||
voice = "fr-FR-VivienneMultilingualNeural"
|
voice = "fr-FR-VivienneMultilingualNeural"
|
||||||
|
|
||||||
print(f"🔊 Using voice: {voice}")
|
print(f"🔊 Using voice: {voice}")
|
||||||
|
|
||||||
@@ -818,6 +879,8 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
tts_ass_path,
|
tts_ass_path,
|
||||||
display_text=clean_text_for_display(request.text),
|
display_text=clean_text_for_display(request.text),
|
||||||
delay=2.0,
|
delay=2.0,
|
||||||
|
tts_provider=request.tts_provider,
|
||||||
|
minimax_api_key=request.minimax_api_key,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify files were created
|
# Verify files were created
|
||||||
@@ -1189,12 +1252,13 @@ async def preview_tts(request: ReelRequest, x_api_key: str = Header(None)):
|
|||||||
|
|
||||||
# Determine voice (reuse logic)
|
# Determine voice (reuse logic)
|
||||||
voice = request.tts_voice
|
voice = request.tts_voice
|
||||||
if voice == "male":
|
if request.tts_provider != "minimax":
|
||||||
voice = "fr-FR-RemyMultilingualNeural"
|
if voice == "male":
|
||||||
elif voice == "female":
|
voice = "fr-FR-RemyMultilingualNeural"
|
||||||
voice = "fr-FR-VivienneMultilingualNeural"
|
elif voice == "female":
|
||||||
elif not voice or "Neural" not in voice:
|
voice = "fr-FR-VivienneMultilingualNeural"
|
||||||
voice = "fr-FR-VivienneMultilingualNeural"
|
elif not voice or "Neural" not in voice:
|
||||||
|
voice = "fr-FR-VivienneMultilingualNeural"
|
||||||
|
|
||||||
# Pass original text for subtitles (implied in SRT for preview too if needed, though mostly audio)
|
# Pass original text for subtitles (implied in SRT for preview too if needed, though mostly audio)
|
||||||
# Preview TTS generation doesn't technically need the full 2s video delay, but if the frontend plays it against standard timing it might.
|
# Preview TTS generation doesn't technically need the full 2s video delay, but if the frontend plays it against standard timing it might.
|
||||||
|
|||||||
@@ -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.");
|
console.log("[Migration] Safe migration completed.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Migration] Error during migration:", error);
|
console.error("[Migration] Error during migration:", error);
|
||||||
|
|||||||
+47
-1
@@ -10,7 +10,7 @@ import passport from "./auth";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { openRouterService } from "./services/openrouter";
|
import { openRouterService } from "./services/openrouter";
|
||||||
import { minioService as cloudinaryService, buildMinioUrl } from "./services/minio";
|
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 type { User, InsertUser, ScheduledPost, FreesoundConfig } from "@shared/schema";
|
||||||
import { freeSoundService } from "./services/freesound";
|
import { freeSoundService } from "./services/freesound";
|
||||||
import { analyticsRouter } from "./routes/analytics";
|
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
|
// Reels & Music Routes
|
||||||
app.use("/api", requireAuth, reelsRouter);
|
app.use("/api", requireAuth, reelsRouter);
|
||||||
app.use("/api/remotion", requireAuth, remotionRouter);
|
app.use("/api/remotion", requireAuth, remotionRouter);
|
||||||
|
|||||||
+36
-2
@@ -298,6 +298,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
overlayText,
|
overlayText,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
wordDuration = 0.6,
|
wordDuration = 0.6,
|
||||||
fontSize = 64,
|
fontSize = 64,
|
||||||
musicVolume = 0.25,
|
musicVolume = 0.25,
|
||||||
@@ -352,12 +353,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const finalWordDuration = wordDuration;
|
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
|
// Traiter la vidéo via FFmpeg
|
||||||
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
const result = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
musicUrl: finalMusicUrl,
|
musicUrl: finalMusicUrl,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
|
minimaxApiKey,
|
||||||
wordDuration: finalWordDuration,
|
wordDuration: finalWordDuration,
|
||||||
fontSize,
|
fontSize,
|
||||||
musicVolume,
|
musicVolume,
|
||||||
@@ -389,13 +401,23 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
reelsRouter.post('/reels/tts-preview', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { text, voice } = req.body;
|
const user = req.user as User;
|
||||||
|
const { text, voice, ttsProvider } = req.body;
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return res.status(400).json({ error: 'Texte requis' });
|
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) {
|
if (!result.success) {
|
||||||
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
return res.status(500).json({ error: result.error || 'Erreur de génération TTS' });
|
||||||
@@ -487,6 +509,7 @@ async function processReelBackground(
|
|||||||
description?: string;
|
description?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
ttsVoice?: string;
|
ttsVoice?: string;
|
||||||
|
ttsProvider?: string;
|
||||||
pageIds: string[];
|
pageIds: string[];
|
||||||
scheduledFor?: string;
|
scheduledFor?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
@@ -506,6 +529,7 @@ async function processReelBackground(
|
|||||||
description,
|
description,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
pageIds,
|
pageIds,
|
||||||
scheduledFor,
|
scheduledFor,
|
||||||
wordDuration,
|
wordDuration,
|
||||||
@@ -585,11 +609,21 @@ async function processReelBackground(
|
|||||||
|
|
||||||
const finalWordDuration = wordDuration ?? 0.6;
|
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), {
|
const ffmpegResult = await ffmpegService.processReelFromUrl(resolveInternalUrl(media.originalUrl), {
|
||||||
text: overlayText,
|
text: overlayText,
|
||||||
musicUrl: finalMusicUrl,
|
musicUrl: finalMusicUrl,
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsVoice,
|
ttsVoice,
|
||||||
|
ttsProvider,
|
||||||
|
minimaxApiKey: minimaxApiKeyBg,
|
||||||
wordDuration: finalWordDuration,
|
wordDuration: finalWordDuration,
|
||||||
fontSize,
|
fontSize,
|
||||||
musicVolume,
|
musicVolume,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ interface FFmpegReelRequest {
|
|||||||
music_url?: string; // OU URL directe de la musique
|
music_url?: string; // OU URL directe de la musique
|
||||||
tts_enabled?: boolean; // Activation du TTS
|
tts_enabled?: boolean; // Activation du TTS
|
||||||
tts_voice?: string; // Voix TTS (ex: fr-FR-VivienneNeural)
|
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)
|
word_duration?: number; // Durée par mot (default: 0.6s)
|
||||||
font_size?: number; // Taille police (default: 24)
|
font_size?: number; // Taille police (default: 24)
|
||||||
music_volume?: number; // Volume musique (default: 0.25)
|
music_volume?: number; // Volume musique (default: 0.25)
|
||||||
@@ -71,6 +73,8 @@ export class FFmpegService {
|
|||||||
musicUrl?: string;
|
musicUrl?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
ttsVoice?: string;
|
ttsVoice?: string;
|
||||||
|
ttsProvider?: string;
|
||||||
|
minimaxApiKey?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
@@ -90,6 +94,8 @@ export class FFmpegService {
|
|||||||
music_url: options.musicUrl,
|
music_url: options.musicUrl,
|
||||||
tts_enabled: options.ttsEnabled,
|
tts_enabled: options.ttsEnabled,
|
||||||
tts_voice: options.ttsVoice,
|
tts_voice: options.ttsVoice,
|
||||||
|
tts_provider: options.ttsProvider,
|
||||||
|
minimax_api_key: options.minimaxApiKey,
|
||||||
word_duration: options.wordDuration ?? 0.6,
|
word_duration: options.wordDuration ?? 0.6,
|
||||||
font_size: options.fontSize ?? 64,
|
font_size: options.fontSize ?? 64,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
@@ -172,6 +178,8 @@ export class FFmpegService {
|
|||||||
musicUrl?: string;
|
musicUrl?: string;
|
||||||
ttsEnabled?: boolean;
|
ttsEnabled?: boolean;
|
||||||
ttsVoice?: string;
|
ttsVoice?: string;
|
||||||
|
ttsProvider?: string;
|
||||||
|
minimaxApiKey?: string;
|
||||||
wordDuration?: number;
|
wordDuration?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
musicVolume?: number;
|
musicVolume?: number;
|
||||||
@@ -191,6 +199,8 @@ export class FFmpegService {
|
|||||||
music_url: options.musicUrl,
|
music_url: options.musicUrl,
|
||||||
tts_enabled: options.ttsEnabled,
|
tts_enabled: options.ttsEnabled,
|
||||||
tts_voice: options.ttsVoice,
|
tts_voice: options.ttsVoice,
|
||||||
|
tts_provider: options.ttsProvider,
|
||||||
|
minimax_api_key: options.minimaxApiKey,
|
||||||
word_duration: options.wordDuration ?? 0.6,
|
word_duration: options.wordDuration ?? 0.6,
|
||||||
font_size: options.fontSize ?? 64,
|
font_size: options.fontSize ?? 64,
|
||||||
music_volume: options.musicVolume ?? 0.25,
|
music_volume: options.musicVolume ?? 0.25,
|
||||||
@@ -285,7 +295,9 @@ export class FFmpegService {
|
|||||||
}
|
}
|
||||||
async previewTTS(
|
async previewTTS(
|
||||||
text: string,
|
text: string,
|
||||||
voice: string
|
voice: string,
|
||||||
|
ttsProvider?: string,
|
||||||
|
minimaxApiKey?: string
|
||||||
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
): Promise<{ success: boolean; audioBase64?: string; error?: string }> {
|
||||||
const config = this.ensureConfigured();
|
const config = this.ensureConfigured();
|
||||||
|
|
||||||
@@ -298,7 +310,9 @@ export class FFmpegService {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
text,
|
text,
|
||||||
tts_voice: voice
|
tts_voice: voice,
|
||||||
|
tts_provider: ttsProvider,
|
||||||
|
minimax_api_key: minimaxApiKey,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import {
|
|||||||
type InsertAudioTrack,
|
type InsertAudioTrack,
|
||||||
appConfig,
|
appConfig,
|
||||||
type AppConfig,
|
type AppConfig,
|
||||||
|
minimaxConfig,
|
||||||
|
type MinimaxConfig,
|
||||||
|
type InsertMinimaxConfig,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { eq, and, gte, lte, desc, asc, isNull, inArray } from "drizzle-orm";
|
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();
|
const [created] = await db.insert(appConfig).values(data).returning();
|
||||||
return created;
|
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();
|
export const storage = new DatabaseStorage();
|
||||||
@@ -142,6 +142,14 @@ export const freesoundConfig = pgTable("freesound_config", {
|
|||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const minimaxConfig = pgTable("minimax_config", {
|
||||||
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
|
userId: varchar("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
apiKey: text("api_key").notNull(),
|
||||||
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
export const media = pgTable("media", {
|
export const media = pgTable("media", {
|
||||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||||
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: varchar("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
@@ -489,4 +497,20 @@ export type AppConfig = typeof appConfig.$inferSelect;
|
|||||||
export type InsertFreesoundConfig = z.infer<typeof insertFreesoundConfigSchema>;
|
export type InsertFreesoundConfig = z.infer<typeof insertFreesoundConfigSchema>;
|
||||||
export type UpdateFreesoundConfig = z.infer<typeof updateFreesoundConfigSchema>;
|
export type UpdateFreesoundConfig = z.infer<typeof updateFreesoundConfigSchema>;
|
||||||
|
|
||||||
|
export const insertMinimaxConfigSchema = createInsertSchema(minimaxConfig).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateMinimaxConfigSchema = insertMinimaxConfigSchema.omit({
|
||||||
|
userId: true
|
||||||
|
}).partial({
|
||||||
|
apiKey: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type MinimaxConfig = typeof minimaxConfig.$inferSelect;
|
||||||
|
export type InsertMinimaxConfig = z.infer<typeof insertMinimaxConfigSchema>;
|
||||||
|
export type UpdateMinimaxConfig = z.infer<typeof updateMinimaxConfigSchema>;
|
||||||
|
|
||||||
|
|
||||||
Reference in new issue
Block a user