@danhachuel/thunderbolt 0.4.76 → 0.4.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -69,6 +69,7 @@ from hermes_ui.update_manager import check_version, restart_current_process, upd
69
69
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
70
70
  from hermes_ui.script_generation import generate_script_document
71
71
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
72
+ from hermes_ui.elevenlabs_voices import ElevenLabsVoicesError, cached_personal_voices, fetch_personal_voices, personal_voice_options
72
73
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
73
74
  from hermes_ui.thumbnails import (
74
75
  generate_thumbnail_for_task,
@@ -185,7 +186,7 @@ VIDEO_CONCATENATION_OPTIONS = ["Random Concatenation (Recommended)", "Sequential
185
186
  VIDEO_TRANSITION_OPTIONS = ["None", "Fade", "Dissolve"]
186
187
  VIDEO_ENCODER_OPTIONS = ["Default (Recommended)", "H.264", "H.265"]
187
188
  VOICEOVER_MODE_OPTIONS = ["Auto", "Upload", "None"]
188
- VOICEOVER_SERVICE_OPTIONS = ["Azure Speech SDK V2", "Azure TTS V1"]
189
+ VOICEOVER_SERVICE_OPTIONS = ["Azure Speech SDK V2", "Azure TTS V1", "ElevenLabs"]
189
190
  VOICEOVER_VOLUME_OPTIONS = ["20%", "40%", "60%", "80%", "100%"]
190
191
  VOICEOVER_SPEED_OPTIONS = ["0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "2.0x"]
191
192
  BACKGROUND_MUSIC_SOURCE_OPTIONS = ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API", "Random Background Music", "Sem música"]
@@ -928,6 +929,9 @@ def voice_catalog(current: str = "") -> list[str]:
928
929
  voices.extend(f"{item.get('name', '')}-{item.get('gender', '')}" for item in voice_data if item.get("name"))
929
930
  except (OSError, json.JSONDecodeError):
930
931
  pass
932
+ for voice_id in personal_voice_options(read_json("settings.json", {})):
933
+ if voice_id not in voices:
934
+ voices.append(voice_id)
931
935
  for candidate in (current, "en-US-AriaNeural-Female", "pt-BR-FranciscaNeural-Female", "pt-BR-AntonioNeural-Male"):
932
936
  if candidate and candidate not in voices:
933
937
  voices.append(candidate)
@@ -1091,6 +1095,8 @@ def render_video_generation_settings(
1091
1095
  and str(configured_voice_settings.get("azure_speech_region") or "").strip()
1092
1096
  )
1093
1097
  voice_service_default = "Azure Speech SDK V2" if configured_azure_voice else "Azure TTS V1"
1098
+ if str(st.session_state.get(f"{prefix}_voice", "")).strip() in personal_voice_options(configured_voice_settings):
1099
+ voice_service_default = "ElevenLabs"
1094
1100
  voice_service_index = VOICEOVER_SERVICE_OPTIONS.index(voice_service_default)
1095
1101
  settings["voiceover_service"] = st.selectbox(
1096
1102
  "Voiceover Service",
@@ -1108,7 +1114,13 @@ def render_video_generation_settings(
1108
1114
  st.session_state[channel_state_key] = channel_id
1109
1115
  current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
1110
1116
  voice_options = voice_catalog(current_voice)
1111
- settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
1117
+ personal_options = personal_voice_options(configured_voice_settings)
1118
+ settings["voice"] = st.selectbox(
1119
+ "Voice (match script language)",
1120
+ voice_options,
1121
+ format_func=lambda value: personal_options.get(value, value) if value else "Sem voz seleccionada",
1122
+ key=f"{prefix}_voice",
1123
+ )
1112
1124
  volume_speed_cols = st.columns(2)
1113
1125
  with volume_speed_cols[0]:
1114
1126
  settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
@@ -3312,7 +3324,7 @@ def render_music_creation():
3312
3324
  ("music_task_prompt", "prompt"),
3313
3325
  ):
3314
3326
  st.session_state[state_key] = str(generated_fields.get(generated_key) or "")
3315
- provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
3327
+ provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria", "Eleven Music"], key="music_task_provider")
3316
3328
  theme = st.text_input("Tema / assunto principal", key="music_task_theme", placeholder="Ex.: uma viagem nocturna pela costa portuguesa")
3317
3329
  title = st.text_input("Título da música", key="music_task_title")
3318
3330
  language = st.selectbox(
@@ -3324,6 +3336,26 @@ def render_music_creation():
3324
3336
  )
3325
3337
  genre = st.selectbox("Género musical", list(MUSIC_GENRES), key="music_task_genre")
3326
3338
  vocal = st.selectbox("Vocal", list(MUSIC_VOCAL_OPTIONS), key="music_task_vocal")
3339
+ eleven_voice_id = ""
3340
+ eleven_voice_gender = ""
3341
+ if provider_label == "Eleven Music":
3342
+ cached_voices = cached_personal_voices()
3343
+ if not cached_voices:
3344
+ st.info("Sincronize primeiro as vozes pessoais em Vozes Personalizadas. O Eleven Music não disponibiliza voz de coral para este selector.")
3345
+ else:
3346
+ voice_options = {str(item.get("voice_id")): item for item in cached_voices if item.get("voice_id")}
3347
+ selected_voice_id = st.selectbox(
3348
+ "Voz personalizada (Eleven Music)",
3349
+ [""] + list(voice_options),
3350
+ format_func=lambda item: "Sem voz personalizada" if not item else str(voice_options[item].get("name") or item),
3351
+ key="music_task_eleven_voice",
3352
+ )
3353
+ eleven_voice_id = selected_voice_id
3354
+ selected_voice = voice_options.get(selected_voice_id, {})
3355
+ labels = selected_voice.get("labels") if isinstance(selected_voice.get("labels"), dict) else {}
3356
+ raw_gender = str(labels.get("gender") or labels.get("sex") or "").strip().casefold()
3357
+ eleven_voice_gender = "female" if raw_gender in {"female", "feminino", "woman", "mulher"} else ("male" if raw_gender in {"male", "masculino", "man", "homem"} else "")
3358
+ st.caption(f"Tipo de voz detectado automaticamente: {eleven_voice_gender or 'não identificado'}. Vozes de coral não estão disponíveis no Eleven Music.")
3327
3359
  references = st.text_area(
3328
3360
  "Referências culturais, paisagens, clima ou artistas similares (opcional)",
3329
3361
  key="music_task_references",
@@ -3368,6 +3400,8 @@ def render_music_creation():
3368
3400
  vocal=vocal,
3369
3401
  references=references,
3370
3402
  theme=theme,
3403
+ voice_id=eleven_voice_id,
3404
+ voice_gender=eleven_voice_gender,
3371
3405
  )
3372
3406
  st.success(f"Música criada no Music Backlog: {task['title']}")
3373
3407
  st.rerun()
@@ -3376,8 +3410,49 @@ def render_music_creation():
3376
3410
 
3377
3411
 
3378
3412
  def render_custom_music_voices() -> None:
3379
- """Reserved view for future reusable custom music-voice blueprints."""
3413
+ """List and test personal ElevenLabs voices without exposing credentials."""
3380
3414
  st.title("Vozes Personalizadas")
3415
+ st.caption("Vozes pessoais do ElevenLabs Voice Lab disponíveis para narração no Thunderbolt.")
3416
+ settings = read_json("settings.json", {})
3417
+ refresh_col, cache_col = st.columns([1, 2])
3418
+ with refresh_col:
3419
+ refresh = st.button("Actualizar vozes", type="primary", use_container_width=True, key="elevenlabs_refresh_voices")
3420
+ try:
3421
+ voices, metadata = fetch_personal_voices(settings, force=refresh)
3422
+ if metadata.get("source") == "cache":
3423
+ label = "cache local"
3424
+ if metadata.get("stale"):
3425
+ st.warning("A API não respondeu; a mostrar a última lista guardada localmente.")
3426
+ else:
3427
+ label = "API ElevenLabs"
3428
+ with cache_col:
3429
+ st.caption(f"Fonte: {label} · última actualização: {metadata.get('updated_at') or '—'}")
3430
+ except ElevenLabsVoicesError as exc:
3431
+ st.warning(str(exc))
3432
+ st.info("As vozes padrão continuam disponíveis na criação de vídeos.")
3433
+ return
3434
+
3435
+ if not voices:
3436
+ st.info("Nenhuma voz pessoal foi encontrada nesta conta ElevenLabs.")
3437
+ return
3438
+ test_text = st.text_input("Texto curto para testar uma voz", value=DEFAULT_SAMPLE, key="elevenlabs_voice_test_text")
3439
+ for voice in voices:
3440
+ with st.container(border=True):
3441
+ voice_cols = st.columns([2.2, 1.8, 1])
3442
+ with voice_cols[0]:
3443
+ st.markdown(f"**{voice.get('name', 'Sem nome')}**")
3444
+ st.caption(f"ID: `{voice.get('voice_id', '')}`")
3445
+ with voice_cols[1]:
3446
+ labels = voice.get("labels") or {}
3447
+ st.caption("Categoria: personal")
3448
+ st.caption("Labels: " + (", ".join(f"{key}: {value}" for key, value in labels.items()) if labels else "não disponíveis"))
3449
+ with voice_cols[2]:
3450
+ if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", use_container_width=True):
3451
+ try:
3452
+ preview = synthesize_preview(test_text, "elevenlabs", voice["voice_id"], settings)
3453
+ st.audio(str(preview), format="audio/mpeg")
3454
+ except (OSError, RuntimeError, ValueError) as exc:
3455
+ st.error(f"Não foi possível testar esta voz: {exc}")
3381
3456
 
3382
3457
 
3383
3458
  def render_scripts():
@@ -0,0 +1,134 @@
1
+ """ElevenLabs personal voice catalogue with local, credential-safe caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import requests
11
+
12
+ from . import storage
13
+
14
+ CACHE_FILENAME = "elevenlabs_voices.json"
15
+ DEFAULT_CACHE_TTL_SECONDS = 6 * 60 * 60
16
+ VOICES_ENDPOINT = "https://api.elevenlabs.io/v1/voices"
17
+
18
+
19
+ class ElevenLabsVoicesError(RuntimeError):
20
+ """A safe, user-facing error raised while reading the ElevenLabs catalogue."""
21
+
22
+
23
+ def cache_path() -> Path:
24
+ return storage.STATE / CACHE_FILENAME
25
+
26
+
27
+ def _utc_now() -> datetime:
28
+ return datetime.now(timezone.utc)
29
+
30
+
31
+ def _normalise_voice(item: Any) -> dict[str, Any] | None:
32
+ if not isinstance(item, dict):
33
+ return None
34
+ voice_id = str(item.get("voice_id") or "").strip()
35
+ category = str(item.get("category") or "").strip().casefold()
36
+ name = str(item.get("name") or "").strip()
37
+ if not voice_id or not name or category != "personal":
38
+ return None
39
+ labels = item.get("labels") if isinstance(item.get("labels"), dict) else {}
40
+ samples = item.get("samples") if isinstance(item.get("samples"), list) else []
41
+ return {
42
+ "voice_id": voice_id,
43
+ "name": name,
44
+ "category": "personal",
45
+ "labels": {str(key): str(value) for key, value in labels.items()},
46
+ "samples": [sample for sample in samples if isinstance(sample, dict)],
47
+ }
48
+
49
+
50
+ def normalise_voices(payload: Any) -> list[dict[str, Any]]:
51
+ items = payload.get("voices", []) if isinstance(payload, dict) else []
52
+ if not isinstance(items, list):
53
+ raise ElevenLabsVoicesError("A API ElevenLabs devolveu uma lista de vozes inválida.")
54
+ result = [_normalise_voice(item) for item in items]
55
+ return [item for item in result if item is not None]
56
+
57
+
58
+ def _read_cache() -> dict[str, Any] | None:
59
+ payload = storage.read_json(CACHE_FILENAME, {})
60
+ return payload if isinstance(payload, dict) and isinstance(payload.get("voices"), list) else None
61
+
62
+
63
+ def _cache_is_fresh(cache: dict[str, Any], ttl_seconds: int) -> bool:
64
+ try:
65
+ updated = datetime.fromisoformat(str(cache.get("updated_at")).replace("Z", "+00:00"))
66
+ return (_utc_now() - updated).total_seconds() < ttl_seconds
67
+ except (TypeError, ValueError, OverflowError):
68
+ return False
69
+
70
+
71
+ def _save_cache(voices: list[dict[str, Any]], *, source: str) -> dict[str, Any]:
72
+ payload = {"updated_at": _utc_now().isoformat(), "source": source, "voices": voices}
73
+ storage.write_json(CACHE_FILENAME, payload)
74
+ return payload
75
+
76
+
77
+ def fetch_personal_voices(
78
+ settings: dict[str, Any],
79
+ *,
80
+ force: bool = False,
81
+ ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS,
82
+ request_get: Callable[..., Any] = requests.get,
83
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
84
+ """Return personal voices, using cache unless force is requested."""
85
+ cache = _read_cache()
86
+ if cache and not force and _cache_is_fresh(cache, ttl_seconds):
87
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at")}
88
+
89
+ api_key = str(settings.get("elevenlabs_api_key") or "").strip()
90
+ if not api_key:
91
+ if cache:
92
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
93
+ raise ElevenLabsVoicesError("Configure a API Key do ElevenLabs em Configuração API antes de actualizar as vozes.")
94
+ try:
95
+ response = request_get(VOICES_ENDPOINT, headers={"xi-api-key": api_key}, timeout=30)
96
+ status = int(getattr(response, "status_code", 200))
97
+ if status == 401:
98
+ raise ElevenLabsVoicesError("A API Key do ElevenLabs foi recusada. Confirme a chave em Configuração API.")
99
+ if status == 429:
100
+ raise ElevenLabsVoicesError("O limite de pedidos do ElevenLabs foi atingido. Tente novamente mais tarde.")
101
+ response.raise_for_status()
102
+ voices = normalise_voices(response.json())
103
+ saved = _save_cache(voices, source="elevenlabs_api")
104
+ return voices, {"source": "api", "updated_at": saved["updated_at"]}
105
+ except ElevenLabsVoicesError:
106
+ if cache:
107
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
108
+ raise
109
+ except requests.Timeout as exc:
110
+ if cache:
111
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
112
+ raise ElevenLabsVoicesError("O ElevenLabs demorou demasiado tempo a responder. Tente novamente.") from exc
113
+ except (requests.RequestException, ValueError, json.JSONDecodeError) as exc:
114
+ if cache:
115
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
116
+ raise ElevenLabsVoicesError("Não foi possível obter as vozes do ElevenLabs. Verifique a ligação e a API Key.") from exc
117
+
118
+
119
+ def personal_voice_options(settings: dict[str, Any]) -> dict[str, str]:
120
+ """Return voice_id -> display label without contacting the remote API."""
121
+ cache = _read_cache()
122
+ if not cache:
123
+ return {}
124
+ return {
125
+ str(voice["voice_id"]): f"{voice.get('name', voice['voice_id'])} · ElevenLabs personal"
126
+ for voice in cache.get("voices", [])
127
+ if isinstance(voice, dict) and voice.get("voice_id")
128
+ }
129
+
130
+
131
+ def cached_personal_voices() -> list[dict[str, Any]]:
132
+ """Return the locally cached personal voices without making a network call."""
133
+ cache = _read_cache()
134
+ return [voice for voice in (cache or {}).get("voices", []) if isinstance(voice, dict)]
@@ -155,6 +155,8 @@ def create_music_task(
155
155
  vocal: str = "",
156
156
  references: str = "",
157
157
  theme: str = "",
158
+ voice_id: str = "",
159
+ voice_gender: str = "",
158
160
  ) -> dict[str, Any]:
159
161
  """Enqueue one audio-only generation; no video task or video worker is used."""
160
162
  cleaned_prompt = str(prompt or "").strip()
@@ -164,7 +166,7 @@ def create_music_task(
164
166
  task = {
165
167
  "id": f"music_{uuid.uuid4().hex[:12]}",
166
168
  "kind": "audio_generation",
167
- "provider": "lyria" if str(provider).strip().casefold() == "google lyria" else "suno",
169
+ "provider": "lyria" if str(provider).strip().casefold() == "google lyria" else ("eleven_music" if str(provider).strip().casefold() == "eleven music" else "suno"),
168
170
  "model": str(model or "").strip(),
169
171
  "title": str(title or "Música sem título").strip() or "Música sem título",
170
172
  "prompt": cleaned_prompt,
@@ -173,6 +175,8 @@ def create_music_task(
173
175
  "vocal": str(vocal or "").strip(),
174
176
  "references": str(references or "").strip(),
175
177
  "theme": str(theme or "").strip(),
178
+ "voice_id": str(voice_id or "").strip(),
179
+ "voice_gender": str(voice_gender or "").strip().casefold(),
176
180
  "duration_seconds": 120,
177
181
  "state": "to_do",
178
182
  "stage": "music_generation",
@@ -258,6 +262,33 @@ def request_lyria_generation(settings: dict[str, Any], prompt: str, title: str =
258
262
  return {"ok": False, "message": "Não foi possível contactar Google Lyria.", "data": {}}
259
263
 
260
264
 
265
+ def request_eleven_music_generation(settings: dict[str, Any], prompt: str, title: str = "", model: str = "music_v2", *, voice_id: str = "", voice_gender: str = "") -> dict[str, Any]:
266
+ """Generate music through Eleven Music's synchronous Music API."""
267
+ api_key = str(settings.get("elevenlabs_api_key") or "").strip()
268
+ if not api_key:
269
+ return {"ok": False, "message": "Configure a API Key do ElevenLabs em Configuração API antes de solicitar Eleven Music.", "data": {}}
270
+ clean_prompt = str(prompt or "").strip()
271
+ if voice_id:
272
+ gender = voice_gender if voice_gender in {"male", "female"} else "unspecified"
273
+ clean_prompt = f"{clean_prompt}\nVocal principal: voz personalizada ElevenLabs seleccionada ({gender}). Não usar coro."
274
+ try:
275
+ response = requests.post(
276
+ "https://api.elevenlabs.io/v1/music",
277
+ params={"output_format": "mp3_44100_128"},
278
+ headers={"xi-api-key": api_key, "Content-Type": "application/json"},
279
+ json={"prompt": clean_prompt[:4100], "model_id": model or "music_v2", "force_instrumental": False},
280
+ timeout=300,
281
+ )
282
+ if response.status_code >= 400:
283
+ return {"ok": False, "message": f"Eleven Music devolveu HTTP {response.status_code}.", "data": {"status_code": response.status_code}}
284
+ if not response.content:
285
+ return {"ok": False, "message": "Eleven Music não devolveu áudio.", "data": {}}
286
+ output = store_music_file(f"{title or 'eleven-music-generated'}.mp3", response.content)
287
+ return {"ok": True, "message": "Música gerada por Eleven Music.", "data": {"audio_path": str(output), "model": model or "music_v2", "voice_id": voice_id, "voice_gender": voice_gender}}
288
+ except (requests.RequestException, OSError, ValueError) as exc:
289
+ return {"ok": False, "message": f"Não foi possível contactar Eleven Music: {exc}", "data": {}}
290
+
291
+
261
292
  def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | None:
262
293
  """Run exactly one queued audio generation and persist only its audio artefact."""
263
294
  task = next((record for record in list_music_tasks() if str(record.get("id") or "") == str(task_id)), None)
@@ -268,6 +299,9 @@ def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | N
268
299
  if provider == "lyria":
269
300
  result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
270
301
  audio_path = str((result.get("data") or {}).get("audio_path") or "")
302
+ elif provider == "eleven_music":
303
+ result = request_eleven_music_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or "music_v2"), voice_id=str(task.get("voice_id") or ""), voice_gender=str(task.get("voice_gender") or ""))
304
+ audio_path = str((result.get("data") or {}).get("audio_path") or "")
271
305
  else:
272
306
  result = request_suno_generation(
273
307
  settings,
@@ -25,6 +25,7 @@ from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_
25
25
  from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
26
26
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
27
27
  from hermes_ui.thumbnail_blueprints import thumbnail_blueprint_for_channel
28
+ from hermes_ui.voice_preview import synthesize_preview
28
29
 
29
30
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
30
31
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
@@ -763,6 +764,8 @@ def _uses_azure_speech_sdk_v2(
763
764
  settings: dict[str, Any] | None = None,
764
765
  ) -> bool:
765
766
  service = str(generation_settings.get("voiceover_service") or "").strip().casefold()
767
+ if service in {"elevenlabs", "eleven labs"}:
768
+ return False
766
769
  if service in {
767
770
  "azure speech sdk v2",
768
771
  "azure speech",
@@ -913,6 +916,23 @@ def _run_video_helper_once(
913
916
  sync_moneyprinter_config(settings, str(configured_root))
914
917
  except OSError as exc:
915
918
  raise PipelineError(f"Não foi possível sincronizar a configuração do MoneyPrinterTurbo: {exc}") from exc
919
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
920
+ voiceover_mode = str(generation_settings.get("voiceover_mode") or "").strip().casefold()
921
+ voiceover_service = str(generation_settings.get("voiceover_service") or "").strip().casefold()
922
+ generated_elevenlabs_audio: Path | None = None
923
+ if voiceover_mode not in {"none", "upload"} and voiceover_service == "elevenlabs":
924
+ elevenlabs_voice = str(task.get("voice") or generation_settings.get("voice") or "").strip()
925
+ script_text = str(task.get("video_script") or generation_settings.get("video_script") or "").strip()
926
+ if not elevenlabs_voice:
927
+ raise PipelineError("Seleccione uma voz personalizada ElevenLabs antes de criar o vídeo.")
928
+ if not str(settings.get("elevenlabs_api_key") or "").strip():
929
+ raise PipelineError("ElevenLabs foi seleccionado, mas a API Key não está configurada.")
930
+ if not script_text:
931
+ raise PipelineError("ElevenLabs foi seleccionado, mas o roteiro ainda não está disponível.")
932
+ try:
933
+ generated_elevenlabs_audio = synthesize_preview(script_text, "elevenlabs", elevenlabs_voice, settings)
934
+ except (OSError, RuntimeError, ValueError) as exc:
935
+ raise PipelineError(f"Não foi possível gerar a narração ElevenLabs: {exc}") from exc
916
936
  env_values = {
917
937
  "MPT_LLM_PROVIDER": provider,
918
938
  "MPT_LLM_API_KEY": str(card.get("api_key") or "").strip(),
@@ -932,7 +952,6 @@ def _run_video_helper_once(
932
952
  if configured_root:
933
953
  command.extend(["--root", str(configured_root)])
934
954
  command.extend(["--subject", subject, "--"])
935
- generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
936
955
  if _uses_azure_speech_sdk_v2(generation_settings, settings) and str(generation_settings.get("voiceover_mode") or "").strip().casefold() not in {"none", "upload"}:
937
956
  missing_voice_config = [
938
957
  field for field, value in (
@@ -951,7 +970,7 @@ def _run_video_helper_once(
951
970
  })
952
971
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
953
972
  command.extend(_moneyprinter_cli_args(task, route, settings=settings))
954
- if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
973
+ if voiceover_mode == "upload":
955
974
  voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
956
975
  if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
957
976
  message = "O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio."
@@ -959,6 +978,8 @@ def _run_video_helper_once(
959
978
  metadata.update({"failure_api": "Ficheiro local", "failure_provider": "local_storage", "failure_service": "Áudio de narração", "failure_config_fields": "voiceover_file"})
960
979
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
961
980
  command.extend(["--custom-audio-file", str(voiceover_file.resolve())])
981
+ elif generated_elevenlabs_audio is not None:
982
+ command.extend(["--custom-audio-file", str(generated_elevenlabs_audio.resolve())])
962
983
  output_lines: list[str] = []
963
984
  line_queue: queue.Queue[str | None] = queue.Queue()
964
985
  started_at = time.monotonic()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.76",
3
+ "version": "0.4.78",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",