@danhachuel/thunderbolt 0.3.40 → 0.3.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +9 -9
- package/README.md +7 -3
- package/app/main.py +275 -179
- package/hermes_ui/domain.py +15 -6
- package/hermes_ui/pipeline_worker.py +22 -2
- 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.42
|
|
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 @danhachuel/thunderbolt@0.3.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.3.42 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.3.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.3.42 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.
|
|
@@ -438,8 +438,8 @@ Após iniciar a aplicação, valide o seguinte percurso:
|
|
|
438
438
|
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.
|
|
439
439
|
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**.
|
|
440
440
|
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**.
|
|
441
|
-
7. **Criação de Vídeos:** depois de seleccionar o canal, confirme que o painel entre **Canal** e **Estilo wide** mostra o Blueprint e
|
|
442
|
-
8. **Lote geral:** confirme que não existe **Canais incluídos** nem selector parcial. A UI deve listar todos os canais cadastrados e criar exactamente uma tarefa por canal, com briefing, título, thumbnail e Blueprint próprios. Use **Gerar tópicos individuais para todos os canais** e **Gerar
|
|
441
|
+
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.
|
|
442
|
+
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.
|
|
443
443
|
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.
|
|
444
444
|
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.
|
|
445
445
|
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.
|
|
@@ -487,7 +487,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
487
487
|
|
|
488
488
|
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.
|
|
489
489
|
|
|
490
|
-
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.
|
|
490
|
+
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.42 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
491
491
|
|
|
492
492
|
### Niche Finder Apify
|
|
493
493
|
|
|
@@ -515,9 +515,9 @@ No modo automático de **Cortes**, a aplicação não inventa uma selecção vir
|
|
|
515
515
|
|
|
516
516
|
O formulário permite escrever manualmente o **Tópico ou briefing** ou clicar em **Gerar tópico/briefing com IA**. O botão usa o provider LLM, Base URL, API key e modelo guardados em **Configuração API > API Keys > Serviços e modelos**, incorpora a descrição, o nicho e o Blueprint do canal e coloca o resultado no campo para revisão. Abrir a página não inicia chamadas externas.
|
|
517
517
|
|
|
518
|
-
Depois do tópico,
|
|
518
|
+
Depois do tópico e do título inicial, a área **Gerar Thumbnail com IA** gera apenas o briefing/prompt da thumbnail e preserva o título já existente. Não chama o gerador de títulos nem cria candidatos redundantes. Em **Lote no mesmo canal**, gera uma variante independente para cada vídeo; ao criar as tasks, cada vídeo recebe a sua própria variante de thumbnail. A área **Gerar Thumbnail com IA** fica fechada por defeito, tal como **Configurações de vídeo**, **Configurações de áudio** e **Configurações de legendas**, formando as quatro áreas expansíveis principais. A geração da imagem final continua disponível no botão específico do Nano Banana depois de existir um prompt. Se não existir um provider de imagem configurado, fica com estado **Prompt de thumbnail pronto — imagem pendente de provider de imagem**, sem criar um ficheiro artificial.
|
|
519
519
|
|
|
520
|
-
No modo **Lote geral**, todos os canais cadastrados são incluídos automaticamente. Não existe selector **Canais incluídos**. O Thunderbolt cria exactamente uma task por canal e cada task recebe briefing, título, thumbnail, Blueprint, voz e contexto próprios. O mesmo tópico não é replicado entre canais.
|
|
520
|
+
No modo **Lote geral**, todos os canais cadastrados são incluídos automaticamente. Não existe selector **Canais incluídos**. O botão **Gerar Thumbnail com IA para todos os vídeos** percorre os tópicos individuais já preparados e gera somente uma thumbnail por vídeo/canal; não regenera títulos. O Thunderbolt cria exactamente uma task por canal e cada task recebe briefing, título, thumbnail, Blueprint, voz e contexto próprios. O mesmo tópico não é replicado entre canais.
|
|
521
521
|
|
|
522
522
|
## Pipeline: Criação de Vídeos, Criação de Músicas, Roteiros e Automação
|
|
523
523
|
|
|
@@ -525,7 +525,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
|
|
|
525
525
|
|
|
526
526
|
A aba **Roteiros**, colocada entre **Criação de Músicas** e **Upload**, permite seleccionar um canal opcional, um Blueprint, **Roteiro de vídeo** ou **Letra de música**, idioma, tema e estrutura. O botão **Gerar com IA a partir do Blueprint** usa o provider LLM configurado e devolve um rascunho Markdown editável; o utilizador deve rever e clicar em **Guardar documento no storage**. Os ficheiros são guardados em `storage/scripts/` e o índice em `storage/state/scripts.json`; o caminho absoluto aparece no topo da página. Na subaba **Vídeos** de **Criação de Vídeos**, a aplicação mostra a frase `Os vídeos são guardados em <storage>/videos`, que identifica a pasta local dos vídeos.
|
|
527
527
|
|
|
528
|
-
A aba **Automação Youtube**, dentro do menu expansível **Automação**,
|
|
528
|
+
A aba **Automação Youtube**, dentro do menu expansível **Automação**, apresenta os cartões no layout compacto de duas linhas: na primeira ficam avatar, nome, handle, toggle **Automação ligada** e horário; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ligada** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
|
|
529
529
|
|
|
530
530
|
A área **Teste de vozes**, dentro de **Configurações > Configuração API**, é isolada da pipeline. O preview pode ser reproduzido e descarregado de `storage/voice_previews/`. Caminhos vazios, directórios, ficheiros vazios e previews sem permissões de leitura são ignorados com uma mensagem clara, sem traceback. Se uma instalação antiga mostrar que `edge-tts` está em falta, execute `npx.cmd --yes @danhachuel/thunderbolt install`; o detector reinstala apenas a dependência ausente.
|
|
531
531
|
|
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.42
|
|
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
|
|
|
@@ -107,11 +107,15 @@ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** fica num expand
|
|
|
107
107
|
|
|
108
108
|
A aba **Criação de Vídeos** permite escrever manualmente o **Tópico ou briefing** ou usar **Gerar tópico/briefing com IA**. A geração usa o provider LLM configurado localmente em **Configuração API > API Keys > Serviços e modelos**, incorpora o Blueprint, a descrição, o idioma e a voz do canal e só ocorre depois do clique do utilizador. Se não houver credenciais ou modelo, a UI mostra uma mensagem accionável e não inventa conteúdo.
|
|
109
109
|
|
|
110
|
-
Entre **Canal** e **Estilo wide**, a UI mostra o Blueprint padrão resolvido do canal
|
|
110
|
+
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 continua disponível através do botão específico do Nano Banana; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
|
|
111
111
|
|
|
112
112
|
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.
|
|
113
113
|
|
|
114
|
-
No modo **Lote geral**, o Thunderbolt remove a selecção parcial e processa automaticamente todos os canais cadastrados. É 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.
|
|
114
|
+
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.
|
|
115
|
+
|
|
116
|
+
## Automação Youtube — layout compacto por canal
|
|
117
|
+
|
|
118
|
+
A aba **Automação Youtube** usa um cartão compacto de duas linhas. Na primeira ficam avatar, nome, handle, **Automação ligada** e **Horário (HH:MM)**; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. As chaves e funções dos controlos permanecem as mesmas: Blueprint e Narrador/Voz são editáveis, enquanto Idioma e Nicho são apresentados a partir do canal.
|
|
115
119
|
|
|
116
120
|
## OpenAI/ NVIDIA NIM — descoberta de modelos
|
|
117
121
|
|
package/app/main.py
CHANGED
|
@@ -362,8 +362,13 @@ def save_ui_language(value: str) -> str:
|
|
|
362
362
|
return normalized
|
|
363
363
|
|
|
364
364
|
|
|
365
|
+
def normalize_video_language(value: Any, default: str = "pt") -> str:
|
|
366
|
+
raw = str(value or "").strip()
|
|
367
|
+
return "music" if raw.casefold() == "music" else language_code(raw, default=default)
|
|
368
|
+
|
|
369
|
+
|
|
365
370
|
def save_video_language(value: str) -> str:
|
|
366
|
-
normalized =
|
|
371
|
+
normalized = normalize_video_language(value)
|
|
367
372
|
settings = read_json("settings.json", {})
|
|
368
373
|
settings["video_language"] = normalized
|
|
369
374
|
write_json("settings.json", settings)
|
|
@@ -486,6 +491,13 @@ def blueprint_for_channel(channel: dict) -> dict[str, Any]:
|
|
|
486
491
|
return {"id": blueprint_id, "name": blueprint_id}
|
|
487
492
|
|
|
488
493
|
|
|
494
|
+
def channel_video_language(channel: dict[str, Any] | None, fallback: str = "pt") -> str:
|
|
495
|
+
"""Return the channel's canonical video language, preserving legacy labels."""
|
|
496
|
+
if not isinstance(channel, dict):
|
|
497
|
+
return normalize_video_language(fallback)
|
|
498
|
+
return normalize_video_language(channel.get("language") or fallback)
|
|
499
|
+
|
|
500
|
+
|
|
489
501
|
def channel_blueprint_summary(channel: dict) -> dict[str, str]:
|
|
490
502
|
blueprint = blueprint_for_channel(channel)
|
|
491
503
|
configured_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
@@ -501,12 +513,13 @@ def channel_blueprint_summary(channel: dict) -> dict[str, str]:
|
|
|
501
513
|
def render_channel_blueprint_panel(channel: dict, *, compact: bool = False) -> None:
|
|
502
514
|
summary = channel_blueprint_summary(channel)
|
|
503
515
|
voice = summary["voice"] or "Sem voz padrão"
|
|
516
|
+
video_language = language_label(channel_video_language(channel))
|
|
504
517
|
if summary["name"] == "SEM BLUEPRINT CONFIGURADO":
|
|
505
|
-
st.warning("**SEM BLUEPRINT CONFIGURADO** · configure um Blueprint padrão na aba Canais.")
|
|
518
|
+
st.warning(f"**SEM BLUEPRINT CONFIGURADO** · configure um Blueprint padrão na aba Canais. · **Idioma:** {video_language}")
|
|
506
519
|
elif compact:
|
|
507
|
-
st.caption(f"**Blueprint:** {summary['name']} · **Voz:** {voice}")
|
|
520
|
+
st.caption(f"**Blueprint:** {summary['name']} · **Voz:** {voice} · **Idioma:** {video_language}")
|
|
508
521
|
else:
|
|
509
|
-
st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice}")
|
|
522
|
+
st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice} · **Idioma:** {video_language}")
|
|
510
523
|
|
|
511
524
|
|
|
512
525
|
def creative_payload_from_result(channel: dict, topic: str, creative: dict, topic_source: str = "manual") -> dict[str, Any]:
|
|
@@ -704,6 +717,75 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
|
|
|
704
717
|
return payload
|
|
705
718
|
|
|
706
719
|
|
|
720
|
+
def generate_thumbnail_for_ui(
|
|
721
|
+
settings: dict[str, Any],
|
|
722
|
+
channel: dict[str, Any],
|
|
723
|
+
topic: str,
|
|
724
|
+
*,
|
|
725
|
+
title: str = "",
|
|
726
|
+
topic_source: str = "manual",
|
|
727
|
+
) -> dict[str, Any]:
|
|
728
|
+
"""Generate exactly one thumbnail brief for an existing video topic/title."""
|
|
729
|
+
topic = str(topic or "").strip()
|
|
730
|
+
if not topic:
|
|
731
|
+
raise CreativeGenerationError("É necessário um tópico antes de gerar a thumbnail.")
|
|
732
|
+
variant = generate_thumbnail_prompt(
|
|
733
|
+
settings,
|
|
734
|
+
channel,
|
|
735
|
+
topic,
|
|
736
|
+
blueprint=blueprint_for_channel(channel),
|
|
737
|
+
language=str(channel.get("language") or "Português"),
|
|
738
|
+
)
|
|
739
|
+
return {
|
|
740
|
+
"topic": topic,
|
|
741
|
+
"title": str(title or topic).strip(),
|
|
742
|
+
"topic_source": topic_source or "manual",
|
|
743
|
+
"thumbnail_variant": variant,
|
|
744
|
+
"thumbnail_variants": [variant],
|
|
745
|
+
"thumbnail_prompt": variant.get("image_prompt", ""),
|
|
746
|
+
"thumbnail_text": variant.get("overlay_text", ""),
|
|
747
|
+
"thumbnail_status": "prompt_ready",
|
|
748
|
+
"title_candidates": [],
|
|
749
|
+
"ai_generation": {"thumbnail": variant},
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def generate_thumbnail_variants_for_ui(
|
|
754
|
+
settings: dict[str, Any],
|
|
755
|
+
channel: dict[str, Any],
|
|
756
|
+
topic: str,
|
|
757
|
+
count: int,
|
|
758
|
+
*,
|
|
759
|
+
title: str = "",
|
|
760
|
+
topic_source: str = "manual",
|
|
761
|
+
) -> dict[str, Any]:
|
|
762
|
+
"""Generate one independent thumbnail brief for each video in a batch."""
|
|
763
|
+
count = max(1, min(int(count or 1), 100))
|
|
764
|
+
variants: list[dict[str, Any]] = []
|
|
765
|
+
for _index in range(count):
|
|
766
|
+
generated = generate_thumbnail_for_ui(
|
|
767
|
+
settings,
|
|
768
|
+
channel,
|
|
769
|
+
topic,
|
|
770
|
+
title=title,
|
|
771
|
+
topic_source=topic_source,
|
|
772
|
+
)
|
|
773
|
+
variants.append(dict(generated["thumbnail_variant"]))
|
|
774
|
+
first = variants[0]
|
|
775
|
+
return {
|
|
776
|
+
"topic": topic,
|
|
777
|
+
"title": str(title or topic).strip(),
|
|
778
|
+
"topic_source": topic_source or "manual",
|
|
779
|
+
"thumbnail_variant": first,
|
|
780
|
+
"thumbnail_variants": variants,
|
|
781
|
+
"thumbnail_prompt": first.get("image_prompt", ""),
|
|
782
|
+
"thumbnail_text": first.get("overlay_text", ""),
|
|
783
|
+
"thumbnail_status": "prompt_ready",
|
|
784
|
+
"title_candidates": [],
|
|
785
|
+
"ai_generation": {"thumbnail_variants": variants},
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
|
|
707
789
|
def valid_hhmm(value: str) -> bool:
|
|
708
790
|
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
709
791
|
|
|
@@ -746,7 +828,15 @@ def render_video_generation_settings(
|
|
|
746
828
|
key=f"{prefix}_video_subject",
|
|
747
829
|
placeholder="Ex.: How AI is changing everyday life",
|
|
748
830
|
)
|
|
749
|
-
normalized_current_language =
|
|
831
|
+
normalized_current_language = normalize_video_language(current_language)
|
|
832
|
+
if channel is not None:
|
|
833
|
+
channel_id = str(channel.get("id") or channel.get("name") or "")
|
|
834
|
+
channel_language_state_key = f"{prefix}_language_channel_id"
|
|
835
|
+
if st.session_state.get(channel_language_state_key) != channel_id:
|
|
836
|
+
channel_language = channel_video_language(channel, fallback=normalized_current_language)
|
|
837
|
+
st.session_state[f"{prefix}_script_language"] = channel_language
|
|
838
|
+
st.session_state[channel_language_state_key] = channel_id
|
|
839
|
+
normalized_current_language = normalize_video_language(st.session_state.get(f"{prefix}_script_language") or normalized_current_language)
|
|
750
840
|
settings["script_language"] = st.selectbox(
|
|
751
841
|
"Script Language",
|
|
752
842
|
VIDEO_LANGUAGE_SELECTION_OPTIONS,
|
|
@@ -755,14 +845,14 @@ def render_video_generation_settings(
|
|
|
755
845
|
key=f"{prefix}_script_language",
|
|
756
846
|
)
|
|
757
847
|
with subject_cols[1]:
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
848
|
+
st.markdown("**Advanced Script Settings**")
|
|
849
|
+
settings["script_structure_notes"] = st.text_area(
|
|
850
|
+
"Estrutura e notas opcionais",
|
|
851
|
+
value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
|
|
852
|
+
key=f"{prefix}_script_structure_notes",
|
|
853
|
+
height=100,
|
|
854
|
+
placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
|
|
855
|
+
)
|
|
766
856
|
settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
|
|
767
857
|
settings["video_script"] = st.text_area(
|
|
768
858
|
"Video Script (Optional)",
|
|
@@ -804,66 +894,69 @@ def render_video_generation_settings(
|
|
|
804
894
|
st.error(st.session_state[f"{prefix}_save_draft_error"])
|
|
805
895
|
|
|
806
896
|
if "Configurações de vídeo" in visible_sections:
|
|
807
|
-
st.
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
settings["
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
897
|
+
with st.expander("Configurações de vídeo", expanded=False):
|
|
898
|
+
st.markdown("### Video Settings")
|
|
899
|
+
video_cols = st.columns(2)
|
|
900
|
+
with video_cols[0]:
|
|
901
|
+
settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
|
|
902
|
+
if settings["video_source"] == "full_ia":
|
|
903
|
+
settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
|
|
904
|
+
else:
|
|
905
|
+
settings["style_ia"] = ""
|
|
906
|
+
settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
|
|
907
|
+
settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
|
|
908
|
+
settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
|
|
909
|
+
settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
|
|
910
|
+
with video_cols[1]:
|
|
911
|
+
settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
|
|
912
|
+
settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
|
|
913
|
+
settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
|
|
914
|
+
settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
|
|
824
915
|
|
|
825
916
|
if "Configurações de áudio" in visible_sections:
|
|
826
|
-
st.
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
st.session_state
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
917
|
+
with st.expander("Configurações de áudio", expanded=False):
|
|
918
|
+
st.markdown("### Audio Settings")
|
|
919
|
+
audio_cols = st.columns(2)
|
|
920
|
+
with audio_cols[0]:
|
|
921
|
+
settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
|
|
922
|
+
settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
|
|
923
|
+
if channel is not None:
|
|
924
|
+
channel_id = str(channel.get("id") or channel.get("name") or "")
|
|
925
|
+
channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
|
|
926
|
+
channel_state_key = f"{prefix}_voice_channel_id"
|
|
927
|
+
if st.session_state.get(channel_state_key) != channel_id:
|
|
928
|
+
st.session_state[f"{prefix}_voice"] = channel_voice
|
|
929
|
+
st.session_state[channel_state_key] = channel_id
|
|
930
|
+
current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
|
|
931
|
+
voice_options = voice_catalog(current_voice)
|
|
932
|
+
settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
|
|
933
|
+
volume_speed_cols = st.columns(2)
|
|
934
|
+
with volume_speed_cols[0]:
|
|
935
|
+
settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
|
|
936
|
+
with volume_speed_cols[1]:
|
|
937
|
+
settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
|
|
938
|
+
st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
|
|
939
|
+
with audio_cols[1]:
|
|
940
|
+
settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
|
|
941
|
+
settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
|
|
850
942
|
|
|
851
943
|
if "Configurações de legendas" in visible_sections:
|
|
852
|
-
st.
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
944
|
+
with st.expander("Configurações de legendas", expanded=False):
|
|
945
|
+
st.markdown("### Subtitle Settings")
|
|
946
|
+
subtitle_cols = st.columns(2)
|
|
947
|
+
with subtitle_cols[0]:
|
|
948
|
+
settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
|
|
949
|
+
settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
|
|
950
|
+
settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
|
|
951
|
+
settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
|
|
952
|
+
settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
|
|
953
|
+
settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
|
|
954
|
+
settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
|
|
955
|
+
with subtitle_cols[1]:
|
|
956
|
+
settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
|
|
957
|
+
settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
|
|
958
|
+
settings["subtitle_outline_width"] = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=1.5, step=0.25, key=f"{prefix}_subtitle_outline_width")
|
|
959
|
+
st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
|
|
867
960
|
return settings
|
|
868
961
|
|
|
869
962
|
|
|
@@ -2099,43 +2192,42 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2099
2192
|
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
2100
2193
|
music_path = st.session_state.get(f"{prefix}_music_path", "")
|
|
2101
2194
|
|
|
2195
|
+
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel", key=f"{prefix}_quantity")
|
|
2102
2196
|
payloads: dict[str, dict[str, Any]] = {}
|
|
2103
2197
|
if mode == "general":
|
|
2104
2198
|
existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
|
|
2105
2199
|
payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2200
|
+
with st.expander("Gerar Thumbnail com IA", expanded=False):
|
|
2201
|
+
if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", use_container_width=True):
|
|
2202
|
+
settings = read_json("settings.json", {})
|
|
2203
|
+
new_payloads: dict[str, dict[str, Any]] = {}
|
|
2204
|
+
errors: list[str] = []
|
|
2205
|
+
with st.spinner("A gerar uma thumbnail independente por vídeo…"):
|
|
2206
|
+
for channel in all_channels:
|
|
2207
|
+
try:
|
|
2208
|
+
topic_result = existing_topics.get(channel["id"])
|
|
2209
|
+
topic = str((topic_result or {}).get("topic") or "").strip()
|
|
2210
|
+
if not topic:
|
|
2211
|
+
errors.append(f"{channel.get('name', 'Canal')}: gere primeiro o tópico individual do canal.")
|
|
2212
|
+
continue
|
|
2213
|
+
generated = generate_thumbnail_for_ui(settings, channel, topic, title=topic, topic_source=str((topic_result or {}).get("topic_source") or "llm"))
|
|
2214
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
2215
|
+
new_payloads[channel["id"]] = generated
|
|
2216
|
+
except CreativeGenerationError as exc:
|
|
2217
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
2218
|
+
if errors:
|
|
2219
|
+
for error in errors:
|
|
2220
|
+
st.error(error)
|
|
2221
|
+
else:
|
|
2222
|
+
st.session_state[f"{prefix}_general_payloads"] = new_payloads
|
|
2223
|
+
payloads = new_payloads
|
|
2224
|
+
st.success(f"Thumbnail pronta para {len(new_payloads)} vídeo(s).")
|
|
2129
2225
|
payloads = st.session_state.get(f"{prefix}_general_payloads", payloads)
|
|
2130
2226
|
for channel in all_channels:
|
|
2131
2227
|
payload = payloads.get(channel["id"])
|
|
2132
2228
|
if not payload:
|
|
2133
2229
|
continue
|
|
2134
|
-
with st.expander(f"{channel.get('name', 'Canal')} —
|
|
2135
|
-
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
2136
|
-
if title_options:
|
|
2137
|
-
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"{prefix}_general_title_{channel['id']}")
|
|
2138
|
-
payload["title"] = selected_title
|
|
2230
|
+
with st.expander(f"{channel.get('name', 'Canal')} — thumbnail", expanded=False):
|
|
2139
2231
|
variants = payload.get("thumbnail_variants", [])
|
|
2140
2232
|
if variants:
|
|
2141
2233
|
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
@@ -2176,83 +2268,87 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
|
|
|
2176
2268
|
st.caption("A imagem ainda não foi gerada. Configure a API key em Configuração API > API Keys.")
|
|
2177
2269
|
st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
|
|
2178
2270
|
else:
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
if
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
topic_for_creative = str(topic_result["topic"]).strip()
|
|
2188
|
-
st.session_state[f"{prefix}_topic"] = topic_for_creative
|
|
2189
|
-
st.session_state[f"{prefix}_topic_meta"] = topic_result
|
|
2190
|
-
generated = generate_creative_for_ui(
|
|
2191
|
-
read_json("settings.json", {}),
|
|
2192
|
-
selected_one,
|
|
2193
|
-
topic_for_creative,
|
|
2194
|
-
topic_source=_video_topic_source(topic_for_creative, prefix),
|
|
2195
|
-
)
|
|
2196
|
-
|
|
2197
|
-
st.session_state[f"{prefix}_creative_payload"] = generated
|
|
2198
|
-
st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
|
|
2199
|
-
st.rerun()
|
|
2200
|
-
except CreativeGenerationError as exc:
|
|
2201
|
-
st.error(str(exc))
|
|
2202
|
-
payload = st.session_state.get(f"{prefix}_creative_payload")
|
|
2203
|
-
if payload:
|
|
2204
|
-
st.subheader("Título e Thumbnail automáticos")
|
|
2205
|
-
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
2206
|
-
if title_options:
|
|
2207
|
-
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"{prefix}_title_choice")
|
|
2208
|
-
payload["title"] = selected_title
|
|
2209
|
-
with st.expander(f"Ver {len(title_options)} candidatos de título"):
|
|
2210
|
-
st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
|
|
2211
|
-
variants = payload.get("thumbnail_variants", [])
|
|
2212
|
-
if variants:
|
|
2213
|
-
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
2214
|
-
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
|
|
2215
|
-
variant_index = labels.index(selected_variant_label)
|
|
2216
|
-
variant = variants[variant_index]
|
|
2217
|
-
payload["thumbnail_variant"] = variant
|
|
2218
|
-
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
2219
|
-
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
2220
|
-
st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
|
|
2221
|
-
st.code(variant.get("image_prompt", ""), language="text")
|
|
2222
|
-
thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
|
|
2223
|
-
if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
|
|
2271
|
+
with st.expander("Gerar Thumbnail com IA", expanded=False):
|
|
2272
|
+
topic_for_thumbnail = str(generation_settings.get("video_subject") or "").strip()
|
|
2273
|
+
if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", use_container_width=True):
|
|
2274
|
+
if selected_one is None:
|
|
2275
|
+
st.error("Seleccione primeiro um canal.")
|
|
2276
|
+
elif not topic_for_thumbnail:
|
|
2277
|
+
st.error("Preencha o Video Subject antes de gerar a thumbnail.")
|
|
2278
|
+
else:
|
|
2224
2279
|
try:
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
)
|
|
2280
|
+
existing_payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
|
|
2281
|
+
existing_title = str(existing_payload.get("title") or topic_for_thumbnail).strip()
|
|
2282
|
+
generated = generate_thumbnail_variants_for_ui(
|
|
2283
|
+
read_json("settings.json", {}),
|
|
2284
|
+
selected_one,
|
|
2285
|
+
topic_for_thumbnail,
|
|
2286
|
+
int(quantity if mode == "same_channel" else 1),
|
|
2287
|
+
title=existing_title,
|
|
2288
|
+
topic_source=_video_topic_source(topic_for_thumbnail, prefix),
|
|
2234
2289
|
)
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
st.success("Thumbnail
|
|
2290
|
+
generated = {**existing_payload, **generated}
|
|
2291
|
+
generated["topic"] = topic_for_thumbnail
|
|
2292
|
+
generated["title"] = existing_title
|
|
2293
|
+
generated["title_candidates"] = existing_payload.get("title_candidates", [])
|
|
2294
|
+
st.session_state[f"{prefix}_creative_payload"] = generated
|
|
2295
|
+
st.success(f"Thumbnail pronta para {int(quantity if mode == 'same_channel' else 1)} vídeo(s); o título existente foi preservado.")
|
|
2241
2296
|
st.rerun()
|
|
2242
|
-
except
|
|
2297
|
+
except CreativeGenerationError as exc:
|
|
2243
2298
|
st.error(str(exc))
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2299
|
+
payload = st.session_state.get(f"{prefix}_creative_payload")
|
|
2300
|
+
if payload:
|
|
2301
|
+
st.subheader("Thumbnail automática")
|
|
2302
|
+
st.caption(f"Título preservado: {payload.get('title') or payload.get('topic') or 'Sem título'}")
|
|
2303
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
2304
|
+
if title_options:
|
|
2305
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"{prefix}_title_choice")
|
|
2306
|
+
payload["title"] = selected_title
|
|
2307
|
+
with st.expander(f"Ver {len(title_options)} candidatos de título"):
|
|
2308
|
+
st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
|
|
2309
|
+
variants = payload.get("thumbnail_variants", [])
|
|
2310
|
+
if variants:
|
|
2311
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
2312
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
|
|
2313
|
+
variant_index = labels.index(selected_variant_label)
|
|
2314
|
+
variant = variants[variant_index]
|
|
2315
|
+
payload["thumbnail_variant"] = variant
|
|
2316
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
2317
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
2318
|
+
st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
|
|
2319
|
+
st.code(variant.get("image_prompt", ""), language="text")
|
|
2320
|
+
thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
|
|
2321
|
+
if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
|
|
2322
|
+
try:
|
|
2323
|
+
thumbnail_path = str(
|
|
2324
|
+
generate_thumbnail_image(
|
|
2325
|
+
read_json("settings.json", {}),
|
|
2326
|
+
variant.get("image_prompt", ""),
|
|
2327
|
+
topic=str(payload.get("topic") or ""),
|
|
2328
|
+
variant_index=variant_index,
|
|
2329
|
+
lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
|
|
2330
|
+
lettering_prompt=str(variant.get("lettering_prompt") or ""),
|
|
2331
|
+
)
|
|
2332
|
+
)
|
|
2333
|
+
variant["image_path"] = thumbnail_path
|
|
2334
|
+
payload["thumbnail_path"] = thumbnail_path
|
|
2335
|
+
payload["thumbnail_status"] = "generated"
|
|
2336
|
+
st.session_state[f"{prefix}_creative_payload"] = payload
|
|
2337
|
+
record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
|
|
2338
|
+
st.success("Thumbnail gerada com Nano Banana.")
|
|
2339
|
+
st.rerun()
|
|
2340
|
+
except ThumbnailGenerationError as exc:
|
|
2341
|
+
st.error(str(exc))
|
|
2342
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
2343
|
+
st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
|
|
2344
|
+
payload["thumbnail_path"] = thumbnail_path
|
|
2345
|
+
payload["thumbnail_status"] = "generated"
|
|
2346
|
+
else:
|
|
2347
|
+
st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
|
|
2348
|
+
st.session_state[f"{prefix}_creative_payload"] = payload
|
|
2251
2349
|
|
|
2252
2350
|
st.session_state[f"{prefix}_generation_settings"] = dict(generation_settings)
|
|
2253
2351
|
with st.form(f"{prefix}_form"):
|
|
2254
|
-
|
|
2255
|
-
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
2256
2352
|
language = generation_settings["script_language"]
|
|
2257
2353
|
fmt = generation_settings["video_format"]
|
|
2258
2354
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
@@ -3604,21 +3700,28 @@ def render_automation():
|
|
|
3604
3700
|
for channel in channels:
|
|
3605
3701
|
channel_id = channel["id"]
|
|
3606
3702
|
with st.container(border=True):
|
|
3607
|
-
|
|
3608
|
-
with
|
|
3703
|
+
header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
|
|
3704
|
+
with header_cols[0]:
|
|
3609
3705
|
if channel.get("thumbnail_url"):
|
|
3610
3706
|
st.image(channel["thumbnail_url"], width=48)
|
|
3611
3707
|
else:
|
|
3612
3708
|
st.markdown("### YT")
|
|
3613
|
-
with
|
|
3709
|
+
with header_cols[1]:
|
|
3614
3710
|
st.write(f"**{channel.get('name', 'Sem nome')}**")
|
|
3615
3711
|
st.caption(channel.get("handle") or channel.get("url") or "sem URL")
|
|
3712
|
+
with header_cols[2]:
|
|
3713
|
+
enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
|
|
3714
|
+
with header_cols[3]:
|
|
3715
|
+
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
3616
3716
|
blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
|
|
3617
|
-
default_cols = st.columns(
|
|
3717
|
+
default_cols = st.columns([1.15, 1.15, 1.5, 1.7, 1.35], gap="small")
|
|
3618
3718
|
with default_cols[0]:
|
|
3619
3719
|
st.markdown("**Idioma Padrão**")
|
|
3620
3720
|
st.caption(language_label(channel.get("language") or "pt"))
|
|
3621
3721
|
with default_cols[1]:
|
|
3722
|
+
st.markdown("**Nicho Padrão**")
|
|
3723
|
+
st.caption(channel_niche_label(channel))
|
|
3724
|
+
with default_cols[2]:
|
|
3622
3725
|
automation_blueprint = st.selectbox(
|
|
3623
3726
|
"Blueprint Padrão",
|
|
3624
3727
|
blueprint_ids,
|
|
@@ -3626,9 +3729,6 @@ def render_automation():
|
|
|
3626
3729
|
format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
|
|
3627
3730
|
key=f"automation_blueprint_{channel_id}",
|
|
3628
3731
|
)
|
|
3629
|
-
with default_cols[2]:
|
|
3630
|
-
st.markdown("**Nicho Padrão**")
|
|
3631
|
-
st.caption(channel_niche_label(channel))
|
|
3632
3732
|
with default_cols[3]:
|
|
3633
3733
|
automation_voice = st.selectbox(
|
|
3634
3734
|
"Narrador/Voz Padrão",
|
|
@@ -3637,11 +3737,7 @@ def render_automation():
|
|
|
3637
3737
|
format_func=lambda item: item or "Sem voz padrão",
|
|
3638
3738
|
key=f"automation_voice_{channel_id}",
|
|
3639
3739
|
)
|
|
3640
|
-
with
|
|
3641
|
-
enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
|
|
3642
|
-
with cols[3]:
|
|
3643
|
-
schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
|
|
3644
|
-
with cols[4]:
|
|
3740
|
+
with default_cols[4]:
|
|
3645
3741
|
if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
|
|
3646
3742
|
if not valid_hhmm(schedule_time):
|
|
3647
3743
|
st.error("Use o formato HH:MM, por exemplo 08:30.")
|
package/hermes_ui/domain.py
CHANGED
|
@@ -135,7 +135,16 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
135
135
|
topic = f"Vídeo para {channel.get('name', 'Canal')}"
|
|
136
136
|
title = str(payload.get("title") or topic).strip()
|
|
137
137
|
artifacts = dict(payload.get("artifacts") or {})
|
|
138
|
-
|
|
138
|
+
thumbnail_variants = payload.get("thumbnail_variants") if isinstance(payload.get("thumbnail_variants"), list) else []
|
|
139
|
+
thumbnail_variant = payload.get("thumbnail_variant") if isinstance(payload.get("thumbnail_variant"), dict) else {}
|
|
140
|
+
if count > 1 and thumbnail_variants:
|
|
141
|
+
candidate = thumbnail_variants[index % len(thumbnail_variants)]
|
|
142
|
+
if isinstance(candidate, dict):
|
|
143
|
+
thumbnail_variant = candidate
|
|
144
|
+
thumbnail_path = str(thumbnail_variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
|
|
145
|
+
thumbnail_prompt = str(thumbnail_variant.get("image_prompt") or payload.get("thumbnail_prompt") or "").strip()
|
|
146
|
+
thumbnail_text = str(thumbnail_variant.get("overlay_text") or payload.get("thumbnail_text") or "").strip()
|
|
147
|
+
thumbnail_status = str(payload.get("thumbnail_status") or ("generated" if thumbnail_path else "not_generated"))
|
|
139
148
|
if thumbnail_path:
|
|
140
149
|
artifacts.setdefault("thumbnail", thumbnail_path)
|
|
141
150
|
task = {
|
|
@@ -161,11 +170,11 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
161
170
|
"voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
|
|
162
171
|
"automation_on": bool(channel.get("automation_on", False)),
|
|
163
172
|
"automation_time": channel.get("automation_time", "00:00"),
|
|
164
|
-
"thumbnail_variant":
|
|
165
|
-
"thumbnail_variants":
|
|
166
|
-
"thumbnail_prompt":
|
|
167
|
-
"thumbnail_text":
|
|
168
|
-
"thumbnail_status":
|
|
173
|
+
"thumbnail_variant": thumbnail_variant,
|
|
174
|
+
"thumbnail_variants": thumbnail_variants,
|
|
175
|
+
"thumbnail_prompt": thumbnail_prompt,
|
|
176
|
+
"thumbnail_text": thumbnail_text,
|
|
177
|
+
"thumbnail_status": thumbnail_status,
|
|
169
178
|
"title_candidates": payload.get("title_candidates", []),
|
|
170
179
|
"ai_generation": payload.get("ai_generation", {}),
|
|
171
180
|
"stage": "script",
|
|
@@ -470,8 +470,26 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
470
470
|
_update(task_id, artifacts=artifacts, progress=30)
|
|
471
471
|
|
|
472
472
|
_update(task_id, stage="title", state="doing", progress=35)
|
|
473
|
-
|
|
474
|
-
|
|
473
|
+
provided_title = str(task.get("title") or "").strip()
|
|
474
|
+
existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
|
|
475
|
+
existing_variant = dict(existing_variant)
|
|
476
|
+
if not str(existing_variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
|
|
477
|
+
existing_variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
|
|
478
|
+
if not str(existing_variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
|
|
479
|
+
existing_variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
|
|
480
|
+
prepared_thumbnail = bool(str(existing_variant.get("image_prompt") or "").strip())
|
|
481
|
+
if provided_title and prepared_thumbnail:
|
|
482
|
+
# The creation UI already generated the title/thumbnail brief. Do not call
|
|
483
|
+
# the complete creative-package generator a second time for this task.
|
|
484
|
+
creative = {
|
|
485
|
+
"title": provided_title,
|
|
486
|
+
"keywords": [],
|
|
487
|
+
"thumbnail_variant": existing_variant,
|
|
488
|
+
"title_candidates": task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else [],
|
|
489
|
+
}
|
|
490
|
+
else:
|
|
491
|
+
creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
|
|
492
|
+
title = str(creative.get("title") or provided_title or topic).strip()
|
|
475
493
|
provided_keywords = generation_settings.get("video_keywords")
|
|
476
494
|
if isinstance(provided_keywords, str):
|
|
477
495
|
provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
|
|
@@ -482,6 +500,8 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
482
500
|
|
|
483
501
|
_update(task_id, stage="keywords", state="doing", progress=48)
|
|
484
502
|
variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
|
|
503
|
+
if not variant and prepared_thumbnail:
|
|
504
|
+
variant = existing_variant
|
|
485
505
|
prompt_payload = {
|
|
486
506
|
"topic": topic,
|
|
487
507
|
"title": title,
|
package/package.json
CHANGED