@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.
- package/MANUAL-INSTALACAO.md +8 -8
- package/README.md +2 -2
- package/app/influencers_ui.py +2 -4
- package/app/main.py +30 -29
- package/hermes_ui/pipeline_worker.py +26 -0
- package/package.json +1 -1
- package/seed/skills/azure_tts_chunked.py +187 -0
- package/seed/skills/mpt_agent.py +117 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.3.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
package/app/influencers_ui.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
5766
|
-
st.
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
5770
|
-
|
|
5771
|
-
|
|
5772
|
-
|
|
5773
|
-
|
|
5774
|
-
|
|
5775
|
-
|
|
5776
|
-
|
|
5777
|
-
|
|
5778
|
-
)
|
|
5779
|
-
|
|
5780
|
-
|
|
5781
|
-
|
|
5782
|
-
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
st.
|
|
5787
|
-
|
|
5788
|
-
|
|
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
|
-
|
|
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
|
@@ -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, {"\"": """})}">'
|
|
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())
|
package/seed/skills/mpt_agent.py
CHANGED
|
@@ -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
|
-
*
|
|
639
|
+
*forwarded_args,
|
|
524
640
|
"--video-subject",
|
|
525
641
|
subject,
|
|
526
642
|
"--task-id",
|