@danhachuel/thunderbolt 0.3.69 → 0.3.71

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.
@@ -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.69
5
+ > **Versão deste manual:** 0.3.71
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)
@@ -35,9 +35,13 @@ O backend local predefinido é **SQLite**, pelo que Personagens e Geração de C
35
35
 
36
36
  Se pretender trabalhar sem serviço externo, seleccione **SQLite** no selector da aba **Configuração API > AI Influencers**. O caminho `storage/state/ai_influencers.db` e a pasta `storage/influencers/` são geridos internamente, sem campos editáveis para o utilizador. Apenas um backend é usado de cada vez. A base fica na pasta persistente do Thunderbolt, fora da instalação temporária do pacote npm; numa actualização normal, personagens, assets e configurações são preservados. O instalador também procura uma base `ai_influencers.db` válida em instalações anteriores do cache npm e recupera-a sem substituir uma base persistente já existente.
37
37
 
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.
38
+ Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens** e **Vídeos**. Seleccione um personagem, prompt e provider/modelo configurado no pool correspondente. A subaba **Imagens** usa o pool de imagem configurado em **Imagem e Video IA**. A subaba **Vídeos** requer uma imagem inicial e usa o pool de vídeo; 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
 
40
- A geração guarda prompt, caption, provider, modelo, plataforma, estado e caminho do artefacto no backend. **Instagram**, **TikTok**, **YouTube Shorts** e **Facebook** são destinos de revisão; não existe publicação automática nesta primeira adaptação. A publicação deve ser executada depois de configurar e confirmar a integração social correspondente numa acção separada.
40
+ Em **AI Influencers > Motion Control**, carregue um vídeo original `.mp4` ou `.mov` entre 3 e 30 segundos e uma imagem de referência `.jpg`, `.jpeg` ou `.png` até 10 MB. O prompt é opcional e limitado a 2500 caracteres. O Thunderbolt valida os limites, guarda os originais no storage local, envia-os ao upload temporário oficial KIE, cria `kling-2.6/motion-control`, consulta `/api/v1/jobs/recordInfo` e descarrega o MP4 final localmente. É necessário um cartão KIE AI activo no pool de vídeo. O workflow não utiliza callback público, Telegram, Postiz, Google Drive ou publicação social.
41
+
42
+ Em **AI Influencers > UGC Products**, carregue a imagem do produto e preencha **Roteiro de vídeo**. Se o roteiro contiver dois blocos separados por `---`, os blocos são usados directamente; nos restantes casos, o pool LLM cria dois prompts de oito segundos, mantendo o produto e proibindo alterações físicas, texto incorporado, legendas e watermark. O Thunderbolt usa o endpoint KIE VEO3.1 `/api/v1/veo/generate` com `veo3_fast`, uma `imageUrls`, `duration: 8` e `resolution: 720p`, consulta `/api/v1/veo/record-info`, descarrega os dois clips e junta-os localmente com FFmpeg. O resultado final e os IDs das tarefas são persistidos no backend AI Influencers; não há Telegram, Postiz, Drive, upload ou publicação social.
43
+
44
+ A geração standalone guarda prompt, provider, modelo, estado, IDs das tarefas, inputs e caminho do artefacto em conteúdos internos do backend. O proprietário técnico desses registos fica oculto da lista de personagens. Os destinos sociais não são apresentados nestes dois formulários e a publicação permanece fora do workflow.
41
45
 
42
46
  O runtime do Thunderbolt adapta o comportamento dos workflows do episódio 35, mas não instala nem executa n8n. Formulários, Data Tables, Switch, Wait e subworkflows são substituídos por páginas Streamlit, repository Supabase/SQLite, adapters multimédia, worker, logs e notificações. Nunca coloque API keys nos JSONs dos workflows, em ficheiros versionados ou em mensagens de diagnóstico.
43
47
 
@@ -50,7 +54,7 @@ A execução segue sempre **Tema → Script → Título → Keywords (opcional)
50
54
  | Opção visível | Rota executada | Resultado esperado |
51
55
  |---|---|---|
52
56
  | Pexels/Pixabay | MoneyPrinterTurbo stock com a fonte efectiva e as chaves correspondentes | MP4 composto localmente |
53
- | Full IA | Pool de vídeo limitado a FAL AI, KIE AI e Agnes AI | MP4 gerado pelo provider activo |
57
+ | Full IA | Pool de vídeo limitado pelos cartões activos que declaram capacidade | MP4 gerado pelo provider activo |
54
58
  | Apenas Música | Áudio local/Suno já preparado | Ficheiro musical pronto para um upload de música; não gera MP4, thumbnail nem upload YouTube |
55
59
 
56
60
  Antes de iniciar a rota stock, confirme em **Configurações > Configuração API > Fontes de materiais** que existe pelo menos uma API key para a fonte efectiva. Sem uma key Pexels/Pixabay, o erro é apresentado como configuração da fonte, em vez de uma falha genérica de vídeo. Se o MoneyPrinterTurbo devolver `MPT_NEEDS_INPUT`, o Thunderbolt lê `LLM_PROVIDER`, `MISSING` e `INVALID` e mostra no erro todas as APIs afectadas, como **OpenAI / NVIDIA NIM API + Pexels API**, incluindo os campos que faltam. **Google Lyria não é apresentado como implementado nesta release**; a rota Apenas Música aceita áudio local e a integração Suno existente.
@@ -128,13 +132,13 @@ Execute:
128
132
  Windows PowerShell ou MobaXterm:
129
133
 
130
134
  ```powershell
131
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.69 install
135
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install
132
136
  ```
133
137
 
134
138
  Linux/macOS:
135
139
 
136
140
  ```bash
137
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.69 install
141
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install
138
142
  ```
139
143
 
140
144
  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.
@@ -142,7 +146,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
142
146
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
143
147
 
144
148
  ```powershell
145
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.69 install --purge-data
149
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install --purge-data
146
150
  ```
147
151
 
148
152
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -391,15 +395,17 @@ Em **Configuração API > API Keys**, o expander **LLM — providers e modelos**
391
395
 
392
396
  Dentro do expander **LLM — providers e modelos**, acima dos cartões de providers, está o card **Limite LLM NVIDIA NIM**. Ele permite ligar **Activar limitador NVIDIA NIM — 40 RPM**. O valor padrão é 40 pedidos numa janela de 60 segundos. O contador é persistido localmente por cartão/chave e aplica-se à criação manual, aos workers e às automações. A opção vem desligada para não alterar instalações existentes; ligue-a quando o endpoint activo for NVIDIA NIM e a sua conta tiver esse limite.
393
397
 
394
- O expander **Imagem e Video** substitui o antigo bloco isolado da Nano Banana. Os cartões disponíveis são **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Em cada cartão, configure a API key/token, o modelo, a Base URL, o estado **Provider activo**, a participação no **Pool Imagem**, a participação no **Pool Vídeo** e a prioridade. **Image Size** e **Aspect Ratio** não aparecem nos cartões e não são editáveis: são defaults internos aplicados automaticamente no prompt da geração. O botão **Testar Chamada API** faz apenas uma verificação read-only; não inicia geração de imagem ou vídeo.
398
+ O expander **Imagem e Video Montagem/MoviePy** aparece primeiro dentro de **Configuração API > API Keys**. Ele reúne as fontes usadas pela montagem local de vídeo com MoviePy/FFmpeg, incluindo os cartões de Pexels, Pixabay, Coverr, WaveSpeed AI, LoomLoom, TwelveLabs e ficheiro local. Pode manter várias API keys do mesmo provedor, seleccionar a fonte activa e testar cada fonte remota; a opção local não apresenta um diagnóstico remoto artificial.
399
+
400
+ Logo abaixo fica o expander **Imagem e Video IA**, que substitui o antigo bloco isolado da Nano Banana. Os cartões disponíveis são **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Em cada cartão, configure a API key/token, o modelo, a Base URL, o estado **Provider activo**, a participação no **Pool Imagem**, a participação no **Pool Vídeo** e a prioridade. **Image Size** e **Aspect Ratio** não aparecem nos cartões e não são editáveis: são defaults internos aplicados automaticamente no prompt da geração. O botão **Testar Chamada API** faz apenas uma verificação read-only; não inicia geração de imagem ou vídeo.
395
401
 
396
402
  A área **Voz, TTS e música — Azure Speech, restantes serviços e Suno** está dividida em cartões independentes para **Azure Speech**, **ElevenLabs**, **SiliconFlow**, **MiniMax TTS**, **Chatterbox**, **Sonilo** e **Suno**. Cada cartão agrupa apenas as credenciais, parâmetros e diagnóstico do serviço correspondente. O Suno aparece num cartão próprio porque é uma integração de criação musical, enquanto os restantes providers são serviços de voz/TTS.
397
403
 
398
- Na subaba **Imagem e Video**, a lista **Provider de media** inclui **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen** para a rota Full IA. O cartão **HeyGen** pede a API key, o **Avatar ID** e, opcionalmente, o **Voice ID**; a opção **Provider activo**, **Pool Vídeo**, **Prioridade** e **Testar Chamada API** funcionam como nos restantes cartões. O teste HeyGen usa apenas `GET /v3/users/me` e não consome uma geração. Nano Banana e Hugging Face podem permanecer no catálogo sem serem seleccionados para vídeo quando o cartão não declara capacidade de vídeo.
404
+ No expander **Imagem e Video IA**, a lista **Provider de media** inclui **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen** para a rota Full IA. O cartão **HeyGen** pede a API key, o **Avatar ID** e, opcionalmente, o **Voice ID**; a opção **Provider activo**, **Pool Vídeo**, **Prioridade** e **Testar Chamada API** funcionam como nos restantes cartões. O teste HeyGen usa apenas `GET /v3/users/me` e não consome uma geração. Nano Banana e Hugging Face podem permanecer no catálogo sem serem seleccionados para vídeo quando o cartão não declara capacidade de vídeo.
399
405
 
400
406
  ### API Tiktok — várias aplicações
401
407
 
402
- Em **Configuração API > API Tiktok**, situada entre **Contas Google** e **Fontes de Materiais**, cada aplicação TikTok é configurada num card independente. O card contém exclusivamente **TikTok Client ID** e **TikTok Client Secret** e apresenta os botões **Testar chamada API**, **Guardar card** e **Apagar card**. Use **Adicionar nova API** no final da lista para criar outra aplicação sem substituir as anteriores.
408
+ Em **Configuração API > API Tiktok**, situada entre **Contas Google** e **AI Influencers**, cada aplicação TikTok é configurada num card independente. O card contém exclusivamente **TikTok Client ID** e **TikTok Client Secret** e apresenta os botões **Testar chamada API**, **Guardar card** e **Apagar card**. Use **Adicionar nova API** no final da lista para criar outra aplicação sem substituir as anteriores.
403
409
 
404
410
  O botão de teste é read-only. Sem um token OAuth TikTok já autorizado, o Thunderbolt informa que é necessário concluir o Playground/autorização do TikTok for Developers, sem inventar um resultado nem enviar credenciais. O token e a autorização não são campos dos cards. Instalações que ainda tenham `tiktok_client_key` e `tiktok_client_secret` são migradas automaticamente para o primeiro card; o adapter usa o primeiro card completo e mantém fallback para os campos antigos.
405
411
 
@@ -568,7 +574,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
568
574
 
569
575
  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.
570
576
 
571
- 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.69 install`; o instalador detecta e reutiliza o que já estiver válido.
577
+ 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.70 install`; o instalador detecta e reutiliza o que já estiver válido.
572
578
 
573
579
  ### Niche Finder Apify
574
580
 
package/README.md CHANGED
@@ -20,7 +20,7 @@ A primeira versão implementa a camada UI independente com:
20
20
  | 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 |
21
21
  | Upload Música | Subabas **JewelMusic**, **Pushtunes** e **ytmusicapi** para upload de ficheiros, sincronização de bibliotecas e envio autenticado para YouTube Music; credenciais locais e histórico em `uploads.json` |
22
22
  | 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 |
23
- | AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA, Redes Sociais, Tutorial Meta e Tutorial Supabase; Personagens aceita múltiplas imagens e documentos `.md`/`.json`, e Geração de Conteúdo IA separa Imagens, Vídeos e Motion Control |
23
+ | AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA, Motion Control, UGC Products e Redes Sociais; Personagens aceita múltiplas imagens e documentos `.md`/`.json`, e os dois workflows standalone guardam os resultados localmente sem Telegram ou publicação social |
24
24
  | 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
25
  | Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
26
26
  | Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, upload de MP4 para Postiz via API key/MCP configurável, TikTok, Instagram e Facebook Pages no front end |
@@ -34,7 +34,11 @@ A área **AI Influencers > Personagens** permite guardar um personagem com nome,
34
34
 
35
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**, o selector **Backend da base de dados de AI Influencers** aparece logo abaixo do estado do backend. O card **Supabase** contém apenas **Supabase Project URL** e **Supabase API key**. Se **Supabase** estiver seleccionado mas faltar qualquer uma dessas credenciais, o **Backend activo** muda automaticamente para **SQLite**; quando ambas estão preenchidas, passa para Supabase. O SQLite usa internamente `storage/state/ai_influencers.db` e `storage/influencers/`, sem campos editáveis de caminho ou Storage bucket. O botão **Testar ligação do backend** é read-only e não bloqueia a utilização local. A base SQLite fica na pasta persistente do Thunderbolt, fora da instalação temporária do pacote npm; durante uma actualização normal, personagens, assets e configurações são preservados. Se uma versão anterior tiver guardado `ai_influencers.db` no cache npm, o instalador procura uma cópia válida e recupera-a sem substituir uma base persistente já existente.
36
36
 
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.
37
+ A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens** e **Vídeos**. 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.
38
+
39
+ A página **AI Influencers > Motion Control** adapta apenas a criação do workflow Kling 2.6 Motion Control. O formulário recebe um vídeo original `.mp4`/`.mov` de 3–30 segundos, uma imagem `.jpg`/`.jpeg`/`.png` até 10 MB e um prompt opcional até 2500 caracteres. Os ficheiros são preservados no storage local, enviados temporariamente para o upload oficial KIE e usados em `POST /api/v1/jobs/createTask`; o estado é consultado em `/api/v1/jobs/recordInfo`, o MP4 é descarregado imediatamente e a tarefa é guardada no backend AI Influencers. Não existe callback público, Telegram, Postiz, Google Drive, upload ou publicação social.
40
+
41
+ A página **AI Influencers > UGC Products** adapta somente a criação do workflow Gemini/VEO3. Recebe uma imagem local do produto e o campo **Roteiro de vídeo**. Um roteiro dividido por `---` é respeitado directamente; caso contrário, o pool LLM estrutura dois prompts com regras de continuidade, preservação do produto, acções fisicamente possíveis e sem texto incorporado, legendas ou watermark. Cada segmento usa `POST /api/v1/veo/generate` com `veo3_fast`, `imageUrls`, `duration: 8` e `resolution: 720p`; o polling usa o endpoint oficial `/api/v1/veo/record-info`. O Thunderbolt descarrega os dois MP4, junta-os localmente com FFmpeg e persiste o artefacto final, sem Telegram, Postiz, Drive ou redes sociais.
38
42
 
39
43
  A migração é baseada no workflow público [AI Agents A-Z — episódio 35](https://github.com/gyoridavid/ai_agents_az/tree/main/episode_35), mas o runtime do Thunderbolt não instala nem executa n8n. Os formulários, Data Tables, waits e subworkflows são substituídos pela UI Streamlit, pelo repositório dual Supabase/SQLite, pelos adapters multimédia e pelos logs/notificações locais. As API keys são sempre configuradas localmente e não devem ser colocadas nos JSONs do workflow ou no GitHub.
40
44
 
@@ -97,26 +101,26 @@ A UI suporta os temas **Dark** e **Light** através do menu nativo de três pont
97
101
 
98
102
  ## Navegação da UI 0.3.66
99
103
 
100
- 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.
104
+ 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**, **Motion Control**, **UGC Products**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A criação de Motion Control e UGC Products ocorre nas páginas standalone e guarda os artefactos localmente; não existem tabs duplicadas dentro de Geração de Conteúdo IA.
101
105
  **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.
102
106
 
103
107
  A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans` e `posts` e do bucket `instagram-images`, com ligação para a fonte original no GitHub. As duas frases promocionais da comunidade foram omitidas. Todas as subabas internas também são localizadas pelo helper nativo de tabs:
104
108
 
105
109
  O conteúdo interno das páginas também é localizado, não apenas a navegação: títulos, subtítulos, descrições, labels de campos, placeholders, opções de selectores, botões, avisos, mensagens de sucesso/erro, estados vazios, métricas, expanderes e blocos Markdown/HTML são traduzidos no idioma seleccionado através da camada global de conteúdo. Valores técnicos, chaves de estado, IDs de formulários, nomes de ficheiros, URLs e dados introduzidos pelo utilizador permanecem inalterados.
106
110
 
107
- Todas as subabas internas também são localizadas pelo helper nativo de tabs: **Blueprints/Brandings**, **Pesquisa pública/Cadastro manual/Contas cadastradas**, **Upload/Biblioteca**, **Importar do YouTube/Canais em lote gmail/Cadastro manual**, **Criar vídeo/Vídeos**, **Novo roteiro/letra/Histórico guardado**, as três abas de análise de clusters, as quatro abas de cortes, **Vídeos/Código Python**, as quatro opções de Upload, **JewelMusic/Pushtunes/ytmusicapi**, **API Keys/Teste de vozes**, **Serviços e modelos/Fontes de materiais**, **Client MCP/Servidor MCP/Skill** e **Notificações/Geral/Telegram**. As chaves técnicas e a ordem funcional permanecem inalteradas.
111
+ Todas as subabas internas também são localizadas pelo helper nativo de tabs: **Blueprints/Brandings**, **Pesquisa pública/Cadastro manual/Contas cadastradas**, **Upload/Biblioteca**, **Importar do YouTube/Canais em lote gmail/Cadastro manual**, **Criar vídeo/Vídeos**, **Novo roteiro/letra/Histórico guardado**, as três abas de análise de clusters, as quatro abas de cortes, **Vídeos/Código Python**, as quatro opções de Upload, **JewelMusic/Pushtunes/ytmusicapi**, **API Keys/Teste de vozes**, os expanders **Imagem e Video Montagem/MoviePy** e **Imagem e Video IA**, **Client MCP/Servidor MCP/Skill** e **Notificações/Geral/Telegram**. As chaves técnicas e a ordem funcional permanecem inalteradas.
108
112
 
109
- Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo` por conta, documentos de Upload directo, o formulário **Adicionar outra conta Gmail**, o bloco global de `INNERTUBE_API_KEY` e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, **Imagem e Video**, TikTok e Postiz.
113
+ Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo` por conta, documentos de Upload directo, o formulário **Adicionar outra conta Gmail**, o bloco global de `INNERTUBE_API_KEY` e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, os expanders **Imagem e Video Montagem/MoviePy** e **Imagem e Video IA**, TikTok e Postiz.
110
114
 
111
- Em **Configuração API > API Keys**, a subaba **Fontes de materiais** segue o padrão do MoneyPrinterTurbo: seleccione uma fonte — **Pexels**, **Pixabay**, **Coverr**, **WaveSpeed AI**, **LoomLoom**, **TwelveLabs** ou **Ficheiros locais** — e introduza as API keys dessa fonte. **Adicionar outra chave** cria outra linha para a mesma fonte; as listas são guardadas separadamente por fonte, deduplicadas e exportadas para o `config.toml` do motor como arrays para rotação. A subaba não expõe endpoint, proxy, qualidade, codec, FFmpeg, Whisper, directório ou filtros técnicos: esses valores permanecem internalizados e a fonte local não requer credencial.
115
+ Em **Configuração API > API Keys**, o expander **Imagem e Video Montagem/MoviePy** segue o padrão do MoneyPrinterTurbo: seleccione uma fonte — **Pexels**, **Pixabay**, **Coverr**, **WaveSpeed AI**, **LoomLoom**, **TwelveLabs** ou **Ficheiros locais** — e introduza as API keys dessa fonte. **Adicionar outra chave** cria outra linha para a mesma fonte; as listas são guardadas separadamente por fonte, deduplicadas e exportadas para o `config.toml` do motor como arrays para rotação. O expander não expõe endpoint, proxy, qualidade, codec, FFmpeg, Whisper, directório ou filtros técnicos: esses valores permanecem internalizados e a fonte local não requer credencial. Ele aparece acima do expander **Imagem e Video IA**.
112
116
 
113
- A subaba **Serviços e modelos** mantém as credenciais de providers e serviços que não são fontes de materiais. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
117
+ O expander **Imagem e Video IA** mantém as credenciais dos providers de geração que não são fontes de montagem. A chave `gemini_image_api_key` da Nano Banana continua separada de `gemini_api_key` do LLM textual. `INNERTUBE_API_KEY` permanece exclusivamente em **Configurações > Contas Google**, no bloco de configuração global fora dos cartões, e não é duplicada em API Keys.
114
118
 
115
119
  A área expansível **Voz, TTS e música — Azure Speech, restantes serviços e Suno** está organizada em cartões independentes para **Azure Speech**, **ElevenLabs**, **SiliconFlow**, **MiniMax TTS**, **Chatterbox**, **Sonilo** e **Suno**. Cada cartão contém apenas os campos do respectivo serviço e o seu próprio botão de diagnóstico; o Suno fica visualmente separado por ser um agente de criação musical, não um provider TTS. Em **Pipeline Vídeos > Criação de Vídeos > Configurações de áudio**, o modo **Upload** disponibiliza um uploader de narração, botão para guardar o ficheiro, pré-visualização e validação antes da criação. O caminho é passado ao MoneyPrinterTurbo através de `--custom-audio-file`, conforme o fluxo oficial documentado no [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo).
116
120
 
117
121
  Nos cartões de **LLM — providers e modelos**, o campo **Prioridade** define a ordem de tentativa do pool textual. O valor **1** é usado primeiro; em quota, timeout, erro de transporte ou falha transitória, o router tenta a prioridade **2**, depois **3** e assim sucessivamente. O checkbox antigo **LLM principal** deixou de existir. O card **Limite LLM NVIDIA NIM** fica dentro deste mesmo expander, acima dos cartões de providers, e os seus controlos continuam partilhados pela UI, pipeline e automações. Quando **LLM Telegram** está marcado, o cartão fica reservado exclusivamente às notificações Telegram: o campo Prioridade é desactivado e o cartão é excluído do pool textual normal. A chave interna de cartão activo continua apenas como espelho de compatibilidade para instalações antigas.
118
122
 
119
- O expander **Imagem e Video** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. Os parâmetros técnicos de composição, como **Image Size** e **Aspect Ratio**, não são apresentados nem editáveis nos cartões: ficam como defaults internos e são acrescentados automaticamente ao prompt da geração de imagem e/ou vídeo. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
123
+ O expander **Imagem e Video IA** reúne cartões independentes para **Nano Banana**, **Pollinations.ai**, **Agnes AI**, **Hugging Face Inference API**, **Cloudflare Workers AI**, **InferencePort Proxy**, **阿里云 (Alibaba Cloud Model Studio)**, **KIE AI** e **FAL AI**. Cada cartão permite configurar API key/token quando necessário, modelo, Base URL, activar o provider, escolher participação no **Pool Imagem** e/ou **Pool Vídeo**, definir prioridade e executar **Testar Chamada API**. Os parâmetros técnicos de composição, como **Image Size** e **Aspect Ratio**, não são apresentados nem editáveis nos cartões: ficam como defaults internos e são acrescentados automaticamente ao prompt da geração de imagem e/ou vídeo. O InferencePort usa por defeito `http://localhost:8080/v1` e não exige chave; o Cloudflare requer Account ID; os restantes providers devem ser preenchidos de acordo com a conta e endpoint disponibilizados pelo serviço.
120
124
 
121
125
  O router mantém três pools independentes: **LLM textual**, **imagem** e **vídeo**. O failover só passa ao cartão seguinte em quota, timeout, erro de transporte ou HTTP transitório; erros de credencial, endpoint/modelo inexistente ou payload inválido ficam registados e não são mascarados por um provider diferente. O pool de vídeo externo é opcional: quando desligado, o worker continua a usar o motor local MoneyPrinterTurbo; quando ligado, usa apenas os cartões marcados para Pool Vídeo.
122
126
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ import hashlib
6
7
  import io
7
8
  import json
8
9
  from pathlib import Path
@@ -13,14 +14,26 @@ import streamlit as st
13
14
  from hermes_ui.influencers import (
14
15
  BACKEND_OPTIONS,
15
16
  InfluencerBackendError,
17
+ STANDALONE_CONTENT_INFLUENCER_ID,
16
18
  backend_name,
17
19
  backend_status,
20
+ ensure_standalone_content_owner,
18
21
  get_repository,
19
22
  test_backend,
20
23
  )
21
- from hermes_ui.media_generation import MediaGenerationError, generate_image_for_card, generate_video_for_card
24
+ from hermes_ui.media_generation import (
25
+ MediaGenerationError,
26
+ generate_image_for_card,
27
+ generate_motion_control_video,
28
+ generate_ugc_product_video,
29
+ generate_video_for_card,
30
+ upload_kie_file,
31
+ validate_motion_control_file,
32
+ )
33
+ from hermes_ui.creative_generation import generate_ugc_segment_prompts
22
34
  from hermes_ui.media_providers import media_cards_for_pool, media_provider_definition
23
35
  from hermes_ui.notifications import record_notification
36
+ from hermes_ui.storage import STORAGE, ensure_storage
24
37
 
25
38
 
26
39
  CONTENT_STATES = {
@@ -195,7 +208,7 @@ def render_ai_influencer_characters(
195
208
 
196
209
  with created_characters_tab:
197
210
  try:
198
- influencers = repository.list_influencers()
211
+ influencers = [item for item in repository.list_influencers() if str(item.get("id") or "") != STANDALONE_CONTENT_INFLUENCER_ID]
199
212
  except Exception:
200
213
  st.error("Não foi possível consultar os personagens no backend seleccionado.")
201
214
  return
@@ -301,6 +314,170 @@ def _render_content_history(repository: Any, influencer_id: str = "") -> None:
301
314
  st.video(str(artifact))
302
315
 
303
316
 
317
+ def _store_uploaded_file(uploaded: Any, folder: str) -> Path:
318
+ """Persist an uploaded Streamlit file under storage without trusting its filename."""
319
+ ensure_storage()
320
+ original = Path(str(getattr(uploaded, "name", "upload.bin") or "upload.bin")).name
321
+ safe_name = "".join(char if char.isalnum() or char in ".-_" else "_" for char in original).strip("._") or "upload.bin"
322
+ content = uploaded.getvalue()
323
+ if not content:
324
+ raise MediaGenerationError(f"O ficheiro {safe_name} está vazio.")
325
+ digest = hashlib.sha256(content).hexdigest()[:16]
326
+ destination = STORAGE / "influencer_workflows" / folder / f"{digest}-{safe_name}"
327
+ destination.parent.mkdir(parents=True, exist_ok=True)
328
+ if not destination.exists():
329
+ destination.write_bytes(content)
330
+ return destination
331
+
332
+
333
+ def _workflow_owner(repository: Any) -> str:
334
+ return ensure_standalone_content_owner(repository)
335
+
336
+
337
+ def _workflow_provider_cards(settings: Mapping[str, Any]) -> list[dict[str, Any]]:
338
+ return [card for card in media_cards_for_pool(settings, "video") if str(card.get("provider") or "").strip().lower() == "kie_ai"]
339
+
340
+
341
+ def render_motion_control(settings: dict[str, Any]) -> None:
342
+ """Render Kling 2.6 Motion Control creation only; no social delivery is exposed."""
343
+ st.title("Motion Control")
344
+ st.caption("Crie um vídeo Kling 2.6 a partir de um vídeo de movimento e de uma imagem de referência. O resultado é descarregado para o storage local; não há Telegram, Postiz, Drive ou publicação social.")
345
+ repository = _repository(settings)
346
+ if repository is None:
347
+ return
348
+ cards = _workflow_provider_cards(settings)
349
+ if not cards:
350
+ st.warning("Active e configure pelo menos um cartão KIE AI no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
351
+ return
352
+ card_options = [str(card.get("id") or "") for card in cards]
353
+ with st.form("influencer_motion_control_form"):
354
+ video_upload = st.file_uploader("Vídeo original de movimento", type=["mp4", "mov"], key="motion_control_video")
355
+ image_upload = st.file_uploader("Imagem de referência", type=["jpg", "jpeg", "png"], key="motion_control_image")
356
+ prompt = st.text_area("Prompt (opcional)", height=120, max_chars=2500, placeholder="Descreva como preservar a identidade da imagem e aplicar o movimento…", key="motion_control_prompt")
357
+ provider_id = st.selectbox("Provider / modelo", card_options, format_func=lambda value: _provider_label(next(card for card in cards if str(card.get("id")) == value)), key="motion_control_provider")
358
+ generate = st.form_submit_button("Gerar Motion Control", type="primary", use_container_width=True)
359
+ if generate:
360
+ if video_upload is None or image_upload is None:
361
+ st.error("Seleccione o vídeo original e a imagem de referência.")
362
+ return
363
+ card = next(card for card in cards if str(card.get("id")) == provider_id)
364
+ try:
365
+ video_path = _store_uploaded_file(video_upload, "motion-control-inputs")
366
+ image_path = _store_uploaded_file(image_upload, "motion-control-inputs")
367
+ video_info = validate_motion_control_file(video_path, kind="vídeo")
368
+ image_info = validate_motion_control_file(image_path, kind="imagem")
369
+ owner_id = _workflow_owner(repository)
370
+ record = repository.create_content(
371
+ {
372
+ "influencer_id": owner_id,
373
+ "content_type": "video",
374
+ "prompt": prompt,
375
+ "caption": "",
376
+ "provider": card.get("provider"),
377
+ "model": "kling-2.6/motion-control",
378
+ "platform": "",
379
+ "state": "running",
380
+ "metadata": {
381
+ "workflow": "motion_control",
382
+ "input_video_path": str(video_path),
383
+ "reference_image_path": str(image_path),
384
+ "input_video_duration_seconds": video_info.get("duration_seconds"),
385
+ "input_video_size_bytes": video_info.get("size_bytes"),
386
+ "reference_image_size_bytes": image_info.get("size_bytes"),
387
+ },
388
+ }
389
+ )
390
+ with st.spinner("A enviar os inputs para KIE e a aguardar o Kling Motion Control…"):
391
+ try:
392
+ image_url = upload_kie_file(image_path, card, upload_path="thunderbolt/motion-control")
393
+ video_url = upload_kie_file(video_path, card, upload_path="thunderbolt/motion-control")
394
+ output, task_id = generate_motion_control_video(settings, card, image_url=image_url, video_url=video_url, prompt=prompt)
395
+ metadata = {"workflow": "motion_control", "input_video_path": str(video_path), "reference_image_path": str(image_path), "kie_input_urls_temporary": True}
396
+ repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": task_id, "metadata": metadata})
397
+ record_notification("influencer_content_completed", "Motion Control concluído", "O vídeo Motion Control foi gerado e guardado localmente.", metadata={"content_id": record["id"], "workflow": "motion_control", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:completed")
398
+ st.success("Motion Control concluído. O vídeo está disponível no histórico local abaixo.")
399
+ except Exception as exc:
400
+ repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
401
+ record_notification("influencer_content_failed", "Motion Control falhou", f"A criação Motion Control falhou: {str(exc)[:240]}", metadata={"content_id": record["id"], "workflow": "motion_control", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:failed")
402
+ st.error(f"Não foi possível gerar o Motion Control: {exc}")
403
+ except (MediaGenerationError, ValueError, OSError) as exc:
404
+ st.error(str(exc))
405
+ _render_content_history(repository, STANDALONE_CONTENT_INFLUENCER_ID)
406
+
407
+
408
+ def render_ugc_products(settings: dict[str, Any]) -> None:
409
+ """Render the local product-image to VEO3 workflow without Telegram or social publishing."""
410
+ st.title("UGC Products")
411
+ st.caption("Crie um anúncio UGC a partir de uma imagem de produto e de um roteiro local. O workflow gera dois clips VEO3 de 8 segundos e junta-os com FFmpeg no storage local.")
412
+ repository = _repository(settings)
413
+ if repository is None:
414
+ return
415
+ video_cards = [card for card in _workflow_provider_cards(settings) if str(card.get("model") or "").strip().lower() in {"", "veo3", "veo3_fast", "veo3_lite"}]
416
+ if not video_cards:
417
+ st.warning("Active e configure um cartão KIE AI com modelo VEO3 compatível no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
418
+ return
419
+ image_cards, _ = _provider_options(settings, "image")
420
+ image_cards = [card for card in image_cards if str(card.get("provider") or "").strip().lower() == "nano_banana"]
421
+ with st.form("influencer_ugc_products_form"):
422
+ product_upload = st.file_uploader("Imagem do produto", type=["jpg", "jpeg", "png", "webp"], key="ugc_products_image")
423
+ script = st.text_area("Roteiro de vídeo", height=180, placeholder="Escreva a demonstração, falas e acções. Para controlar os dois clips manualmente, separe-os com uma linha contendo ---.", key="ugc_products_script")
424
+ improve_image = st.checkbox("Melhorar a imagem com o provider de imagem activo (opcional)", value=False, key="ugc_products_improve_image")
425
+ image_provider_id = ""
426
+ if improve_image and image_cards:
427
+ image_provider_options = [str(card.get("id") or "") for card in image_cards]
428
+ image_provider_id = st.selectbox("Provider de melhoria da imagem", image_provider_options, format_func=lambda value: _provider_label(next(card for card in image_cards if str(card.get("id")) == value)), key="ugc_products_image_provider")
429
+ elif improve_image:
430
+ st.info("Não existe um provider activo no pool de imagem; será usada a imagem original.")
431
+ card_options = [str(card.get("id") or "") for card in video_cards]
432
+ provider_id = st.selectbox("Provider VEO3", card_options, format_func=lambda value: _provider_label(next(card for card in video_cards if str(card.get("id")) == value)), key="ugc_products_video_provider")
433
+ generate = st.form_submit_button("Gerar UGC Product", type="primary", use_container_width=True)
434
+ if generate:
435
+ if product_upload is None:
436
+ st.error("Seleccione a imagem do produto.")
437
+ return
438
+ card = next(card for card in video_cards if str(card.get("id")) == provider_id)
439
+ try:
440
+ product_path = _store_uploaded_file(product_upload, "ugc-products-inputs")
441
+ image_path = product_path
442
+ if improve_image and image_provider_id:
443
+ image_card = next(card for card in image_cards if str(card.get("id")) == image_provider_id)
444
+ with st.spinner("A melhorar a imagem do produto…"):
445
+ image_path = generate_image_for_card(settings, image_card, "Melhorar a apresentação fotográfica do mesmo produto sem alterar a embalagem, logótipo, cores, forma ou objectos visíveis. Fundo comercial limpo e luz natural.", topic="UGC Product", reference_image=product_path)
446
+ prompts = generate_ugc_segment_prompts(settings, script)
447
+ owner_id = _workflow_owner(repository)
448
+ record = repository.create_content(
449
+ {
450
+ "influencer_id": owner_id,
451
+ "content_type": "video",
452
+ "prompt": script,
453
+ "caption": "",
454
+ "provider": card.get("provider"),
455
+ "model": str(card.get("model") or "veo3_fast"),
456
+ "platform": "",
457
+ "state": "running",
458
+ "metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "telegram": False, "social_publish": False},
459
+ }
460
+ )
461
+ with st.spinner("A enviar a imagem para KIE, a gerar os dois clips VEO3 e a juntá-los localmente…"):
462
+ try:
463
+ image_url = upload_kie_file(image_path, card, upload_path="thunderbolt/ugc-products")
464
+ output, task_ids = generate_ugc_product_video(settings, card, image_url=image_url, prompts=prompts)
465
+ repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": ",".join(task_ids), "metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "task_ids": task_ids, "telegram": False, "social_publish": False}})
466
+ record_notification("influencer_content_completed", "UGC Products concluído", "O vídeo UGC Product foi gerado, unido e guardado localmente.", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:completed")
467
+ st.success("UGC Product concluído. O vídeo final está disponível no histórico local abaixo.")
468
+ with st.expander("Prompts dos dois segmentos", expanded=False):
469
+ for index, prompt_item in enumerate(prompts, start=1):
470
+ st.markdown(f"**Segmento {index}**")
471
+ st.code(prompt_item)
472
+ except Exception as exc:
473
+ repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
474
+ record_notification("influencer_content_failed", "UGC Products falhou", f"A criação UGC Products falhou: {str(exc)[:240]}", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:failed")
475
+ st.error(f"Não foi possível gerar o UGC Product: {exc}")
476
+ except (MediaGenerationError, ValueError, OSError) as exc:
477
+ st.error(str(exc))
478
+ _render_content_history(repository, STANDALONE_CONTENT_INFLUENCER_ID)
479
+
480
+
304
481
  def render_ai_influencer_content(settings: dict[str, Any]) -> None:
305
482
  st.title("Geração de Conteúdo IA")
306
483
  st.caption("Gere conteúdos para redes sociais a partir de um personagem e dos seus assets. A publicação permanece separada e exige uma acção explícita.")
@@ -308,11 +485,11 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
308
485
  if repository is None:
309
486
  return
310
487
  try:
311
- influencers = repository.list_influencers()
488
+ influencers = [item for item in repository.list_influencers() if str(item.get("id") or "") != STANDALONE_CONTENT_INFLUENCER_ID]
312
489
  except Exception:
313
490
  st.error("Não foi possível consultar os personagens.")
314
491
  return
315
- images_tab, videos_tab, motion_tab = st.tabs(["Imagens", "Vídeos", "Motion Control"])
492
+ images_tab, videos_tab = st.tabs(["Imagens", "Vídeos"])
316
493
 
317
494
  with images_tab:
318
495
  st.subheader("Gerar imagem para redes sociais")
@@ -323,7 +500,7 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
323
500
  current = str(st.session_state.get("influencer_selected_id") or options[0])
324
501
  cards, provider_options = _provider_options(settings, "image")
325
502
  if not cards:
326
- st.warning("Não existem providers activos no pool de imagem. Configure um provider em Configuração API > API Keys > Imagem e Video.")
503
+ st.warning("Não existem providers activos no pool de imagem. Configure um provider em Configuração API > API Keys > Imagem e Video IA.")
327
504
  with st.form("influencer_image_content_form"):
328
505
  influencer_id = st.selectbox("Personagem", options, index=options.index(current) if current in options else 0, format_func=lambda value: _influencer_label(influencers, value), key="content_image_influencer")
329
506
  assets = repository.list_assets(influencer_id)
@@ -371,7 +548,7 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
371
548
  current = str(st.session_state.get("influencer_selected_id") or options[0])
372
549
  cards, provider_options = _provider_options(settings, "video")
373
550
  if not cards:
374
- st.warning("Não existem providers activos no pool de vídeo. Configure KIE AI, Replicate, FAL AI ou outro provider compatível em Configuração API > API Keys > Imagem e Video.")
551
+ st.warning("Não existem providers activos no pool de vídeo. Configure KIE AI, Replicate, FAL AI ou outro provider compatível em Configuração API > API Keys > Imagem e Video IA.")
375
552
  with st.form("influencer_video_content_form"):
376
553
  influencer_id = st.selectbox("Personagem", options, index=options.index(current) if current in options else 0, format_func=lambda value: _influencer_label(influencers, value), key="content_video_influencer")
377
554
  assets = repository.list_assets(influencer_id)
@@ -415,13 +592,11 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
415
592
  selected_id = str(st.session_state.get("content_video_influencer") or options[0])
416
593
  _render_content_history(repository, selected_id)
417
594
 
418
- with motion_tab:
419
- st.subheader("Motion Control")
420
- st.info("A subaba Motion Control foi preparada para a próxima evolução. Nenhuma operação é executada nesta versão.")
421
-
422
595
 
423
596
  __all__ = [
424
597
  "render_ai_influencer_characters",
425
598
  "render_ai_influencer_content",
599
+ "render_motion_control",
600
+ "render_ugc_products",
426
601
  "render_ai_influencers_api_status",
427
602
  ]
package/app/main.py CHANGED
@@ -31,7 +31,7 @@ from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_a
31
31
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
32
32
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
33
33
  from app.modules.niche_finder.summarizer import summarize_items
34
- from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status
34
+ from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
35
35
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
36
36
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
37
37
  from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
@@ -5039,7 +5039,7 @@ def _material_source_card_definition(provider: str) -> dict[str, str]:
5039
5039
  }
5040
5040
 
5041
5041
 
5042
- def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int) -> None:
5042
+ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int, *, embedded: bool = False) -> None:
5043
5043
  card = normalize_material_card(cards[index], index)
5044
5044
  cards[index] = card
5045
5045
  card_id = str(card["id"])
@@ -5054,7 +5054,8 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
5054
5054
  st.caption(definition["description"])
5055
5055
  with header_cols[1]:
5056
5056
  _render_credential_status("" if is_local else card.get("api_key"), local=is_local, required=not is_local)
5057
- with st.form(f"material_source_card_form_{card_id}"):
5057
+ card_form = nullcontext() if embedded else st.form(f"material_source_card_form_{card_id}")
5058
+ with card_form:
5058
5059
  content_cols = st.columns(2)
5059
5060
  with content_cols[0]:
5060
5061
  if is_local:
@@ -5085,29 +5086,30 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
5085
5086
  st.rerun()
5086
5087
 
5087
5088
 
5088
- def render_material_source_api_keys(settings: dict[str, Any]) -> None:
5089
- st.subheader("Fontes de Materiais")
5090
- st.caption("Configure cada provedor num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.")
5091
- migrated, changed = ensure_material_source_cards(settings)
5092
- cards = [dict(item) for item in migrated.get("material_source_cards", [])]
5093
- if changed:
5094
- write_json("settings.json", settings)
5095
- for index in range(len(cards)):
5096
- _render_material_source_card(settings, cards, index)
5089
+ def render_material_source_api_keys(settings: dict[str, Any], *, embedded: bool = False) -> None:
5090
+ with st.expander("Imagem e Video Montagem/MoviePy", expanded=False):
5091
+ st.caption("Configure as fontes usadas pela montagem de vídeo com MoviePy/FFmpeg num cartão independente. Pode repetir o mesmo provedor para guardar várias API keys; a fonte seleccionada será usada pela pipeline.")
5092
+ migrated, changed = ensure_material_source_cards(settings)
5093
+ cards = [dict(item) for item in migrated.get("material_source_cards", [])]
5094
+ if changed:
5095
+ write_json("settings.json", settings)
5096
+ for index in range(len(cards)):
5097
+ _render_material_source_card(settings, cards, index, embedded=embedded)
5097
5098
 
5098
- st.divider()
5099
- st.markdown("**Adicionar fonte de materiais**")
5100
- provider_codes = [item["code"] for item in material_source_catalog()] + ["local"]
5101
- provider_to_add = st.selectbox(
5102
- "Provedor de materiais",
5103
- provider_codes,
5104
- format_func=lambda value: _material_source_card_definition(value)["label"],
5105
- key="material_new_provider_choice",
5106
- )
5107
- if st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card"):
5108
- cards.append(new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
5109
- _persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
5110
- st.rerun()
5099
+ st.divider()
5100
+ st.markdown("**Adicionar fonte de materiais**")
5101
+ provider_codes = [item["code"] for item in material_source_catalog()] + ["local"]
5102
+ provider_to_add = st.selectbox(
5103
+ "Provedor de materiais",
5104
+ provider_codes,
5105
+ format_func=lambda value: _material_source_card_definition(value)["label"],
5106
+ key="material_new_provider_choice",
5107
+ )
5108
+ add_source_clicked = st.form_submit_button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card") if embedded else st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card")
5109
+ if add_source_clicked:
5110
+ cards.append(new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
5111
+ _persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
5112
+ st.rerun()
5111
5113
 
5112
5114
 
5113
5115
  def _api_status_badge(label: str, kind: str = "missing") -> None:
@@ -5490,7 +5492,7 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
5490
5492
  if changed:
5491
5493
  settings.update(migrated)
5492
5494
  write_json("settings.json", settings)
5493
- with st.expander("Imagem e Video", expanded=False):
5495
+ with st.expander("Imagem e Video IA", expanded=False):
5494
5496
  full_ia_labels = ", ".join(media_provider_definition(code).label for code in FULL_IA_VIDEO_PROVIDER_CODES)
5495
5497
  st.caption(f"Configure providers de imagem e vídeo em cartões independentes. O router usa apenas o pool correspondente e faz failover entre providers activos. Pool Full IA: {full_ia_labels}.")
5496
5498
  image_cards = [card for card in cards if card.get("supports_image")]
@@ -5537,7 +5539,7 @@ def render_settings():
5537
5539
  key=f"settings_{key}",
5538
5540
  )
5539
5541
 
5540
- api_keys_tab, google_accounts_tab, tiktok_api_tab, material_sources_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "Fontes de Materiais", "AI Influencers", "Teste de Voz"])
5542
+ api_keys_tab, google_accounts_tab, tiktok_api_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "AI Influencers", "Teste de Voz"])
5541
5543
 
5542
5544
  with api_keys_tab:
5543
5545
  with st.container(border=True):
@@ -5586,6 +5588,8 @@ def render_settings():
5586
5588
  llm_rpm_limit = int(llm_rpm_settings["llm_rpm_limit"])
5587
5589
  llm_rpm_window_seconds = int(llm_rpm_settings["llm_rpm_window_seconds"])
5588
5590
 
5591
+ render_material_source_api_keys(settings, embedded=True)
5592
+
5589
5593
  render_media_provider_cards(settings, embedded=True)
5590
5594
 
5591
5595
  with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=False):
@@ -5758,9 +5762,6 @@ def render_settings():
5758
5762
  with tiktok_api_tab:
5759
5763
  render_tiktok_api_cards(settings)
5760
5764
 
5761
- with material_sources_tab:
5762
- render_material_source_api_keys(settings)
5763
-
5764
5765
  with ai_influencers_tab:
5765
5766
  st.subheader("AI Influencers")
5766
5767
  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.")
@@ -6396,6 +6397,7 @@ def main():
6396
6397
  models_ai_items = [
6397
6398
  ("Personagens", ":material/person:", "Personagens"),
6398
6399
  ("Geração de Conteúdo IA", ":material/auto_awesome:", "Geração de Conteúdo IA"),
6400
+ ("Motion Control", ":material/motion_photos_on:", "Motion Control"),
6399
6401
  ("UGC Products", ":material/shopping_bag:", "UGC Products"),
6400
6402
  ("Redes Sociais", ":material/share:", "Redes Sociais"),
6401
6403
  ]
@@ -6537,7 +6539,8 @@ def main():
6537
6539
  language_normalizer=normalize_video_language,
6538
6540
  ),
6539
6541
  "Geração de Conteúdo IA": lambda: render_ai_influencer_content(read_json("settings.json", {})),
6540
- "UGC Products": lambda: render_edit_placeholder("UGC Products", ""),
6542
+ "Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
6543
+ "UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
6541
6544
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
6542
6545
  "Analista Growth Youtube": lambda: render_edit_placeholder("Analista Growth Youtube", ""),
6543
6546
  "Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
@@ -481,3 +481,42 @@ def generate_creative_package(
481
481
  "topic_source": "llm",
482
482
  "generated_by": "configured_llm",
483
483
  }
484
+
485
+
486
+ UGC_SEGMENT_SYSTEM_PROMPT = (
487
+ "És um realizador de anúncios UGC de produto. Responde apenas com JSON válido no formato "
488
+ "{\"segments\":[{\"prompt\":\"...\"},{\"prompt\":\"...\"}]}. "
489
+ "Cria exactamente dois prompts para clips consecutivos de 8 segundos cada. "
490
+ "Preserva exactamente o produto, embalagem, cores, logótipo e objectos visíveis na imagem; não inventes, removas ou alteres características. "
491
+ "Descreve apenas acções fisicamente possíveis, continuidade visual entre segmentos, diálogo natural quando solicitado e nenhuma legenda, texto incorporado, watermark ou interface."
492
+ )
493
+
494
+
495
+ def _ugc_fallback_prompts(instructions: str) -> list[str]:
496
+ base = str(instructions or "Apresentar o produto de forma clara, natural e convincente.").strip()
497
+ return [
498
+ f"Clip UGC 1 de 8 segundos. {base} Mostrar o produto exactamente como aparece na imagem, com enquadramento estável, luz natural e uma demonstração inicial fisicamente possível. Sem texto no vídeo, sem legendas e sem watermark.",
499
+ f"Clip UGC 2 de 8 segundos, continuidade directa do clip anterior. {base} Completar a demonstração com uma reacção humana natural e uma apresentação final do produto, sem alterar os objectos visíveis. Sem texto no vídeo, sem legendas e sem watermark.",
500
+ ]
501
+
502
+
503
+ def generate_ugc_segment_prompts(settings: dict[str, Any], instructions: str) -> list[str]:
504
+ """Return exactly two safe VEO prompts, accepting an explicit `---` split without LLM."""
505
+ clean = str(instructions or "").strip()
506
+ explicit = [part.strip() for part in re.split(r"\n\s*(?:---|===|SEGMENTO\s*2)\s*\n", clean, flags=re.IGNORECASE) if part.strip()]
507
+ if len(explicit) == 2:
508
+ return [
509
+ f"Clip UGC 1 de 8 segundos. {explicit[0]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
510
+ f"Clip UGC 2 de 8 segundos, em continuidade. {explicit[1]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
511
+ ]
512
+ try:
513
+ result = _chat_json(settings, UGC_SEGMENT_SYSTEM_PROMPT, clean or "Cria uma demonstração UGC curta e natural do produto.")
514
+ raw_segments = result.get("segments")
515
+ if isinstance(raw_segments, list):
516
+ prompts = [str(item.get("prompt") or "").strip() for item in raw_segments if isinstance(item, dict)]
517
+ prompts = [item for item in prompts if item]
518
+ if len(prompts) >= 2:
519
+ return [f"{item} Sem texto incorporado, sem legendas e sem watermark." for item in prompts[:2]]
520
+ except CreativeGenerationError:
521
+ pass
522
+ return _ugc_fallback_prompts(clean)
@@ -20,6 +20,7 @@ from typing import Any, Iterable, Mapping
20
20
  from .storage import ROOT, STORAGE
21
21
 
22
22
  BACKEND_OPTIONS = ("Supabase", "SQLite")
23
+ STANDALONE_CONTENT_INFLUENCER_ID = "__standalone_ai_content__"
23
24
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
24
25
  DOCUMENT_EXTENSIONS = {".md", ".json"}
25
26
  ASSET_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS
@@ -516,6 +517,22 @@ class SupabaseInfluencerRepository:
516
517
  return self._data(response)
517
518
 
518
519
 
520
+ def ensure_standalone_content_owner(repository: Any) -> str:
521
+ """Return a hidden owner row for AI content that has no character dependency."""
522
+ existing = repository.get_influencer(STANDALONE_CONTENT_INFLUENCER_ID)
523
+ if existing:
524
+ return STANDALONE_CONTENT_INFLUENCER_ID
525
+ repository.create_influencer(
526
+ {
527
+ "id": STANDALONE_CONTENT_INFLUENCER_ID,
528
+ "name": "__Thunderbolt AI content__",
529
+ "bio": "Internal owner for standalone Motion Control and UGC Products content.",
530
+ "language": "",
531
+ }
532
+ )
533
+ return STANDALONE_CONTENT_INFLUENCER_ID
534
+
535
+
519
536
  def sqlite_path_from_settings(settings: Mapping[str, Any]) -> Path:
520
537
  raw = str(settings.get("influencer_sqlite_path") or "storage/state/ai_influencers.db").strip()
521
538
  return _resolve_sqlite_path(raw)
@@ -567,12 +584,14 @@ __all__ = [
567
584
  "DOCUMENT_EXTENSIONS",
568
585
  "IMAGE_EXTENSIONS",
569
586
  "InfluencerBackendError",
587
+ "STANDALONE_CONTENT_INFLUENCER_ID",
570
588
  "SQLiteInfluencerRepository",
571
589
  "SupabaseInfluencerRepository",
572
590
  "SQLITE_SCHEMA",
573
591
  "backend_name",
574
592
  "backend_status",
575
593
  "get_repository",
594
+ "ensure_standalone_content_owner",
576
595
  "parse_document",
577
596
  "sqlite_path_from_settings",
578
597
  "test_backend",
@@ -242,6 +242,8 @@ for _language_code, _api_key_values in _API_KEY_EXPANDER_TRANSLATIONS.items():
242
242
  # provider cards themselves expose their capability labels in the current UI.
243
243
  for _language_code in UI_TRANSLATIONS:
244
244
  UI_TRANSLATIONS[_language_code].setdefault("Imagem e Video", "Imagem e Video")
245
+ UI_TRANSLATIONS[_language_code].setdefault("Imagem e Video IA", "Imagem e Video IA")
246
+ UI_TRANSLATIONS[_language_code].setdefault("Imagem e Video Montagem/MoviePy", "Imagem e Video Montagem/MoviePy")
245
247
 
246
248
 
247
249
  # Labels introduced by the reorganised sidebar.
@@ -982,7 +984,8 @@ _CONTENT_TRANSLATION_ROWS = (
982
984
  ("Niche Finder — execução remota no Kaggle", "Niche Finder — execução remota no Kaggle", "Niche Finder — remote execution on Kaggle", "Niche Finder — Kaggle 远程执行", "Niche Finder — Remoteausführung auf Kaggle", "Niche Finder — chạy từ xa trên Kaggle", "Niche Finder — Kaggle'da uzaktan çalıştırma", "Niche Finder — удалённый запуск на Kaggle", "Niche Finder — ejecución remota en Kaggle", "Niche Finder — eksekusi jarak jauh di Kaggle", "Niche Finder — esecuzione remota su Kaggle"),
983
985
  ("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.", "The dataset remains on Kaggle. Thunderbolt uses these credentials only to publish/run the kernel and retrieve small analysis results.", "数据集保留在 Kaggle。Thunderbolt 仅使用这些凭证发布/运行 kernel 并获取小型分析结果。", "Der Datensatz bleibt auf Kaggle. Thunderbolt verwendet diese Zugangsdaten nur zum Veröffentlichen/Ausführen des Kernels und zum Abrufen kleiner Analyseergebnisse.", "Dataset vẫn ở Kaggle. Thunderbolt chỉ dùng thông tin xác thực để xuất bản/chạy kernel và lấy kết quả phân tích nhỏ.", "Veri kümesi Kaggle'da kalır. Thunderbolt bu kimlik bilgilerini yalnızca kernel'ı yayınlamak/çalıştırmak ve küçük analiz sonuçlarını almak için kullanır.", "Датасет остаётся на Kaggle. Thunderbolt использует эти данные только для публикации/запуска kernel и получения небольших результатов анализа.", "El dataset permanece en Kaggle. Thunderbolt usa estas credenciales solo para publicar/ejecutar el kernel y obtener pequeños resultados del análisis.", "Dataset tetap di Kaggle. Thunderbolt menggunakan kredensial ini hanya untuk menerbitkan/menjalankan kernel dan mengambil hasil analisis kecil.", "Il dataset resta su Kaggle. Thunderbolt usa queste credenziali solo per pubblicare/eseguire il kernel e ottenere piccoli risultati dell'analisi."),
984
986
  ("Niche Finder — execução através da Apify", "Niche Finder — execução através da Apify", "Niche Finder — execution through Apify", "Niche Finder — 通过 Apify 执行", "Niche Finder — Ausführung über Apify", "Niche Finder — chạy qua Apify", "Niche Finder — Apify üzerinden çalıştırma", "Niche Finder — запуск через Apify", "Niche Finder — ejecución mediante Apify", "Niche Finder — eksekusi melalui Apify", "Niche Finder — esecuzione tramite Apify"),
985
- ("Imagem e Video", "Imagem e Video", "Image and Video", "图像和视频", "Bild und Video", "Hình ảnh và video", "Görüntü ve Video", "Изображение и видео", "Imagen y vídeo", "Gambar dan Video", "Immagine e Video"),
987
+ ("Imagem e Video IA", "Imagem e Video IA", "AI Image and Video", "图像和视频 AI", "KI-Bild und -Video", "Hình ảnh và video AI", "Yapay Zekâ Görüntü ve Video", "ИИ: изображения и видео", "Imagen y vídeo con IA", "Gambar dan Video AI", "Immagini e video IA"),
988
+ ("Imagem e Video Montagem/MoviePy", "Imagem e Video Montagem/MoviePy", "Image and Video Assembly/MoviePy", "图像和视频拼装/MoviePy", "Bild- und Videomontage/MoviePy", "Lắp ráp hình ảnh và video/MoviePy", "Görüntü ve Video Montajı/MoviePy", "Сборка изображения и видео/MoviePy", "Montaje de imagen y vídeo/MoviePy", "Perakitan Gambar dan Video/MoviePy", "Montaggio immagini e video/MoviePy"),
986
989
  ("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.", "A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.", "Nano Banana generates the final thumbnail image from the selected variant. The key is stored locally and is separate from the Gemini key used as a text LLM.", "Nano Banana 根据所选变体生成最终缩略图。密钥仅保存在本地存储中,与用于文本 LLM 的 Gemini 密钥分开。", "Nano Banana erzeugt das endgültige Thumbnail aus der ausgewählten Variante. Der Schlüssel wird nur lokal gespeichert und ist vom Gemini-Schlüssel für das Text-LLM getrennt.", "Nano Banana tạo ảnh thumbnail cuối từ biến thể đã chọn. Key chỉ lưu cục bộ và tách biệt với key Gemini dùng cho LLM văn bản.", "Nano Banana, seçilen varyanttan son küçük resim görselini oluşturur. Anahtar yalnızca yerel depolamada tutulur ve metin LLM'i için kullanılan Gemini anahtarından ayrıdır.", "Nano Banana создаёт финальное изображение миниатюры из выбранного варианта. Ключ хранится локально отдельно от ключа Gemini для текстовой LLM.", "Nano Banana genera la miniatura final a partir de la variante elegida. La clave se guarda localmente y es distinta de la clave de Gemini del LLM de texto.", "Nano Banana menghasilkan gambar thumbnail akhir dari varian yang dipilih. Kunci disimpan secara lokal dan terpisah dari kunci Gemini untuk LLM teks.", "Nano Banana genera l'immagine finale della miniatura dalla variante scelta. La chiave è salvata localmente ed è separata da quella Gemini usata per l'LLM testuale."),
987
990
  ("LLM — providers e modelos", "LLM — providers e modelos", "LLM — providers and models", "LLM — 服务商和模型", "LLM — Anbieter und Modelle", "LLM — provider và mô hình", "LLM — sağlayıcılar ve modeller", "LLM — провайдеры и модели", "LLM — proveedores y modelos", "LLM — penyedia dan model", "LLM — provider e modelli"),
988
991
  ("OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/ NVIDIA NIM — API key, Base URL e modelo", "OpenAI/NVIDIA NIM — API key, Base URL and model", "OpenAI/NVIDIA NIM — API 密钥、基础 URL 和模型", "OpenAI/NVIDIA NIM — API-Schlüssel, Basis-URL und Modell", "OpenAI/NVIDIA NIM — API key, Base URL và mô hình", "OpenAI/NVIDIA NIM — API anahtarı, Temel URL ve model", "OpenAI/NVIDIA NIM — API-ключ, базовый URL и модель", "OpenAI/NVIDIA NIM — clave API, URL base y modelo", "OpenAI/NVIDIA NIM — kunci API, URL Dasar, dan model", "OpenAI/NVIDIA NIM — API key, URL di base e modello"),
@@ -9,6 +9,12 @@ from __future__ import annotations
9
9
 
10
10
  import base64
11
11
  import binascii
12
+ import json
13
+ import mimetypes
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
12
18
  import time
13
19
  from pathlib import Path
14
20
  from typing import Any, Mapping
@@ -544,3 +550,332 @@ def generate_video_from_pool(
544
550
  if not _is_retryable_media_error(exc):
545
551
  raise
546
552
  raise MediaGenerationError("Todos os providers do pool de vídeo falharam: " + " | ".join(errors))
553
+
554
+
555
+ KIE_FILE_UPLOAD_ENDPOINT = "https://kieai.redpandaai.co/api/file-stream-upload"
556
+ KIE_MOTION_MODEL = "kling-2.6/motion-control"
557
+ KIE_MOTION_IMAGE_MAX_BYTES = 10 * 1024 * 1024
558
+ KIE_MOTION_VIDEO_MAX_BYTES = 100 * 1024 * 1024
559
+ KIE_MOTION_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
560
+ KIE_MOTION_VIDEO_EXTENSIONS = {".mp4", ".mov"}
561
+ KIE_VEO_MODELS = {"veo3", "veo3_fast", "veo3_lite"}
562
+
563
+
564
+ def _require_kie_card(card: Mapping[str, Any]) -> dict[str, Any]:
565
+ current = dict(card)
566
+ if str(current.get("provider") or "").strip().lower() != "kie_ai":
567
+ raise MediaGenerationError("Este workflow requer um cartão KIE AI activo no pool de vídeo.")
568
+ if not _api_key(current):
569
+ raise MediaGenerationError("Configure a API key do KIE AI em Configuração API > API Keys > Imagem e Video IA.")
570
+ return current
571
+
572
+
573
+ def _kie_json_error(payload: Any, *, fallback: str) -> ProviderCallError | None:
574
+ if not isinstance(payload, Mapping):
575
+ return ProviderCallError(fallback, category="payload", retryable=False)
576
+ try:
577
+ code = int(payload.get("code", 200))
578
+ except (TypeError, ValueError):
579
+ code = 200
580
+ if code == 200:
581
+ return None
582
+ category = "quota" if code in {402, 429, 433} else "credential" if code == 401 else "endpoint_or_model" if code in {404, 455, 505} else "transient" if code >= 500 else "payload"
583
+ return ProviderCallError(str(payload.get("msg") or fallback)[:500], status_code=code, category=category, retryable=category in {"quota", "transient"})
584
+
585
+
586
+ def validate_motion_control_file(path: str | Path, *, kind: str) -> dict[str, Any]:
587
+ """Validate KIE Motion Control local input limits before uploading it."""
588
+ candidate = Path(path).expanduser()
589
+ if not candidate.is_file():
590
+ raise MediaGenerationError(f"O ficheiro de {kind} não está disponível.")
591
+ suffix = candidate.suffix.lower()
592
+ if kind == "imagem":
593
+ allowed, maximum = KIE_MOTION_IMAGE_EXTENSIONS, KIE_MOTION_IMAGE_MAX_BYTES
594
+ elif kind == "vídeo":
595
+ allowed, maximum = KIE_MOTION_VIDEO_EXTENSIONS, KIE_MOTION_VIDEO_MAX_BYTES
596
+ else:
597
+ raise MediaGenerationError("Tipo de input Motion Control inválido.")
598
+ if suffix not in allowed:
599
+ raise MediaGenerationError(f"O {kind} Motion Control deve estar em {', '.join(sorted(allowed))}.")
600
+ size = candidate.stat().st_size
601
+ if size <= 0:
602
+ raise MediaGenerationError(f"O ficheiro de {kind} está vazio.")
603
+ if size > maximum:
604
+ raise MediaGenerationError(f"O ficheiro de {kind} excede o limite KIE de {maximum // (1024 * 1024)} MB.")
605
+ duration = None
606
+ if kind == "vídeo":
607
+ ffprobe = shutil.which("ffprobe")
608
+ if not ffprobe:
609
+ raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control: instale FFmpeg/ffprobe e tente novamente.")
610
+ try:
611
+ probe = subprocess.run(
612
+ [ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(candidate)],
613
+ capture_output=True,
614
+ text=True,
615
+ timeout=30,
616
+ check=False,
617
+ )
618
+ duration = float(probe.stdout.strip())
619
+ except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
620
+ raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control com ffprobe.") from exc
621
+ if duration < 3 or duration > 30:
622
+ raise MediaGenerationError(f"O vídeo Motion Control deve ter entre 3 e 30 segundos; o ficheiro tem {duration:.1f}s.")
623
+ return {"path": str(candidate), "name": candidate.name, "size_bytes": size, "extension": suffix, "duration_seconds": duration}
624
+
625
+
626
+ def upload_kie_file(path: str | Path, card: Mapping[str, Any], *, upload_path: str = "thunderbolt/influencers") -> str:
627
+ """Upload one local file to KIE's temporary public file store using the selected card."""
628
+ current = _require_kie_card(card)
629
+ candidate = Path(path).expanduser()
630
+ if not candidate.is_file():
631
+ raise MediaGenerationError("O ficheiro seleccionado não está disponível para upload KIE.")
632
+ mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
633
+ try:
634
+ with candidate.open("rb") as handle:
635
+ response = requests.post(
636
+ KIE_FILE_UPLOAD_ENDPOINT,
637
+ headers={"Authorization": f"Bearer {_api_key(current)}"},
638
+ files={"file": (candidate.name, handle, mime)},
639
+ data={"uploadPath": upload_path, "fileName": candidate.name},
640
+ timeout=300,
641
+ )
642
+ except requests.RequestException as exc:
643
+ raise MediaGenerationError(f"Falha no upload temporário do ficheiro para KIE: {str(exc)[:220]}") from exc
644
+ if response.status_code >= 400:
645
+ raise MediaGenerationError(f"O upload temporário KIE devolveu HTTP {response.status_code}.")
646
+ try:
647
+ payload = response.json()
648
+ except ValueError as exc:
649
+ raise MediaGenerationError("O upload temporário KIE devolveu uma resposta inválida.") from exc
650
+ error = _kie_json_error(payload, fallback="O upload temporário KIE falhou.")
651
+ if error:
652
+ raise MediaGenerationError(str(error))
653
+ data = payload.get("data") if isinstance(payload, Mapping) else None
654
+ url = str((data or {}).get("downloadUrl") or (data or {}).get("fileUrl") or payload.get("downloadUrl") or payload.get("fileUrl") or "").strip() if isinstance(payload, Mapping) else ""
655
+ if not url.startswith(("http://", "https://")):
656
+ raise MediaGenerationError("O upload KIE terminou sem devolver um URL público temporário.")
657
+ return url
658
+
659
+
660
+ def _download_video_url(url: str, destination: Path, *, card: Mapping[str, Any] | None = None) -> Path:
661
+ try:
662
+ response = requests.get(url, headers=_headers(card or {}) if card else {}, timeout=300)
663
+ response.raise_for_status()
664
+ except requests.RequestException as exc:
665
+ raise MediaGenerationError(f"Não foi possível descarregar o vídeo gerado: {str(exc)[:220]}") from exc
666
+ if not response.content:
667
+ raise MediaGenerationError("O provider devolveu um vídeo vazio.")
668
+ destination.parent.mkdir(parents=True, exist_ok=True)
669
+ destination.write_bytes(response.content)
670
+ return destination
671
+
672
+
673
+ def _kie_result_urls(value: Any) -> list[str]:
674
+ if isinstance(value, str):
675
+ raw = value.strip()
676
+ try:
677
+ value = json.loads(raw)
678
+ except json.JSONDecodeError:
679
+ value = [raw] if raw.startswith(("http://", "https://")) else []
680
+ if isinstance(value, Mapping):
681
+ value = value.get("resultUrls") or value.get("result_urls") or value.get("urls") or []
682
+ if not isinstance(value, list):
683
+ return []
684
+ return [str(item).strip() for item in value if str(item).strip().startswith(("http://", "https://"))]
685
+
686
+
687
+ def _poll_kie_task(card: Mapping[str, Any], task_id: str, *, endpoint: str, attempts: int = 40, interval_seconds: float = 5.0, veo: bool = False) -> list[str]:
688
+ base = _base_url(card) or "https://api.kie.ai/api/v1"
689
+ url = f"{base}{endpoint}"
690
+ for index in range(max(1, attempts)):
691
+ try:
692
+ response = requests.get(url, headers=_headers(card), params={"taskId": task_id}, timeout=60)
693
+ if response.status_code >= 400:
694
+ category = "quota" if response.status_code == 429 else "transient" if response.status_code >= 500 else "endpoint_or_model"
695
+ raise ProviderCallError(f"Consulta KIE devolveu HTTP {response.status_code}.", status_code=response.status_code, category=category, retryable=category in {"quota", "transient"})
696
+ payload = response.json()
697
+ except requests.RequestException as exc:
698
+ raise ProviderCallError(f"Falha ao consultar a tarefa KIE: {str(exc)[:220]}", category="transient", retryable=True) from exc
699
+ error = _kie_json_error(payload, fallback="A consulta KIE devolveu um erro.")
700
+ if error:
701
+ raise error
702
+ data = payload.get("data") if isinstance(payload, Mapping) and isinstance(payload.get("data"), Mapping) else {}
703
+ if veo:
704
+ flag = data.get("successFlag")
705
+ if str(flag) in {"2", "3"}:
706
+ raise ProviderCallError(str(data.get("errorMessage") or payload.get("msg") or "A tarefa VEO falhou."), category="provider", retryable=False)
707
+ response_data = data.get("response") if isinstance(data.get("response"), Mapping) else {}
708
+ urls = _kie_result_urls(response_data.get("resultUrls") or data.get("resultUrls"))
709
+ if str(flag) == "1" and urls:
710
+ return urls
711
+ else:
712
+ state = str(data.get("state") or payload.get("state") or "").lower()
713
+ if state in {"fail", "failed", "error", "cancelled", "canceled"}:
714
+ raise ProviderCallError(str(data.get("failMsg") or payload.get("msg") or "A tarefa KIE falhou."), category="provider", retryable=False)
715
+ urls = _kie_result_urls(data.get("resultJson"))
716
+ if state == "success" and urls:
717
+ return urls
718
+ if index + 1 < attempts:
719
+ time.sleep(max(0.2, interval_seconds))
720
+ raise ProviderCallError("A tarefa KIE não concluiu dentro do limite de polling local.", category="transient", retryable=True)
721
+
722
+
723
+ def generate_motion_control_video(
724
+ settings: Mapping[str, Any],
725
+ card: Mapping[str, Any],
726
+ *,
727
+ image_url: str,
728
+ video_url: str,
729
+ prompt: str = "",
730
+ output_path: Path | None = None,
731
+ ) -> tuple[Path, str]:
732
+ """Create a Kling 2.6 Motion Control video and download it locally."""
733
+ current = _require_kie_card(card)
734
+ if not image_url.startswith(("http://", "https://")) or not video_url.startswith(("http://", "https://")):
735
+ raise MediaGenerationError("Motion Control requer URLs KIE acessíveis para a imagem e o vídeo enviados.")
736
+ clean_prompt = str(prompt or "").strip()
737
+ if len(clean_prompt) > 2500:
738
+ raise MediaGenerationError("O prompt Motion Control não pode exceder 2500 caracteres.")
739
+ base = _base_url(current) or "https://api.kie.ai/api/v1"
740
+ body: dict[str, Any] = {
741
+ "model": KIE_MOTION_MODEL,
742
+ "input": {
743
+ "prompt": clean_prompt or "Preserve a identidade visual da imagem de referência e aplique os movimentos do vídeo de forma natural, estável e fisicamente plausível.",
744
+ "input_urls": [image_url],
745
+ "video_urls": [video_url],
746
+ "character_orientation": "video",
747
+ "mode": "720p",
748
+ },
749
+ }
750
+
751
+ def request(_: dict[str, Any]) -> Any:
752
+ response = requests.post(f"{base}/jobs/createTask", headers=_headers(current), json=body, timeout=180)
753
+ if response.status_code < 400:
754
+ try:
755
+ payload = response.json()
756
+ except ValueError:
757
+ payload = {}
758
+ error = _kie_json_error(payload, fallback="A criação Motion Control KIE falhou.")
759
+ if error:
760
+ raise error
761
+ return response
762
+
763
+ try:
764
+ routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
765
+ data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
766
+ task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
767
+ if not task_id:
768
+ raise MediaGenerationError("KIE aceitou Motion Control mas não devolveu taskId.")
769
+ urls = _poll_kie_task(routed.card, task_id, endpoint="/jobs/recordInfo", veo=False)
770
+ except (ProviderRoutingError, ProviderCallError) as exc:
771
+ raise MediaGenerationError(str(exc)) from exc
772
+ destination = output_path or (STORAGE / "influencer_workflows" / f"motion-control-{task_id}.mp4")
773
+ return _download_video_url(urls[0], destination, card=routed.card), task_id
774
+
775
+
776
+ def generate_ugc_segment(
777
+ settings: Mapping[str, Any],
778
+ card: Mapping[str, Any],
779
+ *,
780
+ image_url: str,
781
+ prompt: str,
782
+ output_path: Path,
783
+ duration: int = 8,
784
+ ) -> tuple[Path, str]:
785
+ """Create one VEO3.1 image-to-video segment through the official KIE endpoint."""
786
+ current = _require_kie_card(card)
787
+ if not image_url.startswith(("http://", "https://")):
788
+ raise MediaGenerationError("UGC Products requer um URL KIE acessível para a imagem do produto.")
789
+ model = _model(current).lower() or "veo3_fast"
790
+ if model not in KIE_VEO_MODELS:
791
+ model = "veo3_fast"
792
+ if duration not in {4, 6, 8}:
793
+ raise MediaGenerationError("A duração de um segmento VEO3 deve ser 4, 6 ou 8 segundos.")
794
+ base = _base_url(current) or "https://api.kie.ai/api/v1"
795
+ body = {
796
+ "prompt": str(prompt or "").strip(),
797
+ "imageUrls": [image_url],
798
+ "model": model,
799
+ "aspect_ratio": "16:9",
800
+ "resolution": "720p",
801
+ "duration": duration,
802
+ }
803
+
804
+ def request(_: dict[str, Any]) -> Any:
805
+ response = requests.post(f"{base}/veo/generate", headers=_headers(current), json=body, timeout=180)
806
+ if response.status_code < 400:
807
+ try:
808
+ payload = response.json()
809
+ except ValueError:
810
+ payload = {}
811
+ error = _kie_json_error(payload, fallback="A criação de segmento VEO KIE falhou.")
812
+ if error:
813
+ raise error
814
+ return response
815
+
816
+ try:
817
+ routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
818
+ data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
819
+ task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
820
+ if not task_id:
821
+ raise MediaGenerationError("KIE aceitou VEO3 mas não devolveu taskId.")
822
+ urls = _poll_kie_task(routed.card, task_id, endpoint="/veo/record-info", veo=True)
823
+ except (ProviderRoutingError, ProviderCallError) as exc:
824
+ raise MediaGenerationError(str(exc)) from exc
825
+ return _download_video_url(urls[0], output_path, card=routed.card), task_id
826
+
827
+
828
+ def concatenate_video_files(paths: list[Path], output_path: Path) -> Path:
829
+ """Join generated clips locally with FFmpeg, without uploading the result anywhere."""
830
+ valid = [Path(path) for path in paths if Path(path).is_file()]
831
+ if not valid:
832
+ raise MediaGenerationError("Não existem segmentos locais para concatenar.")
833
+ if len(valid) == 1:
834
+ output_path.parent.mkdir(parents=True, exist_ok=True)
835
+ if valid[0] != output_path:
836
+ output_path.write_bytes(valid[0].read_bytes())
837
+ return output_path
838
+ try:
839
+ from .metadata_cleaner import _resolve_ffmpeg
840
+
841
+ ffmpeg = _resolve_ffmpeg()
842
+ except Exception as exc:
843
+ raise MediaGenerationError(str(exc)) from exc
844
+ output_path.parent.mkdir(parents=True, exist_ok=True)
845
+ descriptor, manifest_name = tempfile.mkstemp(prefix="ugc-concat-", suffix=".txt", dir=str(output_path.parent))
846
+ os.close(descriptor)
847
+ manifest = Path(manifest_name)
848
+ try:
849
+ manifest.write_text("\n".join(f"file '{str(path).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'" for path in valid) + "\n", encoding="utf-8")
850
+ output_path.parent.mkdir(parents=True, exist_ok=True)
851
+ command = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy", str(output_path)]
852
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False)
853
+ if completed.returncode != 0:
854
+ raise MediaGenerationError(f"FFmpeg não conseguiu juntar os segmentos UGC: {completed.stderr[-500:]}")
855
+ except OSError as exc:
856
+ raise MediaGenerationError(f"Não foi possível executar FFmpeg para juntar os segmentos UGC: {exc}") from exc
857
+ finally:
858
+ manifest.unlink(missing_ok=True)
859
+ return output_path
860
+
861
+
862
+ def generate_ugc_product_video(
863
+ settings: Mapping[str, Any],
864
+ card: Mapping[str, Any],
865
+ *,
866
+ image_url: str,
867
+ prompts: list[str],
868
+ output_path: Path | None = None,
869
+ ) -> tuple[Path, list[str]]:
870
+ """Generate two 8-second KIE VEO3 clips and concatenate them locally."""
871
+ clean_prompts = [str(item or "").strip() for item in prompts if str(item or "").strip()]
872
+ if len(clean_prompts) != 2:
873
+ raise MediaGenerationError("UGC Products requer exactamente dois prompts de segmento.")
874
+ ensure_storage()
875
+ destination = output_path or (STORAGE / "influencer_workflows" / f"ugc-products-{abs(hash((image_url, *clean_prompts))) & 0xffffffffffffffff:x}.mp4")
876
+ segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
877
+ task_ids: list[str] = []
878
+ for prompt, segment_path in zip(clean_prompts, segment_paths):
879
+ _, task_id = generate_ugc_segment(settings, card, image_url=image_url, prompt=prompt, output_path=segment_path, duration=8)
880
+ task_ids.append(task_id)
881
+ return concatenate_video_files(segment_paths, destination), task_ids
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.69",
3
+ "version": "0.3.71",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",