@danhachuel/thunderbolt 0.3.67 → 0.3.69

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.67
5
+ > **Versão deste manual:** 0.3.69
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -31,9 +31,9 @@ O MoneyPrinterTurbo declara Python 3.11 ou superior como requisito e documenta a
31
31
 
32
32
  A área **AI Influencers > Personagens** permite criar personagens com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e ficheiros `.md`/`.json`. O campo **Idioma** é um selector e reutiliza exactamente a mesma lista, os códigos e os labels visuais de **Pipeline Vídeos > Criação de Vídeos**. No uploader, seleccione vários ficheiros de uma vez; cada imagem pode ser pré-visualizada e cada documento é validado como UTF-8, Markdown ou JSON válido. Os assets são deduplicados por SHA-256 e não são gravados como base64 dentro das tabelas.
33
33
 
34
- O backend local predefinido é **SQLite**, pelo que Personagens e Geração de Conteúdo IA podem ser usados sem credenciais externas. Abra **Configurações > Configuração API > AI Influencers > Banco de Dados Influencers** para confirmar **SQLite** ou seleccionar **Supabase**. Se seleccionar Supabase, preencha **Supabase Project URL**, **Supabase API key** e **Supabase Storage bucket**, clique em **Testar ligação do backend**, aplique primeiro `seed/references/ai_influencers_schema.sql` no SQL Editor do projecto, exponha as quatro tabelas na Data API e configure permissões/RLS adequadas. O bucket deve existir com o nome configurado, por defeito `ai-influencers`.
34
+ O backend local predefinido é **SQLite**, pelo que Personagens e Geração de Conteúdo IA podem ser usados sem credenciais externas. Em **Configurações > Configuração API > AI Influencers**, o selector **Backend da base de dados de AI Influencers** fica imediatamente abaixo da frase de estado. O card foi renomeado para **Supabase** e contém apenas **Supabase Project URL** e **Supabase API key**. Se seleccionar Supabase mas faltar qualquer uma dessas credenciais, o **Backend activo** permanece automaticamente em SQLite; só muda para Supabase quando ambas estão configuradas. Clique em **Testar ligação do backend** e, para uma base remota, aplique primeiro `seed/references/ai_influencers_schema.sql` no SQL Editor do projecto, exponha as quatro tabelas na Data API e configure permissões/RLS adequadas.
35
35
 
36
- Se pretender trabalhar sem serviço externo, seleccione **SQLite** no selector da aba **Configuração API > AI Influencers** e indique o caminho do ficheiro. O padrão é `storage/state/ai_influencers.db`; o SQLite cria o schema e guarda os assets em `storage/influencers/`. Apenas um backend é usado de cada vez. A aba **AI Influencers** mostra o estado e permite editar directamente o selector, os campos Supabase e o caminho SQLite. 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.
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
38
  Em **AI Influencers > Geração de Conteúdo IA**, utilize as subabas **Imagens**, **Vídeos** e **Motion Control**. Seleccione um personagem, prompt e destinos sociais. A subaba **Imagens** usa o pool de imagem configurado em **Imagem e Video**. A subaba **Vídeos** requer uma imagem inicial e usa o pool de vídeo; escolha o provider/modelo no cartão configurado, sem dependência obrigatória do Veo 3.1. KIE AI, Replicate e FAL AI podem usar tarefas assíncronas; o Thunderbolt consulta o estado e guarda o resultado local antes da revisão. Na Replicate, o campo Modelo deve ser o identificador do modelo ou da versão, e os inputs específicos dependem do modelo configurado.
39
39
 
@@ -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.67 install
131
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.69 install
132
132
  ```
133
133
 
134
134
  Linux/macOS:
135
135
 
136
136
  ```bash
137
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.67 install
137
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.69 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.67 install --purge-data
145
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.69 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.
@@ -405,7 +405,7 @@ O botão de teste é read-only. Sem um token OAuth TikTok já autorizado, o Thun
405
405
 
406
406
  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
407
 
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.
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`. 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
409
 
410
410
  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
411
 
@@ -568,7 +568,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
568
568
 
569
569
  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
570
 
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.67 install`; o instalador detecta e reutiliza o que já estiver válido.
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.
572
572
 
573
573
  ### Niche Finder Apify
574
574
 
package/README.md CHANGED
@@ -32,7 +32,7 @@ A primeira versão implementa a camada UI independente com:
32
32
 
33
33
  A área **AI Influencers > Personagens** permite guardar um personagem com nome, biografia, idioma, Instagram Business ID opcional, várias imagens de referência e documentos `.md` ou `.json`. O campo **Idioma** é um selector que reutiliza exactamente a lista, os códigos e os labels visuais de **Pipeline Vídeos > Criação de Vídeos**. Cada asset é validado, sanitizado, deduplicado por SHA-256 e guardado separado do perfil; imagens têm pré-visualização e documentos são mostrados como Markdown ou JSON estruturado.
34
34
 
35
- O backend local predefinido é **SQLite**, por isso Personagens e Geração de Conteúdo IA funcionam imediatamente sem credenciais externas. Em **Configurações > Configuração API > AI Influencers > Banco de Dados Influencers**, pode manter **SQLite** ou seleccionar **Supabase** quando quiser uma base remota; nesse caso, configure o **Supabase Project URL**, a **Supabase API key** e o bucket de Storage, aplique `seed/references/ai_influencers_schema.sql` no SQL Editor e confirme Data API/RLS. SQLite cria localmente `storage/state/ai_influencers.db` e `storage/influencers/`; apenas um backend fica activo de cada vez. A aba **AI Influencers** mostra o estado actual e permite editar directamente o selector, as credenciais Supabase, o bucket de Storage e o caminho do SQLite. O botão **Testar ligação do backend** é read-only e não bloqueia a utilização local. 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.
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
37
  A página **AI Influencers > Geração de Conteúdo IA** contém as subabas **Imagens**, **Vídeos** e **Motion Control**. Imagens usam os cartões activos do **Pool Imagem**; Vídeos usam os cartões activos do **Pool Vídeo** e exigem uma imagem inicial image-to-video. O selector de modelo não está preso ao Veo 3.1: pode utilizar KIE AI, Replicate, FAL AI, Pollinations ou outro cartão que declare suporte para vídeo. Para Replicate, o campo Modelo deve conter o identificador aceito pela API, como `owner/model` ou `owner/model:version`; a tarefa usa `POST /v1/predictions`, consulta o estado assíncrono e guarda o resultado localmente. A publicação para Instagram, TikTok, YouTube Shorts ou Facebook não é automática: os destinos são registados para revisão e o envio exige uma acção posterior explícita.
38
38
 
@@ -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
 
@@ -103,9 +103,7 @@ def _provider_label(card: Mapping[str, Any]) -> str:
103
103
 
104
104
 
105
105
  def render_ai_influencers_api_status(settings: dict[str, Any]) -> None:
106
- """Show backend status; editable selector and credentials are rendered by the settings page."""
107
- st.subheader("AI Influencers")
108
- st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA. O selector e as credenciais são editados nesta aba, em Banco de Dados Influencers.")
106
+ """Show the effective backend status after selector and credentials are loaded."""
109
107
  status = backend_status(settings)
110
108
  cols = st.columns(3)
111
109
  with cols[0]:
@@ -118,7 +116,7 @@ def render_ai_influencers_api_status(settings: dict[str, Any]) -> None:
118
116
  st.info(f"{status['message']} Destino: `{status['target']}`")
119
117
  else:
120
118
  st.warning(status["message"])
121
- st.markdown("A migração SQL idempotente está disponível em `seed/references/ai_influencers_schema.sql`. No Supabase, aplique-a no SQL Editor e confirme as políticas RLS e o bucket configurado.")
119
+ st.markdown("A migração SQL idempotente está disponível em `seed/references/ai_influencers_schema.sql`. No Supabase, aplique-a no SQL Editor e confirme as políticas RLS.")
122
120
  if st.button("Testar backend AI Influencers", key="influencers_api_status_test"):
123
121
  result = test_backend(settings)
124
122
  if result.get("ok"):
package/app/main.py CHANGED
@@ -5762,34 +5762,36 @@ def render_settings():
5762
5762
  render_material_source_api_keys(settings)
5763
5763
 
5764
5764
  with ai_influencers_tab:
5765
- render_ai_influencers_api_status(settings)
5766
- st.divider()
5767
- st.subheader("Banco de Dados Influencers")
5768
- st.caption("Escolha o backend usado por Personagens e Geração de Conteúdo IA. SQLite local funciona sem credenciais externas; Supabase é opcional. Se Supabase estiver seleccionado mas ainda incompleto, o sistema mantém SQLite como fallback operacional.")
5769
- with st.form("influencer_database_settings_form"):
5770
- saved_backend = str(settings.get("influencer_db_backend") or "SQLite").strip()
5771
- backend_index = list(BACKEND_OPTIONS).index(saved_backend) if saved_backend in BACKEND_OPTIONS else list(BACKEND_OPTIONS).index("SQLite")
5772
- influencer_db_backend = st.selectbox(
5773
- "Backend da base de dados de AI Influencers",
5774
- list(BACKEND_OPTIONS),
5775
- index=backend_index,
5776
- key="settings_influencer_db_backend",
5777
- help="Seleccione Supabase para usar a base remota ou SQLite para guardar tudo localmente.",
5778
- )
5779
- db_cols = st.columns(2)
5780
- with db_cols[0]:
5781
- influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
5782
- influencer_supabase_key = text_setting("Supabase API key", "influencer_supabase_key", secret=True, help_text="Use uma chave com as permissões RLS adequadas. Nunca é colocada no GitHub ou nos logs.")
5783
- influencer_supabase_bucket = text_setting("Supabase Storage bucket", "influencer_supabase_bucket", help_text="Bucket usado para imagens e documentos, por defeito ai-influencers.")
5784
- with db_cols[1]:
5785
- influencer_sqlite_path = text_setting("SQLite ficheiro local", "influencer_sqlite_path", help_text="Caminho absoluto ou relativo ao projecto. Por defeito: storage/state/ai_influencers.db")
5786
- st.caption("SQLite usa a biblioteca standard do Python e cria o schema automaticamente ao testar ou usar o backend.")
5787
- test_backend_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True)
5788
- save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", use_container_width=True)
5765
+ st.subheader("AI Influencers")
5766
+ 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.")
5767
+ saved_backend = str(settings.get("influencer_db_backend") or "SQLite").strip()
5768
+ backend_index = list(BACKEND_OPTIONS).index(saved_backend) if saved_backend in BACKEND_OPTIONS else list(BACKEND_OPTIONS).index("SQLite")
5769
+ influencer_db_backend = st.selectbox(
5770
+ "Backend da base de dados de AI Influencers",
5771
+ list(BACKEND_OPTIONS),
5772
+ index=backend_index,
5773
+ key="settings_influencer_db_backend",
5774
+ help="Seleccione Supabase para usar a base remota ou SQLite para guardar tudo localmente.",
5775
+ )
5776
+ with st.container(border=True):
5777
+ st.subheader("Supabase")
5778
+ st.caption("Configure apenas os dados da ligação Supabase. Se o selector estiver em Supabase mas faltar qualquer credencial, o backend activo permanece SQLite.")
5779
+ with st.form("influencer_database_settings_form"):
5780
+ db_cols = st.columns(2)
5781
+ with db_cols[0]:
5782
+ influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
5783
+ with db_cols[1]:
5784
+ influencer_supabase_key = text_setting("Supabase API key", "influencer_supabase_key", secret=True, help_text="Use uma chave com as permissões RLS adequadas. Nunca é colocada no GitHub ou nos logs.")
5785
+ test_backend_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True)
5786
+ save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", use_container_width=True)
5787
+ effective_settings = dict(settings)
5788
+ effective_settings.update({
5789
+ "influencer_db_backend": influencer_db_backend,
5790
+ "influencer_supabase_url": influencer_supabase_url,
5791
+ "influencer_supabase_key": influencer_supabase_key,
5792
+ })
5789
5793
  if test_backend_clicked:
5790
- test_settings = dict(settings)
5791
- test_settings.update({"influencer_db_backend": influencer_db_backend, "influencer_supabase_url": influencer_supabase_url, "influencer_supabase_key": influencer_supabase_key, "influencer_supabase_bucket": influencer_supabase_bucket, "influencer_sqlite_path": influencer_sqlite_path})
5792
- db_result = test_backend(test_settings)
5794
+ db_result = test_backend(effective_settings)
5793
5795
  if db_result.get("ok"):
5794
5796
  st.success(db_result.get("message") or "Backend disponível.")
5795
5797
  else:
@@ -5799,12 +5801,11 @@ def render_settings():
5799
5801
  "influencer_db_backend": influencer_db_backend,
5800
5802
  "influencer_supabase_url": influencer_supabase_url.strip(),
5801
5803
  "influencer_supabase_key": influencer_supabase_key.strip(),
5802
- "influencer_supabase_bucket": influencer_supabase_bucket.strip() or "ai-influencers",
5803
- "influencer_sqlite_path": influencer_sqlite_path.strip() or "storage/state/ai_influencers.db",
5804
5804
  })
5805
5805
  write_json("settings.json", settings)
5806
5806
  st.success("Configuração do backend AI Influencers guardada.")
5807
5807
  st.rerun()
5808
+ render_ai_influencers_api_status(effective_settings)
5808
5809
 
5809
5810
  with voice_test_tab:
5810
5811
  st.subheader("Teste de Voz")
@@ -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.67",
3
+ "version": "0.3.69",
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",