@danhachuel/thunderbolt 0.3.56 → 0.3.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +9 -9
- package/README.md +5 -3
- package/app/influencers_ui.py +2 -2
- package/app/main.py +66 -26
- package/hermes_ui/domain.py +23 -0
- package/hermes_ui/pipeline_worker.py +175 -104
- package/package.json +1 -1
- package/seed/references/guide-supabase.md +2 -2
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.58
|
|
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)
|
|
@@ -31,9 +31,9 @@ O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a
|
|
|
31
31
|
|
|
32
32
|
A área **AI Influencers > Personagens** permite criar personagens com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e ficheiros `.md`/`.json`. O campo **Idioma** é um selector e reutiliza exactamente a mesma lista, os códigos e os labels visuais de **Pipeline Vídeos > Criação de Vídeos**. No uploader, seleccione vários ficheiros de uma vez; cada imagem pode ser pré-visualizada e cada documento é validado como UTF-8, Markdown ou JSON válido. Os assets são deduplicados por SHA-256 e não são gravados como base64 dentro das tabelas.
|
|
33
33
|
|
|
34
|
-
O backend local predefinido é **SQLite**, pelo que Personagens e Geração de Conteúdo IA podem ser usados sem credenciais externas. Abra **Configurações > Configuração API >
|
|
34
|
+
O backend local predefinido é **SQLite**, pelo que Personagens e Geração de Conteúdo IA podem ser usados sem credenciais externas. Abra **Configurações > Configuração API > AI Influencers > Banco de Dados Influencers** para confirmar **SQLite** ou seleccionar **Supabase**. Se seleccionar Supabase, preencha **Supabase Project URL**, **Supabase API key** e **Supabase Storage bucket**, clique em **Testar ligação do backend**, aplique primeiro `seed/references/ai_influencers_schema.sql` no SQL Editor do projecto, exponha as quatro tabelas na Data API e configure permissões/RLS adequadas. O bucket deve existir com o nome configurado, por defeito `ai-influencers`.
|
|
35
35
|
|
|
36
|
-
Se pretender trabalhar sem serviço externo, seleccione **SQLite** no
|
|
36
|
+
Se pretender trabalhar sem serviço externo, seleccione **SQLite** no selector da aba **Configuração API > AI Influencers** e indique o caminho do ficheiro. O padrão é `storage/state/ai_influencers.db`; o SQLite cria o schema e guarda os assets em `storage/influencers/`. Apenas um backend é usado de cada vez. A aba **AI Influencers** mostra o estado e permite editar directamente o selector, os campos Supabase e o caminho SQLite.
|
|
37
37
|
|
|
38
38
|
Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens**, **Vídeos** e **Motion Control**. Seleccione um personagem, prompt e destinos sociais. A subaba **Imagens** usa o pool de imagem configurado em **Imagem e Video**. A subaba **Vídeos** requer uma imagem inicial e usa o pool de vídeo; escolha o provider/modelo no cartão configurado, sem dependência obrigatória do Veo 3.1. KIE AI, Replicate e FAL AI podem usar tarefas assíncronas; o Thunderbolt consulta o estado e guarda o resultado local antes da revisão. Na Replicate, o campo Modelo deve ser o identificador do modelo ou da versão, e os inputs específicos dependem do modelo configurado.
|
|
39
39
|
|
|
@@ -114,13 +114,13 @@ Execute:
|
|
|
114
114
|
Windows PowerShell ou MobaXterm:
|
|
115
115
|
|
|
116
116
|
```powershell
|
|
117
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
117
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.58 install
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
Linux/macOS:
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
-
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
123
|
+
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.58 install
|
|
124
124
|
```
|
|
125
125
|
|
|
126
126
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -128,7 +128,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
|
|
|
128
128
|
Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
|
|
129
129
|
|
|
130
130
|
```powershell
|
|
131
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
131
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.58 install --purge-data
|
|
132
132
|
```
|
|
133
133
|
|
|
134
134
|
O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
|
|
@@ -453,7 +453,7 @@ Se o launcher ou o worker for encerrado abruptamente, uma tarefa `doing` sem act
|
|
|
453
453
|
|
|
454
454
|
O **Backlog Vídeos** e a lista **Automação > Automação Youtube > Vídeos cadastrados** usam o mesmo catálogo completo de `storage/state/tasks.json`. Por isso, tarefas criadas manualmente e tarefas criadas pelo worker diário aparecem nos dois locais, sem filtragem implícita por origem. O filtro do Backlog mostra os estados conhecidos e inclui automaticamente estados adicionais encontrados nos dados.
|
|
455
455
|
|
|
456
|
-
Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas.
|
|
456
|
+
Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas. Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a partir dos artefactos persistidos: reutiliza roteiro, título/keywords, vídeo, prompt e thumbnail prontos e só executa novamente uma etapa cujo resultado não exista. O botão **Apagar** remove o card da fila depois de confirmação e preserva os ficheiros de artefactos; uma tarefa em execução deve ser parada antes da remoção.
|
|
457
457
|
|
|
458
458
|
### Upload Música — JewelMusic, Pushtunes e ytmusicapi
|
|
459
459
|
|
|
@@ -542,7 +542,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
542
542
|
|
|
543
543
|
Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
|
|
544
544
|
|
|
545
|
-
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
545
|
+
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.58 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
546
546
|
|
|
547
547
|
### Niche Finder Apify
|
|
548
548
|
|
|
@@ -580,7 +580,7 @@ O modo `Pexels/Pixabay` representa materiais de stock. Ao seleccionar `full_ia`,
|
|
|
580
580
|
|
|
581
581
|
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.
|
|
582
582
|
|
|
583
|
-
A aba **Automação Youtube**, dentro do menu expansível **Automação**, apresenta os cartões no layout compacto de duas linhas: na primeira ficam avatar, nome, handle, toggle **Automação ligada** e horário; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ligada** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado.
|
|
583
|
+
A aba **Automação Youtube**, dentro do menu expansível **Automação**, apresenta os cartões no layout compacto de duas linhas: na primeira ficam avatar, nome, handle, toggle **Automação ligada** e horário; na segunda ficam **Idioma Padrão**, **Nicho Padrão**, **Blueprint Padrão**, **Narrador/Voz Padrão** e o botão **Guardar**. Blueprint e Narrador/Voz continuam editáveis no card; Idioma e Nicho são mostrados a partir da configuração guardada no canal. A aba também guarda **Automação ligada** e valida horários diários no formato `HH:MM`. Os valores ficam sincronizados com o editor existente no cartão da aba **Canais Youtube** e são copiados para novas tarefas. O launcher inicia o worker local, que consulta o relógio do computador, gera um briefing, título e pacote de thumbnail específicos com o provider LLM configurado, cria o lote agendado na fila e evita duplicar o mesmo canal no mesmo dia. Se a geração não puder ser executada, o erro fica registado e o placeholder antigo não é usado. Ao iniciar um vídeo cadastrado, o worker retoma o ponto persistido sem regenerar as etapas concluídas; se um roteiro não estiver guardado, ele é criado antes de continuar. O card inclui **Start**, **Stop** e **Apagar**, sendo a remoção protegida por confirmação.
|
|
584
584
|
|
|
585
585
|
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.
|
|
586
586
|
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
32
32
|
|
|
33
33
|
A área **AI Influencers > Personagens** permite guardar um personagem com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e documentos `.md` ou `.json`. O campo **Idioma** é um selector que reutiliza exactamente a lista, os códigos e os labels visuais de **Pipeline Vídeos > Criação de Vídeos**. Cada asset é validado, sanitizado, deduplicado por SHA-256 e guardado separado do perfil; imagens têm pré-visualização e documentos são mostrados como Markdown ou JSON estruturado.
|
|
34
34
|
|
|
35
|
-
O backend local predefinido é **SQLite**, por isso Personagens e Geração de Conteúdo IA funcionam imediatamente sem credenciais externas. Em **Configurações > Configuração API >
|
|
35
|
+
O backend local predefinido é **SQLite**, por isso Personagens e Geração de Conteúdo IA funcionam imediatamente sem credenciais externas. Em **Configurações > Configuração API > AI Influencers > Banco de Dados Influencers**, pode manter **SQLite** ou seleccionar **Supabase** quando quiser uma base remota; nesse caso, configure o **Supabase Project URL**, a **Supabase API key** e o bucket de Storage, aplique `seed/references/ai_influencers_schema.sql` no SQL Editor e confirme Data API/RLS. SQLite cria localmente `storage/state/ai_influencers.db` e `storage/influencers/`; apenas um backend fica activo de cada vez. A aba **AI Influencers** mostra o estado actual e permite editar directamente o selector, as credenciais Supabase, o bucket de Storage e o caminho do SQLite. O botão **Testar ligação do backend** é read-only e não bloqueia a utilização local.
|
|
36
36
|
|
|
37
37
|
A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens**, **Vídeos** e **Motion Control**. Imagens usam os cartões activos do **Pool Imagem**; Vídeos usam os cartões activos do **Pool Vídeo** e exigem uma imagem inicial image-to-video. O selector de modelo não está preso ao Veo 3.1: pode utilizar KIE AI, Replicate, FAL AI, Pollinations ou outro cartão que declare suporte para vídeo. Para Replicate, o campo Modelo deve conter o identificador aceito pela API, como `owner/model` ou `owner/model:version`; a tarefa usa `POST /v1/predictions`, consulta o estado assíncrono e guarda o resultado localmente. A publicação para Instagram, TikTok, YouTube Shorts ou Facebook não é automática: os destinos são registados para revisão e o envio exige uma acção posterior explícita.
|
|
38
38
|
|
|
@@ -79,7 +79,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
|
|
|
79
79
|
|
|
80
80
|
A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
|
|
81
81
|
|
|
82
|
-
## Navegação da UI 0.3.
|
|
82
|
+
## Navegação da UI 0.3.58
|
|
83
83
|
|
|
84
84
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A página Geração de Conteúdo IA divide-se em **Imagens**, **Vídeos** e **Motion Control**; a subaba Motion Control permanece vazia nesta release.
|
|
85
85
|
**Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
@@ -118,6 +118,8 @@ A página **Edição > Download Mídia** utiliza a API Python do [yt-dlp](https:
|
|
|
118
118
|
|
|
119
119
|
Os dois cards mostram de forma consistente o estado técnico, o rótulo legível, a barra de progresso, a etapa e o formato do vídeo. São suportados estados como `to_do`, `doing`, `blocked`, `done`, `failed` e `cancelled`; formatos ausentes usam `format`, `style_wide` ou `wide` como fallback. Quando existe `artifacts.video`, o Backlog mostra que o vídeo está pronto e disponibiliza a descarga do MP4 mesmo que a thumbnail esteja pendente.
|
|
120
120
|
|
|
121
|
+
Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a tarefa a partir dos artefactos persistidos. O worker reutiliza o roteiro, título/keywords, MP4, prompt de thumbnail e imagem já existentes; só volta a gerar uma etapa quando o respectivo resultado não está disponível, e repete o upload apenas quando ele ainda não foi concluído. O botão **Apagar** remove o vídeo da fila e de `tasks.json` após confirmação, preservando os ficheiros de artefactos; tarefas em execução devem ser paradas antes de serem removidas.
|
|
122
|
+
|
|
121
123
|
## Canais Youtube — edição por cartão e vídeos recentes
|
|
122
124
|
|
|
123
125
|
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**.
|
|
@@ -356,7 +358,7 @@ A subaba **Roteiros**, entre **Criação de Músicas** e **Upload**, permite esc
|
|
|
356
358
|
|
|
357
359
|
Na subaba **Vídeos** de **Criação de Vídeos**, a frase `Os vídeos são guardados em <storage>/videos` identifica a pasta real onde os artefactos de vídeo devem ser procurados.
|
|
358
360
|
|
|
359
|
-
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais Youtube**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação Youtube**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação Youtube** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados
|
|
361
|
+
O agente musical guarda os ficheiros em `storage/music/`, aceita formatos de áudio comuns e pode descarregar uma URL de áudio devolvida por um endpoint Suno compatível. Em **Canais Youtube**, abra **Definir Blueprint e voz padrão** no cartão do canal para guardar os defaults; em **Automação Youtube**, os mesmos dois selectores aparecem no cartão e são sincronizados. Esses valores são usados automaticamente em novas tarefas criadas para o canal. A aba **Automação Youtube** também guarda `Automação ON` e um horário diário `HH:MM` por canal e lista os vídeos cadastrados. Nos cards, **Start** retoma o processamento sem regenerar etapas já prontas, enquanto **Apagar** remove o card da fila após confirmação e preserva os artefactos locais. O worker continua a ser executado pelo processo local configurado.
|
|
360
362
|
|
|
361
363
|
## Teste de vozes
|
|
362
364
|
|
package/app/influencers_ui.py
CHANGED
|
@@ -103,9 +103,9 @@ def _provider_label(card: Mapping[str, Any]) -> str:
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def render_ai_influencers_api_status(settings: dict[str, Any]) -> None:
|
|
106
|
-
"""
|
|
106
|
+
"""Show backend status; editable selector and credentials are rendered by the settings page."""
|
|
107
107
|
st.subheader("AI Influencers")
|
|
108
|
-
st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA.
|
|
108
|
+
st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA. O selector e as credenciais são editados nesta aba, em Banco de Dados Influencers.")
|
|
109
109
|
status = backend_status(settings)
|
|
110
110
|
cols = st.columns(3)
|
|
111
111
|
with cols[0]:
|
package/app/main.py
CHANGED
|
@@ -22,7 +22,7 @@ try:
|
|
|
22
22
|
except (OSError, json.JSONDecodeError):
|
|
23
23
|
APP_VERSION = ""
|
|
24
24
|
|
|
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
|
|
25
|
+
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
26
26
|
from hermes_ui.drafts import list_drafts, save_draft
|
|
27
27
|
from hermes_ui.automation_worker import load_worker_status
|
|
28
28
|
from hermes_ui.pipeline_worker import load_pipeline_worker_status, recover_stale_tasks, STALE_TASK_SECONDS, WORKER_HEARTBEAT_TIMEOUT_SECONDS
|
|
@@ -3887,6 +3887,7 @@ def render_automation():
|
|
|
3887
3887
|
|
|
3888
3888
|
st.divider()
|
|
3889
3889
|
st.subheader("Vídeos cadastrados")
|
|
3890
|
+
st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
|
|
3890
3891
|
tasks = load_video_tasks_for_catalog()
|
|
3891
3892
|
if not tasks:
|
|
3892
3893
|
st.info("Ainda não existem vídeos cadastrados.")
|
|
@@ -3903,7 +3904,7 @@ def render_automation():
|
|
|
3903
3904
|
st.write(_video_task_format(task))
|
|
3904
3905
|
with task_cols[3]:
|
|
3905
3906
|
state = str(task.get("state") or "")
|
|
3906
|
-
start_col, stop_col = st.columns(
|
|
3907
|
+
start_col, stop_col, delete_col = st.columns(3)
|
|
3907
3908
|
with start_col:
|
|
3908
3909
|
if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
|
|
3909
3910
|
transition_task(task["id"], "doing")
|
|
@@ -3912,6 +3913,26 @@ def render_automation():
|
|
|
3912
3913
|
if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
|
|
3913
3914
|
transition_task(task["id"], "blocked")
|
|
3914
3915
|
st.rerun()
|
|
3916
|
+
with delete_col:
|
|
3917
|
+
confirm_delete_key = f"automation_confirm_delete_{task['id']}"
|
|
3918
|
+
if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
|
|
3919
|
+
st.session_state[confirm_delete_key] = True
|
|
3920
|
+
st.rerun()
|
|
3921
|
+
if st.session_state.get(confirm_delete_key):
|
|
3922
|
+
st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
|
|
3923
|
+
confirm_col, cancel_col = st.columns(2)
|
|
3924
|
+
with confirm_col:
|
|
3925
|
+
if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
|
|
3926
|
+
try:
|
|
3927
|
+
delete_task(task["id"])
|
|
3928
|
+
st.session_state.pop(confirm_delete_key, None)
|
|
3929
|
+
st.rerun()
|
|
3930
|
+
except ValueError as exc:
|
|
3931
|
+
st.error(str(exc))
|
|
3932
|
+
with cancel_col:
|
|
3933
|
+
if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
|
|
3934
|
+
st.session_state.pop(confirm_delete_key, None)
|
|
3935
|
+
st.rerun()
|
|
3915
3936
|
|
|
3916
3937
|
|
|
3917
3938
|
def render_upload_direct():
|
|
@@ -5437,20 +5458,7 @@ def render_settings():
|
|
|
5437
5458
|
|
|
5438
5459
|
render_media_provider_cards(settings, embedded=True)
|
|
5439
5460
|
|
|
5440
|
-
|
|
5441
|
-
st.caption("Escolha um backend para personagens e conteúdos. SQLite local funciona sem credenciais externas; Supabase é opcional para quem quiser usar uma base remota. O teste é read-only e não cria nem elimina registos.")
|
|
5442
|
-
configured_backend = backend_name(settings)
|
|
5443
|
-
influencer_db_backend = st.selectbox("Backend da base de dados de AI Influencers", list(BACKEND_OPTIONS), index=list(BACKEND_OPTIONS).index(configured_backend), key="settings_influencer_db_backend")
|
|
5444
|
-
db_cols = st.columns(2)
|
|
5445
|
-
with db_cols[0]:
|
|
5446
|
-
influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
|
|
5447
|
-
influencer_supabase_key = text_setting("Supabase API key", "influencer_supabase_key", secret=True, help_text="Use uma chave com as permissões RLS adequadas. Nunca é colocada no GitHub ou nos logs.")
|
|
5448
|
-
influencer_supabase_bucket = text_setting("Supabase Storage bucket", "influencer_supabase_bucket", help_text="Bucket usado para imagens e documentos, por defeito ai-influencers.")
|
|
5449
|
-
with db_cols[1]:
|
|
5450
|
-
influencer_sqlite_path = text_setting("SQLite ficheiro local", "influencer_sqlite_path", help_text="Caminho absoluto ou relativo ao projecto. Por defeito: storage/state/ai_influencers.db")
|
|
5451
|
-
st.caption("SQLite usa a biblioteca standard do Python e cria o schema automaticamente ao testar ou usar o backend.")
|
|
5452
|
-
db_test_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True, key="api_test_influencer_database")
|
|
5453
|
-
st.caption("Para Supabase, aplique primeiro seed/references/ai_influencers_schema.sql no SQL Editor e configure o bucket/políticas RLS.")
|
|
5461
|
+
st.info("A configuração do backend AI Influencers, incluindo o selector entre SQLite e Supabase e as credenciais, está na aba AI Influencers.")
|
|
5454
5462
|
|
|
5455
5463
|
with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=False):
|
|
5456
5464
|
st.caption("Cada serviço está separado no seu próprio cartão. Os botões de teste ficam dentro do cartão correspondente e fazem apenas diagnóstico, sem gerar áudio ou música.")
|
|
@@ -5599,15 +5607,6 @@ def render_settings():
|
|
|
5599
5607
|
widget_key="api_test_postiz",
|
|
5600
5608
|
)
|
|
5601
5609
|
|
|
5602
|
-
if db_test_clicked:
|
|
5603
|
-
test_settings = dict(settings)
|
|
5604
|
-
test_settings.update({"influencer_db_backend": influencer_db_backend, "influencer_supabase_url": influencer_supabase_url, "influencer_supabase_key": influencer_supabase_key, "influencer_supabase_bucket": influencer_supabase_bucket, "influencer_sqlite_path": influencer_sqlite_path})
|
|
5605
|
-
db_result = test_backend(test_settings)
|
|
5606
|
-
if db_result.get("ok"):
|
|
5607
|
-
st.success(db_result.get("message") or "Backend disponível.")
|
|
5608
|
-
else:
|
|
5609
|
-
st.error(db_result.get("message") or "O backend não está disponível.")
|
|
5610
|
-
|
|
5611
5610
|
save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
|
|
5612
5611
|
if save_all_settings:
|
|
5613
5612
|
settings.update({
|
|
@@ -5615,7 +5614,6 @@ def render_settings():
|
|
|
5615
5614
|
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
5616
5615
|
"apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
|
|
5617
5616
|
"llm_rpm_limit_enabled": bool(llm_rpm_limit_enabled), "llm_rpm_limit": int(llm_rpm_limit), "llm_rpm_window_seconds": int(llm_rpm_window_seconds),
|
|
5618
|
-
"influencer_db_backend": influencer_db_backend, "influencer_supabase_url": influencer_supabase_url.strip(), "influencer_supabase_key": influencer_supabase_key.strip(), "influencer_supabase_bucket": influencer_supabase_bucket.strip() or "ai-influencers", "influencer_sqlite_path": influencer_sqlite_path.strip() or "storage/state/ai_influencers.db",
|
|
5619
5617
|
"azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
|
|
5620
5618
|
"siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
|
|
5621
5619
|
"minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
|
|
@@ -5647,6 +5645,48 @@ def render_settings():
|
|
|
5647
5645
|
|
|
5648
5646
|
with ai_influencers_tab:
|
|
5649
5647
|
render_ai_influencers_api_status(settings)
|
|
5648
|
+
st.divider()
|
|
5649
|
+
st.subheader("Banco de Dados Influencers")
|
|
5650
|
+
st.caption("Escolha o backend usado por Personagens e Geração de Conteúdo IA. SQLite local funciona sem credenciais externas; Supabase é opcional. Se Supabase estiver seleccionado mas ainda incompleto, o sistema mantém SQLite como fallback operacional.")
|
|
5651
|
+
with st.form("influencer_database_settings_form"):
|
|
5652
|
+
saved_backend = str(settings.get("influencer_db_backend") or "SQLite").strip()
|
|
5653
|
+
backend_index = list(BACKEND_OPTIONS).index(saved_backend) if saved_backend in BACKEND_OPTIONS else list(BACKEND_OPTIONS).index("SQLite")
|
|
5654
|
+
influencer_db_backend = st.selectbox(
|
|
5655
|
+
"Backend da base de dados de AI Influencers",
|
|
5656
|
+
list(BACKEND_OPTIONS),
|
|
5657
|
+
index=backend_index,
|
|
5658
|
+
key="settings_influencer_db_backend",
|
|
5659
|
+
help="Seleccione Supabase para usar a base remota ou SQLite para guardar tudo localmente.",
|
|
5660
|
+
)
|
|
5661
|
+
db_cols = st.columns(2)
|
|
5662
|
+
with db_cols[0]:
|
|
5663
|
+
influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
|
|
5664
|
+
influencer_supabase_key = text_setting("Supabase API key", "influencer_supabase_key", secret=True, help_text="Use uma chave com as permissões RLS adequadas. Nunca é colocada no GitHub ou nos logs.")
|
|
5665
|
+
influencer_supabase_bucket = text_setting("Supabase Storage bucket", "influencer_supabase_bucket", help_text="Bucket usado para imagens e documentos, por defeito ai-influencers.")
|
|
5666
|
+
with db_cols[1]:
|
|
5667
|
+
influencer_sqlite_path = text_setting("SQLite ficheiro local", "influencer_sqlite_path", help_text="Caminho absoluto ou relativo ao projecto. Por defeito: storage/state/ai_influencers.db")
|
|
5668
|
+
st.caption("SQLite usa a biblioteca standard do Python e cria o schema automaticamente ao testar ou usar o backend.")
|
|
5669
|
+
test_backend_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True)
|
|
5670
|
+
save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", use_container_width=True)
|
|
5671
|
+
if test_backend_clicked:
|
|
5672
|
+
test_settings = dict(settings)
|
|
5673
|
+
test_settings.update({"influencer_db_backend": influencer_db_backend, "influencer_supabase_url": influencer_supabase_url, "influencer_supabase_key": influencer_supabase_key, "influencer_supabase_bucket": influencer_supabase_bucket, "influencer_sqlite_path": influencer_sqlite_path})
|
|
5674
|
+
db_result = test_backend(test_settings)
|
|
5675
|
+
if db_result.get("ok"):
|
|
5676
|
+
st.success(db_result.get("message") or "Backend disponível.")
|
|
5677
|
+
else:
|
|
5678
|
+
st.error(db_result.get("message") or "O backend não está disponível.")
|
|
5679
|
+
if save_backend_clicked:
|
|
5680
|
+
settings.update({
|
|
5681
|
+
"influencer_db_backend": influencer_db_backend,
|
|
5682
|
+
"influencer_supabase_url": influencer_supabase_url.strip(),
|
|
5683
|
+
"influencer_supabase_key": influencer_supabase_key.strip(),
|
|
5684
|
+
"influencer_supabase_bucket": influencer_supabase_bucket.strip() or "ai-influencers",
|
|
5685
|
+
"influencer_sqlite_path": influencer_sqlite_path.strip() or "storage/state/ai_influencers.db",
|
|
5686
|
+
})
|
|
5687
|
+
write_json("settings.json", settings)
|
|
5688
|
+
st.success("Configuração do backend AI Influencers guardada.")
|
|
5689
|
+
st.rerun()
|
|
5650
5690
|
|
|
5651
5691
|
with voice_test_tab:
|
|
5652
5692
|
st.subheader("Teste de Voz")
|
package/hermes_ui/domain.py
CHANGED
|
@@ -259,6 +259,29 @@ def update_task(task_id: str, updates: dict[str, Any]) -> dict[str, Any] | None:
|
|
|
259
259
|
return None
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
def delete_task(task_id: str) -> dict[str, Any] | None:
|
|
263
|
+
"""Remove uma tarefa de vídeo da fila sem apagar os ficheiros dos artefactos."""
|
|
264
|
+
normalized_id = str(task_id or "").strip()
|
|
265
|
+
if not normalized_id:
|
|
266
|
+
return None
|
|
267
|
+
tasks = read_json("tasks.json", [])
|
|
268
|
+
if not isinstance(tasks, list):
|
|
269
|
+
return None
|
|
270
|
+
removed = next((task for task in tasks if isinstance(task, dict) and str(task.get("id") or "") == normalized_id), None)
|
|
271
|
+
if removed is None:
|
|
272
|
+
return None
|
|
273
|
+
if str(removed.get("state") or "") == "doing":
|
|
274
|
+
raise ValueError("Pare a tarefa antes de a remover da fila.")
|
|
275
|
+
write_json("tasks.json", [task for task in tasks if not (isinstance(task, dict) and str(task.get("id") or "") == normalized_id)])
|
|
276
|
+
queues = read_json("queues.json", {})
|
|
277
|
+
if isinstance(queues, dict):
|
|
278
|
+
for queue_name, queue_items in list(queues.items()):
|
|
279
|
+
if isinstance(queue_items, list):
|
|
280
|
+
queues[queue_name] = [item for item in queue_items if str(item) != normalized_id]
|
|
281
|
+
write_json("queues.json", queues)
|
|
282
|
+
return removed
|
|
283
|
+
|
|
284
|
+
|
|
262
285
|
def transition_task(task_id: str, state: str | None = None, stage: str | None = None, error: str | None = None) -> dict[str, Any] | None:
|
|
263
286
|
if state and state not in VALID_STATES:
|
|
264
287
|
raise ValueError(f"Estado inválido: {state}")
|
|
@@ -459,6 +459,58 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
459
459
|
return video_path
|
|
460
460
|
|
|
461
461
|
|
|
462
|
+
def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
|
|
463
|
+
"""Load a previously saved script so retries do not regenerate it."""
|
|
464
|
+
artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
|
|
465
|
+
script_path = Path(str(artifacts.get("script") or "")).expanduser()
|
|
466
|
+
if not script_path.is_file() or script_path.stat().st_size <= 0:
|
|
467
|
+
return None
|
|
468
|
+
try:
|
|
469
|
+
raw = script_path.read_text(encoding="utf-8").strip()
|
|
470
|
+
except OSError:
|
|
471
|
+
return None
|
|
472
|
+
if not raw:
|
|
473
|
+
return None
|
|
474
|
+
content = raw
|
|
475
|
+
if raw.startswith("---"):
|
|
476
|
+
parts = raw.split("---", 2)
|
|
477
|
+
if len(parts) == 3:
|
|
478
|
+
content = parts[2].strip()
|
|
479
|
+
if content.startswith(">"):
|
|
480
|
+
content = "\n".join(line[1:].lstrip() if line.startswith(">") else line for line in content.splitlines()).strip()
|
|
481
|
+
return {
|
|
482
|
+
"document_type": "video_script",
|
|
483
|
+
"title": str(task.get("title") or topic),
|
|
484
|
+
"summary": topic,
|
|
485
|
+
"content": content,
|
|
486
|
+
"language": str(task.get("language") or channel.get("language") or "Português"),
|
|
487
|
+
"blueprint_id": str(blueprint.get("id") or ""),
|
|
488
|
+
"blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
489
|
+
"channel_id": str(channel.get("id") or ""),
|
|
490
|
+
"channel_name": str(channel.get("name") or "Canal sem nome"),
|
|
491
|
+
"generated_by": "persisted_pipeline_artifact",
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _valid_artifact_path(value: Any) -> Path | None:
|
|
496
|
+
path = Path(str(value or "")).expanduser()
|
|
497
|
+
try:
|
|
498
|
+
return path if path.is_file() and path.stat().st_size > 0 else None
|
|
499
|
+
except OSError:
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _read_json_artifact(value: Any) -> dict[str, Any] | None:
|
|
504
|
+
path = _valid_artifact_path(value)
|
|
505
|
+
if path is None:
|
|
506
|
+
return None
|
|
507
|
+
try:
|
|
508
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
509
|
+
except (OSError, json.JSONDecodeError):
|
|
510
|
+
return None
|
|
511
|
+
return payload if isinstance(payload, dict) else None
|
|
512
|
+
|
|
513
|
+
|
|
462
514
|
def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
463
515
|
task_id = str(task.get("id") or "")
|
|
464
516
|
channel = _channel_for_task(task)
|
|
@@ -473,116 +525,130 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
473
525
|
raise PipelineError("A IA não devolveu um tema válido.")
|
|
474
526
|
_update(task_id, topic=topic, topic_source="llm", ai_generation={"topic": topic_result}, progress=12)
|
|
475
527
|
|
|
476
|
-
_update(task_id, stage="script", state="doing", progress=18, error=None)
|
|
477
528
|
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
478
|
-
provided_script = str(generation_settings.get("video_script") or "").strip()
|
|
479
|
-
if provided_script:
|
|
480
|
-
script = {
|
|
481
|
-
"document_type": "video_script",
|
|
482
|
-
"title": str(task.get("title") or topic),
|
|
483
|
-
"summary": topic,
|
|
484
|
-
"content": provided_script,
|
|
485
|
-
"language": str(task.get("language") or channel.get("language") or "Português"),
|
|
486
|
-
"blueprint_id": str(blueprint.get("id") or ""),
|
|
487
|
-
"blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
488
|
-
"channel_id": str(channel.get("id") or ""),
|
|
489
|
-
"channel_name": str(channel.get("name") or "Canal sem nome"),
|
|
490
|
-
"generation_settings": generation_settings,
|
|
491
|
-
"generated_by": "video_creation_form",
|
|
492
|
-
}
|
|
493
|
-
else:
|
|
494
|
-
script = generate_script_document(
|
|
495
|
-
settings,
|
|
496
|
-
document_type="Roteiro de vídeo",
|
|
497
|
-
title=str(task.get("title") or topic),
|
|
498
|
-
brief=topic,
|
|
499
|
-
language=str(task.get("language") or channel.get("language") or "Português"),
|
|
500
|
-
channel=channel,
|
|
501
|
-
blueprint=blueprint,
|
|
502
|
-
structure_notes=str(generation_settings.get("script_structure_notes") or ""),
|
|
503
|
-
generation_settings=generation_settings,
|
|
504
|
-
)
|
|
505
|
-
script_record = save_script_document(script)
|
|
506
529
|
artifacts = dict(task.get("artifacts") or {})
|
|
507
|
-
|
|
508
|
-
|
|
530
|
+
script = _read_persisted_script(task, channel, blueprint, topic)
|
|
531
|
+
if script is None:
|
|
532
|
+
_update(task_id, stage="script", state="doing", progress=18, error=None)
|
|
533
|
+
provided_script = str(generation_settings.get("video_script") or "").strip()
|
|
534
|
+
if provided_script:
|
|
535
|
+
script = {
|
|
536
|
+
"document_type": "video_script",
|
|
537
|
+
"title": str(task.get("title") or topic),
|
|
538
|
+
"summary": topic,
|
|
539
|
+
"content": provided_script,
|
|
540
|
+
"language": str(task.get("language") or channel.get("language") or "Português"),
|
|
541
|
+
"blueprint_id": str(blueprint.get("id") or ""),
|
|
542
|
+
"blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
543
|
+
"channel_id": str(channel.get("id") or ""),
|
|
544
|
+
"channel_name": str(channel.get("name") or "Canal sem nome"),
|
|
545
|
+
"generation_settings": generation_settings,
|
|
546
|
+
"generated_by": "video_creation_form",
|
|
547
|
+
}
|
|
548
|
+
else:
|
|
549
|
+
script = generate_script_document(
|
|
550
|
+
settings,
|
|
551
|
+
document_type="Roteiro de vídeo",
|
|
552
|
+
title=str(task.get("title") or topic),
|
|
553
|
+
brief=topic,
|
|
554
|
+
language=str(task.get("language") or channel.get("language") or "Português"),
|
|
555
|
+
channel=channel,
|
|
556
|
+
blueprint=blueprint,
|
|
557
|
+
structure_notes=str(generation_settings.get("script_structure_notes") or ""),
|
|
558
|
+
generation_settings=generation_settings,
|
|
559
|
+
)
|
|
560
|
+
script_record = save_script_document(script)
|
|
561
|
+
artifacts["script"] = script_record.get("path", "")
|
|
562
|
+
_update(task_id, artifacts=artifacts, progress=max(30, int(task.get("progress") or 0)))
|
|
563
|
+
else:
|
|
564
|
+
_update(task_id, artifacts=artifacts, error=None, progress=max(30, int(task.get("progress") or 0)))
|
|
509
565
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
provided_keywords = generation_settings.get("video_keywords") or task.get("keywords")
|
|
566
|
+
title = str(task.get("title") or "").strip()
|
|
567
|
+
provided_keywords = generation_settings.get("video_keywords") or task.get("keywords") or task.get("tags")
|
|
513
568
|
if isinstance(provided_keywords, str):
|
|
514
569
|
provided_keywords = re.split(r"[,\n;|]+", provided_keywords)
|
|
515
|
-
|
|
570
|
+
keywords = [str(item).strip() for item in provided_keywords or [] if str(item).strip()][:15]
|
|
516
571
|
title_candidates = task.get("title_candidates") if isinstance(task.get("title_candidates"), list) else []
|
|
517
|
-
|
|
518
|
-
"title"
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
572
|
+
if not title or not keywords:
|
|
573
|
+
_update(task_id, stage="title", state="doing", progress=max(35, int(task.get("progress") or 0)), error=None)
|
|
574
|
+
editorial: dict[str, Any] = {
|
|
575
|
+
"title": title or topic,
|
|
576
|
+
"title_candidates": title_candidates,
|
|
577
|
+
"keywords": keywords,
|
|
578
|
+
}
|
|
579
|
+
if not title:
|
|
580
|
+
try:
|
|
581
|
+
editorial = generate_title_and_keywords(
|
|
582
|
+
settings,
|
|
583
|
+
channel,
|
|
584
|
+
topic,
|
|
585
|
+
blueprint,
|
|
586
|
+
language=str(task.get("language") or channel.get("language") or "Português"),
|
|
587
|
+
)
|
|
588
|
+
except CreativeGenerationError:
|
|
589
|
+
# A falha editorial não deve impedir a geração do vídeo: usa o tema
|
|
590
|
+
# como título e keywords determinísticas, deixando o erro de imagem
|
|
591
|
+
# para a etapa posterior e independente da criação do MP4.
|
|
592
|
+
editorial = {"title": topic, "title_candidates": [], "keywords": []}
|
|
593
|
+
title = str(editorial.get("title") or title or topic).strip()
|
|
594
|
+
keywords = keywords or (
|
|
595
|
+
editorial.get("keywords") if isinstance(editorial.get("keywords"), list) else []
|
|
596
|
+
)
|
|
597
|
+
keywords = [str(item).strip() for item in keywords if str(item).strip()][:15] or _keywords(
|
|
598
|
+
topic,
|
|
599
|
+
title,
|
|
600
|
+
str(channel.get("niche") or ""),
|
|
601
|
+
)
|
|
602
|
+
title_candidates = editorial.get("title_candidates") if isinstance(editorial.get("title_candidates"), list) else title_candidates
|
|
603
|
+
title_artifact = _save_json_artifact(
|
|
604
|
+
task_id,
|
|
605
|
+
"title-keywords",
|
|
606
|
+
{"topic": topic, "title": title, "keywords": keywords, "title_candidates": title_candidates},
|
|
607
|
+
)
|
|
608
|
+
artifacts["title_keywords"] = title_artifact
|
|
609
|
+
_update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, progress=max(45, int(task.get("progress") or 0)))
|
|
610
|
+
else:
|
|
611
|
+
_update(task_id, title=title, tags=keywords, artifacts=artifacts, title_candidates=title_candidates, error=None, progress=max(50, int(task.get("progress") or 0)))
|
|
553
612
|
|
|
554
|
-
_update(task_id, stage="keywords", state="doing", progress=
|
|
555
|
-
_update(task_id, tags=keywords, progress=50)
|
|
613
|
+
_update(task_id, stage="keywords", state="doing", progress=max(50, int(task.get("progress") or 0)), error=None)
|
|
614
|
+
_update(task_id, tags=keywords, progress=max(50, int(task.get("progress") or 0)))
|
|
556
615
|
|
|
557
616
|
# O vídeo é deliberadamente concluído antes de qualquer chamada ao provider
|
|
558
617
|
# de imagem. O artefacto fica persistido mesmo que a quota da thumbnail falhe.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
618
|
+
existing_video = _valid_artifact_path(artifacts.get("video"))
|
|
619
|
+
if existing_video is None:
|
|
620
|
+
_update(task_id, stage="video", state="doing", progress=max(52, int(task.get("progress") or 0)), error=None)
|
|
621
|
+
video_cards = media_cards_for_pool(settings, "video")
|
|
622
|
+
if bool(settings.get("media_video_pool_enabled")) and video_cards:
|
|
623
|
+
try:
|
|
624
|
+
video_prompt = _append_generation_constraints(
|
|
625
|
+
f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
|
|
626
|
+
kind="video",
|
|
627
|
+
)
|
|
628
|
+
video_path = generate_video_from_pool(settings, video_prompt)
|
|
629
|
+
except MediaGenerationError as exc:
|
|
630
|
+
raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
|
|
631
|
+
else:
|
|
632
|
+
video_path = _run_video_helper({**task, "topic": topic, "title": title})
|
|
633
|
+
current_after_video = _task_by_id(task_id) or {}
|
|
634
|
+
artifacts = dict(current_after_video.get("artifacts") or artifacts)
|
|
635
|
+
artifacts["video"] = str(video_path)
|
|
636
|
+
_update(task_id, artifacts=artifacts, video_ready=True, progress=max(80, int(task.get("progress") or 0)))
|
|
570
637
|
else:
|
|
571
|
-
video_path =
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
artifacts["video"] = str(video_path)
|
|
575
|
-
_update(task_id, artifacts=artifacts, video_ready=True, progress=80)
|
|
638
|
+
video_path = existing_video
|
|
639
|
+
artifacts["video"] = str(existing_video)
|
|
640
|
+
_update(task_id, stage="video", state="doing", artifacts=artifacts, video_ready=True, error=None, progress=max(80, int(task.get("progress") or 0)))
|
|
576
641
|
|
|
577
|
-
|
|
642
|
+
persisted_prompt = _read_json_artifact(artifacts.get("thumbnail_prompt_json"))
|
|
643
|
+
persisted_variant = persisted_prompt.get("thumbnail") if isinstance(persisted_prompt, dict) and isinstance(persisted_prompt.get("thumbnail"), dict) else {}
|
|
578
644
|
existing_variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
|
|
579
|
-
|
|
580
|
-
if not str(
|
|
581
|
-
|
|
582
|
-
if not str(
|
|
583
|
-
|
|
584
|
-
variant = existing_variant
|
|
645
|
+
variant = {**persisted_variant, **dict(existing_variant)}
|
|
646
|
+
if not str(variant.get("image_prompt") or "").strip() and str(task.get("thumbnail_prompt") or "").strip():
|
|
647
|
+
variant["image_prompt"] = str(task.get("thumbnail_prompt") or "").strip()
|
|
648
|
+
if not str(variant.get("overlay_text") or "").strip() and str(task.get("thumbnail_text") or "").strip():
|
|
649
|
+
variant["overlay_text"] = str(task.get("thumbnail_text") or "").strip()
|
|
585
650
|
if not str(variant.get("image_prompt") or "").strip():
|
|
651
|
+
_update(task_id, stage="thumbnail_prompt", state="doing", progress=max(82, int(task.get("progress") or 0)), error=None)
|
|
586
652
|
try:
|
|
587
653
|
variant = generate_thumbnail_prompt(
|
|
588
654
|
settings,
|
|
@@ -608,24 +674,25 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
608
674
|
"lettering_prompt": str(variant.get("lettering_prompt") or ""),
|
|
609
675
|
},
|
|
610
676
|
}
|
|
611
|
-
|
|
612
|
-
|
|
677
|
+
if _valid_artifact_path(artifacts.get("thumbnail_prompt_json")) is None:
|
|
678
|
+
artifacts["thumbnail_prompt_json"] = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
|
|
613
679
|
_update(
|
|
614
680
|
task_id,
|
|
615
681
|
stage="thumbnail_prompt",
|
|
616
682
|
state="doing",
|
|
617
|
-
progress=84,
|
|
683
|
+
progress=max(84, int(task.get("progress") or 0)),
|
|
618
684
|
thumbnail_variant=variant,
|
|
619
685
|
thumbnail_prompt=str(variant.get("image_prompt") or ""),
|
|
620
686
|
thumbnail_text=str(variant.get("overlay_text") or ""),
|
|
621
687
|
thumbnail_status="prompt_ready",
|
|
622
688
|
artifacts=artifacts,
|
|
689
|
+
error=None,
|
|
623
690
|
)
|
|
624
691
|
|
|
625
|
-
_update(task_id, stage="thumbnail", state="doing", progress=86, error=None)
|
|
692
|
+
_update(task_id, stage="thumbnail", state="doing", progress=max(86, int(task.get("progress") or 0)), error=None)
|
|
626
693
|
current_artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or artifacts)
|
|
627
|
-
existing_thumbnail =
|
|
628
|
-
if existing_thumbnail
|
|
694
|
+
existing_thumbnail = _valid_artifact_path(current_artifacts.get("thumbnail") or variant.get("image_path"))
|
|
695
|
+
if existing_thumbnail is not None:
|
|
629
696
|
thumbnail_path = existing_thumbnail
|
|
630
697
|
else:
|
|
631
698
|
try:
|
|
@@ -641,9 +708,13 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
641
708
|
raise PipelineError(f"A thumbnail não foi gerada; o vídeo já está disponível em {video_path}: {exc}") from exc
|
|
642
709
|
artifacts = dict((_task_by_id(task_id) or {}).get("artifacts") or current_artifacts)
|
|
643
710
|
artifacts["thumbnail"] = str(thumbnail_path)
|
|
644
|
-
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=90)
|
|
711
|
+
_update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=max(90, int(task.get("progress") or 0)))
|
|
712
|
+
|
|
713
|
+
stored_upload = artifacts.get("upload")
|
|
714
|
+
if isinstance(stored_upload, dict) and stored_upload:
|
|
715
|
+
return _update(task_id, stage="upload", state="done", progress=100, artifacts=artifacts, video_ready=True, error=None)
|
|
645
716
|
|
|
646
|
-
_update(task_id, stage="upload", state="doing", progress=94, error=None)
|
|
717
|
+
_update(task_id, stage="upload", state="doing", progress=max(94, int(task.get("progress") or 0)), error=None)
|
|
647
718
|
result = upload_with_default_route(
|
|
648
719
|
settings,
|
|
649
720
|
storage_root=STORAGE,
|
package/package.json
CHANGED
|
@@ -60,9 +60,9 @@ Depois, carregue a imagem de referência para o bucket usando o botão `Upload f
|
|
|
60
60
|
|
|
61
61
|
Para usar **AI Influencers > Personagens** e **Geração de Conteúdo IA** com Supabase, aplique o ficheiro `seed/references/ai_influencers_schema.sql` no SQL Editor. Ele cria as tabelas `influencers`, `influencer_assets`, `influencer_weekly_plans` e `influencer_content`, além dos índices e da activação de RLS.
|
|
62
62
|
|
|
63
|
-
No Thunderbolt, abra **Configurações > Configuração API >
|
|
63
|
+
No Thunderbolt, abra **Configurações > Configuração API > AI Influencers > Banco de Dados Influencers**, seleccione **Supabase** e preencha o **Supabase Project URL**, a **Supabase API key** e o bucket de Storage. Crie o bucket com o mesmo nome configurado, por defeito `ai-influencers`, e confirme as políticas RLS/Storage antes de guardar imagens ou documentos. A chave não deve ser colocada no GitHub, nos workflows JSON ou em screenshots.
|
|
64
64
|
|
|
65
|
-
O Thunderbolt usa o Supabase como backend seleccionado, não como executor de n8n: os assets são enviados ao Storage, os metadados ficam nas tabelas e os estados de geração ficam em `influencer_content`. Para uma execução totalmente local, seleccione **SQLite** no mesmo
|
|
65
|
+
O Thunderbolt usa o Supabase como backend seleccionado, não como executor de n8n: os assets são enviados ao Storage, os metadados ficam nas tabelas e os estados de geração ficam em `influencer_content`. Para uma execução totalmente local, seleccione **SQLite** no mesmo painel; a alternativa cria `storage/state/ai_influencers.db` e não utiliza a conta Supabase.
|
|
66
66
|
|
|
67
67
|
A conta Supabase está pronta para ser utilizada pela automação.
|
|
68
68
|
|