@danhachuel/thunderbolt 0.3.94 → 0.3.95
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 +56 -18
- package/hermes_ui/music.py +137 -0
- package/hermes_ui/storage.py +3 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -41,7 +41,7 @@ from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
|
41
41
|
from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
|
|
42
42
|
from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
|
|
43
43
|
from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_cards_for_pool, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
|
|
44
|
-
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file, store_voiceover_file
|
|
44
|
+
from hermes_ui.music import create_music_task, list_music_files, list_music_tasks, materialize_suno_audio, request_suno_generation, run_music_task, store_music_file, store_voiceover_file, transition_music_task
|
|
45
45
|
from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
|
|
46
46
|
from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
|
|
47
47
|
from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
|
|
@@ -2603,8 +2603,27 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2603
2603
|
|
|
2604
2604
|
|
|
2605
2605
|
def render_music_creation():
|
|
2606
|
-
"""
|
|
2607
|
-
|
|
2606
|
+
"""Create audio-only items for the independent Suno/Lyria Music Backlog."""
|
|
2607
|
+
st.title("Criação de Músicas")
|
|
2608
|
+
st.caption("Crie apenas áudio. Os pedidos desta página não criam vídeo, não usam o MoneyPrinterTurbo e não entram no Backlog Vídeos.")
|
|
2609
|
+
settings = read_json("settings.json", {})
|
|
2610
|
+
with st.form("create_music_task_form"):
|
|
2611
|
+
provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
|
|
2612
|
+
title = st.text_input("Título da música", key="music_task_title")
|
|
2613
|
+
prompt = st.text_area("Prompt musical", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="music_task_prompt", height=150)
|
|
2614
|
+
lyria_model = ""
|
|
2615
|
+
if provider_label == "Google Lyria":
|
|
2616
|
+
models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
|
|
2617
|
+
configured = str(settings.get("lyria_model") or models[0])
|
|
2618
|
+
lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
|
|
2619
|
+
submitted = st.form_submit_button("Adicionar ao Music Backlog", type="primary", use_container_width=True)
|
|
2620
|
+
if submitted:
|
|
2621
|
+
try:
|
|
2622
|
+
task = create_music_task(provider_label, prompt, title, lyria_model)
|
|
2623
|
+
st.success(f"Música adicionada ao Music Backlog: {task['title']}")
|
|
2624
|
+
st.rerun()
|
|
2625
|
+
except ValueError as exc:
|
|
2626
|
+
st.error(str(exc))
|
|
2608
2627
|
|
|
2609
2628
|
|
|
2610
2629
|
def render_scripts():
|
|
@@ -3637,7 +3656,7 @@ def render_videos():
|
|
|
3637
3656
|
st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
|
|
3638
3657
|
st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
|
|
3639
3658
|
_render_pipeline_progress_panel()
|
|
3640
|
-
tasks =
|
|
3659
|
+
tasks = load_video_tasks_for_catalog()
|
|
3641
3660
|
if not tasks:
|
|
3642
3661
|
st.info("Nenhum vídeo criado.")
|
|
3643
3662
|
return
|
|
@@ -3697,12 +3716,14 @@ def render_videos():
|
|
|
3697
3716
|
|
|
3698
3717
|
|
|
3699
3718
|
def render_music_backlog() -> None:
|
|
3700
|
-
"""Render the
|
|
3719
|
+
"""Render the independent audio-only queue; it never reads the video pipeline."""
|
|
3701
3720
|
st.subheader("Music Backlog")
|
|
3702
|
-
st.caption("
|
|
3721
|
+
st.caption("Fila independente de geração de áudio por Suno AI ou Google Lyria. Não inclui tarefas, worker ou progresso de vídeo.")
|
|
3703
3722
|
st.caption(f"As músicas são guardadas em `{STORAGE / 'music'}`.")
|
|
3704
|
-
|
|
3705
|
-
|
|
3723
|
+
tasks = list_music_tasks()
|
|
3724
|
+
active = [task for task in tasks if str(task.get("state") or "") == "doing"]
|
|
3725
|
+
if active:
|
|
3726
|
+
st.info(f"Geração musical em execução · {len(active)} tarefa(s) de áudio.")
|
|
3706
3727
|
if not tasks:
|
|
3707
3728
|
st.info("Nenhuma música criada.")
|
|
3708
3729
|
return
|
|
@@ -3715,10 +3736,10 @@ def render_music_backlog() -> None:
|
|
|
3715
3736
|
with st.container(border=True):
|
|
3716
3737
|
cols = st.columns([2.2, 1, 1, 1.2, 1.8])
|
|
3717
3738
|
with cols[0]:
|
|
3718
|
-
st.write(f"**{task.get('title') or
|
|
3719
|
-
st.caption(f"
|
|
3720
|
-
st.caption(
|
|
3721
|
-
music_path = str(task.get("
|
|
3739
|
+
st.write(f"**{task.get('title') or 'Música sem título'}**")
|
|
3740
|
+
st.caption(f"Provider: {'Google Lyria' if str(task.get('provider') or '').casefold() == 'lyria' else 'Suno AI'}")
|
|
3741
|
+
st.caption(str(task.get('id') or ''))
|
|
3742
|
+
music_path = str(task.get("audio_path") or "").strip()
|
|
3722
3743
|
if music_path and Path(music_path).is_file():
|
|
3723
3744
|
music_file = Path(music_path)
|
|
3724
3745
|
st.success("Música pronta; pode continuar para o destino configurado.")
|
|
@@ -3735,22 +3756,29 @@ def render_music_backlog() -> None:
|
|
|
3735
3756
|
else:
|
|
3736
3757
|
st.caption("A música será disponibilizada quando a etapa de geração terminar.")
|
|
3737
3758
|
with cols[1]:
|
|
3738
|
-
st.caption("
|
|
3739
|
-
st.write(
|
|
3759
|
+
st.caption("Tipo")
|
|
3760
|
+
st.write("Áudio")
|
|
3740
3761
|
with cols[2]:
|
|
3741
|
-
st.write(
|
|
3762
|
+
st.write({"music_generation": "Geração musical", "completed": "Concluída", "failed": "Falha"}.get(str(task.get("stage") or ""), "Na fila"))
|
|
3742
3763
|
with cols[3]:
|
|
3743
|
-
|
|
3764
|
+
state = str(task.get("state") or "unknown").strip().lower()
|
|
3765
|
+
progress = _video_task_progress(task)
|
|
3766
|
+
st.caption("Estado")
|
|
3767
|
+
st.write(state or "—")
|
|
3768
|
+
st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
|
|
3769
|
+
st.progress(progress, text=f"{progress}%")
|
|
3770
|
+
if task.get("error"):
|
|
3771
|
+
st.error(str(task.get("error") or "")[:500])
|
|
3744
3772
|
with cols[4]:
|
|
3745
3773
|
state = str(task.get("state") or "")
|
|
3746
3774
|
start_col, stop_col = st.columns(2)
|
|
3747
3775
|
with start_col:
|
|
3748
3776
|
if st.button("Start", key=f"music_backlog_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
|
|
3749
|
-
|
|
3777
|
+
run_music_task(str(task["id"]), read_json("settings.json", {}))
|
|
3750
3778
|
st.rerun()
|
|
3751
3779
|
with stop_col:
|
|
3752
3780
|
if st.button("Stop", key=f"music_backlog_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
|
|
3753
|
-
|
|
3781
|
+
transition_music_task(str(task["id"]), "blocked")
|
|
3754
3782
|
st.rerun()
|
|
3755
3783
|
|
|
3756
3784
|
|
|
@@ -6102,6 +6130,15 @@ def render_settings():
|
|
|
6102
6130
|
widget_key="api_test_voice_suno",
|
|
6103
6131
|
)
|
|
6104
6132
|
|
|
6133
|
+
with st.container(border=True):
|
|
6134
|
+
st.markdown("#### Google Lyria — geração musical")
|
|
6135
|
+
st.caption("Google Lyria gera apenas áudio através da API Gemini Interactions; não usa a pipeline de vídeo.")
|
|
6136
|
+
lyria_api_key = text_setting("Google Lyria API key", "lyria_api_key", secret=True, help_text="Chave da Gemini API com acesso ao modelo Lyria. O valor é guardado apenas localmente.")
|
|
6137
|
+
_render_credential_status(lyria_api_key)
|
|
6138
|
+
lyria_models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
|
|
6139
|
+
saved_lyria_model = str(settings.get("lyria_model") or lyria_models[0])
|
|
6140
|
+
lyria_model = st.selectbox("Modelo Google Lyria", lyria_models, index=lyria_models.index(saved_lyria_model) if saved_lyria_model in lyria_models else 0, key="settings_lyria_model")
|
|
6141
|
+
|
|
6105
6142
|
with st.expander("Publicação através do Upload-Post", expanded=False):
|
|
6106
6143
|
upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
|
|
6107
6144
|
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
@@ -6150,6 +6187,7 @@ def render_settings():
|
|
|
6150
6187
|
"elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
|
|
6151
6188
|
"chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
|
|
6152
6189
|
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint,
|
|
6190
|
+
"lyria_api_key": lyria_api_key, "lyria_model": lyria_model,
|
|
6153
6191
|
"upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
|
|
6154
6192
|
"upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
|
|
6155
6193
|
"upload_post_auto_upload": upload_post_auto_upload,
|
package/hermes_ui/music.py
CHANGED
|
@@ -2,6 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
+
import base64
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
5
8
|
from pathlib import Path
|
|
6
9
|
from typing import Any
|
|
7
10
|
|
|
@@ -115,3 +118,137 @@ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str =
|
|
|
115
118
|
return {"ok": True, "message": "Pedido de música enviado ao endpoint Suno configurado.", "data": body if isinstance(body, dict) else {"response": body}}
|
|
116
119
|
except (requests.RequestException, ValueError) as exc:
|
|
117
120
|
return {"ok": False, "message": f"Não foi possível contactar o endpoint Suno configurado: {exc}", "data": {}}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_music_tasks() -> list[dict[str, Any]]:
|
|
124
|
+
"""Return only tasks created for the independent audio-generation queue."""
|
|
125
|
+
records = storage.read_json("music_tasks.json", [])
|
|
126
|
+
if not isinstance(records, list):
|
|
127
|
+
return []
|
|
128
|
+
return [dict(record) for record in records if isinstance(record, dict) and str(record.get("id") or "").strip()]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _save_music_tasks(tasks: list[dict[str, Any]]) -> None:
|
|
132
|
+
storage.write_json("music_tasks.json", tasks)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def create_music_task(provider: str, prompt: str, title: str, model: str = "") -> dict[str, Any]:
|
|
136
|
+
"""Enqueue one audio-only generation; no video task or video worker is used."""
|
|
137
|
+
cleaned_prompt = str(prompt or "").strip()
|
|
138
|
+
if not cleaned_prompt:
|
|
139
|
+
raise ValueError("Escreva um prompt musical antes de adicionar à fila.")
|
|
140
|
+
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
141
|
+
task = {
|
|
142
|
+
"id": f"music_{uuid.uuid4().hex[:12]}",
|
|
143
|
+
"kind": "audio_generation",
|
|
144
|
+
"provider": "lyria" if str(provider).strip().casefold() == "google lyria" else "suno",
|
|
145
|
+
"model": str(model or "").strip(),
|
|
146
|
+
"title": str(title or "Música sem título").strip() or "Música sem título",
|
|
147
|
+
"prompt": cleaned_prompt,
|
|
148
|
+
"state": "to_do",
|
|
149
|
+
"stage": "music_generation",
|
|
150
|
+
"progress": 0,
|
|
151
|
+
"audio_path": "",
|
|
152
|
+
"error": "",
|
|
153
|
+
"created_at": now,
|
|
154
|
+
"updated_at": now,
|
|
155
|
+
}
|
|
156
|
+
tasks = list_music_tasks()
|
|
157
|
+
tasks.insert(0, task)
|
|
158
|
+
_save_music_tasks(tasks)
|
|
159
|
+
return task
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _update_music_task(task_id: str, **changes: Any) -> dict[str, Any] | None:
|
|
163
|
+
tasks = list_music_tasks()
|
|
164
|
+
updated: dict[str, Any] | None = None
|
|
165
|
+
for task in tasks:
|
|
166
|
+
if str(task.get("id") or "") == str(task_id):
|
|
167
|
+
task.update(changes)
|
|
168
|
+
task["updated_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
169
|
+
updated = dict(task)
|
|
170
|
+
break
|
|
171
|
+
if updated is not None:
|
|
172
|
+
_save_music_tasks(tasks)
|
|
173
|
+
return updated
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def transition_music_task(task_id: str, state: str) -> dict[str, Any] | None:
|
|
177
|
+
"""Change only an independent audio queue state."""
|
|
178
|
+
if state not in {"to_do", "doing", "blocked", "done", "failed", "cancelled"}:
|
|
179
|
+
raise ValueError("Estado de música inválido.")
|
|
180
|
+
return _update_music_task(task_id, state=state)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract_lyria_audio(payload: dict[str, Any]) -> bytes:
|
|
184
|
+
"""Extract a base64 audio block from Gemini Interactions without logging the payload."""
|
|
185
|
+
candidates: list[Any] = []
|
|
186
|
+
output_audio = payload.get("output_audio") if isinstance(payload, dict) else None
|
|
187
|
+
if isinstance(output_audio, dict):
|
|
188
|
+
candidates.append(output_audio.get("data"))
|
|
189
|
+
for step in payload.get("steps", []) if isinstance(payload, dict) else []:
|
|
190
|
+
if not isinstance(step, dict):
|
|
191
|
+
continue
|
|
192
|
+
content = step.get("content")
|
|
193
|
+
for block in content if isinstance(content, list) else []:
|
|
194
|
+
if isinstance(block, dict) and str(block.get("type") or "").casefold() == "audio":
|
|
195
|
+
candidates.append(block.get("data"))
|
|
196
|
+
for candidate in candidates:
|
|
197
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
198
|
+
try:
|
|
199
|
+
decoded = base64.b64decode(candidate, validate=True)
|
|
200
|
+
except (ValueError, TypeError):
|
|
201
|
+
continue
|
|
202
|
+
if decoded:
|
|
203
|
+
return decoded
|
|
204
|
+
return b""
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def request_lyria_generation(settings: dict[str, Any], prompt: str, title: str = "", model: str = "") -> dict[str, Any]:
|
|
208
|
+
"""Generate audio through Google Lyria's Interactions API without invoking video tooling."""
|
|
209
|
+
api_key = str(settings.get("lyria_api_key") or settings.get("gemini_api_key") or "").strip()
|
|
210
|
+
selected_model = str(model or settings.get("lyria_model") or "lyria-3-clip-preview").strip()
|
|
211
|
+
if not api_key:
|
|
212
|
+
return {"ok": False, "message": "Configure a Google Lyria API key em Configurações antes de solicitar uma música.", "data": {}}
|
|
213
|
+
try:
|
|
214
|
+
response = requests.post(
|
|
215
|
+
"https://generativelanguage.googleapis.com/v1beta/interactions",
|
|
216
|
+
headers={"x-goog-api-key": api_key, "Content-Type": "application/json"},
|
|
217
|
+
json={"model": selected_model, "input": str(prompt or "").strip()},
|
|
218
|
+
timeout=180,
|
|
219
|
+
)
|
|
220
|
+
if response.status_code >= 400:
|
|
221
|
+
return {"ok": False, "message": f"Google Lyria devolveu HTTP {response.status_code}.", "data": {"status_code": response.status_code}}
|
|
222
|
+
body = response.json() if response.content else {}
|
|
223
|
+
audio = _extract_lyria_audio(body if isinstance(body, dict) else {})
|
|
224
|
+
if not audio:
|
|
225
|
+
return {"ok": False, "message": "Google Lyria não devolveu áudio na resposta.", "data": {}}
|
|
226
|
+
output = store_music_file(f"{title or 'lyria-generated'}.mp3", audio)
|
|
227
|
+
return {"ok": True, "message": "Música gerada por Google Lyria.", "data": {"audio_path": str(output), "model": selected_model}}
|
|
228
|
+
except (requests.RequestException, ValueError):
|
|
229
|
+
return {"ok": False, "message": "Não foi possível contactar Google Lyria.", "data": {}}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | None:
|
|
233
|
+
"""Run exactly one queued audio generation and persist only its audio artefact."""
|
|
234
|
+
task = next((record for record in list_music_tasks() if str(record.get("id") or "") == str(task_id)), None)
|
|
235
|
+
if not task:
|
|
236
|
+
return None
|
|
237
|
+
_update_music_task(task_id, state="doing", stage="music_generation", progress=15, error="")
|
|
238
|
+
provider = str(task.get("provider") or "suno").casefold()
|
|
239
|
+
if provider == "lyria":
|
|
240
|
+
result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
|
|
241
|
+
audio_path = str((result.get("data") or {}).get("audio_path") or "")
|
|
242
|
+
else:
|
|
243
|
+
result = request_suno_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""))
|
|
244
|
+
try:
|
|
245
|
+
output = materialize_suno_audio(result.get("data") or {}, str(task.get("title") or "suno-generated.mp3")) if result.get("ok") else None
|
|
246
|
+
audio_path = str(output or "")
|
|
247
|
+
except (OSError, requests.RequestException, ValueError):
|
|
248
|
+
audio_path = ""
|
|
249
|
+
result = {"ok": False, "message": "A música Suno foi solicitada, mas não foi possível guardar o áudio devolvido.", "data": {}}
|
|
250
|
+
if result.get("ok") and audio_path:
|
|
251
|
+
completed = _update_music_task(task_id, state="done", stage="completed", progress=100, audio_path=audio_path, error="")
|
|
252
|
+
record_notification("music_completed", f"Música concluída: {task.get('title') or 'Música'}", "Áudio guardado no Music Backlog.", metadata={"task_id": task_id, "provider": provider, "filename": Path(audio_path).name}, dedupe_key=f"music-task:{task_id}:{audio_path}")
|
|
253
|
+
return completed
|
|
254
|
+
return _update_music_task(task_id, state="failed", stage="failed", progress=100, error=str(result.get("message") or "A geração musical falhou.")[:500])
|
package/hermes_ui/storage.py
CHANGED
|
@@ -24,6 +24,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
24
24
|
"channels.json": [],
|
|
25
25
|
"channel_videos.json": [],
|
|
26
26
|
"tasks.json": [],
|
|
27
|
+
"music_tasks.json": [],
|
|
27
28
|
"queues.json": {"niche": [], "blueprint": [], "brand": [], "script": [], "title": [], "thumbnail": [], "video": [], "edit": [], "upload": []},
|
|
28
29
|
"batches.json": [],
|
|
29
30
|
"uploads.json": [],
|
|
@@ -300,6 +301,8 @@ DEFAULTS: dict[str, Any] = {
|
|
|
300
301
|
"suno_api_key": "",
|
|
301
302
|
"suno_api_base_url": "",
|
|
302
303
|
"suno_api_endpoint": "/api/generate",
|
|
304
|
+
"lyria_api_key": "",
|
|
305
|
+
"lyria_model": "lyria-3-clip-preview",
|
|
303
306
|
"jewelmusic_enabled": False,
|
|
304
307
|
"jewelmusic_api_key": "",
|
|
305
308
|
"jewelmusic_base_url": "https://api.jewelmusic.com",
|
package/package.json
CHANGED