@danhachuel/thunderbolt 0.2.18 → 0.2.20
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/MANUAL-INSTALACAO.md +28 -4
- package/README.md +33 -7
- package/app/main.py +472 -15
- package/hermes_ui/domain.py +36 -0
- package/hermes_ui/mcp.py +145 -0
- package/hermes_ui/music.py +82 -0
- package/hermes_ui/storage.py +56 -1
- package/hermes_ui/voice_preview.py +119 -0
- package/integrations/data/azure_voices.json +1326 -0
- package/integrations/platforms.py +114 -44
- package/integrations/youtube_direct_upload.py +169 -0
- package/package.json +3 -1
- package/requirements.txt +1 -0
- package/scripts/cli.mjs +4 -1
- package/scripts/install.mjs +4 -1
- package/seed/skills/moneyprinterturbo-video.md +132 -0
package/hermes_ui/domain.py
CHANGED
|
@@ -34,8 +34,13 @@ def create_channel(name: str, url: str = "", metadata: dict[str, Any] | None = N
|
|
|
34
34
|
"last_youtube_sync": None,
|
|
35
35
|
"language": "Português",
|
|
36
36
|
"blueprint_id": "",
|
|
37
|
+
"default_blueprint_id": "",
|
|
37
38
|
"style_wide": "pexels",
|
|
38
39
|
"voice": "",
|
|
40
|
+
"default_voice": "",
|
|
41
|
+
"delegated_session_id": "",
|
|
42
|
+
"automation_on": False,
|
|
43
|
+
"automation_time": "00:00",
|
|
39
44
|
"active": True,
|
|
40
45
|
"daily_limit": 1,
|
|
41
46
|
"backlog_total": 0,
|
|
@@ -61,6 +66,17 @@ def update_channel(channel_id: str, updates: dict[str, Any]) -> dict[str, Any] |
|
|
|
61
66
|
return None
|
|
62
67
|
|
|
63
68
|
|
|
69
|
+
def delete_channel(channel_id: str) -> dict[str, Any] | None:
|
|
70
|
+
"""Remove apenas o cadastro do canal, preservando tarefas e artefactos."""
|
|
71
|
+
channels = read_json("channels.json", [])
|
|
72
|
+
remaining = [channel for channel in channels if channel.get("id") != channel_id]
|
|
73
|
+
if len(remaining) == len(channels):
|
|
74
|
+
return None
|
|
75
|
+
removed = next(channel for channel in channels if channel.get("id") == channel_id)
|
|
76
|
+
write_json("channels.json", remaining)
|
|
77
|
+
return removed
|
|
78
|
+
|
|
79
|
+
|
|
64
80
|
def create_batch(mode: str, channel_ids: list[str], topic: str, quantity: int, options: dict[str, Any]) -> dict[str, Any]:
|
|
65
81
|
batch = {
|
|
66
82
|
"id": make_id("batch"),
|
|
@@ -97,6 +113,15 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
97
113
|
"language": batch["options"].get("language", channel.get("language", "Português")),
|
|
98
114
|
"format": batch["options"].get("format", "wide"),
|
|
99
115
|
"style_wide": batch["options"].get("style_wide", channel.get("style_wide", "pexels")),
|
|
116
|
+
"style_ia": batch["options"].get("style_ia", ""),
|
|
117
|
+
"music_mode": batch["options"].get("music_mode", False),
|
|
118
|
+
"music_path": batch["options"].get("music_path", ""),
|
|
119
|
+
"music_source": batch["options"].get("music_source", ""),
|
|
120
|
+
"background_mode": batch["options"].get("background_mode", "stock"),
|
|
121
|
+
"blueprint_id": channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
|
|
122
|
+
"voice": channel.get("default_voice") or channel.get("voice", ""),
|
|
123
|
+
"automation_on": bool(channel.get("automation_on", False)),
|
|
124
|
+
"automation_time": channel.get("automation_time", "00:00"),
|
|
100
125
|
"stage": "script",
|
|
101
126
|
"state": "to_do",
|
|
102
127
|
"progress": 0,
|
|
@@ -115,6 +140,17 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
115
140
|
return created
|
|
116
141
|
|
|
117
142
|
|
|
143
|
+
def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
144
|
+
tasks = read_json("tasks.json", [])
|
|
145
|
+
for task in tasks:
|
|
146
|
+
if task.get("id") == task_id:
|
|
147
|
+
task.update(updates)
|
|
148
|
+
task["updated_at"] = now()
|
|
149
|
+
write_json("tasks.json", tasks)
|
|
150
|
+
return task
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
|
|
118
154
|
def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
|
|
119
155
|
if state and state not in VALID_STATES:
|
|
120
156
|
raise ValueError(f"Estado inválido: {state}")
|
package/hermes_ui/mcp.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from . import storage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SKILL_FILENAME = "moneyprinterturbo-video.md"
|
|
12
|
+
|
|
13
|
+
MCP_DEFAULTS: list[dict[str, Any]] = [
|
|
14
|
+
{
|
|
15
|
+
"id": "short-video-maker",
|
|
16
|
+
"name": "Short Video Maker",
|
|
17
|
+
"repository": "https://github.com/gyoridavid/short-video-maker",
|
|
18
|
+
"protocol": "MCP + REST",
|
|
19
|
+
"description": "Servidor externo para criação de vídeos curtos, com MCP e API REST.",
|
|
20
|
+
"port": 3123,
|
|
21
|
+
"active": False,
|
|
22
|
+
"endpoint_note": "Porta documentada pelo projecto: 3123.",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "autovio",
|
|
26
|
+
"name": "AutoVio",
|
|
27
|
+
"repository": "https://github.com/Auto-Vio/autovio",
|
|
28
|
+
"protocol": "MCP + REST",
|
|
29
|
+
"description": "Pipeline externo de vídeo com API REST e servidor MCP separado.",
|
|
30
|
+
"port": 3001,
|
|
31
|
+
"active": False,
|
|
32
|
+
"endpoint_note": "Porta padrão da API backend documentada pelo projecto: 3001.",
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "openmontage",
|
|
36
|
+
"name": "OpenMontage",
|
|
37
|
+
"repository": "https://github.com/calesthio/OpenMontage",
|
|
38
|
+
"protocol": "Agente local",
|
|
39
|
+
"description": "Sistema externo de produção agentic de vídeo; o README não documenta um servidor MCP/HTTP padrão.",
|
|
40
|
+
"port": 8000,
|
|
41
|
+
"active": False,
|
|
42
|
+
"endpoint_note": "Porta editável de referência; o projecto não documenta uma porta local padrão.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "opencut",
|
|
46
|
+
"name": "OpenCut",
|
|
47
|
+
"repository": "https://github.com/opencut-app/opencut",
|
|
48
|
+
"protocol": "API em desenvolvimento",
|
|
49
|
+
"description": "Editor externo; o README actual indica API/MCP em desenvolvimento e documenta um servidor API local.",
|
|
50
|
+
"port": 8787,
|
|
51
|
+
"active": False,
|
|
52
|
+
"endpoint_note": "Porta padrão da API documentada pelo projecto: 8787; frontend usa 5173.",
|
|
53
|
+
},
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _merge_defaults(saved: Any) -> list[dict[str, Any]]:
|
|
58
|
+
saved_by_id = {
|
|
59
|
+
str(item.get("id")): item
|
|
60
|
+
for item in (saved if isinstance(saved, list) else [])
|
|
61
|
+
if isinstance(item, dict) and item.get("id")
|
|
62
|
+
}
|
|
63
|
+
merged: list[dict[str, Any]] = []
|
|
64
|
+
for default in MCP_DEFAULTS:
|
|
65
|
+
item = {**default, **saved_by_id.get(default["id"], {})}
|
|
66
|
+
try:
|
|
67
|
+
item["port"] = max(1, min(65535, int(item.get("port", default["port"]))))
|
|
68
|
+
except (TypeError, ValueError):
|
|
69
|
+
item["port"] = default["port"]
|
|
70
|
+
item["active"] = bool(item.get("active", False))
|
|
71
|
+
merged.append(item)
|
|
72
|
+
return merged
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_integrations() -> list[dict[str, Any]]:
|
|
76
|
+
return _merge_defaults(storage.read_json("mcp_integrations.json", MCP_DEFAULTS))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_integrations(integrations: list[dict[str, Any]]) -> None:
|
|
80
|
+
cleaned: list[dict[str, Any]] = []
|
|
81
|
+
allowed = {item["id"] for item in MCP_DEFAULTS}
|
|
82
|
+
for item in integrations:
|
|
83
|
+
if not isinstance(item, dict) or item.get("id") not in allowed:
|
|
84
|
+
continue
|
|
85
|
+
copy = dict(item)
|
|
86
|
+
try:
|
|
87
|
+
copy["port"] = max(1, min(65535, int(copy.get("port", 1))))
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
continue
|
|
90
|
+
copy["active"] = bool(copy.get("active", False))
|
|
91
|
+
cleaned.append(copy)
|
|
92
|
+
storage.write_json("mcp_integrations.json", _merge_defaults(cleaned))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def update_integration(integration_id: str, **updates: Any) -> list[dict[str, Any]]:
|
|
96
|
+
integrations = load_integrations()
|
|
97
|
+
for item in integrations:
|
|
98
|
+
if item.get("id") == integration_id:
|
|
99
|
+
item.update(updates)
|
|
100
|
+
break
|
|
101
|
+
save_integrations(integrations)
|
|
102
|
+
return load_integrations()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def detect_local_service(integration: dict[str, Any], timeout: float = 0.35) -> dict[str, Any]:
|
|
106
|
+
"""Detecta apenas um serviço local já iniciado; nunca inicia ou instala processos."""
|
|
107
|
+
port = integration.get("port")
|
|
108
|
+
try:
|
|
109
|
+
port = int(port)
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
return {"available": False, "message": "Porta inválida."}
|
|
112
|
+
try:
|
|
113
|
+
response = requests.get(f"http://127.0.0.1:{port}/", timeout=timeout)
|
|
114
|
+
return {
|
|
115
|
+
"available": True,
|
|
116
|
+
"message": f"Serviço respondeu em localhost:{port} (HTTP {response.status_code}).",
|
|
117
|
+
}
|
|
118
|
+
except requests.RequestException:
|
|
119
|
+
return {"available": False, "message": f"Nenhum serviço detectado em localhost:{port}."}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def skill_source_path() -> Path:
|
|
123
|
+
return Path(__file__).resolve().parents[1] / "seed" / "skills" / SKILL_FILENAME
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def skill_destination_path() -> Path:
|
|
127
|
+
return storage.STORAGE / "skills" / SKILL_FILENAME
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def install_skill_locally(*, overwrite: bool = True) -> Path:
|
|
131
|
+
source = skill_source_path()
|
|
132
|
+
if not source.exists():
|
|
133
|
+
raise FileNotFoundError("A skill MoneyPrinterTurbo não está disponível no pacote local.")
|
|
134
|
+
destination = skill_destination_path()
|
|
135
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
if overwrite or not destination.exists():
|
|
137
|
+
destination.write_bytes(source.read_bytes())
|
|
138
|
+
return destination
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def read_packaged_skill() -> bytes:
|
|
142
|
+
source = skill_source_path()
|
|
143
|
+
if not source.exists():
|
|
144
|
+
raise FileNotFoundError("A skill MoneyPrinterTurbo não está disponível no pacote local.")
|
|
145
|
+
return source.read_bytes()
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from . import storage
|
|
11
|
+
|
|
12
|
+
MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def music_directory() -> Path:
|
|
16
|
+
directory = storage.STORAGE / "music"
|
|
17
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return directory
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def list_music_files() -> list[Path]:
|
|
22
|
+
return sorted((path for path in music_directory().iterdir() if path.is_file() and path.suffix.lower() in MUSIC_EXTENSIONS), key=lambda path: path.stat().st_mtime, reverse=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def safe_music_name(name: str) -> str:
|
|
26
|
+
stem = re.sub(r"[^\w\-. ]+", "_", Path(name).stem, flags=re.UNICODE).strip() or "music"
|
|
27
|
+
suffix = Path(name).suffix.lower()
|
|
28
|
+
if suffix not in MUSIC_EXTENSIONS:
|
|
29
|
+
suffix = ".mp3"
|
|
30
|
+
return f"{stem}{suffix}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def store_music_file(name: str, content: bytes) -> Path:
|
|
34
|
+
if not content:
|
|
35
|
+
raise ValueError("O ficheiro de música está vazio.")
|
|
36
|
+
target = music_directory() / safe_music_name(name)
|
|
37
|
+
target.write_bytes(content)
|
|
38
|
+
return target
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp3") -> Path | None:
|
|
42
|
+
candidates: list[str] = []
|
|
43
|
+
for key in ("audio_url", "audioUrl", "download_url", "downloadUrl", "url"):
|
|
44
|
+
value = data.get(key) if isinstance(data, dict) else None
|
|
45
|
+
if isinstance(value, str) and value.startswith("http"):
|
|
46
|
+
candidates.append(value)
|
|
47
|
+
clips = data.get("clips") if isinstance(data, dict) else None
|
|
48
|
+
if isinstance(clips, list):
|
|
49
|
+
for clip in clips:
|
|
50
|
+
if isinstance(clip, dict):
|
|
51
|
+
for key in ("audio_url", "audioUrl", "download_url", "downloadUrl", "url"):
|
|
52
|
+
value = clip.get(key)
|
|
53
|
+
if isinstance(value, str) and value.startswith("http"):
|
|
54
|
+
candidates.append(value)
|
|
55
|
+
if not candidates:
|
|
56
|
+
return None
|
|
57
|
+
response = requests.get(candidates[0], timeout=120)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
return store_music_file(title, response.content)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def request_suno_generation(settings: dict[str, Any], prompt: str, title: str = "", duration_seconds: int = 120) -> dict[str, Any]:
|
|
63
|
+
"""Request music from a configured Suno-compatible endpoint.
|
|
64
|
+
|
|
65
|
+
Suno-compatible deployments expose different endpoint paths; the UI therefore
|
|
66
|
+
requires an explicit base URL and never invents a public credential or endpoint.
|
|
67
|
+
"""
|
|
68
|
+
api_key = str(settings.get("suno_api_key", "") or "").strip()
|
|
69
|
+
base_url = str(settings.get("suno_api_base_url", "") or "").strip().rstrip("/")
|
|
70
|
+
endpoint = str(settings.get("suno_api_endpoint", "/api/generate") or "/api/generate").strip()
|
|
71
|
+
if not api_key or not base_url:
|
|
72
|
+
return {"ok": False, "message": "Configure Suno API Key e Suno API Base URL em Configurações antes de solicitar uma música.", "data": {}}
|
|
73
|
+
url = endpoint if endpoint.startswith("http") else f"{base_url}/{endpoint.lstrip('/')}"
|
|
74
|
+
payload = {"prompt": prompt.strip(), "title": title.strip(), "duration": max(120, int(duration_seconds)), "make_instrumental": True}
|
|
75
|
+
try:
|
|
76
|
+
response = requests.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=30)
|
|
77
|
+
if response.status_code >= 400:
|
|
78
|
+
return {"ok": False, "message": f"Suno devolveu HTTP {response.status_code}: {response.text[:240]}", "data": {"status_code": response.status_code}}
|
|
79
|
+
body = response.json() if response.content else {}
|
|
80
|
+
return {"ok": True, "message": "Pedido de música enviado ao endpoint Suno configurado.", "data": body if isinstance(body, dict) else {"response": body}}
|
|
81
|
+
except (requests.RequestException, ValueError) as exc:
|
|
82
|
+
return {"ok": False, "message": f"Não foi possível contactar o endpoint Suno configurado: {exc}", "data": {}}
|
package/hermes_ui/storage.py
CHANGED
|
@@ -21,6 +21,48 @@ DEFAULTS: dict[str, Any] = {
|
|
|
21
21
|
"batches.json": [],
|
|
22
22
|
"uploads.json": [],
|
|
23
23
|
"metadata_edits.json": [],
|
|
24
|
+
"mcp_integrations.json": [
|
|
25
|
+
{
|
|
26
|
+
"id": "short-video-maker",
|
|
27
|
+
"name": "Short Video Maker",
|
|
28
|
+
"repository": "https://github.com/gyoridavid/short-video-maker",
|
|
29
|
+
"protocol": "MCP + REST",
|
|
30
|
+
"description": "Servidor externo para criação de vídeos curtos, com MCP e API REST.",
|
|
31
|
+
"port": 3123,
|
|
32
|
+
"active": False,
|
|
33
|
+
"endpoint_note": "Porta documentada pelo projecto: 3123.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "autovio",
|
|
37
|
+
"name": "AutoVio",
|
|
38
|
+
"repository": "https://github.com/Auto-Vio/autovio",
|
|
39
|
+
"protocol": "MCP + REST",
|
|
40
|
+
"description": "Pipeline externo de vídeo com API REST e servidor MCP separado.",
|
|
41
|
+
"port": 3001,
|
|
42
|
+
"active": False,
|
|
43
|
+
"endpoint_note": "Porta padrão da API backend documentada pelo projecto: 3001.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "openmontage",
|
|
47
|
+
"name": "OpenMontage",
|
|
48
|
+
"repository": "https://github.com/calesthio/OpenMontage",
|
|
49
|
+
"protocol": "Agente local",
|
|
50
|
+
"description": "Sistema externo de produção agentic de vídeo; não documenta um servidor MCP/HTTP padrão.",
|
|
51
|
+
"port": 8000,
|
|
52
|
+
"active": False,
|
|
53
|
+
"endpoint_note": "Porta editável de referência; o projecto não documenta uma porta local padrão.",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "opencut",
|
|
57
|
+
"name": "OpenCut",
|
|
58
|
+
"repository": "https://github.com/opencut-app/opencut",
|
|
59
|
+
"protocol": "API em desenvolvimento",
|
|
60
|
+
"description": "Editor externo; a documentação actual indica API/MCP em desenvolvimento.",
|
|
61
|
+
"port": 8787,
|
|
62
|
+
"active": False,
|
|
63
|
+
"endpoint_note": "Porta padrão da API documentada pelo projecto: 8787; frontend usa 5173.",
|
|
64
|
+
},
|
|
65
|
+
],
|
|
24
66
|
"settings.json": {
|
|
25
67
|
"port": 3030,
|
|
26
68
|
"moneyprinter_path": "",
|
|
@@ -135,6 +177,19 @@ DEFAULTS: dict[str, Any] = {
|
|
|
135
177
|
"tiktok_scopes": "user.info.basic,video.publish,video.upload",
|
|
136
178
|
"tiktok_access_token": "",
|
|
137
179
|
"tiktok_connection_status": "not_configured",
|
|
180
|
+
"suno_api_key": "",
|
|
181
|
+
"suno_api_base_url": "",
|
|
182
|
+
"suno_api_endpoint": "/api/generate",
|
|
183
|
+
"voice_preview_provider": "edge",
|
|
184
|
+
"voice_preview_rate": "+0%",
|
|
185
|
+
"direct_cookie_sid": "",
|
|
186
|
+
"direct_cookie_ssid": "",
|
|
187
|
+
"direct_cookie_hsid": "",
|
|
188
|
+
"direct_cookie_apisid": "",
|
|
189
|
+
"direct_cookie_sapisid": "",
|
|
190
|
+
"direct_session_info": "",
|
|
191
|
+
"direct_innertube_api_key": "",
|
|
192
|
+
"direct_chunk_size": 262144,
|
|
138
193
|
},
|
|
139
194
|
}
|
|
140
195
|
|
|
@@ -156,7 +211,7 @@ def seed_blueprints() -> None:
|
|
|
156
211
|
|
|
157
212
|
|
|
158
213
|
def ensure_storage() -> None:
|
|
159
|
-
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs"]:
|
|
214
|
+
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews"]:
|
|
160
215
|
path.mkdir(parents=True, exist_ok=True)
|
|
161
216
|
seed_blueprints()
|
|
162
217
|
for filename, default in DEFAULTS.items():
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from . import storage
|
|
14
|
+
|
|
15
|
+
DEFAULT_SAMPLE = "Esta é uma amostra de voz do Thunderbolt. O resultado é apenas um teste e não altera nenhum vídeo ou tarefa da pipeline."
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def preview_directory() -> Path:
|
|
19
|
+
directory = storage.STORAGE / "voice_previews"
|
|
20
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
return directory
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _edge_voice_name(value: str) -> str:
|
|
25
|
+
return str(value or "en-US-AriaNeural-Female").split("-Female", 1)[0].split("-Male", 1)[0]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _save_edge_async(text: str, voice: str, output: Path, rate: str) -> None:
|
|
29
|
+
import edge_tts
|
|
30
|
+
|
|
31
|
+
kwargs: dict[str, Any] = {"rate": rate}
|
|
32
|
+
if "boundary" in inspect.signature(edge_tts.Communicate).parameters:
|
|
33
|
+
kwargs["boundary"] = "WordBoundary"
|
|
34
|
+
communicate = edge_tts.Communicate(text, _edge_voice_name(voice), **kwargs)
|
|
35
|
+
if hasattr(communicate, "save"):
|
|
36
|
+
result = communicate.save(str(output))
|
|
37
|
+
if inspect.isawaitable(result):
|
|
38
|
+
await result
|
|
39
|
+
return
|
|
40
|
+
raise RuntimeError("A versão instalada de edge-tts não possui save().")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save_edge(text: str, voice: str, output: Path, rate: str) -> None:
|
|
44
|
+
asyncio.run(_save_edge_async(text, voice, output, rate))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _save_azure_speech(text: str, voice: str, output: Path, settings: dict[str, Any], rate: str) -> None:
|
|
48
|
+
key = str(settings.get("azure_speech_key", "") or "").strip()
|
|
49
|
+
region = str(settings.get("azure_speech_region", "") or "").strip()
|
|
50
|
+
if not key or not region:
|
|
51
|
+
raise RuntimeError("Configure Azure Speech key e região antes de testar Azure Speech.")
|
|
52
|
+
endpoint = f"https://{region}.tts.speech.microsoft.com/cognitiveservices/v1"
|
|
53
|
+
ssml = f"<speak version='1.0' xml:lang='pt-BR'><voice name='{_edge_voice_name(voice)}'><prosody rate='{rate}'>{text}</prosody></voice></speak>"
|
|
54
|
+
response = requests.post(endpoint, data=ssml.encode("utf-8"), headers={"Ocp-Apim-Subscription-Key": key, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "audio-24khz-96kbitrate-mono-mp3"}, timeout=45)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
output.write_bytes(response.content)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _save_openai_compatible(text: str, voice: str, output: Path, settings: dict[str, Any], provider: str) -> None:
|
|
60
|
+
if provider == "elevenlabs":
|
|
61
|
+
api_key = str(settings.get("elevenlabs_api_key", "") or "").strip()
|
|
62
|
+
base = "https://api.elevenlabs.io/v1/text-to-speech"
|
|
63
|
+
voice_id = voice.split("|", 1)[-1] if "|" in voice else voice
|
|
64
|
+
endpoint = f"{base}/{voice_id or '21m00Tcm4TlvDq8ikWAM'}"
|
|
65
|
+
headers = {"xi-api-key": api_key, "Content-Type": "application/json"}
|
|
66
|
+
payload = {"text": text, "model_id": settings.get("elevenlabs_model_id", "eleven_multilingual_v2")}
|
|
67
|
+
elif provider == "minimax":
|
|
68
|
+
api_key = str(settings.get("minimax_tts_api_key", "") or "").strip()
|
|
69
|
+
endpoint = str(settings.get("minimax_tts_base_url", "") or "").strip().rstrip("/")
|
|
70
|
+
endpoint = f"{endpoint}/v1/t2a_v2" if endpoint and not endpoint.endswith("/t2a_v2") else endpoint
|
|
71
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
72
|
+
payload = {"model": settings.get("minimax_tts_model_id", "speech-2.8-hd"), "text": text, "voice_setting": {"voice_id": voice or settings.get("minimax_tts_voice_id", "English_expressive_narrator"), "speed": 1, "vol": 1, "pitch": 0}}
|
|
73
|
+
else:
|
|
74
|
+
api_key = str(settings.get(f"{provider}_tts_api_key", "") or settings.get(f"{provider}_api_key", "") or "").strip()
|
|
75
|
+
endpoint = str(settings.get(f"{provider}_tts_base_url", "") or settings.get(f"{provider}_base_url", "") or "").strip().rstrip("/")
|
|
76
|
+
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
77
|
+
payload = {"model": settings.get(f"{provider}_model_id", settings.get(f"{provider}_model_name", "")), "input": text, "voice": voice}
|
|
78
|
+
if not api_key or not endpoint:
|
|
79
|
+
raise RuntimeError(f"Configure a API key e o endpoint de {provider} antes do teste.")
|
|
80
|
+
response = requests.post(endpoint, json=payload, headers=headers, timeout=60)
|
|
81
|
+
response.raise_for_status()
|
|
82
|
+
content_type = response.headers.get("content-type", "")
|
|
83
|
+
if "application/json" in content_type:
|
|
84
|
+
body = response.json()
|
|
85
|
+
audio = body.get("audio") or body.get("audio_base64") or body.get("data")
|
|
86
|
+
if isinstance(audio, dict):
|
|
87
|
+
audio = audio.get("audio") or audio.get("url")
|
|
88
|
+
if isinstance(audio, str) and audio.startswith("http"):
|
|
89
|
+
audio_response = requests.get(audio, timeout=60)
|
|
90
|
+
audio_response.raise_for_status()
|
|
91
|
+
output.write_bytes(audio_response.content)
|
|
92
|
+
elif isinstance(audio, str):
|
|
93
|
+
output.write_bytes(base64.b64decode(audio))
|
|
94
|
+
else:
|
|
95
|
+
raise RuntimeError("O provider devolveu JSON sem áudio reconhecível.")
|
|
96
|
+
else:
|
|
97
|
+
output.write_bytes(response.content)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def synthesize_preview(text: str, provider: str, voice: str, settings: dict[str, Any], rate: str = "+0%") -> Path:
|
|
101
|
+
text = (text or "").strip()
|
|
102
|
+
if not text:
|
|
103
|
+
raise ValueError("Introduza um texto para testar a voz.")
|
|
104
|
+
if len(text) > 1000:
|
|
105
|
+
raise ValueError("O texto de teste deve ter no máximo 1000 caracteres.")
|
|
106
|
+
provider = provider.lower().strip()
|
|
107
|
+
extension = ".mp3"
|
|
108
|
+
output = preview_directory() / f"voice-preview-{uuid.uuid4().hex[:10]}{extension}"
|
|
109
|
+
if provider in {"edge", "azure_v1"}:
|
|
110
|
+
_save_edge(text, voice, output, rate)
|
|
111
|
+
elif provider == "azure_speech":
|
|
112
|
+
_save_azure_speech(text, voice, output, settings, rate)
|
|
113
|
+
elif provider in {"elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"}:
|
|
114
|
+
_save_openai_compatible(text, voice, output, settings, provider)
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(f"Provider de preview não suportado: {provider}")
|
|
117
|
+
if not output.exists() or output.stat().st_size == 0:
|
|
118
|
+
raise RuntimeError("O provider não gerou um ficheiro de áudio válido.")
|
|
119
|
+
return output
|