@danhachuel/thunderbolt 0.3.44 → 0.3.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +11 -11
- package/README.md +6 -4
- package/app/main.py +47 -5
- package/hermes_ui/automation_worker.py +11 -11
- package/hermes_ui/creative_generation.py +70 -0
- package/hermes_ui/domain.py +5 -2
- package/hermes_ui/media_generation.py +57 -7
- package/hermes_ui/media_providers.py +13 -5
- package/hermes_ui/pipeline_worker.py +113 -53
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.46
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
103
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.46 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
109
|
+
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.46 install
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -114,7 +114,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
|
|
|
114
114
|
Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
|
|
115
115
|
|
|
116
116
|
```powershell
|
|
117
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
117
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.46 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.
|
|
@@ -363,11 +363,11 @@ Em **Configuração API > API Keys**, o expander **LLM — providers e modelos**
|
|
|
363
363
|
|
|
364
364
|
O bloco **Limite LLM NVIDIA NIM** permite ligar **Activar limitador NVIDIA NIM — 40 RPM**. O valor padrão é 40 pedidos numa janela de 60 segundos. O contador é persistido localmente por cartão/chave e aplica-se à criação manual, aos workers e às automações. A opção vem desligada para não alterar instalações existentes; ligue-a quando o endpoint activo for NVIDIA NIM e a sua conta tiver esse limite.
|
|
365
365
|
|
|
366
|
-
O expander **Imagem e Video** substitui o antigo bloco isolado da Nano Banana. Os cartões disponíveis são **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Em cada cartão, configure a API key/token, o modelo, a Base URL, o estado **Provider activo**, a participação no **Pool Imagem**, a participação no **Pool Vídeo** e a prioridade. O botão **Testar Chamada API** faz apenas uma verificação read-only; não inicia geração de imagem ou vídeo.
|
|
366
|
+
O expander **Imagem e Video** substitui o antigo bloco isolado da Nano Banana. Os cartões disponíveis são **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Em cada cartão, configure a API key/token, o modelo, a Base URL, o estado **Provider activo**, a participação no **Pool Imagem**, a participação no **Pool Vídeo** e a prioridade. **Image Size** e **Aspect Ratio** não aparecem nos cartões e não são editáveis: são defaults internos aplicados automaticamente no prompt da geração. O botão **Testar Chamada API** faz apenas uma verificação read-only; não inicia geração de imagem ou vídeo.
|
|
367
367
|
|
|
368
368
|
| Provider | Configuração mínima | Pool suportado |
|
|
369
369
|
|---|---|---|
|
|
370
|
-
| Nano Banana | API key
|
|
370
|
+
| Nano Banana | API key e modelo Gemini; composição aplicada internamente | Imagem |
|
|
371
371
|
| Pollinations.ai | API key/token, modelo e Base URL | Imagem e vídeo |
|
|
372
372
|
| Agnes AI | API key, modelo e Base URL | Imagem e vídeo |
|
|
373
373
|
| Hugging Face Inference API | Token, modelo e `https://router.huggingface.co/v1` | Imagem |
|
|
@@ -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,
|
|
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**.
|
|
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
|
|
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.
|
|
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.46 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.
|
|
72
|
+
## Navegação da UI 0.3.46
|
|
73
73
|
|
|
74
74
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
75
75
|
|
|
@@ -85,7 +85,7 @@ Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o p
|
|
|
85
85
|
|
|
86
86
|
A subaba **Serviços e modelos** mantém as credenciais de providers e serviços que não são fontes de materiais. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
|
|
87
87
|
|
|
88
|
-
O expander **Imagem e Video** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
|
|
88
|
+
O expander **Imagem e Video** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. Os parâmetros técnicos de composição, como **Image Size** e **Aspect Ratio**, não são apresentados nem editáveis nos cartões: ficam como defaults internos e são acrescentados automaticamente ao prompt da geração de imagem e/ou vídeo. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
|
|
89
89
|
|
|
90
90
|
O router mantém três pools independentes: **LLM textual**, **imagem** e **vídeo**. O failover só passa ao cartão seguinte em quota, timeout, erro de transporte ou HTTP transitório; erros de credencial, endpoint/modelo inexistente ou payload inválido ficam registados e não são mascarados por um provider diferente. O pool de vídeo externo é opcional: quando desligado, o worker continua a usar o motor local MoneyPrinterTurbo; quando ligado, usa apenas os cartões marcados para Pool Vídeo.
|
|
91
91
|
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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/
|
|
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
|
-
|
|
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))
|
|
@@ -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
|
|
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
|
-
|
|
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":
|
|
164
|
-
"title_candidates":
|
|
165
|
-
"thumbnail_variant":
|
|
166
|
-
"thumbnail_variants":
|
|
167
|
-
"thumbnail_prompt":
|
|
168
|
-
"thumbnail_text":
|
|
169
|
-
"thumbnail_status":
|
|
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
|
-
"
|
|
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],
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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", "
|
|
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:
|
|
@@ -16,7 +16,14 @@ from urllib.parse import urljoin
|
|
|
16
16
|
|
|
17
17
|
import requests
|
|
18
18
|
|
|
19
|
-
from .media_providers import
|
|
19
|
+
from .media_providers import (
|
|
20
|
+
INTERNAL_IMAGE_ASPECT_RATIO,
|
|
21
|
+
INTERNAL_IMAGE_SIZE,
|
|
22
|
+
INTERNAL_VIDEO_ASPECT_RATIO,
|
|
23
|
+
INTERNAL_VIDEO_SIZE,
|
|
24
|
+
media_cards_for_pool,
|
|
25
|
+
media_provider_definition,
|
|
26
|
+
)
|
|
20
27
|
from .provider_routing import (
|
|
21
28
|
POOL_IMAGE,
|
|
22
29
|
POOL_VIDEO,
|
|
@@ -52,6 +59,28 @@ def _headers(card: Mapping[str, Any], *, fal: bool = False) -> dict[str, str]:
|
|
|
52
59
|
return headers
|
|
53
60
|
|
|
54
61
|
|
|
62
|
+
def _append_generation_constraints(
|
|
63
|
+
prompt: str,
|
|
64
|
+
*,
|
|
65
|
+
kind: str,
|
|
66
|
+
aspect_ratio: str = "",
|
|
67
|
+
size: str = "",
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Append controlled rendering constraints without exposing editable UI fields."""
|
|
70
|
+
clean = str(prompt or "").strip()
|
|
71
|
+
if kind == "image":
|
|
72
|
+
ratio = str(aspect_ratio or INTERNAL_IMAGE_ASPECT_RATIO).strip()
|
|
73
|
+
resolution = str(size or INTERNAL_IMAGE_SIZE).strip()
|
|
74
|
+
constraints = f"Target image composition: {ratio} aspect ratio, {resolution} resolution."
|
|
75
|
+
else:
|
|
76
|
+
ratio = str(aspect_ratio or INTERNAL_VIDEO_ASPECT_RATIO).strip()
|
|
77
|
+
resolution = str(size or INTERNAL_VIDEO_SIZE).strip()
|
|
78
|
+
constraints = f"Target video composition: {ratio} aspect ratio, {resolution} resolution."
|
|
79
|
+
if not clean:
|
|
80
|
+
return constraints
|
|
81
|
+
return f"{clean}\n\n{constraints}"
|
|
82
|
+
|
|
83
|
+
|
|
55
84
|
def _decode_data(value: Any) -> bytes | None:
|
|
56
85
|
if not isinstance(value, str) or not value.strip():
|
|
57
86
|
return None
|
|
@@ -142,14 +171,20 @@ def _image_request(card: dict[str, Any], prompt: str) -> Any:
|
|
|
142
171
|
provider = str(card.get("provider") or "").strip().lower()
|
|
143
172
|
style = str(card.get("api_style") or media_provider_definition(provider).api_style)
|
|
144
173
|
endpoint = _image_endpoint(card)
|
|
174
|
+
constrained_prompt = _append_generation_constraints(
|
|
175
|
+
prompt,
|
|
176
|
+
kind="image",
|
|
177
|
+
aspect_ratio=str(card.get("aspect_ratio") or ""),
|
|
178
|
+
size=str(card.get("image_size") or ""),
|
|
179
|
+
)
|
|
145
180
|
if style == "cloudflare":
|
|
146
|
-
return requests.post(endpoint, headers=_headers(card), json={"prompt":
|
|
181
|
+
return requests.post(endpoint, headers=_headers(card), json={"prompt": constrained_prompt}, timeout=180)
|
|
147
182
|
if style == "fal_queue":
|
|
148
|
-
return requests.post(endpoint, headers=_headers(card, fal=True), json={"prompt":
|
|
183
|
+
return requests.post(endpoint, headers=_headers(card, fal=True), json={"prompt": constrained_prompt, "num_images": 1}, timeout=180)
|
|
149
184
|
if style == "dashscope":
|
|
150
|
-
body = {"model": _model(card), "input": {"prompt":
|
|
185
|
+
body = {"model": _model(card), "input": {"prompt": constrained_prompt}, "parameters": {"n": 1}}
|
|
151
186
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
152
|
-
body = {"model": _model(card), "prompt":
|
|
187
|
+
body = {"model": _model(card), "prompt": constrained_prompt, "n": 1, "response_format": "b64_json"}
|
|
153
188
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
154
189
|
|
|
155
190
|
|
|
@@ -171,10 +206,17 @@ def generate_image_for_card(
|
|
|
171
206
|
merged = dict(settings)
|
|
172
207
|
merged["gemini_image_api_key"] = _api_key(card)
|
|
173
208
|
merged["gemini_image_model"] = _model(card) or merged.get("gemini_image_model") or "gemini-3.1-flash-image"
|
|
209
|
+
merged["gemini_image_aspect_ratio"] = str(card.get("aspect_ratio") or INTERNAL_IMAGE_ASPECT_RATIO)
|
|
210
|
+
merged["gemini_image_size"] = str(card.get("image_size") or INTERNAL_IMAGE_SIZE)
|
|
174
211
|
try:
|
|
175
212
|
return generate_thumbnail_image(
|
|
176
213
|
merged,
|
|
177
|
-
|
|
214
|
+
_append_generation_constraints(
|
|
215
|
+
prompt,
|
|
216
|
+
kind="image",
|
|
217
|
+
aspect_ratio=str(card.get("aspect_ratio") or ""),
|
|
218
|
+
size=str(card.get("image_size") or ""),
|
|
219
|
+
),
|
|
178
220
|
topic=topic,
|
|
179
221
|
variant_index=variant_index,
|
|
180
222
|
lettering_text=lettering_text,
|
|
@@ -260,7 +302,15 @@ def _video_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
260
302
|
def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> Any:
|
|
261
303
|
style = str(card.get("api_style") or media_provider_definition(card.get("provider")).api_style)
|
|
262
304
|
endpoint = _video_endpoint(card)
|
|
263
|
-
body: dict[str, Any] = {
|
|
305
|
+
body: dict[str, Any] = {
|
|
306
|
+
"model": _model(card),
|
|
307
|
+
"prompt": _append_generation_constraints(
|
|
308
|
+
prompt,
|
|
309
|
+
kind="video",
|
|
310
|
+
aspect_ratio=str(card.get("aspect_ratio") or ""),
|
|
311
|
+
size=str(card.get("video_size") or ""),
|
|
312
|
+
),
|
|
313
|
+
}
|
|
264
314
|
if image_url:
|
|
265
315
|
body["image_url"] = image_url
|
|
266
316
|
if style == "fal_queue":
|
|
@@ -12,6 +12,12 @@ MEDIA_VIDEO_ACTIVE_CARD_KEY = "media_video_active_card_id"
|
|
|
12
12
|
MEDIA_IMAGE_ACTIVE_PROVIDER_KEY = "media_image_provider"
|
|
13
13
|
MEDIA_VIDEO_ACTIVE_PROVIDER_KEY = "media_video_provider"
|
|
14
14
|
|
|
15
|
+
# Mantidos internamente para preservar compatibilidade e controlar o prompt dos providers.
|
|
16
|
+
INTERNAL_IMAGE_ASPECT_RATIO = "16:9"
|
|
17
|
+
INTERNAL_IMAGE_SIZE = "1K"
|
|
18
|
+
INTERNAL_VIDEO_ASPECT_RATIO = "16:9"
|
|
19
|
+
INTERNAL_VIDEO_SIZE = "1080p"
|
|
20
|
+
|
|
15
21
|
|
|
16
22
|
@dataclass(frozen=True, slots=True)
|
|
17
23
|
class MediaProviderDefinition:
|
|
@@ -35,7 +41,6 @@ MEDIA_PROVIDER_CATALOG: tuple[MediaProviderDefinition, ...] = (
|
|
|
35
41
|
default_base_url="https://generativelanguage.googleapis.com/v1beta",
|
|
36
42
|
supports_image=True,
|
|
37
43
|
api_style="gemini_interactions",
|
|
38
|
-
extra_fields=("aspect_ratio", "image_size"),
|
|
39
44
|
description="Geração de imagem nativa Gemini para thumbnails e artes.",
|
|
40
45
|
),
|
|
41
46
|
MediaProviderDefinition(
|
|
@@ -170,6 +175,9 @@ def normalize_media_card(card: Any, index: int = 0) -> dict[str, Any]:
|
|
|
170
175
|
}
|
|
171
176
|
for field in definition.extra_fields:
|
|
172
177
|
result[field] = str(source.get(field) or "").strip()
|
|
178
|
+
if provider == "nano_banana":
|
|
179
|
+
result["aspect_ratio"] = str(source.get("aspect_ratio") or INTERNAL_IMAGE_ASPECT_RATIO).strip()
|
|
180
|
+
result["image_size"] = str(source.get("image_size") or INTERNAL_IMAGE_SIZE).strip()
|
|
173
181
|
test_result = source.get("test_result")
|
|
174
182
|
if isinstance(test_result, Mapping) and str(test_result.get("status") or "") in {"success", "error"}:
|
|
175
183
|
result["test_result"] = {
|
|
@@ -215,8 +223,8 @@ def ensure_media_provider_cards(settings: Mapping[str, Any]) -> tuple[dict[str,
|
|
|
215
223
|
"api_key": legacy_key,
|
|
216
224
|
"model": legacy_model,
|
|
217
225
|
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
|
218
|
-
"aspect_ratio": str(result.get("gemini_image_aspect_ratio") or
|
|
219
|
-
"image_size": str(result.get("gemini_image_size") or
|
|
226
|
+
"aspect_ratio": str(result.get("gemini_image_aspect_ratio") or INTERNAL_IMAGE_ASPECT_RATIO),
|
|
227
|
+
"image_size": str(result.get("gemini_image_size") or INTERNAL_IMAGE_SIZE),
|
|
220
228
|
"enabled": True,
|
|
221
229
|
},
|
|
222
230
|
0,
|
|
@@ -267,8 +275,8 @@ def apply_media_provider_cards_to_settings(settings: Mapping[str, Any], cards: l
|
|
|
267
275
|
if nano_card:
|
|
268
276
|
result["gemini_image_api_key"] = str(nano_card.get("api_key") or "")
|
|
269
277
|
result["gemini_image_model"] = str(nano_card.get("model") or "gemini-3.1-flash-image")
|
|
270
|
-
result["gemini_image_aspect_ratio"] = str(nano_card.get("aspect_ratio") or result.get("gemini_image_aspect_ratio") or
|
|
271
|
-
result["gemini_image_size"] = str(nano_card.get("image_size") or result.get("gemini_image_size") or
|
|
278
|
+
result["gemini_image_aspect_ratio"] = str(nano_card.get("aspect_ratio") or result.get("gemini_image_aspect_ratio") or INTERNAL_IMAGE_ASPECT_RATIO)
|
|
279
|
+
result["gemini_image_size"] = str(nano_card.get("image_size") or result.get("gemini_image_size") or INTERNAL_IMAGE_SIZE)
|
|
272
280
|
return result
|
|
273
281
|
|
|
274
282
|
|
|
@@ -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
|
-
from hermes_ui.media_generation import MediaGenerationError, generate_image_from_pool, generate_video_from_pool
|
|
20
|
+
from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, 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,
|
|
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,90 @@ 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 = _append_generation_constraints(
|
|
558
|
+
f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
|
|
559
|
+
kind="video",
|
|
560
|
+
)
|
|
561
|
+
video_path = generate_video_from_pool(settings, video_prompt)
|
|
562
|
+
except MediaGenerationError as exc:
|
|
563
|
+
raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
|
|
564
|
+
else:
|
|
565
|
+
video_path = _run_video_helper({**task, "topic": topic, "title": title})
|
|
566
|
+
current_after_video = _task_by_id(task_id) or {}
|
|
567
|
+
artifacts = dict(current_after_video.get("artifacts") or artifacts)
|
|
568
|
+
artifacts["video"] = str(video_path)
|
|
569
|
+
_update(task_id, artifacts=artifacts, video_ready=True, progress=80)
|
|
570
|
+
|
|
571
|
+
_update(task_id, stage="thumbnail_prompt", state="doing", progress=82, error=None)
|
|
506
572
|
existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
|
|
507
573
|
existing_variant = dict(existing_variant)
|
|
508
574
|
if not str(existing_variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
|
|
509
575
|
existing_variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
|
|
510
576
|
if not str(existing_variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
|
|
511
577
|
existing_variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
|
|
512
|
-
|
|
513
|
-
if
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
578
|
+
variant = existing_variant
|
|
579
|
+
if not str(variant.get("image_prompt") or "").strip():
|
|
580
|
+
try:
|
|
581
|
+
variant = generate_thumbnail_prompt(
|
|
582
|
+
settings,
|
|
583
|
+
channel,
|
|
584
|
+
topic,
|
|
585
|
+
blueprint=blueprint,
|
|
586
|
+
language=str(task.get("language") or channel.get("language") or "Português"),
|
|
587
|
+
)
|
|
588
|
+
except CreativeGenerationError as exc:
|
|
589
|
+
raise PipelineError(f"Não foi possível gerar o prompt da thumbnail: {exc}") from exc
|
|
537
590
|
prompt_payload = {
|
|
538
591
|
"topic": topic,
|
|
539
592
|
"title": title,
|
|
@@ -550,34 +603,41 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
550
603
|
},
|
|
551
604
|
}
|
|
552
605
|
prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
|
|
553
|
-
artifacts
|
|
554
|
-
_update(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
606
|
+
artifacts["thumbnail_prompt_json"] = prompt_artifact
|
|
607
|
+
_update(
|
|
608
|
+
task_id,
|
|
609
|
+
stage="thumbnail_prompt",
|
|
610
|
+
state="doing",
|
|
611
|
+
progress=84,
|
|
612
|
+
thumbnail_variant=variant,
|
|
613
|
+
thumbnail_prompt=str(variant.get("image_prompt") or ""),
|
|
614
|
+
thumbnail_text=str(variant.get("overlay_text") or ""),
|
|
615
|
+
thumbnail_status="prompt_ready",
|
|
616
|
+
artifacts=artifacts,
|
|
563
617
|
)
|
|
564
|
-
artifacts["thumbnail"] = str(thumbnail_path)
|
|
565
|
-
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
|
|
566
618
|
|
|
567
|
-
_update(task_id, stage="
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
619
|
+
_update(task_id, stage="thumbnail", state="doing", progress=86, error=None)
|
|
620
|
+
current_artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or artifacts)
|
|
621
|
+
existing_thumbnail = Path(str(current_artifacts.get("thumbnail") or variant.get("image_path") or "")).expanduser()
|
|
622
|
+
if existing_thumbnail.is_file() and existing_thumbnail.stat().st_size > 0:
|
|
623
|
+
thumbnail_path = existing_thumbnail
|
|
575
624
|
else:
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
625
|
+
try:
|
|
626
|
+
thumbnail_path = _generate_pipeline_thumbnail(
|
|
627
|
+
settings,
|
|
628
|
+
str(variant.get("image_prompt") or ""),
|
|
629
|
+
topic=topic,
|
|
630
|
+
variant_index=0,
|
|
631
|
+
lettering_text=str(variant.get("overlay_text") or ""),
|
|
632
|
+
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
633
|
+
)
|
|
634
|
+
except (MediaGenerationError, ThumbnailGenerationError) as exc:
|
|
635
|
+
raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
|
|
636
|
+
artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or current_artifacts)
|
|
637
|
+
artifacts["thumbnail"] = str(thumbnail_path)
|
|
638
|
+
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=90)
|
|
579
639
|
|
|
580
|
-
_update(task_id, stage="upload", state="doing", progress=
|
|
640
|
+
_update(task_id, stage="upload", state="doing", progress=94, error=None)
|
|
581
641
|
result = upload_with_default_route(
|
|
582
642
|
settings,
|
|
583
643
|
storage_root=STORAGE,
|
package/package.json
CHANGED