@danhachuel/thunderbolt 0.3.43 → 0.3.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
4
4
 
5
- > **Versão deste manual:** 0.3.43
5
+ > **Versão deste manual:** 0.3.45
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.43 install
103
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.45 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.43 install
109
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.45 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.43 install --purge-data
117
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.45 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.
@@ -425,9 +425,9 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
425
425
 
426
426
  Ao clicar em **Start** no **Backlog Vídeos**, a tarefa passa para `doing` e é processada pelo worker de pipeline iniciado pelo launcher normal. O worker grava o estado em `storage/state/pipeline_worker.json` e actualiza `updated_at`, a etapa e a percentagem em `storage/state/tasks.json`. O painel do Backlog consulta esse estado automaticamente a cada cinco segundos e mostra o worker, a etapa corrente, a percentagem, a idade da actualização e a mensagem de erro quando existe.
427
427
 
428
- A percentagem representa o avanço conhecido do pipeline: tema, roteiro, título/keywords, thumbnail, vídeo e upload. Durante a chamada longa ao MoneyPrinterTurbo, a UI mantém um heartbeat a cada cinco segundos e avança apenas dentro da faixa reservada à geração do vídeo; não apresenta 100% antes de existir um MP4 válido. O worker passa explicitamente a **Pasta do motor de vídeo** configurada na UI ao helper, por isso o clone usado, os logs e o manifesto pertencem à instalação seleccionada pelo utilizador.
428
+ A percentagem representa o avanço conhecido do pipeline: tema, roteiro, título/keywords, vídeo, prompt da thumbnail, thumbnail e upload. Durante a chamada longa ao MoneyPrinterTurbo, a UI mantém um heartbeat a cada cinco segundos e avança apenas dentro da faixa reservada à geração do vídeo; quando o MP4 é devolvido, o artefacto é persistido antes da geração de imagem. O worker passa explicitamente a **Pasta do motor de vídeo** configurada na UI ao helper, por isso o clone usado, os logs e o manifesto pertencem à instalação seleccionada pelo utilizador.
429
429
 
430
- Uma execução da etapa Vídeo tem limite de 20 minutos. Se o processo externo terminar com erro, exceder o limite, devolver um resultado inválido ou ocorrer uma excepção inesperada, a tarefa é marcada como `failed`, com a etapa em `failed_stage`. As últimas linhas devolvidas pelo helper, sem as credenciais configuradas, são guardadas num artefacto `video-diagnostics` e as referências `video_log`/`video_result` ficam associadas à tarefa. Se o utilizador clicar em **Stop**, a tarefa passa para `blocked`, o subprocesso é terminado cooperativamente e o worker não substitui esse estado por `failed`.
430
+ Uma execução da etapa Vídeo tem limite de 20 minutos. Depois de um MP4 válido, a tarefa fica marcada com `video_ready` e o artefacto é guardado antes da chamada de imagem. Se a quota do provider de thumbnail for excedida, a tarefa pode ser marcada como `failed` na etapa `thumbnail`, mas o vídeo mantém-se disponível em `artifacts.video` para descarga no Backlog, utilização no Upload ou publicação directa depois de carregar uma thumbnail manual. Se o processo externo de vídeo terminar com erro, exceder o limite, devolver um resultado inválido ou ocorrer uma excepção inesperada, a tarefa é marcada como `failed`, com a etapa em `failed_stage`. As últimas linhas devolvidas pelo helper, sem as credenciais configuradas, são guardadas num artefacto `video-diagnostics` e as referências `video_log`/`video_result` ficam associadas à tarefa. Se o utilizador clicar em **Stop**, a tarefa passa para `blocked`, o subprocesso é terminado cooperativamente e o worker não substitui esse estado por `failed`.
431
431
 
432
432
  Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem actualização durante 25 minutos é recuperada e marcada como `failed`, evitando estados indefinidos eternos. Para processar novas tarefas, deixe a aplicação iniciada com o comando normal; o painel avisa quando não existe heartbeat recente do worker.
433
433
 
@@ -473,8 +473,8 @@ Após iniciar a aplicação, valide o seguinte percurso:
473
473
  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.
474
474
  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**.
475
475
  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**.
476
- 7. **Criação de Vídeos:** depois de seleccionar o canal, confirme que o painel entre **Canal** e **Estilo wide** mostra o Blueprint, a voz e o idioma, ou **SEM BLUEPRINT CONFIGURADO**. Confirme as quatro áreas fechadas por defeito: **Configurações de vídeo**, **Configurações de áudio**, **Configurações de legendas** e **Gerar Thumbnail com IA**. Depois de existir um tópico e um título inicial, clique em **Gerar Thumbnail com IA**, confirme que apenas o prompt/briefing de thumbnail é criado e que o título permanece igual. Em lote no mesmo canal, confirme uma variante independente para cada vídeo; escolha uma variante, gere a imagem se houver provider configurado 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.
477
- 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 depois **Gerar Thumbnail com IA para todos os vídeos**; confirme que a segunda acção não regenera títulos.
476
+ 7. **Criação de Vídeos:** depois de seleccionar o canal, confirme que o painel entre **Canal** e **Estilo wide** mostra o Blueprint, a voz e o idioma, ou **SEM BLUEPRINT CONFIGURADO**. Confirme as quatro áreas fechadas por defeito: **Configurações de vídeo**, **Configurações de áudio**, **Configurações de legendas** e **Gerar Thumbnail com IA**. A execução do worker deve seguir **Tema Roteiro Título Keywords Vídeo Prompt da thumbnail em JSON Thumbnail Upload**. Inicie a tarefa e confirme no Backlog que o MP4 é guardado e descarregável antes da etapa de imagem; se a quota da thumbnail falhar, a tarefa deve conservar `artifacts.video` e `video_ready`. Depois, carregue manualmente a imagem em **Thumbnails** ou gere-a quando houver provider disponível. Em lote no mesmo canal, confirme uma variante independente por vídeo. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
477
+ 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 e Blueprint próprios; o prompt e a imagem da thumbnail podem ser preparados depois de o vídeo existir. Use **Gerar tópicos individuais para todos os canais** e, quando desejado, **Gerar Thumbnail com IA para todos os vídeos**; confirme que a segunda acção não regenera títulos.
478
478
  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.
479
479
  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.
480
480
  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.
@@ -522,7 +522,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
522
522
 
523
523
  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.
524
524
 
525
- 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.43 install`; o instalador detecta e reutiliza o que já estiver válido.
525
+ 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.45 install`; o instalador detecta e reutiliza o que já estiver válido.
526
526
 
527
527
  ### Niche Finder Apify
528
528
 
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.43
72
+ ## Navegação da UI 0.3.45
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
 
@@ -93,7 +93,7 @@ No bloco **Limite LLM NVIDIA NIM**, a opção **Activar limitador NVIDIA NIM —
93
93
 
94
94
  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.
95
95
 
96
- 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.
96
+ 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**.
97
97
 
98
98
  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.
99
99
 
@@ -101,7 +101,7 @@ A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https:
101
101
 
102
102
  **Backlog Vídeos** e **Automação Youtube > Vídeos cadastrados** usam o mesmo catálogo completo persistido em `storage/state/tasks.json`. Assim, todas as tarefas criadas manualmente ou pela automação diária aparecem nos dois fluxos, sem duplicação nem filtros implícitos por origem. O filtro de estado do Backlog mantém os estados conhecidos e acrescenta estados novos encontrados no storage.
103
103
 
104
- Os dois cards mostram de forma consistente o estado técnico, o rótulo legível, a barra de progresso, a etapa e o formato do vídeo. São suportados estados como `to_do`, `doing`, `blocked`, `done`, `failed` e `cancelled`; formatos ausentes usam `format`, `style_wide` ou `wide` como fallback.
104
+ Os dois cards mostram de forma consistente o estado técnico, o rótulo legível, a barra de progresso, a etapa e o formato do vídeo. São suportados estados como `to_do`, `doing`, `blocked`, `done`, `failed` e `cancelled`; formatos ausentes usam `format`, `style_wide` ou `wide` como fallback. Quando existe `artifacts.video`, o Backlog mostra que o vídeo está pronto e disponibiliza a descarga do MP4 mesmo que a thumbnail esteja pendente.
105
105
 
106
106
  ## Canais Youtube — edição por cartão e vídeos recentes
107
107
 
@@ -117,7 +117,9 @@ A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou brief
117
117
 
118
118
  Entre **Canal** e **Estilo wide**, a UI mostra o Blueprint padrão resolvido do canal, a voz associada e o **Idioma** configurado nesse canal. Ao trocar o canal, o selector **Script Language** é sincronizado automaticamente com o código canónico do novo canal, incluindo a conversão de rótulos legados como `Português` ou `36 – Português (Brasil)`. O valor sincronizado é usado no roteiro, keywords, geração criativa e payload da tarefa; o idioma global só serve como fallback quando não existe canal seleccionado. Quando não existe configuração, apresenta **SEM BLUEPRINT CONFIGURADO** e mantém o idioma do canal visível. Depois do tópico e do título inicial, **Gerar Thumbnail com IA** gera apenas um briefing/prompt de thumbnail e preserva o título existente, sem chamar o gerador de títulos. Em lotes no mesmo canal, é gerada uma variante independente por vídeo e o payload correspondente é atribuído a cada task. A geração da imagem final passa pelo **Pool Imagem**, com failover para os cartões activos elegíveis; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
119
119
 
120
- O pacote criativo é persistido na task. O título escolhido fica pré-preenchido no Upload e a thumbnail é usada quando existe um ficheiro em `artifacts.thumbnail`. Sem provider de imagem configurado, a aplicação conserva o prompt e apresenta **Prompt de thumbnail pronto imagem pendente de provider de imagem**, sem criar um PNG falso.
120
+ A cascata de criação no worker segue agora a ordem **Tema Roteiro Título Keywords → Vídeo → Prompt da thumbnail em JSON Thumbnail (gerar imagem) → Upload**. O vídeo é guardado em `artifacts.video` e marcado como `video_ready` antes de qualquer chamada ao provider de imagem. Se a quota da imagem for excedida, a tarefa pode ficar em falha na etapa Thumbnail, mas o MP4 permanece pronto no Backlog para descarregar, utilizar no Upload ou editar a thumbnail posteriormente.
121
+
122
+ O pacote editorial é persistido na task. O título e as keywords são preparados antes do vídeo; o prompt JSON e a variante visual são criados depois de existir um MP4 válido. A thumbnail é usada no Upload quando existe um ficheiro em `artifacts.thumbnail`. Sem provider de imagem configurado, a aplicação conserva o prompt e apresenta **Prompt de thumbnail pronto — imagem pendente de provider de imagem**, sem criar um PNG falso. A página **Thumbnails** continua a permitir gerar a imagem ou carregar manualmente uma PNG, JPG, JPEG ou WEBP para a tarefa.
121
123
 
122
124
  No modo **Lote geral**, o Thunderbolt remove a selecção parcial e processa automaticamente todos os canais cadastrados. **Gerar Thumbnail com IA para todos os vídeos** percorre os tópicos individuais já preparados e gera apenas uma thumbnail por canal/vídeo, sem regenerar títulos. É criada exactamente uma tarefa por canal, com briefing, título, thumbnail, Blueprint, voz e contexto próprios; o mesmo tópico nunca é reutilizado para todos os canais. A Automação diária usa o mesmo serviço no horário local de cada canal e não cria o placeholder antigo quando o provider LLM não está configurado.
123
125
 
@@ -224,9 +226,9 @@ thunderbolt
224
226
  No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
225
227
 
226
228
  ```powershell
227
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.43 install
228
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.43 doctor
229
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.43
229
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 install
230
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44 doctor
231
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.44
230
232
  ```
231
233
 
232
234
  Como alternativa, pode permitir scripts para o seu utilizador:
@@ -250,7 +252,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
250
252
 
251
253
  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.
252
254
 
253
- **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 --prefer-online @danhachuel/thunderbolt@0.3.43 install`; o instalador detecta e reutiliza componentes já válidos.
255
+ **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 --prefer-online @danhachuel/thunderbolt@0.3.44 install`; o instalador detecta e reutiliza componentes já válidos.
254
256
 
255
257
  ## Editor Python baseado no PYEdit
256
258
 
package/app/main.py CHANGED
@@ -62,7 +62,7 @@ from hermes_ui.thumbnails import (
62
62
  upload_thumbnail_image,
63
63
  )
64
64
  from hermes_ui.draft_video import DRAFT_SETTING_SECTIONS, missing_content_fields, missing_setting_sections, normalise_saved_script, setting_widget_suffixes
65
- from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_topic_for_channel, generate_video_keywords
65
+ from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_thumbnail_prompt, generate_title_and_keywords, generate_topic_for_channel, generate_video_keywords
66
66
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
67
67
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
68
68
  from integrations.postiz import PostizAdapter
@@ -530,6 +530,7 @@ def creative_payload_from_result(channel: dict, topic: str, creative: dict, topi
530
530
  "topic_source": topic_source,
531
531
  "title": str(creative.get("title") or topic).strip(),
532
532
  "title_candidates": creative.get("title_candidates") or [],
533
+ "keywords": creative.get("keywords") or [],
533
534
  "thumbnail_variant": variant,
534
535
  "thumbnail_variants": creative.get("thumbnail_variants") or [],
535
536
  "thumbnail_prompt": str(variant.get("image_prompt") or ""),
@@ -718,6 +719,32 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
718
719
  return payload
719
720
 
720
721
 
722
+ def generate_editorial_for_ui(settings: dict[str, Any], channel: dict, topic: str, topic_source: str = "manual") -> dict[str, Any]:
723
+ """Generate title and keywords only; thumbnail prompt/image stay in the later pipeline stages."""
724
+ editorial = generate_title_and_keywords(
725
+ settings,
726
+ channel,
727
+ topic,
728
+ blueprint_for_channel(channel),
729
+ language=str(channel.get("language") or "Português"),
730
+ )
731
+ keywords = [str(item).strip() for item in editorial.get("keywords", []) if str(item).strip()]
732
+ return {
733
+ "topic": topic.strip(),
734
+ "topic_source": topic_source or "manual",
735
+ "title": str(editorial.get("title") or topic).strip(),
736
+ "title_candidates": editorial.get("title_candidates") or [],
737
+ "keywords": keywords[:15],
738
+ "thumbnail_variant": {},
739
+ "thumbnail_variants": [],
740
+ "thumbnail_prompt": "",
741
+ "thumbnail_text": "",
742
+ "thumbnail_status": "pending_prompt",
743
+ "generation_settings": {"video_keywords": keywords[:15]},
744
+ "ai_generation": {"editorial": editorial},
745
+ }
746
+
747
+
721
748
  def generate_thumbnail_for_ui(
722
749
  settings: dict[str, Any],
723
750
  channel: dict[str, Any],
@@ -2383,7 +2410,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2383
2410
  edited_topic = topic_result["topic"]
2384
2411
  else:
2385
2412
  topic_result = {**topic_result, "topic": edited_topic, "topic_source": topic_result.get("topic_source", "manual")}
2386
- generated = generate_creative_for_ui(settings, channel, edited_topic, topic_source=topic_result.get("topic_source", "llm"))
2413
+ generated = generate_editorial_for_ui(settings, channel, edited_topic, topic_source=topic_result.get("topic_source", "llm"))
2387
2414
  generated["ai_generation"]["topic"] = topic_result
2388
2415
  generated_payloads[channel_id] = generated
2389
2416
  except CreativeGenerationError as exc:
@@ -2414,14 +2441,14 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2414
2441
  payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
2415
2442
  if not payload.get("title") or not payload.get("thumbnail_variants"):
2416
2443
  try:
2417
- payload = generate_creative_for_ui(
2444
+ payload = generate_editorial_for_ui(
2418
2445
  read_json("settings.json", {}),
2419
2446
  selected_one or {},
2420
2447
  topic_value,
2421
2448
  topic_source=_video_topic_source(topic_value, prefix),
2422
2449
  )
2423
2450
  except CreativeGenerationError as exc:
2424
- st.warning(f"Título/thumbnail automáticos pendentes: {exc} A tarefa será criada com o tópico como título e sem ficheiro de thumbnail.")
2451
+ st.warning(f"Título/keywords automáticos pendentes: {exc} A tarefa será criada com o tópico como título; o prompt e a imagem da thumbnail serão gerados depois do vídeo.")
2425
2452
  payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
2426
2453
  payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or _video_topic_source(topic_value), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings})
2427
2454
  batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
@@ -3453,13 +3480,28 @@ def render_videos():
3453
3480
  st.write(f"**{task.get('title') or task.get('topic', 'Sem título')}**")
3454
3481
  st.caption(f"Tópico: {task.get('topic', 'Sem tópico')}")
3455
3482
  st.caption(f"{task.get('channel_name')} · {task.get('id')}")
3456
- thumbnail_path = (task.get('artifacts') or {}).get('thumbnail', '')
3483
+ artifacts = task.get('artifacts') or {}
3484
+ thumbnail_path = artifacts.get('thumbnail', '')
3457
3485
  if thumbnail_path and Path(thumbnail_path).is_file():
3458
3486
  st.image(thumbnail_path, width=180)
3459
3487
  else:
3460
3488
  status = task.get('thumbnail_status', 'not_generated')
3461
3489
  prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
3462
3490
  st.caption(f"Thumbnail: {status}{prompt_note}")
3491
+ video_path = str(artifacts.get('video') or '').strip()
3492
+ if video_path and Path(video_path).is_file():
3493
+ video_file = Path(video_path)
3494
+ st.success('Vídeo pronto; a thumbnail pode ser criada ou carregada depois.')
3495
+ st.download_button(
3496
+ 'Descarregar vídeo pronto',
3497
+ data=video_file.read_bytes(),
3498
+ file_name=video_file.name,
3499
+ mime='video/mp4',
3500
+ key=f"pipeline_video_download_{task['id']}",
3501
+ use_container_width=True,
3502
+ )
3503
+ elif video_path:
3504
+ st.caption(f'Vídeo registado: {video_path}')
3463
3505
  with cols[1]:
3464
3506
  st.caption("Formato")
3465
3507
  st.write(_video_task_format(task))
@@ -5597,18 +5639,21 @@ def render_logs():
5597
5639
  st.dataframe(
5598
5640
  rows,
5599
5641
  use_container_width=True,
5642
+ height=520,
5600
5643
  hide_index=True,
5601
5644
  column_config={
5602
- "Operação": st.column_config.TextColumn("Operação", width="medium"),
5603
- "Estado": st.column_config.TextColumn("Estado", width="small"),
5604
- "Data": st.column_config.TextColumn("Data", width="small"),
5605
- "Hora": st.column_config.TextColumn("Hora", width="small"),
5606
- "Registo": st.column_config.TextColumn("Registo", width="medium"),
5607
- "Origem": st.column_config.TextColumn("Origem", width="small"),
5608
- "Progresso": st.column_config.TextColumn("Progresso", width="small"),
5609
- "Detalhes": st.column_config.TextColumn("Detalhes", width="large"),
5645
+ # Fixed minimum widths keep the long Detalhes column navigable on narrow screens.
5646
+ "Operação": st.column_config.TextColumn("Operação", width=190),
5647
+ "Estado": st.column_config.TextColumn("Estado", width=115),
5648
+ "Data": st.column_config.TextColumn("Data", width=105),
5649
+ "Hora": st.column_config.TextColumn("Hora", width=105),
5650
+ "Registo": st.column_config.TextColumn("Registo", width=280),
5651
+ "Origem": st.column_config.TextColumn("Origem", width=130),
5652
+ "Progresso": st.column_config.TextColumn("Progresso", width=100),
5653
+ "Detalhes": st.column_config.TextColumn("Detalhes", width=760),
5610
5654
  },
5611
5655
  )
5656
+ st.caption("Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
5612
5657
 
5613
5658
 
5614
5659
  def render_notifications():
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  from . import storage
11
- from .creative_generation import generate_creative_package, generate_topic_for_channel
11
+ from .creative_generation import generate_title_and_keywords, generate_topic_for_channel
12
12
  from .domain import create_batch, create_tasks_for_batch
13
13
  from .notifications import record_notification
14
14
 
@@ -149,28 +149,28 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
149
149
  blueprint = _blueprint_for_channel(channel)
150
150
  user_context = str(channel.get("automation_topic") or "").strip()
151
151
  topic_package = generate_topic_for_channel(settings, channel, blueprint, user_context=user_context)
152
- creative = generate_creative_package(
152
+ editorial = generate_title_and_keywords(
153
153
  settings,
154
154
  channel,
155
155
  topic_package["topic"],
156
156
  blueprint,
157
157
  language=str(channel.get("language") or "Português"),
158
158
  )
159
- variant = creative["thumbnail_variant"]
160
159
  payload = {
161
160
  "topic": topic_package["topic"],
162
161
  "topic_source": "llm",
163
- "title": creative["title"],
164
- "title_candidates": creative["title_candidates"],
165
- "thumbnail_variant": variant,
166
- "thumbnail_variants": creative["thumbnail_variants"],
167
- "thumbnail_prompt": variant.get("image_prompt", ""),
168
- "thumbnail_text": variant.get("overlay_text", ""),
169
- "thumbnail_status": creative.get("thumbnail_status", "prompt_ready"),
162
+ "title": editorial["title"],
163
+ "title_candidates": editorial["title_candidates"],
164
+ "thumbnail_variant": {},
165
+ "thumbnail_variants": [],
166
+ "thumbnail_prompt": "",
167
+ "thumbnail_text": "",
168
+ "thumbnail_status": "pending_prompt",
170
169
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
171
170
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
172
171
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
173
- "ai_generation": {"topic": topic_package, "creative": creative},
172
+ "generation_settings": {"video_keywords": editorial.get("keywords", [])},
173
+ "ai_generation": {"topic": topic_package, "editorial": editorial},
174
174
  }
175
175
  return topic_package["topic"], payload
176
176
 
@@ -299,6 +299,76 @@ def generate_thumbnail_prompt(
299
299
  }
300
300
 
301
301
 
302
+ def generate_title_and_keywords(
303
+ settings: dict[str, Any],
304
+ channel: dict[str, Any],
305
+ topic: str,
306
+ blueprint: dict[str, Any] | None = None,
307
+ language: str = "",
308
+ ) -> dict[str, Any]:
309
+ """Generate the editorial title and SEO keywords without generating thumbnail prompts."""
310
+ if not topic.strip():
311
+ raise CreativeGenerationError("É necessário um tópico ou briefing antes de gerar título e keywords.")
312
+ context = channel_context(channel, blueprint)
313
+ system = (
314
+ "És um director editorial para YouTube. Cria apenas o pacote editorial do vídeo: títulos candidatos e keywords SEO. "
315
+ "Não cries prompts, conceitos ou variantes de thumbnail. Gera exactamente pelo menos 20 títulos candidatos, "
316
+ "um título seleccionado e entre 8 e 15 keywords curtas. O título deve carregar keywords no início, ter curiosidade, "
317
+ "especificidade e emoção, sem clickbait falso. Responde apenas com JSON válido nas chaves selected_title, "
318
+ "title_candidates e keywords."
319
+ )
320
+ user = json.dumps(
321
+ {
322
+ "channel": context,
323
+ "language": language or context["language"],
324
+ "topic": topic.strip(),
325
+ "reference_rules": reference_bundle(),
326
+ "keywords_schema": ["lista de 8 a 15 keywords SEO curtas, sem hashtags"],
327
+ "title_candidates_schema": {
328
+ "title": "string",
329
+ "formula": "string",
330
+ "curiosity_score": "integer 0-3",
331
+ "specificity_score": "integer 0-3",
332
+ "emotional_score": "integer 0-3",
333
+ },
334
+ },
335
+ ensure_ascii=False,
336
+ )
337
+ result = _chat_json(settings, system, user)
338
+ raw_titles = result.get("title_candidates")
339
+ if not isinstance(raw_titles, list) or len(raw_titles) < 20:
340
+ raise CreativeGenerationError("O provider deve devolver pelo menos 20 títulos candidatos.")
341
+ titles: list[dict[str, Any]] = []
342
+ for item in raw_titles[:30]:
343
+ if not isinstance(item, dict) or not str(item.get("title") or "").strip():
344
+ continue
345
+ titles.append(
346
+ {
347
+ "title": str(item["title"]).strip(),
348
+ "formula": str(item.get("formula") or "custom").strip(),
349
+ "curiosity_score": _score(item.get("curiosity_score")),
350
+ "specificity_score": _score(item.get("specificity_score")),
351
+ "emotional_score": _score(item.get("emotional_score")),
352
+ }
353
+ )
354
+ if len(titles) < 20:
355
+ raise CreativeGenerationError("Os títulos devolvidos pelo provider não têm conteúdo suficiente.")
356
+ selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
357
+ if selected_title not in {item["title"] for item in titles}:
358
+ selected_title = titles[0]["title"]
359
+ raw_keywords = result.get("keywords")
360
+ keywords = [str(item).strip() for item in raw_keywords if str(item).strip()] if isinstance(raw_keywords, list) else []
361
+ if not keywords:
362
+ keywords = _keywords_from_text(topic, selected_title)
363
+ return {
364
+ "title": selected_title,
365
+ "title_candidates": titles,
366
+ "keywords": keywords[:15],
367
+ "topic_source": "llm",
368
+ "generated_by": "configured_llm",
369
+ }
370
+
371
+
302
372
  def generate_creative_package(
303
373
  settings: dict[str, Any],
304
374
  channel: dict[str, Any],
@@ -7,7 +7,8 @@ from typing import Any
7
7
  from .notifications import record_notification
8
8
  from .storage import append_json, now, read_json, write_json
9
9
 
10
- STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "thumbnail_prompt", "thumbnail", "video", "edit", "upload"]
10
+ STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload"]
11
+ LEGACY_STAGES = {"edit"}
11
12
  VALID_STATES = {"to_do", "doing", "blocked", "done", "failed", "cancelled"}
12
13
 
13
14
 
@@ -176,10 +177,12 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
176
177
  "thumbnail_text": thumbnail_text,
177
178
  "thumbnail_status": thumbnail_status,
178
179
  "title_candidates": payload.get("title_candidates", []),
180
+ "keywords": payload.get("keywords", options.get("keywords", [])),
179
181
  "ai_generation": payload.get("ai_generation", {}),
180
182
  "stage": "script",
181
183
  "state": "to_do",
182
184
  "progress": 0,
185
+ "video_ready": bool(artifacts.get("video")),
183
186
  "artifacts": artifacts,
184
187
  "error": None,
185
188
  "created_at": now(),
@@ -266,7 +269,7 @@ def transition_task(task_id: str, state: str | None = None, stage: str | None =
266
269
  if state:
267
270
  task["state"] = state
268
271
  if stage:
269
- if stage not in STAGES:
272
+ if stage not in STAGES and stage not in LEGACY_STAGES:
270
273
  raise ValueError(f"Etapa inválida: {stage}")
271
274
  task["stage"] = stage
272
275
  if error is not None:
@@ -12,14 +12,14 @@ from pathlib import Path
12
12
  from typing import Any
13
13
 
14
14
  from integrations.upload_routing import upload_with_default_route
15
- from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
15
+ from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_title_and_keywords, generate_thumbnail_prompt, generate_topic_for_channel
16
16
  from hermes_ui.script_documents import save_script_document
17
17
  from hermes_ui.script_generation import generate_script_document
18
18
  from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
19
19
  from hermes_ui.llm_providers import active_llm_card, provider_definition
20
20
  from hermes_ui.media_generation import MediaGenerationError, generate_image_from_pool, generate_video_from_pool
21
21
  from hermes_ui.media_providers import media_cards_for_pool
22
- from hermes_ui.thumbnail_generation import generate_thumbnail_image
22
+ from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
23
23
 
24
24
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
25
25
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
@@ -401,7 +401,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
401
401
  # O helper expõe o resultado final, mas não uma percentagem estável.
402
402
  # Mantemos uma faixa reservada para a etapa de vídeo e avançamos-a
403
403
  # lentamente enquanto o processo responde, sem fingir conclusão.
404
- video_progress = min(79, 68 + int(elapsed // 15))
404
+ video_progress = min(79, 52 + int(elapsed // 15))
405
405
  current_task = _task_by_id(task_id)
406
406
  if current_task and str(current_task.get("state") or "") in {"blocked", "cancelled"}:
407
407
  _stop_process(process)
@@ -503,37 +503,87 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
503
503
 
504
504
  _update(task_id, stage="title", state="doing", progress=35)
505
505
  provided_title = str(task.get("title") or "").strip()
506
+ provided_keywords = generation_settings.get("video_keywords") or task.get("keywords")
507
+ if isinstance(provided_keywords, str):
508
+ provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
509
+ provided_keywords = [str(item).strip() for item in provided_keywords or [] if str(item).strip()]
510
+ title_candidates = task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else []
511
+ editorial: dict[str, Any] = {
512
+ "title": provided_title or topic,
513
+ "title_candidates": title_candidates,
514
+ "keywords": provided_keywords,
515
+ }
516
+ if not provided_title:
517
+ try:
518
+ editorial = generate_title_and_keywords(
519
+ settings,
520
+ channel,
521
+ topic,
522
+ blueprint,
523
+ language=str(task.get("language") or channel.get("language") or "Português"),
524
+ )
525
+ except CreativeGenerationError:
526
+ # A falha editorial não deve impedir a geração do vídeo: usa o tema
527
+ # como título e keywords determinísticas, deixando o erro de imagem
528
+ # para a etapa posterior e independente da criação do MP4.
529
+ editorial = {"title": topic, "title_candidates": [], "keywords": []}
530
+ title = str(editorial.get("title") or provided_title or topic).strip()
531
+ keywords = provided_keywords[:15] or (
532
+ editorial.get("keywords") if isinstance(editorial.get("keywords"), list) else []
533
+ )
534
+ keywords = [str(item).strip() for item in keywords if str(item).strip()][:15] or _keywords(
535
+ topic,
536
+ title,
537
+ str(channel.get("niche") or ""),
538
+ )
539
+ title_candidates = editorial.get("title_candidates") if isinstance(editorial.get("title_candidates"), list) else title_candidates
540
+ title_artifact = _save_json_artifact(
541
+ task_id,
542
+ "title-keywords",
543
+ {"topic": topic, "title": title, "keywords": keywords, "title_candidates": title_candidates},
544
+ )
545
+ artifacts = {**artifacts, "title_keywords": title_artifact}
546
+ _update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, progress=45)
547
+
548
+ _update(task_id, stage="keywords", state="doing", progress=48)
549
+ _update(task_id, tags=keywords, progress=50)
550
+
551
+ # O vídeo é deliberadamente concluído antes de qualquer chamada ao provider
552
+ # de imagem. O artefacto fica persistido mesmo que a quota da thumbnail falhe.
553
+ _update(task_id, stage="video", state="doing", progress=52, error=None)
554
+ video_cards = media_cards_for_pool(settings, "video")
555
+ if bool(settings.get("media_video_pool_enabled")) and video_cards:
556
+ try:
557
+ video_prompt = f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}"
558
+ video_path = generate_video_from_pool(settings, video_prompt)
559
+ except MediaGenerationError as exc:
560
+ raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
561
+ else:
562
+ video_path = _run_video_helper({**task, "topic": topic, "title": title})
563
+ current_after_video = _task_by_id(task_id) or {}
564
+ artifacts = dict(current_after_video.get("artifacts") or artifacts)
565
+ artifacts["video"] = str(video_path)
566
+ _update(task_id, artifacts=artifacts, video_ready=True, progress=80)
567
+
568
+ _update(task_id, stage="thumbnail_prompt", state="doing", progress=82, error=None)
506
569
  existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
507
570
  existing_variant = dict(existing_variant)
508
571
  if not str(existing_variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
509
572
  existing_variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
510
573
  if not str(existing_variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
511
574
  existing_variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
512
- prepared_thumbnail = bool(str(existing_variant.get("image_prompt") or "").strip())
513
- if provided_title and prepared_thumbnail:
514
- # The creation UI already generated the title/thumbnail brief. Do not call
515
- # the complete creative-package generator a second time for this task.
516
- creative = {
517
- "title": provided_title,
518
- "keywords": [],
519
- "thumbnail_variant": existing_variant,
520
- "title_candidates": task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else [],
521
- }
522
- else:
523
- creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
524
- title = str(creative.get("title") or provided_title or topic).strip()
525
- provided_keywords = generation_settings.get("video_keywords")
526
- if isinstance(provided_keywords, str):
527
- provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
528
- provided_keywords = [str(item).strip() for item in provided_keywords or [] if str(item).strip()]
529
- keywords = provided_keywords[:15] or (creative.get("keywords") if isinstance(creative.get("keywords"), list) else _keywords(topic, title, str(channel.get("niche") or "")))
530
- title_artifact = _save_json_artifact(task_id, "title-keywords", {"topic": topic, "title": title, "keywords": keywords, "title_candidates": creative.get("title_candidates", [])})
531
- _update(task_id, title=title, tags=keywords, artifacts={**artifacts, "title_keywords": title_artifact}, title_candidates=creative.get("title_candidates", []), progress=45)
532
-
533
- _update(task_id, stage="keywords", state="doing", progress=48)
534
- variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
535
- if not variant and prepared_thumbnail:
536
- variant = existing_variant
575
+ variant = existing_variant
576
+ if not str(variant.get("image_prompt") or "").strip():
577
+ try:
578
+ variant = generate_thumbnail_prompt(
579
+ settings,
580
+ channel,
581
+ topic,
582
+ blueprint=blueprint,
583
+ language=str(task.get("language") or channel.get("language") or "Português"),
584
+ )
585
+ except CreativeGenerationError as exc:
586
+ raise PipelineError(f"Não foi possível gerar o prompt da thumbnail: {exc}") from exc
537
587
  prompt_payload = {
538
588
  "topic": topic,
539
589
  "title": title,
@@ -550,34 +600,41 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
550
600
  },
551
601
  }
552
602
  prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
553
- artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
554
- _update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
555
- _update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
556
- thumbnail_path = _generate_pipeline_thumbnail(
557
- settings,
558
- str(variant.get("image_prompt") or ""),
559
- topic=topic,
560
- variant_index=0,
561
- lettering_text=str(variant.get("overlay_text") or ""),
562
- lettering_prompt=str(variant.get("lettering_prompt") or ""),
603
+ artifacts["thumbnail_prompt_json"] = prompt_artifact
604
+ _update(
605
+ task_id,
606
+ stage="thumbnail_prompt",
607
+ state="doing",
608
+ progress=84,
609
+ thumbnail_variant=variant,
610
+ thumbnail_prompt=str(variant.get("image_prompt") or ""),
611
+ thumbnail_text=str(variant.get("overlay_text") or ""),
612
+ thumbnail_status="prompt_ready",
613
+ artifacts=artifacts,
563
614
  )
564
- artifacts["thumbnail"] = str(thumbnail_path)
565
- _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
566
615
 
567
- _update(task_id, stage="video", state="doing", progress=68)
568
- video_cards = media_cards_for_pool(settings, "video")
569
- if bool(settings.get("media_video_pool_enabled")) and video_cards:
570
- try:
571
- video_prompt = f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}"
572
- video_path = generate_video_from_pool(settings, video_prompt)
573
- except MediaGenerationError as exc:
574
- raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
616
+ _update(task_id, stage="thumbnail", state="doing", progress=86, error=None)
617
+ current_artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or artifacts)
618
+ existing_thumbnail = Path(str(current_artifacts.get("thumbnail") or variant.get("image_path") or "")).expanduser()
619
+ if existing_thumbnail.is_file() and existing_thumbnail.stat().st_size > 0:
620
+ thumbnail_path = existing_thumbnail
575
621
  else:
576
- video_path = _run_video_helper({**task, "topic": topic})
577
- artifacts["video"] = str(video_path)
578
- _update(task_id, artifacts=artifacts, progress=80)
622
+ try:
623
+ thumbnail_path = _generate_pipeline_thumbnail(
624
+ settings,
625
+ str(variant.get("image_prompt") or ""),
626
+ topic=topic,
627
+ variant_index=0,
628
+ lettering_text=str(variant.get("overlay_text") or ""),
629
+ lettering_prompt=str(variant.get("lettering_prompt") or ""),
630
+ )
631
+ except (MediaGenerationError, ThumbnailGenerationError) as exc:
632
+ raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
633
+ artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or current_artifacts)
634
+ artifacts["thumbnail"] = str(thumbnail_path)
635
+ _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=90)
579
636
 
580
- _update(task_id, stage="upload", state="doing", progress=86)
637
+ _update(task_id, stage="upload", state="doing", progress=94, error=None)
581
638
  result = upload_with_default_route(
582
639
  settings,
583
640
  storage_root=STORAGE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.43",
3
+ "version": "0.3.45",
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",