@danhachuel/thunderbolt 0.3.81 → 0.3.83

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.
@@ -4,6 +4,7 @@ import json
4
4
  import os
5
5
  import queue
6
6
  import re
7
+ import signal
7
8
  import subprocess
8
9
  import threading
9
10
  import time
@@ -27,6 +28,7 @@ from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_th
27
28
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
28
29
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
29
30
  VIDEO_TIMEOUT_SECONDS = 20 * 60
31
+ LONG_STOCK_VIDEO_TIMEOUT_SECONDS = 45 * 60
30
32
  STALE_TASK_SECONDS = VIDEO_TIMEOUT_SECONDS + 5 * 60
31
33
  WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
32
34
  CASCADE_STAGE_ORDER = ("topic", "script", "title", "keywords", "video", "thumbnail_prompt", "thumbnail", "upload")
@@ -184,13 +186,14 @@ def _recover_stale_tasks() -> list[str]:
184
186
  if not updated_at:
185
187
  continue
186
188
  age_seconds = (current_time - updated_at.astimezone(timezone.utc)).total_seconds()
187
- if age_seconds <= STALE_TASK_SECONDS:
189
+ timeout_seconds = _task_stale_timeout_seconds(task)
190
+ if age_seconds <= timeout_seconds:
188
191
  continue
189
192
  task_id = str(task.get("id") or "")
190
193
  if not task_id:
191
194
  continue
192
195
  message = (
193
- f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
196
+ f"A tarefa ficou sem heartbeat durante mais de {timeout_seconds // 60} minutos. "
194
197
  "Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
195
198
  )
196
199
  failed_stage = str(task.get("stage") or "pipeline")
@@ -568,7 +571,21 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
568
571
 
569
572
  def _stop_process(process: subprocess.Popen[str]) -> None:
570
573
  if process.poll() is None:
571
- process.kill()
574
+ try:
575
+ process_id = getattr(process, "pid", None)
576
+ if os.name != "nt" and process_id:
577
+ os.killpg(os.getpgid(process_id), signal.SIGKILL)
578
+ elif os.name == "nt" and process_id:
579
+ subprocess.run(
580
+ ["taskkill", "/PID", str(process_id), "/T", "/F"],
581
+ check=False,
582
+ stdout=subprocess.DEVNULL,
583
+ stderr=subprocess.DEVNULL,
584
+ )
585
+ else:
586
+ process.kill()
587
+ except (OSError, ProcessLookupError):
588
+ process.kill()
572
589
  try:
573
590
  process.wait(timeout=5)
574
591
  except subprocess.TimeoutExpired:
@@ -602,6 +619,23 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
602
619
  return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
603
620
 
604
621
 
622
+ def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
623
+ """Reserve extra bounded time only for long stock-video downloads and assembly."""
624
+ effective_settings = settings if isinstance(settings, dict) else _settings()
625
+ route = _normalise_video_route(task, effective_settings)
626
+ script = str(task.get("video_script") or "").strip()
627
+ if route in {"pexels", "pixabay"} and len(script) >= 1_200:
628
+ return max(VIDEO_TIMEOUT_SECONDS, LONG_STOCK_VIDEO_TIMEOUT_SECONDS)
629
+ return VIDEO_TIMEOUT_SECONDS
630
+
631
+
632
+ def _task_stale_timeout_seconds(task: dict[str, Any]) -> int:
633
+ """Keep stale-task recovery aligned with the actual execution budget."""
634
+ if str(task.get("stage") or "").strip().casefold() == "video":
635
+ return _video_timeout_seconds(task) + 5 * 60
636
+ return STALE_TASK_SECONDS
637
+
638
+
605
639
  def _mpt_aspect_ratio(value: Any, format_value: Any = "") -> str:
606
640
  raw = str(value or format_value or "").strip().casefold()
607
641
  if raw in {"landscape 16:9", "wide", "16:9", "landscape"}:
@@ -847,6 +881,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
847
881
  output_lines: list[str] = []
848
882
  line_queue: queue.Queue[str | None] = queue.Queue()
849
883
  started_at = time.monotonic()
884
+ timeout_seconds = _video_timeout_seconds(task, settings)
850
885
  process: subprocess.Popen[str] | None = None
851
886
 
852
887
  def _read_output() -> None:
@@ -863,6 +898,7 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
863
898
  command,
864
899
  cwd=helper_dir,
865
900
  env=env,
901
+ start_new_session=os.name != "nt",
866
902
  stdout=subprocess.PIPE,
867
903
  stderr=subprocess.STDOUT,
868
904
  text=True,
@@ -917,9 +953,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
917
953
  last_heartbeat = elapsed
918
954
  if process.poll() is not None and output_finished:
919
955
  break
920
- if elapsed >= VIDEO_TIMEOUT_SECONDS:
956
+ if elapsed >= timeout_seconds:
921
957
  _stop_process(process)
922
- message = f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
958
+ message = f"A etapa Vídeo excedeu o limite de {timeout_seconds // 60} minutos e foi encerrada."
923
959
  metadata = _failure_attribution(task, settings, "video", error=message)
924
960
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
925
961
  finally:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.81",
3
+ "version": "0.3.83",
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",
@@ -149,7 +149,9 @@ def _synthesise_chunk(
149
149
  try:
150
150
  result = synthesizer.speak_ssml_async(_build_ssml(text, voice, rate)).get()
151
151
  finally:
152
- synthesizer.close()
152
+ close = getattr(synthesizer, "close", None)
153
+ if callable(close):
154
+ close()
153
155
  if result.reason != speechsdk.ResultReason.SynthesizingAudioCompleted:
154
156
  details = getattr(
155
157
  getattr(result, "cancellation_details", None), "error_details", ""