@danhachuel/thunderbolt 0.3.36 → 0.3.38

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.
@@ -1,5 +1,4 @@
1
1
  [theme]
2
- base = "dark"
3
2
  font = "sans serif"
4
3
 
5
4
  [theme.dark]
@@ -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.36
5
+ > **Versão deste manual:** 0.3.38
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.36 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.3.38 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.36 install
109
+ npx --yes @danhachuel/thunderbolt@0.3.38 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.
@@ -200,7 +200,7 @@ Todas as subabas internas e o conteúdo das páginas também são traduzidos nos
200
200
 
201
201
  ### Temas Light e Dark
202
202
 
203
- A aplicação usa o mecanismo nativo de temas do Streamlit e é distribuída com `.streamlit/config.toml`, seguindo o padrão de configuração do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O ficheiro define **Dark** como base inicial. A alternância para **Light** e o regresso a **Dark** ficam exclusivamente no menu nativo de três pontos do Streamlit, no local original do toolbar; não existe um selector Theme dentro da página. Os componentes próprios da UI herdam as cores do tema activo através de `currentColor` e `color-mix`. O toolbar nativo, o indicador de execução, **Deploy** e o menu principal continuam sem sobreposições CSS.
203
+ A aplicação usa o mecanismo nativo de temas do Streamlit e é distribuída com `.streamlit/config.toml`, seguindo o padrão de configuração do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O ficheiro disponibiliza as variantes nomeadas **Dark** e **Light**. A alternância entre elas fica exclusivamente no menu nativo de três pontos do Streamlit, no local original do toolbar; não existe um selector Theme dentro da página. Os componentes próprios da UI herdam as cores do tema activo através de `currentColor` e `color-mix`. O toolbar nativo, o indicador de execução, **Deploy** e o menu principal continuam sem sobreposições CSS.
204
204
 
205
205
  ## 4. Diagnóstico antes de iniciar
206
206
 
@@ -386,6 +386,16 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
386
386
  | Telegram Proxy | Proxy HTTP/HTTPS/SOCKS opcional para ambientes sem acesso directo |
387
387
  | Telegram timeout | Limite de espera de cada envio, entre 5 e 120 segundos |
388
388
 
389
+ ### Execução do pipeline de vídeos e acompanhamento
390
+
391
+ Ao clicar em **Start** no **Backlog Vídeos**, a tarefa passa para `doing` e é processada pelo worker de pipeline iniciado pelo launcher normal. O worker grava o estado em `storage/state/pipeline_worker.json` e actualiza `updated_at`, a etapa e a percentagem em `storage/state/tasks.json`. O painel do Backlog consulta esse estado automaticamente a cada cinco segundos e mostra o worker, a etapa corrente, a percentagem, a idade da actualização e a mensagem de erro quando existe.
392
+
393
+ A percentagem representa o avanço conhecido do pipeline: tema, roteiro, título/keywords, thumbnail, vídeo e upload. Durante a chamada longa ao MoneyPrinterTurbo, a UI mantém um heartbeat a cada cinco segundos e avança apenas dentro da faixa reservada à geração do vídeo; não apresenta 100% antes de existir um MP4 válido. O worker passa explicitamente a **Pasta do motor de vídeo** configurada na UI ao helper, por isso o clone usado, os logs e o manifesto pertencem à instalação seleccionada pelo utilizador.
394
+
395
+ Uma execução da etapa Vídeo tem limite de 20 minutos. Se o processo externo terminar com erro, exceder o limite, devolver um resultado inválido ou ocorrer uma excepção inesperada, a tarefa é marcada como `failed`, com a etapa em `failed_stage`. As últimas linhas devolvidas pelo helper, sem as credenciais configuradas, são guardadas num artefacto `video-diagnostics` e as referências `video_log`/`video_result` ficam associadas à tarefa. Se o utilizador clicar em **Stop**, a tarefa passa para `blocked`, o subprocesso é terminado cooperativamente e o worker não substitui esse estado por `failed`.
396
+
397
+ Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem actualização durante 25 minutos é recuperada e marcada como `failed`, evitando estados indefinidos eternos. Para processar novas tarefas, deixe a aplicação iniciada com o comando normal; o painel avisa quando não existe heartbeat recente do worker.
398
+
389
399
  ### Upload Música — JewelMusic, Pushtunes e ytmusicapi
390
400
 
391
401
  A área **Pipeline Música > Upload Música** separa três métodos com contratos diferentes. Em **JewelMusic**, active a integração, introduza a API Key fornecida pelo dashboard da JewelMusic e confirme a Base URL oficial `https://api.jewelmusic.com` e, se necessário, configure proxy e timeout. Carregue ou seleccione um ficheiro de música, indique artista e título e clique em **Enviar música para JewelMusic**. O teste de ligação consulta `/v1/ping`; o upload envia `multipart/form-data` para `/v1/tracks/upload` com os metadados preenchidos.
@@ -449,11 +459,11 @@ A subaba de fontes não é um painel de tuning: endpoints, proxy, qualidade, cor
449
459
 
450
460
  ## Gestão de Canais, edição e vídeos recentes
451
461
 
452
- Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (Voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
462
+ Na página **Configurações > Canais Youtube**, cada canal cadastrado aparece num cartão com o botão **Editar**. O editor permite alterar o nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. Guardar alterações actualiza o mesmo registo local; não é necessário apagar e criar o canal novamente.
453
463
 
454
- O cartão mostra o nicho ou Canais de Referência imediatamente abaixo do nome. Os três blocos de gestão usam os rótulos **Prompts do Canal**, **Canais de Referência** e **Narrador**, com botões que abrem o editor do próprio canal.
464
+ O cartão mostra o nicho imediatamente abaixo do nome. Os quatro blocos compactos de gestão usam os rótulos **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**, com os botões de edição correspondentes; o idioma é apenas apresentado a partir da configuração já guardada no canal.
455
465
 
456
- A secção **Últimos 10 vídeos publicados** fica abaixo das configurações do canal e não dentro de Criação de Vídeos. Clique 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 vista **Lista** apresenta título, data, URL, estado e botão **Editar vídeo**; a vista **Kanban** separa Planejamento, Produção, Finalizado e Agendado/Publicado. 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.
466
+ 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.
457
467
 
458
468
  ## Canais em lote por conta Google/YouTube
459
469
 
@@ -471,7 +481,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
471
481
 
472
482
  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.
473
483
 
474
- 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.36 install`; o instalador detecta e reutiliza o que já estiver válido.
484
+ 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.38 install`; o instalador detecta e reutiliza o que já estiver válido.
475
485
 
476
486
  ### Niche Finder Apify
477
487
 
@@ -509,7 +519,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
509
519
 
510
520
  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.
511
521
 
512
- A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista os canais e vídeos, permite escolher e guardar **Blueprint padrão** e **Voz padrão** por canal, 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.
522
+ 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.
513
523
 
514
524
  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.
515
525
 
package/README.md CHANGED
@@ -12,6 +12,7 @@ A primeira versão implementa a camada UI independente com:
12
12
  |---|---|
13
13
  | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
14
  | Pipeline Vídeos | Menu expansível com Criação de Vídeos, Backlog Vídeos, Roteiros, Thumbnails e Upload |
15
+ | Worker de vídeo | Heartbeat persistido, barra de progresso por etapas, timeout de 20 minutos, recuperação de tarefas abandonadas e diagnóstico bounded do helper MoneyPrinterTurbo |
15
16
  | Pipeline Música | Menu expansível com Criação de Músicas e Upload Música |
16
17
  | Blueprints Youtube | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
17
18
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
@@ -66,9 +67,9 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
66
67
 
67
68
  ## Temas claro e escuro
68
69
 
69
- 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` define **Dark** como base inicial 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.
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.
70
71
 
71
- ## Navegação da UI 0.3.36
72
+ ## Navegação da UI 0.3.38
72
73
 
73
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**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
74
75
 
@@ -90,11 +91,11 @@ A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https:
90
91
 
91
92
  ## Canais Youtube — edição por cartão e vídeos recentes
92
93
 
93
- A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Canais de Referência / Nicho**, **Prompts do Canal** (Blueprint padrão), **Narrador** (voz padrão), conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
94
+ A página **Canais Youtube** mantém o cadastro e a importação existentes, mas cada cartão agora tem o botão **Editar**. O editor permite alterar nome, URL, handle, idioma, estilo wide, **Nicho**, **Blueprint Padrão**, **Narrador/Voz Padrão**, conta Google do Upload directo, descrição e Automação ON/horário. O nicho aparece imediatamente abaixo do nome do canal no cartão; quando não existe, a UI mostra **SEM NICHO CONFIGURADO**.
94
95
 
95
- Os blocos do cartão usam a nomenclatura solicitada: **Prompts do Canal**, **Canais de Referência** e **Narrador**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
96
+ Os quatro blocos compactos do cartão usam a nomenclatura solicitada: **Blueprint Padrão**, **Nicho**, **Narrador/Voz Padrão** e **Idioma**. Os botões de acção abrem o mesmo editor persistente, sem criar um segundo canal nem perder as associações existentes.
96
97
 
97
- Abaixo do cartão, a secção **Últimos 10 vídeos publicados** usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e podem ser apresentados em **Lista** ou **Kanban**, nos grupos Planejamento, Produção, Finalizado e Agendado/Publicado. Cada vídeo tem **Editar vídeo** para alterar localmente o título, estado, data, URL e notas. A fonte pública não substitui o vídeo nem publica alterações no YouTube; os campos editáveis são overrides locais de gestão.
98
+ Abaixo do cartão, a secção **Últimos 10 vídeos publicados** fica num expander fechado por defeito e usa o feed público RSS do YouTube, sem Data API Key. O carregamento ocorre quando se clica em **Actualizar últimos 10 vídeos**, evitando chamadas automáticas ao abrir a página. Os vídeos ficam guardados em `storage/state/channel_videos.json` e são apresentados apenas no modo **Lista**. Cada vídeo tem **Editar vídeo** para alterar localmente o título, estado, data, URL e notas. A fonte pública não substitui o vídeo nem publica alterações no YouTube; os campos editáveis são overrides locais de gestão.
98
99
 
99
100
  ## Criação de Vídeos — geração editorial por canal
100
101
 
package/app/main.py CHANGED
@@ -1,12 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
- import html
5
4
  import json
6
5
  import mimetypes
7
6
  import re
8
7
  from contextlib import nullcontext
9
- from datetime import date, datetime
8
+ from datetime import date, datetime, timezone
10
9
  import sys
11
10
  import uuid
12
11
  from pathlib import Path
@@ -26,6 +25,7 @@ except (OSError, json.JSONDecodeError):
26
25
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
27
26
  from hermes_ui.drafts import list_drafts, save_draft
28
27
  from hermes_ui.automation_worker import load_worker_status
28
+ from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
29
29
  from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
30
30
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
31
31
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
@@ -207,30 +207,6 @@ st.markdown("""
207
207
  [data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
208
208
  [data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
209
209
  [data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
210
- .tb-channel-kanban-card { box-sizing:border-box; min-height:330px; height:330px; padding:0.85rem 0.9rem; border:1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius:14px; background:color-mix(in srgb, currentColor 5%, transparent); color:inherit; box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
211
- .tb-channel-kanban-card__header { display:flex; align-items:center; gap:0.58rem; min-width:0; }
212
- .tb-channel-kanban-card__avatar { width:46px; height:46px; flex:0 0 46px; border-radius:10px; object-fit:cover; background:color-mix(in srgb, var(--tb-accent) 14%, transparent); }
213
- .tb-channel-kanban-card__avatar--fallback { display:flex; align-items:center; justify-content:center; font-weight:800; color:var(--tb-accent); }
214
- .tb-channel-kanban-card__identity { min-width:0; flex:1 1 auto; }
215
- .tb-channel-kanban-card__name { font-size:1rem; font-weight:750; line-height:1.18; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
216
- .tb-channel-kanban-card__niche { margin-top:0.22rem; font-size:0.76rem; opacity:.66; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
217
- .tb-channel-kanban-card__badges { display:flex; gap:0.28rem; flex:0 0 auto; align-items:center; }
218
- .tb-channel-kanban-card__badge { padding:0.18rem 0.42rem; border-radius:6px; font-size:0.67rem; font-weight:750; line-height:1.1; white-space:nowrap; }
219
- .tb-channel-kanban-card__badge--youtube { background:#dc2626; color:#fff; }
220
- .tb-channel-kanban-card__badge--active { background:color-mix(in srgb, #22c55e 25%, transparent); color:#16a34a; border:1px solid color-mix(in srgb, #22c55e 50%, transparent); }
221
- .tb-channel-kanban-card__badge--inactive { background:color-mix(in srgb, #ef4444 18%, transparent); color:#dc2626; border:1px solid color-mix(in srgb, #ef4444 45%, transparent); }
222
- .tb-channel-kanban-card__rule { height:1px; margin:0.72rem 0 0.62rem; background:color-mix(in srgb, currentColor 13%, transparent); }
223
- .tb-channel-kanban-card__metrics { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:0.34rem; }
224
- .tb-channel-kanban-card__metric { min-width:0; min-height:67px; padding:0.48rem 0.28rem 0.36rem; border-radius:8px; text-align:center; background:color-mix(in srgb, currentColor 4%, transparent); }
225
- .tb-channel-kanban-card__metric-value { font-size:1.12rem; font-weight:800; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
226
- .tb-channel-kanban-card__metric-label { margin-top:0.3rem; font-size:0.66rem; opacity:.64; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
227
- .tb-channel-kanban-card__status-row { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.34rem; margin-top:0.6rem; }
228
- .tb-channel-kanban-card__status { min-width:0; padding:0.42rem 0.28rem; border-radius:8px; text-align:center; font-size:0.71rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
229
- .tb-channel-kanban-card__status strong { font-size:0.92rem; margin-right:0.18rem; }
230
- .tb-channel-kanban-card__status--doing { color:#d97706; background:color-mix(in srgb, #f59e0b 14%, transparent); border:1px solid color-mix(in srgb, #f59e0b 35%, transparent); }
231
- .tb-channel-kanban-card__status--done { color:#16a34a; background:color-mix(in srgb, #22c55e 11%, transparent); border:1px solid color-mix(in srgb, #22c55e 30%, transparent); }
232
- .tb-channel-kanban-card__titles { margin-top:0.58rem; font-size:0.73rem; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
233
- @media (max-width: 900px) { .tb-channel-kanban-card { min-height:310px; height:310px; } .tb-channel-kanban-card__name { font-size:.9rem; } }
234
210
  .content-card { box-sizing:border-box; padding:1rem 1.1rem; border:1px solid rgba(128,128,128,.28); border-radius:14px; background:rgba(128,128,128,.10); color:inherit; min-height:110px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
235
211
  .content-card { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 5%, transparent); box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); }
236
212
  .content-label { color:inherit; opacity:.72; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
@@ -967,38 +943,38 @@ def render_channel_video_editor(video: dict, channel_id: str) -> None:
967
943
 
968
944
  def render_channel_videos(channel: dict) -> None:
969
945
  channel_id = str(channel.get("id") or "")
970
- st.markdown("### Últimos 10 vídeos publicados")
971
- st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
972
- refresh_col = st.columns(1)[0]
973
- with refresh_col:
974
- if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
975
- result = fetch_channel_videos_public(channel, limit=10)
976
- if result.ok:
977
- videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
978
- st.session_state[f"channel_videos_{channel_id}"] = videos
979
- st.success(result.message)
980
- st.rerun()
981
- else:
982
- st.warning(result.message)
983
- videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
984
- if not videos:
985
- st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
986
- return
987
- for video in videos[:10]:
988
- with st.container(border=True):
989
- cols = st.columns([0.7, 3.4, 1.4, 1.1])
990
- with cols[0]:
991
- if video.get("thumbnail_url"):
992
- st.image(video["thumbnail_url"], width=64)
946
+ with st.expander("Últimos 10 vídeos publicados", expanded=False):
947
+ st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
948
+ refresh_col = st.columns(1)[0]
949
+ with refresh_col:
950
+ if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
951
+ result = fetch_channel_videos_public(channel, limit=10)
952
+ if result.ok:
953
+ videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
954
+ st.session_state[f"channel_videos_{channel_id}"] = videos
955
+ st.success(result.message)
956
+ st.rerun()
993
957
  else:
994
- st.markdown("### YT")
995
- with cols[1]:
996
- st.write(f"**{video.get('title', 'Vídeo sem título')}**")
997
- st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
998
- with cols[2]:
999
- st.caption(str(video.get("status") or "publicado").title())
1000
- with cols[3]:
1001
- render_channel_video_editor(video, channel_id)
958
+ st.warning(result.message)
959
+ videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
960
+ if not videos:
961
+ st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
962
+ return
963
+ for video in videos[:10]:
964
+ with st.container(border=True):
965
+ cols = st.columns([0.7, 3.4, 1.4, 1.1])
966
+ with cols[0]:
967
+ if video.get("thumbnail_url"):
968
+ st.image(video["thumbnail_url"], width=64)
969
+ else:
970
+ st.markdown("### YT")
971
+ with cols[1]:
972
+ st.write(f"**{video.get('title', 'Vídeo sem título')}**")
973
+ st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
974
+ with cols[2]:
975
+ st.caption(str(video.get("status") or "publicado").title())
976
+ with cols[3]:
977
+ render_channel_video_editor(video, channel_id)
1002
978
 
1003
979
 
1004
980
  def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
@@ -1020,10 +996,10 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
1020
996
  style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
1021
997
  style_value = {"pexels": "Pexels/Pixabay", "music": "Apenas Música"}.get(str(channel.get("style_wide") or "pexels"), str(channel.get("style_wide") or "Pexels/Pixabay"))
1022
998
  edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
1023
- edited_niche = st.text_input("Canais de Referência / Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
999
+ edited_niche = st.text_input("Nicho", value=str(channel.get("niche") or channel_niche_label(channel) if channel_niche_label(channel) != "SEM NICHO CONFIGURADO" else "") )
1024
1000
  with edit_cols[1]:
1025
- edited_blueprint = st.selectbox("Prompts do Canal", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
1026
- edited_voice = st.selectbox("Narrador", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
1001
+ edited_blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"))
1002
+ edited_voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão")
1027
1003
  edited_account = st.selectbox("Conta Google para Upload directo", account_ids, index=account_ids.index(current_account) if current_account in account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item or "Sem conta Google associada"))
1028
1004
  edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
1029
1005
  edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
@@ -1437,101 +1413,6 @@ def render_tiktok_prompt_masters():
1437
1413
  st.error(str(exc))
1438
1414
 
1439
1415
 
1440
- def _format_channel_metric(value: Any) -> str:
1441
- """Format a channel metric compactly for the Kanban card."""
1442
- if value is None or value == "":
1443
- return "—"
1444
- try:
1445
- number = int(value)
1446
- except (TypeError, ValueError):
1447
- return html.escape(str(value))
1448
- if abs(number) >= 1_000_000:
1449
- return f"{number / 1_000_000:.1f}M".replace(".0M", "M")
1450
- if abs(number) >= 1_000:
1451
- return f"{number / 1_000:.1f}K".replace(".0K", "K")
1452
- return f"{number:,}".replace(",", ".")
1453
-
1454
-
1455
- def _channel_pipeline_counts(channel: dict[str, Any]) -> tuple[int, int, int]:
1456
- """Return in-production, completed and titled task counts for one channel."""
1457
- channel_id = str(channel.get("id") or "")
1458
- tasks = read_json("tasks.json", [])
1459
- if not channel_id or not isinstance(tasks, list):
1460
- return 0, 0, 0
1461
- related = [
1462
- task for task in tasks
1463
- if isinstance(task, dict) and str(task.get("channel_id") or "") == channel_id
1464
- ]
1465
- in_production = sum(1 for task in related if str(task.get("state") or "").lower() == "doing")
1466
- completed = sum(1 for task in related if str(task.get("state") or "").lower() == "done")
1467
- titled = sum(1 for task in related if str(task.get("title") or "").strip() or task.get("title_candidates"))
1468
- return in_production, completed, titled
1469
-
1470
-
1471
- def _render_channel_kanban_card(channel: dict[str, Any]) -> None:
1472
- """Render one compact, square-style channel card for the global Kanban view."""
1473
- ui_language = current_ui_language()
1474
- name = html.escape(str(channel.get("name") or "Sem nome"))
1475
- niche = html.escape(channel_niche_label(channel))
1476
- thumbnail_url = str(channel.get("thumbnail_url") or "").strip()
1477
- if thumbnail_url:
1478
- avatar = f'<img class="tb-channel-kanban-card__avatar" src="{html.escape(thumbnail_url, quote=True)}" alt="">'
1479
- else:
1480
- avatar = '<div class="tb-channel-kanban-card__avatar tb-channel-kanban-card__avatar--fallback">YT</div>'
1481
- active = bool(channel.get("active", True))
1482
- active_label = ui_text("Activo" if active else "Inactivo", ui_language)
1483
- active_class = "active" if active else "inactive"
1484
- subscribers = _format_channel_metric(channel.get("subscriber_count"))
1485
- views = _format_channel_metric(channel.get("view_count"))
1486
- videos = _format_channel_metric(channel.get("video_count"))
1487
- in_production, completed, titled = _channel_pipeline_counts(channel)
1488
- youtube_label = ui_text("YouTube", ui_language)
1489
- subscribers_label = ui_text("Inscritos", ui_language)
1490
- views_label = ui_text("Visualizações", ui_language)
1491
- videos_label = ui_text("Vídeos", ui_language)
1492
- production_label = ui_text("Em produção", ui_language)
1493
- completed_label = ui_text("Finalizados", ui_language)
1494
- titles_label = ui_text("Títulos", ui_language)
1495
- st.markdown(
1496
- f"""
1497
- <div class="tb-channel-kanban-card">
1498
- <div class="tb-channel-kanban-card__header">
1499
- {avatar}
1500
- <div class="tb-channel-kanban-card__identity">
1501
- <div class="tb-channel-kanban-card__name">{name}</div>
1502
- <div class="tb-channel-kanban-card__niche">{niche}</div>
1503
- </div>
1504
- <div class="tb-channel-kanban-card__badges">
1505
- <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--youtube">{youtube_label}</span>
1506
- <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--{active_class}">{active_label}</span>
1507
- </div>
1508
- </div>
1509
- <div class="tb-channel-kanban-card__rule"></div>
1510
- <div class="tb-channel-kanban-card__metrics">
1511
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{subscribers}</div><div class="tb-channel-kanban-card__metric-label">{subscribers_label}</div></div>
1512
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{views}</div><div class="tb-channel-kanban-card__metric-label">{views_label}</div></div>
1513
- <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{videos}</div><div class="tb-channel-kanban-card__metric-label">{videos_label}</div></div>
1514
- </div>
1515
- <div class="tb-channel-kanban-card__status-row">
1516
- <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--doing"><strong>{in_production}</strong>{production_label}</div>
1517
- <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--done"><strong>{completed}</strong>{completed_label}</div>
1518
- </div>
1519
- <div class="tb-channel-kanban-card__titles">▣ <strong>{titled}</strong> {titles_label}</div>
1520
- </div>
1521
- """,
1522
- unsafe_allow_html=True,
1523
- )
1524
-
1525
-
1526
- def render_registered_channels_kanban(channels: list[dict[str, Any]]) -> None:
1527
- """Render all registered channels in a responsive three-column Kanban grid."""
1528
- for row_start in range(0, len(channels), 3):
1529
- channel_columns = st.columns(3)
1530
- for column, channel in zip(channel_columns, channels[row_start:row_start + 3]):
1531
- with column:
1532
- _render_channel_kanban_card(channel)
1533
-
1534
-
1535
1416
  def render_channels():
1536
1417
  st.title("Canais Youtube")
1537
1418
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
@@ -1551,7 +1432,7 @@ def render_channels():
1551
1432
  with lookup_cols[0]:
1552
1433
  lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
1553
1434
  with lookup_cols[1]:
1554
- st.radio("Apresentação dos canais", ["Lista", "Kanban"], horizontal=True, key="youtube_channels_view_mode")
1435
+ st.caption("Os canais cadastrados são apresentados apenas em lista.")
1555
1436
  col1, col2 = st.columns([1, 1])
1556
1437
  with col1:
1557
1438
  if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
@@ -1587,17 +1468,17 @@ def render_channels():
1587
1468
  handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
1588
1469
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(imported.get("language") or "pt")), format_func=language_label, key="yt_import_language")
1589
1470
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
1590
- blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
1471
+ blueprint = st.selectbox("Blueprint Padrão", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
1591
1472
  voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
1592
1473
  current_voice = imported.get("default_voice") or imported.get("voice", "")
1593
- voice = st.selectbox("Voz padrão do canal", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
1474
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, index=voice_options.index(current_voice) if current_voice in voice_options else 0, format_func=lambda item: item or "Sem voz padrão", key="yt_import_voice")
1594
1475
  imported_account_id = str(imported.get("google_account_id", ""))
1595
1476
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, index=youtube_account_ids.index(imported_account_id) if imported_account_id in youtube_account_ids else 0, format_func=lambda item: youtube_account_labels.get(item, item), key="yt_import_google_account_id")
1596
1477
  st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
1597
1478
  automation_on = st.toggle("Automação ON", value=bool(imported.get("automation_on", False)), key="yt_import_automation_on")
1598
1479
  automation_time = st.text_input("Horário diário (HH:MM)", value=imported.get("automation_time", "00:00"), key="yt_import_automation_time")
1599
1480
  description = st.text_area("Descrição", value=imported.get("description", ""), key="yt_import_description")
1600
- niche = st.text_input("Canais de Referência / Nicho", value=imported.get("niche", ""), key="yt_import_niche")
1481
+ niche = st.text_input("Nicho", value=imported.get("niche", ""), key="yt_import_niche")
1601
1482
  metrics = st.columns(3)
1602
1483
  with metrics[0]: subscriber_count = st.number_input("Inscritos", min_value=0, value=int(imported.get("subscriber_count") or 0), key="yt_import_subscribers")
1603
1484
  with metrics[1]: video_count = st.number_input("Vídeos", min_value=0, value=int(imported.get("video_count") or 0), key="yt_import_videos")
@@ -1733,15 +1614,15 @@ def render_channels():
1733
1614
  url = st.text_input("URL do canal", placeholder="https://youtube.com/@seucanal", key="manual_channel_url")
1734
1615
  handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
1735
1616
  description = st.text_area("Descrição", key="manual_channel_description")
1736
- niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
1617
+ niche = st.text_input("Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
1737
1618
  language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
1738
1619
  style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
1739
1620
  manual_blueprint_items = blueprint_catalog()
1740
1621
  manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
1741
1622
  manual_blueprint_labels = {item[0]: item[1] for item in manual_blueprint_items}
1742
- blueprint = st.selectbox("Blueprint padrão do canal", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
1623
+ blueprint = st.selectbox("Blueprint Padrão", manual_blueprint_ids, format_func=lambda item: manual_blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="manual_channel_blueprint")
1743
1624
  voice_options = voice_catalog()
1744
- voice = st.selectbox("Voz padrão do canal", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
1625
+ voice = st.selectbox("Narrador/Voz Padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="manual_channel_voice")
1745
1626
  google_account_id = st.selectbox("Conta Google para Upload directo", youtube_account_ids, format_func=lambda item: youtube_account_labels.get(item, item), key="manual_channel_google_account_id")
1746
1627
  st.caption("O DELEGATED_SESSION_ID é lido exclusivamente do documento JSON da conta Google associada.")
1747
1628
  automation_on = st.toggle("Automação ON", value=False, key="manual_channel_automation_on")
@@ -1787,9 +1668,6 @@ def render_channels():
1787
1668
  if not channels:
1788
1669
  st.info("Nenhum canal cadastrado.")
1789
1670
  return
1790
- if st.session_state.get("youtube_channels_view_mode", "Lista") == "Kanban":
1791
- render_registered_channels_kanban(channels)
1792
- return
1793
1671
  for channel in channels:
1794
1672
  channel_id = str(channel["id"])
1795
1673
  edit_key = f"edit_channel_{channel_id}"
@@ -1827,22 +1705,25 @@ def render_channels():
1827
1705
  render_channel_edit_form(channel, youtube_account_ids, youtube_account_labels, youtube_accounts_by_id)
1828
1706
  else:
1829
1707
  summary = channel_blueprint_summary(channel)
1830
- block_cols = st.columns(3)
1708
+ channel_language = language_label(channel.get("language") or "pt")
1709
+ block_cols = st.columns(4, gap="small")
1831
1710
  with block_cols[0]:
1832
- st.markdown(f"**Prompts do Canal**\n\n{summary['name']}")
1833
- if st.button("Editar Prompts", key=f"edit_prompts_{channel_id}", use_container_width=True):
1711
+ st.markdown(f"**Blueprint Padrão**\n\n{summary['name']}")
1712
+ if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", use_container_width=True):
1834
1713
  st.session_state[edit_key] = True
1835
1714
  st.rerun()
1836
1715
  with block_cols[1]:
1837
- st.markdown(f"**Canais de Referência**\n\n{channel_niche_label(channel)}")
1716
+ st.markdown(f"**Nicho**\n\n{channel_niche_label(channel)}")
1838
1717
  if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
1839
1718
  st.session_state[edit_key] = True
1840
1719
  st.rerun()
1841
1720
  with block_cols[2]:
1842
- st.markdown(f"**Narrador**\n\n{summary['voice'] or 'Sem voz padrão'}")
1843
- if st.button("Configurar Narrador", key=f"edit_voice_{channel_id}", use_container_width=True):
1721
+ st.markdown(f"**Narrador/Voz Padrão**\n\n{summary['voice'] or 'Sem voz padrão'}")
1722
+ if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", use_container_width=True):
1844
1723
  st.session_state[edit_key] = True
1845
1724
  st.rerun()
1725
+ with block_cols[3]:
1726
+ st.markdown(f"**Idioma**\n\n{channel_language}")
1846
1727
 
1847
1728
  channel_account_ids = list(youtube_account_ids)
1848
1729
  current_channel_account_id = str(channel.get("google_account_id", ""))
@@ -2449,6 +2330,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2449
2330
  tasks = create_tasks_for_batch(batch)
2450
2331
  st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra {ui_text('Backlog Vídeos', current_ui_language())} para acompanhar.")
2451
2332
 
2333
+ _render_pipeline_progress_panel()
2452
2334
  if draft_tab is not None:
2453
2335
  with draft_tab:
2454
2336
  render_video_from_draft()
@@ -3315,10 +3197,106 @@ def render_python_editor():
3315
3197
  st.error(str(exc))
3316
3198
 
3317
3199
 
3200
+ _PIPELINE_STAGE_LABELS = {
3201
+ "niche": "Tema",
3202
+ "blueprint": "Blueprint",
3203
+ "brand": "Branding",
3204
+ "topic": "Tema",
3205
+ "script": "Roteiro",
3206
+ "title": "Título",
3207
+ "keywords": "Keywords",
3208
+ "thumbnail_prompt": "Prompt da thumbnail",
3209
+ "thumbnail": "Thumbnail",
3210
+ "video": "Vídeo",
3211
+ "edit": "Edição",
3212
+ "upload": "Upload",
3213
+ "idle": "A aguardar",
3214
+ }
3215
+
3216
+
3217
+ def _pipeline_progress_value(task: dict[str, Any]) -> int:
3218
+ try:
3219
+ return max(0, min(100, int(task.get("progress") or 0)))
3220
+ except (TypeError, ValueError):
3221
+ return 0
3222
+
3223
+
3224
+ def _pipeline_stage_label(task: dict[str, Any]) -> str:
3225
+ stage = str(task.get("stage") or "pipeline")
3226
+ return _PIPELINE_STAGE_LABELS.get(stage, stage.replace("_", " ").title())
3227
+
3228
+
3229
+ def _pipeline_time_age(value: Any) -> str:
3230
+ text = str(value or "").strip()
3231
+ if not text:
3232
+ return "sem actualização registada"
3233
+ try:
3234
+ parsed = datetime.fromisoformat(text)
3235
+ if parsed.tzinfo is None:
3236
+ parsed = parsed.replace(tzinfo=timezone.utc)
3237
+ seconds = max(0, int((datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds()))
3238
+ except ValueError:
3239
+ return f"última actualização: {text}"
3240
+ if seconds < 60:
3241
+ return f"actualizado há {seconds}s"
3242
+ if seconds < 3600:
3243
+ return f"actualizado há {seconds // 60}min"
3244
+ return f"actualizado há {seconds // 3600}h"
3245
+
3246
+
3247
+ def _render_pipeline_worker_banner(worker_status: dict[str, Any], active_count: int) -> None:
3248
+ if worker_status.get("alive"):
3249
+ stage = _PIPELINE_STAGE_LABELS.get(str(worker_status.get("stage") or "idle"), str(worker_status.get("stage") or "idle"))
3250
+ progress = max(0, min(100, int(worker_status.get("progress") or 0)))
3251
+ st.success(f"Worker de vídeo activo · {active_count} tarefa(s) em execução · {stage} · {progress}%")
3252
+ else:
3253
+ st.warning("Worker de vídeo sem heartbeat recente. O launcher deve estar aberto para processar as tarefas.")
3254
+ heartbeat = worker_status.get("last_heartbeat_at") or worker_status.get("updated_at")
3255
+ if heartbeat:
3256
+ st.caption(f"{_pipeline_time_age(heartbeat)} · timeout de execução: {STALE_TASK_SECONDS // 60} minutos")
3257
+ if worker_status.get("last_error"):
3258
+ st.error(f"Último erro do worker: {worker_status['last_error']}")
3259
+
3260
+
3261
+ @st.fragment(run_every=5.0)
3262
+ def _render_pipeline_progress_live() -> None:
3263
+ """Poll the persisted pipeline state only while video tasks are active."""
3264
+ worker_status = load_pipeline_worker_status()
3265
+ tasks = read_json("tasks.json", [])
3266
+ active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
3267
+ if not worker_status.get("alive") and active:
3268
+ recovered = recover_stale_tasks()
3269
+ if recovered:
3270
+ tasks = read_json("tasks.json", [])
3271
+ active = [task for task in tasks if isinstance(task, dict) and str(task.get("state") or "") == "doing"]
3272
+ worker_status = load_pipeline_worker_status()
3273
+ if not active:
3274
+ # A fragment that was already polling must stop itself after the worker
3275
+ # reaches done/failed; otherwise Streamlit keeps refreshing an obsolete
3276
+ # fragment even though the page no longer renders an active task.
3277
+ st.rerun(scope="app")
3278
+ return
3279
+ _render_pipeline_worker_banner(worker_status, len(active))
3280
+ for task in active:
3281
+ progress = _pipeline_progress_value(task)
3282
+ label = str(task.get("title") or task.get("topic") or task.get("id") or "Vídeo")
3283
+ st.progress(progress, text=f"{label} · {_pipeline_stage_label(task)} · {progress}%")
3284
+ st.caption(f"{task.get('channel_name') or 'Canal'} · {_pipeline_time_age(task.get('updated_at'))}")
3285
+ if task.get("error"):
3286
+ st.error(str(task.get("error")))
3287
+
3288
+
3289
+ def _render_pipeline_progress_panel() -> None:
3290
+ tasks = read_json("tasks.json", [])
3291
+ if any(isinstance(task, dict) and str(task.get("state") or "") == "doing" for task in tasks):
3292
+ _render_pipeline_progress_live()
3293
+
3294
+
3318
3295
  def render_videos():
3319
3296
  st.subheader("Backlog Videos")
3320
3297
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
3321
3298
  st.caption(f"Os vídeos são guardados em `{STORAGE / 'videos'}`.")
3299
+ _render_pipeline_progress_panel()
3322
3300
  tasks = read_json("tasks.json", [])
3323
3301
  if not tasks:
3324
3302
  st.info("Nenhum vídeo criado.")
@@ -3341,8 +3319,16 @@ def render_videos():
3341
3319
  prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
3342
3320
  st.caption(f"Thumbnail: {status}{prompt_note}")
3343
3321
  with cols[1]: st.write(task.get("format", "wide"))
3344
- with cols[2]: st.write(task.get("stage", "—"))
3345
- with cols[3]: st.write(task.get("state", "—"))
3322
+ with cols[2]:
3323
+ st.write(_pipeline_stage_label(task))
3324
+ if str(task.get("state") or "") in {"to_do", "doing", "blocked"}:
3325
+ progress = _pipeline_progress_value(task)
3326
+ st.progress(progress, text=f"{progress}%")
3327
+ with cols[3]:
3328
+ st.write(task.get("state", "—"))
3329
+ if task.get("error"):
3330
+ st.caption(str(task.get("error"))[:240])
3331
+
3346
3332
  with cols[4]:
3347
3333
  state = str(task.get("state") or "")
3348
3334
  start_col, stop_col = st.columns(2)
@@ -3585,20 +3571,25 @@ def render_automation():
3585
3571
  with cols[1]:
3586
3572
  st.write(f"**{channel.get('name', 'Sem nome')}**")
3587
3573
  st.caption(channel.get("handle") or channel.get("url") or "sem URL")
3588
- st.caption(f"Blueprint actual: {channel.get('default_blueprint_id') or channel.get('blueprint_id') or '—'} · Voz actual: {channel.get('default_voice') or channel.get('voice') or '—'}")
3589
3574
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
3590
- default_cols = st.columns(2)
3575
+ default_cols = st.columns(4, gap="small")
3591
3576
  with default_cols[0]:
3577
+ st.markdown("**Idioma Padrão**")
3578
+ st.caption(language_label(channel.get("language") or "pt"))
3579
+ with default_cols[1]:
3592
3580
  automation_blueprint = st.selectbox(
3593
- "Blueprint padrão",
3581
+ "Blueprint Padrão",
3594
3582
  blueprint_ids,
3595
3583
  index=blueprint_ids.index(current_blueprint) if current_blueprint in blueprint_ids else 0,
3596
3584
  format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
3597
3585
  key=f"automation_blueprint_{channel_id}",
3598
3586
  )
3599
- with default_cols[1]:
3587
+ with default_cols[2]:
3588
+ st.markdown("**Nicho Padrão**")
3589
+ st.caption(channel_niche_label(channel))
3590
+ with default_cols[3]:
3600
3591
  automation_voice = st.selectbox(
3601
- "Voz padrão",
3592
+ "Narrador/Voz Padrão",
3602
3593
  voice_options,
3603
3594
  index=voice_options.index(current_voice) if current_voice in voice_options else 0,
3604
3595
  format_func=lambda item: item or "Sem voz padrão",
@@ -2,8 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import os
5
+ import queue
5
6
  import re
6
7
  import subprocess
8
+ import threading
7
9
  import time
8
10
  from datetime import datetime, timezone
9
11
  from pathlib import Path
@@ -20,13 +22,18 @@ from hermes_ui.thumbnail_generation import generate_thumbnail_image
20
22
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
21
23
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
22
24
  VIDEO_TIMEOUT_SECONDS = 20 * 60
23
- STALE_TASK_SECONDS = 2 * 60 * 60
25
+ STALE_TASK_SECONDS = VIDEO_TIMEOUT_SECONDS + 5 * 60
26
+ WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
24
27
 
25
28
 
26
29
  class PipelineError(RuntimeError):
27
30
  """Raised when a pipeline stage cannot complete with an actionable error."""
28
31
 
29
32
 
33
+ class PipelineStopped(PipelineError):
34
+ """Raised when the user stops a task while the worker is processing it."""
35
+
36
+
30
37
  def _now() -> str:
31
38
  return datetime.now(timezone.utc).isoformat()
32
39
 
@@ -41,16 +48,45 @@ def _lock_path() -> Path:
41
48
  return STORAGE / "state" / PIPELINE_LOCK_FILENAME
42
49
 
43
50
 
51
+ def _pid_alive(pid: int) -> bool:
52
+ if pid <= 0:
53
+ return False
54
+ try:
55
+ os.kill(pid, 0)
56
+ except ProcessLookupError:
57
+ return False
58
+ except PermissionError:
59
+ return True
60
+ except OSError:
61
+ return False
62
+ return True
63
+
64
+
44
65
  def _acquire_lock() -> Path | None:
45
66
  path = _lock_path()
46
67
  path.parent.mkdir(parents=True, exist_ok=True)
47
68
  try:
48
69
  descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
70
+ except FileExistsError:
71
+ try:
72
+ old_pid = int(path.read_text(encoding="utf-8").strip())
73
+ except (OSError, ValueError):
74
+ old_pid = 0
75
+ if _pid_alive(old_pid):
76
+ return None
77
+ try:
78
+ path.unlink()
79
+ except OSError:
80
+ return None
81
+ try:
82
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
83
+ except FileExistsError:
84
+ return None
85
+ try:
49
86
  os.write(descriptor, str(os.getpid()).encode("ascii"))
87
+ finally:
50
88
  os.close(descriptor)
51
- return path
52
- except FileExistsError:
53
- return None
89
+ return path
54
90
 
55
91
 
56
92
  def _write_worker_state(**updates: Any) -> None:
@@ -62,6 +98,71 @@ def _write_worker_state(**updates: Any) -> None:
62
98
  write_json(PIPELINE_LOG_FILENAME, state)
63
99
 
64
100
 
101
+ def _worker_heartbeat(**updates: Any) -> None:
102
+ _write_worker_state(
103
+ worker_pid=os.getpid(),
104
+ last_heartbeat_at=_now(),
105
+ **updates,
106
+ )
107
+
108
+
109
+ def _parse_timestamp(value: Any) -> datetime | None:
110
+ text = str(value or "").strip()
111
+ if not text:
112
+ return None
113
+ try:
114
+ parsed = datetime.fromisoformat(text)
115
+ except ValueError:
116
+ return None
117
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
118
+
119
+
120
+ def load_pipeline_worker_status() -> dict[str, Any]:
121
+ """Return the persisted worker heartbeat for the Backlog UI."""
122
+ status = read_json(PIPELINE_LOG_FILENAME, {})
123
+ if not isinstance(status, dict):
124
+ status = {}
125
+ heartbeat_at = _parse_timestamp(status.get("last_heartbeat_at"))
126
+ status["alive"] = bool(
127
+ heartbeat_at
128
+ and (datetime.now(timezone.utc) - heartbeat_at.astimezone(timezone.utc)).total_seconds()
129
+ <= WORKER_HEARTBEAT_TIMEOUT_SECONDS
130
+ )
131
+ return status
132
+
133
+
134
+ def _recover_stale_tasks() -> list[str]:
135
+ """Convert abandoned doing tasks to failed after the worker timeout window."""
136
+ from hermes_ui.domain import update_task
137
+
138
+ recovered: list[str] = []
139
+ current_time = datetime.now(timezone.utc)
140
+ for task in read_json("tasks.json", []):
141
+ if not isinstance(task, dict) or str(task.get("state") or "") != "doing":
142
+ continue
143
+ updated_at = _parse_timestamp(task.get("updated_at"))
144
+ if not updated_at:
145
+ continue
146
+ age_seconds = (current_time - updated_at.astimezone(timezone.utc)).total_seconds()
147
+ if age_seconds <= STALE_TASK_SECONDS:
148
+ continue
149
+ task_id = str(task.get("id") or "")
150
+ if not task_id:
151
+ continue
152
+ message = (
153
+ f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
154
+ "Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
155
+ )
156
+ update_task(task_id, {"state": "failed", "error": message, "failed_stage": task.get("stage") or "pipeline"})
157
+ recovered.append(task_id)
158
+ return recovered
159
+
160
+
161
+ def recover_stale_tasks() -> list[str]:
162
+ """Public wrapper used by the UI to recover tasks after an abrupt worker exit."""
163
+ return _recover_stale_tasks()
164
+
165
+
65
166
  def _task_by_id(task_id: str) -> dict[str, Any] | None:
66
167
  return next((task for task in read_json("tasks.json", []) if isinstance(task, dict) and task.get("id") == task_id), None)
67
168
 
@@ -69,9 +170,20 @@ def _task_by_id(task_id: str) -> dict[str, Any] | None:
69
170
  def _update(task_id: str, **updates: Any) -> dict[str, Any]:
70
171
  from hermes_ui.domain import update_task
71
172
 
173
+ current = _task_by_id(task_id)
174
+ if not current:
175
+ raise PipelineError(f"Tarefa {task_id} deixou de existir durante a execução.")
176
+ if str(current.get("state") or "") in {"blocked", "cancelled"}:
177
+ raise PipelineStopped("A tarefa foi parada pelo utilizador.")
72
178
  updated = update_task(task_id, updates)
73
179
  if not updated:
74
180
  raise PipelineError(f"Tarefa {task_id} deixou de existir durante a execução.")
181
+ _worker_heartbeat(
182
+ task_id=task_id,
183
+ status="running",
184
+ stage=str(updated.get("stage") or "pipeline"),
185
+ progress=int(updated.get("progress") or 0),
186
+ )
75
187
  return updated
76
188
 
77
189
 
@@ -111,6 +223,75 @@ def _save_json_artifact(task_id: str, name: str, payload: dict[str, Any]) -> str
111
223
  return str(path)
112
224
 
113
225
 
226
+ def _configured_moneyprinter_root(settings: dict[str, Any]) -> Path | None:
227
+ """Resolve the installed MoneyPrinterTurbo project selected by the user."""
228
+ configured = str(settings.get("moneyprinter_path") or os.environ.get("MONEYPRINTER_PATH") or "").strip()
229
+ if not configured:
230
+ return None
231
+ root = Path(configured).expanduser().resolve()
232
+ if not (root / "cli.py").is_file():
233
+ raise PipelineError(f"A pasta configurada do MoneyPrinterTurbo não contém cli.py: {root}")
234
+ if not ((root / "config.toml").is_file() or (root / "config.example.toml").is_file()):
235
+ raise PipelineError(f"A pasta configurada do MoneyPrinterTurbo não contém config.toml: {root}")
236
+ return root
237
+
238
+
239
+ def _helper_output_value(output: str, key: str) -> str:
240
+ match = re.search(rf"(?m)^{re.escape(key)}=(.+)$", output)
241
+ return match.group(1).strip() if match else ""
242
+
243
+
244
+ def _redact_helper_output(text: str) -> str:
245
+ for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY"):
246
+ secret = os.environ.get(key, "").strip()
247
+ if secret:
248
+ text = text.replace(secret, "[redacted]")
249
+ return text
250
+
251
+
252
+ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, str]:
253
+ """Persist only bounded helper diagnostics and return its declared file paths."""
254
+ task_id = str(task.get("id") or "").strip()
255
+ if not task_id:
256
+ return {}
257
+ log_file = _helper_output_value(output, "LOG_FILE")
258
+ result_file = _helper_output_value(output, "RESULT_FILE")
259
+ try:
260
+ payload: dict[str, Any] = {
261
+ "captured_at": _now(),
262
+ "log_file": log_file,
263
+ "result_file": result_file,
264
+ "output_tail": _redact_helper_output(output[-6000:]),
265
+ }
266
+ artifact_path = _save_json_artifact(task_id, "video-diagnostics", payload)
267
+ current = _task_by_id(task_id) or task
268
+ artifacts = dict(current.get("artifacts") or {})
269
+ artifacts["video_diagnostics"] = artifact_path
270
+ updates: dict[str, Any] = {"artifacts": artifacts}
271
+ if log_file:
272
+ updates["video_log"] = log_file
273
+ artifacts["video_log"] = log_file
274
+ if result_file:
275
+ updates["video_result"] = result_file
276
+ artifacts["video_result"] = result_file
277
+ from hermes_ui.domain import update_task
278
+ update_task(task_id, updates)
279
+ return {"log_file": log_file, "result_file": result_file, "artifact": artifact_path}
280
+ except Exception:
281
+ # Diagnostics must never hide the actual generation error.
282
+ return {"log_file": log_file, "result_file": result_file}
283
+
284
+
285
+ def _stop_process(process: subprocess.Popen[str]) -> None:
286
+ if process.poll() is None:
287
+ process.kill()
288
+ try:
289
+ process.wait(timeout=5)
290
+ except subprocess.TimeoutExpired:
291
+ process.kill()
292
+ process.wait()
293
+
294
+
114
295
  def _run_video_helper(task: dict[str, Any]) -> Path:
115
296
  helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
116
297
  helper = helper_dir / "mpt_agent.py"
@@ -120,6 +301,10 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
120
301
  if not subject:
121
302
  raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
122
303
  settings = _settings()
304
+ configured_root = _configured_moneyprinter_root(settings)
305
+ task_id = str(task.get("id") or "").strip()
306
+ if not task_id:
307
+ raise PipelineError("A tarefa de vídeo não tem um identificador válido.")
123
308
  env = os.environ.copy()
124
309
  card = active_llm_card(settings)
125
310
  provider = str(card.get("provider") or "openai").strip()
@@ -134,23 +319,97 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
134
319
  for key, value in env_values.items():
135
320
  if value:
136
321
  env[key] = value
137
- command = ["uv", "run", "--no-project", "--python", "3.11", "python", "mpt_agent.py", "--subject", subject]
322
+ command = ["uv", "run", "--no-project", "--python", "3.11", "python", "mpt_agent.py"]
323
+ if configured_root:
324
+ command.extend(["--root", str(configured_root)])
325
+ command.extend(["--subject", subject])
326
+ output_lines: list[str] = []
327
+ line_queue: queue.Queue[str | None] = queue.Queue()
328
+ started_at = time.monotonic()
329
+ process: subprocess.Popen[str] | None = None
330
+
331
+ def _read_output() -> None:
332
+ if process is None or process.stdout is None:
333
+ line_queue.put(None)
334
+ return
335
+ for line in iter(process.stdout.readline, ""):
336
+ line_queue.put(line.rstrip())
337
+ process.stdout.close()
338
+ line_queue.put(None)
339
+
138
340
  try:
139
- result = subprocess.run(command, cwd=helper_dir, env=env, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_SECONDS, check=False)
341
+ process = subprocess.Popen(
342
+ command,
343
+ cwd=helper_dir,
344
+ env=env,
345
+ stdout=subprocess.PIPE,
346
+ stderr=subprocess.STDOUT,
347
+ text=True,
348
+ bufsize=1,
349
+ )
140
350
  except FileNotFoundError as exc:
141
351
  raise PipelineError("O comando uv não está instalado; não foi possível iniciar a geração de vídeo.") from exc
142
- except subprocess.TimeoutExpired as exc:
143
- raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.") from exc
144
- output = "\n".join(part for part in (result.stdout, result.stderr) if part)
145
- if result.returncode == 10:
352
+
353
+ reader = threading.Thread(target=_read_output, name=f"mpt-output-{task.get('id', 'video')}", daemon=True)
354
+ reader.start()
355
+ output_finished = False
356
+ last_heartbeat = 0.0
357
+ try:
358
+ while True:
359
+ try:
360
+ line = line_queue.get(timeout=0.5)
361
+ if line is None:
362
+ output_finished = True
363
+ elif line:
364
+ output_lines.append(line)
365
+ except queue.Empty:
366
+ pass
367
+ elapsed = time.monotonic() - started_at
368
+ if elapsed - last_heartbeat >= 5:
369
+ # O helper expõe o resultado final, mas não uma percentagem estável.
370
+ # Mantemos uma faixa reservada para a etapa de vídeo e avançamos-a
371
+ # lentamente enquanto o processo responde, sem fingir conclusão.
372
+ video_progress = min(79, 68 + int(elapsed // 15))
373
+ current_task = _task_by_id(task_id)
374
+ if current_task and str(current_task.get("state") or "") in {"blocked", "cancelled"}:
375
+ _stop_process(process)
376
+ raise PipelineStopped("A tarefa foi parada pelo utilizador.")
377
+ _update(
378
+ task_id,
379
+ progress=video_progress,
380
+ video_elapsed_seconds=int(elapsed),
381
+ )
382
+ _worker_heartbeat(
383
+ task_id=str(task.get("id") or ""),
384
+ status="running",
385
+ stage="video",
386
+ progress=video_progress,
387
+ video_elapsed_seconds=int(elapsed),
388
+ )
389
+ last_heartbeat = elapsed
390
+ if process.poll() is not None and output_finished:
391
+ break
392
+ if elapsed >= VIDEO_TIMEOUT_SECONDS:
393
+ _stop_process(process)
394
+ raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.")
395
+ finally:
396
+ reader.join(timeout=2)
397
+ _persist_video_diagnostics(task, "\n".join(output_lines))
398
+ if process.returncode is None:
399
+ process.wait(timeout=5)
400
+ result_code = process.returncode
401
+ output = "\n".join(output_lines)
402
+ _persist_video_diagnostics(task, output)
403
+ if result_code == 10:
146
404
  raise PipelineError("A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo.")
147
- if result.returncode != 0:
148
- detail = output[-1200:].strip() or "erro sem detalhes devolvidos pelo helper"
405
+ if result_code != 0:
406
+ detail = _redact_helper_output(output[-1200:]).strip() or "erro sem detalhes devolvidos pelo helper"
149
407
  raise PipelineError(f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}")
150
408
  match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
151
409
  video_path = Path(match.group(1).strip()).expanduser() if match else None
152
410
  if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
153
- result_file = Path.home() / "MoneyPrinterTurbo" / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
411
+ result_root = configured_root or (Path.home() / "MoneyPrinterTurbo")
412
+ result_file = result_root / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
154
413
  if result_file.is_file():
155
414
  try:
156
415
  payload = json.loads(result_file.read_text(encoding="utf-8"))
@@ -285,23 +544,33 @@ def run_once() -> dict[str, Any]:
285
544
  if lock is None:
286
545
  return {"ok": True, "busy": True}
287
546
  try:
547
+ recovered = _recover_stale_tasks()
288
548
  tasks = read_json("tasks.json", [])
289
549
  candidate = next((task for task in tasks if isinstance(task, dict) and task.get("state") in {"to_do", "doing"}), None)
290
550
  if not candidate:
291
- _write_worker_state(last_task_id=None, last_error="", status="idle")
292
- return {"ok": True, "status": "idle"}
551
+ _worker_heartbeat(last_task_id=None, last_error="", status="idle", stage="idle", progress=0, recovered_task_ids=recovered)
552
+ return {"ok": True, "status": "idle", "recovered_task_ids": recovered}
293
553
  task_id = str(candidate.get("id") or "")
294
- _write_worker_state(last_task_id=task_id, status="running", last_error="")
554
+ _worker_heartbeat(last_task_id=task_id, status="running", stage=str(candidate.get("stage") or "pipeline"), progress=int(candidate.get("progress") or 0), last_error="", recovered_task_ids=recovered)
295
555
  try:
296
556
  result = _run_task(candidate)
297
- _write_worker_state(status="completed", last_error="")
298
- return {"ok": True, "task_id": task_id, "task": result}
557
+ _worker_heartbeat(status="completed", last_error="", stage=str(result.get("stage") or "upload"), progress=100, task_id=task_id)
558
+ return {"ok": True, "task_id": task_id, "task": result, "recovered_task_ids": recovered}
559
+ except PipelineStopped as exc:
560
+ current_task = _task_by_id(task_id) or candidate
561
+ current_state = str(current_task.get("state") or "")
562
+ if current_state not in {"blocked", "cancelled"}:
563
+ from hermes_ui.domain import update_task
564
+ update_task(task_id, {"state": "blocked", "error": str(exc), "failed_stage": current_task.get("stage") or "pipeline"})
565
+ _worker_heartbeat(status="stopped", last_error=str(exc), stage=str(current_task.get("stage") or "pipeline"), progress=int(current_task.get("progress") or 0), task_id=task_id)
566
+ return {"ok": True, "task_id": task_id, "status": "stopped", "recovered_task_ids": recovered}
299
567
  except Exception as exc:
300
568
  message = str(exc)[:2000]
301
569
  from hermes_ui.domain import update_task
302
- update_task(task_id, {"state": "failed", "error": message})
303
- _write_worker_state(status="failed", last_error=message)
304
- return {"ok": False, "task_id": task_id, "error": message}
570
+ current_task = _task_by_id(task_id) or candidate
571
+ update_task(task_id, {"state": "failed", "error": message, "failed_stage": current_task.get("stage") or "pipeline"})
572
+ _worker_heartbeat(status="failed", last_error=message, stage=str(current_task.get("stage") or "pipeline"), progress=int(current_task.get("progress") or 0), task_id=task_id)
573
+ return {"ok": False, "task_id": task_id, "error": message, "recovered_task_ids": recovered}
305
574
  finally:
306
575
  try:
307
576
  lock.unlink()
@@ -311,6 +580,7 @@ def run_once() -> dict[str, Any]:
311
580
 
312
581
  def run_worker(interval_seconds: int = 5) -> None:
313
582
  ensure_storage()
583
+ _worker_heartbeat(status="starting", stage="idle", progress=0, last_error="")
314
584
  while True:
315
585
  run_once()
316
586
  time.sleep(max(2, int(interval_seconds)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
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",