@danhachuel/thunderbolt 0.3.97 → 0.3.99

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.
@@ -6,6 +6,7 @@ import base64
6
6
  import hashlib
7
7
  import io
8
8
  import json
9
+ import mimetypes
9
10
  from pathlib import Path
10
11
  from typing import Any, Mapping
11
12
 
@@ -294,24 +295,38 @@ def _render_content_history(repository: Any, influencer_id: str = "") -> None:
294
295
  if not records:
295
296
  return
296
297
  st.subheader("Conteúdos gerados")
297
- rows = []
298
298
  for item in records:
299
- rows.append({
300
- "Tipo": str(item.get("content_type") or "").capitalize(),
301
- "Estado": CONTENT_STATES.get(str(item.get("state") or ""), str(item.get("state") or "—")),
302
- "Provider": f"{item.get('provider') or '—'} · {item.get('model') or '—'}",
303
- "Plataforma": item.get("platform") or "",
304
- "Artefacto": item.get("artifact_path") or item.get("error") or "—",
305
- "Criado": item.get("created_at") or "—",
306
- })
307
- st.dataframe(rows, use_container_width=True, hide_index=True)
308
- latest = records[0]
309
- artifact = Path(str(latest.get("artifact_path") or ""))
310
- if artifact.is_file() and latest.get("state") == "completed":
311
- if latest.get("content_type") == "image":
312
- st.image(str(artifact), caption="Última imagem gerada", use_container_width=True)
313
- elif latest.get("content_type") == "video":
314
- st.video(str(artifact))
299
+ content_id = str(item.get("id") or "content")
300
+ content_type = str(item.get("content_type") or "").strip().lower()
301
+ artifact = Path(str(item.get("artifact_path") or ""))
302
+ state = str(item.get("state") or "")
303
+ type_label = "Imagem" if content_type == "image" else "Vídeo" if content_type == "video" else "Conteúdo"
304
+ with st.container(border=True):
305
+ preview_col, detail_col = st.columns([1.25, 2.75])
306
+ with preview_col:
307
+ if artifact.is_file() and content_type == "image":
308
+ st.image(str(artifact), caption=f"{type_label} gerada", use_container_width=True)
309
+ elif artifact.is_file() and content_type == "video":
310
+ st.video(str(artifact))
311
+ else:
312
+ st.caption("Artefacto indisponível")
313
+ with detail_col:
314
+ st.write(f"**{type_label} · {CONTENT_STATES.get(state, state or '—')}**")
315
+ st.caption(f"Provider: {item.get('provider') or '—'} · {item.get('model') or '—'}")
316
+ st.caption(f"Plataforma: {item.get('platform') or '—'} · Criado: {item.get('created_at') or '—'}")
317
+ if item.get("error"):
318
+ st.error(str(item.get("error") or "")[:700])
319
+ if artifact.is_file() and state == "completed" and content_type in {"image", "video"}:
320
+ fallback_mime = "image/png" if content_type == "image" else "video/mp4"
321
+ mime = mimetypes.guess_type(artifact.name)[0] or fallback_mime
322
+ st.download_button(
323
+ f"Descarregar {type_label.casefold()}",
324
+ data=artifact.read_bytes(),
325
+ file_name=artifact.name,
326
+ mime=mime,
327
+ key=f"influencer_content_download_{content_type}_{content_id}",
328
+ use_container_width=True,
329
+ )
315
330
 
316
331
 
317
332
  def _store_uploaded_file(uploaded: Any, folder: str) -> Path:
package/app/main.py CHANGED
@@ -43,6 +43,7 @@ from hermes_ui.material_sources import apply_material_source_cards_to_settings,
43
43
  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
44
44
  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
45
45
  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
46
+ from hermes_ui.music_generation import MUSIC_GENRES, MUSIC_VOCAL_OPTIONS, generate_music_fields
46
47
  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
47
48
  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
48
49
  from hermes_ui.influencers import BACKEND_OPTIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, backend_name, backend_status, get_repository, test_backend
@@ -2651,20 +2652,75 @@ def render_music_creation():
2651
2652
  st.title("Criação de Músicas")
2652
2653
  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.")
2653
2654
  settings = read_json("settings.json", {})
2654
- with st.form("create_music_task_form"):
2655
- provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
2656
- title = st.text_input("Título da música", key="music_task_title")
2657
- prompt = st.text_area("Prompt musical", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="music_task_prompt", height=150)
2658
- lyria_model = ""
2659
- if provider_label == "Google Lyria":
2660
- models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
2661
- configured = str(settings.get("lyria_model") or models[0])
2662
- lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
2663
- submitted = st.form_submit_button("Adicionar ao Music Backlog", type="primary", use_container_width=True)
2664
- if submitted:
2655
+ generated_fields = st.session_state.pop("music_task_generated_fields", None)
2656
+ if isinstance(generated_fields, dict):
2657
+ for state_key, generated_key in (
2658
+ ("music_task_title", "title"),
2659
+ ("music_task_language", "language"),
2660
+ ("music_task_genre", "genre"),
2661
+ ("music_task_vocal", "vocal"),
2662
+ ("music_task_references", "references"),
2663
+ ("music_task_prompt", "prompt"),
2664
+ ):
2665
+ st.session_state[state_key] = str(generated_fields.get(generated_key) or "")
2666
+ provider_label = st.selectbox("Provider de geração musical", ["Suno AI", "Google Lyria"], key="music_task_provider")
2667
+ theme = st.text_input("Tema / assunto principal", key="music_task_theme", placeholder="Ex.: uma viagem nocturna pela costa portuguesa")
2668
+ title = st.text_input("Título da música", key="music_task_title")
2669
+ language = st.selectbox(
2670
+ "Idioma da letra/música",
2671
+ list(LANGUAGE_CODES),
2672
+ index=list(LANGUAGE_CODES).index(str(st.session_state.get("music_task_language") or "pt")) if str(st.session_state.get("music_task_language") or "pt") in LANGUAGE_CODES else list(LANGUAGE_CODES).index("pt"),
2673
+ format_func=language_label,
2674
+ key="music_task_language",
2675
+ )
2676
+ genre = st.selectbox("Género musical", list(MUSIC_GENRES), key="music_task_genre")
2677
+ vocal = st.selectbox("Vocal", list(MUSIC_VOCAL_OPTIONS), key="music_task_vocal")
2678
+ references = st.text_area(
2679
+ "Referências culturais, paisagens, clima ou artistas similares (opcional)",
2680
+ key="music_task_references",
2681
+ placeholder="Ex.: pôr do sol mediterrânico, estrada molhada, nostalgia suave e arranjos acústicos contemporâneos",
2682
+ height=90,
2683
+ )
2684
+ if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", use_container_width=True, icon=":material/auto_awesome:"):
2685
+ try:
2686
+ with st.spinner("A criar título, letra e prompt musical originais…"):
2687
+ generated = generate_music_fields(
2688
+ settings,
2689
+ theme=theme,
2690
+ language=language,
2691
+ genre=genre,
2692
+ vocal=vocal,
2693
+ references=references,
2694
+ )
2695
+ st.session_state["music_task_generated_fields"] = generated
2696
+ st.rerun()
2697
+ except CreativeGenerationError as exc:
2698
+ st.error(str(exc))
2699
+ prompt = st.text_area(
2700
+ "Prompt musical",
2701
+ placeholder="Use Gerar campos musicais com IA para criar letra e estilo completos, ou escreva um prompt próprio.",
2702
+ key="music_task_prompt",
2703
+ height=300,
2704
+ )
2705
+ lyria_model = ""
2706
+ if provider_label == "Google Lyria":
2707
+ models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
2708
+ configured = str(settings.get("lyria_model") or models[0])
2709
+ lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
2710
+ if st.button("Gerar Música", key="music_task_submit", type="primary", use_container_width=True, icon=":material/music_note:"):
2665
2711
  try:
2666
- task = create_music_task(provider_label, prompt, title, lyria_model)
2667
- st.success(f"Música adicionada ao Music Backlog: {task['title']}")
2712
+ task = create_music_task(
2713
+ provider_label,
2714
+ prompt,
2715
+ title,
2716
+ lyria_model,
2717
+ language=language,
2718
+ genre=genre,
2719
+ vocal=vocal,
2720
+ references=references,
2721
+ theme=theme,
2722
+ )
2723
+ st.success(f"Música criada no Music Backlog: {task['title']}")
2668
2724
  st.rerun()
2669
2725
  except ValueError as exc:
2670
2726
  st.error(str(exc))
@@ -3788,7 +3844,7 @@ def render_music_backlog() -> None:
3788
3844
  music_file = Path(music_path)
3789
3845
  st.success("Música pronta; pode continuar para o destino configurado.")
3790
3846
  st.download_button(
3791
- "Descarregar música pronta",
3847
+ "Descarregar música",
3792
3848
  data=music_file.read_bytes(),
3793
3849
  file_name=music_file.name,
3794
3850
  mime="audio/mpeg",
@@ -3865,6 +3921,14 @@ def render_thumbnails():
3865
3921
  image_path = record.get("image_path")
3866
3922
  if image_path and image_path.is_file():
3867
3923
  st.image(str(image_path), use_container_width=True)
3924
+ st.download_button(
3925
+ "Descarregar thumbnail",
3926
+ data=image_path.read_bytes(),
3927
+ file_name=image_path.name,
3928
+ mime="image/jpeg" if image_path.suffix.lower() in {".jpg", ".jpeg"} else "image/png",
3929
+ key=f"thumbnail_download_{task_id}_{record['variant_index']}",
3930
+ use_container_width=True,
3931
+ )
3868
3932
  else:
3869
3933
  st.markdown("### Sem imagem")
3870
3934
  st.caption("Imagem ainda não gerada")
@@ -97,7 +97,14 @@ def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp
97
97
  return store_music_file(title, response.content)
98
98
 
99
99
 
100
- def request_suno_generation(settings: dict[str, Any], prompt: str, title: str = "", duration_seconds: int = 120) -> dict[str, Any]:
100
+ def request_suno_generation(
101
+ settings: dict[str, Any],
102
+ prompt: str,
103
+ title: str = "",
104
+ duration_seconds: int = 120,
105
+ *,
106
+ make_instrumental: bool = True,
107
+ ) -> dict[str, Any]:
101
108
  """Request music from a configured Suno-compatible endpoint.
102
109
 
103
110
  Suno-compatible deployments expose different endpoint paths; the UI therefore
@@ -109,7 +116,12 @@ def request_suno_generation(settings: dict[str, Any], prompt: str, title: str =
109
116
  if not api_key or not base_url:
110
117
  return {"ok": False, "message": "Configure Suno API Key e Suno API Base URL em Configurações antes de solicitar uma música.", "data": {}}
111
118
  url = endpoint if endpoint.startswith("http") else f"{base_url}/{endpoint.lstrip('/')}"
112
- payload = {"prompt": prompt.strip(), "title": title.strip(), "duration": max(120, int(duration_seconds)), "make_instrumental": True}
119
+ payload = {
120
+ "prompt": prompt.strip(),
121
+ "title": title.strip(),
122
+ "duration": max(120, int(duration_seconds)),
123
+ "make_instrumental": bool(make_instrumental),
124
+ }
113
125
  try:
114
126
  response = requests.post(url, json=payload, headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, timeout=30)
115
127
  if response.status_code >= 400:
@@ -132,7 +144,18 @@ def _save_music_tasks(tasks: list[dict[str, Any]]) -> None:
132
144
  storage.write_json("music_tasks.json", tasks)
133
145
 
134
146
 
135
- def create_music_task(provider: str, prompt: str, title: str, model: str = "") -> dict[str, Any]:
147
+ def create_music_task(
148
+ provider: str,
149
+ prompt: str,
150
+ title: str,
151
+ model: str = "",
152
+ *,
153
+ language: str = "",
154
+ genre: str = "",
155
+ vocal: str = "",
156
+ references: str = "",
157
+ theme: str = "",
158
+ ) -> dict[str, Any]:
136
159
  """Enqueue one audio-only generation; no video task or video worker is used."""
137
160
  cleaned_prompt = str(prompt or "").strip()
138
161
  if not cleaned_prompt:
@@ -145,6 +168,12 @@ def create_music_task(provider: str, prompt: str, title: str, model: str = "") -
145
168
  "model": str(model or "").strip(),
146
169
  "title": str(title or "Música sem título").strip() or "Música sem título",
147
170
  "prompt": cleaned_prompt,
171
+ "language": str(language or "").strip(),
172
+ "genre": str(genre or "").strip(),
173
+ "vocal": str(vocal or "").strip(),
174
+ "references": str(references or "").strip(),
175
+ "theme": str(theme or "").strip(),
176
+ "duration_seconds": 120,
148
177
  "state": "to_do",
149
178
  "stage": "music_generation",
150
179
  "progress": 0,
@@ -240,7 +269,13 @@ def run_music_task(task_id: str, settings: dict[str, Any]) -> dict[str, Any] | N
240
269
  result = request_lyria_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""), str(task.get("model") or ""))
241
270
  audio_path = str((result.get("data") or {}).get("audio_path") or "")
242
271
  else:
243
- result = request_suno_generation(settings, str(task.get("prompt") or ""), str(task.get("title") or ""))
272
+ result = request_suno_generation(
273
+ settings,
274
+ str(task.get("prompt") or ""),
275
+ str(task.get("title") or ""),
276
+ int(task.get("duration_seconds") or 120),
277
+ make_instrumental=not bool(str(task.get("vocal") or "").strip()),
278
+ )
244
279
  try:
245
280
  output = materialize_suno_audio(result.get("data") or {}, str(task.get("title") or "suno-generated.mp3")) if result.get("ok") else None
246
281
  audio_path = str(output or "")
@@ -0,0 +1,88 @@
1
+ """Structured, original music briefs generated through the configured LLM pool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from .creative_generation import CreativeGenerationError, _chat_json
9
+ from .languages import language_code
10
+
11
+
12
+ MUSIC_GENRES = (
13
+ "Pop", "Rock", "Hip Hop", "EDM (Eletrônica)", "Techno", "Country", "Folk", "Indie", "K-pop", "Jazz",
14
+ "Reggae", "Classical", "Metal", "Punk", "Afrobeat (Nigéria)", "Ambient (Reino Unido)", "Anime (Japão)",
15
+ "Arabic Pop / Khaleeji (Mundo Árabe)", "Austropop (Áustria)", "Ballad (Internacional)", "Banda (México)",
16
+ "Blues (EUA)", "Bollywood / Indian (Índia)", "City Pop (Japão)", "Corridos Tumbados (México)", "C-pop (China)",
17
+ "Cumbia (Colômbia)", "Dangdut (Indonésia)", "Dansband (Suécia)", "Deutschrap (Alemanha)",
18
+ "Disco Polo (Polônia)", "Drum & Bass (Reino Unido)", "Entechno (Grécia)", "Fado (Portugal)",
19
+ "Flamenco (Espanha)", "Forró (Brasil)", "Funk (Brasil)", "Fusion (Internacional)", "Gamolan (Indonésia)",
20
+ "Gospel (EUA)", "Grime (Reino Unido)", "Hardstyle (Países Baixos)", "J-Pop (Japão)", "J-Rock (Japão)",
21
+ "K-Hip Hop (Coreia do Sul)", "Kizomba (Angola)", "Klapa (Croácia)", "Klezmer (Judeu / Europa Oriental)",
22
+ "Laïko (Grécia)", "Latin (América Latina)", "Mandopop (China / Taiwan)", "Māori (Nova Zelândia)", "MPB (Brasil)",
23
+ "Mundart (Dialect Pop) (Suíça)", "Pasifika (Pacífico)", "Pimba (Portugal)", "Pop Sueco (Suécia)", "R&B (EUA)",
24
+ "Rap Francês (França)", "Reggaeton (Porto Rico)", "Regional Mexicano (México)", "Samba / Pagode (Brasil)",
25
+ "Schlager (Alemanha)", "Sertanejo (Brasil)", "Trot (Coreia do Sul)", "Urbano / Trap (América Latina)",
26
+ "Variété (França)", "Vocaloid (Japão)",
27
+ )
28
+ MUSIC_VOCAL_OPTIONS = ("Masculino", "Feminina", "Coral")
29
+
30
+
31
+ def generate_music_fields(
32
+ settings: dict[str, Any],
33
+ *,
34
+ theme: str,
35
+ language: str,
36
+ genre: str,
37
+ vocal: str,
38
+ references: str = "",
39
+ ) -> dict[str, str]:
40
+ """Generate original, ready-to-use music fields through the active LLM pool."""
41
+ cleaned_theme = str(theme or "").strip()
42
+ if not cleaned_theme:
43
+ raise CreativeGenerationError("Escreva o tema ou assunto principal antes de gerar os campos musicais com IA.")
44
+ selected_genre = str(genre or "").strip()
45
+ selected_vocal = str(vocal or "").strip()
46
+ selected_language = language_code(language)
47
+ system = (
48
+ "És um compositor e produtor musical. Cria uma canção inteiramente original, sem reproduzir letras, melodias, "
49
+ "frases distintivas ou a identidade de artistas existentes. As referências fornecidas servem apenas para atributos "
50
+ "de alto nível, como instrumentação, clima, contexto cultural e energia. Responde apenas com JSON válido contendo "
51
+ "as chaves title, language, genre, vocal, cultural_references e music_prompt. "
52
+ "music_prompt deve ser Markdown legível, pronto para uma ferramenta de geração musical, com exactamente estas secções: "
53
+ "# 1. Nome da Música, # 2. Idioma da Música, # 3. Letra / Lyrics, # 4. Estilo / Style Prompt. "
54
+ "A letra deve ser original, incluir [Intro], [Verse 1], [Pre-Chorus], [Chorus], [Verse 2], [Bridge], "
55
+ "[Instrumental Solo], [Final Chorus] e [Outro], ter uma estrutura que vise pelo menos dois minutos, e o estilo deve "
56
+ "informar subgénero, vocal, instrumentação, BPM, clima, produção e estrutura."
57
+ )
58
+ user = json.dumps(
59
+ {
60
+ "theme": cleaned_theme,
61
+ "selected_language_code": selected_language,
62
+ "selected_genre": selected_genre,
63
+ "selected_vocal": selected_vocal,
64
+ "cultural_landscape_weather_or_artist_references": str(references or "").strip(),
65
+ "available_genres": list(MUSIC_GENRES),
66
+ "available_vocals": list(MUSIC_VOCAL_OPTIONS),
67
+ "requirements": {
68
+ "minimum_target_duration_seconds": 120,
69
+ "preserve_selected_language_genre_and_vocal_when_present": True,
70
+ "output_original_content_only": True,
71
+ },
72
+ },
73
+ ensure_ascii=False,
74
+ )
75
+ result = _chat_json(settings, system, user)
76
+ music_prompt = str(result.get("music_prompt") or "").strip()
77
+ if not music_prompt:
78
+ raise CreativeGenerationError("O provider LLM não devolveu um prompt musical válido.")
79
+ generated_genre = str(result.get("genre") or selected_genre).strip()
80
+ generated_vocal = str(result.get("vocal") or selected_vocal).strip()
81
+ return {
82
+ "title": str(result.get("title") or cleaned_theme).strip()[:180],
83
+ "language": language_code(result.get("language"), default=selected_language),
84
+ "genre": generated_genre if generated_genre in MUSIC_GENRES else selected_genre,
85
+ "vocal": generated_vocal if generated_vocal in MUSIC_VOCAL_OPTIONS else selected_vocal,
86
+ "references": str(result.get("cultural_references") or references or "").strip()[:800],
87
+ "prompt": music_prompt[:16000],
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.97",
3
+ "version": "0.3.99",
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",