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 (