@danhachuel/thunderbolt 0.3.68 → 0.3.70

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.68
5
+ > **Versão deste manual:** 0.3.70
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)
@@ -128,13 +128,13 @@ Execute:
128
128
  Windows PowerShell ou MobaXterm:
129
129
 
130
130
  ```powershell
131
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.68 install
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install
132
132
  ```
133
133
 
134
134
  Linux/macOS:
135
135
 
136
136
  ```bash
137
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.68 install
137
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install
138
138
  ```
139
139
 
140
140
  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 +142,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
142
142
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
143
143
 
144
144
  ```powershell
145
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.68 install --purge-data
145
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.70 install --purge-data
146
146
  ```
147
147
 
148
148
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -391,21 +391,23 @@ Em **Configuração API > API Keys**, o expander **LLM — providers e modelos**
391
391
 
392
392
  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
393
 
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.
394
+ 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.
395
+
396
+ 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
397
 
396
398
  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
399
 
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.
400
+ 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
401
 
400
402
  ### API Tiktok — várias aplicações
401
403
 
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.
404
+ 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
405
 
404
406
  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
407
 
406
408
  Na criação de vídeo, abra **Configurações de áudio** e escolha **Upload** em **Modo de narração**. Use **Ficheiro de narração** para seleccionar o áudio, clique em **Guardar áudio de narração** e confirme a pré-visualização. O Thunderbolt valida que o ficheiro existe antes de criar a tarefa e encaminha o caminho ao MoneyPrinterTurbo com `--custom-audio-file`; este argumento é necessário porque o motor não persiste automaticamente o caminho carregado. São aceites ficheiros `.mp3`, `.wav`, `.m4a`, `.aac`, `.flac` e `.ogg`, guardados no storage local em `voiceovers`.
407
409
 
408
- O selector **Voiceover Service** oferece **Azure Speech SDK V2** e **Azure TTS V1**. Quando existe **Azure Speech key + região**, a opção V2 é preferida e o worker marca internamente a voz com `-V2`, activando o SDK Azure Speech e evitando o stream `edge_tts`. Tarefas antigas que ainda tenham `Azure TTS V1` guardado também são migradas para V2 quando essas credenciais estão configuradas. Sem credenciais Azure, o V1 continua disponível como fallback sem key; nesse caso o timeout interno do stream passa a 90 segundos para tolerar scripts longos e redes lentas. Um erro de TTS identifica explicitamente **Azure Speech / edge_tts API**, a etapa **Narração TTS** e os providers envolvidos.
410
+ O selector **Voiceover Service** oferece **Azure Speech SDK V2** e **Azure TTS V1**. Quando existe **Azure Speech key + região**, a opção V2 é preferida e o worker marca internamente a voz com `-V2`, activando o SDK Azure Speech e evitando o stream `edge_tts`. Para roteiros longos, o helper divide automaticamente o texto em segmentos seguros, sintetiza cada segmento com retry e concatena o MP3 antes de o entregar ao MoneyPrinterTurbo como `--custom-audio-file`; isto evita o limite de 10 minutos da síntese em tempo real documentado pela [Microsoft](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-quotas-and-limits). Tarefas antigas que ainda tenham `Azure TTS V1` guardado também são migradas para V2 quando essas credenciais estão configuradas. Sem credenciais Azure, o V1 continua disponível como fallback sem key; nesse caso o timeout interno do stream passa a 90 segundos para tolerar scripts longos e redes lentas. O erro `1007`/`600000ms` é atribuído explicitamente à **Azure Speech SDK V2 API**, à etapa **Narração TTS** e ao limite de duração.
409
411
 
410
412
  As notificações novas são verificadas automaticamente na sessão activa e aparecem como pop-ups no canto inferior direito, mesmo quando está aberta outra página. O pop-up não marca o registo como lido: a aba **Notificações** continua a ser o centro persistente para consultar o histórico, gerir preferências e marcar eventos como lidos. Cada ID é apresentado no máximo uma vez por sessão do navegador.
411
413
 
@@ -568,7 +570,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
568
570
 
569
571
  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
572
 
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.68 install`; o instalador detecta e reutiliza o que já estiver válido.
573
+ 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
574
 
573
575
  ### Niche Finder Apify
574
576
 
package/README.md CHANGED
@@ -44,7 +44,7 @@ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opç
44
44
 
45
45
  A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa o pool de vídeo configurável com **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen**, respeitando apenas cartões activos que declarem capacidade de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
46
46
 
47
- Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Os timeouts `azure_tts_v1`/`edge_tts` são identificados como **Azure Speech / edge_tts API**. Quando há Azure Speech key e região, o worker encaminha a voz para o SDK Azure Speech V2; sem essas credenciais, o fallback edge_tts usa um timeout interno de 90 segundos. O worker invoca o helper com `--` antes das flags MoneyPrinterTurbo, porque `mpt_agent.py` reserva esses argumentos para a CLI filha. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
47
+ Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Os timeouts `azure_tts_v1`/`edge_tts` são identificados como **Azure Speech / edge_tts API**. Quando há Azure Speech key e região, o worker encaminha a voz para o SDK Azure Speech V2. Para evitar o limite de 10 minutos da síntese em tempo real documentado pela [Microsoft](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-quotas-and-limits), o helper divide o roteiro em segmentos seguros, sintetiza-os sequencialmente com retry e concatena o MP3 antes de o entregar ao MoneyPrinterTurbo como áudio customizado. O erro `1007`/`600000ms` é atribuído explicitamente à **Azure Speech SDK V2 API**. Sem credenciais Azure, o fallback edge_tts usa um timeout interno de 90 segundos. O worker invoca o helper com `--` antes das flags MoneyPrinterTurbo, porque `mpt_agent.py` reserva esses argumentos para a CLI filha. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
48
48
 
49
49
  Na subaba **Configuração API > API Keys > Imagem e Video**, o selector **Provider de media** inclui a mesma lista do pool Full IA. O cartão **HeyGen** usa a API V3, apresenta os campos **Avatar ID** e **Voice ID**, valida a credencial com uma chamada read-only e participa no failover de vídeo quando estiver activo e configurado. Nano Banana e Hugging Face permanecem disponíveis no catálogo; a participação efectiva no pool de vídeo depende da capacidade declarada pelo cartão, para não encaminhar vídeo para um endpoint que apenas suporte imagem.
50
50
 
@@ -104,19 +104,19 @@ A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans`
104
104
 
105
105
  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
106
 
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.
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**, 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
108
 
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.
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, os expanders **Imagem e Video Montagem/MoviePy** e **Imagem e Video IA**, TikTok e Postiz.
110
110
 
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.
111
+ 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
112
 
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.
113
+ 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
114
 
115
115
  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
116
 
117
117
  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
118
 
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.
119
+ 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
120
 
121
121
  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
122
 
@@ -323,7 +323,7 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
323
323
  current = str(st.session_state.get("influencer_selected_id") or options[0])
324
324
  cards, provider_options = _provider_options(settings, "image")
325
325
  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.")
326
+ 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
327
  with st.form("influencer_image_content_form"):
328
328
  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
329
  assets = repository.list_assets(influencer_id)
@@ -371,7 +371,7 @@ def render_ai_influencer_content(settings: dict[str, Any]) -> None:
371
371
  current = str(st.session_state.get("influencer_selected_id") or options[0])
372
372
  cards, provider_options = _provider_options(settings, "video")
373
373
  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.")
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 IA.")
375
375
  with st.form("influencer_video_content_form"):
376
376
  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
377
  assets = repository.list_assets(influencer_id)
package/app/main.py CHANGED
@@ -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.")
@@ -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"),
@@ -308,6 +308,22 @@ def _provider_api_label(provider: str) -> str:
308
308
  return f"{provider_definition(code).label} API"
309
309
 
310
310
 
311
+ def _is_azure_long_audio_error(text: str) -> bool:
312
+ combined = str(text or "").casefold()
313
+ duration_marker = any(
314
+ marker in combined
315
+ for marker in (
316
+ "600000ms",
317
+ "600000 ms",
318
+ "maximum media duration",
319
+ "maximum audio length",
320
+ )
321
+ )
322
+ code_marker = any(marker in combined for marker in ("error code: 1007", "error code=1007", "code 1007"))
323
+ speech_marker = any(marker in combined for marker in ("azure", "speech synthesis", "speech sdk", "tts"))
324
+ return speech_marker and (duration_marker or code_marker)
325
+
326
+
311
327
  def _failure_attribution(
312
328
  task: dict[str, Any],
313
329
  settings: dict[str, Any],
@@ -324,6 +340,16 @@ def _failure_attribution(
324
340
  provider_code = str(markers["helper_provider"] or "").strip().casefold()
325
341
  combined = f"{output} {error}".casefold()
326
342
 
343
+ if stage == "video" and _is_azure_long_audio_error(combined):
344
+ return {
345
+ "failure_api": "Azure Speech SDK V2 API",
346
+ "failure_provider": "azure_speech",
347
+ "failure_service": "Narração TTS — limite de 600000 ms",
348
+ "failure_route": route,
349
+ "failure_config_fields": "",
350
+ "failure_stage": stage,
351
+ }
352
+
327
353
  if stage == "video" and any(marker in combined for marker in ("edge_tts", "edge tts", "azure_tts_v1", "azure speech")):
328
354
  return {
329
355
  "failure_api": "Azure Speech / edge_tts API",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.68",
3
+ "version": "0.3.70",
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",
@@ -0,0 +1,187 @@
1
+ """Generate long Azure Speech V2 audio by synthesizing safe sequential chunks.
2
+
3
+ This helper runs inside the MoneyPrinterTurbo uv project so it can use the
4
+ same Azure Speech SDK and audio dependencies as the installed engine. Secrets
5
+ are read only from environment variables and are never printed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import os
12
+ import re
13
+ import shutil
14
+ import sys
15
+ import time
16
+ from pathlib import Path
17
+ from tempfile import TemporaryDirectory
18
+ from xml.sax.saxutils import escape
19
+
20
+
21
+ MAX_CHUNK_CHARACTERS = 1800
22
+ RETRY_COUNT = 3
23
+
24
+
25
+ def _build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(description="Synthesize Azure Speech V2 audio in safe chunks.")
27
+ parser.add_argument("--text-file", type=Path, required=True)
28
+ parser.add_argument("--output", type=Path, required=True)
29
+ parser.add_argument("--voice", required=True)
30
+ parser.add_argument("--rate", type=float, default=1.0)
31
+ return parser
32
+
33
+
34
+ def _split_long_piece(piece: str, limit: int) -> list[str]:
35
+ piece = " ".join(piece.split())
36
+ if len(piece) <= limit:
37
+ return [piece]
38
+ words = piece.split(" ")
39
+ chunks: list[str] = []
40
+ current = ""
41
+ for word in words:
42
+ if len(word) > limit:
43
+ if current:
44
+ chunks.append(current)
45
+ current = ""
46
+ for start in range(0, len(word), limit):
47
+ chunks.append(word[start : start + limit])
48
+ continue
49
+ candidate = f"{current} {word}".strip()
50
+ if current and len(candidate) > limit:
51
+ chunks.append(current)
52
+ current = word
53
+ else:
54
+ current = candidate
55
+ if current:
56
+ chunks.append(current)
57
+ return chunks
58
+
59
+
60
+ def split_text(text: str, limit: int = MAX_CHUNK_CHARACTERS) -> list[str]:
61
+ """Split paragraphs and sentences without sending a 10-minute request."""
62
+ normalized = re.sub(r"\r\n?", "\n", text).strip()
63
+ if not normalized:
64
+ return []
65
+ pieces = [part.strip() for part in re.split(r"\n+|(?<=[.!?。!?;;])\s+", normalized) if part.strip()]
66
+ chunks: list[str] = []
67
+ current = ""
68
+ for piece in pieces:
69
+ for fragment in _split_long_piece(piece, limit):
70
+ candidate = f"{current} {fragment}".strip()
71
+ if current and len(candidate) > limit:
72
+ chunks.append(current)
73
+ current = fragment
74
+ else:
75
+ current = candidate
76
+ if current:
77
+ chunks.append(current)
78
+ return chunks
79
+
80
+
81
+ def _normalise_rate(value: float) -> float:
82
+ try:
83
+ rate = float(value)
84
+ except (TypeError, ValueError):
85
+ rate = 1.0
86
+ return max(0.25, min(4.0, rate))
87
+
88
+
89
+ def _build_ssml(text: str, voice: str, rate: float) -> str:
90
+ locale = "-".join(voice.split("-", 2)[:2]) if len(voice.split("-", 2)) >= 2 else "en-US"
91
+ return (
92
+ '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" '
93
+ f'xml:lang="{escape(locale)}">'
94
+ f'<voice name="{escape(voice, {"\"": "&quot;"})}">'
95
+ f'<prosody rate="{_normalise_rate(rate):g}">{escape(text)}</prosody>'
96
+ "</voice></speak>"
97
+ )
98
+
99
+
100
+ def _synthesise_chunk(speechsdk, text: str, voice: str, rate: float, target: Path) -> None:
101
+ speech_key = os.environ.get("AZURE_SPEECH_KEY", "").strip()
102
+ region = os.environ.get("AZURE_SPEECH_REGION", "").strip()
103
+ if not speech_key or not region:
104
+ raise RuntimeError("Azure Speech SDK V2 requer AZURE_SPEECH_KEY e AZURE_SPEECH_REGION.")
105
+ speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=region)
106
+ speech_config.speech_synthesis_voice_name = voice
107
+ speech_config.set_speech_synthesis_output_format(
108
+ speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
109
+ )
110
+ audio_config = speechsdk.audio.AudioOutputConfig(filename=str(target), use_default_speaker=False)
111
+ synthesizer = speechsdk.SpeechSynthesizer(audio_config=audio_config, speech_config=speech_config)
112
+ try:
113
+ result = synthesizer.speak_ssml_async(_build_ssml(text, voice, rate)).get()
114
+ finally:
115
+ synthesizer.close()
116
+ if result.reason != speechsdk.ResultReason.SynthesizingAudioCompleted:
117
+ details = getattr(getattr(result, "cancellation_details", None), "error_details", "")
118
+ reason = str(details or getattr(result, "reason", "unknown"))
119
+ raise RuntimeError(f"Azure Speech SDK V2 não concluiu um segmento: {reason[:500]}")
120
+ if not target.is_file() or target.stat().st_size <= 0:
121
+ raise RuntimeError("Azure Speech SDK V2 terminou sem produzir o áudio do segmento.")
122
+
123
+
124
+ def generate(text: str, voice: str, rate: float, output: Path) -> int:
125
+ import azure.cognitiveservices.speech as speechsdk
126
+ from pydub import AudioSegment
127
+
128
+ ffmpeg_binary = os.environ.get("IMAGEIO_FFMPEG_EXE", "").strip() or shutil.which("ffmpeg")
129
+ if not ffmpeg_binary:
130
+ try:
131
+ import imageio_ffmpeg
132
+
133
+ ffmpeg_binary = imageio_ffmpeg.get_ffmpeg_exe()
134
+ except Exception:
135
+ ffmpeg_binary = ""
136
+ if ffmpeg_binary:
137
+ AudioSegment.converter = ffmpeg_binary
138
+ else:
139
+ raise RuntimeError("FFmpeg não está disponível para concatenar os segmentos Azure Speech V2.")
140
+
141
+ chunks = split_text(text)
142
+ if not chunks:
143
+ raise RuntimeError("O roteiro não contém texto para síntese Azure Speech.")
144
+ output.parent.mkdir(parents=True, exist_ok=True)
145
+ with TemporaryDirectory(prefix="azure-v2-chunks-", dir=str(output.parent)) as temporary:
146
+ temporary_path = Path(temporary)
147
+ segment_paths: list[Path] = []
148
+ for index, chunk in enumerate(chunks, start=1):
149
+ segment_path = temporary_path / f"segment-{index:04d}.mp3"
150
+ last_error: Exception | None = None
151
+ for attempt in range(1, RETRY_COUNT + 1):
152
+ try:
153
+ _synthesise_chunk(speechsdk, chunk, voice, rate, segment_path)
154
+ last_error = None
155
+ break
156
+ except Exception as exc: # Azure SDK exposes provider-specific exception classes.
157
+ last_error = exc
158
+ if attempt < RETRY_COUNT:
159
+ time.sleep(2 ** (attempt - 1))
160
+ if last_error is not None:
161
+ raise RuntimeError(f"Falha no segmento Azure Speech {index}/{len(chunks)}: {last_error}") from last_error
162
+ segment_paths.append(segment_path)
163
+
164
+ combined = AudioSegment.empty()
165
+ for segment_path in segment_paths:
166
+ combined += AudioSegment.from_file(segment_path, format="mp3")
167
+ combined.export(output, format="mp3", bitrate="192k")
168
+ if not output.is_file() or output.stat().st_size <= 0:
169
+ raise RuntimeError("A concatenação Azure Speech V2 não produziu áudio válido.")
170
+ return len(chunks)
171
+
172
+
173
+ def main(argv: list[str] | None = None) -> int:
174
+ args = _build_parser().parse_args(argv)
175
+ text = args.text_file.read_text(encoding="utf-8")
176
+ try:
177
+ count = generate(text, args.voice, args.rate, args.output)
178
+ except Exception as exc:
179
+ print(f"Azure Speech V2 chunked synthesis failed: {exc}", file=sys.stderr)
180
+ return 1
181
+ print(f"AZURE_CHUNKED_AUDIO={args.output.resolve()}")
182
+ print(f"AZURE_CHUNK_COUNT={count}")
183
+ return 0
184
+
185
+
186
+ if __name__ == "__main__":
187
+ raise SystemExit(main())
@@ -11,6 +11,7 @@ import shutil
11
11
  import subprocess
12
12
  import sys
13
13
  import tempfile
14
+ import tomllib
14
15
  import urllib.error
15
16
  import urllib.request
16
17
  import uuid
@@ -299,6 +300,108 @@ def has_cli_option(cli_args: list[str], option: str) -> bool:
299
300
  return any(item == option or item.startswith(f"{option}=") for item in cli_args)
300
301
 
301
302
 
303
+ def _toml_section_value(config_path: Path, section: str, key: str) -> str:
304
+ """Read one nested TOML value without logging the value or its contents."""
305
+ try:
306
+ payload = tomllib.loads(config_path.read_text(encoding="utf-8"))
307
+ except (OSError, tomllib.TOMLDecodeError):
308
+ return ""
309
+ section_payload = payload.get(section)
310
+ if not isinstance(section_payload, dict):
311
+ return ""
312
+ value = section_payload.get(key, "")
313
+ return str(value or "").strip()
314
+
315
+
316
+ def _forwarded_option_value(cli_args: list[str], option: str) -> str:
317
+ for index, item in enumerate(cli_args):
318
+ if item == option and index + 1 < len(cli_args):
319
+ return str(cli_args[index + 1]).strip()
320
+ if item.startswith(f"{option}="):
321
+ return item.split("=", 1)[1].strip()
322
+ return ""
323
+
324
+
325
+ def _azure_v2_voice(cli_args: list[str]) -> str:
326
+ """Return the unmarked Azure voice when this invocation requests Azure V2."""
327
+ voice = _forwarded_option_value(cli_args, "--voice-name")
328
+ if "-V2-" not in voice and not voice.endswith("-V2"):
329
+ return ""
330
+ base_voice = re.sub(r"-V2(?=-|$)", "", voice, count=1).strip()
331
+ return re.sub(r"-(?:Female|Male)$", "", base_voice, flags=re.IGNORECASE).strip()
332
+
333
+
334
+ def _voice_rate(cli_args: list[str]) -> float:
335
+ value = _forwarded_option_value(cli_args, "--voice-rate")
336
+ try:
337
+ return float(value) if value else 1.0
338
+ except ValueError:
339
+ return 1.0
340
+
341
+
342
+ def _prepare_azure_v2_chunked_audio(
343
+ root: Path,
344
+ config_path: Path,
345
+ task_dir: Path,
346
+ cli_args: list[str],
347
+ uv: str,
348
+ ) -> Path | None:
349
+ """Create task-local audio in chunks before MPT starts its normal pipeline."""
350
+ voice = _azure_v2_voice(cli_args)
351
+ text = _forwarded_option_value(cli_args, "--video-script")
352
+ if not voice or not text.strip():
353
+ return None
354
+ speech_key = _toml_section_value(config_path, "azure", "speech_key")
355
+ speech_region = _toml_section_value(config_path, "azure", "speech_region")
356
+ if not speech_key or not speech_region:
357
+ raise SkillError("Azure Speech SDK V2 foi seleccionado, mas as credenciais não estão completas.")
358
+ text_file = task_dir / "azure-v2-script.txt"
359
+ audio_file = task_dir / "azure-v2-audio.mp3"
360
+ text_file.parent.mkdir(parents=True, exist_ok=True)
361
+ text_file.write_text(text.strip(), encoding="utf-8")
362
+ chunk_script = Path(__file__).with_name("azure_tts_chunked.py")
363
+ command = [
364
+ uv,
365
+ "run",
366
+ "--project",
367
+ str(root),
368
+ "python",
369
+ str(chunk_script),
370
+ "--text-file",
371
+ str(text_file),
372
+ "--output",
373
+ str(audio_file),
374
+ "--voice",
375
+ voice,
376
+ "--rate",
377
+ str(_voice_rate(cli_args)),
378
+ ]
379
+ environment = os.environ.copy()
380
+ environment["AZURE_SPEECH_KEY"] = speech_key
381
+ environment["AZURE_SPEECH_REGION"] = speech_region
382
+ ffmpeg_path = _toml_section_value(config_path, "app", "ffmpeg_path")
383
+ if ffmpeg_path:
384
+ environment["IMAGEIO_FFMPEG_EXE"] = ffmpeg_path
385
+ log("synthesizing Azure Speech V2 in safe chunks before MoneyPrinterTurbo")
386
+ result = subprocess.run(
387
+ command,
388
+ cwd=root,
389
+ env=environment,
390
+ stdout=subprocess.PIPE,
391
+ stderr=subprocess.STDOUT,
392
+ text=True,
393
+ errors="replace",
394
+ check=False,
395
+ )
396
+ if result.returncode != 0 or not audio_file.is_file() or audio_file.stat().st_size <= 0:
397
+ detail = "\n".join((result.stdout or "").splitlines()[-12:]).strip()
398
+ raise SkillError(
399
+ "Azure Speech SDK V2 falhou na síntese segmentada. "
400
+ + (detail or "O helper não devolveu detalhes.")
401
+ )
402
+ return audio_file
403
+
404
+
302
405
  def missing_config(config_path: Path, cli_args: list[str]) -> tuple[str, list[str]]:
303
406
  """Return the active provider and only the fields required by this run."""
304
407
  text = config_path.read_text(encoding="utf-8")
@@ -493,9 +596,11 @@ def generate_video(
493
596
  if not uv:
494
597
  raise SkillError("uv was not found; reopen the terminal or add uv to PATH")
495
598
  run_checked([uv, "sync", "--frozen"], cwd=root)
599
+ config_path = ensure_config(root)
496
600
 
497
601
  task_id = str(uuid.uuid4())
498
602
  task_dir = root / "storage" / "tasks" / task_id
603
+ task_dir.mkdir(parents=True, exist_ok=True)
499
604
  log_dir = root / ".agent-logs" / "moneyprinterturbo-video"
500
605
  log_dir.mkdir(parents=True, exist_ok=True)
501
606
  log_path = log_dir / f"run-{task_id}.log"
@@ -515,12 +620,23 @@ def generate_video(
515
620
  if has_cli_option(cli_args, "--voice-name")
516
621
  else ["--voice-name", DEFAULT_VOICE_NAME]
517
622
  )
623
+ forwarded_args = list(cli_args)
624
+ if not has_cli_option(forwarded_args, "--custom-audio-file"):
625
+ chunked_audio = _prepare_azure_v2_chunked_audio(
626
+ root,
627
+ config_path,
628
+ task_dir,
629
+ forwarded_args,
630
+ uv,
631
+ )
632
+ if chunked_audio is not None:
633
+ forwarded_args.extend(["--custom-audio-file", str(chunked_audio)])
518
634
  command = [
519
635
  uv,
520
636
  "run",
521
637
  "python",
522
638
  "cli.py",
523
- *cli_args,
639
+ *forwarded_args,
524
640
  "--video-subject",
525
641
  subject,
526
642
  "--task-id",