@danhachuel/thunderbolt 0.2.90 → 0.2.91

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
@@ -1718,13 +1718,16 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1718
1718
  if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
1719
1719
  if selected_one is None:
1720
1720
  st.error("Seleccione primeiro um canal.")
1721
- elif not topic_for_creative:
1722
- st.error("Escreva ou gere primeiro um tópico/briefing.")
1723
1721
  else:
1724
1722
  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")
1723
+ if not topic_for_creative:
1724
+ topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one)
1725
+ topic_for_creative = str(topic_result["topic"]).strip()
1726
+ st.session_state["new_video_topic"] = topic_for_creative
1727
+ st.session_state["new_video_topic_meta"] = topic_result
1728
+ generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm")
1726
1729
  st.session_state["new_video_creative_payload"] = generated
1727
- st.success("Título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
1730
+ st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
1728
1731
  st.rerun()
1729
1732
  except CreativeGenerationError as exc:
1730
1733
  st.error(str(exc))
@@ -1825,9 +1828,18 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1825
1828
  st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
1826
1829
  else:
1827
1830
  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.")
1831
+ if not selected:
1832
+ st.error("Seleccione um canal.")
1830
1833
  else:
1834
+ if not topic_value:
1835
+ try:
1836
+ topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one or {})
1837
+ topic_value = str(topic_result["topic"]).strip()
1838
+ st.session_state["new_video_topic"] = topic_value
1839
+ st.session_state["new_video_topic_meta"] = topic_result
1840
+ except CreativeGenerationError as exc:
1841
+ st.error(f"Não foi possível gerar automaticamente o tema: {exc}")
1842
+ st.stop()
1831
1843
  quantity_value = int(quantity if mode == "same_channel" else 1)
1832
1844
  payload = dict(st.session_state.get("new_video_creative_payload") or {})
1833
1845
  if not payload.get("title") or not payload.get("thumbnail_variants"):
@@ -3169,7 +3181,8 @@ def render_upload_conventional():
3169
3181
  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
3182
  yt_cols = st.columns(3)
3171
3183
  with yt_cols[0]:
3172
- privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"yt_privacy_{task['id']}")
3184
+ privacy_status = st.selectbox("Privacidade", ["private", "unlisted", "public"], index=1, key=f"yt_privacy_{task['id']}")
3185
+ st.caption("Fluxo recomendado: não listado · incorporação activa · permitir remix de áudio e vídeo · publicar no feed de subscritos.")
3173
3186
  with yt_cols[1]:
3174
3187
  category_id = st.text_input("Category ID", value="22", key=f"yt_category_{task['id']}")
3175
3188
  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
 
@@ -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.91",
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
@@ -145,6 +145,10 @@ if (args[0] === "worker" || args.includes("--worker")) {
145
145
  run(python, ["-m", "hermes_ui.automation_worker", ...args.filter((arg) => arg !== "worker" && arg !== "--worker")], "worker de automação");
146
146
  }
147
147
 
148
+ if (args[0] === "pipeline-worker" || args.includes("--pipeline-worker")) {
149
+ run(python, ["-m", "hermes_ui.pipeline_worker", ...args.filter((arg) => arg !== "pipeline-worker" && arg !== "--pipeline-worker")], "worker do pipeline de vídeos");
150
+ }
151
+
148
152
  const port = process.env.THUNDERBOLT_PORT || process.env.HERMES_PORT || "3030";
149
153
  const worker = spawn(python, ["-m", "hermes_ui.automation_worker"], {
150
154
  cwd: root,
@@ -152,6 +156,12 @@ const worker = spawn(python, ["-m", "hermes_ui.automation_worker"], {
152
156
  env: runtimeEnv,
153
157
  windowsHide: false,
154
158
  });
159
+ const pipelineWorker = spawn(python, ["-m", "hermes_ui.pipeline_worker"], {
160
+ cwd: root,
161
+ stdio: "inherit",
162
+ env: runtimeEnv,
163
+ windowsHide: false,
164
+ });
155
165
  const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", port, "--server.address", "localhost"], {
156
166
  cwd: root,
157
167
  stdio: "inherit",
@@ -161,6 +171,7 @@ const child = spawn(python, ["-m", "streamlit", "run", main, "--server.port", po
161
171
 
162
172
  const stopWorker = () => {
163
173
  if (!worker.killed) worker.kill();
174
+ if (!pipelineWorker.killed) pipelineWorker.kill();
164
175
  };
165
176
  process.on("SIGINT", stopWorker);
166
177
  process.on("SIGTERM", stopWorker);
@@ -169,3 +180,4 @@ child.on("exit", (code, signal) => {
169
180
  process.exit(code ?? (signal ? 1 : 0));
170
181
  });
171
182
  worker.on("error", (error) => console.error(`Thunderbolt worker: ${error.message}`));
183
+ 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())