@danhachuel/thunderbolt 0.3.61 → 0.3.63

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.
Files changed (42) hide show
  1. package/MANUAL-INSTALACAO.md +20 -6
  2. package/README.md +10 -2
  3. package/app/main.py +26 -6
  4. package/hermes_ui/automation_worker.py +6 -1
  5. package/hermes_ui/domain.py +14 -2
  6. package/hermes_ui/logs.py +43 -0
  7. package/hermes_ui/media_generation.py +5 -1
  8. package/hermes_ui/notifications.py +18 -1
  9. package/hermes_ui/pipeline_worker.py +418 -24
  10. package/package.json +1 -3
  11. package/seed/skills/mpt_agent.py +13 -3
  12. package/storage/blueprints/importados/demo-content-hermes.json +0 -23
  13. package/storage/tiktok/prompts_master/2Dstickman.md +0 -193
  14. package/storage/tiktok/prompts_master/3Dskeletoncharacter.md +0 -1
  15. package/storage/tiktok/prompts_master/ANIMALMOUNTEDCAMERA.md +0 -299
  16. package/storage/tiktok/prompts_master/ASMR.md +0 -74
  17. package/storage/tiktok/prompts_master/ASMRDEBOCACOMDOCES.md +0 -130
  18. package/storage/tiktok/prompts_master/AnimalX-Ray.md +0 -136
  19. package/storage/tiktok/prompts_master/CAREVOLUTION.md +0 -176
  20. package/storage/tiktok/prompts_master/CINEMATICBUNKERSURVIVAL.md +0 -180
  21. package/storage/tiktok/prompts_master/CINEMATICSLUMTRANSFORMATIONENGINE.md +0 -212
  22. package/storage/tiktok/prompts_master/CINEMATOGR/303/201FICOS3D.md +0 -207
  23. package/storage/tiktok/prompts_master/CartoonNostalgia.md +0 -131
  24. package/storage/tiktok/prompts_master/EPOXYFLOORTRANSFORMATION.md +0 -254
  25. package/storage/tiktok/prompts_master/Edgar'sHouseofTerrorsChooseYourStory.md +0 -134
  26. package/storage/tiktok/prompts_master/FOODBABYFACECHARACTER.md +0 -141
  27. package/storage/tiktok/prompts_master/FuitNovels.md +0 -166
  28. package/storage/tiktok/prompts_master/KIDSSTORYCREATOR(PROMODE).md +0 -235
  29. package/storage/tiktok/prompts_master/MatchstickPrompt.md +0 -86
  30. package/storage/tiktok/prompts_master/PERSONAGENSABANDONADOS.md +0 -208
  31. package/storage/tiktok/prompts_master/Pixar-styleanimated.md +0 -193
  32. package/storage/tiktok/prompts_master/RESTORATIONTIMELAPSE.md +0 -262
  33. package/storage/tiktok/prompts_master/RUSTREMOVAL.md +0 -275
  34. package/storage/tiktok/prompts_master/ReceitasIndianas.md +0 -60
  35. package/storage/tiktok/prompts_master/SkyscraperMonster.md +0 -140
  36. package/storage/tiktok/prompts_master/TIMETRAVELVLOG.md +0 -264
  37. package/storage/tiktok/prompts_master/Tarot.md +0 -58
  38. package/storage/tiktok/prompts_master/UFOs(OVNIs).md +0 -63
  39. package/storage/tiktok/prompts_master/VisualCraftSequenceGenerator.md +0 -284
  40. package/storage/tiktok/prompts_master/craftingStorytellingAI.md +0 -79
  41. package/storage/tiktok/prompts_master/hist/303/263riasinfantishoradedormir.md +0 -71
  42. package/storage/tiktok/prompts_master/viralTikTokdance.md +0 -77
@@ -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.61
5
+ > **Versão deste manual:** 0.3.63
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)
@@ -41,6 +41,20 @@ A geração guarda prompt, caption, provider, modelo, plataforma, estado e camin
41
41
 
42
42
  O runtime do Thunderbolt adapta o comportamento dos workflows do episódio 35, mas não instala nem executa n8n. Formulários, Data Tables, Switch, Wait e subworkflows são substituídos por páginas Streamlit, repository Supabase/SQLite, adapters multimédia, worker, logs e notificações. Nunca coloque API keys nos JSONs dos workflows, em ficheiros versionados ou em mensagens de diagnóstico.
43
43
 
44
+ ## Pipeline de vídeo: fontes, composição e ordem
45
+
46
+ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, seleccione a fonte no próprio vídeo ou na automação. **Pexels/Pixabay** é a rota stock e usa a infraestrutura do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): pesquisa por keywords, filtros de proporção e duração, cache e deduplicação de clips, descarga de materiais e composição MoviePy/FFmpeg com concatenação, transições, narração, legendas e música de fundo. A fonte escolhida é passada explicitamente ao processo (`pexels` ou `pixabay`) e as listas de API keys são sincronizadas no `config.toml`, permitindo a rotação configurada.
47
+
48
+ A execução segue sempre **Tema → Script → Título → Keywords (opcional) → Vídeo → Prompt Thumbnail (JSON) → Thumbnail (imagem) → Upload**. Keywords são opcionais: se a geração por LLM falhar, é usado um fallback determinístico para não bloquear a etapa de vídeo. O worker guarda o MP4 antes de iniciar prompt ou imagem da thumbnail; assim, uma falha de quota na imagem não elimina um vídeo concluído.
49
+
50
+ | Opção visível | Rota executada | Resultado esperado |
51
+ |---|---|---|
52
+ | Pexels/Pixabay | MoneyPrinterTurbo stock com a fonte efectiva e as chaves correspondentes | MP4 composto localmente |
53
+ | Full IA | Pool de vídeo limitado a FAL AI, KIE AI e Agnes AI | MP4 gerado pelo provider activo |
54
+ | Apenas Música | Áudio local/Suno já preparado | Ficheiro musical pronto para um upload de música; não gera MP4, thumbnail nem upload YouTube |
55
+
56
+ Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. Se o MoneyPrinterTurbo devolver `MPT_NEEDS_INPUT`, o Thunderbolt lê `LLM_PROVIDER`, `MISSING` e `INVALID` e mostra no erro todas as APIs afectadas, como **OpenAI / NVIDIA NIM API + Pexels API**, incluindo os campos que faltam. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
57
+
44
58
  ## 2. Pré-requisitos
45
59
 
46
60
  Instale os seguintes componentes antes de executar o instalador:
@@ -114,13 +128,13 @@ Execute:
114
128
  Windows PowerShell ou MobaXterm:
115
129
 
116
130
  ```powershell
117
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install
118
132
  ```
119
133
 
120
134
  Linux/macOS:
121
135
 
122
136
  ```bash
123
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install
137
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install
124
138
  ```
125
139
 
126
140
  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.
@@ -128,7 +142,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
128
142
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
129
143
 
130
144
  ```powershell
131
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install --purge-data
145
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install --purge-data
132
146
  ```
133
147
 
134
148
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -524,7 +538,7 @@ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compacto
524
538
 
525
539
  A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal, dentro de um expander fechado por defeito, e não dentro de Criação de Vídeos. Clique no expander e depois em **Actualizar últimos 10 vídeos** para consultar o feed RSS público do YouTube sem Data API Key. Os resultados são guardados em `storage/state/channel_videos.json`. A única vista disponível é **Lista**, que apresenta título, data, URL, estado e botão **Editar vídeo**. A edição local permite alterar título, estado, data, URL e notas. Essas alterações são overrides de gestão local e não publicam automaticamente no YouTube.
526
540
 
527
- A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso** e **Detalhes**; estados como pendente, em execução, concluído, publicado, falha, cancelado e bloqueado são apresentados quando existirem no storage local.
541
+ A página **Configurações > Logs** aparece no menu entre **Notificações** e **Configuração API**. Mostra uma projecção unificada das tarefas e notificações persistentes, com filtro livre por operação, filtros por operação e estado e as colunas mínimas **Operação**, **Estado**, **Data** e **Hora**. A tabela também mostra **Registo**, **Origem**, **Progresso**, **API/Provider** e **Detalhes**; em qualquer falha nova, **API/Provider** identifica a API ou o pool responsável, o serviço, a rota e os campos de configuração em falta. Registos anteriores sem essa metadata aparecem explicitamente como falhas históricas cuja API não pôde ser identificada. Estados como pendente, em execução, concluído, publicado, falha, cancelado e bloqueado são apresentados quando existirem no storage local.
528
542
 
529
543
  ## Canais em lote por conta Google/YouTube
530
544
 
@@ -542,7 +556,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
542
556
 
543
557
  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.
544
558
 
545
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.61 install`; o instalador detecta e reutiliza o que já estiver válido.
559
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.63 install`; o instalador detecta e reutiliza o que já estiver válido.
546
560
 
547
561
  ### Niche Finder Apify
548
562
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Consulte o [Manual completo de instalação](MANUAL-INSTALACAO.md) antes do primeiro teste local.
4
4
 
5
- Thunderbolt — UI web local da Fase 3, baseada no fluxo Streamlit do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). A aplicação organiza canais, blueprints, lotes de vídeos, filas, artefactos e upload em armazenamento local JSON.
5
+ Thunderbolt — UI web local da Fase 3, baseada no fluxo Streamlit do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). A aplicação organiza canais, blueprints, lotes de vídeos, filas, artefactos e upload em armazenamento local JSON. O pacote npm distribui apenas os seeds versionados em `seed/`; não inclui `storage/` persistente, pelo que Blueprints, personagens, configurações e artefactos locais não são enviados durante uma actualização.
6
6
 
7
7
  ## Estado actual
8
8
 
@@ -38,6 +38,14 @@ A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Im
38
38
 
39
39
  A migração é baseada no workflow público [AI Agents A-Z — episódio 35](https://github.com/gyoridavid/ai_agents_az/tree/main/episode_35), mas o runtime do Thunderbolt não instala nem executa n8n. Os formulários, Data Tables, waits e subworkflows são substituídos pela UI Streamlit, pelo repositório dual Supabase/SQLite, pelos adapters multimédia e pelos logs/notificações locais. As API keys são sempre configuradas localmente e não devem ser colocadas nos JSONs do workflow ou no GitHub.
40
40
 
41
+ ## Pipeline de vídeo — fontes e ordem de execução
42
+
43
+ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações.
44
+
45
+ A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa exclusivamente os cartões activos FAL AI, KIE AI e Agnes AI do pool de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
46
+
47
+ Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
48
+
41
49
  ## Upload-Post — publicação para múltiplas plataformas
42
50
 
43
51
  A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
@@ -79,7 +87,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
79
87
 
80
88
  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.
81
89
 
82
- ## Navegação da UI 0.3.61
90
+ ## Navegação da UI 0.3.63
83
91
 
84
92
  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**, **Geração de Conteúdo IA**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A página Geração de Conteúdo IA divide-se em **Imagens**, **Vídeos** e **Motion Control**; a subaba Motion Control permanece vazia nesta release.
85
93
  **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.
package/app/main.py CHANGED
@@ -97,6 +97,7 @@ AI_STYLE_OPTIONS = [
97
97
  ]
98
98
 
99
99
  WIDE_STYLE_OPTIONS = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
100
+ MATERIAL_SOURCE_OPTIONS = ["Pexels", "Pixabay"]
100
101
 
101
102
  VIDEO_LANGUAGE_OPTIONS = [
102
103
  "00 – Apenas Música de Fundo (Sem Falas)",
@@ -993,6 +994,18 @@ def render_video_generation_settings(
993
994
  video_cols = st.columns(2)
994
995
  with video_cols[0]:
995
996
  settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
997
+ if settings["video_source"] == "Pexels/Pixabay":
998
+ configured_material_source = selected_material_source(read_json("settings.json", {}))
999
+ material_source_labels = {"pexels": "Pexels", "pixabay": "Pixabay"}
1000
+ configured_material_label = material_source_labels.get(configured_material_source, "Pexels")
1001
+ settings["material_source"] = st.selectbox(
1002
+ "Stock Material Source",
1003
+ MATERIAL_SOURCE_OPTIONS,
1004
+ index=MATERIAL_SOURCE_OPTIONS.index(configured_material_label),
1005
+ key=f"{prefix}_material_source",
1006
+ )
1007
+ else:
1008
+ settings["material_source"] = ""
996
1009
  if settings["video_source"] == "full_ia":
997
1010
  settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
998
1011
  else:
@@ -2037,6 +2050,7 @@ def _create_video_task_from_saved_script(record: dict[str, Any], channel: dict[s
2037
2050
  "format": settings.get("video_format", "wide"),
2038
2051
  "style_wide": style,
2039
2052
  "style_ia": settings.get("style_ia", ""),
2053
+ "material_source": settings.get("material_source", "") if style == "pexels" else "",
2040
2054
  "music_mode": style == "music",
2041
2055
  "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"),
2042
2056
  "voice": str(settings.get("voice") or channel.get("default_voice") or channel.get("voice") or ""),
@@ -2266,6 +2280,12 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2266
2280
  ),
2267
2281
  )
2268
2282
  wide_style_label = generation_settings["video_source"]
2283
+ style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
2284
+ material_source = (
2285
+ {"Pexels": "pexels", "Pixabay": "pixabay"}.get(str(generation_settings.get("material_source") or ""), "")
2286
+ if style == "pexels"
2287
+ else ""
2288
+ )
2269
2289
  style_ia = generation_settings.get("style_ia", "")
2270
2290
  music_path = ""
2271
2291
  music_source = ""
@@ -2469,7 +2489,6 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2469
2489
  submitted = st.form_submit_button("Criar tarefas", type="primary")
2470
2490
  if submitted:
2471
2491
  save_video_language(language)
2472
- style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
2473
2492
  if style == "music" and not music_path:
2474
2493
  st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
2475
2494
  st.stop()
@@ -2517,8 +2536,8 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2517
2536
  st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
2518
2537
  if len(payloads) == len(selected):
2519
2538
  batch_topic = "Lote geral — um vídeo independente por canal"
2520
- channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings} for cid, payload in payloads.items()}
2521
- batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings, "topic_source": "llm", "channel_payloads": channel_payloads})
2539
+ channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings} for cid, payload in payloads.items()}
2540
+ batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings, "topic_source": "llm", "channel_payloads": channel_payloads})
2522
2541
  tasks = create_tasks_for_batch(batch)
2523
2542
  st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
2524
2543
  else:
@@ -2542,8 +2561,8 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2542
2561
  except CreativeGenerationError as exc:
2543
2562
  st.warning(f"Título/keywords automáticos pendentes: {exc} A tarefa será criada com o tópico como título; o prompt e a imagem da thumbnail serão gerados depois do vídeo.")
2544
2563
  payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
2545
- payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or _video_topic_source(topic_value), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings})
2546
- batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
2564
+ payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or _video_topic_source(topic_value), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings})
2565
+ batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "material_source": material_source, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
2547
2566
  tasks = create_tasks_for_batch(batch)
2548
2567
  st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
2549
2568
 
@@ -5843,10 +5862,11 @@ def render_logs():
5843
5862
  "Registo": st.column_config.TextColumn("Registo", width=280),
5844
5863
  "Origem": st.column_config.TextColumn("Origem", width=130),
5845
5864
  "Progresso": st.column_config.TextColumn("Progresso", width=100),
5865
+ "API/Provider": st.column_config.TextColumn("API/Provider", width=220),
5846
5866
  "Detalhes": st.column_config.TextColumn("Detalhes", width=760),
5847
5867
  },
5848
5868
  )
5849
- st.caption("Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
5869
+ st.caption("A coluna API/Provider identifica a API responsável por cada falha; quando o registo é anterior a esta correcção, o sistema assinala que a API não pôde ser identificada. Quando a tabela exceder a largura disponível, utilize a barra de rolagem horizontal na parte inferior para consultar todo o conteúdo das células.")
5850
5870
 
5851
5871
 
5852
5872
  def render_notifications():
@@ -10,6 +10,7 @@ from typing import Any
10
10
  from . import storage
11
11
  from .creative_generation import generate_title_and_keywords, generate_topic_for_channel
12
12
  from .domain import create_batch, create_tasks_for_batch
13
+ from .material_sources import selected_material_source
13
14
  from .notifications import record_notification
14
15
 
15
16
  WORKER_STATE_FILE = "automation_worker.json"
@@ -156,6 +157,8 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
156
157
  blueprint,
157
158
  language=str(channel.get("language") or "Português"),
158
159
  )
160
+ style_wide = str(channel.get("style_wide") or "pexels").strip().casefold()
161
+ material_source = selected_material_source(settings) if style_wide in {"pexels", "pexels/pixabay", "stock"} else ""
159
162
  payload = {
160
163
  "topic": topic_package["topic"],
161
164
  "topic_source": "llm",
@@ -169,7 +172,8 @@ def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
169
172
  "blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
170
173
  "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
171
174
  "voice": str(channel.get("default_voice") or channel.get("voice") or ""),
172
- "generation_settings": {"video_keywords": editorial.get("keywords", [])},
175
+ "material_source": material_source,
176
+ "generation_settings": {"video_keywords": editorial.get("keywords", []), "material_source": material_source},
173
177
  "ai_generation": {"topic": topic_package, "editorial": editorial},
174
178
  }
175
179
  return topic_package["topic"], payload
@@ -204,6 +208,7 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
204
208
  "format": "wide",
205
209
  "style_wide": style_wide,
206
210
  "style_ia": channel.get("style_ia") or "",
211
+ "material_source": payload.get("material_source") or "",
207
212
  "music_mode": music_mode,
208
213
  "background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
209
214
  "music_path": channel.get("music_path") or "",
@@ -160,6 +160,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
160
160
  "language": payload.get("language", options.get("language", channel.get("language", "Português"))),
161
161
  "format": payload.get("format", options.get("format", "wide")),
162
162
  "style_wide": payload.get("style_wide", options.get("style_wide", channel.get("style_wide", "pexels"))),
163
+ "material_source": payload.get("material_source", options.get("material_source", channel.get("material_source", ""))),
163
164
  "style_ia": payload.get("style_ia", options.get("style_ia", "")),
164
165
  "music_mode": payload.get("music_mode", options.get("music_mode", False)),
165
166
  "music_path": payload.get("music_path", options.get("music_path", "")),
@@ -220,11 +221,22 @@ def _notify_task_completion(task: dict[str, Any], previous_state: str = "") -> N
220
221
  title = str(task.get("title") or task.get("topic") or "Actividade")
221
222
  channel = str(task.get("channel_name") or "Canal")
222
223
  if current_state == "failed":
224
+ failure_api = str(task.get("failure_api") or "API não identificada (falha anterior)").strip()
223
225
  record_notification(
224
226
  "activity_failed",
225
227
  f"Actividade falhou: {title}",
226
- f"A actividade de {channel} terminou com erro.",
227
- metadata={"task_id": task_id, "channel_name": channel, "error": task.get("error") or ""},
228
+ f"A actividade de {channel} terminou com erro. API/provider: {failure_api}.",
229
+ metadata={
230
+ "task_id": task_id,
231
+ "channel_name": channel,
232
+ "error": task.get("error") or "",
233
+ "failure_api": task.get("failure_api") or "API não identificada (falha anterior)",
234
+ "failure_provider": task.get("failure_provider") or "unknown",
235
+ "failure_service": task.get("failure_service") or "Thunderbolt",
236
+ "failure_route": task.get("failure_route") or "",
237
+ "failure_config_fields": task.get("failure_config_fields") or "",
238
+ "failure_stage": task.get("failure_stage") or task.get("failed_stage") or task.get("stage") or "pipeline",
239
+ },
228
240
  dedupe_key=f"task:{task_id}:failed",
229
241
  )
230
242
  return
package/hermes_ui/logs.py CHANGED
@@ -76,6 +76,29 @@ def _operation_label(operation_code: str, fallback: str = "Operação") -> str:
76
76
  return str(event.get("label") if event else fallback or operation_code or "Operação")
77
77
 
78
78
 
79
+ def _fallback_task_failure_api(task: dict[str, Any]) -> str:
80
+ """Infer a safe API label for legacy failed tasks without structured metadata."""
81
+ route = str(task.get("material_source") or task.get("style_wide") or "").strip().casefold()
82
+ if "pexels" in route and "pixabay" in route:
83
+ return "Pexels/Pixabay API (fonte efectiva não registada)"
84
+ if "pixabay" in route:
85
+ return "Pixabay API"
86
+ if "pexels" in route or "stock" in route:
87
+ return "Pexels/Pixabay API (fonte efectiva não registada)"
88
+ if route in {"full_ia", "full ia", "ia"}:
89
+ return "FAL AI / KIE AI / Agnes AI"
90
+ if route in {"music", "musica", "música"} or bool(task.get("music_mode")):
91
+ return "Suno / Ficheiro local"
92
+ stage = str(task.get("stage") or task.get("failed_stage") or "").strip().casefold()
93
+ if stage in {"topic", "script", "title", "keywords", "thumbnail_prompt"}:
94
+ return "LLM textual API (provider não registado)"
95
+ if stage == "thumbnail":
96
+ return "Pool de imagem API (provider não registado)"
97
+ if stage == "upload":
98
+ return "YouTube Upload API"
99
+ return "API não identificada (falha anterior)"
100
+
101
+
79
102
  def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
80
103
  task_id = str(task.get("id") or "").strip()
81
104
  if not task_id:
@@ -100,6 +123,20 @@ def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
100
123
  if progress_value is not None:
101
124
  details.append(f"Progresso: {progress_value}%")
102
125
  error = str(task.get("error") or "").strip()
126
+ failure_api = str(task.get("failure_api") or "").strip()
127
+ failure_provider = str(task.get("failure_provider") or "").strip()
128
+ failure_service = str(task.get("failure_service") or "").strip()
129
+ failure_fields = str(task.get("failure_config_fields") or "").strip()
130
+ api_provider = failure_api
131
+ if state in {"failed", "error"}:
132
+ api_provider = api_provider or _fallback_task_failure_api(task)
133
+ details.append(f"API/provider: {api_provider}")
134
+ if failure_provider:
135
+ details.append(f"Provider: {failure_provider}")
136
+ if failure_service:
137
+ details.append(f"Serviço: {failure_service}")
138
+ if failure_fields:
139
+ details.append(f"Configuração: {failure_fields}")
103
140
  if error:
104
141
  details.append(error[:500])
105
142
  return {
@@ -114,6 +151,7 @@ def _task_log(task: dict[str, Any]) -> dict[str, Any] | None:
114
151
  "source": "Tarefas",
115
152
  "record": title,
116
153
  "details": " · ".join(details),
154
+ "api_provider": api_provider or "",
117
155
  "progress": progress_value,
118
156
  "task_id": task_id,
119
157
  }
@@ -141,6 +179,9 @@ def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
141
179
  date, time = format_log_date_time(occurred_at)
142
180
  metadata = entry.get("metadata") if isinstance(entry.get("metadata"), dict) else {}
143
181
  public_metadata = [f"{key}: {value}" for key, value in metadata.items() if value not in (None, "")]
182
+ api_provider = str(metadata.get("failure_api") or metadata.get("api_provider") or "").strip()
183
+ if status_code in {"failed", "error"}:
184
+ api_provider = api_provider or "API não identificada (falha anterior)"
144
185
  return {
145
186
  "id": f"notification:{entry_id}",
146
187
  "operation_code": event_type,
@@ -153,6 +194,7 @@ def _notification_log(entry: dict[str, Any]) -> dict[str, Any] | None:
153
194
  "source": "Notificações",
154
195
  "record": str(entry.get("title") or entry.get("label") or "Notificação"),
155
196
  "details": " · ".join([str(entry.get("message") or "").strip(), *public_metadata]).strip(" ·")[:1000],
197
+ "api_provider": api_provider,
156
198
  "progress": None,
157
199
  "task_id": str(metadata.get("task_id") or ""),
158
200
  }
@@ -227,6 +269,7 @@ def logs_to_rows(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
227
269
  "Registo": item.get("record") or "—",
228
270
  "Origem": item.get("source") or "—",
229
271
  "Progresso": f"{item['progress']}%" if item.get("progress") is not None else "—",
272
+ "API/Provider": item.get("api_provider") or "—",
230
273
  "Detalhes": item.get("details") or "—",
231
274
  }
232
275
  for item in records
@@ -489,9 +489,13 @@ def generate_video_from_pool(
489
489
  *,
490
490
  image_url: str = "",
491
491
  output_path: Path | None = None,
492
+ allowed_providers: set[str] | None = None,
492
493
  ) -> Path:
493
- """Try eligible video cards in priority order, keeping image and video pools separate."""
494
+ """Try eligible video cards in priority order, optionally restricted to a route."""
494
495
  cards = media_cards_for_pool(settings, "video")
496
+ if allowed_providers is not None:
497
+ allowed = {str(provider).strip().lower() for provider in allowed_providers}
498
+ cards = [card for card in cards if str(card.get("provider") or "").strip().lower() in allowed]
495
499
  if not cards:
496
500
  raise MediaGenerationError("Não existem providers activos no pool de vídeo.")
497
501
  errors: list[str] = []
@@ -236,7 +236,24 @@ def _task_notifications() -> int:
236
236
  title = str(task.get("title") or task.get("topic") or "Actividade")
237
237
  channel = str(task.get("channel_name") or "Canal")
238
238
  if state == "failed":
239
- if record_notification("activity_failed", f"Actividade falhou: {title}", f"A actividade de {channel} terminou com erro.", metadata={"task_id": task_id, "channel_name": channel}, dedupe_key=f"task:{task_id}:failed"):
239
+ failure_api = str(task.get("failure_api") or "API não identificada (falha anterior)").strip()
240
+ if record_notification(
241
+ "activity_failed",
242
+ f"Actividade falhou: {title}",
243
+ f"A actividade de {channel} terminou com erro. API/provider: {failure_api}.",
244
+ metadata={
245
+ "task_id": task_id,
246
+ "channel_name": channel,
247
+ "error": task.get("error") or "",
248
+ "failure_api": task.get("failure_api") or "API não identificada (falha anterior)",
249
+ "failure_provider": task.get("failure_provider") or "unknown",
250
+ "failure_service": task.get("failure_service") or "Thunderbolt",
251
+ "failure_route": task.get("failure_route") or "",
252
+ "failure_config_fields": task.get("failure_config_fields") or "",
253
+ "failure_stage": task.get("failure_stage") or task.get("failed_stage") or task.get("stage") or "pipeline",
254
+ },
255
+ dedupe_key=f"task:{task_id}:failed",
256
+ ):
240
257
  created += 1
241
258
  continue
242
259
  if state != "done":