@danhachuel/thunderbolt 0.2.90 → 0.2.92

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
@@ -216,9 +216,20 @@ st.markdown("""
216
216
 
217
217
 
218
218
  def current_ui_language() -> str:
219
- settings = read_json("settings.json", {})
220
- stored = st.session_state.get("ui_language") or settings.get("ui_language") or "pt"
221
- normalized = language_code(stored)
219
+ """Return the session language without hitting disk on every widget render."""
220
+ cached = st.session_state.get("ui_language")
221
+ if cached:
222
+ return language_code(cached)
223
+ requested = ""
224
+ try:
225
+ requested = str(st.query_params.get("lang") or "").strip()
226
+ except Exception:
227
+ requested = ""
228
+ if requested:
229
+ normalized = language_code(requested)
230
+ else:
231
+ settings = read_json("settings.json", {})
232
+ normalized = language_code(settings.get("ui_language") or "pt")
222
233
  st.session_state["ui_language"] = normalized
223
234
  return normalized
224
235
 
@@ -235,7 +246,7 @@ _STREAMLIT_I18N_INSTALLED = False
235
246
  def _translate_streamlit_arguments(method_name: str, args: tuple[Any, ...], kwargs: dict[str, Any]) -> tuple[tuple[Any, ...], dict[str, Any]]:
236
247
  if not args and "label" not in kwargs:
237
248
  return args, kwargs
238
- selected_language = current_ui_language()
249
+ selected_language = str(st.session_state.get("ui_language") or current_ui_language())
239
250
  translated_args = list(args)
240
251
  if translated_args and isinstance(translated_args[0], str):
241
252
  translated_args[0] = ui_text(translated_args[0], selected_language)
@@ -339,6 +350,10 @@ def render_ui_language_picker(language: str) -> None:
339
350
  )
340
351
  if selected != current:
341
352
  save_ui_language(selected)
353
+ try:
354
+ st.query_params["lang"] = selected
355
+ except Exception:
356
+ pass
342
357
  st.rerun()
343
358
 
344
359
 
@@ -1718,13 +1733,16 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1718
1733
  if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
1719
1734
  if selected_one is None:
1720
1735
  st.error("Seleccione primeiro um canal.")
1721
- elif not topic_for_creative:
1722
- st.error("Escreva ou gere primeiro um tópico/briefing.")
1723
1736
  else:
1724
1737
  try:
1725
- generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
1738
+ if not topic_for_creative:
1739
+ topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one)
1740
+ topic_for_creative = str(topic_result["topic"]).strip()
1741
+ st.session_state["new_video_topic"] = topic_for_creative
1742
+ st.session_state["new_video_topic_meta"] = topic_result
1743
+ generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm")
1726
1744
  st.session_state["new_video_creative_payload"] = generated
1727
- st.success("Título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
1745
+ st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
1728
1746
  st.rerun()
1729
1747
  except CreativeGenerationError as exc:
1730
1748
  st.error(str(exc))
@@ -1825,9 +1843,18 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1825
1843
  st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
1826
1844
  else:
1827
1845
  topic_value = str(st.session_state.get("new_video_topic", "") or "").strip()
1828
- if not topic_value or not selected:
1829
- st.error("Escreva ou gere um tópico e seleccione um canal.")
1846
+ if not selected:
1847
+ st.error("Seleccione um canal.")
1830
1848
  else:
1849
+ if not topic_value:
1850
+ try:
1851
+ topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one or {})
1852
+ topic_value = str(topic_result["topic"]).strip()
1853
+ st.session_state["new_video_topic"] = topic_value
1854
+ st.session_state["new_video_topic_meta"] = topic_result
1855
+ except CreativeGenerationError as exc:
1856
+ st.error(f"Não foi possível gerar automaticamente o tema: {exc}")
1857
+ st.stop()
1831
1858
  quantity_value = int(quantity if mode == "same_channel" else 1)
1832
1859
  payload = dict(st.session_state.get("new_video_creative_payload") or {})
1833
1860
  if not payload.get("title") or not payload.get("thumbnail_variants"):
@@ -3169,7 +3196,8 @@ def render_upload_conventional():
3169
3196
  tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
3170
3197
  yt_cols = st.columns(3)
3171
3198
  with yt_cols[0]:
3172
- privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"yt_privacy_{task['id']}")
3199
+ privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], index=1, key=f"yt_privacy_{task['id']}")
3200
+ st.caption("Fluxo recomendado: não listado · incorporação activa · permitir remix de áudio e vídeo · publicar no feed de subscritos.")
3173
3201
  with yt_cols[1]:
3174
3202
  category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
3175
3203
  with yt_cols[2]:
@@ -178,6 +178,17 @@ def _short_overlay(value: Any) -> str:
178
178
  return " ".join(words[:4])
179
179
 
180
180
 
181
+ def _keywords_from_text(*values: str) -> list[str]:
182
+ """Build a small deterministic keyword fallback when the LLM omits keywords."""
183
+ blocked = {"para", "como", "sobre", "mais", "esse", "esta", "that", "this", "with", "from", "video"}
184
+ result: list[str] = []
185
+ for value in values:
186
+ for word in re.findall(r"[\wÀ-ÿ]{4,}", str(value or "").casefold(), flags=re.UNICODE):
187
+ if word not in blocked and word not in result:
188
+ result.append(word)
189
+ return result[:15]
190
+
191
+
181
192
  def generate_creative_package(
182
193
  settings: dict[str, Any],
183
194
  channel: dict[str, Any],
@@ -194,7 +205,7 @@ def generate_creative_package(
194
205
  "O título deve carregar keywords no início, ter curiosidade, especificidade e emoção, sem clickbait falso. "
195
206
  "A thumbnail deve ter no máximo três elementos, alto contraste, uma composição clara, texto opcional de até 4 palavras, "
196
207
  "safe zones e leitura em 120px. O texto da thumbnail não pode repetir o título integralmente. Remove AI tells. "
197
- "Responde apenas com JSON válido nas chaves selected_title, title_candidates e thumbnail_variants."
208
+ "Responde apenas com JSON válido nas chaves selected_title, title_candidates, keywords e thumbnail_variants."
198
209
  )
199
210
  user = json.dumps(
200
211
  {
@@ -202,6 +213,7 @@ def generate_creative_package(
202
213
  "language": language or context["language"],
203
214
  "topic": topic.strip(),
204
215
  "reference_rules": reference_bundle(),
216
+ "keywords_schema": ["lista de 8 a 15 keywords SEO curtas, sem hashtags"],
205
217
  "title_candidates_schema": {
206
218
  "title": "string",
207
219
  "formula": "string",
@@ -241,6 +253,14 @@ def generate_creative_package(
241
253
  )
242
254
  if len(titles) < 20:
243
255
  raise CreativeGenerationError("Os títulos devolvidos pelo provider não têm conteúdo suficiente.")
256
+ raw_keywords = result.get("keywords")
257
+ keywords = [str(item).strip() for item in raw_keywords if str(item).strip()] if isinstance(raw_keywords, list) else []
258
+ selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
259
+ if selected_title not in {item["title"] for item in titles}:
260
+ selected_title = titles[0]["title"]
261
+ if not keywords:
262
+ keywords = _keywords_from_text(topic, selected_title)
263
+
244
264
  variants_raw = result.get("thumbnail_variants")
245
265
  if not isinstance(variants_raw, list) or len(variants_raw) < 3:
246
266
  raise CreativeGenerationError("O provider deve devolver pelo menos 3 variantes de thumbnail.")
@@ -264,12 +284,10 @@ def generate_creative_package(
264
284
  )
265
285
  if len(variants) < 3:
266
286
  raise CreativeGenerationError("As variantes de thumbnail devolvidas pelo provider estão incompletas.")
267
- selected_title = str(result.get("selected_title") or titles[0]["title"]).strip()
268
- if selected_title not in {item["title"] for item in titles}:
269
- selected_title = titles[0]["title"]
270
287
  return {
271
288
  "title": selected_title,
272
289
  "title_candidates": titles,
290
+ "keywords": keywords[:15],
273
291
  "thumbnail_variant": variants[0],
274
292
  "thumbnail_variants": variants,
275
293
  "thumbnail_status": "prompt_ready",
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from .notifications import record_notification
8
8
  from .storage import append_json, now, read_json, write_json
9
9
 
10
- STAGES = ["niche", "blueprint", "brand", "script", "title", "thumbnail", "video", "edit", "upload"]
10
+ STAGES = ["niche", "blueprint", "brand", "topic", "script", "title", "keywords", "thumbnail_prompt", "thumbnail", "video", "edit", "upload"]
11
11
  VALID_STATES = {"to_do", "doing", "blocked", "done", "failed", "cancelled"}
12
12
 
13
13
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import base64
6
+ from functools import lru_cache
6
7
  from typing import Any
7
8
 
8
9
 
@@ -589,6 +590,22 @@ for _row in _CONTENT_TOKEN_TRANSLATION_ROWS:
589
590
  UI_TOKEN_TRANSLATIONS[_code][_source] = _translated
590
591
 
591
592
 
593
+ @lru_cache(maxsize=16)
594
+ def _combined_translations(language: str) -> dict[str, str]:
595
+ """Build the immutable-language translation index once per process."""
596
+ return {
597
+ **UI_TRANSLATIONS.get(language, {}),
598
+ **UI_CONTENT_TRANSLATIONS.get(language, {}),
599
+ **UI_TOKEN_TRANSLATIONS.get(language, {}),
600
+ }
601
+
602
+
603
+ @lru_cache(maxsize=16)
604
+ def _sorted_translations(language: str) -> tuple[tuple[str, str], ...]:
605
+ """Cache longest-first token replacement order for each language."""
606
+ return tuple(sorted(_combined_translations(language).items(), key=lambda item: len(item[0]), reverse=True))
607
+
608
+
592
609
  def translate_ui_content(value: Any, language: Any = "pt") -> Any:
593
610
  """Translate visible Streamlit content while preserving non-text values and user data markup."""
594
611
  if not isinstance(value, str):
@@ -601,12 +618,7 @@ def translate_ui_content(value: Any, language: Any = "pt") -> Any:
601
618
  if exact is not None:
602
619
  return exact
603
620
  translated = value
604
- combined_translations = {
605
- **UI_TRANSLATIONS.get(code, {}),
606
- **UI_CONTENT_TRANSLATIONS.get(code, {}),
607
- **UI_TOKEN_TRANSLATIONS.get(code, {}),
608
- }
609
- for source, target in sorted(combined_translations.items(), key=lambda item: len(item[0]), reverse=True):
621
+ for source, target in _sorted_translations(code):
610
622
  if source != target and source in translated:
611
623
  translated = translated.replace(source, target)
612
624
  return translated
@@ -0,0 +1,288 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from integrations.upload_routing import upload_with_default_route
13
+ from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
14
+ from hermes_ui.script_documents import save_script_document
15
+ from hermes_ui.script_generation import generate_script_document
16
+ from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
17
+ from hermes_ui.thumbnail_generation import generate_thumbnail_image
18
+
19
+ PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
20
+ PIPELINE_LOG_FILENAME = "pipeline_worker.json"
21
+ VIDEO_TIMEOUT_SECONDS = 20 * 60
22
+ STALE_TASK_SECONDS = 2 * 60 * 60
23
+
24
+
25
+ class PipelineError(RuntimeError):
26
+ """Raised when a pipeline stage cannot complete with an actionable error."""
27
+
28
+
29
+ def _now() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ def _settings() -> dict[str, Any]:
34
+ value = read_json("settings.json", {})
35
+ return value if isinstance(value, dict) else {}
36
+
37
+
38
+ def _lock_path() -> Path:
39
+ ensure_storage()
40
+ return STORAGE / "state" / PIPELINE_LOCK_FILENAME
41
+
42
+
43
+ def _acquire_lock() -> Path | None:
44
+ path = _lock_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ try:
47
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
48
+ os.write(descriptor, str(os.getpid()).encode("ascii"))
49
+ os.close(descriptor)
50
+ return path
51
+ except FileExistsError:
52
+ return None
53
+
54
+
55
+ def _write_worker_state(**updates: Any) -> None:
56
+ state = read_json(PIPELINE_LOG_FILENAME, {})
57
+ if not isinstance(state, dict):
58
+ state = {}
59
+ state.update(updates)
60
+ state["updated_at"] = _now()
61
+ write_json(PIPELINE_LOG_FILENAME, state)
62
+
63
+
64
+ def _task_by_id(task_id: str) -> dict[str, Any] | None:
65
+ return next((task for task in read_json("tasks.json", []) if isinstance(task, dict) and task.get("id") == task_id), None)
66
+
67
+
68
+ def _update(task_id: str, **updates: Any) -> dict[str, Any]:
69
+ from hermes_ui.domain import update_task
70
+
71
+ updated = update_task(task_id, updates)
72
+ if not updated:
73
+ raise PipelineError(f"Tarefa {task_id} deixou de existir durante a execução.")
74
+ return updated
75
+
76
+
77
+ def _channel_for_task(task: dict[str, Any]) -> dict[str, Any]:
78
+ channel_id = str(task.get("channel_id") or "")
79
+ return next((channel for channel in read_json("channels.json", []) if str(channel.get("id")) == channel_id), {})
80
+
81
+
82
+ def _blueprint_for_channel(channel: dict[str, Any]) -> dict[str, Any]:
83
+ blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "")
84
+ blueprints = read_json("blueprints.json", [])
85
+ if not isinstance(blueprints, list):
86
+ return {}
87
+ return next((item for item in blueprints if isinstance(item, dict) and str(item.get("id")) == blueprint_id), {})
88
+
89
+
90
+ def _keywords(topic: str, title: str, niche: str = "") -> list[str]:
91
+ """Derive deterministic SEO keywords when the LLM does not return a keyword list."""
92
+ source = f"{title} {topic} {niche}".casefold()
93
+ words = re.findall(r"[\wÀ-ÿ]{4,}", source, flags=re.UNICODE)
94
+ blocked = {"para", "como", "sobre", "mais", "esse", "esta", "that", "this", "with", "from", "video"}
95
+ result: list[str] = []
96
+ for word in words:
97
+ if word in blocked or word in result:
98
+ continue
99
+ result.append(word)
100
+ return result[:15]
101
+
102
+
103
+ def _save_json_artifact(task_id: str, name: str, payload: dict[str, Any]) -> str:
104
+ directory = STORAGE / "artifacts"
105
+ directory.mkdir(parents=True, exist_ok=True)
106
+ path = directory / f"{task_id}-{name}.json"
107
+ temporary = path.with_suffix(".tmp")
108
+ temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
109
+ temporary.replace(path)
110
+ return str(path)
111
+
112
+
113
+ def _run_video_helper(task: dict[str, Any]) -> Path:
114
+ helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
115
+ helper = helper_dir / "mpt_agent.py"
116
+ if not helper.is_file():
117
+ raise PipelineError("O helper de vídeo MoneyPrinterTurbo não está instalado no pacote.")
118
+ subject = str(task.get("topic") or "").strip()
119
+ if not subject:
120
+ raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
121
+ settings = _settings()
122
+ env = os.environ.copy()
123
+ provider = str(settings.get("llm_provider") or "openai").strip()
124
+ env_values = {
125
+ "MPT_LLM_PROVIDER": provider,
126
+ "MPT_LLM_API_KEY": str(settings.get(f"{provider}_api_key") or "").strip(),
127
+ "MPT_LLM_BASE_URL": str(settings.get(f"{provider}_base_url") or "").strip(),
128
+ "MPT_LLM_MODEL_NAME": str(settings.get(f"{provider}_model_name") or "").strip(),
129
+ "MPT_PEXELS_API_KEY": str(settings.get("pexels_api_key") or "").strip(),
130
+ }
131
+ for key, value in env_values.items():
132
+ if value:
133
+ env[key] = value
134
+ command = ["uv", "run", "--no-project", "--python", "3.11", "python", "mpt_agent.py", "--subject", subject]
135
+ try:
136
+ result = subprocess.run(command, cwd=helper_dir, env=env, capture_output=True, text=True, timeout=VIDEO_TIMEOUT_SECONDS, check=False)
137
+ except FileNotFoundError as exc:
138
+ raise PipelineError("O comando uv não está instalado; não foi possível iniciar a geração de vídeo.") from exc
139
+ except subprocess.TimeoutExpired as exc:
140
+ raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.") from exc
141
+ output = "\n".join(part for part in (result.stdout, result.stderr) if part)
142
+ if result.returncode == 10:
143
+ raise PipelineError("A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo.")
144
+ if result.returncode != 0:
145
+ detail = output[-1200:].strip() or "erro sem detalhes devolvidos pelo helper"
146
+ raise PipelineError(f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}")
147
+ match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
148
+ video_path = Path(match.group(1).strip()).expanduser() if match else None
149
+ if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
150
+ result_file = Path.home() / "MoneyPrinterTurbo" / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
151
+ if result_file.is_file():
152
+ try:
153
+ payload = json.loads(result_file.read_text(encoding="utf-8"))
154
+ video_path = Path(str(payload.get("video_file") or payload.get("VIDEO_FILE") or "")).expanduser()
155
+ except (OSError, json.JSONDecodeError):
156
+ video_path = None
157
+ if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
158
+ raise PipelineError("MoneyPrinterTurbo terminou sem devolver um MP4 válido.")
159
+ return video_path
160
+
161
+
162
+ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
163
+ task_id = str(task.get("id") or "")
164
+ channel = _channel_for_task(task)
165
+ settings = _settings()
166
+ blueprint = _blueprint_for_channel(channel)
167
+ topic = str(task.get("topic") or "").strip()
168
+ if not topic or str(task.get("topic_source") or "") in {"auto", "llm_pending"}:
169
+ _update(task_id, stage="topic", state="doing", progress=5, error=None)
170
+ topic_result = generate_topic_for_channel(settings, channel, blueprint, user_context=str(task.get("topic_context") or ""))
171
+ topic = str(topic_result.get("topic") or "").strip()
172
+ if not topic:
173
+ raise PipelineError("A IA não devolveu um tema válido.")
174
+ _update(task_id, topic=topic, topic_source="llm", ai_generation={"topic": topic_result}, progress=12)
175
+
176
+ _update(task_id, stage="script", state="doing", progress=18, error=None)
177
+ script = generate_script_document(
178
+ settings,
179
+ document_type="Roteiro de vídeo",
180
+ title=str(task.get("title") or topic),
181
+ brief=topic,
182
+ language=str(task.get("language") or channel.get("language") or "Português"),
183
+ channel=channel,
184
+ blueprint=blueprint,
185
+ structure_notes=str((task.get("generation_settings") or {}).get("script_structure_notes") or ""),
186
+ generation_settings=task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {},
187
+ )
188
+ script_record = save_script_document(script)
189
+ artifacts = dict(task.get("artifacts") or {})
190
+ artifacts["script"] = script_record.get("path", "")
191
+ _update(task_id, artifacts=artifacts, progress=30)
192
+
193
+ _update(task_id, stage="title", state="doing", progress=35)
194
+ creative = generate_creative_package(settings, channel, topic, blueprint, language=str(task.get("language") or channel.get("language") or "Português"))
195
+ title = str(creative.get("title") or topic).strip()
196
+ keywords = creative.get("keywords") if isinstance(creative.get("keywords"), list) else _keywords(topic, title, str(channel.get("niche") or ""))
197
+ title_artifact = _save_json_artifact(task_id, "title-keywords", {"topic": topic, "title": title, "keywords": keywords, "title_candidates": creative.get("title_candidates", [])})
198
+ _update(task_id, title=title, tags=keywords, artifacts={**artifacts, "title_keywords": title_artifact}, title_candidates=creative.get("title_candidates", []), progress=45)
199
+
200
+ _update(task_id, stage="keywords", state="doing", progress=48)
201
+ variant = creative.get("thumbnail_variant") if isinstance(creative.get("thumbnail_variant"), dict) else {}
202
+ prompt_payload = {"topic": topic, "title": title, "keywords": keywords, "thumbnail": variant, "requirements": {"aspect_ratio": "16:9", "resolution": "1920x1080", "max_elements": 3, "max_overlay_words": 4}}
203
+ prompt_artifact = _save_json_artifact(task_id, "thumbnail-prompt", prompt_payload)
204
+ artifacts = {**artifacts, "thumbnail_prompt_json": prompt_artifact}
205
+ _update(task_id, stage="thumbnail_prompt", state="doing", progress=52, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
206
+ _update(task_id, stage="thumbnail", state="doing", progress=56, thumbnail_prompt=str(variant.get("image_prompt") or ""), thumbnail_text=str(variant.get("overlay_text") or ""), thumbnail_status="prompt_ready", artifacts=artifacts)
207
+ thumbnail_path = generate_thumbnail_image(settings, str(variant.get("image_prompt") or ""), topic=topic, variant_index=0)
208
+ artifacts["thumbnail"] = str(thumbnail_path)
209
+ _update(task_id, artifacts=artifacts, thumbnail_status="generated", progress=62)
210
+
211
+ _update(task_id, stage="video", state="doing", progress=68)
212
+ video_path = _run_video_helper({**task, "topic": topic})
213
+ artifacts["video"] = str(video_path)
214
+ _update(task_id, artifacts=artifacts, progress=80)
215
+
216
+ _update(task_id, stage="upload", state="doing", progress=86)
217
+ result = upload_with_default_route(
218
+ settings,
219
+ storage_root=STORAGE,
220
+ channel=channel,
221
+ account=next((item for item in settings.get("youtube_batch_accounts", []) if isinstance(item, dict) and str(item.get("id")) == str(channel.get("google_account_id"))), None),
222
+ video_path=str(video_path),
223
+ title=title,
224
+ description=str(script.get("summary") or "") + "\n\n" + str(script.get("content") or "")[:5000],
225
+ tags=keywords,
226
+ language=str(task.get("language") or channel.get("language") or "pt-BR"),
227
+ privacy_status="unlisted",
228
+ thumbnail_path=str(thumbnail_path),
229
+ captions_path=str(artifacts.get("captions") or ""),
230
+ )
231
+ if not result.ok:
232
+ raise PipelineError(result.message)
233
+ artifacts["upload"] = result.data
234
+ return _update(task_id, stage="upload", state="done", progress=100, artifacts=artifacts, error=None)
235
+
236
+
237
+ def run_once() -> dict[str, Any]:
238
+ ensure_storage()
239
+ lock = _acquire_lock()
240
+ if lock is None:
241
+ return {"ok": True, "busy": True}
242
+ try:
243
+ tasks = read_json("tasks.json", [])
244
+ candidate = next((task for task in tasks if isinstance(task, dict) and task.get("state") in {"to_do", "doing"}), None)
245
+ if not candidate:
246
+ _write_worker_state(last_task_id=None, last_error="", status="idle")
247
+ return {"ok": True, "status": "idle"}
248
+ task_id = str(candidate.get("id") or "")
249
+ _write_worker_state(last_task_id=task_id, status="running", last_error="")
250
+ try:
251
+ result = _run_task(candidate)
252
+ _write_worker_state(status="completed", last_error="")
253
+ return {"ok": True, "task_id": task_id, "task": result}
254
+ except Exception as exc:
255
+ message = str(exc)[:2000]
256
+ from hermes_ui.domain import update_task
257
+ update_task(task_id, {"state": "failed", "error": message})
258
+ _write_worker_state(status="failed", last_error=message)
259
+ return {"ok": False, "task_id": task_id, "error": message}
260
+ finally:
261
+ try:
262
+ lock.unlink()
263
+ except FileNotFoundError:
264
+ pass
265
+
266
+
267
+ def run_worker(interval_seconds: int = 5) -> None:
268
+ ensure_storage()
269
+ while True:
270
+ run_once()
271
+ time.sleep(max(2, int(interval_seconds)))
272
+
273
+
274
+ def main() -> None:
275
+ import argparse
276
+
277
+ parser = argparse.ArgumentParser(description="Executor do pipeline de criação de vídeos Thunderbolt")
278
+ parser.add_argument("--once", action="store_true")
279
+ parser.add_argument("--interval", type=int, default=5)
280
+ args = parser.parse_args()
281
+ if args.once:
282
+ print(json.dumps(run_once(), ensure_ascii=False), flush=True)
283
+ else:
284
+ run_worker(args.interval)
285
+
286
+
287
+ if __name__ == "__main__":
288
+ main()
@@ -84,7 +84,7 @@ def upload_with_default_route(
84
84
  tags: list[str] | None = None,
85
85
  category_id: str = "22",
86
86
  language: str = "pt-BR",
87
- privacy_status: str = "private",
87
+ privacy_status: str = "unlisted",
88
88
  thumbnail_path: str = "",
89
89
  captions_path: str = "",
90
90
  official_uploader: Callable[..., IntegrationResult] | None = None,
@@ -155,8 +155,12 @@ class YouTubeDirectUploader:
155
155
  "initialMetadata": {
156
156
  "title": {"newTitle": title},
157
157
  "description": {"newDescription": description},
158
- "privacy": {"newPrivacy": visibility},
158
+ "privacy": {"newPrivacy": visibility or "unlisted"},
159
159
  "draftState": {"isDraft": False},
160
+ "allowEmbedding": {"newAllowEmbedding": True},
161
+ "allowAudioRemixing": {"newAllowAudioRemixing": True},
162
+ "allowVideoRemixing": {"newAllowVideoRemixing": True},
163
+ "notifySubscribers": {"newNotifySubscribers": True},
160
164
  "targetedAudience": {"operation": "MDE_TARGETED_AUDIENCE_UPDATE_OPERATION_SET", "newTargetedAudience": "MDE_TARGETED_AUDIENCE_TYPE_ALL"},
161
165
  },
162
166
  "botguardClientResponse": f"${hashlib.sha1(os.urandom(16)).hexdigest()}",
@@ -192,7 +196,7 @@ class YouTubeDirectUploader:
192
196
  response.raise_for_status()
193
197
  offset += len(chunk)
194
198
 
195
- def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "private", chunk_size: int | None = None) -> DirectUploadResult:
199
+ def upload(self, video_path: str | Path, *, title: str, description: str = "", visibility: str = "unlisted", chunk_size: int | None = None) -> DirectUploadResult:
196
200
  path = Path(video_path)
197
201
  validation_error = validate_direct_upload(path, self.channel, self.settings, self.account, self.storage_root)
198
202
  if validation_error:
@@ -58,13 +58,19 @@ def build_agent_video_metadata(
58
58
  tags: list[str] | None = None,
59
59
  category_id: str = "22",
60
60
  language: str = "pt-BR",
61
- privacy_status: str = "private",
61
+ privacy_status: str = "unlisted",
62
62
  publish_at: str | None = None,
63
+ embeddable: bool = True,
64
+ notify_subscribers: bool = True,
65
+ allow_audio_remixing: bool = True,
66
+ allow_video_remixing: bool = True,
63
67
  ) -> dict[str, Any]:
64
68
  """Build the snippet/status payload used by PublishingSchedulingAgent."""
65
69
  status: dict[str, Any] = {
66
- "privacyStatus": privacy_status or "private",
70
+ "privacyStatus": privacy_status or "unlisted",
67
71
  "selfDeclaredMadeForKids": False,
72
+ "embeddable": bool(embeddable),
73
+ "publicStatsViewable": True,
68
74
  }
69
75
  if publish_at and status["privacyStatus"] == "private":
70
76
  # YouTube requires a future publishAt with privacyStatus=private.
@@ -235,13 +241,17 @@ class _GoogleYouTubeBase:
235
241
  language=language,
236
242
  privacy_status=privacy_status,
237
243
  publish_at=publish_at,
244
+ embeddable=True,
245
+ notify_subscribers=True,
246
+ allow_audio_remixing=True,
247
+ allow_video_remixing=True,
238
248
  )
239
249
  try:
240
250
  from googleapiclient.http import MediaFileUpload
241
251
  except ImportError as exc:
242
252
  raise RuntimeError("A biblioteca de upload Google ainda não está instalada.") from exc
243
253
  media = MediaFileUpload(str(path), mimetype="video/mp4", chunksize=8 * 1024 * 1024, resumable=True)
244
- request = youtube.videos().insert(part="snippet,status", body=body, media_body=media)
254
+ request = youtube.videos().insert(part="snippet,status", body=body, media_body=media, notifySubscribers=True)
245
255
  response = None
246
256
  while response is None:
247
257
  _, response = request.next_chunk()
@@ -318,7 +328,7 @@ def upload_youtube_with_fallback(
318
328
  tags: list[str] | None = None,
319
329
  category_id: str = "22",
320
330
  language: str = "pt-BR",
321
- privacy_status: str = "private",
331
+ privacy_status: str = "unlisted",
322
332
  publish_at: str | None = None,
323
333
  thumbnail_path: str | None = None,
324
334
  captions_path: str | None = None,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.2.90",
3
+ "version": "0.2.92",
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",
@@ -20,6 +20,7 @@
20
20
  "seed/prompt_masters/**/*.md",
21
21
  "seed/blueprints/**/*.json",
22
22
  "seed/skills/*.md",
23
+ "seed/skills/*.py",
23
24
  "seed/references/*.md",
24
25
  "README.md",
25
26
  "MANUAL-INSTALACAO.md",
package/scripts/cli.mjs CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
+ import http from "node:http";
4
+ import net from "node:net";
3
5
  import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync } from "node:fs";
4
6
  import { homedir, platform } from "node:os";
5
7
  import { join, resolve } from "node:path";
@@ -145,14 +147,69 @@ if (args[0] === "worker" || args.includes("--worker")) {
145
147
  run(python, ["-m", "hermes_ui.automation_worker", ...args.filter((arg) => arg !== "worker" && arg !== "--worker")], "worker de automação");
146
148
  }
147
149
 
150
+ if (args[0] === "pipeline-worker" || args.includes("--pipeline-worker")) {
151
+ run(python, ["-m", "hermes_ui.pipeline_worker", ...args.filter((arg) => arg !== "pipeline-worker" && arg !== "--pipeline-worker")], "worker do pipeline de vídeos");
152
+ }
153
+
148
154
  const port = process.env.THUNDERBOLT_PORT || process.env.HERMES_PORT || "3030";
155
+ const publicPort = Number.parseInt(String(port), 10);
156
+ const backendPort = Number.isFinite(publicPort) ? publicPort + 1 : 3031;
157
+ const supportedLanguages = new Set(["en", "zh", "de", "vi", "tr", "pt", "ru", "es", "id", "it"]);
158
+
159
+ const proxy = http.createServer((request, response) => {
160
+ const requestUrl = new URL(request.url || "/", `http://localhost:${publicPort}`);
161
+ const pathParts = requestUrl.pathname.split("/").filter(Boolean);
162
+ const languagePrefix = pathParts.length === 1 && supportedLanguages.has(pathParts[0]) ? pathParts[0] : "";
163
+ if (languagePrefix) {
164
+ requestUrl.pathname = "/";
165
+ requestUrl.searchParams.set("lang", languagePrefix);
166
+ response.writeHead(302, { Location: `${requestUrl.pathname}?${requestUrl.searchParams.toString()}` });
167
+ response.end();
168
+ return;
169
+ }
170
+ const upstream = http.request({
171
+ hostname: "127.0.0.1",
172
+ port: backendPort,
173
+ method: request.method,
174
+ path: `${requestUrl.pathname}${requestUrl.search}`,
175
+ headers: { ...request.headers, host: `127.0.0.1:${backendPort}` },
176
+ }, (upstreamResponse) => {
177
+ response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
178
+ upstreamResponse.pipe(response);
179
+ });
180
+ upstream.on("error", (error) => {
181
+ response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
182
+ response.end(`Thunderbolt backend indisponível: ${error.message}`);
183
+ });
184
+ request.pipe(upstream);
185
+ });
186
+
187
+ proxy.on("upgrade", (request, clientSocket, head) => {
188
+ const upstreamSocket = net.connect(backendPort, "127.0.0.1", () => {
189
+ const headers = Object.entries(request.headers)
190
+ .map(([name, value]) => `${name}: ${Array.isArray(value) ? value.join(", ") : value}`)
191
+ .join("\\r\\n");
192
+ upstreamSocket.write(`GET ${request.url} HTTP/1.1\\r\\n${headers}\\r\\n\\r\\n`);
193
+ if (head.length) upstreamSocket.write(head);
194
+ clientSocket.pipe(upstreamSocket).pipe(clientSocket);
195
+ });
196
+ upstreamSocket.on("error", () => clientSocket.destroy());
197
+ });
198
+
199
+ proxy.listen(publicPort, "127.0.0.1");
149
200
  const worker = spawn(python, ["-m", "hermes_ui.automation_worker"], {
150
201
  cwd: root,
151
202
  stdio: "inherit",
152
203
  env: runtimeEnv,
153
204
  windowsHide: false,
154
205
  });
155
- const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", port, "--server.address", "localhost"], {
206
+ const pipelineWorker = spawn(python, ["-m", "hermes_ui.pipeline_worker"], {
207
+ cwd: root,
208
+ stdio: "inherit",
209
+ env: runtimeEnv,
210
+ windowsHide: false,
211
+ });
212
+ const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", String(backendPort), "--server.address", "127.0.0.1"], {
156
213
  cwd: root,
157
214
  stdio: "inherit",
158
215
  env: runtimeEnv,
@@ -160,7 +217,9 @@ const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", po
160
217
  });
161
218
 
162
219
  const stopWorker = () => {
220
+ proxy.close();
163
221
  if (!worker.killed) worker.kill();
222
+ if (!pipelineWorker.killed) pipelineWorker.kill();
164
223
  };
165
224
  process.on("SIGINT", stopWorker);
166
225
  process.on("SIGTERM", stopWorker);
@@ -169,3 +228,4 @@ child.on("exit", (code, signal) => {
169
228
  process.exit(code ?? (signal ? 1 : 0));
170
229
  });
171
230
  worker.on("error", (error) => console.error(`Thunderbolt worker: ${error.message}`));
231
+ pipelineWorker.on("error", (error) => console.error(`Thunderbolt pipeline worker: ${error.message}`));
@@ -0,0 +1,640 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform installation and video generation for the MoneyPrinterTurbo Skill."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import urllib.error
15
+ import urllib.request
16
+ import uuid
17
+ import zipfile
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+
22
+ PROJECT_ARCHIVE_URL = (
23
+ "https://github.com/harry0703/MoneyPrinterTurbo/archive/refs/heads/main.zip"
24
+ )
25
+ DEFAULT_ROOT = Path.home() / "MoneyPrinterTurbo"
26
+ DEFAULT_VOICE_NAME = "zh-CN-XiaoxiaoNeural-Female"
27
+ NEEDS_INPUT_EXIT_CODE = 10
28
+ SUPPORTED_SOURCES = {"pexels", "pixabay", "coverr", "local"}
29
+ PEXELS_API_KEY_URL = "https://www.pexels.com/api/"
30
+ PEXELS_VALIDATION_URL = "https://api.pexels.com/v1/collections?per_page=1"
31
+ PEXELS_API_KEY_HELP_URL = (
32
+ "https://help.pexels.com/hc/en-us/articles/"
33
+ "900004904026-How-do-I-get-an-API-key"
34
+ )
35
+
36
+ # Keep the recommended list focused on commonly used providers. When an LLM
37
+ # key is missing, the helper emits all choices at once to avoid extra turns.
38
+ RECOMMENDED_LLM_PROVIDERS = {
39
+ "moonshot": (
40
+ "Kimi / Moonshot AI",
41
+ "https://platform.kimi.com?track_id=track-2f5441d6ffd84c509dd079d78e9db5dc&aff=moneyprinterturbo",
42
+ ),
43
+ "openai": ("OpenAI", "https://platform.openai.com/api-keys"),
44
+ "gemini": ("Google Gemini", "https://aistudio.google.com/app/apikey"),
45
+ "deepseek": ("DeepSeek", "https://platform.deepseek.com/api_keys"),
46
+ "volcengine": (
47
+ "ByteDance VolcEngine Ark / Doubao",
48
+ "https://www.volcengine.com/activity/ai618?utm_source=MoneyPrinterTurbo",
49
+ ),
50
+ "minimax": ("MiniMax", "https://platform.minimax.io/"),
51
+ "mimo": (
52
+ "Xiaomi MiMo",
53
+ "https://platform.xiaomimimo.com/docs/zh-CN/quick-start/first-api-call",
54
+ ),
55
+ }
56
+ KEYLESS_LLM_PROVIDERS = {"ollama", "litellm"}
57
+ CUSTOM_OPENAI_PROVIDER = "oneapi"
58
+
59
+ # Hidden providers such as Qwen, Azure, and Grok remain usable when already
60
+ # selected, but are not automatic fallback candidates. A fully configured
61
+ # generic OpenAI-compatible endpoint can be reused safely.
62
+ ADDITIONAL_REUSABLE_PROVIDERS = (CUSTOM_OPENAI_PROVIDER,)
63
+
64
+
65
+ class SkillError(RuntimeError):
66
+ """An actionable Skill error that can be reported without a traceback."""
67
+
68
+
69
+ def log(message: str) -> None:
70
+ """Flush concise progress so the agent knows the long-running job started."""
71
+ print(f"[MoneyPrinterTurbo] {message}", flush=True)
72
+
73
+
74
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
75
+ parser = argparse.ArgumentParser(
76
+ description="Install MoneyPrinterTurbo and generate a final video from a topic."
77
+ )
78
+ parser.add_argument("--subject", required=True, help="video topic")
79
+ parser.add_argument(
80
+ "--root",
81
+ type=Path,
82
+ default=DEFAULT_ROOT,
83
+ help=f"MoneyPrinterTurbo installation directory (default: {DEFAULT_ROOT})",
84
+ )
85
+ parser.add_argument(
86
+ "cli_args",
87
+ nargs=argparse.REMAINDER,
88
+ help="additional MoneyPrinterTurbo CLI arguments placed after --",
89
+ )
90
+ args = parser.parse_args(argv)
91
+ args.subject = args.subject.strip()
92
+ if not args.subject:
93
+ parser.error("--subject cannot be empty")
94
+ if args.cli_args and args.cli_args[0] == "--":
95
+ args.cli_args = args.cli_args[1:]
96
+ return args
97
+
98
+
99
+ def _safe_extract(archive: zipfile.ZipFile, destination: Path) -> None:
100
+ """Reject ZIP entries that would escape the temporary extraction directory."""
101
+ destination = destination.resolve()
102
+ for member in archive.infolist():
103
+ target = (destination / member.filename).resolve()
104
+ if target != destination and destination not in target.parents:
105
+ raise SkillError(f"project archive contains an unsafe path: {member.filename}")
106
+ archive.extractall(destination)
107
+
108
+
109
+ def ensure_project(root: Path) -> None:
110
+ """Reuse an existing project or install it from the official GitHub archive."""
111
+ root = root.expanduser().resolve()
112
+ if (root / "cli.py").is_file() and (root / "config.example.toml").is_file():
113
+ log(f"using existing project: {root}")
114
+ return
115
+ if root.exists() and any(root.iterdir()):
116
+ raise SkillError(f"installation directory exists but is not a valid project: {root}")
117
+
118
+ root.parent.mkdir(parents=True, exist_ok=True)
119
+ log(f"first-time installation: downloading the official project to {root}")
120
+ with tempfile.TemporaryDirectory(prefix="mpt-install-") as temp_dir_value:
121
+ temp_dir = Path(temp_dir_value)
122
+ archive_path = temp_dir / "MoneyPrinterTurbo.zip"
123
+ request = urllib.request.Request(
124
+ PROJECT_ARCHIVE_URL,
125
+ headers={"User-Agent": "MoneyPrinterTurbo-Agent-Skill"},
126
+ )
127
+ with urllib.request.urlopen(request, timeout=120) as response:
128
+ # Stream the archive to avoid holding a second full copy in memory.
129
+ with archive_path.open("wb") as archive_file:
130
+ shutil.copyfileobj(response, archive_file)
131
+ with zipfile.ZipFile(archive_path) as archive:
132
+ _safe_extract(archive, temp_dir)
133
+
134
+ candidates = [
135
+ path
136
+ for path in temp_dir.iterdir()
137
+ if path.is_dir() and (path / "cli.py").is_file()
138
+ ]
139
+ if len(candidates) != 1:
140
+ raise SkillError("download completed but no valid MoneyPrinterTurbo project was found")
141
+ if root.exists():
142
+ root.rmdir()
143
+ shutil.move(str(candidates[0]), str(root))
144
+ log("project download completed")
145
+
146
+
147
+ def ensure_config(root: Path) -> Path:
148
+ """Create the initial configuration without overwriting an existing file."""
149
+ config_path = root / "config.toml"
150
+ if not config_path.exists():
151
+ shutil.copy2(root / "config.example.toml", config_path)
152
+ log(f"created configuration file: {config_path}")
153
+ return config_path
154
+
155
+
156
+ def _plain_config_value(text: str, key: str) -> str:
157
+ """Read a simple top-level TOML value without printing its contents."""
158
+ match = re.search(rf"(?m)^{re.escape(key)}\s*=\s*(.*)$", text)
159
+ if not match:
160
+ return ""
161
+ value = match.group(1).split("#", 1)[0].strip()
162
+ if value.startswith('"') and value.endswith('"'):
163
+ return value[1:-1]
164
+ return value
165
+
166
+
167
+ def _replace_config_value(text: str, key: str, value: object) -> str:
168
+ """Replace one active field while preserving the configuration layout."""
169
+ pattern = re.compile(rf"(?m)^({re.escape(key)}\s*=\s*).*$")
170
+ if not pattern.search(text):
171
+ raise SkillError(f"configuration field not found in config.toml: {key}")
172
+ encoded = json.dumps(value, ensure_ascii=False)
173
+ return pattern.sub(lambda match: f"{match.group(1)}{encoded}", text, count=1)
174
+
175
+
176
+ def _has_configured_value(value: str) -> bool:
177
+ """Treat empty strings and whitespace-only key arrays as unconfigured."""
178
+ if not value:
179
+ return False
180
+ try:
181
+ parsed = json.loads(value)
182
+ except json.JSONDecodeError:
183
+ return bool(value.strip())
184
+ if isinstance(parsed, list):
185
+ return any(str(item).strip() for item in parsed)
186
+ return bool(str(parsed).strip())
187
+
188
+
189
+ def _parse_string_list(value: str) -> list[str]:
190
+ """Parse a configured string list while removing blanks and duplicates."""
191
+ try:
192
+ parsed = json.loads(value)
193
+ except json.JSONDecodeError:
194
+ return []
195
+ if not isinstance(parsed, list):
196
+ return []
197
+ return list(dict.fromkeys(str(item).strip() for item in parsed if str(item).strip()))
198
+
199
+
200
+ def apply_environment_config(config_path: Path) -> None:
201
+ """Write supplied credentials while logging field names only."""
202
+ provider = os.environ.get("MPT_LLM_PROVIDER", "").strip().lower()
203
+ if provider == "openai_compatible":
204
+ provider = CUSTOM_OPENAI_PROVIDER
205
+ llm_key = os.environ.get("MPT_LLM_API_KEY", "").strip()
206
+ base_url = os.environ.get("MPT_LLM_BASE_URL", "").strip()
207
+ model_name = os.environ.get("MPT_LLM_MODEL_NAME", "").strip()
208
+ pexels_key = os.environ.get("MPT_PEXELS_API_KEY", "").strip()
209
+ if not any((provider, llm_key, base_url, model_name, pexels_key)):
210
+ return
211
+
212
+ text = config_path.read_text(encoding="utf-8")
213
+ current_provider = _plain_config_value(text, "llm_provider") or "moonshot"
214
+ provider = provider or current_provider
215
+ changes: list[str] = []
216
+ if os.environ.get("MPT_LLM_PROVIDER", "").strip():
217
+ text = _replace_config_value(text, "llm_provider", provider)
218
+ changes.append("llm_provider")
219
+ if llm_key:
220
+ text = _replace_config_value(text, f"{provider}_api_key", llm_key)
221
+ changes.append(f"{provider}_api_key")
222
+ if base_url:
223
+ text = _replace_config_value(text, f"{provider}_base_url", base_url)
224
+ changes.append(f"{provider}_base_url")
225
+ if model_name:
226
+ text = _replace_config_value(text, f"{provider}_model_name", model_name)
227
+ changes.append(f"{provider}_model_name")
228
+ if pexels_key:
229
+ text = _replace_config_value(text, "pexels_api_keys", [pexels_key])
230
+ changes.append("pexels_api_keys")
231
+ config_path.write_text(text, encoding="utf-8")
232
+ log("updated configuration fields: " + ", ".join(changes))
233
+
234
+
235
+ def _provider_is_ready(text: str, provider: str) -> bool:
236
+ """Return whether a provider has enough configuration to generate."""
237
+ if provider in KEYLESS_LLM_PROVIDERS:
238
+ return True
239
+ if not _has_configured_value(
240
+ _plain_config_value(text, f"{provider}_api_key")
241
+ ):
242
+ return False
243
+ if provider == CUSTOM_OPENAI_PROVIDER:
244
+ return all(
245
+ _has_configured_value(_plain_config_value(text, f"{provider}_{suffix}"))
246
+ for suffix in ("base_url", "model_name")
247
+ )
248
+ return True
249
+
250
+
251
+ def reuse_existing_llm_provider(config_path: Path) -> str:
252
+ """
253
+ Reuse existing LLM credentials before asking the user for another key.
254
+
255
+ Keep the current provider when it is ready. Otherwise, scan configured
256
+ recommended providers in UI order and update ``llm_provider``. Credential
257
+ values are inspected in memory and are never logged.
258
+ """
259
+ text = config_path.read_text(encoding="utf-8")
260
+ current_provider = _plain_config_value(text, "llm_provider") or "moonshot"
261
+ if _provider_is_ready(text, current_provider):
262
+ return current_provider
263
+
264
+ reusable_providers = (
265
+ *RECOMMENDED_LLM_PROVIDERS,
266
+ *ADDITIONAL_REUSABLE_PROVIDERS,
267
+ )
268
+ for provider in reusable_providers:
269
+ if _provider_is_ready(text, provider):
270
+ text = _replace_config_value(text, "llm_provider", provider)
271
+ config_path.write_text(text, encoding="utf-8")
272
+ log(f"reusing configured LLM provider: {provider}")
273
+ return provider
274
+ return current_provider
275
+
276
+
277
+ def selected_video_source(cli_args: list[str]) -> str:
278
+ """Read the effective material source from forwarded CLI arguments."""
279
+ for index, item in enumerate(cli_args):
280
+ if item == "--video-source" and index + 1 < len(cli_args):
281
+ return cli_args[index + 1].strip().lower()
282
+ if item.startswith("--video-source="):
283
+ return item.split("=", 1)[1].strip().lower()
284
+ return "pexels"
285
+
286
+
287
+ def has_cli_option(cli_args: list[str], option: str) -> bool:
288
+ """Return whether forwarded arguments explicitly set a CLI option."""
289
+ return any(item == option or item.startswith(f"{option}=") for item in cli_args)
290
+
291
+
292
+ def missing_config(config_path: Path, cli_args: list[str]) -> tuple[str, list[str]]:
293
+ """Return the active provider and only the fields required by this run."""
294
+ text = config_path.read_text(encoding="utf-8")
295
+ provider = _plain_config_value(text, "llm_provider") or "moonshot"
296
+ missing: list[str] = []
297
+ if provider not in KEYLESS_LLM_PROVIDERS and not _has_configured_value(
298
+ _plain_config_value(text, f"{provider}_api_key")
299
+ ):
300
+ missing.append(f"{provider}_api_key")
301
+ if provider == CUSTOM_OPENAI_PROVIDER:
302
+ for suffix in ("base_url", "model_name"):
303
+ field = f"{provider}_{suffix}"
304
+ if not _has_configured_value(_plain_config_value(text, field)):
305
+ missing.append(field)
306
+
307
+ source = selected_video_source(cli_args)
308
+ if source not in SUPPORTED_SOURCES:
309
+ raise SkillError(f"unsupported video source: {source}")
310
+ if source != "local":
311
+ value = _plain_config_value(text, f"{source}_api_keys")
312
+ if not _has_configured_value(value):
313
+ missing.append(f"{source}_api_keys")
314
+ return provider, missing
315
+
316
+
317
+ def report_missing_config(provider: str, missing: list[str]) -> int:
318
+ """Tell the agent exactly which credentials must be requested."""
319
+ print("MPT_NEEDS_INPUT")
320
+ print(f"LLM_PROVIDER={provider}")
321
+ for field in missing:
322
+ print(f"MISSING={field}")
323
+ if any(field.endswith("_api_key") for field in missing):
324
+ print("LLM_PROVIDER_OPTIONS_BEGIN")
325
+ for provider_id, (label, url) in RECOMMENDED_LLM_PROVIDERS.items():
326
+ print(f"LLM_PROVIDER_OPTION={provider_id}|{label}|{url}")
327
+ print(
328
+ "LLM_PROVIDER_OPTION=oneapi|Other OpenAI-compatible provider|"
329
+ "requires an API key, API base URL, and model name"
330
+ )
331
+ print("LLM_PROVIDER_OPTIONS_END")
332
+ if any(field.startswith(f"{CUSTOM_OPENAI_PROVIDER}_") for field in missing):
333
+ print(
334
+ "OPENAI_COMPATIBLE_REQUIRED="
335
+ "API key, API base URL, model name"
336
+ )
337
+ if "pexels_api_keys" in missing:
338
+ print(f"PEXELS_API_KEY_URL={PEXELS_API_KEY_URL}")
339
+ print(f"PEXELS_API_KEY_HELP_URL={PEXELS_API_KEY_HELP_URL}")
340
+ print("Request only the listed values, set the environment variables, and rerun the same command.")
341
+ return NEEDS_INPUT_EXIT_CODE
342
+
343
+
344
+ def report_invalid_pexels_config() -> int:
345
+ """Request only a new Pexels key when every configured key is rejected."""
346
+ print("MPT_NEEDS_INPUT")
347
+ print("INVALID=pexels_api_keys")
348
+ print(f"PEXELS_API_KEY_URL={PEXELS_API_KEY_URL}")
349
+ print(f"PEXELS_API_KEY_HELP_URL={PEXELS_API_KEY_HELP_URL}")
350
+ print("All configured Pexels API keys were rejected or are unavailable. Provide a new key.")
351
+ return NEEDS_INPUT_EXIT_CODE
352
+
353
+
354
+ def _validate_pexels_key(api_key: str) -> str:
355
+ """
356
+ Return ``valid``, ``rejected``, or ``unknown`` for a Pexels key.
357
+
358
+ HTTP 401, 403, and rate-limited 429 responses make a key unusable for this
359
+ run. Network and server errors return unknown so the configuration is kept.
360
+ """
361
+ # Curated and popular search requests may hit a public cache and return 200
362
+ # without valid authorization. My Collections is account-specific, requires
363
+ # authentication, and still returns 200 for an empty collection list.
364
+ request = urllib.request.Request(
365
+ PEXELS_VALIDATION_URL,
366
+ headers={
367
+ "Authorization": api_key,
368
+ "User-Agent": "MoneyPrinterTurbo-Agent-Skill",
369
+ },
370
+ )
371
+ try:
372
+ with urllib.request.urlopen(request, timeout=15) as response:
373
+ return "valid" if 200 <= response.status < 300 else "unknown"
374
+ except urllib.error.HTTPError as exc:
375
+ if exc.code in {401, 403, 429}:
376
+ return "rejected"
377
+ return "unknown"
378
+ except (TimeoutError, urllib.error.URLError):
379
+ return "unknown"
380
+
381
+
382
+ def validate_pexels_config(config_path: Path, cli_args: list[str]) -> bool:
383
+ """
384
+ Validate all Pexels keys used by the default material source.
385
+
386
+ Downstream code selects configured keys randomly. Keeping rejected keys can
387
+ cause intermittent 401 responses and missing material results. If at least
388
+ one key is verified, retain only verified keys. If validation is impossible
389
+ because of a transient network failure, keep the original configuration.
390
+ """
391
+ if selected_video_source(cli_args) != "pexels":
392
+ return True
393
+
394
+ text = config_path.read_text(encoding="utf-8")
395
+ keys = _parse_string_list(_plain_config_value(text, "pexels_api_keys"))
396
+ if not keys:
397
+ return False
398
+
399
+ valid_keys: list[str] = []
400
+ rejected_count = 0
401
+ unknown_count = 0
402
+ for api_key in keys:
403
+ status = _validate_pexels_key(api_key)
404
+ if status == "valid":
405
+ valid_keys.append(api_key)
406
+ elif status == "rejected":
407
+ rejected_count += 1
408
+ else:
409
+ unknown_count += 1
410
+
411
+ if valid_keys:
412
+ if valid_keys != keys:
413
+ text = _replace_config_value(text, "pexels_api_keys", valid_keys)
414
+ config_path.write_text(text, encoding="utf-8")
415
+ log(
416
+ "Pexels key validation completed: "
417
+ f"valid={len(valid_keys)}, rejected={rejected_count}, "
418
+ f"unknown={unknown_count}"
419
+ )
420
+ return True
421
+ if unknown_count:
422
+ log("Pexels keys could not be verified due to a network or service error; keeping the existing configuration")
423
+ return True
424
+
425
+ log(f"Pexels key validation failed: all {rejected_count} configured keys are unusable")
426
+ return False
427
+
428
+
429
+ def result_manifest_path(root: Path) -> Path:
430
+ return root / ".agent-logs" / "moneyprinterturbo-video" / "latest-result.json"
431
+
432
+
433
+ def write_result_manifest(root: Path, payload: dict[str, object]) -> Path:
434
+ """
435
+ Atomically write the stable result file for agents that cannot wait.
436
+
437
+ The file contains task status and result paths only, never configuration
438
+ contents, credentials, or full logs.
439
+ """
440
+ result_path = result_manifest_path(root)
441
+ result_path.parent.mkdir(parents=True, exist_ok=True)
442
+ data = {
443
+ "updated_at": datetime.now(timezone.utc).isoformat(),
444
+ **payload,
445
+ }
446
+ unique_suffix = str(uuid.uuid4()).replace("-", "")
447
+ temp_path = result_path.with_name(
448
+ f".{result_path.name}.{os.getpid()}.{unique_suffix}.tmp"
449
+ )
450
+ temp_path.write_text(
451
+ json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8"
452
+ )
453
+ temp_path.replace(result_path)
454
+ return result_path.resolve()
455
+
456
+
457
+ def run_checked(command: list[str], *, cwd: Path) -> None:
458
+ """Run dependency sync quietly and show only the last 30 lines on failure."""
459
+ log("installing or verifying project dependencies with uv")
460
+ result = subprocess.run(
461
+ command,
462
+ cwd=cwd,
463
+ stdout=subprocess.PIPE,
464
+ stderr=subprocess.STDOUT,
465
+ text=True,
466
+ errors="replace",
467
+ check=False,
468
+ )
469
+ if result.returncode != 0:
470
+ output_tail = (result.stdout or "").splitlines()[-30:]
471
+ if output_tail:
472
+ print("\n".join(output_tail), file=sys.stderr)
473
+ raise SkillError(f"dependency installation failed with exit code {result.returncode}")
474
+
475
+
476
+ def generate_video(
477
+ root: Path,
478
+ subject: str,
479
+ cli_args: list[str],
480
+ ) -> tuple[list[Path], Path, Path, Path]:
481
+ """Run one traceable CLI task and return only its final video files."""
482
+ uv = shutil.which("uv")
483
+ if not uv:
484
+ raise SkillError("uv was not found; reopen the terminal or add uv to PATH")
485
+ run_checked([uv, "sync", "--frozen"], cwd=root)
486
+
487
+ task_id = str(uuid.uuid4())
488
+ task_dir = root / "storage" / "tasks" / task_id
489
+ log_dir = root / ".agent-logs" / "moneyprinterturbo-video"
490
+ log_dir.mkdir(parents=True, exist_ok=True)
491
+ log_path = log_dir / f"run-{task_id}.log"
492
+ write_result_manifest(
493
+ root,
494
+ {
495
+ "status": "running",
496
+ "subject": subject,
497
+ "task_id": task_id,
498
+ "task_dir": str(task_dir.resolve()),
499
+ "log_file": str(log_path.resolve()),
500
+ "video_files": [],
501
+ },
502
+ )
503
+ voice_args = (
504
+ []
505
+ if has_cli_option(cli_args, "--voice-name")
506
+ else ["--voice-name", DEFAULT_VOICE_NAME]
507
+ )
508
+ command = [
509
+ uv,
510
+ "run",
511
+ "python",
512
+ "cli.py",
513
+ *cli_args,
514
+ "--video-subject",
515
+ subject,
516
+ "--task-id",
517
+ task_id,
518
+ # Older CLI versions leave voice_name empty and fail during Edge TTS
519
+ # with ``Invalid voice ''``. Supply a stable Chinese voice unless the
520
+ # user has explicitly selected another voice.
521
+ *voice_args,
522
+ # A Skill request must produce a finished video. Force the final stage
523
+ # so forwarded options cannot stop at script, audio, or materials.
524
+ "--stop-at",
525
+ "video",
526
+ ]
527
+ log(f"starting video generation, task ID: {task_id}")
528
+ log(f"full generation log: {log_path}")
529
+ with log_path.open("w", encoding="utf-8") as log_file:
530
+ result = subprocess.run(
531
+ command,
532
+ cwd=root,
533
+ stdout=log_file,
534
+ stderr=subprocess.STDOUT,
535
+ text=True,
536
+ check=False,
537
+ )
538
+ if result.returncode != 0:
539
+ tail = log_path.read_text(encoding="utf-8", errors="replace").splitlines()[-30:]
540
+ if tail:
541
+ print("\n".join(tail), file=sys.stderr)
542
+ error = (
543
+ f"video generation failed with exit code {result.returncode}; "
544
+ f"log: {log_path}"
545
+ )
546
+ write_result_manifest(
547
+ root,
548
+ {
549
+ "status": "failed",
550
+ "subject": subject,
551
+ "task_id": task_id,
552
+ "task_dir": str(task_dir.resolve()),
553
+ "log_file": str(log_path.resolve()),
554
+ "video_files": [],
555
+ "error": error,
556
+ },
557
+ )
558
+ raise SkillError(error)
559
+
560
+ videos = sorted(
561
+ path.resolve()
562
+ for path in task_dir.glob("final-*.mp4")
563
+ if path.is_file() and path.stat().st_size > 0
564
+ )
565
+ if not videos:
566
+ error = f"generation completed without a valid final MP4; log: {log_path}"
567
+ write_result_manifest(
568
+ root,
569
+ {
570
+ "status": "failed",
571
+ "subject": subject,
572
+ "task_id": task_id,
573
+ "task_dir": str(task_dir.resolve()),
574
+ "log_file": str(log_path.resolve()),
575
+ "video_files": [],
576
+ "error": error,
577
+ },
578
+ )
579
+ raise SkillError(error)
580
+ result_path = write_result_manifest(
581
+ root,
582
+ {
583
+ "status": "completed",
584
+ "subject": subject,
585
+ "task_id": task_id,
586
+ "task_dir": str(task_dir.resolve()),
587
+ "log_file": str(log_path.resolve()),
588
+ "video_files": [str(video) for video in videos],
589
+ },
590
+ )
591
+ return videos, task_dir.resolve(), log_path.resolve(), result_path
592
+
593
+
594
+ def main(argv: list[str] | None = None) -> int:
595
+ args = parse_args(argv)
596
+ root = args.root.expanduser().resolve()
597
+ try:
598
+ ensure_project(root)
599
+ config_path = ensure_config(root)
600
+ apply_environment_config(config_path)
601
+ reuse_existing_llm_provider(config_path)
602
+ provider, missing = missing_config(config_path, args.cli_args)
603
+ if missing:
604
+ write_result_manifest(
605
+ root,
606
+ {
607
+ "status": "needs_input",
608
+ "subject": args.subject,
609
+ "missing": missing,
610
+ },
611
+ )
612
+ return report_missing_config(provider, missing)
613
+ if not validate_pexels_config(config_path, args.cli_args):
614
+ write_result_manifest(
615
+ root,
616
+ {
617
+ "status": "needs_input",
618
+ "subject": args.subject,
619
+ "invalid": ["pexels_api_keys"],
620
+ },
621
+ )
622
+ return report_invalid_pexels_config()
623
+ videos, task_dir, log_path, result_path = generate_video(
624
+ root, args.subject, args.cli_args
625
+ )
626
+ except (OSError, SkillError, urllib.error.URLError, zipfile.BadZipFile) as exc:
627
+ print(f"MPT_ERROR={exc}", file=sys.stderr)
628
+ return 1
629
+
630
+ print("MPT_RESULT")
631
+ for video in videos:
632
+ print(f"VIDEO_FILE={video}")
633
+ print(f"TASK_DIR={task_dir}")
634
+ print(f"LOG_FILE={log_path}")
635
+ print(f"RESULT_FILE={result_path}")
636
+ return 0
637
+
638
+
639
+ if __name__ == "__main__":
640
+ raise SystemExit(main())