@danhachuel/thunderbolt 0.2.75 → 0.2.76

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.
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.2.56 install
109
+ npx --yes @danhachuel/thunderbolt@0.2.76 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.
@@ -388,14 +388,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
388
388
  3. **Brandings:** abra a subaba **Brandings** e confirme a listagem dos ficheiros JSON.
389
389
  4. **Canais:** em **Importar do YouTube**, use o método **Página pública — sem API Key** com um URL `/channel/UC...`, um handle ou uma subpágina `/videos`; o parser resolve o ID, consulta a página pública e tenta o RSS quando necessário. Confirme que o resultado abre o formulário de revisão sem Data API Key. Se o canal não existir ou não fornecer metadados, confirme a mensagem clara e que o formulário de uma pesquisa anterior desaparece. A Data API é opcional e fica separada; em **Cadastro manual**, preencha os dados sem qualquer consulta externa.
390
390
  5. **Niche Finder Kaggle:** abra o menu expansível **Niche Finder**, seleccione **Niche Finder Kaggle**, defina os parâmetros dentro do conteúdo principal e confirme que não há preparação automática. Antes do clique, não deve existir download de dataset nem análise. Clique em **Analisar Nichos** para iniciar a preparação dos dados e a análise; depois altere país, engagement, datas e tags e clique novamente para aplicar os novos parâmetros. Em seguida, abra a alternativa independente **Niche Finder Apify**, configure as palavras-chave e filtros na própria aba e confirme que nada é executado antes de clicar em **Pesquisar no Apify**.
391
- 6. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote. O campo **Tópico ou briefing** pode ser escrito manualmente ou preenchido pelo botão **Gerar tópico/briefing com IA**; a geração só ocorre após o clique e usa o provider configurado em **Configuração API > API Keys**.
391
+ 6. **Pipeline > Criação de Vídeos:** teste primeiro o modo **Canal específico** e depois os modos de lote. O campo **Tópico ou briefing** pode ser escrito manualmente ou preenchido pelo botão **Gerar tópico/briefing com IA**; a geração só ocorre após o clique e usa o provider configurado em **Configuração API > API Keys > Serviços e modelos**.
392
392
  7. **Criação de Vídeos:** depois de seleccionar o canal, confirme que o painel entre **Canal** e **Estilo wide** mostra o Blueprint e a voz, ou **SEM BLUEPRINT CONFIGURADO**. Clique em **Gerar títulos e thumbnails com IA**, confirme pelo menos 20 candidatos de título e 3–5 variantes de thumbnail, escolha uma variante e verifique o prompt guardado. Na subaba **Vídeos**, verifique o estado `to_do`, o título escolhido, o estado da thumbnail e os botões **Iniciar** e **Parar**. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
393
393
  8. **Lote geral:** confirme que não existe **Canais incluídos** nem selector parcial. A UI deve listar todos os canais cadastrados e criar exactamente uma tarefa por canal, com briefing, título, thumbnail e Blueprint próprios. Use **Gerar tópicos individuais para todos os canais** e **Gerar títulos e thumbnails para todos os canais** antes de criar o batch.
394
394
  9. **Upload:** configure o OAuth Client ID e Secret, autorize primeiro o **youtube-automation-agent** na própria aba, confirme o estado **pronto para publicar**, preencha título/descrição/tags e publique um MP4 real. O botão **Autorizar fallback OAuth** existe apenas para redundância; a Data API Key, se configurada, é exclusivamente para consultas oficiais públicas e nunca substitui OAuth.
395
395
  10. **Edição > Limpador de Metadados:** suba um vídeo de terceiro, preencha título, preview, links, timestamps e tags, aplique a limpeza e descarregue a cópia limpa e o manifesto JSON. O original fica preservado e vídeos das páginas de criação não são aceites nesta área.
396
- 11. **Configurações > Configuração API:** confirme que os caminhos e credenciais estão locais e não aparecem no Git. Na secção **Contas Google/YouTube — canais em lote**, adicione cada conta com o seu e-mail/Gmail, OAuth Client ID, OAuth Client Secret e `sessionInfo` próprios; use **Repetir campos para nova conta** para preparar contas adicionais e o ícone **Apagar conta** para eliminar individualmente a conta, tokens, documento JSON e associações de canais.
397
- 12. **OpenAI/ NVIDIA NIM:** em **LLM — providers e modelos**, seleccione `openai`, confirme a Base URL, introduza a API key e clique em **Consultar/actualizar modelos NIM**. Escolha um modelo da lista ou utilize o fallback manual e guarde as configurações. Confirme que a consulta só ocorre após o clique e que um erro de endpoint não interrompe a UI.
398
- 13. **Criação de Vídeos:** confirme que `Pexels/Pixabay` substitui o label antigo, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
396
+ 11. **Configurações > Configuração API:** confirme que as credenciais ficam locais e não aparecem no Git. Em **API Keys > Fontes de materiais**, seleccione Pexels, Pixabay, Coverr, WaveSpeed AI, LoomLoom, TwelveLabs ou Ficheiros locais. Para uma fonte com API, preencha a primeira chave e use **Adicionar outra chave** para criar quantas linhas forem necessárias; clique em **Guardar fonte e chaves**. Cada fonte mantém a sua própria lista, com deduplicação e rotação interna. Não são pedidos endpoint, proxy, qualidade, codec, FFmpeg, Whisper, directório ou filtros nesta subaba. Na secção **Contas Google/YouTube — canais em lote**, adicione cada conta com o seu e-mail/Gmail, OAuth Client ID, OAuth Client Secret e `sessionInfo` próprios; use **Repetir campos para nova conta** para preparar contas adicionais e o ícone **Apagar conta** para eliminar individualmente a conta, tokens, documento JSON e associações de canais.
397
+ 12. **OpenAI/ NVIDIA NIM:** em **API Keys > Serviços e modelos > LLM — providers e modelos**, seleccione `openai`, confirme a Base URL, introduza a API key e clique em **Consultar/actualizar modelos NIM**. Escolha um modelo da lista ou utilize o fallback manual e guarde as configurações. Confirme que a consulta só ocorre após o clique e que um erro de endpoint não interrompe a UI.
398
+ 13. **Criação de Vídeos:** confirme que a fonte seleccionada em **API Keys > Fontes de materiais** é usada pela pipeline, que `Estilo IA` aparece apenas em `full_ia` e que `Apenas Música` exige áudio e guarda `background_mode=none`.
399
399
  14. **Canais Youtube:** abra **Configurações > Canais Youtube**, clique em **Editar** num cartão, altere o nicho, Blueprint/Prompts do Canal, Narrador/Voz padrão e horário, guarde e confirme que o cartão é actualizado sem duplicar o canal. Clique em **Actualizar últimos 10 vídeos**, valide a vista Lista, mude para Kanban e abra **Editar vídeo** para testar título, estado, data, URL e notas.
400
400
  15. **Automação Youtube:** no cartão de cada canal, escolha o **Blueprint padrão** e a **Voz padrão**, clique em `Guardar` e confirme que o resumo do cartão se actualiza. Configure também **Automação ON** e um horário `HH:MM`; confirme a lista de vídeos cadastrados. Os mesmos defaults aparecem em **Canais Youtube** e são usados em novas tarefas. Inicie o Thunderbolt pelo launcher, confirme o aviso verde **Worker activo** e verifique que o relógio apresentado corresponde ao computador. O worker cria no máximo um lote por canal por dia quando o horário local coincide.
401
401
  16. **Configurações > Configuração API > Teste de vozes:** teste Edge/Azure ou provider configurado e confirme reprodução/download sem criação de tarefa.
@@ -404,6 +404,14 @@ Após iniciar a aplicação, valide o seguinte percurso:
404
404
  19. **Configurações > MCP > Servidor MCP:** abra a subaba, mantenha o host `127.0.0.1`, active **Servidor MCP ON** e clique em **Guardar e iniciar Servidor MCP**. Confirme o endpoint `/mcp` e o health endpoint `/health`. Mantenha **Permitir ferramentas de escrita** desactivado até precisar que um agente crie lotes.
405
405
  20. **Configurações > MCP > Skill:** clique em **Guardar skill localmente** e confirme o ficheiro em `storage/skills/moneyprinterturbo-video.md`; opcionalmente use **Descarregar skill .md** para obter a cópia através do navegador.
406
406
 
407
+ ## Configuração API — fontes de materiais
408
+
409
+ A área **Configurações > Configuração API > API Keys** separa as credenciais de serviços da configuração de fontes. Abra a subaba **Fontes de materiais** e escolha a fonte que a pipeline deverá usar. Para **Pexels**, **Pixabay**, **Coverr**, **WaveSpeed AI**, **LoomLoom** ou **TwelveLabs**, cada linha **API Key** é mascarada. Clique em **Adicionar outra chave** para guardar várias credenciais da mesma fonte; por exemplo, duas chaves Pixabay e duas Pexels ficam em listas independentes.
410
+
411
+ Clique em **Guardar fonte e chaves** para persistir a fonte activa e as respectivas chaves no storage local. O Thunderbolt remove linhas vazias e duplicadas, mantém compatibilidade com instalações antigas que usavam campos separados e escreve arrays no `config.toml` do MoneyPrinterTurbo para permitir rotação interna. Ao seleccionar **Ficheiros locais**, nenhuma API key é necessária e os materiais são obtidos do storage local.
412
+
413
+ A subaba de fontes não é um painel de tuning: endpoints, proxy, qualidade, correspondência ao roteiro, directório, FFmpeg, codec, Whisper e outros parâmetros técnicos não são expostos ali. A subaba **Serviços e modelos** continua disponível para credenciais de LLM, TTS, Nano Banana, TikTok, Postiz e integrações que não são fontes de materiais. `INNERTUBE_API_KEY` não pertence a esta página; permanece no documento de cada conta em **Configurações > Contas Google**.
414
+
407
415
  ## Gestão de Canais, edição e vídeos recentes
408
416
 
409
417
  Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
@@ -428,11 +436,11 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
428
436
 
429
437
  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.
430
438
 
431
- 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 @danhachuel/thunderbolt@0.2.56 install`; o instalador detecta e reutiliza o que já estiver válido.
439
+ 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 @danhachuel/thunderbolt@0.2.76 install`; o instalador detecta e reutiliza o que já estiver válido.
432
440
 
433
441
  ### Niche Finder Apify
434
442
 
435
- A alternativa Apify não usa o dataset, parâmetros, execução ou estado da alternativa Kaggle. Configure o **Apify API Token**, o **Apify Actor ID**, o intervalo de consulta e o limite da execução em **Configuração API**. Na aba, informe até três palavras-chave, período, máximo de resultados, Shorts, duração, idioma das legendas, ordenação e filtros de legendas. O botão **Pesquisar no Apify** inicia manualmente o actor `streamers~youtube-scraper`, consulta o estado do run, carrega o dataset, normaliza os vídeos, limpa SRT, calcula VSC Ratio, tenta resumir transcrições através do LLM configurado e disponibiliza resultados JSON/CSV. O histórico resumido é guardado em `storage/state/niche_apify_runs.json`; não há gravação automática no Airtable do workflow anexado.
443
+ A alternativa Apify não usa o dataset, parâmetros, execução ou estado da alternativa Kaggle. Configure o **Apify API Token**, o **Apify Actor ID**, o intervalo de consulta e o limite da execução em **Configuração API > API Keys > Serviços e modelos**. Na aba, informe até três palavras-chave, período, máximo de resultados, Shorts, duração, idioma das legendas, ordenação e filtros de legendas. O botão **Pesquisar no Apify** inicia manualmente o actor `streamers~youtube-scraper`, consulta o estado do run, carrega o dataset, normaliza os vídeos, limpa SRT, calcula VSC Ratio, tenta resumir transcrições através do LLM configurado e disponibiliza resultados JSON/CSV. O histórico resumido é guardado em `storage/state/niche_apify_runs.json`; não há gravação automática no Airtable do workflow anexado.
436
444
 
437
445
  ## AI Influencers: Personagens, Redes Sociais e Tutorial Meta
438
446
 
@@ -454,7 +462,7 @@ No modo automático de **Cortes**, a aplicação não inventa uma selecção vir
454
462
 
455
463
  ## Criação de Vídeos — IA editorial e pacotes criativos
456
464
 
457
- O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configuração API > API Keys**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
465
+ O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configuração API > API Keys > Serviços e modelos**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
458
466
 
459
467
  Depois do tópico, clique em **Gerar títulos e thumbnails com IA**. O Thunderbolt solicita pelo menos 20 títulos candidatos e 3–5 variantes de thumbnail, aplica as regras das referências empacotadas em `seed/references/` e guarda a variante escolhida na task. O título aparece pré-preenchido no Upload. A thumbnail guarda conceito, texto de até quatro palavras, composição, paleta, prompt e sinergia com o título; se não existir um provider de imagem configurado, fica com estado **Prompt de thumbnail pronto — imagem pendente de provider de imagem**, sem criar um ficheiro artificial.
460
468
 
@@ -472,7 +480,7 @@ A área **Teste de vozes**, dentro de **Configurações > Configuração API**,
472
480
 
473
481
  ## Upload, Postiz e Upload directo
474
482
 
475
- A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações Postiz, seleccionar o canal ligado e enviar vídeos MP4 através da Public API. Configure **Activar Postiz como fallback final**, **Postiz API key**, **Postiz Public API Base URL**, **Postiz MCP URL** e, opcionalmente, o ID da integração padrão em **Configuração API > API Keys**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
483
+ A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações Postiz, seleccionar o canal ligado e enviar vídeos MP4 através da Public API. Configure **Activar Postiz como fallback final**, **Postiz API key**, **Postiz Public API Base URL**, **Postiz MCP URL** e, opcionalmente, o ID da integração padrão em **Configuração API > API Keys > Serviços e modelos**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
476
484
 
477
485
  O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
478
486
 
@@ -498,7 +506,7 @@ A aba **Limpador de Metadados** recebe somente vídeos externos já prontos. O f
498
506
 
499
507
  A descrição combina **Preview**, **Links** e **Timestamps**, seguindo a estrutura do workflow `YTBMetadataGenerator.json`. O preview recomendado tem entre 100 e 200 caracteres; os capítulos devem começar por `00:00`. O manifesto JSON descarregado contém os campos para um fluxo posterior de upload. Esta adaptação local não faz scraping automático via RSS/Apify nem publica directamente no YouTube.
500
508
 
501
- Se a limpeza falhar, confirme a disponibilidade do FFmpeg em **Configurações > Configuração API > Caminho FFmpeg** ou execute `doctor`. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
509
+ Se a limpeza falhar, execute `doctor` para confirmar a disponibilidade do FFmpeg e valide a instalação de `imageio-ffmpeg`; o caminho técnico é detectado internamente e não é configurado na subaba de fontes. O processo usa `-map_metadata -1` para remover os metadados existentes e `-c copy` para evitar uma re-encodificação desnecessária sempre que o contentor permitir.
502
510
 
503
511
  ## 11. Problemas frequentes
504
512
 
package/README.md CHANGED
@@ -22,12 +22,12 @@ A primeira versão implementa a camada UI independente com:
22
22
  | Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
23
23
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, upload de MP4 para Postiz via API key/MCP configurável, TikTok, Instagram e Facebook Pages no front end |
24
24
  | MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
25
- | Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, Postiz API key/Base URL/MCP URL/integração padrão, TikTok Client ID/Secret e Upload-Post |
25
+ | Configurações | **Contas Google**, **Configuração API** e **Notificações**; API Keys divididas entre **Serviços e modelos** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` no documento da conta, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
26
26
  | Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
27
27
 
28
28
  ## Upload directo — credenciais por conta e por canal
29
29
 
30
- O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
30
+ O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações > Contas Google > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
31
31
 
32
32
  O botão **Adicionar outra conta Gmail** fica abaixo de uma divisória, fora dos cartões existentes. Ao criar uma conta, o Thunderbolt cria imediatamente `credentials.json` com placeholders vazios. Um upload completo ou parcial é incorporado por merge: os cookies enviados actualizam apenas os cookies fornecidos e valores como `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` existentes não são apagados. O alerta **Documento incompleto: SID, SSID, HSID, APISID, SAPISID, sessionInfo, INNERTUBE_API_KEY** permanece visível por conta quando necessário.
33
33
 
@@ -38,11 +38,15 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
38
38
 
39
39
  Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
40
40
 
41
- ## Navegação da UI 0.2.75
41
+ ## Navegação da UI 0.2.76
42
42
 
43
43
  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** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **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** e **Tutorial Meta**, 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**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
44
44
 
45
- Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo`, documentos de Upload directo, `INNERTUBE_API_KEY`, o formulário **Adicionar outra conta Gmail** e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes API Keys, providers, modelos, serviços, materiais, Nano Banana, TikTok, Postiz e o **Teste de vozes**.
45
+ Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo`, documentos de Upload directo, `INNERTUBE_API_KEY`, o formulário **Adicionar outra conta Gmail** e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, Nano Banana, TikTok e Postiz.
46
+
47
+ Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o padrão do MoneyPrinterTurbo: seleccione uma fonte — **Pexels**, **Pixabay**, **Coverr**, **WaveSpeed AI**, **LoomLoom**, **TwelveLabs** ou **Ficheiros locais** — e introduza as API keys dessa fonte. **Adicionar outra chave** cria outra linha para a mesma fonte; as listas são guardadas separadamente por fonte, deduplicadas e exportadas para o `config.toml` do motor como arrays para rotação. A subaba não expõe endpoint, proxy, qualidade, codec, FFmpeg, Whisper, directório ou filtros técnicos: esses valores permanecem internalizados e a fonte local não requer credencial.
48
+
49
+ 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**, dentro da configuração da conta, e não é duplicada em API Keys.
46
50
 
47
51
  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.
48
52
 
@@ -58,7 +62,7 @@ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** usa o feed púb
58
62
 
59
63
  ## Criação de Vídeos — geração editorial por canal
60
64
 
61
- A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o provider LLM configurado localmente em **Configurações Técnicas > API Keys**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo.
65
+ A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o provider LLM configurado localmente em **Configuração API > API Keys > Serviços e modelos**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo.
62
66
 
63
67
  Entre **Canal** e **Estilo wide**, a UI mostra o Blueprint padrão resolvido do canal e a voz associada. Quando não existe configuração, apresenta **SEM BLUEPRINT CONFIGURADO**. Depois do briefing, **Gerar títulos e thumbnails com IA** cria pelo menos 20 candidatos de título e 3–5 variantes de thumbnail. Os títulos seguem as fórmulas e regras de curiosidade, especificidade, emoção, keyword inicial e humanização fornecidas nas referências empacotadas; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
64
68
 
@@ -68,7 +72,7 @@ No modo **Lote geral**, o Thunderbolt remove a selecção parcial e processa aut
68
72
 
69
73
  ## OpenAI/ NVIDIA NIM — descoberta de modelos
70
74
 
71
- Em **Configurações Técnicas > LLM — providers e modelos**, a área anteriormente chamada **OpenAI — API key, Base URL e modelo** passa a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. O campo de modelo manual usa o parâmetro `help` suportado pelas versões do Streamlit instaladas pelo Thunderbolt, evitando o erro de argumento `help_text`. O provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
75
+ Em **Configuração API > API Keys > Serviços e modelos > LLM — providers e modelos**, a área anteriormente chamada **OpenAI — API key, Base URL e modelo** passa a chamar-se **OpenAI/ NVIDIA NIM — API key, Base URL e modelo**. O campo de modelo manual usa o parâmetro `help` suportado pelas versões do Streamlit instaladas pelo Thunderbolt, evitando o erro de argumento `help_text`. O provider interno continua a ser `openai`, para preservar a compatibilidade com o MoneyPrinterTurbo e com os restantes endpoints OpenAI-compatible.
72
76
 
73
77
  A Base URL predefinida para NVIDIA NIM é `https://integrate.api.nvidia.com/v1`. Depois de inserir a API key, clique em **Consultar/actualizar modelos NIM**. O Thunderbolt consulta a Base URL acrescentando `/models`, lê os identificadores do formato OpenAI (`data[].id`) e apresenta-os num selector. O modelo escolhido é guardado em `openai_model_name` e é sincronizado para o `config.toml` do motor.
74
78
 
@@ -165,9 +169,9 @@ thunderbolt
165
169
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
166
170
 
167
171
  ```powershell
168
- npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install
169
- npx.cmd --yes @danhachuel/thunderbolt@0.2.56 doctor
170
- npx.cmd --yes @danhachuel/thunderbolt@0.2.56
172
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install
173
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.76 doctor
174
+ npx.cmd --yes @danhachuel/thunderbolt@0.2.76
171
175
  ```
172
176
 
173
177
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -191,13 +195,13 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
191
195
 
192
196
  A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
193
197
 
194
- **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em Configurações Técnicas. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.56 install`; o instalador detecta e reutiliza componentes já válidos.
198
+ **Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em **Configuração API > API Keys > Serviços e modelos**. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.76 install`; o instalador detecta e reutiliza componentes já válidos.
195
199
 
196
200
  ## Editor Python baseado no PYEdit
197
201
 
198
202
  O **Editor Python** combina o recorte seguro do [PYEdit](https://github.com/Congren/PYEdit) com o armazenamento local do Thunderbolt. Na subaba **Vídeos**, pode escolher um vídeo já gerado e registado nos artefactos da pipeline, indicar qualquer pasta local de vídeos ou fazer upload manual. A página não inicia nenhuma edição ao ser aberta.
199
203
 
200
- As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo. Cada operação cria uma cópia em `storage/python_editor/outputs/`, preserva o original, regista um histórico separado em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. As operações usam FFmpeg local, aproveitando o caminho configurado em **Configurações Técnicas** ou o fallback `imageio-ffmpeg`.
204
+ As operações disponíveis são cortar trecho, remover áudio, extrair áudio, substituir áudio, alterar velocidade e redimensionar vídeo. Cada operação cria uma cópia em `storage/python_editor/outputs/`, preserva o original, regista um histórico separado em `storage/state/python_editor_edits.json` e permite descarregar o resultado e um manifesto JSON. As operações usam FFmpeg local, aproveitando a detecção interna e o fallback `imageio-ffmpeg`; a subaba de fontes não expõe caminhos técnicos.
201
205
 
202
206
  Na subaba **Código Python**, pode criar e guardar scripts em `storage/python_editor/scripts/` ou carregar scripts existentes. Por segurança, a UI não executa código Python, não possui botão de execução e não executa scripts automaticamente.
203
207
 
@@ -284,7 +288,7 @@ O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áu
284
288
 
285
289
  ## Teste de vozes
286
290
 
287
- A área **Configurações > Configurações Técnicas > Teste de vozes** é isolada da pipeline.
291
+ A área **Configurações > Configuração API > Teste de vozes** é isolada da pipeline.
288
292
  Permite escolher Edge/Azure Speech ou um provider HTTP configurado, seleccionar voz/ID, alterar a velocidade, sintetizar uma amostra, reproduzi-la e descarregá-la. O áudio é guardado em `storage/voice_previews/` e nunca altera vídeos ou tarefas. Se uma instalação antiga não tiver `edge-tts`, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o instalador detecta a dependência em falta sem reinstalar componentes válidos.
289
293
 
290
294
  ## MCP e integrações externas opcionais
@@ -302,7 +306,7 @@ As portas iniciais são `3123` para Short Video Maker, `3001` para a API backend
302
306
 
303
307
  A subaba **Vídeos**, dentro de **Criação de Vídeos** ou **Criação de Músicas**, mostra o backlog e permite iniciar/parar tarefas; deixou de existir como botão principal na barra lateral.
304
308
 
305
- A página **Configurações Técnicas** contém apenas o TikTok Client ID e o TikTok Client Secret para TikTok.
309
+ As credenciais do TikTok ficam em **Configuração API > API Keys > Serviços e modelos**, no expander próprio de TikTok.
306
310
  Para YouTube, o bloco principal pede o OAuth Client ID e Client Secret; a Data API Key, se usada, fica numa área opcional separada. Client ID + Client Secret não formam uma Data API Key nem um token OAuth.
307
311
  Redirect URI, scopes, autorização OAuth e tokens são geridos no TikTok for Developers Playground. O adaptador rejeita o upload quando faltam credenciais ou OAuth, em vez de indicar sucesso falso.
308
312
 
package/app/main.py CHANGED
@@ -33,6 +33,7 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
33
33
  from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
34
34
  from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
35
35
  from hermes_ui.mcp_server import server_status, start_server, stop_server
36
+ from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
36
37
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
37
38
  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
38
39
  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
@@ -3238,6 +3239,51 @@ def render_google_accounts():
3238
3239
  st.success("Configuração global do YouTube guardada em Contas Google.")
3239
3240
  st.rerun()
3240
3241
 
3242
+ def render_material_source_api_keys(settings: dict[str, Any]) -> None:
3243
+ st.subheader("Fontes de materiais")
3244
+ st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
3245
+ source_catalog = material_source_catalog()
3246
+ source_codes = [item["code"] for item in source_catalog] + ["local"]
3247
+ source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
3248
+ source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
3249
+ selected_source = st.selectbox(
3250
+ "Fonte de materiais",
3251
+ source_codes,
3252
+ index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
3253
+ format_func=lambda value: source_labels.get(value, value),
3254
+ key="material_source_selector",
3255
+ )
3256
+ st.caption(source_help.get(selected_source, ""))
3257
+ current_keys = material_api_keys(settings, selected_source)
3258
+ row_key = f"material_source_key_rows_{selected_source}"
3259
+ row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
3260
+ if selected_source == "local":
3261
+ st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
3262
+ if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
3263
+ settings["video_source"] = selected_source
3264
+ write_json("settings.json", settings)
3265
+ st.success("Fonte de materiais guardada: ficheiros locais.")
3266
+ st.rerun()
3267
+ return
3268
+ with st.form(f"material_api_keys_form_{selected_source}"):
3269
+ key_values: list[str] = []
3270
+ for index in range(row_count):
3271
+ key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
3272
+ save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
3273
+ add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
3274
+ if add_key:
3275
+ st.session_state[row_key] = row_count + 1
3276
+ st.rerun()
3277
+ if save_keys:
3278
+ update_material_api_keys(settings, selected_source, key_values)
3279
+ settings["video_source"] = selected_source
3280
+ write_json("settings.json", settings)
3281
+ st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
3282
+ st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
3283
+ st.rerun()
3284
+ st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
3285
+
3286
+
3241
3287
  def render_settings():
3242
3288
  st.title("Configuração API")
3243
3289
  st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
@@ -3255,221 +3301,191 @@ def render_settings():
3255
3301
  api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
3256
3302
 
3257
3303
  with api_keys_tab:
3258
- with st.form("settings_form"):
3259
- st.subheader("API Keys")
3260
- port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3261
- moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3262
- with st.expander("Niche Finder execução remota no Kaggle", expanded=True):
3263
- st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3264
- kaggle_cols = st.columns(3)
3265
- with kaggle_cols[0]:
3266
- kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3267
- with kaggle_cols[1]:
3268
- kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3269
- with kaggle_cols[2]:
3270
- kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3271
-
3272
- with st.expander("Niche Finder execução através da Apify", expanded=True):
3273
- st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3274
- apify_cols = st.columns(4)
3275
- with apify_cols[0]:
3276
- apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3277
- with apify_cols[1]:
3278
- apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3279
- with apify_cols[2]:
3280
- apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3281
- with apify_cols[3]:
3282
- apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3283
-
3284
- with st.expander("Serviço, materiais e rede"):
3285
- cols = st.columns(2)
3286
- with cols[0]:
3287
- log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
3288
- listen_host = text_setting("API listen host", "listen_host")
3289
- listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
3290
- video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
3291
- with cols[1]:
3292
- endpoint = text_setting("Endpoint público", "endpoint")
3293
- proxy_http = text_setting("Proxy HTTP", "proxy_http")
3294
- proxy_https = text_setting("Proxy HTTPS", "proxy_https")
3295
- match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
3296
-
3297
- with st.expander("Nano Banana geração de thumbnails", expanded=True):
3298
- st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3299
- nano_cols = st.columns(2)
3300
- with nano_cols[0]:
3301
- gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3302
- gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3303
- with nano_cols[1]:
3304
- gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3305
- gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3306
-
3307
- with st.expander("LLM — providers e modelos", expanded=True):
3308
- provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3309
- llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
3310
- st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3311
- st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3312
- openai_cols = st.columns(3)
3313
- with openai_cols[0]:
3314
- openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3315
- with openai_cols[1]:
3316
- openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3317
- with openai_cols[2]:
3318
- cached_catalog = st.session_state.get("openai_model_catalog", {})
3319
- catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3320
- cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3321
- current_model_name = str(settings.get("openai_model_name", "") or "")
3322
- manual_option = "__manual_model__"
3323
- if cached_models:
3324
- model_options = [manual_option, *cached_models]
3325
- model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3326
- selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3327
- if selected_model == manual_option:
3328
- openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3304
+ api_service_tab, material_sources_tab = st.tabs(["Serviços e modelos", "Fontes de materiais"])
3305
+ with api_service_tab:
3306
+ with st.form("settings_form"):
3307
+ st.subheader("API Keys")
3308
+ port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
3309
+ moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
3310
+ with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
3311
+ st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
3312
+ kaggle_cols = st.columns(3)
3313
+ with kaggle_cols[0]:
3314
+ kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
3315
+ with kaggle_cols[1]:
3316
+ kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
3317
+ with kaggle_cols[2]:
3318
+ kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
3319
+
3320
+ with st.expander("Niche Finder — execução através da Apify", expanded=True):
3321
+ st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
3322
+ apify_cols = st.columns(4)
3323
+ with apify_cols[0]:
3324
+ apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
3325
+ with apify_cols[1]:
3326
+ apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
3327
+ with apify_cols[2]:
3328
+ apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
3329
+ with apify_cols[3]:
3330
+ apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
3331
+
3332
+ with st.expander("Nano Banana — geração de thumbnails", expanded=True):
3333
+ st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
3334
+ nano_cols = st.columns(2)
3335
+ with nano_cols[0]:
3336
+ gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
3337
+ gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
3338
+ with nano_cols[1]:
3339
+ gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
3340
+ gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
3341
+
3342
+ with st.expander("LLM — providers e modelos", expanded=True):
3343
+ provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
3344
+ llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
3345
+ st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
3346
+ st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
3347
+ openai_cols = st.columns(3)
3348
+ with openai_cols[0]:
3349
+ openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
3350
+ with openai_cols[1]:
3351
+ openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
3352
+ with openai_cols[2]:
3353
+ cached_catalog = st.session_state.get("openai_model_catalog", {})
3354
+ catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
3355
+ cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
3356
+ current_model_name = str(settings.get("openai_model_name", "") or "")
3357
+ manual_option = "__manual_model__"
3358
+ if cached_models:
3359
+ model_options = [manual_option, *cached_models]
3360
+ model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
3361
+ selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
3362
+ if selected_model == manual_option:
3363
+ openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
3364
+ else:
3365
+ openai_model_name = selected_model
3329
3366
  else:
3330
- openai_model_name = selected_model
3367
+ openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3368
+ refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3369
+ if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3370
+ st.warning(str(cached_catalog["error"]))
3371
+ elif cached_models:
3372
+ st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3331
3373
  else:
3332
- openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
3333
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
3334
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
3335
- st.warning(str(cached_catalog["error"]))
3336
- elif cached_models:
3337
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
3338
- else:
3339
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3340
- llm_fields = [
3341
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3342
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3343
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3344
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3345
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3346
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3347
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3348
- ]
3349
- for label, prefix, has_key in llm_fields:
3350
- st.markdown(f"**{label}**")
3351
- cols = st.columns(3)
3374
+ st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
3375
+ llm_fields = [
3376
+ ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
3377
+ ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
3378
+ ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
3379
+ ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
3380
+ ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
3381
+ ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
3382
+ ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
3383
+ ]
3384
+ for label, prefix, has_key in llm_fields:
3385
+ st.markdown(f"**{label}**")
3386
+ cols = st.columns(3)
3387
+ with cols[0]:
3388
+ if has_key:
3389
+ settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3390
+ else:
3391
+ settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3392
+ with cols[1]:
3393
+ settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3394
+ with cols[2]:
3395
+ settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3396
+
3397
+ with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3398
+ cols = st.columns(2)
3352
3399
  with cols[0]:
3353
- if has_key:
3354
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
3355
- else:
3356
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
3400
+ azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3401
+ azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3402
+ siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3403
+ minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3404
+ minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3405
+ minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3406
+ minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3357
3407
  with cols[1]:
3358
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
3359
- with cols[2]:
3360
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
3361
-
3362
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
3363
- cols = st.columns(2)
3364
- with cols[0]:
3365
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
3366
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
3367
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
3368
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
3369
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
3370
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
3371
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
3372
- with cols[1]:
3373
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3374
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3375
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3376
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3377
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3378
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3379
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3380
- st.markdown("**Suno agente musical opcional**")
3381
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3382
- suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
3383
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3384
-
3385
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
3386
- cols = st.columns(2)
3387
- with cols[0]:
3388
- pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
3389
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
3390
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
3391
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
3392
- material_directory = text_setting("Pasta de materiais", "material_directory")
3393
- with cols[1]:
3394
- subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
3395
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
3396
- video_codec = text_setting("Codec de vídeo", "video_codec")
3397
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
3398
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
3399
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
3400
-
3401
- with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3402
- st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
3403
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3404
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3405
-
3406
- with st.expander("Publicação através do Upload-Post"):
3407
- upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3408
- upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3409
- upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3410
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3411
- upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3412
-
3413
- with st.expander("Postiz — API key, integração e MCP", expanded=True):
3414
- st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3415
- postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3416
- postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3417
- postiz_cols = st.columns(2)
3418
- with postiz_cols[0]:
3419
- postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3420
- postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3421
- postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3422
- with postiz_cols[1]:
3423
- postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3424
- postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3425
- st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
3426
-
3427
- if refresh_openai_models:
3428
- try:
3429
- discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3430
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3431
- st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3432
- st.rerun()
3433
- except ModelDiscoveryError as exc:
3434
- st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3435
- st.rerun()
3408
+ elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
3409
+ elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
3410
+ chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
3411
+ chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
3412
+ chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
3413
+ sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
3414
+ sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
3415
+ st.markdown("**Suno agente musical opcional**")
3416
+ suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
3417
+ suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
3418
+ suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
3419
+
3420
+ with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
3421
+ st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
3422
+ tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
3423
+ tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
3424
+
3425
+ with st.expander("Publicação através do Upload-Post"):
3426
+ upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
3427
+ upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
3428
+ upload_post_username = text_setting("Upload-Post username", "upload_post_username")
3429
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
3430
+ upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
3431
+
3432
+ with st.expander("Postiz API key, integração e MCP", expanded=True):
3433
+ st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
3434
+ postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
3435
+ postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
3436
+ postiz_cols = st.columns(2)
3437
+ with postiz_cols[0]:
3438
+ postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
3439
+ postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
3440
+ postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
3441
+ with postiz_cols[1]:
3442
+ postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
3443
+ postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
3444
+ st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz é tentado depois da API Oficial e do Upload directo.")
3445
+
3446
+ if refresh_openai_models:
3447
+ try:
3448
+ discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
3449
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
3450
+ st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
3451
+ st.rerun()
3452
+ except ModelDiscoveryError as exc:
3453
+ st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
3454
+ st.rerun()
3436
3455
 
3437
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3438
- if save_all_settings:
3439
- settings.update({
3440
- "port": port, "moneyprinter_path": moneyprinter_path,
3441
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3442
- "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3443
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
3444
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
3445
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3446
- "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3447
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3448
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3449
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3450
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3451
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
3452
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3453
- "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
3454
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
3455
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
3456
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3457
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3458
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3459
- "upload_post_auto_upload": upload_post_auto_upload,
3460
- "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3461
- "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3462
- "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3463
- })
3464
- write_json("settings.json", settings)
3465
- try:
3466
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
3467
- if synced:
3468
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
3469
- else:
3470
- st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3471
- except Exception as exc:
3472
- st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3456
+ save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
3457
+ if save_all_settings:
3458
+ settings.update({
3459
+ "port": port, "moneyprinter_path": moneyprinter_path,
3460
+ "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
3461
+ "apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
3462
+ "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
3463
+ "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
3464
+ "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
3465
+ "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
3466
+ "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
3467
+ "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
3468
+ "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
3469
+ "sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint,
3470
+ "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
3471
+ "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
3472
+ "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
3473
+ "upload_post_auto_upload": upload_post_auto_upload,
3474
+ "postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
3475
+ "postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
3476
+ "postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
3477
+ })
3478
+ write_json("settings.json", settings)
3479
+ try:
3480
+ synced = sync_moneyprinter_config(settings, moneyprinter_path)
3481
+ if synced:
3482
+ st.success(f"Configurações guardadas e sincronizadas com {synced}")
3483
+ else:
3484
+ st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
3485
+ except Exception as exc:
3486
+ st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
3487
+ with material_sources_tab:
3488
+ render_material_source_api_keys(settings)
3473
3489
 
3474
3490
  with voice_test_tab:
3475
3491
  st.subheader("Teste de vozes")
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
7
+ {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
8
+ {"code": "pixabay", "label": "Pixabay", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pixabay_api_keys"},
9
+ {"code": "coverr", "label": "Coverr", "description": "Fonte de vídeos de stock com API própria.", "legacy_key": "coverr_api_keys"},
10
+ {"code": "wavespeed", "label": "WaveSpeed AI", "description": "Geração de clips por IA; requer um serviço configurado.", "legacy_key": "wavespeed_api_keys"},
11
+ {"code": "loomloom", "label": "LoomLoom", "description": "Fonte paga de materiais; requer confirmação no fluxo de criação.", "legacy_key": "loomloom_api_keys"},
12
+ {"code": "twelvelabs", "label": "TwelveLabs", "description": "Ranking e análise semântica opcional dos materiais.", "legacy_key": "twelvelabs_api_keys"},
13
+ )
14
+
15
+
16
+ def _as_key_list(value: Any) -> list[str]:
17
+ if isinstance(value, list):
18
+ values = value
19
+ else:
20
+ values = str(value or "").replace("\n", ",").split(",")
21
+ result: list[str] = []
22
+ seen: set[str] = set()
23
+ for item in values:
24
+ key = str(item or "").strip()
25
+ if key and key not in seen:
26
+ result.append(key)
27
+ seen.add(key)
28
+ return result
29
+
30
+
31
+ def material_source_catalog() -> list[dict[str, str]]:
32
+ return [dict(item) for item in MATERIAL_SOURCE_CATALOG]
33
+
34
+
35
+ def material_api_keys(settings: dict[str, Any], source: str) -> list[str]:
36
+ code = str(source or "").strip().lower()
37
+ saved = settings.get("material_api_keys", {})
38
+ if isinstance(saved, dict) and code in saved:
39
+ return _as_key_list(saved.get(code))
40
+ legacy_key = next((item["legacy_key"] for item in MATERIAL_SOURCE_CATALOG if item["code"] == code), f"{code}_api_keys")
41
+ return _as_key_list(settings.get(legacy_key, ""))
42
+
43
+
44
+ def all_material_api_keys(settings: dict[str, Any]) -> dict[str, list[str]]:
45
+ return {item["code"]: material_api_keys(settings, item["code"]) for item in MATERIAL_SOURCE_CATALOG}
46
+
47
+
48
+ def update_material_api_keys(settings: dict[str, Any], source: str, keys: list[str]) -> dict[str, Any]:
49
+ code = str(source or "").strip().lower()
50
+ if code not in {item["code"] for item in MATERIAL_SOURCE_CATALOG}:
51
+ raise ValueError("Fonte de materiais inválida.")
52
+ cleaned = _as_key_list(keys)
53
+ mapping = all_material_api_keys(settings)
54
+ mapping[code] = cleaned
55
+ settings["material_api_keys"] = mapping
56
+ source_entry = next(item for item in MATERIAL_SOURCE_CATALOG if item["code"] == code)
57
+ settings[source_entry["legacy_key"]] = cleaned
58
+ return settings
59
+
60
+
61
+ def selected_material_source(settings: dict[str, Any]) -> str:
62
+ source = str(settings.get("video_source") or "pexels").strip().lower()
63
+ valid = {item["code"] for item in MATERIAL_SOURCE_CATALOG} | {"local"}
64
+ return source if source in valid else "pexels"
@@ -197,9 +197,12 @@ DEFAULTS: dict[str, Any] = {
197
197
  "endpoint": "",
198
198
  "proxy_http": "",
199
199
  "proxy_https": "",
200
+ "material_api_keys": {},
200
201
  "pexels_api_keys": "",
201
202
  "pixabay_api_keys": "",
202
203
  "coverr_api_keys": "",
204
+ "wavespeed_api_keys": "",
205
+ "loomloom_api_keys": "",
203
206
  "twelvelabs_api_keys": "",
204
207
  "sonilo_api_key": "",
205
208
  "subtitle_provider": "edge",
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any
5
5
 
6
+ from hermes_ui.material_sources import all_material_api_keys, selected_material_source
7
+
6
8
  try:
7
9
  import toml
8
10
  except ImportError: # pragma: no cover - installation fallback
@@ -112,9 +114,13 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
112
114
  for settings_key, config_key in app_map.items():
113
115
  if settings_key in settings:
114
116
  app[config_key] = settings[settings_key]
115
- for key in ("pexels_api_keys", "pixabay_api_keys", "coverr_api_keys", "twelvelabs_api_keys"):
116
- if key in settings:
117
- app[key] = _list_value(settings[key])
117
+ # A UI can store the canonical mapping while older installations still
118
+ # expose one legacy field per source. Export every supported source as a
119
+ # TOML array so MoneyPrinterTurbo can rotate keys without CSV parsing.
120
+ canonical_material_keys = all_material_api_keys(settings)
121
+ for source, keys in canonical_material_keys.items():
122
+ app[f"{source}_api_keys"] = _list_value(keys)
123
+ app["video_source"] = selected_material_source(settings)
118
124
  config["app"] = app
119
125
 
120
126
  whisper["model_size"] = settings.get("whisper_model_size", whisper.get("model_size", "large-v3"))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.75",
3
+ "version": "0.2.76",
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",