@danhachuel/thunderbolt 0.2.89 → 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 +24 -10
- package/hermes_ui/creative_generation.py +22 -4
- package/hermes_ui/domain.py +1 -1
- package/hermes_ui/pipeline_worker.py +288 -0
- package/hermes_ui/storage.py +22 -2
- 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 +12 -0
- package/scripts/install.mjs +3 -0
- package/seed/skills/mpt_agent.py +640 -0
package/app/main.py
CHANGED
|
@@ -22,7 +22,7 @@ except (OSError, json.JSONDecodeError):
|
|
|
22
22
|
|
|
23
23
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
24
24
|
from hermes_ui.automation_worker import load_worker_status
|
|
25
|
-
from hermes_ui.storage import BLUEPRINTS, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
|
|
25
|
+
from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
|
|
26
26
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
27
27
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
28
28
|
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
@@ -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
|
-
|
|
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("
|
|
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
|
|
1829
|
-
st.error("
|
|
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]:
|
|
@@ -3566,8 +3579,9 @@ def render_settings():
|
|
|
3566
3579
|
gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
|
|
3567
3580
|
|
|
3568
3581
|
with st.expander("LLM — providers e modelos", expanded=True):
|
|
3569
|
-
provider_options = ["
|
|
3570
|
-
|
|
3582
|
+
provider_options = ["openai", "moonshot", "shengsuanyun", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
|
|
3583
|
+
current_llm_provider = str(settings.get("llm_provider") or DEFAULT_LLM_PROVIDER).strip().lower()
|
|
3584
|
+
llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(current_llm_provider) if current_llm_provider in provider_options else 0)
|
|
3571
3585
|
st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
|
|
3572
3586
|
st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
|
|
3573
3587
|
openai_cols = st.columns(3)
|
|
@@ -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
|
|
|
@@ -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()
|
package/hermes_ui/storage.py
CHANGED
|
@@ -10,6 +10,8 @@ from typing import Any
|
|
|
10
10
|
|
|
11
11
|
ROOT = Path(__file__).resolve().parents[1]
|
|
12
12
|
STORAGE = Path(os.getenv("THUNDERBOLT_STORAGE_DIR") or ROOT / "storage")
|
|
13
|
+
DEFAULT_LLM_PROVIDER = "openai"
|
|
14
|
+
LEGACY_DEFAULT_LLM_PROVIDERS = {"", "moonshot"}
|
|
13
15
|
STATE = STORAGE / "state"
|
|
14
16
|
BLUEPRINTS = STORAGE / "blueprints"
|
|
15
17
|
TIKTOK_PROMPT_MASTERS = STORAGE / "tiktok" / "prompts_master"
|
|
@@ -125,7 +127,7 @@ DEFAULTS: dict[str, Any] = {
|
|
|
125
127
|
"apify_actor_id": "streamers~youtube-scraper",
|
|
126
128
|
"apify_poll_interval_seconds": 10,
|
|
127
129
|
"apify_run_timeout_seconds": 900,
|
|
128
|
-
"llm_provider":
|
|
130
|
+
"llm_provider": DEFAULT_LLM_PROVIDER
|
|
129
131
|
"moonshot_api_key": "",
|
|
130
132
|
"moonshot_base_url": "",
|
|
131
133
|
"moonshot_model_name": "",
|
|
@@ -290,6 +292,18 @@ def seed_prompt_masters() -> None:
|
|
|
290
292
|
shutil.copy2(source, target)
|
|
291
293
|
|
|
292
294
|
|
|
295
|
+
def _migrate_settings(settings: Any) -> tuple[dict[str, Any], bool]:
|
|
296
|
+
"""Keep the shipped OpenAI/NVIDIA NIM default across package upgrades."""
|
|
297
|
+
if not isinstance(settings, dict):
|
|
298
|
+
return {"llm_provider": DEFAULT_LLM_PROVIDER}, True
|
|
299
|
+
|
|
300
|
+
provider = str(settings.get("llm_provider") or "").strip().lower()
|
|
301
|
+
if provider not in LEGACY_DEFAULT_LLM_PROVIDERS:
|
|
302
|
+
return settings, False
|
|
303
|
+
|
|
304
|
+
return {**settings, "llm_provider": DEFAULT_LLM_PROVIDER}, True
|
|
305
|
+
|
|
306
|
+
|
|
293
307
|
def ensure_storage() -> None:
|
|
294
308
|
for path in [STATE, BLUEPRINTS / "canais", BLUEPRINTS / "nichos", BLUEPRINTS / "importados", BLUEPRINTS / "brandings", TIKTOK_PROMPT_MASTERS, MEDIA_DOWNLOADS, STORAGE / "brand", STORAGE / "scripts", STORAGE / "thumbnails", STORAGE / "videos", STORAGE / "artifacts", STORAGE / "skills", STORAGE / "metadata_cleaner", STORAGE / "metadata_cleaner" / "outputs", STORAGE / "music", STORAGE / "voice_previews", STORAGE / "python_editor", NICHES_DATA]:
|
|
295
309
|
path.mkdir(parents=True, exist_ok=True)
|
|
@@ -321,7 +335,13 @@ def read_json(name: str, default: Any | None = None) -> Any:
|
|
|
321
335
|
path = STATE / name
|
|
322
336
|
try:
|
|
323
337
|
with path.open("r", encoding="utf-8") as handle:
|
|
324
|
-
|
|
338
|
+
data = json.load(handle)
|
|
339
|
+
if name != "settings.json":
|
|
340
|
+
return data
|
|
341
|
+
migrated, changed = _migrate_settings(data)
|
|
342
|
+
if changed:
|
|
343
|
+
atomic_write(path, migrated)
|
|
344
|
+
return migrated
|
|
325
345
|
except (json.JSONDecodeError, OSError):
|
|
326
346
|
backup = path.with_suffix(path.suffix + f".corrupt-{datetime.now().strftime('%Y%m%d%H%M%S')}")
|
|
327
347
|
if path.exists():
|
|
@@ -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.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}`));
|
package/scripts/install.mjs
CHANGED
|
@@ -302,6 +302,9 @@ function writeSettings(moneyprinterPath) {
|
|
|
302
302
|
try { settings = JSON.parse(readFileSync(settingsPath, "utf8")); } catch { settings = {}; }
|
|
303
303
|
}
|
|
304
304
|
settings.moneyprinter_path = moneyprinterPath;
|
|
305
|
+
if (!settings.llm_provider || String(settings.llm_provider).trim().toLowerCase() === "moonshot") {
|
|
306
|
+
settings.llm_provider = "openai";
|
|
307
|
+
}
|
|
305
308
|
settings.port = Number(process.env.THUNDERBOLT_PORT || process.env.HERMES_PORT || settings.port || 3030);
|
|
306
309
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
307
310
|
}
|
|
@@ -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())
|