@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.
- package/.streamlit/config.toml +0 -1
- package/MANUAL-INSTALACAO.md +19 -9
- package/README.md +6 -5
- package/app/main.py +169 -178
- package/hermes_ui/pipeline_worker.py +291 -21
- package/package.json +1 -1
package/.streamlit/config.toml
CHANGED
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.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.
|
|
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.
|
|
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
|
|
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, **
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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`
|
|
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.
|
|
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, **
|
|
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: **
|
|
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
|
|
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.
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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.
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
with
|
|
1001
|
-
|
|
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("
|
|
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("
|
|
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.
|
|
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
|
|
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
|
|
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("
|
|
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("
|
|
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
|
|
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
|
|
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
|
-
|
|
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"**
|
|
1833
|
-
if st.button("Editar
|
|
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"**
|
|
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]:
|
|
3345
|
-
|
|
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(
|
|
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
|
|
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[
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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