@danhachuel/thunderbolt 0.3.93 → 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():
@@ -3576,6 +3595,21 @@ def load_video_tasks_for_catalog() -> list[dict[str, Any]]:
3576
3595
  return [task for task in saved if isinstance(task, dict) and str(task.get("id") or "").strip()]
3577
3596
 
3578
3597
 
3598
+ def _is_music_task(task: dict[str, Any]) -> bool:
3599
+ """Identify music pipeline tasks without conflating them with ordinary video tasks."""
3600
+ return bool(task.get("music_mode")) or str(task.get("style_wide") or task.get("style") or "").strip().casefold() in {"music", "música"}
3601
+
3602
+
3603
+ def load_music_tasks_for_catalog() -> list[dict[str, Any]]:
3604
+ """Return persisted tasks that were explicitly created through a music route."""
3605
+ return [task for task in load_video_tasks_for_catalog() if _is_music_task(task)]
3606
+
3607
+
3608
+ def load_standard_video_tasks_for_catalog() -> list[dict[str, Any]]:
3609
+ """Keep video backlog free of tasks that belong to the dedicated music queue."""
3610
+ return [task for task in load_video_tasks_for_catalog() if not _is_music_task(task)]
3611
+
3612
+
3579
3613
  def _video_task_format(task: dict[str, Any]) -> str:
3580
3614
  value = task.get("format") or task.get("style_wide") or task.get("style") or "wide"
3581
3615
  return str(value).strip() or "wide"
@@ -3681,6 +3715,73 @@ def render_videos():
3681
3715
  st.rerun()
3682
3716
 
3683
3717
 
3718
+ def render_music_backlog() -> None:
3719
+ """Render the independent audio-only queue; it never reads the video pipeline."""
3720
+ st.subheader("Music Backlog")
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.")
3722
+ st.caption(f"As músicas são guardadas em `{STORAGE / 'music'}`.")
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.")
3727
+ if not tasks:
3728
+ st.info("Nenhuma música criada.")
3729
+ return
3730
+ known_states = ["to_do", "doing", "blocked", "done", "failed", "cancelled"]
3731
+ extra_states = sorted({str(task.get("state") or "unknown") for task in tasks if str(task.get("state") or "unknown") not in known_states})
3732
+ state_filter = st.selectbox("Filtrar por estado", ["Todos", *known_states, *extra_states], key="music_backlog_state_filter")
3733
+ for task in tasks:
3734
+ if state_filter != "Todos" and task.get("state") != state_filter:
3735
+ continue
3736
+ with st.container(border=True):
3737
+ cols = st.columns([2.2, 1, 1, 1.2, 1.8])
3738
+ with cols[0]:
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()
3743
+ if music_path and Path(music_path).is_file():
3744
+ music_file = Path(music_path)
3745
+ st.success("Música pronta; pode continuar para o destino configurado.")
3746
+ st.download_button(
3747
+ "Descarregar música pronta",
3748
+ data=music_file.read_bytes(),
3749
+ file_name=music_file.name,
3750
+ mime="audio/mpeg",
3751
+ key=f"music_backlog_download_{task['id']}",
3752
+ use_container_width=True,
3753
+ )
3754
+ elif music_path:
3755
+ st.caption(f"Música registada: {music_path}")
3756
+ else:
3757
+ st.caption("A música será disponibilizada quando a etapa de geração terminar.")
3758
+ with cols[1]:
3759
+ st.caption("Tipo")
3760
+ st.write("Áudio")
3761
+ with cols[2]:
3762
+ st.write({"music_generation": "Geração musical", "completed": "Concluída", "failed": "Falha"}.get(str(task.get("stage") or ""), "Na fila"))
3763
+ with cols[3]:
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])
3772
+ with cols[4]:
3773
+ state = str(task.get("state") or "")
3774
+ start_col, stop_col = st.columns(2)
3775
+ with start_col:
3776
+ if st.button("Start", key=f"music_backlog_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
3777
+ run_music_task(str(task["id"]), read_json("settings.json", {}))
3778
+ st.rerun()
3779
+ with stop_col:
3780
+ if st.button("Stop", key=f"music_backlog_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
3781
+ transition_music_task(str(task["id"]), "blocked")
3782
+ st.rerun()
3783
+
3784
+
3684
3785
  def _thumbnail_editor_context(record: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
3685
3786
  """Resolve the persisted task channel/Blueprint without requiring either one to remain registered."""
3686
3787
  channel_id = str(record.get("channel_id") or "").strip()
@@ -6029,6 +6130,15 @@ def render_settings():
6029
6130
  widget_key="api_test_voice_suno",
6030
6131
  )
6031
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
+
6032
6142
  with st.expander("Publicação através do Upload-Post", expanded=False):
6033
6143
  upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
6034
6144
  upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
@@ -6077,6 +6187,7 @@ def render_settings():
6077
6187
  "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
6078
6188
  "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
6079
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,
6080
6191
  "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
6081
6192
  "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
6082
6193
  "upload_post_auto_upload": upload_post_auto_upload,
@@ -6745,6 +6856,7 @@ def main():
6745
6856
  ]
6746
6857
  music_items = [
6747
6858
  ("Criação de Músicas", ":material/music_note:", "Criação de Músicas"),
6859
+ ("Music Backlog", ":material/queue_music:", "Music Backlog"),
6748
6860
  ("Upload Música", ":material/library_music:", "Upload Música"),
6749
6861
  ]
6750
6862
  models_ai_items = [
@@ -6867,6 +6979,7 @@ def main():
6867
6979
  "Criação de Vídeos": render_new_video,
6868
6980
  "Backlog Vídeos": render_videos,
6869
6981
  "Criação de Músicas": render_music_creation,
6982
+ "Music Backlog": render_music_backlog,
6870
6983
  "Upload Música": render_music_upload,
6871
6984
  "Roteiros": render_scripts,
6872
6985
  "Thumbnails": render_thumbnails,
@@ -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.93",
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",