@danhachuel/thunderbolt 0.3.68 → 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.68
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)
@@ -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.69 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.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.68 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.68 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
@@ -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
 
@@ -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.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",