diff --git a/app.py b/app.py index 0da72341..41e2d199 100644 --- a/app.py +++ b/app.py @@ -1652,6 +1652,7 @@ async def thumbnail_publish_status(publish_id: str): generate_actor_images, get_elevenlabs_voices, DEFAULT_VOICES, + SUPPORTED_LANGUAGES, ) # State for SaaSShorts jobs (separate from video processing jobs) @@ -1679,6 +1680,8 @@ async def saasshorts_analyze( if not req.url and not req.description: raise HTTPException(status_code=400, detail="Provide a URL or a product description") + if req.language not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {req.language}") try: loop = asyncio.get_event_loop() @@ -2080,6 +2083,8 @@ async def saasshorts_generate( raise HTTPException(status_code=400, detail="Missing fal.ai API Key (X-Fal-Key header)") if not elevenlabs_key: raise HTTPException(status_code=400, detail="Missing ElevenLabs API Key (X-ElevenLabs-Key header)") + if req.script.get("language", "en") not in SUPPORTED_LANGUAGES: + raise HTTPException(status_code=400, detail=f"Unsupported language: {req.script.get('language')}") # Support retry: reuse output_dir so cached assets (image, voice, head, broll) are kept reused = False @@ -2249,7 +2254,13 @@ async def saasshorts_voices( # Fallback to default voices return { "voices": [ - {"voice_id": vid, "name": name, "category": "default"} + { + "voice_id": vid, + "name": name, + "category": "default", + "gender": "female" if "Female" in name else "male", + "labels": {"gender": "female" if "Female" in name else "male"}, + } for name, vid in DEFAULT_VOICES.items() ], "source": "defaults", diff --git a/dashboard/src/components/SaaShortsTab.jsx b/dashboard/src/components/SaaShortsTab.jsx index 6ffadbb1..deda8760 100644 --- a/dashboard/src/components/SaaShortsTab.jsx +++ b/dashboard/src/components/SaaShortsTab.jsx @@ -10,6 +10,21 @@ const STYLE_OPTIONS = [ { id: 'comparison', label: 'Before/After', desc: 'Comparison style' }, ]; +const LANGUAGE_OPTIONS = [ + { id: 'en', label: 'English', region: 'US' }, + { id: 'es', label: 'Spanish', region: 'ES' }, + { id: 'hi', label: 'Hindi', region: 'IN' }, + { id: 'bn', label: 'Bengali', region: 'IN' }, + { id: 'ta', label: 'Tamil', region: 'IN' }, + { id: 'te', label: 'Telugu', region: 'IN' }, + { id: 'mr', label: 'Marathi', region: 'IN' }, + { id: 'gu', label: 'Gujarati', region: 'IN' }, + { id: 'kn', label: 'Kannada', region: 'IN' }, + { id: 'ml', label: 'Malayalam', region: 'IN' }, + { id: 'pa', label: 'Punjabi', region: 'IN' }, + { id: 'ur', label: 'Urdu', region: 'IN' }, +]; + const CACHE_KEY = 'saasshorts_cache'; const CACHE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours @@ -61,6 +76,8 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo // Step 2: Configure const [voices, setVoices] = useState([]); + const [voicesSource, setVoicesSource] = useState('defaults'); + const [voicesLoading, setVoicesLoading] = useState(false); const [selectedVoice, setSelectedVoice] = useState('21m00Tcm4TlvDq8ikWAM'); const [actorDescription, setActorDescription] = useState(''); const [editedNarration, setEditedNarration] = useState(''); @@ -89,6 +106,7 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo // UI const [copied, setCopied] = useState(''); + const selectedLanguageOption = LANGUAGE_OPTIONS.find((l) => l.id === language) || LANGUAGE_OPTIONS[0]; const [logsExpanded, setLogsExpanded] = useState(true); // Pre-fill from cache on mount @@ -125,13 +143,13 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo 'es-male': 'ErXwobaYiN019PkySvjV', // Antoni }; // If we have fetched voices, pick the first matching one; otherwise use hardcoded default - const matchingVoice = voices.find(v => (v.labels?.gender || '').toLowerCase() === actorGender); + const matchingVoice = voices.find(v => (v.gender || v.labels?.gender || '').toLowerCase() === actorGender); if (matchingVoice) { setSelectedVoice(matchingVoice.voice_id); } else { setSelectedVoice(genderDefaults[`${language}-${actorGender}`] || genderDefaults['en-female']); } - }, [actorGender, language]); + }, [actorGender, language, voices]); // Poll generation status useEffect(() => { @@ -171,6 +189,7 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo }, [jobId, genStatus]); const fetchVoices = async () => { + setVoicesLoading(true); try { const res = await fetch(getApiUrl('/api/saasshorts/voices'), { headers: { 'X-ElevenLabs-Key': elevenLabsKey }, @@ -178,9 +197,12 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo if (res.ok) { const data = await res.json(); setVoices(data.voices || []); + setVoicesSource(data.source || 'elevenlabs'); } } catch (e) { console.error('Voices fetch error:', e); + } finally { + setVoicesLoading(false); } }; @@ -270,6 +292,7 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo const scriptToSend = { ...scripts[selectedScript] }; scriptToSend._product_name = analysis?.product_name || analysis?.name || ''; scriptToSend._product_url = url; + scriptToSend.language = language; if (editedNarration !== scriptToSend.full_narration) { scriptToSend.full_narration = editedNarration; } @@ -316,6 +339,7 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo const scriptToSend = { ...scripts[selectedScript] }; scriptToSend._product_name = analysis?.product_name || analysis?.name || ''; scriptToSend._product_url = url; + scriptToSend.language = language; if (editedNarration !== scriptToSend.full_narration) { scriptToSend.full_narration = editedNarration; } @@ -496,21 +520,18 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo
-
- {[ - { id: 'en', label: 'English', flag: '🇺🇸' }, - { id: 'es', label: 'Español', flag: '🇪🇸' }, - ].map((l) => ( +
+ {LANGUAGE_OPTIONS.map((l) => ( ))} @@ -859,17 +880,24 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo {/* Voice Selection */}
{(() => { // Filter voices by language/accent const filtered = voices.length > 0 ? voices.filter((v) => { - const gender = (v.labels?.gender || '').toLowerCase(); - // Only show voices that match the selected gender + const gender = (v.gender || v.labels?.gender || '').toLowerCase(); return gender === actorGender; }) .sort((a, b) => { + const aGender = (a.gender || a.labels?.gender || '').toLowerCase(); + const bGender = (b.gender || b.labels?.gender || '').toLowerCase(); + const aAccount = a.is_custom || a.is_premium ? 0 : 1; + const bAccount = b.is_custom || b.is_premium ? 0 : 1; + if (aAccount !== bAccount) return aAccount - bAccount; + const aGenderScore = aGender === actorGender ? 0 : 1; + const bGenderScore = bGender === actorGender ? 0 : 1; + if (aGenderScore !== bGenderScore) return aGenderScore - bGenderScore; const aAccent = (a.labels?.accent || '').toLowerCase(); const bAccent = (b.labels?.accent || '').toLowerCase(); if (language === 'es') { @@ -899,8 +927,12 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo }`} >
-
{v.name}
-
+
+ {v.name} + {v.is_premium && Premium} + {v.is_custom && !v.is_premium && Custom} +
+
{v.labels?.accent || ''} {v.labels?.gender || ''} {v.category ? `· ${v.category}` : ''}
@@ -941,7 +973,7 @@ export default function SaaShortsTab({ geminiApiKey, elevenLabsKey, falKey, uplo ], }; const key = `${language}-${actorGender}`; - const opts = defaults[key] || defaults['en-female']; + const opts = defaults[key] || defaults[`en-${actorGender}`] || defaults['en-female']; return (