@danhachuel/thunderbolt 0.3.41 → 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.
@@ -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.41
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.41 install
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.41 install
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 a voz, ou **SEM BLUEPRINT CONFIGURADO**. Clique em **Gerar títulos e thumbnails com IA**, confirme pelo menos 20 candidatos de título e 3–5 variantes de thumbnail, escolha uma variante e verifique o prompt guardado. Na subaba **Vídeos**, verifique o estado `to_do`, o título escolhido, o estado da thumbnail e os botões **Iniciar** e **Parar**. Confirme também que **Criação de Músicas** mostra o mesmo fluxo com título próprio.
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 títulos e thumbnails para todos os canais** antes de criar o batch.
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.41 install`; o instalador detecta e reutiliza o que já estiver válido.
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, clique em **Gerar títulos e thumbnails com IA**. O Thunderbolt solicita pelo menos 20 títulos candidatos e 3–5 variantes de thumbnail, aplica as regras das referências empacotadas em `seed/references/` e guarda a variante escolhida na task. O título aparece pré-preenchido no Upload. A thumbnail guarda conceito, texto de até quatro palavras, composição, paleta, prompt e sinergia com o título; se não existir um provider de imagem configurado, fica com estado **Prompt de thumbnail pronto — imagem pendente de provider de imagem**, sem criar um ficheiro artificial.
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 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**, lista os canais e vídeos e apresenta nos cards os mesmos quatro elementos do cadastro: **Idioma Padrão**, **Blueprint Padrão**, **Nicho Padrão** e **Narrador/Voz Padrão**. 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 ON** 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.
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.41
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, 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 briefing, **Gerar títulos e thumbnails com IA** cria pelo menos 20 candidatos de título e 3–5 variantes de thumbnail. Os títulos seguem as fórmulas e regras de curiosidade, especificidade, emoção, keyword inicial e humanização fornecidas nas referências empacotadas; as thumbnails guardam conceito, overlay de até quatro palavras, composição, cores, prompt e sinergia com o título.
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
@@ -717,6 +717,75 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
717
717
  return payload
718
718
 
719
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
+
720
789
  def valid_hhmm(value: str) -> bool:
721
790
  return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
722
791
 
@@ -776,14 +845,14 @@ def render_video_generation_settings(
776
845
  key=f"{prefix}_script_language",
777
846
  )
778
847
  with subject_cols[1]:
779
- with st.expander("Advanced Script Settings", expanded=False):
780
- settings["script_structure_notes"] = st.text_area(
781
- "Estrutura e notas opcionais",
782
- value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
783
- key=f"{prefix}_script_structure_notes",
784
- height=100,
785
- placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
786
- )
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
+ )
787
856
  settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
788
857
  settings["video_script"] = st.text_area(
789
858
  "Video Script (Optional)",
@@ -825,66 +894,69 @@ def render_video_generation_settings(
825
894
  st.error(st.session_state[f"{prefix}_save_draft_error"])
826
895
 
827
896
  if "Configurações de vídeo" in visible_sections:
828
- st.markdown("### Video Settings")
829
- video_cols = st.columns(2)
830
- with video_cols[0]:
831
- settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
832
- if settings["video_source"] == "full_ia":
833
- settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
834
- else:
835
- settings["style_ia"] = ""
836
- settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
837
- settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
838
- settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
839
- settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
840
- with video_cols[1]:
841
- settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
842
- settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
843
- settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
844
- settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
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")
845
915
 
846
916
  if "Configurações de áudio" in visible_sections:
847
- st.markdown("### Audio Settings")
848
- audio_cols = st.columns(2)
849
- with audio_cols[0]:
850
- settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
851
- settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
852
- if channel is not None:
853
- channel_id = str(channel.get("id") or channel.get("name") or "")
854
- channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
855
- channel_state_key = f"{prefix}_voice_channel_id"
856
- if st.session_state.get(channel_state_key) != channel_id:
857
- st.session_state[f"{prefix}_voice"] = channel_voice
858
- st.session_state[channel_state_key] = channel_id
859
- current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
860
- voice_options = voice_catalog(current_voice)
861
- settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
862
- volume_speed_cols = st.columns(2)
863
- with volume_speed_cols[0]:
864
- settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
865
- with volume_speed_cols[1]:
866
- settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
867
- st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
868
- with audio_cols[1]:
869
- settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
870
- settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
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")
871
942
 
872
943
  if "Configurações de legendas" in visible_sections:
873
- st.markdown("### Subtitle Settings")
874
- subtitle_cols = st.columns(2)
875
- with subtitle_cols[0]:
876
- settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
877
- settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
878
- settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
879
- settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
880
- settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
881
- settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
882
- settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
883
- with subtitle_cols[1]:
884
- settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
885
- settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
886
- 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")
887
- st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
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.")
888
960
  return settings
889
961
 
890
962
 
@@ -2120,43 +2192,42 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2120
2192
  st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
2121
2193
  music_path = st.session_state.get(f"{prefix}_music_path", "")
2122
2194
 
2195
+ quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel", key=f"{prefix}_quantity")
2123
2196
  payloads: dict[str, dict[str, Any]] = {}
2124
2197
  if mode == "general":
2125
2198
  existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
2126
2199
  payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
2127
- if st.button("Gerar títulos e thumbnails para todos os canais", key=f"{prefix}_generate_general_creative", use_container_width=True):
2128
- settings = read_json("settings.json", {})
2129
- new_payloads: dict[str, dict[str, Any]] = {}
2130
- errors: list[str] = []
2131
- with st.spinner("A gerar títulos e thumbnails independentes por canal…"):
2132
- for channel in all_channels:
2133
- try:
2134
- topic_result = existing_topics.get(channel["id"])
2135
- if not topic_result:
2136
- topic_result = generate_topic_for_ui(settings, channel, general_context)
2137
- generated = generate_creative_for_ui(settings, channel, topic_result["topic"], topic_source="llm")
2138
- generated["ai_generation"]["topic"] = topic_result
2139
- new_payloads[channel["id"]] = generated
2140
- except CreativeGenerationError as exc:
2141
- errors.append(f"{channel.get('name', 'Canal')}: {exc}")
2142
- if errors:
2143
- for error in errors:
2144
- st.error(error)
2145
- else:
2146
- st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
2147
- st.session_state[f"{prefix}_general_payloads"] = new_payloads
2148
- payloads = new_payloads
2149
- st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
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).")
2150
2225
  payloads = st.session_state.get(f"{prefix}_general_payloads", payloads)
2151
2226
  for channel in all_channels:
2152
2227
  payload = payloads.get(channel["id"])
2153
2228
  if not payload:
2154
2229
  continue
2155
- with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
2156
- title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
2157
- if title_options:
2158
- 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']}")
2159
- payload["title"] = selected_title
2230
+ with st.expander(f"{channel.get('name', 'Canal')} — thumbnail", expanded=False):
2160
2231
  variants = payload.get("thumbnail_variants", [])
2161
2232
  if variants:
2162
2233
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
@@ -2197,83 +2268,87 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2197
2268
  st.caption("A imagem ainda não foi gerada. Configure a API key em Configuração API > API Keys.")
2198
2269
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
2199
2270
  else:
2200
- topic_for_creative = str(generation_settings.get("video_subject") or "").strip()
2201
- if st.button("Gerar títulos e thumbnails com IA", key=f"{prefix}_generate_creative", use_container_width=True):
2202
- if selected_one is None:
2203
- st.error("Seleccione primeiro um canal.")
2204
- else:
2205
- try:
2206
- if not topic_for_creative:
2207
- topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one)
2208
- topic_for_creative = str(topic_result["topic"]).strip()
2209
- st.session_state[f"{prefix}_topic"] = topic_for_creative
2210
- st.session_state[f"{prefix}_topic_meta"] = topic_result
2211
- generated = generate_creative_for_ui(
2212
- read_json("settings.json", {}),
2213
- selected_one,
2214
- topic_for_creative,
2215
- topic_source=_video_topic_source(topic_for_creative, prefix),
2216
- )
2217
-
2218
- st.session_state[f"{prefix}_creative_payload"] = generated
2219
- st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
2220
- st.rerun()
2221
- except CreativeGenerationError as exc:
2222
- st.error(str(exc))
2223
- payload = st.session_state.get(f"{prefix}_creative_payload")
2224
- if payload:
2225
- st.subheader("Título e Thumbnail automáticos")
2226
- title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
2227
- if title_options:
2228
- 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")
2229
- payload["title"] = selected_title
2230
- with st.expander(f"Ver {len(title_options)} candidatos de título"):
2231
- st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
2232
- variants = payload.get("thumbnail_variants", [])
2233
- if variants:
2234
- labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
2235
- selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
2236
- variant_index = labels.index(selected_variant_label)
2237
- variant = variants[variant_index]
2238
- payload["thumbnail_variant"] = variant
2239
- payload["thumbnail_prompt"] = variant.get("image_prompt", "")
2240
- payload["thumbnail_text"] = variant.get("overlay_text", "")
2241
- st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
2242
- st.code(variant.get("image_prompt", ""), language="text")
2243
- thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2244
- 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:
2245
2279
  try:
2246
- thumbnail_path = str(
2247
- generate_thumbnail_image(
2248
- read_json("settings.json", {}),
2249
- variant.get("image_prompt", ""),
2250
- topic=str(payload.get("topic") or ""),
2251
- variant_index=variant_index,
2252
- lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
2253
- lettering_prompt=str(variant.get("lettering_prompt") or ""),
2254
- )
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),
2255
2289
  )
2256
- variant["image_path"] = thumbnail_path
2257
- payload["thumbnail_path"] = thumbnail_path
2258
- payload["thumbnail_status"] = "generated"
2259
- st.session_state[f"{prefix}_creative_payload"] = payload
2260
- 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}")
2261
- st.success("Thumbnail gerada com Nano Banana.")
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.")
2262
2296
  st.rerun()
2263
- except ThumbnailGenerationError as exc:
2297
+ except CreativeGenerationError as exc:
2264
2298
  st.error(str(exc))
2265
- if thumbnail_path and Path(thumbnail_path).is_file():
2266
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
2267
- payload["thumbnail_path"] = thumbnail_path
2268
- payload["thumbnail_status"] = "generated"
2269
- else:
2270
- 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.")
2271
- st.session_state[f"{prefix}_creative_payload"] = payload
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
2272
2349
 
2273
2350
  st.session_state[f"{prefix}_generation_settings"] = dict(generation_settings)
2274
2351
  with st.form(f"{prefix}_form"):
2275
-
2276
- quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
2277
2352
  language = generation_settings["script_language"]
2278
2353
  fmt = generation_settings["video_format"]
2279
2354
  submitted = st.form_submit_button("Criar tarefas", type="primary")
@@ -3625,21 +3700,28 @@ def render_automation():
3625
3700
  for channel in channels:
3626
3701
  channel_id = channel["id"]
3627
3702
  with st.container(border=True):
3628
- cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
3629
- with cols[0]:
3703
+ header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
3704
+ with header_cols[0]:
3630
3705
  if channel.get("thumbnail_url"):
3631
3706
  st.image(channel["thumbnail_url"], width=48)
3632
3707
  else:
3633
3708
  st.markdown("### YT")
3634
- with cols[1]:
3709
+ with header_cols[1]:
3635
3710
  st.write(f"**{channel.get('name', 'Sem nome')}**")
3636
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}")
3637
3716
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
3638
- default_cols = st.columns(4, gap="small")
3717
+ default_cols = st.columns([1.15, 1.15, 1.5, 1.7, 1.35], gap="small")
3639
3718
  with default_cols[0]:
3640
3719
  st.markdown("**Idioma Padrão**")
3641
3720
  st.caption(language_label(channel.get("language") or "pt"))
3642
3721
  with default_cols[1]:
3722
+ st.markdown("**Nicho Padrão**")
3723
+ st.caption(channel_niche_label(channel))
3724
+ with default_cols[2]:
3643
3725
  automation_blueprint = st.selectbox(
3644
3726
  "Blueprint Padrão",
3645
3727
  blueprint_ids,
@@ -3647,9 +3729,6 @@ def render_automation():
3647
3729
  format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
3648
3730
  key=f"automation_blueprint_{channel_id}",
3649
3731
  )
3650
- with default_cols[2]:
3651
- st.markdown("**Nicho Padrão**")
3652
- st.caption(channel_niche_label(channel))
3653
3732
  with default_cols[3]:
3654
3733
  automation_voice = st.selectbox(
3655
3734
  "Narrador/Voz Padrão",
@@ -3658,11 +3737,7 @@ def render_automation():
3658
3737
  format_func=lambda item: item or "Sem voz padrão",
3659
3738
  key=f"automation_voice_{channel_id}",
3660
3739
  )
3661
- with cols[2]:
3662
- enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
3663
- with cols[3]:
3664
- schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
3665
- with cols[4]:
3740
+ with default_cols[4]:
3666
3741
  if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
3667
3742
  if not valid_hhmm(schedule_time):
3668
3743
  st.error("Use o formato HH:MM, por exemplo 08:30.")
@@ -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
- thumbnail_path = str(payload.get("thumbnail_path") or "").strip()
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": payload.get("thumbnail_variant", {}),
165
- "thumbnail_variants": payload.get("thumbnail_variants", []),
166
- "thumbnail_prompt": payload.get("thumbnail_prompt", ""),
167
- "thumbnail_text": payload.get("thumbnail_text", ""),
168
- "thumbnail_status": payload.get("thumbnail_status", "not_generated"),
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
- creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
474
- title = str(creative.get("title") or topic).strip()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.41",
3
+ "version": "0.3.42",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",