@danhachuel/thunderbolt 0.4.75 → 0.4.77

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/app/main.py CHANGED
@@ -69,6 +69,7 @@ from hermes_ui.update_manager import check_version, restart_current_process, upd
69
69
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
70
70
  from hermes_ui.script_generation import generate_script_document
71
71
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
72
+ from hermes_ui.elevenlabs_voices import ElevenLabsVoicesError, fetch_personal_voices, personal_voice_options
72
73
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
73
74
  from hermes_ui.thumbnails import (
74
75
  generate_thumbnail_for_task,
@@ -185,7 +186,7 @@ VIDEO_CONCATENATION_OPTIONS = ["Random Concatenation (Recommended)", "Sequential
185
186
  VIDEO_TRANSITION_OPTIONS = ["None", "Fade", "Dissolve"]
186
187
  VIDEO_ENCODER_OPTIONS = ["Default (Recommended)", "H.264", "H.265"]
187
188
  VOICEOVER_MODE_OPTIONS = ["Auto", "Upload", "None"]
188
- VOICEOVER_SERVICE_OPTIONS = ["Azure Speech SDK V2", "Azure TTS V1"]
189
+ VOICEOVER_SERVICE_OPTIONS = ["Azure Speech SDK V2", "Azure TTS V1", "ElevenLabs"]
189
190
  VOICEOVER_VOLUME_OPTIONS = ["20%", "40%", "60%", "80%", "100%"]
190
191
  VOICEOVER_SPEED_OPTIONS = ["0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "2.0x"]
191
192
  BACKGROUND_MUSIC_SOURCE_OPTIONS = ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API", "Random Background Music", "Sem música"]
@@ -928,6 +929,9 @@ def voice_catalog(current: str = "") -> list[str]:
928
929
  voices.extend(f"{item.get('name', '')}-{item.get('gender', '')}" for item in voice_data if item.get("name"))
929
930
  except (OSError, json.JSONDecodeError):
930
931
  pass
932
+ for voice_id in personal_voice_options(read_json("settings.json", {})):
933
+ if voice_id not in voices:
934
+ voices.append(voice_id)
931
935
  for candidate in (current, "en-US-AriaNeural-Female", "pt-BR-FranciscaNeural-Female", "pt-BR-AntonioNeural-Male"):
932
936
  if candidate and candidate not in voices:
933
937
  voices.append(candidate)
@@ -1091,6 +1095,8 @@ def render_video_generation_settings(
1091
1095
  and str(configured_voice_settings.get("azure_speech_region") or "").strip()
1092
1096
  )
1093
1097
  voice_service_default = "Azure Speech SDK V2" if configured_azure_voice else "Azure TTS V1"
1098
+ if str(st.session_state.get(f"{prefix}_voice", "")).strip() in personal_voice_options(configured_voice_settings):
1099
+ voice_service_default = "ElevenLabs"
1094
1100
  voice_service_index = VOICEOVER_SERVICE_OPTIONS.index(voice_service_default)
1095
1101
  settings["voiceover_service"] = st.selectbox(
1096
1102
  "Voiceover Service",
@@ -1108,7 +1114,13 @@ def render_video_generation_settings(
1108
1114
  st.session_state[channel_state_key] = channel_id
1109
1115
  current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
1110
1116
  voice_options = voice_catalog(current_voice)
1111
- settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
1117
+ personal_options = personal_voice_options(configured_voice_settings)
1118
+ settings["voice"] = st.selectbox(
1119
+ "Voice (match script language)",
1120
+ voice_options,
1121
+ format_func=lambda value: personal_options.get(value, value) if value else "Sem voz seleccionada",
1122
+ key=f"{prefix}_voice",
1123
+ )
1112
1124
  volume_speed_cols = st.columns(2)
1113
1125
  with volume_speed_cols[0]:
1114
1126
  settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
@@ -3376,8 +3388,49 @@ def render_music_creation():
3376
3388
 
3377
3389
 
3378
3390
  def render_custom_music_voices() -> None:
3379
- """Reserved view for future reusable custom music-voice blueprints."""
3391
+ """List and test personal ElevenLabs voices without exposing credentials."""
3380
3392
  st.title("Vozes Personalizadas")
3393
+ st.caption("Vozes pessoais do ElevenLabs Voice Lab disponíveis para narração no Thunderbolt.")
3394
+ settings = read_json("settings.json", {})
3395
+ refresh_col, cache_col = st.columns([1, 2])
3396
+ with refresh_col:
3397
+ refresh = st.button("Actualizar vozes", type="primary", use_container_width=True, key="elevenlabs_refresh_voices")
3398
+ try:
3399
+ voices, metadata = fetch_personal_voices(settings, force=refresh)
3400
+ if metadata.get("source") == "cache":
3401
+ label = "cache local"
3402
+ if metadata.get("stale"):
3403
+ st.warning("A API não respondeu; a mostrar a última lista guardada localmente.")
3404
+ else:
3405
+ label = "API ElevenLabs"
3406
+ with cache_col:
3407
+ st.caption(f"Fonte: {label} · última actualização: {metadata.get('updated_at') or '—'}")
3408
+ except ElevenLabsVoicesError as exc:
3409
+ st.warning(str(exc))
3410
+ st.info("As vozes padrão continuam disponíveis na criação de vídeos.")
3411
+ return
3412
+
3413
+ if not voices:
3414
+ st.info("Nenhuma voz pessoal foi encontrada nesta conta ElevenLabs.")
3415
+ return
3416
+ test_text = st.text_input("Texto curto para testar uma voz", value=DEFAULT_SAMPLE, key="elevenlabs_voice_test_text")
3417
+ for voice in voices:
3418
+ with st.container(border=True):
3419
+ voice_cols = st.columns([2.2, 1.8, 1])
3420
+ with voice_cols[0]:
3421
+ st.markdown(f"**{voice.get('name', 'Sem nome')}**")
3422
+ st.caption(f"ID: `{voice.get('voice_id', '')}`")
3423
+ with voice_cols[1]:
3424
+ labels = voice.get("labels") or {}
3425
+ st.caption("Categoria: personal")
3426
+ st.caption("Labels: " + (", ".join(f"{key}: {value}" for key, value in labels.items()) if labels else "não disponíveis"))
3427
+ with voice_cols[2]:
3428
+ if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", use_container_width=True):
3429
+ try:
3430
+ preview = synthesize_preview(test_text, "elevenlabs", voice["voice_id"], settings)
3431
+ st.audio(str(preview), format="audio/mpeg")
3432
+ except (OSError, RuntimeError, ValueError) as exc:
3433
+ st.error(f"Não foi possível testar esta voz: {exc}")
3381
3434
 
3382
3435
 
3383
3436
  def render_scripts():
@@ -4371,6 +4424,22 @@ def _task_thumbnail_path(task: dict[str, Any]) -> Path | None:
4371
4424
  return None
4372
4425
 
4373
4426
 
4427
+ def _task_artifact_path(task: dict[str, Any], *names: str) -> Path | None:
4428
+ """Resolve um artefacto local, incluindo caminhos relativos e legados."""
4429
+ artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
4430
+ for name in names:
4431
+ raw = str(artifacts.get(name) or "").strip()
4432
+ if not raw or raw.startswith(("http://", "https://")):
4433
+ continue
4434
+ candidates = [Path(raw)]
4435
+ if not Path(raw).is_absolute():
4436
+ candidates.extend([STORAGE / raw, STORAGE / "videos" / raw])
4437
+ for candidate in candidates:
4438
+ if candidate.is_file():
4439
+ return candidate
4440
+ return None
4441
+
4442
+
4374
4443
  def _is_music_task(task: dict[str, Any]) -> bool:
4375
4444
  """Identify music pipeline tasks without conflating them with ordinary video tasks."""
4376
4445
  return bool(task.get("music_mode")) or str(task.get("style_wide") or task.get("style") or "").strip().casefold() in {"music", "música"}
@@ -4921,15 +4990,41 @@ def render_automation():
4921
4990
  st.info("Ainda não existem vídeos cadastrados.")
4922
4991
  for task in tasks:
4923
4992
  with st.container(border=True):
4924
- task_cols = st.columns([2.25, 1.65, 1.15, 1.85])
4993
+ task_cols = st.columns([2.25, 1.55, 1.05, 2.15], gap="small")
4994
+ script_path = _task_artifact_path(task, "script")
4995
+ video_path = _task_artifact_path(task, "video")
4996
+ thumbnail_path = _task_thumbnail_path(task)
4997
+ thumbnail_prompt_path = _task_artifact_path(task, "thumbnail_prompt_json")
4998
+ thumbnail_prompt = str(task.get("thumbnail_prompt") or "").strip()
4925
4999
  with task_cols[0]:
4926
- thumbnail_path = _task_thumbnail_path(task)
4927
5000
  if thumbnail_path:
4928
- st.image(thumbnail_path, width=180, caption="Thumbnail")
5001
+ st.image(str(thumbnail_path), width=180, caption="Thumbnail")
4929
5002
  else:
4930
5003
  st.caption("Thumbnail ainda não pronta")
4931
5004
  st.write(f"**{task.get('topic', 'Sem tópico')}**")
4932
5005
  st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
5006
+ thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
5007
+ with thumbnail_download_col:
5008
+ st.download_button(
5009
+ "Baixar Thumbnail",
5010
+ data=thumbnail_path.read_bytes() if thumbnail_path else b"",
5011
+ file_name=thumbnail_path.name if thumbnail_path else "thumbnail.png",
5012
+ mime="image/png",
5013
+ key=f"automation_download_thumbnail_{task['id']}",
5014
+ use_container_width=True,
5015
+ disabled=thumbnail_path is None,
5016
+ )
5017
+ with prompt_download_col:
5018
+ prompt_data = thumbnail_prompt_path.read_bytes() if thumbnail_prompt_path else thumbnail_prompt.encode("utf-8")
5019
+ st.download_button(
5020
+ "Baixar Thumbnail Prompt",
5021
+ data=prompt_data,
5022
+ file_name=thumbnail_prompt_path.name if thumbnail_prompt_path else "thumbnail-prompt.txt",
5023
+ mime="application/json" if thumbnail_prompt_path else "text/plain",
5024
+ key=f"automation_download_thumbnail_prompt_{task['id']}",
5025
+ use_container_width=True,
5026
+ disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5027
+ )
4933
5028
  with task_cols[1]:
4934
5029
  _render_video_task_state(task)
4935
5030
  with task_cols[2]:
@@ -4949,6 +5044,27 @@ def render_automation():
4949
5044
  if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
4950
5045
  stop_task_by_user(task["id"])
4951
5046
  st.rerun()
5047
+ script_download_col, video_download_col = st.columns(2, gap="small")
5048
+ with script_download_col:
5049
+ st.download_button(
5050
+ "Baixar Roteiro",
5051
+ data=script_path.read_bytes() if script_path else b"",
5052
+ file_name=script_path.name if script_path else "roteiro.md",
5053
+ mime="text/markdown",
5054
+ key=f"automation_download_script_{task['id']}",
5055
+ use_container_width=True,
5056
+ disabled=script_path is None,
5057
+ )
5058
+ with video_download_col:
5059
+ st.download_button(
5060
+ "Baixar Vídeo",
5061
+ data=video_path.read_bytes() if video_path else b"",
5062
+ file_name=video_path.name if video_path else "video.mp4",
5063
+ mime="video/mp4",
5064
+ key=f"automation_download_video_{task['id']}",
5065
+ use_container_width=True,
5066
+ disabled=video_path is None,
5067
+ )
4952
5068
  with delete_col:
4953
5069
  confirm_delete_key = f"automation_confirm_delete_{task['id']}"
4954
5070
  if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
@@ -0,0 +1,128 @@
1
+ """ElevenLabs personal voice catalogue with local, credential-safe caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ import requests
11
+
12
+ from . import storage
13
+
14
+ CACHE_FILENAME = "elevenlabs_voices.json"
15
+ DEFAULT_CACHE_TTL_SECONDS = 6 * 60 * 60
16
+ VOICES_ENDPOINT = "https://api.elevenlabs.io/v1/voices"
17
+
18
+
19
+ class ElevenLabsVoicesError(RuntimeError):
20
+ """A safe, user-facing error raised while reading the ElevenLabs catalogue."""
21
+
22
+
23
+ def cache_path() -> Path:
24
+ return storage.STATE / CACHE_FILENAME
25
+
26
+
27
+ def _utc_now() -> datetime:
28
+ return datetime.now(timezone.utc)
29
+
30
+
31
+ def _normalise_voice(item: Any) -> dict[str, Any] | None:
32
+ if not isinstance(item, dict):
33
+ return None
34
+ voice_id = str(item.get("voice_id") or "").strip()
35
+ category = str(item.get("category") or "").strip().casefold()
36
+ name = str(item.get("name") or "").strip()
37
+ if not voice_id or not name or category != "personal":
38
+ return None
39
+ labels = item.get("labels") if isinstance(item.get("labels"), dict) else {}
40
+ samples = item.get("samples") if isinstance(item.get("samples"), list) else []
41
+ return {
42
+ "voice_id": voice_id,
43
+ "name": name,
44
+ "category": "personal",
45
+ "labels": {str(key): str(value) for key, value in labels.items()},
46
+ "samples": [sample for sample in samples if isinstance(sample, dict)],
47
+ }
48
+
49
+
50
+ def normalise_voices(payload: Any) -> list[dict[str, Any]]:
51
+ items = payload.get("voices", []) if isinstance(payload, dict) else []
52
+ if not isinstance(items, list):
53
+ raise ElevenLabsVoicesError("A API ElevenLabs devolveu uma lista de vozes inválida.")
54
+ result = [_normalise_voice(item) for item in items]
55
+ return [item for item in result if item is not None]
56
+
57
+
58
+ def _read_cache() -> dict[str, Any] | None:
59
+ payload = storage.read_json(CACHE_FILENAME, {})
60
+ return payload if isinstance(payload, dict) and isinstance(payload.get("voices"), list) else None
61
+
62
+
63
+ def _cache_is_fresh(cache: dict[str, Any], ttl_seconds: int) -> bool:
64
+ try:
65
+ updated = datetime.fromisoformat(str(cache.get("updated_at")).replace("Z", "+00:00"))
66
+ return (_utc_now() - updated).total_seconds() < ttl_seconds
67
+ except (TypeError, ValueError, OverflowError):
68
+ return False
69
+
70
+
71
+ def _save_cache(voices: list[dict[str, Any]], *, source: str) -> dict[str, Any]:
72
+ payload = {"updated_at": _utc_now().isoformat(), "source": source, "voices": voices}
73
+ storage.write_json(CACHE_FILENAME, payload)
74
+ return payload
75
+
76
+
77
+ def fetch_personal_voices(
78
+ settings: dict[str, Any],
79
+ *,
80
+ force: bool = False,
81
+ ttl_seconds: int = DEFAULT_CACHE_TTL_SECONDS,
82
+ request_get: Callable[..., Any] = requests.get,
83
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
84
+ """Return personal voices, using cache unless force is requested."""
85
+ cache = _read_cache()
86
+ if cache and not force and _cache_is_fresh(cache, ttl_seconds):
87
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at")}
88
+
89
+ api_key = str(settings.get("elevenlabs_api_key") or "").strip()
90
+ if not api_key:
91
+ if cache:
92
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
93
+ raise ElevenLabsVoicesError("Configure a API Key do ElevenLabs em Configuração API antes de actualizar as vozes.")
94
+ try:
95
+ response = request_get(VOICES_ENDPOINT, headers={"xi-api-key": api_key}, timeout=30)
96
+ status = int(getattr(response, "status_code", 200))
97
+ if status == 401:
98
+ raise ElevenLabsVoicesError("A API Key do ElevenLabs foi recusada. Confirme a chave em Configuração API.")
99
+ if status == 429:
100
+ raise ElevenLabsVoicesError("O limite de pedidos do ElevenLabs foi atingido. Tente novamente mais tarde.")
101
+ response.raise_for_status()
102
+ voices = normalise_voices(response.json())
103
+ saved = _save_cache(voices, source="elevenlabs_api")
104
+ return voices, {"source": "api", "updated_at": saved["updated_at"]}
105
+ except ElevenLabsVoicesError:
106
+ if cache:
107
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
108
+ raise
109
+ except requests.Timeout as exc:
110
+ if cache:
111
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
112
+ raise ElevenLabsVoicesError("O ElevenLabs demorou demasiado tempo a responder. Tente novamente.") from exc
113
+ except (requests.RequestException, ValueError, json.JSONDecodeError) as exc:
114
+ if cache:
115
+ return list(cache["voices"]), {"source": "cache", "updated_at": cache.get("updated_at"), "stale": True}
116
+ raise ElevenLabsVoicesError("Não foi possível obter as vozes do ElevenLabs. Verifique a ligação e a API Key.") from exc
117
+
118
+
119
+ def personal_voice_options(settings: dict[str, Any]) -> dict[str, str]:
120
+ """Return voice_id -> display label without contacting the remote API."""
121
+ cache = _read_cache()
122
+ if not cache:
123
+ return {}
124
+ return {
125
+ str(voice["voice_id"]): f"{voice.get('name', voice['voice_id'])} · ElevenLabs personal"
126
+ for voice in cache.get("voices", [])
127
+ if isinstance(voice, dict) and voice.get("voice_id")
128
+ }
@@ -25,6 +25,7 @@ from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_
25
25
  from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
26
26
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
27
27
  from hermes_ui.thumbnail_blueprints import thumbnail_blueprint_for_channel
28
+ from hermes_ui.voice_preview import synthesize_preview
28
29
 
29
30
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
30
31
  PIPELINE_LOG_FILENAME = "pipeline_worker.json"
@@ -763,6 +764,8 @@ def _uses_azure_speech_sdk_v2(
763
764
  settings: dict[str, Any] | None = None,
764
765
  ) -> bool:
765
766
  service = str(generation_settings.get("voiceover_service") or "").strip().casefold()
767
+ if service in {"elevenlabs", "eleven labs"}:
768
+ return False
766
769
  if service in {
767
770
  "azure speech sdk v2",
768
771
  "azure speech",
@@ -913,6 +916,23 @@ def _run_video_helper_once(
913
916
  sync_moneyprinter_config(settings, str(configured_root))
914
917
  except OSError as exc:
915
918
  raise PipelineError(f"Não foi possível sincronizar a configuração do MoneyPrinterTurbo: {exc}") from exc
919
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
920
+ voiceover_mode = str(generation_settings.get("voiceover_mode") or "").strip().casefold()
921
+ voiceover_service = str(generation_settings.get("voiceover_service") or "").strip().casefold()
922
+ generated_elevenlabs_audio: Path | None = None
923
+ if voiceover_mode not in {"none", "upload"} and voiceover_service == "elevenlabs":
924
+ elevenlabs_voice = str(task.get("voice") or generation_settings.get("voice") or "").strip()
925
+ script_text = str(task.get("video_script") or generation_settings.get("video_script") or "").strip()
926
+ if not elevenlabs_voice:
927
+ raise PipelineError("Seleccione uma voz personalizada ElevenLabs antes de criar o vídeo.")
928
+ if not str(settings.get("elevenlabs_api_key") or "").strip():
929
+ raise PipelineError("ElevenLabs foi seleccionado, mas a API Key não está configurada.")
930
+ if not script_text:
931
+ raise PipelineError("ElevenLabs foi seleccionado, mas o roteiro ainda não está disponível.")
932
+ try:
933
+ generated_elevenlabs_audio = synthesize_preview(script_text, "elevenlabs", elevenlabs_voice, settings)
934
+ except (OSError, RuntimeError, ValueError) as exc:
935
+ raise PipelineError(f"Não foi possível gerar a narração ElevenLabs: {exc}") from exc
916
936
  env_values = {
917
937
  "MPT_LLM_PROVIDER": provider,
918
938
  "MPT_LLM_API_KEY": str(card.get("api_key") or "").strip(),
@@ -932,7 +952,6 @@ def _run_video_helper_once(
932
952
  if configured_root:
933
953
  command.extend(["--root", str(configured_root)])
934
954
  command.extend(["--subject", subject, "--"])
935
- generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
936
955
  if _uses_azure_speech_sdk_v2(generation_settings, settings) and str(generation_settings.get("voiceover_mode") or "").strip().casefold() not in {"none", "upload"}:
937
956
  missing_voice_config = [
938
957
  field for field, value in (
@@ -951,7 +970,7 @@ def _run_video_helper_once(
951
970
  })
952
971
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
953
972
  command.extend(_moneyprinter_cli_args(task, route, settings=settings))
954
- if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
973
+ if voiceover_mode == "upload":
955
974
  voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
956
975
  if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
957
976
  message = "O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio."
@@ -959,6 +978,8 @@ def _run_video_helper_once(
959
978
  metadata.update({"failure_api": "Ficheiro local", "failure_provider": "local_storage", "failure_service": "Áudio de narração", "failure_config_fields": "voiceover_file"})
960
979
  raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
961
980
  command.extend(["--custom-audio-file", str(voiceover_file.resolve())])
981
+ elif generated_elevenlabs_audio is not None:
982
+ command.extend(["--custom-audio-file", str(generated_elevenlabs_audio.resolve())])
962
983
  output_lines: list[str] = []
963
984
  line_queue: queue.Queue[str | None] = queue.Queue()
964
985
  started_at = time.monotonic()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.75",
3
+ "version": "0.4.77",
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",