@danhachuel/thunderbolt 0.4.75 → 0.4.77
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 +122 -6
- package/hermes_ui/elevenlabs_voices.py +128 -0
- package/hermes_ui/pipeline_worker.py +23 -2
- package/package.json +1 -1
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, 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
|
-
|
|
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")
|
|
@@ -3376,8 +3388,49 @@ def render_music_creation():
|
|
|
3376
3388
|
|
|
3377
3389
|
|
|
3378
3390
|
def render_custom_music_voices() -> None:
|
|
3379
|
-
"""
|
|
3391
|
+
"""List and test personal ElevenLabs voices without exposing credentials."""
|
|
3380
3392
|
st.title("Vozes Personalizadas")
|
|
3393
|
+
st.caption("Vozes pessoais do ElevenLabs Voice Lab disponíveis para narração no Thunderbolt.")
|
|
3394
|
+
settings = read_json("settings.json", {})
|
|
3395
|
+
refresh_col, cache_col = st.columns([1, 2])
|
|
3396
|
+
with refresh_col:
|
|
3397
|
+
refresh = st.button("Actualizar vozes", type="primary", use_container_width=True, key="elevenlabs_refresh_voices")
|
|
3398
|
+
try:
|
|
3399
|
+
voices, metadata = fetch_personal_voices(settings, force=refresh)
|
|
3400
|
+
if metadata.get("source") == "cache":
|
|
3401
|
+
label = "cache local"
|
|
3402
|
+
if metadata.get("stale"):
|
|
3403
|
+
st.warning("A API não respondeu; a mostrar a última lista guardada localmente.")
|
|
3404
|
+
else:
|
|
3405
|
+
label = "API ElevenLabs"
|
|
3406
|
+
with cache_col:
|
|
3407
|
+
st.caption(f"Fonte: {label} · última actualização: {metadata.get('updated_at') or '—'}")
|
|
3408
|
+
except ElevenLabsVoicesError as exc:
|
|
3409
|
+
st.warning(str(exc))
|
|
3410
|
+
st.info("As vozes padrão continuam disponíveis na criação de vídeos.")
|
|
3411
|
+
return
|
|
3412
|
+
|
|
3413
|
+
if not voices:
|
|
3414
|
+
st.info("Nenhuma voz pessoal foi encontrada nesta conta ElevenLabs.")
|
|
3415
|
+
return
|
|
3416
|
+
test_text = st.text_input("Texto curto para testar uma voz", value=DEFAULT_SAMPLE, key="elevenlabs_voice_test_text")
|
|
3417
|
+
for voice in voices:
|
|
3418
|
+
with st.container(border=True):
|
|
3419
|
+
voice_cols = st.columns([2.2, 1.8, 1])
|
|
3420
|
+
with voice_cols[0]:
|
|
3421
|
+
st.markdown(f"**{voice.get('name', 'Sem nome')}**")
|
|
3422
|
+
st.caption(f"ID: `{voice.get('voice_id', '')}`")
|
|
3423
|
+
with voice_cols[1]:
|
|
3424
|
+
labels = voice.get("labels") or {}
|
|
3425
|
+
st.caption("Categoria: personal")
|
|
3426
|
+
st.caption("Labels: " + (", ".join(f"{key}: {value}" for key, value in labels.items()) if labels else "não disponíveis"))
|
|
3427
|
+
with voice_cols[2]:
|
|
3428
|
+
if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", use_container_width=True):
|
|
3429
|
+
try:
|
|
3430
|
+
preview = synthesize_preview(test_text, "elevenlabs", voice["voice_id"], settings)
|
|
3431
|
+
st.audio(str(preview), format="audio/mpeg")
|
|
3432
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
3433
|
+
st.error(f"Não foi possível testar esta voz: {exc}")
|
|
3381
3434
|
|
|
3382
3435
|
|
|
3383
3436
|
def render_scripts():
|
|
@@ -4371,6 +4424,22 @@ def _task_thumbnail_path(task: dict[str, Any]) -> Path | None:
|
|
|
4371
4424
|
return None
|
|
4372
4425
|
|
|
4373
4426
|
|
|
4427
|
+
def _task_artifact_path(task: dict[str, Any], *names: str) -> Path | None:
|
|
4428
|
+
"""Resolve um artefacto local, incluindo caminhos relativos e legados."""
|
|
4429
|
+
artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
|
|
4430
|
+
for name in names:
|
|
4431
|
+
raw = str(artifacts.get(name) or "").strip()
|
|
4432
|
+
if not raw or raw.startswith(("http://", "https://")):
|
|
4433
|
+
continue
|
|
4434
|
+
candidates = [Path(raw)]
|
|
4435
|
+
if not Path(raw).is_absolute():
|
|
4436
|
+
candidates.extend([STORAGE / raw, STORAGE / "videos" / raw])
|
|
4437
|
+
for candidate in candidates:
|
|
4438
|
+
if candidate.is_file():
|
|
4439
|
+
return candidate
|
|
4440
|
+
return None
|
|
4441
|
+
|
|
4442
|
+
|
|
4374
4443
|
def _is_music_task(task: dict[str, Any]) -> bool:
|
|
4375
4444
|
"""Identify music pipeline tasks without conflating them with ordinary video tasks."""
|
|
4376
4445
|
return bool(task.get("music_mode")) or str(task.get("style_wide") or task.get("style") or "").strip().casefold() in {"music", "música"}
|
|
@@ -4921,15 +4990,41 @@ def render_automation():
|
|
|
4921
4990
|
st.info("Ainda não existem vídeos cadastrados.")
|
|
4922
4991
|
for task in tasks:
|
|
4923
4992
|
with st.container(border=True):
|
|
4924
|
-
task_cols = st.columns([2.25, 1.
|
|
4993
|
+
task_cols = st.columns([2.25, 1.55, 1.05, 2.15], gap="small")
|
|
4994
|
+
script_path = _task_artifact_path(task, "script")
|
|
4995
|
+
video_path = _task_artifact_path(task, "video")
|
|
4996
|
+
thumbnail_path = _task_thumbnail_path(task)
|
|
4997
|
+
thumbnail_prompt_path = _task_artifact_path(task, "thumbnail_prompt_json")
|
|
4998
|
+
thumbnail_prompt = str(task.get("thumbnail_prompt") or "").strip()
|
|
4925
4999
|
with task_cols[0]:
|
|
4926
|
-
thumbnail_path = _task_thumbnail_path(task)
|
|
4927
5000
|
if thumbnail_path:
|
|
4928
|
-
st.image(thumbnail_path, width=180, caption="Thumbnail")
|
|
5001
|
+
st.image(str(thumbnail_path), width=180, caption="Thumbnail")
|
|
4929
5002
|
else:
|
|
4930
5003
|
st.caption("Thumbnail ainda não pronta")
|
|
4931
5004
|
st.write(f"**{task.get('topic', 'Sem tópico')}**")
|
|
4932
5005
|
st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
|
|
5006
|
+
thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
|
|
5007
|
+
with thumbnail_download_col:
|
|
5008
|
+
st.download_button(
|
|
5009
|
+
"Baixar Thumbnail",
|
|
5010
|
+
data=thumbnail_path.read_bytes() if thumbnail_path else b"",
|
|
5011
|
+
file_name=thumbnail_path.name if thumbnail_path else "thumbnail.png",
|
|
5012
|
+
mime="image/png",
|
|
5013
|
+
key=f"automation_download_thumbnail_{task['id']}",
|
|
5014
|
+
use_container_width=True,
|
|
5015
|
+
disabled=thumbnail_path is None,
|
|
5016
|
+
)
|
|
5017
|
+
with prompt_download_col:
|
|
5018
|
+
prompt_data = thumbnail_prompt_path.read_bytes() if thumbnail_prompt_path else thumbnail_prompt.encode("utf-8")
|
|
5019
|
+
st.download_button(
|
|
5020
|
+
"Baixar Thumbnail Prompt",
|
|
5021
|
+
data=prompt_data,
|
|
5022
|
+
file_name=thumbnail_prompt_path.name if thumbnail_prompt_path else "thumbnail-prompt.txt",
|
|
5023
|
+
mime="application/json" if thumbnail_prompt_path else "text/plain",
|
|
5024
|
+
key=f"automation_download_thumbnail_prompt_{task['id']}",
|
|
5025
|
+
use_container_width=True,
|
|
5026
|
+
disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
|
|
5027
|
+
)
|
|
4933
5028
|
with task_cols[1]:
|
|
4934
5029
|
_render_video_task_state(task)
|
|
4935
5030
|
with task_cols[2]:
|
|
@@ -4949,6 +5044,27 @@ def render_automation():
|
|
|
4949
5044
|
if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
|
|
4950
5045
|
stop_task_by_user(task["id"])
|
|
4951
5046
|
st.rerun()
|
|
5047
|
+
script_download_col, video_download_col = st.columns(2, gap="small")
|
|
5048
|
+
with script_download_col:
|
|
5049
|
+
st.download_button(
|
|
5050
|
+
"Baixar Roteiro",
|
|
5051
|
+
data=script_path.read_bytes() if script_path else b"",
|
|
5052
|
+
file_name=script_path.name if script_path else "roteiro.md",
|
|
5053
|
+
mime="text/markdown",
|
|
5054
|
+
key=f"automation_download_script_{task['id']}",
|
|
5055
|
+
use_container_width=True,
|
|
5056
|
+
disabled=script_path is None,
|
|
5057
|
+
)
|
|
5058
|
+
with video_download_col:
|
|
5059
|
+
st.download_button(
|
|
5060
|
+
"Baixar Vídeo",
|
|
5061
|
+
data=video_path.read_bytes() if video_path else b"",
|
|
5062
|
+
file_name=video_path.name if video_path else "video.mp4",
|
|
5063
|
+
mime="video/mp4",
|
|
5064
|
+
key=f"automation_download_video_{task['id']}",
|
|
5065
|
+
use_container_width=True,
|
|
5066
|
+
disabled=video_path is None,
|
|
5067
|
+
)
|
|
4952
5068
|
with delete_col:
|
|
4953
5069
|
confirm_delete_key = f"automation_confirm_delete_{task['id']}"
|
|
4954
5070
|
if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
|
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