@danhachuel/thunderbolt 0.3.49 → 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.
- package/MANUAL-INSTALACAO.md +8 -6
- package/README.md +3 -3
- package/app/main.py +44 -15
- package/hermes_ui/draft_video.py +1 -0
- package/hermes_ui/llm_providers.py +24 -18
- package/hermes_ui/music.py +27 -0
- package/hermes_ui/pipeline_worker.py +6 -0
- package/hermes_ui/provider_routing.py +6 -1
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
@@ -359,7 +359,7 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
|
|
|
359
359
|
|
|
360
360
|
### 4.1 Pools de LLM, imagem e vídeo
|
|
361
361
|
|
|
362
|
-
Em **Configuração API > API Keys**, o expander **LLM — providers e modelos** continua a guardar cartões independentes para os providers textuais. O campo **Prioridade** substitui o antigo checkbox **LLM principal**: o cartão com valor **1** é tentado primeiro, depois o **2**, o **3** e assim sucessivamente. Pode manter várias API keys do mesmo provider em cartões distintos; a geração textual percorre o pool por essa ordem e tenta a prioridade seguinte apenas em quota, timeout, erro de transporte ou falha transitória. A chave interna de cartão activo permanece apenas como espelho de compatibilidade das instalações antigas.
|
|
362
|
+
Em **Configuração API > API Keys**, o expander **LLM — providers e modelos** continua a guardar cartões independentes para os providers textuais. O campo **Prioridade** substitui o antigo checkbox **LLM principal**: o cartão com valor **1** é tentado primeiro, depois o **2**, o **3** e assim sucessivamente. Pode manter várias API keys do mesmo provider em cartões distintos; a geração textual percorre o pool por essa ordem e tenta a prioridade seguinte apenas em quota, timeout, erro de transporte ou falha transitória. Quando **LLM Telegram** está marcado, o campo Prioridade fica desactivado e o cartão é excluído do pool textual normal, sendo usado exclusivamente para notificações Telegram. A chave interna de cartão activo permanece apenas como espelho de compatibilidade das instalações antigas.
|
|
363
363
|
|
|
364
364
|
Dentro do expander **LLM — providers e modelos**, acima dos cartões de providers, está o card **Limite LLM NVIDIA NIM**. Ele permite ligar **Activar limitador NVIDIA NIM — 40 RPM**. O valor padrão é 40 pedidos numa janela de 60 segundos. O contador é persistido localmente por cartão/chave e aplica-se à criação manual, aos workers e às automações. A opção vem desligada para não alterar instalações existentes; ligue-a quando o endpoint activo for NVIDIA NIM e a sua conta tiver esse limite.
|
|
365
365
|
|
|
@@ -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.
|
|
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.
|
|
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,9 +85,9 @@ 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
|
-
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. A chave interna de cartão activo continua apenas como espelho de compatibilidade para instalações antigas.
|
|
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
|
|
|
92
92
|
O expander **Imagem e Video** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. Os parâmetros técnicos de composição, como **Image Size** e **Aspect Ratio**, não são apresentados nem editáveis nos cartões: ficam como defaults internos e são acrescentados automaticamente ao prompt da geração de imagem e/ou vídeo. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
|
|
93
93
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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:
|
|
@@ -5040,22 +5066,25 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
|
|
|
5040
5066
|
with status_cols[0]:
|
|
5041
5067
|
enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"llm_card_{card_id}_enabled")
|
|
5042
5068
|
with status_cols[1]:
|
|
5069
|
+
telegram_llm = st.checkbox(
|
|
5070
|
+
"LLM Telegram",
|
|
5071
|
+
value=bool(card.get("telegram_llm", False)),
|
|
5072
|
+
help="Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.",
|
|
5073
|
+
key=f"llm_card_{card_id}_telegram",
|
|
5074
|
+
)
|
|
5075
|
+
with status_cols[2]:
|
|
5043
5076
|
priority = st.number_input(
|
|
5044
5077
|
"Prioridade",
|
|
5045
5078
|
min_value=1,
|
|
5046
5079
|
max_value=999,
|
|
5047
5080
|
value=max(1, int(card.get("priority", index + 1))),
|
|
5048
5081
|
step=1,
|
|
5049
|
-
|
|
5082
|
+
disabled=bool(telegram_llm),
|
|
5083
|
+
help="Não se aplica a cartões LLM Telegram. Nos restantes cartões, 1 é o primeiro; em falha elegível, segue para 2, 3 e assim por diante.",
|
|
5050
5084
|
key=f"llm_card_{card_id}_priority",
|
|
5051
5085
|
)
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
"LLM Telegram",
|
|
5055
|
-
value=bool(card.get("telegram_llm", False)),
|
|
5056
|
-
help="Usar este cartão exclusivamente para o roteamento de notificações Telegram. Apenas um cartão pode ficar seleccionado.",
|
|
5057
|
-
key=f"llm_card_{card_id}_telegram",
|
|
5058
|
-
)
|
|
5086
|
+
if telegram_llm:
|
|
5087
|
+
st.caption("Exclusivo para Notificações de Telegram — prioridade ignorada no pool LLM.")
|
|
5059
5088
|
|
|
5060
5089
|
save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"llm_card_{card_id}_save")
|
|
5061
5090
|
remove_clicked = False
|
|
@@ -5104,7 +5133,7 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
|
|
|
5104
5133
|
settings.update(migrated)
|
|
5105
5134
|
write_json("settings.json", settings)
|
|
5106
5135
|
with st.expander("LLM — providers e modelos", expanded=False):
|
|
5107
|
-
st.caption("Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; a prioridade 1 é tentada primeiro
|
|
5136
|
+
st.caption("Configure cada provider num cartão independente. Pode repetir o mesmo provider para manter várias API keys; a prioridade 1 é tentada primeiro. Cartões marcados como LLM Telegram ficam excluídos do pool textual e são usados apenas pelas notificações Telegram.")
|
|
5108
5137
|
with st.container(border=True):
|
|
5109
5138
|
st.markdown("### Limite LLM NVIDIA NIM")
|
|
5110
5139
|
st.caption("Quando ligado, limita apenas cartões cujo endpoint é integrate.api.nvidia.com a 40 pedidos por janela de 60 segundos, partilhados entre UI, pipeline e automações.")
|
package/hermes_ui/draft_video.py
CHANGED
|
@@ -305,9 +305,13 @@ def ensure_llm_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str, An
|
|
|
305
305
|
|
|
306
306
|
ids = {str(card.get("id")) for card in cards}
|
|
307
307
|
ordered = _ordered_cards(cards)
|
|
308
|
-
first_enabled = next(
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
first_enabled = next(
|
|
309
|
+
(card for card in ordered if card.get("enabled", True) and not card.get("telegram_llm", False)),
|
|
310
|
+
None,
|
|
311
|
+
)
|
|
312
|
+
desired_active_id = str(first_enabled["id"]) if first_enabled else ""
|
|
313
|
+
if active_id != desired_active_id:
|
|
314
|
+
result[LLM_ACTIVE_CARD_KEY] = desired_active_id
|
|
311
315
|
changed = True
|
|
312
316
|
if not any(card.get("telegram_llm") for card in cards):
|
|
313
317
|
if result.get(LLM_TELEGRAM_CARD_KEY):
|
|
@@ -338,7 +342,7 @@ def active_llm_card(settings: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
338
342
|
cards = migrated.get(LLM_CARDS_KEY, [])
|
|
339
343
|
ordered = _ordered_cards(cards) if isinstance(cards, list) else []
|
|
340
344
|
for card in ordered:
|
|
341
|
-
if card.get("enabled", True):
|
|
345
|
+
if card.get("enabled", True) and not card.get("telegram_llm", False):
|
|
342
346
|
return dict(card)
|
|
343
347
|
return new_llm_card(DEFAULT_LLM_PROVIDER, card_id=DEFAULT_LLM_CARD_ID)
|
|
344
348
|
|
|
@@ -370,22 +374,24 @@ def apply_llm_cards_to_settings(
|
|
|
370
374
|
old_priority = int(card.get("priority", DEFAULT_LLM_PRIORITY))
|
|
371
375
|
card["priority"] = 1 if str(card.get("id")) == str(active_card_id) else max(2, old_priority)
|
|
372
376
|
ordered = _ordered_cards(normalized)
|
|
373
|
-
|
|
377
|
+
normal_enabled = [card for card in ordered if card.get("enabled", True) and not card.get("telegram_llm", False)]
|
|
378
|
+
active = normal_enabled[0] if normal_enabled else None
|
|
374
379
|
result[LLM_CARDS_KEY] = normalized
|
|
375
|
-
result[LLM_ACTIVE_CARD_KEY] = active["id"]
|
|
380
|
+
result[LLM_ACTIVE_CARD_KEY] = active["id"] if active else ""
|
|
376
381
|
result[LLM_TELEGRAM_CARD_KEY] = telegram["id"] if telegram else ""
|
|
377
|
-
result["llm_provider"] = active["provider"]
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
382
|
+
result["llm_provider"] = active["provider"] if active else DEFAULT_LLM_PROVIDER
|
|
383
|
+
|
|
384
|
+
if active:
|
|
385
|
+
prefix = _legacy_prefix(str(active["provider"]))
|
|
386
|
+
result[f"{prefix}_api_key"] = str(active.get("api_key") or "").strip()
|
|
387
|
+
result[f"{prefix}_model_name"] = str(active.get("model") or "").strip()
|
|
388
|
+
result[f"{prefix}_base_url"] = str(active.get("base_url") or "").strip()
|
|
389
|
+
definition = provider_definition(active["provider"])
|
|
390
|
+
for field in definition.extra_fields:
|
|
391
|
+
result[f"{prefix}_{field}"] = str(active.get(field) or "").strip()
|
|
392
|
+
if active.get("provider") == "cloudflare":
|
|
393
|
+
result["cloudflare_account_id"] = str(active.get("account_id") or "").strip()
|
|
394
|
+
result["cloudflare_gateway_id"] = str(active.get("gateway_id") or "").strip()
|
|
389
395
|
return result
|
|
390
396
|
|
|
391
397
|
|
package/hermes_ui/music.py
CHANGED
|
@@ -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()
|
|
@@ -281,7 +281,11 @@ def enabled_cards(settings: Mapping[str, Any], pool: str) -> list[dict[str, Any]
|
|
|
281
281
|
migrated, _ = ensure_llm_provider_cards(settings)
|
|
282
282
|
raw_cards = migrated.get("llm_provider_cards", [])
|
|
283
283
|
cards = [normalize_llm_card(item, index) for index, item in enumerate(raw_cards)] if isinstance(raw_cards, list) else []
|
|
284
|
-
enabled = [
|
|
284
|
+
enabled = [
|
|
285
|
+
(index, card)
|
|
286
|
+
for index, card in enumerate(cards)
|
|
287
|
+
if bool(card.get("enabled", True)) and not bool(card.get("telegram_llm", False))
|
|
288
|
+
]
|
|
285
289
|
enabled.sort(key=lambda pair: (int(pair[1].get("priority", 1)), pair[0]))
|
|
286
290
|
return [card for _index, card in enabled]
|
|
287
291
|
raw_cards = settings.get("media_provider_cards", [])
|
|
@@ -369,6 +373,7 @@ def route_json_request(
|
|
|
369
373
|
raise ProviderRoutingError(f"Pool de providers inválido: {pool}")
|
|
370
374
|
candidates = [dict(item) for item in (cards if cards is not None else enabled_cards(settings, pool))]
|
|
371
375
|
if pool == POOL_LLM:
|
|
376
|
+
candidates = [card for card in candidates if not bool(card.get("telegram_llm", False))]
|
|
372
377
|
candidates = [
|
|
373
378
|
card
|
|
374
379
|
for _index, card in sorted(
|
package/package.json
CHANGED