@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 +79 -4
- package/hermes_ui/elevenlabs_voices.py +134 -0
- package/hermes_ui/music.py +35 -1
- 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, 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
|
-
|
|
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
|
-
"""
|
|
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)]
|
package/hermes_ui/music.py
CHANGED
|
@@ -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
|
|
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