@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 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
- """Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
2607
- render_new_video(page_title="Criação de Músicas", prefix="new_music")
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 = load_standard_video_tasks_for_catalog()
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 music-only counterpart of Backlog Videos with the same controls."""
3719
+ """Render the independent audio-only queue; it never reads the video pipeline."""
3701
3720
  st.subheader("Music Backlog")
3702
- st.caption("Acompanhamento das músicas criadas, estados da pipeline e controlos de execução.")
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
- _render_pipeline_progress_panel()
3705
- tasks = load_music_tasks_for_catalog()
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 task.get('topic', 'Sem título')}**")
3719
- st.caption(f"Música: {task.get('music_source') or 'rota musical'}")
3720
- st.caption(f"{task.get('channel_name')} · {task.get('id')}")
3721
- music_path = str(task.get("music_path") or (task.get("artifacts") or {}).get("music") or "").strip()
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("Formato")
3739
- st.write(_video_task_format(task))
3759
+ st.caption("Tipo")
3760
+ st.write("Áudio")
3740
3761
  with cols[2]:
3741
- st.write(_pipeline_stage_label(task))
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
- _render_video_task_state(task)
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
- transition_task(task["id"], "doing")
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
- transition_task(task["id"], "blocked")
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,
@@ -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])
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.94",
3
+ "version": "0.3.95",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",