@danhachuel/thunderbolt 0.3.50 → 0.3.52
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 +9 -5
- package/README.md +3 -3
- package/app/main.py +96 -10
- package/hermes_ui/draft_video.py +1 -0
- package/hermes_ui/music.py +27 -0
- package/hermes_ui/pipeline_worker.py +6 -0
- 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.52
|
|
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.52 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.52 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.52 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,10 @@ 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
|
+
|
|
372
|
+
As notificações novas são verificadas automaticamente na sessão activa e aparecem como pop-ups no canto inferior direito, mesmo quando está aberta outra página. O pop-up não marca o registo como lido: a aba **Notificações** continua a ser o centro persistente para consultar o histórico, gerir preferências e marcar eventos como lidos. Cada ID é apresentado no máximo uma vez por sessão do navegador.
|
|
373
|
+
|
|
370
374
|
| Provider | Configuração mínima | Pool suportado |
|
|
371
375
|
|---|---|---|
|
|
372
376
|
| Nano Banana | API key e modelo Gemini; composição aplicada internamente | Imagem |
|
|
@@ -524,7 +528,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
524
528
|
|
|
525
529
|
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
530
|
|
|
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.
|
|
531
|
+
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.52 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
528
532
|
|
|
529
533
|
### Niche Finder Apify
|
|
530
534
|
|
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.52
|
|
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
|
|
|
@@ -97,7 +97,7 @@ No bloco **Limite LLM NVIDIA NIM**, a opção **Activar limitador NVIDIA NIM —
|
|
|
97
97
|
|
|
98
98
|
A correcção da integração Nano Banana remove o campo de entrega `inline` rejeitado pelo contrato actual da Interactions API e mantém a edição de lettering com imagem de referência quando suportada pelo provider.
|
|
99
99
|
|
|
100
|
-
A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas. A página **Configurações > Logs**, colocada imediatamente abaixo de Notificações e acima de Configuração API, apresenta o histórico unificado de tarefas e notificações, com filtro livre por operação, filtros adicionais por operação e estado, e as colunas Operação, Estado, Data, Hora, Registo, Origem, Progresso e Detalhes. A tabela mantém uma barra vertical e, quando a soma das colunas excede a largura disponível, disponibiliza também uma barra de rolagem horizontal na parte inferior para consultar integralmente células longas, sobretudo em **Detalhes**.
|
|
100
|
+
A página **AI Influencers > Tutorial Meta** apresenta o guia de configuração de uma conta Instagram profissional e das credenciais Meta para automações com n8n, distribuído localmente em `seed/references/guide-instagram.md` e com ligação para a [fonte original no GitHub](https://github.com/gyoridavid/ai_agents_az/blob/main/episode_8/guide-instagram.md). A página **Configurações > Notificações** mantém um histórico persistente de conclusões e falhas, reconcilia estados escritos por componentes locais e disponibiliza um checkbox independente para cada operação mapeada. A subaba **Geral** mantém esse centro local; a subaba **Telegram** permite configurar Bot Token, Chat ID e proxy opcional e envia as mesmas notificações pela Bot API oficial, sem polling ou mensagens recebidas. As notificações novas também aparecem automaticamente como pop-ups no canto inferior direito enquanto o utilizador está noutra página, sem precisar de abrir Notificações; cada aviso é mostrado uma vez por sessão e o histórico continua a controlar o estado de leitura. A página **Configurações > Logs**, colocada imediatamente abaixo de Notificações e acima de Configuração API, apresenta o histórico unificado de tarefas e notificações, com filtro livre por operação, filtros adicionais por operação e estado, e as colunas Operação, Estado, Data, Hora, Registo, Origem, Progresso e Detalhes. A tabela mantém uma barra vertical e, quando a soma das colunas excede a largura disponível, disponibiliza também uma barra de rolagem horizontal na parte inferior para consultar integralmente células longas, sobretudo em **Detalhes**.
|
|
101
101
|
|
|
102
102
|
A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https://github.com/yt-dlp/yt-dlp) para descarregar vídeos e áudio de URLs públicas, com qualidade, contentor, formato de áudio, legendas, metadados, playlists, progresso e histórico local em `storage/downloads/` e `storage/state/media_downloads.json`. Conversão e combinação de streams podem exigir FFmpeg.
|
|
103
103
|
|
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
|
|
@@ -256,6 +256,9 @@ st.markdown("""
|
|
|
256
256
|
[data-testid="stMultiSelect"] [data-baseweb="tag"]:has(button[aria-label*="Instagram"]) { background:#e1306c !important; }
|
|
257
257
|
[data-testid="stMultiSelect"] [data-baseweb="tag"]:has(button[aria-label*="Facebook Pages"]) { background:#1877f2 !important; }
|
|
258
258
|
[data-testid="stMultiSelect"] [data-baseweb="tag"] svg { color:#ffffff !important; fill:#ffffff !important; }
|
|
259
|
+
/* Notificações globais: o histórico continua persistente, mas os avisos surgem sem abrir a aba. */
|
|
260
|
+
[data-testid="stToastContainer"] { top:auto !important; right:1rem !important; bottom:1rem !important; left:auto !important; z-index:100000 !important; }
|
|
261
|
+
[data-testid="stToast"] { max-width:min(28rem, calc(100vw - 2rem)); }
|
|
259
262
|
</style>
|
|
260
263
|
""", unsafe_allow_html=True)
|
|
261
264
|
|
|
@@ -338,6 +341,66 @@ def install_streamlit_content_translation() -> None:
|
|
|
338
341
|
install_streamlit_content_translation()
|
|
339
342
|
|
|
340
343
|
|
|
344
|
+
NOTIFICATION_TOAST_INTERVAL = "3s"
|
|
345
|
+
NOTIFICATION_TOAST_MAX_PER_CYCLE = 3
|
|
346
|
+
NOTIFICATION_TOAST_SEEN_KEY = "_thunderbolt_notification_toast_seen_ids"
|
|
347
|
+
NOTIFICATION_TOAST_INITIALISED_KEY = "_thunderbolt_notification_toast_initialised"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _notification_toast_seen_ids() -> set[str]:
|
|
351
|
+
raw = st.session_state.get(NOTIFICATION_TOAST_SEEN_KEY, [])
|
|
352
|
+
if isinstance(raw, (list, tuple, set)):
|
|
353
|
+
return {str(item) for item in raw if str(item).strip()}
|
|
354
|
+
return set()
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _save_notification_toast_seen_ids(notification_ids: set[str]) -> None:
|
|
358
|
+
# Limitar o estado evita crescimento indefinido numa sessão longa.
|
|
359
|
+
st.session_state[NOTIFICATION_TOAST_SEEN_KEY] = sorted(notification_ids)[-500:]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _render_notification_toast_cycle() -> None:
|
|
363
|
+
"""Reconcile completions and display only new unread notifications in this session."""
|
|
364
|
+
try:
|
|
365
|
+
reconcile_persisted_notifications()
|
|
366
|
+
except Exception:
|
|
367
|
+
# A camada visual nunca deve interromper o pipeline ou os workers.
|
|
368
|
+
return
|
|
369
|
+
entries = list_notifications(limit=500, unread_only=True)
|
|
370
|
+
current_ids = {str(item.get("id") or "") for item in entries if str(item.get("id") or "")}
|
|
371
|
+
seen_ids = _notification_toast_seen_ids()
|
|
372
|
+
if not st.session_state.get(NOTIFICATION_TOAST_INITIALISED_KEY, False):
|
|
373
|
+
# Não transformar o histórico antigo numa avalanche de pop-ups ao abrir a UI.
|
|
374
|
+
seen_ids.update(current_ids)
|
|
375
|
+
_save_notification_toast_seen_ids(seen_ids)
|
|
376
|
+
st.session_state[NOTIFICATION_TOAST_INITIALISED_KEY] = True
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
pending = [item for item in reversed(entries) if str(item.get("id") or "") not in seen_ids]
|
|
380
|
+
for item in pending[:NOTIFICATION_TOAST_MAX_PER_CYCLE]:
|
|
381
|
+
notification_id = str(item.get("id") or "")
|
|
382
|
+
if not notification_id:
|
|
383
|
+
continue
|
|
384
|
+
title = str(item.get("title") or item.get("label") or "Notificação").strip()
|
|
385
|
+
message = str(item.get("message") or "").strip()
|
|
386
|
+
body = f"**{title}**"
|
|
387
|
+
if message:
|
|
388
|
+
body = f"{body}\n\n{message}"
|
|
389
|
+
if hasattr(st, "toast"):
|
|
390
|
+
st.toast(body)
|
|
391
|
+
seen_ids.add(notification_id)
|
|
392
|
+
_save_notification_toast_seen_ids(seen_ids)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
if hasattr(st, "fragment"):
|
|
396
|
+
@st.fragment(run_every=NOTIFICATION_TOAST_INTERVAL)
|
|
397
|
+
def render_global_notification_toasts() -> None:
|
|
398
|
+
_render_notification_toast_cycle()
|
|
399
|
+
else:
|
|
400
|
+
def render_global_notification_toasts() -> None:
|
|
401
|
+
_render_notification_toast_cycle()
|
|
402
|
+
|
|
403
|
+
|
|
341
404
|
def localized_tab_labels(labels: list[str], language: str | None = None) -> list[str]:
|
|
342
405
|
"""Translate Streamlit tab labels while keeping widget keys and behaviour stable."""
|
|
343
406
|
selected_language = language_code(language or current_ui_language())
|
|
@@ -947,6 +1010,26 @@ def render_video_generation_settings(
|
|
|
947
1010
|
audio_cols = st.columns(2)
|
|
948
1011
|
with audio_cols[0]:
|
|
949
1012
|
settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
|
|
1013
|
+
settings["voiceover_file"] = str(st.session_state.get(f"{prefix}_voiceover_file", "") or "")
|
|
1014
|
+
if settings["voiceover_mode"] == "Upload":
|
|
1015
|
+
st.caption("Carregue uma narração pronta; o ficheiro será usado directamente pelo MoneyPrinterTurbo.")
|
|
1016
|
+
uploaded_voiceover = st.file_uploader(
|
|
1017
|
+
"Ficheiro de narração",
|
|
1018
|
+
type=["mp3", "wav", "m4a", "aac", "flac", "ogg"],
|
|
1019
|
+
key=f"{prefix}_voiceover_upload",
|
|
1020
|
+
)
|
|
1021
|
+
if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", use_container_width=True):
|
|
1022
|
+
try:
|
|
1023
|
+
stored_voiceover = store_voiceover_file(uploaded_voiceover.name, uploaded_voiceover.getvalue())
|
|
1024
|
+
st.session_state[f"{prefix}_voiceover_file"] = str(stored_voiceover)
|
|
1025
|
+
settings["voiceover_file"] = str(stored_voiceover)
|
|
1026
|
+
st.success(f"Narração guardada em `{stored_voiceover}`")
|
|
1027
|
+
except (OSError, ValueError) as exc:
|
|
1028
|
+
st.error(str(exc))
|
|
1029
|
+
stored_voiceover_value = str(st.session_state.get(f"{prefix}_voiceover_file", "") or "")
|
|
1030
|
+
if stored_voiceover_value and Path(stored_voiceover_value).is_file():
|
|
1031
|
+
st.audio(stored_voiceover_value)
|
|
1032
|
+
settings["voiceover_file"] = stored_voiceover_value
|
|
950
1033
|
settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
|
|
951
1034
|
if channel is not None:
|
|
952
1035
|
channel_id = str(channel.get("id") or channel.get("name") or "")
|
|
@@ -965,7 +1048,8 @@ def render_video_generation_settings(
|
|
|
965
1048
|
settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
|
|
966
1049
|
st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
|
|
967
1050
|
with audio_cols[1]:
|
|
968
|
-
|
|
1051
|
+
background_music_label = "Fonte da música de fundo" if current_ui_language() == "pt" else "Background Music Source"
|
|
1052
|
+
settings["background_music_source"] = st.selectbox(background_music_label, BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
|
|
969
1053
|
settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
|
|
970
1054
|
|
|
971
1055
|
if "Configurações de legendas" in visible_sections:
|
|
@@ -2220,7 +2304,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2220
2304
|
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
2221
2305
|
music_path = st.session_state.get(f"{prefix}_music_path", "")
|
|
2222
2306
|
|
|
2223
|
-
|
|
2307
|
+
same_channel_quantity = max(1, min(100, int(generation_settings.get("videos_per_run") or 1))) if mode == "same_channel" else 1
|
|
2224
2308
|
payloads: dict[str, dict[str, Any]] = {}
|
|
2225
2309
|
if mode == "general":
|
|
2226
2310
|
existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
|
|
@@ -2311,7 +2395,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2311
2395
|
read_json("settings.json", {}),
|
|
2312
2396
|
selected_one,
|
|
2313
2397
|
topic_for_thumbnail,
|
|
2314
|
-
|
|
2398
|
+
same_channel_quantity,
|
|
2315
2399
|
title=existing_title,
|
|
2316
2400
|
topic_source=_video_topic_source(topic_for_thumbnail, prefix),
|
|
2317
2401
|
)
|
|
@@ -2320,7 +2404,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2320
2404
|
generated["title"] = existing_title
|
|
2321
2405
|
generated["title_candidates"] = existing_payload.get("title_candidates", [])
|
|
2322
2406
|
st.session_state[f"{prefix}_creative_payload"] = generated
|
|
2323
|
-
st.success(f"Thumbnail pronta para {
|
|
2407
|
+
st.success(f"Thumbnail pronta para {same_channel_quantity} vídeo(s); o título existente foi preservado.")
|
|
2324
2408
|
st.rerun()
|
|
2325
2409
|
except CreativeGenerationError as exc:
|
|
2326
2410
|
st.error(str(exc))
|
|
@@ -2386,6 +2470,11 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2386
2470
|
if style == "music" and not music_path:
|
|
2387
2471
|
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
2388
2472
|
st.stop()
|
|
2473
|
+
if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
|
|
2474
|
+
voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
|
|
2475
|
+
if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
|
|
2476
|
+
st.error("Carregue e guarde um ficheiro de narração em Configurações de áudio antes de criar o vídeo.")
|
|
2477
|
+
st.stop()
|
|
2389
2478
|
if mode == "general":
|
|
2390
2479
|
payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
|
|
2391
2480
|
topics = dict(st.session_state.get(f"{prefix}_general_topics", {}))
|
|
@@ -2437,7 +2526,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2437
2526
|
if not topic_value:
|
|
2438
2527
|
st.error("Preencha o campo Video Subject ou use o botão de geração automática abaixo das keywords.")
|
|
2439
2528
|
st.stop()
|
|
2440
|
-
quantity_value =
|
|
2529
|
+
quantity_value = same_channel_quantity
|
|
2441
2530
|
payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
|
|
2442
2531
|
if not payload.get("title") or not payload.get("thumbnail_variants"):
|
|
2443
2532
|
try:
|
|
@@ -6269,10 +6358,7 @@ def main():
|
|
|
6269
6358
|
"Notificações": render_notifications,
|
|
6270
6359
|
"Logs": render_logs,
|
|
6271
6360
|
}
|
|
6272
|
-
|
|
6273
|
-
reconcile_persisted_notifications()
|
|
6274
|
-
except Exception:
|
|
6275
|
-
pass
|
|
6361
|
+
render_global_notification_toasts()
|
|
6276
6362
|
renderers.get(current_page, render_dashboard)()
|
|
6277
6363
|
|
|
6278
6364
|
if __name__ == "__main__":
|
package/hermes_ui/draft_video.py
CHANGED
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()
|
package/package.json
CHANGED