@danhachuel/thunderbolt 0.3.76 → 0.3.78

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.76
5
+ > **Versão deste manual:** 0.3.78
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)
@@ -132,13 +132,13 @@ Execute:
132
132
  Windows PowerShell ou MobaXterm:
133
133
 
134
134
  ```powershell
135
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.76 install
135
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.77 install
136
136
  ```
137
137
 
138
138
  Linux/macOS:
139
139
 
140
140
  ```bash
141
- npx --yes --prefer-online @danhachuel/thunderbolt@0.3.76 install
141
+ npx --yes --prefer-online @danhachuel/thunderbolt@0.3.77 install
142
142
  ```
143
143
 
144
144
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -146,7 +146,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
146
146
  Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
147
147
 
148
148
  ```powershell
149
- npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.76 install --purge-data
149
+ npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.77 install --purge-data
150
150
  ```
151
151
 
152
152
  O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
@@ -574,7 +574,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
574
574
 
575
575
  Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
576
576
 
577
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.76 install`; o instalador detecta e reutiliza o que já estiver válido.
577
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.77 install`; o instalador detecta e reutiliza o que já estiver válido.
578
578
 
579
579
  ### Niche Finder Apify
580
580
 
@@ -811,3 +811,15 @@ O worker local guarda a etapa actual e os artefactos de cada tarefa. Ao reinicia
811
811
 
812
812
  ### Selector de modelos LLM
813
813
  No card **OpenAI / NVIDIA NIM**, o campo **Modelo** é uma lista suspensa. Use **Consultar modelos** para actualizar as opções do endpoint; se o identificador não estiver disponível, escolha **Escrever modelo manualmente**.
814
+
815
+ ## 16. Diagnóstico prioritário do Azure Speech V2
816
+
817
+ Quando Azure Speech SDK V2 estiver seleccionado, o Thunderbolt prepara a narração em segmentos antes de iniciar o MoneyPrinterTurbo. O tamanho interno dos segmentos é reduzido automaticamente para velocidades de fala lentas, o áudio final é validado e a CLI recebe `--custom-audio-file`. A geração é bloqueada se não houver confirmação dos segmentos; isto evita regressar à chamada monolítica do Azure que pode falhar com `maximum media duration of 600000ms`.
818
+
819
+ Na interface, uma tarefa em execução mostra a última actividade do helper e o tempo decorrido da etapa Vídeo. Se ocorrer uma falha, consulte o caminho `LOG_FILE` ou `RESULT_FILE` apresentado no erro. A linha `updated configuration fields` é apenas sincronização inicial de configuração e não é a causa terminal. Nunca copie chaves, cookies, tokens ou o conteúdo integral do roteiro para pedidos de suporte.
820
+
821
+ ## 17. Quando o card mostra apenas o início da falha de vídeo
822
+
823
+ A partir da versão 0.3.78, o card **Backlog Vídeos** mostra a causa terminal e disponibiliza **Ver diagnóstico completo**. As mensagens `Pexels key validation completed`, `installing or verifying project dependencies with uv` e `TASK_DIR` são apenas progresso ou metadados; não são, por si só, uma falha.
824
+
825
+ Se o `uv` falhar, o erro inclui o detalhe seguro do resolver. Se a CLI do MoneyPrinterTurbo falhar, o resumo inclui `MPT_ERROR` e mantém `Log completo` e `Manifesto`. A interface não exibe chaves, cookies ou tokens. O tempo limite da verificação de dependências é de 15 minutos; a etapa é encerrada em vez de ficar indefinidamente sem resposta.
package/README.md CHANGED
@@ -107,7 +107,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
107
107
 
108
108
  A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
109
109
 
110
- ## Navegação da UI 0.3.76
110
+ ## Navegação da UI 0.3.77
111
111
 
112
112
  A barra lateral apresenta os níveis principais nesta ordem: **Início**, **Automação**, **Niche Finder**, **Canais/Perfis (Vídeos)**, **Pipeline Vídeos**, **Pipeline Música**, **AI Influencers**, **Edição**, **Growth**, **Documentação** e **Configurações**. **Canais/Perfis (Vídeos)** é expansível e contém **Canais YouTube**, **Blueprints Youtube**, **Contas TikTok**, **Prompt Masters** e **Facebook Pages**, nessa ordem. O menu **Arquivos Base** foi removido por ficar vazio.
113
113
  **Pipeline Vídeos** contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**; **Pipeline Música** contém **Criação de Músicas** e **Upload Música**; **Automação** contém **Automação Youtube**; **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**; **AI Influencers** contém **Personagens**, **Geração de Conteúdo IA**, **Motion Control**, **UGC Products** e **Redes Sociais**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
@@ -426,3 +426,15 @@ A pipeline do worker usa agora um orquestrador local em cascata, com artefactos
426
426
 
427
427
  ## Selector de modelos LLM
428
428
  O campo **Modelo** em **Configuração API > API Keys > LLM — providers e modelos** é apresentado como lista suspensa permanente. A lista usa os modelos descobertos pelo endpoint e preserva o modelo guardado; a opção manual continua disponível apenas como fallback explícito.
429
+
430
+ ## Correcção prioritária de geração de vídeo
431
+
432
+ No fluxo MoneyPrinterTurbo, a voz Azure Speech SDK V2 é preparada antes da CLI upstream. O Thunderbolt divide o roteiro em segmentos conservadores ajustados à velocidade, concatena o áudio localmente e só inicia o motor depois de confirmar o ficheiro e a contagem de segmentos. Se essa preparação falhar, a tarefa é interrompida e não existe fallback silencioso para uma chamada monolítica sujeita ao limite de 600000 ms.
433
+
434
+ Durante a execução, o backlog mostra a actividade recente do helper e o tempo da etapa Vídeo. Em caso de falha, a mensagem terminal é extraída do output accionável, enquanto as linhas `using existing project` e `updated configuration fields` deixam de ser tratadas como causa. Os caminhos do log e do manifesto são preservados mesmo quando a falha ocorre antes da criação do MP4.
435
+
436
+ ## Correcção de diagnóstico da etapa Vídeo — 0.3.78
437
+
438
+ A mensagem do card de vídeo deixou de mostrar apenas os primeiros 240 caracteres. A UI apresenta um resumo de erro com até 700 caracteres e disponibiliza um expander não editável com o diagnóstico completo, incluindo os caminhos do log e do manifesto quando existem. Linhas de sucesso de Pexels, sincronização do projecto, verificação do `uv` e marcadores de caminho são excluídas do resumo terminal.
439
+
440
+ A verificação `uv sync --frozen` tem agora timeout controlado, detalhe seguro do resolver em caso de falha e confirmação explícita quando termina. O estado do manifesto é marcado como `failed` mesmo quando a falha acontece antes de a CLI do MoneyPrinterTurbo iniciar.
package/app/main.py CHANGED
@@ -3582,8 +3582,25 @@ def _render_video_task_state(task: dict[str, Any]) -> None:
3582
3582
  st.write(state or "—")
3583
3583
  st.caption(VIDEO_TASK_STATE_LABELS.get(state, state.replace("_", " ").capitalize() or "Desconhecido"))
3584
3584
  st.progress(progress, text=f"{progress}%")
3585
+ helper_status = str(task.get("video_helper_status") or "").strip()
3586
+ if state == "doing" and helper_status:
3587
+ st.caption(f"Actividade: {helper_status[-240:]}")
3588
+ if state == "doing" and task.get("video_elapsed_seconds") is not None:
3589
+ try:
3590
+ elapsed_seconds = max(0, int(task.get("video_elapsed_seconds") or 0))
3591
+ st.caption(f"Tempo da etapa Vídeo: {elapsed_seconds // 60}m {elapsed_seconds % 60:02d}s")
3592
+ except (TypeError, ValueError):
3593
+ pass
3585
3594
  if task.get("error"):
3586
- st.caption(str(task.get("error"))[:240])
3595
+ error_text = str(task.get("error") or "").strip()
3596
+ st.error(error_text[:700])
3597
+ if len(error_text) > 700 or task.get("video_log") or task.get("video_result"):
3598
+ with st.expander("Ver diagnóstico completo", expanded=False):
3599
+ st.code(error_text[:6000])
3600
+ if task.get("video_log"):
3601
+ st.caption(f"Log completo: {task.get('video_log')}")
3602
+ if task.get("video_result"):
3603
+ st.caption(f"Manifesto: {task.get('video_result')}")
3587
3604
 
3588
3605
 
3589
3606
  def render_videos():
@@ -310,12 +310,23 @@ def _helper_output_value(output: str, key: str) -> str:
310
310
 
311
311
 
312
312
  def _redact_helper_output(text: str) -> str:
313
- for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY", "MPT_PIXABAY_API_KEY"):
313
+ for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY", "MPT_PIXABAY_API_KEY", "AZURE_SPEECH_KEY", "AZURE_SPEECH_REGION"):
314
314
  secret = os.environ.get(key, "").strip()
315
315
  if secret:
316
316
  text = text.replace(secret, "[redacted]")
317
317
  return text
318
318
 
319
+ def _terminal_helper_detail(output: str) -> str:
320
+ """Return concise actionable failure lines, never startup or path markers."""
321
+ raw_lines = [_redact_helper_output(line).strip() for line in str(output or "").splitlines()]
322
+ lines = [line for line in raw_lines if line]
323
+ metadata_prefixes = ("TASK_DIR=", "LOG_FILE=", "RESULT_FILE=", "VIDEO_FILE=")
324
+ noise_markers = ("using existing project:", "updated configuration fields:", "starting video generation, task id:", "installing or verifying project dependencies with uv", "pexels key validation completed:")
325
+ useful = [line for line in lines if not line.startswith(metadata_prefixes) and not any(marker in line.casefold() for marker in noise_markers)]
326
+ failure_markers = ("mpt_error=", "traceback", "exception", "error", "failed", "falhou", "failure", "missing", "invalid", "timeout", "timed out", "not found", "exceeded", "excedeu")
327
+ failures = [line for line in useful if any(marker in line.casefold() for marker in failure_markers)]
328
+ return "\n".join((failures or useful)[-8:]).strip()
329
+
319
330
 
320
331
  def _helper_failure_markers(output: str) -> dict[str, Any]:
321
332
  missing = [item.strip() for item in re.findall(r"(?m)^MISSING=(.+)$", output) if item.strip()]
@@ -378,6 +389,16 @@ def _failure_attribution(
378
389
  "failure_stage": stage,
379
390
  }
380
391
 
392
+ if stage == "video" and any(marker in combined for marker in ("azure speech sdk v2", "azure speech v2", "azure_tts_v2")):
393
+ return {
394
+ "failure_api": "Azure Speech SDK V2 API",
395
+ "failure_provider": "azure_speech",
396
+ "failure_service": "Narração TTS — segmentação Azure",
397
+ "failure_route": route,
398
+ "failure_config_fields": "",
399
+ "failure_stage": stage,
400
+ }
401
+
381
402
  if stage == "video" and any(marker in combined for marker in ("edge_tts", "edge tts", "azure_tts_v1", "azure speech")):
382
403
  return {
383
404
  "failure_api": "Azure Speech / edge_tts API",
@@ -504,12 +525,13 @@ def _failure_message(message: str, metadata: dict[str, Any]) -> str:
504
525
 
505
526
 
506
527
  def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, str]:
507
- """Persist only bounded helper diagnostics and return its declared file paths."""
528
+ """Persist bounded helper diagnostics and a safe terminal summary."""
508
529
  task_id = str(task.get("id") or "").strip()
509
530
  if not task_id:
510
531
  return {}
511
532
  log_file = _helper_output_value(output, "LOG_FILE")
512
533
  result_file = _helper_output_value(output, "RESULT_FILE")
534
+ terminal_detail = _terminal_helper_detail(output)
513
535
  try:
514
536
  markers = _helper_failure_markers(output)
515
537
  payload: dict[str, Any] = {
@@ -517,6 +539,7 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
517
539
  "log_file": log_file,
518
540
  "result_file": result_file,
519
541
  "output_tail": _redact_helper_output(output[-6000:]),
542
+ "terminal_detail": terminal_detail,
520
543
  "helper_provider": markers["helper_provider"],
521
544
  "missing_fields": markers["missing_fields"],
522
545
  "invalid_fields": markers["invalid_fields"],
@@ -525,7 +548,10 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
525
548
  current = _task_by_id(task_id) or task
526
549
  artifacts = dict(current.get("artifacts") or {})
527
550
  artifacts["video_diagnostics"] = artifact_path
528
- updates: dict[str, Any] = {"artifacts": artifacts}
551
+ updates: dict[str, Any] = {
552
+ "artifacts": artifacts,
553
+ "video_diagnostic_summary": terminal_detail,
554
+ }
529
555
  if log_file:
530
556
  updates["video_log"] = log_file
531
557
  artifacts["video_log"] = log_file
@@ -707,10 +733,13 @@ def _moneyprinter_cli_args(task: dict[str, Any], route: str, settings: dict[str,
707
733
  args.append("--match-materials-to-script")
708
734
 
709
735
  voice_mode = str(generation_settings.get("voiceover_mode") or "").strip().casefold()
736
+ azure_service = str(generation_settings.get("voiceover_service") or "").strip().casefold()
710
737
  voice = str(task.get("voice") or generation_settings.get("voice") or "").strip()
711
738
  if voice_mode == "none" or voice_mode == "upload":
712
739
  args.extend(["--voice-name", "no-voice"])
713
- elif voice:
740
+ elif voice or azure_service in {"azure speech sdk v2", "azure speech", "azure tts v2", "azure speech sdk"}:
741
+ if not voice:
742
+ voice = "en-US-JennyNeural"
714
743
  if _uses_azure_speech_sdk_v2(generation_settings, settings):
715
744
  voice = _azure_speech_v2_voice_name(voice)
716
745
  args.extend(["--voice-name", voice])
@@ -847,6 +876,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
847
876
  reader.start()
848
877
  output_finished = False
849
878
  last_heartbeat = 0.0
879
+ last_output_line = ""
850
880
  try:
851
881
  while True:
852
882
  try:
@@ -854,6 +884,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
854
884
  if line is None:
855
885
  output_finished = True
856
886
  elif line:
887
+ last_output_line = _redact_helper_output(line).strip()
857
888
  output_lines.append(line)
858
889
  except queue.Empty:
859
890
  pass
@@ -870,6 +901,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
870
901
  _update(
871
902
  task_id,
872
903
  progress=video_progress,
904
+ video_helper_status=last_output_line[-500:] if last_output_line else "",
873
905
  video_elapsed_seconds=int(elapsed),
874
906
  )
875
907
  _worker_heartbeat(
@@ -877,6 +909,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
877
909
  status="running",
878
910
  stage="video",
879
911
  progress=video_progress,
912
+ video_helper_status=last_output_line[-500:] if last_output_line else "",
880
913
  video_elapsed_seconds=int(elapsed),
881
914
  )
882
915
  last_heartbeat = elapsed
@@ -897,10 +930,13 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
897
930
  _persist_video_diagnostics(task, output)
898
931
  if result_code == 10:
899
932
  metadata = _failure_attribution(task, settings, "video", output=output)
933
+ detail = _terminal_helper_detail(output)
900
934
  message = "A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo"
935
+ if detail:
936
+ message += f". Detalhe do helper: {detail}"
901
937
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
902
938
  if result_code != 0:
903
- detail = _redact_helper_output(output[-1200:]).strip() or "erro sem detalhes devolvidos pelo helper"
939
+ detail = _terminal_helper_detail(output) or "erro sem detalhes devolvidos pelo helper"
904
940
  metadata = _failure_attribution(task, settings, "video", error=detail, output=output)
905
941
  message = f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}"
906
942
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from pathlib import Path
4
+ import os
5
+ import tempfile
4
6
  from typing import Any
5
7
 
6
8
  from hermes_ui.languages import language_code
@@ -177,6 +179,28 @@ def build_moneyprinter_config(settings: dict[str, Any], existing: dict[str, Any]
177
179
  return config
178
180
 
179
181
 
182
+ def _atomic_write_text(target: Path, text: str) -> None:
183
+ """Replace TOML atomically so a failed write cannot truncate the prior file."""
184
+ temporary_path: Path | None = None
185
+ try:
186
+ with tempfile.NamedTemporaryFile(
187
+ mode="w", encoding="utf-8", dir=str(target.parent),
188
+ prefix=f".{target.name}.", suffix=".tmp", delete=False,
189
+ ) as handle:
190
+ temporary_path = Path(handle.name)
191
+ handle.write(text)
192
+ handle.flush()
193
+ os.fsync(handle.fileno())
194
+ os.replace(temporary_path, target)
195
+ except Exception:
196
+ if temporary_path is not None:
197
+ try:
198
+ temporary_path.unlink(missing_ok=True)
199
+ except OSError:
200
+ pass
201
+ raise
202
+
203
+
180
204
  def sync_moneyprinter_config(settings: dict[str, Any], moneyprinter_path: str) -> Path | None:
181
205
  if not moneyprinter_path or toml is None:
182
206
  return None
@@ -191,5 +215,5 @@ def sync_moneyprinter_config(settings: dict[str, Any], moneyprinter_path: str) -
191
215
  except Exception:
192
216
  existing = {}
193
217
  payload = build_moneyprinter_config(settings, existing)
194
- target.write_text(toml.dumps(payload), encoding="utf-8")
218
+ _atomic_write_text(target, toml.dumps(payload))
195
219
  return target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.76",
3
+ "version": "0.3.78",
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",
@@ -1,10 +1,10 @@
1
- """Generate long Azure Speech V2 audio by synthesizing safe sequential chunks.
1
+ """Generate long Azure Speech V2 audio through conservative sequential chunks.
2
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.
3
+ The Azure real-time TTS endpoint limits the produced audio of one request to
4
+ 10 minutes. This helper deliberately stays far below that limit, adjusts the
5
+ text budget for slow voices, and concatenates the completed segments locally.
6
+ Secrets are read only from environment variables and are never printed.
6
7
  """
7
-
8
8
  from __future__ import annotations
9
9
 
10
10
  import argparse
@@ -18,12 +18,19 @@ from tempfile import TemporaryDirectory
18
18
  from xml.sax.saxutils import escape
19
19
 
20
20
 
21
- MAX_CHUNK_CHARACTERS = 1800
21
+ # Azure documents a 600000 ms maximum for real-time TTS. This is an internal
22
+ # character budget, not a claim that characters map to a fixed duration. The
23
+ # deliberately conservative ceiling leaves room for slow voices, pauses and
24
+ # punctuation before the service limit can be approached.
25
+ MAX_CHUNK_CHARACTERS = 900
26
+ MIN_CHUNK_CHARACTERS = 180
22
27
  RETRY_COUNT = 3
23
28
 
24
29
 
25
30
  def _build_parser() -> argparse.ArgumentParser:
26
- parser = argparse.ArgumentParser(description="Synthesize Azure Speech V2 audio in safe chunks.")
31
+ parser = argparse.ArgumentParser(
32
+ description="Synthesize Azure Speech V2 audio in conservative chunks."
33
+ )
27
34
  parser.add_argument("--text-file", type=Path, required=True)
28
35
  parser.add_argument("--output", type=Path, required=True)
29
36
  parser.add_argument("--voice", required=True)
@@ -57,18 +64,27 @@ def _split_long_piece(piece: str, limit: int) -> list[str]:
57
64
  return chunks
58
65
 
59
66
 
60
- def split_text(text: str, limit: int = MAX_CHUNK_CHARACTERS) -> list[str]:
61
- """Split paragraphs and sentences without sending a 10-minute request."""
67
+ def split_text(text: str, limit: int | None = None) -> list[str]:
68
+ """Split paragraphs and sentences without sending an oversized request."""
69
+ try:
70
+ effective_limit = int(limit if limit is not None else MAX_CHUNK_CHARACTERS)
71
+ except (TypeError, ValueError):
72
+ effective_limit = MAX_CHUNK_CHARACTERS
73
+ effective_limit = max(1, effective_limit)
62
74
  normalized = re.sub(r"\r\n?", "\n", text).strip()
63
75
  if not normalized:
64
76
  return []
65
- pieces = [part.strip() for part in re.split(r"\n+|(?<=[.!?。!?;;])\s+", normalized) if part.strip()]
77
+ pieces = [
78
+ part.strip()
79
+ for part in re.split(r"\n+|(?<=[.!?。!?;;])\s+", normalized)
80
+ if part.strip()
81
+ ]
66
82
  chunks: list[str] = []
67
83
  current = ""
68
84
  for piece in pieces:
69
- for fragment in _split_long_piece(piece, limit):
85
+ for fragment in _split_long_piece(piece, effective_limit):
70
86
  candidate = f"{current} {fragment}".strip()
71
- if current and len(candidate) > limit:
87
+ if current and len(candidate) > effective_limit:
72
88
  chunks.append(current)
73
89
  current = fragment
74
90
  else:
@@ -86,8 +102,20 @@ def _normalise_rate(value: float) -> float:
86
102
  return max(0.25, min(4.0, rate))
87
103
 
88
104
 
105
+ def chunk_character_limit(rate: float) -> int:
106
+ """Return a conservative text budget adjusted to the requested speech rate."""
107
+ normalized_rate = _normalise_rate(rate)
108
+ # A slow rate stretches the audio, so reduce the text budget proportionally.
109
+ # A fast rate is capped: the service limit is not a reason to send huge SSML.
110
+ return max(
111
+ MIN_CHUNK_CHARACTERS,
112
+ min(MAX_CHUNK_CHARACTERS, int(round(MAX_CHUNK_CHARACTERS * normalized_rate))),
113
+ )
114
+
115
+
89
116
  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"
117
+ parts = voice.split("-", 2)
118
+ locale = "-".join(parts[:2]) if len(parts) >= 2 else "en-US"
91
119
  return (
92
120
  '<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" '
93
121
  f'xml:lang="{escape(locale)}">'
@@ -97,35 +125,51 @@ def _build_ssml(text: str, voice: str, rate: float) -> str:
97
125
  )
98
126
 
99
127
 
100
- def _synthesise_chunk(speechsdk, text: str, voice: str, rate: float, target: Path) -> None:
128
+ def _synthesise_chunk(
129
+ speechsdk, text: str, voice: str, rate: float, target: Path
130
+ ) -> None:
101
131
  speech_key = os.environ.get("AZURE_SPEECH_KEY", "").strip()
102
132
  region = os.environ.get("AZURE_SPEECH_REGION", "").strip()
103
133
  if not speech_key or not region:
104
- raise RuntimeError("Azure Speech SDK V2 requer AZURE_SPEECH_KEY e AZURE_SPEECH_REGION.")
134
+ raise RuntimeError(
135
+ "Azure Speech SDK V2 requer AZURE_SPEECH_KEY e AZURE_SPEECH_REGION."
136
+ )
105
137
  speech_config = speechsdk.SpeechConfig(subscription=speech_key, region=region)
106
138
  speech_config.speech_synthesis_voice_name = voice
107
139
  speech_config.set_speech_synthesis_output_format(
108
140
  speechsdk.SpeechSynthesisOutputFormat.Audio48Khz192KBitRateMonoMp3
109
141
  )
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)
142
+ audio_config = speechsdk.audio.AudioOutputConfig(
143
+ filename=str(target), use_default_speaker=False
144
+ )
145
+ synthesizer = speechsdk.SpeechSynthesizer(
146
+ audio_config=audio_config, speech_config=speech_config
147
+ )
112
148
  try:
113
149
  result = synthesizer.speak_ssml_async(_build_ssml(text, voice, rate)).get()
114
150
  finally:
115
151
  synthesizer.close()
116
152
  if result.reason != speechsdk.ResultReason.SynthesizingAudioCompleted:
117
- details = getattr(getattr(result, "cancellation_details", None), "error_details", "")
153
+ details = getattr(
154
+ getattr(result, "cancellation_details", None), "error_details", ""
155
+ )
118
156
  reason = str(details or getattr(result, "reason", "unknown"))
119
- raise RuntimeError(f"Azure Speech SDK V2 não concluiu um segmento: {reason[:500]}")
157
+ raise RuntimeError(
158
+ f"Azure Speech SDK V2 não concluiu um segmento: {reason[:500]}"
159
+ )
120
160
  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.")
161
+ raise RuntimeError(
162
+ "Azure Speech SDK V2 terminou sem produzir o áudio do segmento."
163
+ )
122
164
 
123
165
 
124
166
  def generate(text: str, voice: str, rate: float, output: Path) -> int:
125
167
  import azure.cognitiveservices.speech as speechsdk
126
168
  from pydub import AudioSegment
127
169
 
128
- ffmpeg_binary = os.environ.get("IMAGEIO_FFMPEG_EXE", "").strip() or shutil.which("ffmpeg")
170
+ ffmpeg_binary = os.environ.get("IMAGEIO_FFMPEG_EXE", "").strip() or shutil.which(
171
+ "ffmpeg"
172
+ )
129
173
  if not ffmpeg_binary:
130
174
  try:
131
175
  import imageio_ffmpeg
@@ -133,16 +177,21 @@ def generate(text: str, voice: str, rate: float, output: Path) -> int:
133
177
  ffmpeg_binary = imageio_ffmpeg.get_ffmpeg_exe()
134
178
  except Exception:
135
179
  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)
180
+ if not ffmpeg_binary:
181
+ raise RuntimeError(
182
+ "FFmpeg não está disponível para concatenar os segmentos Azure Speech V2."
183
+ )
184
+ AudioSegment.converter = ffmpeg_binary
185
+
186
+ normalized_rate = _normalise_rate(rate)
187
+ limit = chunk_character_limit(normalized_rate)
188
+ chunks = split_text(text, limit=limit)
142
189
  if not chunks:
143
190
  raise RuntimeError("O roteiro não contém texto para síntese Azure Speech.")
144
191
  output.parent.mkdir(parents=True, exist_ok=True)
145
- with TemporaryDirectory(prefix="azure-v2-chunks-", dir=str(output.parent)) as temporary:
192
+ with TemporaryDirectory(
193
+ prefix="azure-v2-chunks-", dir=str(output.parent)
194
+ ) as temporary:
146
195
  temporary_path = Path(temporary)
147
196
  segment_paths: list[Path] = []
148
197
  for index, chunk in enumerate(chunks, start=1):
@@ -150,21 +199,28 @@ def generate(text: str, voice: str, rate: float, output: Path) -> int:
150
199
  last_error: Exception | None = None
151
200
  for attempt in range(1, RETRY_COUNT + 1):
152
201
  try:
153
- _synthesise_chunk(speechsdk, chunk, voice, rate, segment_path)
202
+ segment_path.unlink(missing_ok=True)
203
+ _synthesise_chunk(
204
+ speechsdk, chunk, voice, normalized_rate, segment_path
205
+ )
154
206
  last_error = None
155
207
  break
156
- except Exception as exc: # Azure SDK exposes provider-specific exception classes.
208
+ except Exception as exc: # Azure SDK exposes provider-specific classes.
157
209
  last_error = exc
158
210
  if attempt < RETRY_COUNT:
159
211
  time.sleep(2 ** (attempt - 1))
160
212
  if last_error is not None:
161
- raise RuntimeError(f"Falha no segmento Azure Speech {index}/{len(chunks)}: {last_error}") from last_error
213
+ raise RuntimeError(
214
+ f"Falha no segmento Azure Speech {index}/{len(chunks)}: {last_error}"
215
+ ) from last_error
162
216
  segment_paths.append(segment_path)
217
+ print(f"AZURE_CHUNK_PROGRESS={index}/{len(chunks)}", flush=True)
163
218
 
164
219
  combined = AudioSegment.empty()
165
220
  for segment_path in segment_paths:
166
221
  combined += AudioSegment.from_file(segment_path, format="mp3")
167
222
  combined.export(output, format="mp3", bitrate="192k")
223
+
168
224
  if not output.is_file() or output.stat().st_size <= 0:
169
225
  raise RuntimeError("A concatenação Azure Speech V2 não produziu áudio válido.")
170
226
  return len(chunks)
@@ -180,6 +236,7 @@ def main(argv: list[str] | None = None) -> int:
180
236
  return 1
181
237
  print(f"AZURE_CHUNKED_AUDIO={args.output.resolve()}")
182
238
  print(f"AZURE_CHUNK_COUNT={count}")
239
+ print(f"AZURE_CHUNK_CHARACTER_LIMIT={chunk_character_limit(args.rate)}")
183
240
  return 0
184
241
 
185
242
 
@@ -24,6 +24,8 @@ PROJECT_ARCHIVE_URL = (
24
24
  "https://github.com/harry0703/MoneyPrinterTurbo/archive/refs/heads/main.zip"
25
25
  )
26
26
  DEFAULT_ROOT = Path.home() / "MoneyPrinterTurbo"
27
+ CHUNKED_SYNTHESIS_TIMEOUT_SECONDS = 15 * 60
28
+ DEPENDENCY_SYNC_TIMEOUT_SECONDS = 15 * 60
27
29
  DEFAULT_VOICE_NAME = "zh-CN-XiaoxiaoNeural-Female"
28
30
  NEEDS_INPUT_EXIT_CODE = 10
29
31
  SUPPORTED_SOURCES = {"pexels", "pixabay", "coverr", "local"}
@@ -72,6 +74,33 @@ def log(message: str) -> None:
72
74
  print(f"[MoneyPrinterTurbo] {message}", flush=True)
73
75
 
74
76
 
77
+ def _atomic_write_text(path: Path, text: str) -> None:
78
+ """Replace a text file atomically so an interrupted Windows write keeps the old file."""
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ temporary_path: Path | None = None
81
+ try:
82
+ with tempfile.NamedTemporaryFile(
83
+ mode="w",
84
+ encoding="utf-8",
85
+ dir=str(path.parent),
86
+ prefix=f".{path.name}.",
87
+ suffix=".tmp",
88
+ delete=False,
89
+ ) as handle:
90
+ temporary_path = Path(handle.name)
91
+ handle.write(text)
92
+ handle.flush()
93
+ os.fsync(handle.fileno())
94
+ os.replace(temporary_path, path)
95
+ except Exception:
96
+ if temporary_path is not None:
97
+ try:
98
+ temporary_path.unlink(missing_ok=True)
99
+ except OSError:
100
+ pass
101
+ raise
102
+
103
+
75
104
  def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
76
105
  parser = argparse.ArgumentParser(
77
106
  description="Install MoneyPrinterTurbo and generate a final video from a topic."
@@ -239,7 +268,7 @@ def apply_environment_config(config_path: Path) -> None:
239
268
  if pixabay_keys:
240
269
  text = _replace_config_value(text, "pixabay_api_keys", pixabay_keys)
241
270
  changes.append("pixabay_api_keys")
242
- config_path.write_text(text, encoding="utf-8")
271
+ _atomic_write_text(config_path, text)
243
272
  log("updated configuration fields: " + ", ".join(changes))
244
273
 
245
274
 
@@ -279,7 +308,7 @@ def reuse_existing_llm_provider(config_path: Path) -> str:
279
308
  for provider in reusable_providers:
280
309
  if _provider_is_ready(text, provider):
281
310
  text = _replace_config_value(text, "llm_provider", provider)
282
- config_path.write_text(text, encoding="utf-8")
311
+ _atomic_write_text(config_path, text)
283
312
  log(f"reusing configured LLM provider: {provider}")
284
313
  return provider
285
314
  return current_provider
@@ -325,12 +354,10 @@ def _forwarded_option_value(cli_args: list[str], option: str) -> str:
325
354
  def _azure_v2_voice(cli_args: list[str]) -> str:
326
355
  """Return the unmarked Azure voice when this invocation requests Azure V2."""
327
356
  voice = _forwarded_option_value(cli_args, "--voice-name")
328
- if "-V2-" not in voice and not voice.endswith("-V2"):
357
+ if "-v2" not in voice.casefold():
329
358
  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
-
359
+ base_voice = re.sub("-v2", "", voice, count=1, flags=re.IGNORECASE).strip()
360
+ return re.sub(r"-(Female|Male)$", "", base_voice, flags=re.IGNORECASE).strip()
334
361
  def _voice_rate(cli_args: list[str]) -> float:
335
362
  value = _forwarded_option_value(cli_args, "--voice-rate")
336
363
  try:
@@ -358,7 +385,7 @@ def _prepare_azure_v2_chunked_audio(
358
385
  text_file = task_dir / "azure-v2-script.txt"
359
386
  audio_file = task_dir / "azure-v2-audio.mp3"
360
387
  text_file.parent.mkdir(parents=True, exist_ok=True)
361
- text_file.write_text(text.strip(), encoding="utf-8")
388
+ _atomic_write_text(text_file, text.strip() + "\n")
362
389
  chunk_script = Path(__file__).with_name("azure_tts_chunked.py")
363
390
  command = [
364
391
  uv,
@@ -382,24 +409,55 @@ def _prepare_azure_v2_chunked_audio(
382
409
  ffmpeg_path = _toml_section_value(config_path, "app", "ffmpeg_path")
383
410
  if ffmpeg_path:
384
411
  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
- )
412
+ try:
413
+ result = subprocess.run(
414
+ command,
415
+ cwd=root,
416
+ env=environment,
417
+ stdout=subprocess.PIPE,
418
+ stderr=subprocess.STDOUT,
419
+ text=True,
420
+ errors="replace",
421
+ check=False,
422
+ timeout=CHUNKED_SYNTHESIS_TIMEOUT_SECONDS,
423
+ )
424
+ except subprocess.TimeoutExpired as exc:
425
+ raise SkillError(
426
+ "A síntese segmentada Azure Speech SDK V2 excedeu 15 minutos; "
427
+ "a chamada monolítica não será usada."
428
+ ) from exc
429
+ chunk_match = re.search(r"(?m)^AZURE_CHUNK_COUNT=(\d+)$", result.stdout or "")
430
+ if not chunk_match:
431
+ detail = "\n".join((result.stdout or "").splitlines()[-12:]).strip()
432
+ detail = detail.replace(speech_key, "[redacted]").replace(speech_region, "[redacted]")
433
+ raise SkillError(
434
+ "Azure Speech SDK V2 não confirmou segmentos e áudio customizado. "
435
+ + (detail or "O helper não devolveu detalhes.")
436
+ )
396
437
  if result.returncode != 0 or not audio_file.is_file() or audio_file.stat().st_size <= 0:
397
438
  detail = "\n".join((result.stdout or "").splitlines()[-12:]).strip()
439
+ detail = detail.replace(speech_key, "[redacted]").replace(speech_region, "[redacted]")
398
440
  raise SkillError(
399
441
  "Azure Speech SDK V2 falhou na síntese segmentada. "
400
442
  + (detail or "O helper não devolveu detalhes.")
401
443
  )
402
- return audio_file
444
+ chunk_count = int(chunk_match.group(1))
445
+ if chunk_count <= 0:
446
+ raise SkillError("Azure Speech SDK V2 não confirmou segmentos de áudio válidos.")
447
+ _atomic_write_text(
448
+ audio_file.with_suffix(".json"),
449
+ json.dumps(
450
+ {
451
+ "status": "completed",
452
+ "audio_file": str(audio_file.resolve()),
453
+ "chunk_count": chunk_count,
454
+ },
455
+ ensure_ascii=False,
456
+ indent=2,
457
+ ) + "\n",
458
+ )
459
+ log(f"Azure Speech V2 segmentado: {chunk_count} segmentos; áudio customizado preparado")
460
+ return audio_file.resolve()
403
461
 
404
462
 
405
463
  def missing_config(config_path: Path, cli_args: list[str]) -> tuple[str, list[str]]:
@@ -524,7 +582,7 @@ def validate_pexels_config(config_path: Path, cli_args: list[str]) -> bool:
524
582
  if valid_keys:
525
583
  if valid_keys != keys:
526
584
  text = _replace_config_value(text, "pexels_api_keys", valid_keys)
527
- config_path.write_text(text, encoding="utf-8")
585
+ _atomic_write_text(config_path, text)
528
586
  log(
529
587
  "Pexels key validation completed: "
530
588
  f"valid={len(valid_keys)}, rejected={rejected_count}, "
@@ -566,24 +624,37 @@ def write_result_manifest(root: Path, payload: dict[str, object]) -> Path:
566
624
  temp_path.replace(result_path)
567
625
  return result_path.resolve()
568
626
 
569
-
570
627
  def run_checked(command: list[str], *, cwd: Path) -> None:
571
- """Run dependency sync quietly and show only the last 30 lines on failure."""
628
+ """Run dependency sync with a bounded wait and safe failure output."""
572
629
  log("installing or verifying project dependencies with uv")
573
- result = subprocess.run(
574
- command,
575
- cwd=cwd,
576
- stdout=subprocess.PIPE,
577
- stderr=subprocess.STDOUT,
578
- text=True,
579
- errors="replace",
580
- check=False,
581
- )
630
+ try:
631
+ result = subprocess.run(
632
+ command,
633
+ cwd=cwd,
634
+ stdout=subprocess.PIPE,
635
+ stderr=subprocess.STDOUT,
636
+ text=True,
637
+ errors="replace",
638
+ check=False,
639
+ timeout=DEPENDENCY_SYNC_TIMEOUT_SECONDS,
640
+ )
641
+ except subprocess.TimeoutExpired as exc:
642
+ raise SkillError(
643
+ "A verificação das dependências com uv excedeu 15 minutos; "
644
+ "a execução foi interrompida."
645
+ ) from exc
646
+ output = result.stdout or ""
647
+ for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY", "MPT_PIXABAY_API_KEY", "AZURE_SPEECH_KEY", "AZURE_SPEECH_REGION"):
648
+ secret = os.environ.get(key, "").strip()
649
+ if secret:
650
+ output = output.replace(secret, "[redacted]")
582
651
  if result.returncode != 0:
583
- output_tail = (result.stdout or "").splitlines()[-30:]
584
- if output_tail:
585
- print("\n".join(output_tail), file=sys.stderr)
586
- raise SkillError(f"dependency installation failed with exit code {result.returncode}")
652
+ output_tail = "\n".join(output.splitlines()[-30:]).strip()
653
+ raise SkillError(
654
+ f"dependency installation failed with exit code {result.returncode}. "
655
+ + (f"Detalhe do uv: {output_tail}" if output_tail else "O uv não devolveu detalhes.")
656
+ )
657
+ log("project dependencies verified with uv")
587
658
 
588
659
 
589
660
  def generate_video(
@@ -621,7 +692,9 @@ def generate_video(
621
692
  else ["--voice-name", DEFAULT_VOICE_NAME]
622
693
  )
623
694
  forwarded_args = list(cli_args)
624
- if not has_cli_option(forwarded_args, "--custom-audio-file"):
695
+ azure_v2_requested = bool(_azure_v2_voice(forwarded_args))
696
+ custom_audio_requested = has_cli_option(forwarded_args, "--custom-audio-file")
697
+ if azure_v2_requested and not custom_audio_requested:
625
698
  chunked_audio = _prepare_azure_v2_chunked_audio(
626
699
  root,
627
700
  config_path,
@@ -629,8 +702,20 @@ def generate_video(
629
702
  forwarded_args,
630
703
  uv,
631
704
  )
632
- if chunked_audio is not None:
633
- forwarded_args.extend(["--custom-audio-file", str(chunked_audio)])
705
+ if chunked_audio is None or not chunked_audio.is_file() or chunked_audio.stat().st_size <= 0:
706
+ raise SkillError(
707
+ "Azure Speech SDK V2 não produziu áudio segmentado; "
708
+ "a geração foi interrompida para não usar a chamada monolítica."
709
+ )
710
+ forwarded_args.extend(["--custom-audio-file", str(chunked_audio.resolve())])
711
+ elif azure_v2_requested and custom_audio_requested:
712
+ custom_audio_value = _forwarded_option_value(forwarded_args, "--custom-audio-file")
713
+ custom_audio_path = Path(custom_audio_value).expanduser()
714
+ if not custom_audio_value or not custom_audio_path.is_file() or custom_audio_path.stat().st_size <= 0:
715
+ raise SkillError(
716
+ "Azure Speech SDK V2 recebeu um áudio customizado inválido; "
717
+ "a geração foi interrompida para não usar a chamada monolítica."
718
+ )
634
719
  command = [
635
720
  uv,
636
721
  "run",
@@ -750,6 +835,18 @@ def main(argv: list[str] | None = None) -> int:
750
835
  root, args.subject, args.cli_args
751
836
  )
752
837
  except (OSError, SkillError, urllib.error.URLError, zipfile.BadZipFile) as exc:
838
+ manifest_path = result_manifest_path(root)
839
+ try:
840
+ manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) if manifest_path.is_file() else {}
841
+ except (OSError, json.JSONDecodeError):
842
+ manifest_payload = {}
843
+ if manifest_payload.get("status") == "running":
844
+ manifest_payload.update({"status": "failed", "error": str(exc)[:1000]})
845
+ write_result_manifest(root, manifest_payload)
846
+ for output_key, manifest_key in (("TASK_DIR", "task_dir"), ("LOG_FILE", "log_file"), ("RESULT_FILE", "result_file")):
847
+ value = str(manifest_payload.get(manifest_key) or "").strip()
848
+ if value:
849
+ print(f"{output_key}={value}", file=sys.stderr)
753
850
  print(f"MPT_ERROR={exc}", file=sys.stderr)
754
851
  return 1
755
852