@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 +39 -11
- package/hermes_ui/creative_generation.py +22 -4
- package/hermes_ui/domain.py +1 -1
- package/hermes_ui/languages.py +18 -6
- package/hermes_ui/pipeline_worker.py +288 -0
- package/integrations/upload_routing.py +1 -1
- package/integrations/youtube_direct_upload.py +6 -2
- package/integrations/youtube_upload.py +14 -4
- package/package.json +2 -1
- package/scripts/cli.mjs +61 -1
- package/seed/skills/mpt_agent.py +640 -0
package/app/main.py
CHANGED
|
@@ -216,9 +216,20 @@ st.markdown("""
|
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
def current_ui_language() -> str:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
1829
|
-
st.error("
|
|
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",
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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
|
|
package/hermes_ui/languages.py
CHANGED
|
@@ -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
|
-
|
|
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 = "
|
|
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 = "
|
|
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 = "
|
|
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 "
|
|
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 = "
|
|
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.
|
|
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
|
|
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())
|