@danhachuel/thunderbolt 0.2.76 → 0.2.78
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 +21 -7
- package/README.md +20 -6
- package/app/main.py +165 -22
- package/app/modules/niche_finder/apify.py +1 -1
- package/hermes_ui/creative_generation.py +3 -3
- package/hermes_ui/languages.py +197 -0
- package/hermes_ui/notifications.py +3 -0
- package/hermes_ui/storage.py +3 -0
- package/hermes_ui/thumbnail_generation.py +1 -1
- package/integrations/moneyprinter_config.py +7 -0
- package/integrations/postiz.py +1 -1
- package/integrations/upload_post.py +152 -0
- package/integrations/upload_routing.py +1 -1
- package/package.json +1 -1
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.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.78
|
|
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)
|
|
@@ -23,7 +23,7 @@ A instalação assistida cria um ambiente local separado para evitar misturar as
|
|
|
23
23
|
| FFmpeg | Disponibilizado pelo pacote Python `imageio-ffmpeg` |
|
|
24
24
|
| Porta da aplicação | `3030`, configurável com `THUNDERBOLT_PORT` |
|
|
25
25
|
|
|
26
|
-
A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local.
|
|
26
|
+
A aplicação não instala drivers de GPU, Docker, modelos Whisper ou credenciais externas. As chaves de API do MoneyPrinterTurbo são configuradas na aba **Configurações** e sincronizadas com o `config.toml` do clone local. O idioma da interface e o idioma dos vídeos são preferências independentes.
|
|
27
27
|
|
|
28
28
|
O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a instalação com `uv` ou com `venv + pip`. A aplicação segue o mesmo princípio e adiciona um instalador assistido próprio.[1]
|
|
29
29
|
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.77 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.
|
|
@@ -190,6 +190,12 @@ $env:THUNDERBOLT_VENV="C:\ContentHermes\.venv"
|
|
|
190
190
|
npx --yes @danhachuel/thunderbolt install
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
## Idiomas e selector de bandeira
|
|
194
|
+
|
|
195
|
+
O Thunderbolt inclui as dez opções de idioma usadas pelo fluxo MoneyPrinterTurbo: **🇺🇸 Inglês (en)**, **🇨🇳 Chinês Simplificado (zh)**, **🇩🇪 Alemão (de)**, **🇻🇳 Vietnamita (vi)**, **🇹🇷 Turco (tr)**, **🇧🇷 Português (pt)**, **🇷🇺 Russo (ru)**, **🇪🇸 Espanhol (es)**, **🇮🇩 Indonésio (id)** e **🇮🇹 Italiano (it)**. A criação de vídeos mantém ainda os rótulos históricos para não invalidar Blueprints e tarefas antigas.
|
|
196
|
+
|
|
197
|
+
No canto superior direito, imediatamente à esquerda de **Deploy**, o botão com a bandeira actual abre o selector do **Idioma da interface**. A escolha fica guardada como `ui_language` em `storage/state/settings.json` e, quando existe um caminho MoneyPrinterTurbo configurado, é sincronizada com `[ui].language` no `config.toml`. Na Criação de Vídeos e em Roteiros, o selector **Script Language** mostra bandeira, nome e código, mas guarda o código curto; essa escolha é sincronizada com `[ui].video_language`.
|
|
198
|
+
|
|
193
199
|
## 4. Diagnóstico antes de iniciar
|
|
194
200
|
|
|
195
201
|
Depois da instalação, execute:
|
|
@@ -436,7 +442,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
436
442
|
|
|
437
443
|
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.
|
|
438
444
|
|
|
439
|
-
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.2.
|
|
445
|
+
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.2.77 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
440
446
|
|
|
441
447
|
### Niche Finder Apify
|
|
442
448
|
|
|
@@ -478,16 +484,24 @@ A aba **Automação Youtube**, dentro do menu expansível **Automação**, lista
|
|
|
478
484
|
|
|
479
485
|
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.
|
|
480
486
|
|
|
481
|
-
## Upload, Postiz e Upload directo
|
|
487
|
+
## Upload, Postiz, Upload-Post e Upload directo
|
|
482
488
|
|
|
483
489
|
A subaba **Postiz**, dentro de **Upload**, permite carregar as integrações Postiz, seleccionar o canal ligado e enviar vídeos MP4 através da Public API. Configure **Activar Postiz como fallback final**, **Postiz API key**, **Postiz Public API Base URL**, **Postiz MCP URL** e, opcionalmente, o ID da integração padrão em **Configuração API > API Keys > Serviços e modelos**. A API key é enviada como valor bruto do cabeçalho `Authorization`; o upload usa `POST /upload` e a publicação usa `POST /posts`.
|
|
484
490
|
|
|
485
|
-
O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
|
|
491
|
+
O botão de envio YouTube segue a ordem fixa **1. API Oficial, 2. Upload directo, 3. Postiz**. O **Upload-Post** é uma quarta subaba independente e não altera essa rota automática do YouTube. A API Oficial regista no storage local até cinco envios bem-sucedidos por dia por conta Gmail. Quando a quota é atingida ou o método falha, o Thunderbolt valida o `credentials.json` e tenta o Upload directo. Postiz só é tentado como último recurso e apenas quando está activo, com API key e integração válida.
|
|
486
492
|
|
|
487
493
|
Em **Configurações > Contas Google**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
|
|
488
494
|
|
|
489
495
|
Em **Canais Youtube > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
|
|
490
496
|
|
|
497
|
+
### Upload-Post
|
|
498
|
+
|
|
499
|
+
A subaba **Upload-Post** usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para publicar vídeos prontos em uma ou mais plataformas ligadas ao perfil configurado. Em **Configuração API > API Keys > Serviços e modelos > Publicação através do Upload-Post**, active a integração, guarde a API key, o username/perfil e as plataformas padrão. Depois abra **Upload > Upload-Post**, confirme ou altere as plataformas, escreva o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
500
|
+
|
|
501
|
+
A credencial não é duplicada na página de Upload. A integração envia o ficheiro local como `video` em `multipart/form-data`, usa `platform[]` repetido para cada destino e autentica com `Authorization: Apikey ...`. **Processar em segundo plano** envia `async_upload=true`; quando a API devolver `request_id`, este fica visível e é guardado em `uploads.json`. O histórico local também alimenta a notificação **Upload-Post concluído**. O Upload-Post é distinto do Postiz: não usa os endpoints nem a selecção de integrações do Postiz.
|
|
502
|
+
|
|
503
|
+
### Upload directo
|
|
504
|
+
|
|
491
505
|
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
|
|
492
506
|
|
|
493
507
|
Associe cada canal à conta Google correcta em **Canais Youtube**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ A primeira versão implementa a camada UI independente com:
|
|
|
15
15
|
| Blueprints Youtube | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
|
|
16
16
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
17
17
|
| Canais Youtube | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
|
|
18
|
-
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos;
|
|
18
|
+
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; idiomas históricos preservados e dez códigos MoneyPrinterTurbo com bandeiras; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
19
19
|
| Automação | Menu expansível com a subaba **Automação Youtube**, onde ficam os vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
20
20
|
| AI Influencers | Menu expansível com Personagens, Redes Sociais e Tutorial Meta |
|
|
21
21
|
| Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
|
|
@@ -25,6 +25,14 @@ A primeira versão implementa a camada UI independente com:
|
|
|
25
25
|
| Configurações | **Contas Google**, **Configuração API** e **Notificações**; API Keys divididas entre **Serviços e modelos** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` no documento da conta, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
|
|
26
26
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
27
27
|
|
|
28
|
+
## Upload-Post — publicação para múltiplas plataformas
|
|
29
|
+
|
|
30
|
+
A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
31
|
+
|
|
32
|
+
A API key, o username/perfil e a lista inicial de plataformas continuam em **Configuração API > API Keys > Serviços e modelos**, no expander **Publicação através do Upload-Post**. A subaba não pede novamente a credencial. O cliente envia `multipart/form-data` para `https://api.upload-post.com/api/upload`, repete `platform[]` para cada destino e guarda a resposta, o `request_id` e o resultado no histórico local de uploads. A opção **Processar em segundo plano** usa `async_upload=true` quando a API estiver configurada para processamento assíncrono.
|
|
33
|
+
|
|
34
|
+
O Upload-Post é independente do Postiz: Postiz continua a usar o seu fluxo próprio de asset + post, enquanto Upload-Post publica directamente nas plataformas ligadas ao username configurado. Uma publicação aceite pelo Upload-Post também é reconciliada no centro de **Notificações**.
|
|
35
|
+
|
|
28
36
|
## Upload directo — credenciais por conta e por canal
|
|
29
37
|
|
|
30
38
|
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações > Contas Google > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
|
|
@@ -38,7 +46,13 @@ Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs,
|
|
|
38
46
|
|
|
39
47
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
40
48
|
|
|
41
|
-
##
|
|
49
|
+
## Idiomas e selector rápido da UI
|
|
50
|
+
|
|
51
|
+
O Thunderbolt mantém os idiomas históricos da criação de vídeos e acrescenta as dez opções canónicas do MoneyPrinterTurbo: **🇺🇸 Inglês (en)**, **🇨🇳 Chinês Simplificado (zh)**, **🇩🇪 Alemão (de)**, **🇻🇳 Vietnamita (vi)**, **🇹🇷 Turco (tr)**, **🇧🇷 Português (pt)**, **🇷🇺 Russo (ru)**, **🇪🇸 Espanhol (es)**, **🇮🇩 Indonésio (id)** e **🇮🇹 Italiano (it)**. Os códigos curtos são os valores persistidos e sincronizados com `ui.video_language` no `config.toml`; a bandeira e o nome são apenas a apresentação visual.
|
|
52
|
+
|
|
53
|
+
No canto superior direito da aplicação existe um botão compacto com a bandeira do idioma actual, colocado imediatamente à esquerda do botão **Deploy** do Streamlit. Ao abrir o botão, seleccione outro idioma; a preferência é guardada em `storage/state/settings.json` como `ui_language`, e a navegação lateral actualiza-se sem alterar o idioma seleccionado para os vídeos. A primeira implementação traduz o chrome principal da navegação e mantém os rótulos específicos das páginas compatíveis com o fluxo existente.
|
|
54
|
+
|
|
55
|
+
## Navegação da UI 0.2.78
|
|
42
56
|
|
|
43
57
|
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** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **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** e **Tutorial Meta**, 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.
|
|
44
58
|
|
|
@@ -169,9 +183,9 @@ thunderbolt
|
|
|
169
183
|
No Windows PowerShell, se `npx` for bloqueado por `npx.ps1`, use directamente `npx.cmd`:
|
|
170
184
|
|
|
171
185
|
```powershell
|
|
172
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
173
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
174
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
186
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install
|
|
187
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.77 doctor
|
|
188
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.77
|
|
175
189
|
```
|
|
176
190
|
|
|
177
191
|
Como alternativa, pode permitir scripts para o seu utilizador:
|
|
@@ -195,7 +209,7 @@ O menu expansível **Niche Finder** contém duas alternativas independentes. **N
|
|
|
195
209
|
|
|
196
210
|
A interface apresenta dentro da aba os parâmetros da busca: número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, categoria de engagement, intervalo de datas e tags. A página permanece sem resultados até o primeiro clique em **Analisar Nichos**; depois, se os parâmetros forem alterados, mostra os resultados anteriores e pede novo clique para aplicar os filtros actuais. O núcleo aplica normalização, filtros, `log1p`, `StandardScaler`, K-Means e FP-Growth. Os resultados aparecem em DataFrames para clusters, itemsets frequentes, regras de associação e dados analisados, acompanhados por uma visualização Plotly nativa e pesquisa de palavras nos clusters.
|
|
197
211
|
|
|
198
|
-
**Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em **Configuração API > API Keys > Serviços e modelos**. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
212
|
+
**Niche Finder Apify** é a segunda alternativa e não usa o dataset, filtros, execução ou resultados Kaggle. Define três palavras-chave, período, limite de resultados, Shorts, duração, idioma de legendas e ordenação; depois de clicar em **Pesquisar no Apify**, inicia o actor `streamers~youtube-scraper`, acompanha o run, carrega o dataset, normaliza vídeos, limpa SRT, calcula VSC Ratio e tenta resumir as transcrições com o provider LLM configurado. Os resultados ficam na sessão própria `niche_apify_results`, o histórico pequeno fica em `storage/state/niche_apify_runs.json` e existem exportações JSON/CSV. Configure o **Apify API Token** em **Configuração API > API Keys > Serviços e modelos**. As dependências adicionais são instaladas pelo fluxo normal do pacote: `requests`, `pandas` e os componentes já existentes da análise. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.77 install`; o instalador detecta e reutiliza componentes já válidos.
|
|
199
213
|
|
|
200
214
|
## Editor Python baseado no PYEdit
|
|
201
215
|
|
package/app/main.py
CHANGED
|
@@ -37,6 +37,7 @@ from hermes_ui.material_sources import material_api_keys, material_source_catalo
|
|
|
37
37
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
38
38
|
from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
|
|
39
39
|
from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
|
|
40
|
+
from hermes_ui.languages import LANGUAGE_CODES, language_code, language_flag, language_label, ui_text, video_language_label, video_language_options
|
|
40
41
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
41
42
|
from hermes_ui.script_generation import generate_script_document
|
|
42
43
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
@@ -45,6 +46,7 @@ from hermes_ui.creative_generation import CreativeGenerationError, generate_crea
|
|
|
45
46
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
46
47
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
47
48
|
from integrations.postiz import PostizAdapter
|
|
49
|
+
from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
|
|
48
50
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
49
51
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
50
52
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
@@ -125,8 +127,10 @@ VIDEO_LANGUAGE_OPTIONS = [
|
|
|
125
127
|
"50 – Hausa",
|
|
126
128
|
]
|
|
127
129
|
|
|
128
|
-
# Mantemos
|
|
129
|
-
#
|
|
130
|
+
# Mantemos esta lista histórica para preservar Blueprints, testes e tarefas antigas.
|
|
131
|
+
# A UI acrescenta os dez códigos canónicos do MoneyPrinterTurbo com bandeiras.
|
|
132
|
+
VIDEO_LANGUAGE_SELECTION_OPTIONS = ["music", *LANGUAGE_CODES, *[item for item in VIDEO_LANGUAGE_OPTIONS if item not in {"01 – Inglês", "06 – Alemão", "15 – Italiano", "29 – Turco", "31 – Russo", "36 – Português (Brasil)", "39 – Mandarim", "41 – Espanhol (LatAm)", "42 – Vietnamita", "44 – Indonésio"}]]
|
|
133
|
+
|
|
130
134
|
VIDEO_FORMAT_OPTIONS = ["wide", "shorts", "music"]
|
|
131
135
|
VIDEO_CONCATENATION_OPTIONS = ["Random Concatenation (Recommended)", "Sequential Concatenation"]
|
|
132
136
|
VIDEO_TRANSITION_OPTIONS = ["None", "Fade", "Dissolve"]
|
|
@@ -184,6 +188,12 @@ st.markdown("""
|
|
|
184
188
|
[data-testid="stRadio"] label { border:1px solid #2a4052; border-radius:12px; padding:.65rem .8rem; background:#101b25; min-height:4.3rem; }
|
|
185
189
|
[data-testid="stRadio"] label:has(input:checked) { border-color:#c59b55; background:linear-gradient(145deg, rgba(96,71,33,.42), rgba(17,27,37,.95)); }
|
|
186
190
|
[data-testid="stStatusWidget"] { border-color:#2a4052 !important; background:#101b25 !important; }
|
|
191
|
+
/* Selector compacto de idioma, visualmente colocado à esquerda do Deploy do Streamlit. */
|
|
192
|
+
[data-testid="stPopover"] { position:fixed !important; top:0.34rem !important; right:4.75rem !important; z-index:100000 !important; }
|
|
193
|
+
[data-testid="stPopover"] > button { min-width:2.25rem !important; width:2.25rem !important; height:2.25rem !important; padding:0 !important; border:1px solid #2a4052 !important; border-radius:8px !important; background:#101b25 !important; }
|
|
194
|
+
[data-testid="stPopover"] > button p { font-size:1.12rem !important; line-height:1 !important; margin:0 !important; }
|
|
195
|
+
[data-testid="stPopover"] [data-testid="stPopoverBody"] { min-width:18rem; }
|
|
196
|
+
@media (max-width: 700px) { [data-testid="stPopover"] { right:3.2rem !important; } }
|
|
187
197
|
|
|
188
198
|
/* Cores dos chips por identidade da plataforma, sem depender da ordem de selecção. */
|
|
189
199
|
[data-testid="stMultiSelectTagsContainer"] span[data-tag] { color:#ffffff !important; border:0 !important; font-weight:700 !important; }
|
|
@@ -205,6 +215,58 @@ st.markdown("""
|
|
|
205
215
|
""", unsafe_allow_html=True)
|
|
206
216
|
|
|
207
217
|
|
|
218
|
+
def current_ui_language() -> str:
|
|
219
|
+
settings = read_json("settings.json", {})
|
|
220
|
+
stored = st.session_state.get("ui_language") or settings.get("ui_language") or "pt"
|
|
221
|
+
normalized = language_code(stored)
|
|
222
|
+
st.session_state["ui_language"] = normalized
|
|
223
|
+
return normalized
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def save_ui_language(value: str) -> str:
|
|
227
|
+
normalized = language_code(value)
|
|
228
|
+
settings = read_json("settings.json", {})
|
|
229
|
+
settings["ui_language"] = normalized
|
|
230
|
+
write_json("settings.json", settings)
|
|
231
|
+
try:
|
|
232
|
+
sync_moneyprinter_config(settings, str(settings.get("moneyprinter_path") or ""))
|
|
233
|
+
except Exception:
|
|
234
|
+
# The selector remains usable even when no MoneyPrinterTurbo path exists.
|
|
235
|
+
pass
|
|
236
|
+
st.session_state["ui_language"] = normalized
|
|
237
|
+
return normalized
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def save_video_language(value: str) -> str:
|
|
241
|
+
normalized = "music" if str(value or "").strip().casefold() == "music" else language_code(value)
|
|
242
|
+
settings = read_json("settings.json", {})
|
|
243
|
+
settings["video_language"] = normalized
|
|
244
|
+
write_json("settings.json", settings)
|
|
245
|
+
try:
|
|
246
|
+
sync_moneyprinter_config(settings, str(settings.get("moneyprinter_path") or ""))
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
st.session_state["video_language"] = normalized
|
|
250
|
+
return normalized
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render_ui_language_picker(language: str) -> None:
|
|
254
|
+
"""Render the flag-only popover over the Streamlit toolbar area."""
|
|
255
|
+
current = language_code(language)
|
|
256
|
+
with st.popover(language_flag(current), help="Trocar idioma da interface"):
|
|
257
|
+
st.markdown(f"**{ui_text('Idioma da interface', current)}**")
|
|
258
|
+
selected = st.selectbox(
|
|
259
|
+
ui_text("Idioma da interface", current),
|
|
260
|
+
list(LANGUAGE_CODES),
|
|
261
|
+
index=list(LANGUAGE_CODES).index(current),
|
|
262
|
+
format_func=language_label,
|
|
263
|
+
key="ui_language_picker",
|
|
264
|
+
)
|
|
265
|
+
if selected != current:
|
|
266
|
+
save_ui_language(selected)
|
|
267
|
+
st.rerun()
|
|
268
|
+
|
|
269
|
+
|
|
208
270
|
def card(label: str, value: str | int, note: str = ""):
|
|
209
271
|
st.markdown(f'<div class="content-card"><div class="content-label">{label}</div><div class="content-value">{value}</div><div class="small-muted">{note}</div></div>', unsafe_allow_html=True)
|
|
210
272
|
|
|
@@ -376,10 +438,12 @@ def render_video_generation_settings(prefix: str, *, current_language: str = "")
|
|
|
376
438
|
key=f"{prefix}_video_subject",
|
|
377
439
|
placeholder="Ex.: How AI is changing everyday life",
|
|
378
440
|
)
|
|
441
|
+
normalized_current_language = "music" if str(current_language or "").strip().casefold() in {"music", "00 – apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"} else language_code(current_language)
|
|
379
442
|
settings["script_language"] = st.selectbox(
|
|
380
443
|
"Script Language",
|
|
381
|
-
|
|
382
|
-
index=
|
|
444
|
+
VIDEO_LANGUAGE_SELECTION_OPTIONS,
|
|
445
|
+
index=VIDEO_LANGUAGE_SELECTION_OPTIONS.index(normalized_current_language) if normalized_current_language in VIDEO_LANGUAGE_SELECTION_OPTIONS else 0,
|
|
446
|
+
format_func=video_language_label,
|
|
383
447
|
key=f"{prefix}_script_language",
|
|
384
448
|
)
|
|
385
449
|
with subject_cols[1]:
|
|
@@ -602,7 +666,7 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
|
|
|
602
666
|
edited_name = st.text_input("Nome do canal", value=str(channel.get("name") or ""))
|
|
603
667
|
edited_url = st.text_input("URL", value=str(channel.get("url") or ""))
|
|
604
668
|
edited_handle = st.text_input("Handle", value=str(channel.get("handle") or ""))
|
|
605
|
-
edited_language = st.selectbox("Idioma",
|
|
669
|
+
edited_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index(language_code(channel.get("language") or "pt")), format_func=language_label)
|
|
606
670
|
style_options = ["Pexels/Pixabay", "full_ia", "Apenas Música"]
|
|
607
671
|
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"))
|
|
608
672
|
edited_style = st.selectbox("Estilo wide", style_options, index=style_options.index(style_value) if style_value in style_options else 0)
|
|
@@ -1051,7 +1115,7 @@ def render_channels():
|
|
|
1051
1115
|
name = st.text_input("Nome do canal", value=imported.get("name", ""), key="yt_import_name")
|
|
1052
1116
|
url = st.text_input("URL", value=imported.get("url", source if source.startswith("http") else ""), key="yt_import_url")
|
|
1053
1117
|
handle = st.text_input("Handle", value=imported.get("handle", ""), key="yt_import_handle")
|
|
1054
|
-
language = st.selectbox("Idioma",
|
|
1118
|
+
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")
|
|
1055
1119
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="yt_import_style")
|
|
1056
1120
|
blueprint = st.selectbox("Blueprint padrão do canal", blueprint_ids, index=blueprint_ids.index(imported_blueprint) if imported_blueprint in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key="yt_import_blueprint")
|
|
1057
1121
|
voice_options = voice_catalog(imported.get("default_voice") or imported.get("voice", ""))
|
|
@@ -1154,7 +1218,7 @@ def render_channels():
|
|
|
1154
1218
|
with defaults_cols[1]:
|
|
1155
1219
|
batch_voice = st.selectbox("Voz padrão", voice_options, format_func=lambda item: item or "Sem voz padrão", key="batch_channel_voice")
|
|
1156
1220
|
with defaults_cols[2]:
|
|
1157
|
-
batch_language = st.selectbox("Idioma",
|
|
1221
|
+
batch_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="batch_channel_language")
|
|
1158
1222
|
with defaults_cols[3]:
|
|
1159
1223
|
batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
|
|
1160
1224
|
import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
|
|
@@ -1200,7 +1264,7 @@ def render_channels():
|
|
|
1200
1264
|
handle = st.text_input("Handle", placeholder="@seucanal", key="manual_channel_handle")
|
|
1201
1265
|
description = st.text_area("Descrição", key="manual_channel_description")
|
|
1202
1266
|
niche = st.text_input("Canais de Referência / Nicho", placeholder="Ex.: História militar, mistérios, ciência", key="manual_channel_niche")
|
|
1203
|
-
language = st.selectbox("Idioma",
|
|
1267
|
+
language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="manual_channel_language")
|
|
1204
1268
|
style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], index=0, key="manual_channel_style")
|
|
1205
1269
|
manual_blueprint_items = blueprint_catalog()
|
|
1206
1270
|
manual_blueprint_ids = [item[0] for item in manual_blueprint_items]
|
|
@@ -1360,10 +1424,11 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1360
1424
|
)
|
|
1361
1425
|
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
1362
1426
|
selected_one: dict[str, Any] | None = None
|
|
1363
|
-
legacy_language = st.session_state.get("video_language")
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1427
|
+
legacy_language = st.session_state.get("video_language") or read_json("settings.json", {}).get("video_language")
|
|
1428
|
+
if str(legacy_language or "").strip().casefold() in {"music", "00 – apenas música de fundo (sem falas)", "00 - apenas música de fundo (sem falas)"}:
|
|
1429
|
+
st.session_state["video_language"] = "music"
|
|
1430
|
+
else:
|
|
1431
|
+
st.session_state["video_language"] = language_code(legacy_language or "pt")
|
|
1367
1432
|
generation_settings: dict[str, Any] = {}
|
|
1368
1433
|
if mode == "general":
|
|
1369
1434
|
selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
|
|
@@ -1624,6 +1689,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1624
1689
|
fmt = generation_settings["video_format"]
|
|
1625
1690
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
1626
1691
|
if submitted:
|
|
1692
|
+
save_video_language(language)
|
|
1627
1693
|
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
1628
1694
|
if style == "music" and not music_path:
|
|
1629
1695
|
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
@@ -1752,7 +1818,7 @@ def render_scripts():
|
|
|
1752
1818
|
legacy_script_language = str(st.session_state.get("script_language") or "")
|
|
1753
1819
|
script_settings = render_video_generation_settings(
|
|
1754
1820
|
"pipeline_scripts",
|
|
1755
|
-
current_language=legacy_script_language
|
|
1821
|
+
current_language=legacy_script_language or str(read_json("settings.json", {}).get("video_language") or "pt"),
|
|
1756
1822
|
)
|
|
1757
1823
|
language = script_settings["script_language"]
|
|
1758
1824
|
structure_notes = script_settings["script_structure_notes"]
|
|
@@ -2722,11 +2788,13 @@ def render_upload_direct():
|
|
|
2722
2788
|
|
|
2723
2789
|
def render_upload():
|
|
2724
2790
|
st.title("Upload")
|
|
2725
|
-
upload_tab, direct_tab, postiz_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz"])
|
|
2791
|
+
upload_tab, direct_tab, postiz_tab, upload_post_tab = st.tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
|
|
2726
2792
|
with direct_tab:
|
|
2727
2793
|
render_upload_direct()
|
|
2728
2794
|
with postiz_tab:
|
|
2729
2795
|
render_upload_postiz()
|
|
2796
|
+
with upload_post_tab:
|
|
2797
|
+
render_upload_post()
|
|
2730
2798
|
with upload_tab:
|
|
2731
2799
|
render_upload_conventional()
|
|
2732
2800
|
|
|
@@ -2808,6 +2876,78 @@ def render_upload_postiz():
|
|
|
2808
2876
|
(st.success if result.ok else st.error)(result.message)
|
|
2809
2877
|
|
|
2810
2878
|
|
|
2879
|
+
def render_upload_post():
|
|
2880
|
+
st.subheader("Upload-Post")
|
|
2881
|
+
st.caption("Envie um vídeo para uma ou mais plataformas ligadas ao seu perfil Upload-Post. A API key é lida da Configuração API e nunca é mostrada nesta aba.")
|
|
2882
|
+
settings = read_json("settings.json", {})
|
|
2883
|
+
uploader = UploadPostAdapter(settings)
|
|
2884
|
+
status = uploader.status()
|
|
2885
|
+
if not status.ok:
|
|
2886
|
+
st.warning(status.message)
|
|
2887
|
+
if not uploader.enabled:
|
|
2888
|
+
st.info("Active Upload-Post em Configuração API > API Keys > Serviços e modelos e guarde a configuração.")
|
|
2889
|
+
elif not uploader.api_key:
|
|
2890
|
+
st.info("Introduza a API key do Upload-Post em Configuração API > API Keys > Serviços e modelos.")
|
|
2891
|
+
return
|
|
2892
|
+
|
|
2893
|
+
configured_platforms = normalize_upload_post_platforms(uploader.platforms)
|
|
2894
|
+
selected_platforms = st.multiselect(
|
|
2895
|
+
"Plataformas Upload-Post",
|
|
2896
|
+
list(UPLOAD_POST_PLATFORM_OPTIONS),
|
|
2897
|
+
default=configured_platforms,
|
|
2898
|
+
format_func=lambda value: "Facebook Pages" if value == "facebook" else "X (Twitter)" if value == "x" else value.title(),
|
|
2899
|
+
key="upload_post_platforms_selector",
|
|
2900
|
+
help="Seleccione uma ou mais plataformas já ligadas ao perfil Upload-Post.",
|
|
2901
|
+
)
|
|
2902
|
+
async_upload = st.checkbox(
|
|
2903
|
+
"Processar em segundo plano",
|
|
2904
|
+
value=False,
|
|
2905
|
+
key="upload_post_async_upload",
|
|
2906
|
+
help="Envia async_upload=true para a API e mostra o request ID devolvido, quando existir.",
|
|
2907
|
+
)
|
|
2908
|
+
if not selected_platforms:
|
|
2909
|
+
st.info("Seleccione pelo menos uma plataforma antes de publicar.")
|
|
2910
|
+
|
|
2911
|
+
tasks = [task for task in read_json("tasks.json", []) if task.get("state") == "done" or task.get("artifacts", {}).get("video")]
|
|
2912
|
+
if not tasks:
|
|
2913
|
+
st.info("Não há vídeos prontos para enviar pelo Upload-Post.")
|
|
2914
|
+
return
|
|
2915
|
+
for task in tasks:
|
|
2916
|
+
artifacts = task.get("artifacts", {}) or {}
|
|
2917
|
+
video_path = artifacts.get("video", "")
|
|
2918
|
+
with st.container(border=True):
|
|
2919
|
+
st.write(f"**{task.get('topic', 'Vídeo Thunderbolt')}** — {uploader.username}")
|
|
2920
|
+
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2921
|
+
title = st.text_input("Título Upload-Post", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"upload_post_title_{task['id']}")
|
|
2922
|
+
description = st.text_area("Descrição Upload-Post", value=task.get("description", ""), key=f"upload_post_description_{task['id']}", height=90)
|
|
2923
|
+
if st.button("Enviar vídeo pelo Upload-Post", type="primary", key=f"upload_post_send_{task['id']}", disabled=not selected_platforms):
|
|
2924
|
+
result = uploader.upload_video(
|
|
2925
|
+
video_path,
|
|
2926
|
+
title=title,
|
|
2927
|
+
description=description,
|
|
2928
|
+
user=uploader.username,
|
|
2929
|
+
platforms=selected_platforms,
|
|
2930
|
+
async_upload=async_upload,
|
|
2931
|
+
)
|
|
2932
|
+
record = {
|
|
2933
|
+
"id": uuid.uuid4().hex,
|
|
2934
|
+
"task_id": task.get("id"),
|
|
2935
|
+
"destination": "Upload-Post",
|
|
2936
|
+
"target": {"username": uploader.username, "platforms": selected_platforms},
|
|
2937
|
+
"status": "published" if result.ok else "failed",
|
|
2938
|
+
"message": result.message,
|
|
2939
|
+
"data": result.data,
|
|
2940
|
+
"created_at": now(),
|
|
2941
|
+
}
|
|
2942
|
+
uploads = read_json("uploads.json", [])
|
|
2943
|
+
uploads.append(record)
|
|
2944
|
+
write_json("uploads.json", uploads)
|
|
2945
|
+
reconcile_persisted_notifications()
|
|
2946
|
+
(st.success if result.ok else st.error)(result.message)
|
|
2947
|
+
if result.ok and result.data.get("request_id"):
|
|
2948
|
+
st.caption(f"Request ID Upload-Post: {result.data['request_id']}")
|
|
2949
|
+
|
|
2950
|
+
|
|
2811
2951
|
UPLOAD_DESTINATION_TARGET_KEYS = {
|
|
2812
2952
|
"TikTok": "tiktok_accounts",
|
|
2813
2953
|
"Instagram": "instagram_profiles",
|
|
@@ -3956,13 +4096,16 @@ def main():
|
|
|
3956
4096
|
if current_page not in {item[0] for item in top_pages + pipeline_items + pipeline_tiktok_items + automation_items + edition_items + models_ai_items + niche_finder_items + settings_items}:
|
|
3957
4097
|
current_page = "Início"
|
|
3958
4098
|
st.session_state["page"] = current_page
|
|
4099
|
+
ui_language = current_ui_language()
|
|
4100
|
+
render_ui_language_picker(ui_language)
|
|
3959
4101
|
|
|
3960
4102
|
def navigate(target: str):
|
|
3961
4103
|
st.session_state["page"] = target
|
|
3962
4104
|
st.rerun()
|
|
3963
4105
|
|
|
3964
4106
|
def render_nav_button(target: str, icon: str, label: str, *, child: bool = False):
|
|
3965
|
-
|
|
4107
|
+
display_label = ui_text(label, ui_language)
|
|
4108
|
+
if st.button(display_label, key=f"nav_{target}", icon=icon, use_container_width=True, type="primary" if current_page == target else "secondary"):
|
|
3966
4109
|
navigate(target)
|
|
3967
4110
|
|
|
3968
4111
|
with st.sidebar:
|
|
@@ -3970,31 +4113,31 @@ def main():
|
|
|
3970
4113
|
st.markdown(f'<div class="tb-brand"><span class="tb-brand-name">Thunderbolt</span>{version_markup}</div>', unsafe_allow_html=True)
|
|
3971
4114
|
for target, icon, label in top_pages:
|
|
3972
4115
|
if target == "Pipeline":
|
|
3973
|
-
with st.expander("Pipeline", expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
|
|
4116
|
+
with st.expander(ui_text("Pipeline", ui_language), expanded=current_page in {item[0] for item in pipeline_items}, icon=":material/account_tree:"):
|
|
3974
4117
|
for child_target, child_icon, child_label in pipeline_items:
|
|
3975
4118
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3976
4119
|
elif target == "Pipeline TikTok":
|
|
3977
|
-
with st.expander("Pipeline TikTok", expanded=current_page in {item[0] for item in pipeline_tiktok_items}, icon=":material/video_library:"):
|
|
4120
|
+
with st.expander(ui_text("Pipeline TikTok", ui_language), expanded=current_page in {item[0] for item in pipeline_tiktok_items}, icon=":material/video_library:"):
|
|
3978
4121
|
for child_target, child_icon, child_label in pipeline_tiktok_items:
|
|
3979
4122
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3980
4123
|
elif target == "Automação":
|
|
3981
|
-
with st.expander("Automação", expanded=current_page in {item[0] for item in automation_items}, icon=":material/schedule:"):
|
|
4124
|
+
with st.expander(ui_text("Automação", ui_language), expanded=current_page in {item[0] for item in automation_items}, icon=":material/schedule:"):
|
|
3982
4125
|
for child_target, child_icon, child_label in automation_items:
|
|
3983
4126
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3984
4127
|
elif target == "Edição":
|
|
3985
|
-
with st.expander("Edição", expanded=current_page in {item[0] for item in edition_items}, icon=":material/edit:"):
|
|
4128
|
+
with st.expander(ui_text("Edição", ui_language), expanded=current_page in {item[0] for item in edition_items}, icon=":material/edit:"):
|
|
3986
4129
|
for child_target, child_icon, child_label in edition_items:
|
|
3987
4130
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3988
4131
|
elif target == "AI Influencers":
|
|
3989
|
-
with st.expander("AI Influencers", expanded=current_page in {item[0] for item in models_ai_items}, icon=":material/smart_toy:"):
|
|
4132
|
+
with st.expander(ui_text("AI Influencers", ui_language), expanded=current_page in {item[0] for item in models_ai_items}, icon=":material/smart_toy:"):
|
|
3990
4133
|
for child_target, child_icon, child_label in models_ai_items:
|
|
3991
4134
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3992
4135
|
elif target == "Niche Finder":
|
|
3993
|
-
with st.expander("Niche Finder", expanded=current_page in {item[0] for item in niche_finder_items}, icon=":material/search:"):
|
|
4136
|
+
with st.expander(ui_text("Niche Finder", ui_language), expanded=current_page in {item[0] for item in niche_finder_items}, icon=":material/search:"):
|
|
3994
4137
|
for child_target, child_icon, child_label in niche_finder_items:
|
|
3995
4138
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
3996
4139
|
elif target == "Configurações":
|
|
3997
|
-
with st.expander("Configurações", expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
|
|
4140
|
+
with st.expander(ui_text("Configurações", ui_language), expanded=current_page in {item[0] for item in settings_items}, icon=":material/settings:"):
|
|
3998
4141
|
for child_target, child_icon, child_label in settings_items:
|
|
3999
4142
|
render_nav_button(child_target, child_icon, child_label, child=True)
|
|
4000
4143
|
else:
|
|
@@ -37,7 +37,7 @@ def _json_response(response: requests.Response, action: str) -> Any:
|
|
|
37
37
|
def _headers(token: str) -> dict[str, str]:
|
|
38
38
|
token = str(token or "").strip()
|
|
39
39
|
if not token:
|
|
40
|
-
raise ApifyError("Configure o Apify API Token em
|
|
40
|
+
raise ApifyError("Configure o Apify API Token em Configuração API > API Keys > Serviços e modelos antes de iniciar.")
|
|
41
41
|
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
42
42
|
|
|
43
43
|
|
|
@@ -38,15 +38,15 @@ def _provider_config(settings: dict[str, Any]) -> tuple[str, str, str, str]:
|
|
|
38
38
|
base_url = base_url or "http://127.0.0.1:11434/v1"
|
|
39
39
|
if not base_url:
|
|
40
40
|
raise CreativeGenerationError(
|
|
41
|
-
f"O provider LLM '{provider}' não tem Base URL configurada em
|
|
41
|
+
f"O provider LLM '{provider}' não tem Base URL configurada em Configuração API > API Keys > Serviços e modelos."
|
|
42
42
|
)
|
|
43
43
|
if not model:
|
|
44
44
|
raise CreativeGenerationError(
|
|
45
|
-
f"O provider LLM '{provider}' não tem modelo configurado em
|
|
45
|
+
f"O provider LLM '{provider}' não tem modelo configurado em Configuração API > API Keys > Serviços e modelos."
|
|
46
46
|
)
|
|
47
47
|
if provider not in {"ollama", "litellm"} and not key:
|
|
48
48
|
raise CreativeGenerationError(
|
|
49
|
-
f"Configure a API key do provider LLM '{provider}' em
|
|
49
|
+
f"Configure a API key do provider LLM '{provider}' em Configuração API > API Keys > Serviços e modelos."
|
|
50
50
|
)
|
|
51
51
|
return provider, key, base_url.rstrip("/"), model
|
|
52
52
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Canonical UI and video language catalog shared by Thunderbolt screens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# The persisted value is always the short MoneyPrinterTurbo-compatible code.
|
|
9
|
+
LANGUAGE_CATALOG: tuple[dict[str, str], ...] = (
|
|
10
|
+
{"code": "en", "name": "Inglês", "flag": "🇺🇸", "locale": "en-US"},
|
|
11
|
+
{"code": "zh", "name": "Chinês Simplificado", "flag": "🇨🇳", "locale": "zh-CN"},
|
|
12
|
+
{"code": "de", "name": "Alemão", "flag": "🇩🇪", "locale": "de-DE"},
|
|
13
|
+
{"code": "vi", "name": "Vietnamita", "flag": "🇻🇳", "locale": "vi-VN"},
|
|
14
|
+
{"code": "tr", "name": "Turco", "flag": "🇹🇷", "locale": "tr-TR"},
|
|
15
|
+
{"code": "pt", "name": "Português", "flag": "🇧🇷", "locale": "pt-BR"},
|
|
16
|
+
{"code": "ru", "name": "Russo", "flag": "🇷🇺", "locale": "ru-RU"},
|
|
17
|
+
{"code": "es", "name": "Espanhol", "flag": "🇪🇸", "locale": "es-ES"},
|
|
18
|
+
{"code": "id", "name": "Indonésio", "flag": "🇮🇩", "locale": "id-ID"},
|
|
19
|
+
{"code": "it", "name": "Italiano", "flag": "🇮🇹", "locale": "it-IT"},
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
LANGUAGE_BY_CODE = {item["code"]: item for item in LANGUAGE_CATALOG}
|
|
23
|
+
LANGUAGE_CODES = tuple(item["code"] for item in LANGUAGE_CATALOG)
|
|
24
|
+
|
|
25
|
+
# Legacy labels used by existing channels, scripts and tests. They remain
|
|
26
|
+
# readable while new UI selections persist only the canonical short code.
|
|
27
|
+
LEGACY_LANGUAGE_CODES = {
|
|
28
|
+
"01 – inglês": "en",
|
|
29
|
+
"01 - inglês": "en",
|
|
30
|
+
"06 – alemão": "de",
|
|
31
|
+
"06 - alemão": "de",
|
|
32
|
+
"15 – italiano": "it",
|
|
33
|
+
"15 - italiano": "it",
|
|
34
|
+
"29 – turco": "tr",
|
|
35
|
+
"29 - turco": "tr",
|
|
36
|
+
"31 – russo": "ru",
|
|
37
|
+
"31 - russo": "ru",
|
|
38
|
+
"36 – português (brasil)": "pt",
|
|
39
|
+
"36 - português (brasil)": "pt",
|
|
40
|
+
"39 – mandarim": "zh",
|
|
41
|
+
"39 - mandarim": "zh",
|
|
42
|
+
"13 – espanhol (espanha)": "es",
|
|
43
|
+
"41 - espanhol (latam)": "es",
|
|
44
|
+
"42 – vietnamita": "vi",
|
|
45
|
+
"42 - vietnamita": "vi",
|
|
46
|
+
"44 – indonésio": "id",
|
|
47
|
+
"44 - indonésio": "id",
|
|
48
|
+
"13 – espanhol (espanha)": "es",
|
|
49
|
+
"13 - espanhol (espanha)": "es",
|
|
50
|
+
"37 – cantonês": "zh",
|
|
51
|
+
"37 - cantonês": "zh",
|
|
52
|
+
"english": "en",
|
|
53
|
+
"inglês": "en",
|
|
54
|
+
"inglés": "en",
|
|
55
|
+
"zh": "zh",
|
|
56
|
+
"chinês": "zh",
|
|
57
|
+
"chinês simplificado": "zh",
|
|
58
|
+
"deutsch": "de",
|
|
59
|
+
"alemão": "de",
|
|
60
|
+
"vietnamita": "vi",
|
|
61
|
+
"turco": "tr",
|
|
62
|
+
"português": "pt",
|
|
63
|
+
"português (brasil)": "pt",
|
|
64
|
+
"portuguese": "pt",
|
|
65
|
+
"ru": "ru",
|
|
66
|
+
"russo": "ru",
|
|
67
|
+
"español": "es",
|
|
68
|
+
"espanhol": "es",
|
|
69
|
+
"indonésio": "id",
|
|
70
|
+
"bahasa indonesia": "id",
|
|
71
|
+
"italiano": "it",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def language_code(value: Any, default: str = "pt") -> str:
|
|
76
|
+
"""Normalize a canonical code or a legacy display label to a code."""
|
|
77
|
+
raw = str(value or "").strip()
|
|
78
|
+
if raw in LANGUAGE_BY_CODE:
|
|
79
|
+
return raw
|
|
80
|
+
lowered = raw.casefold()
|
|
81
|
+
if lowered in LEGACY_LANGUAGE_CODES:
|
|
82
|
+
return LEGACY_LANGUAGE_CODES[lowered]
|
|
83
|
+
for item in LANGUAGE_CATALOG:
|
|
84
|
+
if lowered in {item["name"].casefold(), item["locale"].casefold()}:
|
|
85
|
+
return item["code"]
|
|
86
|
+
return default if default in LANGUAGE_BY_CODE else "pt"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def language_label(value: Any, *, include_code: bool = True) -> str:
|
|
90
|
+
"""Return the visual label with the requested flag and optional code."""
|
|
91
|
+
code = language_code(value)
|
|
92
|
+
item = LANGUAGE_BY_CODE[code]
|
|
93
|
+
suffix = f" ({item['code']})" if include_code else ""
|
|
94
|
+
return f"{item['flag']} {item['name']}{suffix}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def language_option_labels(*, include_code: bool = True) -> list[str]:
|
|
98
|
+
return [language_label(item["code"], include_code=include_code) for item in LANGUAGE_CATALOG]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def language_option_codes() -> list[str]:
|
|
102
|
+
return list(LANGUAGE_CODES)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
UI_TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
106
|
+
"en": {
|
|
107
|
+
"Início": "Home", "Niche Finder": "Niche Finder", "Pipeline": "Pipeline", "Pipeline TikTok": "TikTok Pipeline",
|
|
108
|
+
"Automação": "Automation", "Edição": "Editing", "AI Influencers": "AI Influencers", "Configurações": "Settings",
|
|
109
|
+
"Criação de Vídeos": "Video Creation", "Criação de Músicas": "Music Creation", "Roteiros": "Scripts", "Upload": "Upload",
|
|
110
|
+
"Prompts Master": "Prompt Masters", "Contas TikTok": "TikTok Accounts", "Automação Youtube": "YouTube Automation",
|
|
111
|
+
"Canais Youtube": "YouTube Channels", "Blueprints Youtube": "YouTube Blueprints", "Contas Google": "Google Accounts",
|
|
112
|
+
"Configuração API": "API Configuration", "Notificações": "Notifications", "Niche Finder Kaggle": "Niche Finder Kaggle",
|
|
113
|
+
"Niche Finder Apify": "Niche Finder Apify", "Limpador de Metadados": "Metadata Cleaner", "Cortes": "Cuts",
|
|
114
|
+
"Editor Python": "Python Editor", "Download Mídia": "Media Download", "Personagens": "Characters", "Redes Sociais": "Social Networks",
|
|
115
|
+
"Tutorial Meta": "Meta Tutorial", "Idioma da interface": "Interface language",
|
|
116
|
+
},
|
|
117
|
+
"zh": {
|
|
118
|
+
"Início": "首页", "Pipeline": "视频流程", "Pipeline TikTok": "TikTok 流程", "Automação": "自动化", "Edição": "编辑", "Configurações": "设置",
|
|
119
|
+
"Criação de Vídeos": "视频创作", "Criação de Músicas": "音乐创作", "Roteiros": "脚本", "Upload": "上传", "Contas Google": "Google 账户", "Notificações": "通知", "Idioma da interface": "界面语言",
|
|
120
|
+
},
|
|
121
|
+
"de": {
|
|
122
|
+
"Início": "Startseite", "Pipeline": "Pipeline", "Pipeline TikTok": "TikTok-Pipeline", "Automação": "Automatisierung", "Edição": "Bearbeitung", "Configurações": "Einstellungen",
|
|
123
|
+
"Criação de Vídeos": "Videoerstellung", "Criação de Músicas": "Musikerstellung", "Roteiros": "Skripte", "Upload": "Upload", "Contas Google": "Google-Konten", "Notificações": "Benachrichtigungen", "Idioma da interface": "Oberflächensprache",
|
|
124
|
+
},
|
|
125
|
+
"vi": {
|
|
126
|
+
"Início": "Trang chủ", "Pipeline": "Quy trình", "Pipeline TikTok": "Quy trình TikTok", "Automação": "Tự động hóa", "Edição": "Chỉnh sửa", "Configurações": "Cài đặt",
|
|
127
|
+
"Criação de Vídeos": "Tạo video", "Criação de Músicas": "Tạo nhạc", "Roteiros": "Kịch bản", "Upload": "Tải lên", "Contas Google": "Tài khoản Google", "Notificações": "Thông báo", "Idioma da interface": "Ngôn ngữ giao diện",
|
|
128
|
+
},
|
|
129
|
+
"tr": {
|
|
130
|
+
"Início": "Ana sayfa", "Pipeline": "Akış", "Pipeline TikTok": "TikTok akışı", "Automação": "Otomasyon", "Edição": "Düzenleme", "Configurações": "Ayarlar",
|
|
131
|
+
"Criação de Vídeos": "Video oluşturma", "Criação de Músicas": "Müzik oluşturma", "Roteiros": "Senaryolar", "Upload": "Yükleme", "Contas Google": "Google hesapları", "Notificações": "Bildirimler", "Idioma da interface": "Arayüz dili",
|
|
132
|
+
},
|
|
133
|
+
"ru": {
|
|
134
|
+
"Início": "Главная", "Pipeline": "Конвейер", "Pipeline TikTok": "Конвейер TikTok", "Automação": "Автоматизация", "Edição": "Редактирование", "Configurações": "Настройки",
|
|
135
|
+
"Criação de Vídeos": "Создание видео", "Criação de Músicas": "Создание музыки", "Roteiros": "Сценарии", "Upload": "Загрузка", "Contas Google": "Аккаунты Google", "Notificações": "Уведомления", "Idioma da interface": "Язык интерфейса",
|
|
136
|
+
},
|
|
137
|
+
"es": {
|
|
138
|
+
"Início": "Inicio", "Pipeline": "Flujo", "Pipeline TikTok": "Flujo de TikTok", "Automação": "Automatización", "Edição": "Edición", "Configurações": "Configuración",
|
|
139
|
+
"Criação de Vídeos": "Creación de vídeos", "Criação de Músicas": "Creación de música", "Roteiros": "Guiones", "Upload": "Carga", "Contas Google": "Cuentas de Google", "Notificações": "Notificaciones", "Idioma da interface": "Idioma de la interfaz",
|
|
140
|
+
},
|
|
141
|
+
"id": {
|
|
142
|
+
"Início": "Beranda", "Pipeline": "Alur", "Pipeline TikTok": "Alur TikTok", "Automação": "Otomatisasi", "Edição": "Penyuntingan", "Configurações": "Pengaturan",
|
|
143
|
+
"Criação de Vídeos": "Pembuatan video", "Criação de Músicas": "Pembuatan musik", "Roteiros": "Skrip", "Upload": "Unggah", "Contas Google": "Akun Google", "Notificações": "Notifikasi", "Idioma da interface": "Bahasa antarmuka",
|
|
144
|
+
},
|
|
145
|
+
"it": {
|
|
146
|
+
"Início": "Home", "Pipeline": "Pipeline", "Pipeline TikTok": "Pipeline TikTok", "Automação": "Automazione", "Edição": "Modifica", "Configurações": "Impostazioni",
|
|
147
|
+
"Criação de Vídeos": "Creazione video", "Criação de Músicas": "Creazione musicale", "Roteiros": "Script", "Upload": "Caricamento", "Contas Google": "Account Google", "Notificações": "Notifiche", "Idioma da interface": "Lingua dell'interfaccia",
|
|
148
|
+
},
|
|
149
|
+
"pt": {},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def ui_text(value: str, language: Any = "pt") -> str:
|
|
154
|
+
code = language_code(language)
|
|
155
|
+
return UI_TRANSLATIONS.get(code, {}).get(value, value)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def language_flag(value: Any) -> str:
|
|
159
|
+
return LANGUAGE_BY_CODE[language_code(value)]["flag"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def language_locale(value: Any) -> str:
|
|
163
|
+
return LANGUAGE_BY_CODE[language_code(value)]["locale"]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def video_language_label(value: Any) -> str:
|
|
167
|
+
"""Format a video-language value while preserving all historical options."""
|
|
168
|
+
raw = str(value or "").strip()
|
|
169
|
+
if raw.casefold() in {
|
|
170
|
+
"music",
|
|
171
|
+
"00 – apenas música de fundo (sem falas)",
|
|
172
|
+
"00 - apenas música de fundo (sem falas)",
|
|
173
|
+
}:
|
|
174
|
+
return "🎵 Apenas Música de Fundo (Sem Falas)"
|
|
175
|
+
normalized = language_code(raw, default="")
|
|
176
|
+
if normalized in LANGUAGE_BY_CODE and (raw.casefold() in LEGACY_LANGUAGE_CODES or raw in LANGUAGE_BY_CODE):
|
|
177
|
+
return language_label(normalized)
|
|
178
|
+
return raw
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def video_language_options() -> list[str]:
|
|
182
|
+
return ["music", *LANGUAGE_CODES]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
__all__ = [
|
|
186
|
+
"LANGUAGE_CATALOG",
|
|
187
|
+
"LANGUAGE_BY_CODE",
|
|
188
|
+
"LANGUAGE_CODES",
|
|
189
|
+
"language_code",
|
|
190
|
+
"language_flag",
|
|
191
|
+
"language_label",
|
|
192
|
+
"language_locale",
|
|
193
|
+
"language_option_codes",
|
|
194
|
+
"language_option_labels",
|
|
195
|
+
"video_language_label",
|
|
196
|
+
"video_language_options",
|
|
197
|
+
]
|
|
@@ -36,6 +36,7 @@ EVENT_CATALOG: tuple[dict[str, str], ...] = (
|
|
|
36
36
|
{"code": "upload_instagram_success", "category": "Upload", "label": "Upload Instagram concluído", "description": "Quando um vídeo for publicado num perfil Instagram."},
|
|
37
37
|
{"code": "upload_facebook_pages_success", "category": "Upload", "label": "Upload Facebook Pages concluído", "description": "Quando um vídeo for publicado numa Facebook Page."},
|
|
38
38
|
{"code": "upload_postiz_success", "category": "Upload", "label": "Upload Postiz concluído", "description": "Quando um vídeo for enviado e publicado através do Postiz."},
|
|
39
|
+
{"code": "upload_upload_post_success", "category": "Upload", "label": "Upload-Post concluído", "description": "Quando um vídeo for aceite pelo Upload-Post para publicação nas plataformas seleccionadas."},
|
|
39
40
|
{"code": "mcp_operation_completed", "category": "Integrações", "label": "Operação MCP concluída", "description": "Quando uma operação mutável de integração MCP terminar com sucesso."},
|
|
40
41
|
)
|
|
41
42
|
EVENTS_BY_CODE = {item["code"]: item for item in EVENT_CATALOG}
|
|
@@ -249,6 +250,8 @@ def _upload_notifications() -> int:
|
|
|
249
250
|
"facebook pages": "upload_facebook_pages_success",
|
|
250
251
|
"facebook_pages": "upload_facebook_pages_success",
|
|
251
252
|
"postiz": "upload_postiz_success",
|
|
253
|
+
"upload-post": "upload_upload_post_success",
|
|
254
|
+
"upload_post": "upload_upload_post_success",
|
|
252
255
|
"youtube direct frontend": "upload_youtube_success",
|
|
253
256
|
}
|
|
254
257
|
for upload in uploads:
|
package/hermes_ui/storage.py
CHANGED
|
@@ -115,6 +115,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
115
115
|
"upload_instagram_success": True,
|
|
116
116
|
"upload_facebook_pages_success": True,
|
|
117
117
|
"upload_postiz_success": True,
|
|
118
|
+
"upload_upload_post_success": True,
|
|
118
119
|
"mcp_operation_completed": True,
|
|
119
120
|
},
|
|
120
121
|
"kaggle_username": "",
|
|
@@ -192,6 +193,8 @@ DEFAULTS: dict[str, Any] = {
|
|
|
192
193
|
"log_level": "DEBUG",
|
|
193
194
|
"listen_host": "127.0.0.1",
|
|
194
195
|
"listen_port": 8080,
|
|
196
|
+
"ui_language": "pt",
|
|
197
|
+
"video_language": "pt",
|
|
195
198
|
"video_source": "pexels",
|
|
196
199
|
"match_materials_to_script": False,
|
|
197
200
|
"endpoint": "",
|
|
@@ -87,7 +87,7 @@ def generate_thumbnail_image(
|
|
|
87
87
|
) -> Path:
|
|
88
88
|
api_key = str(settings.get("gemini_image_api_key") or "").strip()
|
|
89
89
|
if not api_key:
|
|
90
|
-
raise ThumbnailGenerationError("Configure a API key Nano Banana em
|
|
90
|
+
raise ThumbnailGenerationError("Configure a API key Nano Banana em Configuração API > API Keys > Serviços e modelos.")
|
|
91
91
|
clean_prompt = str(prompt or "").strip()
|
|
92
92
|
if not clean_prompt:
|
|
93
93
|
raise ThumbnailGenerationError("A thumbnail não tem um prompt de imagem para gerar.")
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
+
from hermes_ui.languages import language_code
|
|
6
7
|
from hermes_ui.material_sources import all_material_api_keys, selected_material_source
|
|
7
8
|
|
|
8
9
|
try:
|
|
@@ -32,6 +33,7 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
|
|
|
32
33
|
elevenlabs = dict(config.get("elevenlabs") or {})
|
|
33
34
|
chatterbox = dict(config.get("chatterbox") or {})
|
|
34
35
|
proxy = dict(config.get("proxy") or {})
|
|
36
|
+
ui = dict(config.get("ui") or {})
|
|
35
37
|
|
|
36
38
|
app_map = {
|
|
37
39
|
"llm_provider": "llm_provider",
|
|
@@ -123,6 +125,11 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
|
|
|
123
125
|
app["video_source"] = selected_material_source(settings)
|
|
124
126
|
config["app"] = app
|
|
125
127
|
|
|
128
|
+
ui["language"] = language_code(settings.get("ui_language", ui.get("language", "pt")))
|
|
129
|
+
requested_video_language = str(settings.get("video_language") or ui.get("video_language") or "").strip()
|
|
130
|
+
ui["video_language"] = "" if requested_video_language.casefold() == "music" else language_code(requested_video_language)
|
|
131
|
+
config["ui"] = ui
|
|
132
|
+
|
|
126
133
|
whisper["model_size"] = settings.get("whisper_model_size", whisper.get("model_size", "large-v3"))
|
|
127
134
|
whisper["device"] = settings.get("whisper_device", whisper.get("device", "cpu"))
|
|
128
135
|
whisper["compute_type"] = settings.get("whisper_compute_type", whisper.get("compute_type", "int8"))
|
package/integrations/postiz.py
CHANGED
|
@@ -33,7 +33,7 @@ class PostizAdapter:
|
|
|
33
33
|
|
|
34
34
|
def status(self) -> IntegrationResult:
|
|
35
35
|
if not self.api_key:
|
|
36
|
-
return self._error("Postiz não configurado: adicione a API key em
|
|
36
|
+
return self._error("Postiz não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
|
|
37
37
|
return IntegrationResult(True, f"Postiz configurado em {self.base_url}.", {"base_url": self.base_url, "mcp_url": self.mcp_url})
|
|
38
38
|
|
|
39
39
|
def list_integrations(self) -> IntegrationResult:
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Upload-Post API adapter for publishing local video artefacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .platforms import IntegrationResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_UPLOAD_POST_BASE_URL = "https://api.upload-post.com/api"
|
|
15
|
+
UPLOAD_POST_PLATFORM_OPTIONS = (
|
|
16
|
+
"tiktok",
|
|
17
|
+
"instagram",
|
|
18
|
+
"youtube",
|
|
19
|
+
"facebook",
|
|
20
|
+
"linkedin",
|
|
21
|
+
"x",
|
|
22
|
+
"threads",
|
|
23
|
+
"pinterest",
|
|
24
|
+
"reddit",
|
|
25
|
+
"bluesky",
|
|
26
|
+
"discord",
|
|
27
|
+
"telegram",
|
|
28
|
+
)
|
|
29
|
+
_PLATFORM_ALIASES = {
|
|
30
|
+
"facebook pages": "facebook",
|
|
31
|
+
"facebook_pages": "facebook",
|
|
32
|
+
"twitter": "x",
|
|
33
|
+
"x (twitter)": "x",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize_upload_post_platforms(value: Any) -> list[str]:
|
|
38
|
+
"""Return stable Upload-Post platform slugs without duplicates."""
|
|
39
|
+
values = value if isinstance(value, (list, tuple, set)) else str(value or "").replace("\n", ",").split(",")
|
|
40
|
+
result: list[str] = []
|
|
41
|
+
seen: set[str] = set()
|
|
42
|
+
supported = set(UPLOAD_POST_PLATFORM_OPTIONS)
|
|
43
|
+
for item in values:
|
|
44
|
+
platform = str(item or "").strip().lower()
|
|
45
|
+
platform = _PLATFORM_ALIASES.get(platform, platform)
|
|
46
|
+
if platform in supported and platform not in seen:
|
|
47
|
+
result.append(platform)
|
|
48
|
+
seen.add(platform)
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UploadPostAdapter:
|
|
53
|
+
"""Small deterministic client for Upload-Post's ``POST /upload`` endpoint."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
56
|
+
self.settings = settings or {}
|
|
57
|
+
self.api_key = str(self.settings.get("upload_post_api_key") or "").strip()
|
|
58
|
+
self.enabled = bool(self.settings.get("upload_post_enabled", False))
|
|
59
|
+
self.username = str(self.settings.get("upload_post_username") or "").strip()
|
|
60
|
+
self.platforms = normalize_upload_post_platforms(self.settings.get("upload_post_platforms", ""))
|
|
61
|
+
self.base_url = str(self.settings.get("upload_post_base_url") or DEFAULT_UPLOAD_POST_BASE_URL).strip().rstrip("/")
|
|
62
|
+
|
|
63
|
+
def _headers(self) -> dict[str, str]:
|
|
64
|
+
return {"Authorization": f"Apikey {self.api_key}"}
|
|
65
|
+
|
|
66
|
+
def _error(self, message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
|
|
67
|
+
return IntegrationResult(False, message, data or {})
|
|
68
|
+
|
|
69
|
+
def status(self) -> IntegrationResult:
|
|
70
|
+
if not self.enabled:
|
|
71
|
+
return self._error("Upload-Post está desactivado em Configuração API.")
|
|
72
|
+
if not self.api_key:
|
|
73
|
+
return self._error("Upload-Post não configurado: adicione a API key em Configuração API > API Keys > Serviços e modelos.")
|
|
74
|
+
if not self.username:
|
|
75
|
+
return self._error("Upload-Post não configurado: adicione o username/perfil em Configuração API.")
|
|
76
|
+
if not self.platforms:
|
|
77
|
+
return self._error("Upload-Post não configurado: indique pelo menos uma plataforma em Configuração API.")
|
|
78
|
+
return IntegrationResult(
|
|
79
|
+
True,
|
|
80
|
+
f"Upload-Post configurado para {self.username} ({', '.join(self.platforms)}).",
|
|
81
|
+
{"base_url": self.base_url, "username": self.username, "platforms": self.platforms},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def upload_video(
|
|
85
|
+
self,
|
|
86
|
+
video_path: str | Path,
|
|
87
|
+
*,
|
|
88
|
+
title: str,
|
|
89
|
+
description: str = "",
|
|
90
|
+
user: str | None = None,
|
|
91
|
+
platforms: list[str] | None = None,
|
|
92
|
+
async_upload: bool = False,
|
|
93
|
+
) -> IntegrationResult:
|
|
94
|
+
"""Upload a local video to one or more connected Upload-Post platforms."""
|
|
95
|
+
if not self.enabled:
|
|
96
|
+
return self._error("Upload-Post está desactivado em Configuração API.")
|
|
97
|
+
if not self.api_key:
|
|
98
|
+
return self._error("Upload-Post não configurado: adicione a API key em Configuração API.")
|
|
99
|
+
path = Path(video_path)
|
|
100
|
+
if not path.is_file():
|
|
101
|
+
return self._error(f"Vídeo não encontrado para o Upload-Post: {path}")
|
|
102
|
+
selected_user = str(user or self.username).strip()
|
|
103
|
+
selected_platforms = normalize_upload_post_platforms(platforms or self.platforms)
|
|
104
|
+
if not selected_user:
|
|
105
|
+
return self._error("Indique o username/perfil ligado ao Upload-Post antes de publicar.")
|
|
106
|
+
if not selected_platforms:
|
|
107
|
+
return self._error("Seleccione pelo menos uma plataforma do Upload-Post antes de publicar.")
|
|
108
|
+
|
|
109
|
+
form_data: list[tuple[str, str]] = [
|
|
110
|
+
("user", selected_user),
|
|
111
|
+
*[("platform[]", platform) for platform in selected_platforms],
|
|
112
|
+
("title", str(title or "Vídeo Thunderbolt").strip()[:500] or "Vídeo Thunderbolt"),
|
|
113
|
+
("description", str(description or "").strip()),
|
|
114
|
+
("async_upload", "true" if async_upload else "false"),
|
|
115
|
+
]
|
|
116
|
+
content_type = mimetypes.guess_type(path.name)[0] or "video/mp4"
|
|
117
|
+
try:
|
|
118
|
+
with path.open("rb") as handle:
|
|
119
|
+
response = requests.post(
|
|
120
|
+
f"{self.base_url}/upload",
|
|
121
|
+
headers=self._headers(),
|
|
122
|
+
data=form_data,
|
|
123
|
+
files={"video": (path.name, handle, content_type)},
|
|
124
|
+
timeout=240,
|
|
125
|
+
)
|
|
126
|
+
except requests.RequestException as exc:
|
|
127
|
+
return self._error(f"Não foi possível contactar o Upload-Post: {exc}", {"platforms": selected_platforms, "user": selected_user})
|
|
128
|
+
if response.status_code >= 400:
|
|
129
|
+
return self._error(
|
|
130
|
+
f"Upload-Post rejeitou o vídeo (HTTP {response.status_code}): {response.text[:500]}",
|
|
131
|
+
{"platforms": selected_platforms, "user": selected_user},
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
payload: Any = response.json()
|
|
135
|
+
except ValueError:
|
|
136
|
+
return self._error("Upload-Post devolveu uma resposta que não é JSON.", {"response": response.text[:2000]})
|
|
137
|
+
payload_dict = payload if isinstance(payload, dict) else {"response": payload}
|
|
138
|
+
request_id = str(payload_dict.get("request_id") or payload_dict.get("requestId") or "").strip()
|
|
139
|
+
message = "Upload-Post aceitou o vídeo para publicação."
|
|
140
|
+
if request_id:
|
|
141
|
+
message += f" Request ID: {request_id}."
|
|
142
|
+
return IntegrationResult(
|
|
143
|
+
True,
|
|
144
|
+
message,
|
|
145
|
+
{
|
|
146
|
+
"payload": payload_dict,
|
|
147
|
+
"request_id": request_id,
|
|
148
|
+
"user": selected_user,
|
|
149
|
+
"platforms": selected_platforms,
|
|
150
|
+
"async_upload": bool(async_upload),
|
|
151
|
+
},
|
|
152
|
+
)
|
|
@@ -154,7 +154,7 @@ def upload_with_default_route(
|
|
|
154
154
|
|
|
155
155
|
postiz = PostizAdapter(settings)
|
|
156
156
|
if not bool(settings.get("postiz_enabled", False)):
|
|
157
|
-
postiz_result = IntegrationResult(False, "Postiz está desactivado em
|
|
157
|
+
postiz_result = IntegrationResult(False, "Postiz está desactivado em Configuração API > API Keys > Serviços e modelos.", {})
|
|
158
158
|
attempts.append(_attempt_record("Postiz", postiz_result, skipped=True))
|
|
159
159
|
else:
|
|
160
160
|
postiz_publisher = postiz_publisher or postiz.publish_video
|
package/package.json
CHANGED