@danhachuel/thunderbolt 0.3.50 → 0.3.51

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.50
5
+ > **Versão deste manual:** 0.3.51
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.50 install
103
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.51 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.50 install
109
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.51 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -114,7 +114,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
114
114
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
115
115
 
116
116
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.50 install --purge-data
117
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.51 install --purge-data
118
118
  ```
119
119
 
120
120
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -367,6 +367,8 @@ O expander **Imagem e Video** substitui o antigo bloco isolado da Nano Banana. O
367
367
 
368
368
  A área **Voz, TTS e música — Azure Speech, restantes serviços e Suno** está dividida em cartões independentes para **Azure Speech**, **ElevenLabs**, **SiliconFlow**, **MiniMax TTS**, **Chatterbox**, **Sonilo** e **Suno**. Cada cartão agrupa apenas as credenciais, parâmetros e diagnóstico do serviço correspondente. O Suno aparece num cartão próprio porque é uma integração de criação musical, enquanto os restantes providers são serviços de voz/TTS.
369
369
 
370
+ Na criação de vídeo, abra **Configurações de áudio** e escolha **Upload** em **Modo de narração**. Use **Ficheiro de narração** para seleccionar o áudio, clique em **Guardar áudio de narração** e confirme a pré-visualização. O Thunderbolt valida que o ficheiro existe antes de criar a tarefa e encaminha o caminho ao MoneyPrinterTurbo com `--custom-audio-file`; este argumento é necessário porque o motor não persiste automaticamente o caminho carregado. São aceites ficheiros `.mp3`, `.wav`, `.m4a`, `.aac`, `.flac` e `.ogg`, guardados no storage local em `voiceovers`.
371
+
370
372
  | Provider | Configuração mínima | Pool suportado |
371
373
  |---|---|---|
372
374
  | Nano Banana | API key e modelo Gemini; composição aplicada internamente | Imagem |
@@ -524,7 +526,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
524
526
 
525
527
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
526
528
 
527
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.50 install`; o instalador detecta e reutiliza o que já estiver válido.
529
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.51 install`; o instalador detecta e reutiliza o que já estiver válido.
528
530
 
529
531
  ### Niche Finder Apify
530
532
 
package/README.md CHANGED
@@ -69,7 +69,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
69
69
 
70
70
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
71
71
 
72
- ## Navegação da UI 0.3.50
72
+ ## Navegação da UI 0.3.51
73
73
 
74
74
  A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
75
75
 
@@ -85,7 +85,7 @@ Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o p
85
85
 
86
86
  A subaba **Serviços e modelos** mantém as credenciais de providers e serviços que não são fontes de materiais. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
87
87
 
88
- A área expansível **Voz, TTS e música — Azure Speech, restantes serviços e Suno** está organizada em cartões independentes para **Azure Speech**, **ElevenLabs**, **SiliconFlow**, **MiniMax TTS**, **Chatterbox**, **Sonilo** e **Suno**. Cada cartão contém apenas os campos do respectivo serviço e o seu próprio botão de diagnóstico; o Suno fica visualmente separado por ser um agente de criação musical, não um provider TTS.
88
+ A área expansível **Voz, TTS e música — Azure Speech, restantes serviços e Suno** está organizada em cartões independentes para **Azure Speech**, **ElevenLabs**, **SiliconFlow**, **MiniMax TTS**, **Chatterbox**, **Sonilo** e **Suno**. Cada cartão contém apenas os campos do respectivo serviço e o seu próprio botão de diagnóstico; o Suno fica visualmente separado por ser um agente de criação musical, não um provider TTS. Em **Pipeline Vídeos > Criação de Vídeos > Configurações de áudio**, o modo **Upload** disponibiliza um uploader de narração, botão para guardar o ficheiro, pré-visualização e validação antes da criação. O caminho é passado ao MoneyPrinterTurbo através de `--custom-audio-file`, conforme o fluxo oficial documentado no [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo).
89
89
 
90
90
  Nos cartões de **LLM — providers e modelos**, o campo **Prioridade** define a ordem de tentativa do pool textual. O valor **1** é usado primeiro; em quota, timeout, erro de transporte ou falha transitória, o router tenta a prioridade **2**, depois **3** e assim sucessivamente. O checkbox antigo **LLM principal** deixou de existir. O card **Limite LLM NVIDIA NIM** fica dentro deste mesmo expander, acima dos cartões de providers, e os seus controlos continuam partilhados pela UI, pipeline e automações. Quando **LLM Telegram** está marcado, o cartão fica reservado exclusivamente às notificações Telegram: o campo Prioridade é desactivado e o cartão é excluído do pool textual normal. A chave interna de cartão activo continua apenas como espelho de compatibilidade para instalações antigas.
91
91
 
package/app/main.py CHANGED
@@ -40,7 +40,7 @@ from hermes_ui.mcp_server import server_status, start_server, stop_server
40
40
  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
41
41
  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
42
42
  from hermes_ui.media_providers import MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
43
- from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
43
+ from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file, store_voiceover_file
44
44
  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
45
45
  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
46
46
  from hermes_ui.logs import list_logs, logs_to_rows
@@ -947,6 +947,26 @@ def render_video_generation_settings(
947
947
  audio_cols = st.columns(2)
948
948
  with audio_cols[0]:
949
949
  settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
950
+ settings["voiceover_file"] = str(st.session_state.get(f"{prefix}_voiceover_file", "") or "")
951
+ if settings["voiceover_mode"] == "Upload":
952
+ st.caption("Carregue uma narração pronta; o ficheiro será usado directamente pelo MoneyPrinterTurbo.")
953
+ uploaded_voiceover = st.file_uploader(
954
+ "Ficheiro de narração",
955
+ type=["mp3", "wav", "m4a", "aac", "flac", "ogg"],
956
+ key=f"{prefix}_voiceover_upload",
957
+ )
958
+ if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", use_container_width=True):
959
+ try:
960
+ stored_voiceover = store_voiceover_file(uploaded_voiceover.name, uploaded_voiceover.getvalue())
961
+ st.session_state[f"{prefix}_voiceover_file"] = str(stored_voiceover)
962
+ settings["voiceover_file"] = str(stored_voiceover)
963
+ st.success(f"Narração guardada em `{stored_voiceover}`")
964
+ except (OSError, ValueError) as exc:
965
+ st.error(str(exc))
966
+ stored_voiceover_value = str(st.session_state.get(f"{prefix}_voiceover_file", "") or "")
967
+ if stored_voiceover_value and Path(stored_voiceover_value).is_file():
968
+ st.audio(stored_voiceover_value)
969
+ settings["voiceover_file"] = stored_voiceover_value
950
970
  settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
951
971
  if channel is not None:
952
972
  channel_id = str(channel.get("id") or channel.get("name") or "")
@@ -965,7 +985,8 @@ def render_video_generation_settings(
965
985
  settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
966
986
  st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
967
987
  with audio_cols[1]:
968
- settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
988
+ background_music_label = "Fonte da música de fundo" if current_ui_language() == "pt" else "Background Music Source"
989
+ settings["background_music_source"] = st.selectbox(background_music_label, BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
969
990
  settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
970
991
 
971
992
  if "Configurações de legendas" in visible_sections:
@@ -2220,7 +2241,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2220
2241
  st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
2221
2242
  music_path = st.session_state.get(f"{prefix}_music_path", "")
2222
2243
 
2223
- quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel", key=f"{prefix}_quantity")
2244
+ same_channel_quantity = max(1, min(100, int(generation_settings.get("videos_per_run") or 1))) if mode == "same_channel" else 1
2224
2245
  payloads: dict[str, dict[str, Any]] = {}
2225
2246
  if mode == "general":
2226
2247
  existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
@@ -2311,7 +2332,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2311
2332
  read_json("settings.json", {}),
2312
2333
  selected_one,
2313
2334
  topic_for_thumbnail,
2314
- int(quantity if mode == "same_channel" else 1),
2335
+ same_channel_quantity,
2315
2336
  title=existing_title,
2316
2337
  topic_source=_video_topic_source(topic_for_thumbnail, prefix),
2317
2338
  )
@@ -2320,7 +2341,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2320
2341
  generated["title"] = existing_title
2321
2342
  generated["title_candidates"] = existing_payload.get("title_candidates", [])
2322
2343
  st.session_state[f"{prefix}_creative_payload"] = generated
2323
- st.success(f"Thumbnail pronta para {int(quantity if mode == 'same_channel' else 1)} vídeo(s); o título existente foi preservado.")
2344
+ st.success(f"Thumbnail pronta para {same_channel_quantity} vídeo(s); o título existente foi preservado.")
2324
2345
  st.rerun()
2325
2346
  except CreativeGenerationError as exc:
2326
2347
  st.error(str(exc))
@@ -2386,6 +2407,11 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2386
2407
  if style == "music" and not music_path:
2387
2408
  st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
2388
2409
  st.stop()
2410
+ if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
2411
+ voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
2412
+ if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
2413
+ st.error("Carregue e guarde um ficheiro de narração em Configurações de áudio antes de criar o vídeo.")
2414
+ st.stop()
2389
2415
  if mode == "general":
2390
2416
  payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
2391
2417
  topics = dict(st.session_state.get(f"{prefix}_general_topics", {}))
@@ -2437,7 +2463,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2437
2463
  if not topic_value:
2438
2464
  st.error("Preencha o campo Video Subject ou use o botão de geração automática abaixo das keywords.")
2439
2465
  st.stop()
2440
- quantity_value = int(quantity if mode == "same_channel" else 1)
2466
+ quantity_value = same_channel_quantity
2441
2467
  payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
2442
2468
  if not payload.get("title") or not payload.get("thumbnail_variants"):
2443
2469
  try:
@@ -22,6 +22,7 @@ AUDIO_SETTING_KEYS = (
22
22
  "voice",
23
23
  "voiceover_volume",
24
24
  "voiceover_speed",
25
+ "voiceover_file",
25
26
  "background_music_source",
26
27
  "background_music_volume",
27
28
  )
@@ -11,6 +11,7 @@ from . import storage
11
11
  from .notifications import record_notification
12
12
 
13
13
  MUSIC_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
14
+ VOICEOVER_EXTENSIONS = set(MUSIC_EXTENSIONS)
14
15
 
15
16
 
16
17
  def music_directory() -> Path:
@@ -46,6 +47,32 @@ def store_music_file(name: str, content: bytes) -> Path:
46
47
  return target
47
48
 
48
49
 
50
+ def voiceover_directory() -> Path:
51
+ directory = storage.STORAGE / "voiceovers"
52
+ directory.mkdir(parents=True, exist_ok=True)
53
+ return directory
54
+
55
+
56
+ def safe_voiceover_name(name: str) -> str:
57
+ stem = re.sub(r"[^\w\-. ]+", "_", Path(name).stem, flags=re.UNICODE).strip() or "voiceover"
58
+ suffix = Path(name).suffix.lower()
59
+ if suffix not in VOICEOVER_EXTENSIONS:
60
+ suffix = ".mp3"
61
+ return f"{stem}{suffix}"
62
+
63
+
64
+ def store_voiceover_file(name: str, content: bytes) -> Path:
65
+ if not content:
66
+ raise ValueError("O ficheiro de narração está vazio.")
67
+ suffix = Path(name).suffix.lower()
68
+ if suffix not in VOICEOVER_EXTENSIONS:
69
+ allowed = ", ".join(sorted(VOICEOVER_EXTENSIONS))
70
+ raise ValueError(f"Formato de narração não suportado: {suffix or '(sem extensão)'}. Use {allowed}.")
71
+ target = voiceover_directory() / safe_voiceover_name(name)
72
+ target.write_bytes(content)
73
+ return target
74
+
75
+
49
76
  def materialize_suno_audio(data: dict[str, Any], title: str = "suno-generated.mp3") -> Path | None:
50
77
  candidates: list[str] = []
51
78
  for key in ("audio_url", "audioUrl", "download_url", "downloadUrl", "url"):
@@ -355,6 +355,12 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
355
355
  if configured_root:
356
356
  command.extend(["--root", str(configured_root)])
357
357
  command.extend(["--subject", subject])
358
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
359
+ if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
360
+ voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
361
+ if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
362
+ raise PipelineError("O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio.")
363
+ command.extend(["--custom-audio-file", str(voiceover_file.resolve())])
358
364
  output_lines: list[str] = []
359
365
  line_queue: queue.Queue[str | None] = queue.Queue()
360
366
  started_at = time.monotonic()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.50",
3
+ "version": "0.3.51",
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",