@danhachuel/thunderbolt 0.3.61 → 0.3.63
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/MANUAL-INSTALACAO.md +20 -6
- package/README.md +10 -2
- package/app/main.py +26 -6
- package/hermes_ui/automation_worker.py +6 -1
- package/hermes_ui/domain.py +14 -2
- package/hermes_ui/logs.py +43 -0
- package/hermes_ui/media_generation.py +5 -1
- package/hermes_ui/notifications.py +18 -1
- package/hermes_ui/pipeline_worker.py +418 -24
- package/package.json +1 -3
- package/seed/skills/mpt_agent.py +13 -3
- package/storage/blueprints/importados/demo-content-hermes.json +0 -23
- package/storage/tiktok/prompts_master/2Dstickman.md +0 -193
- package/storage/tiktok/prompts_master/3Dskeletoncharacter.md +0 -1
- package/storage/tiktok/prompts_master/ANIMALMOUNTEDCAMERA.md +0 -299
- package/storage/tiktok/prompts_master/ASMR.md +0 -74
- package/storage/tiktok/prompts_master/ASMRDEBOCACOMDOCES.md +0 -130
- package/storage/tiktok/prompts_master/AnimalX-Ray.md +0 -136
- package/storage/tiktok/prompts_master/CAREVOLUTION.md +0 -176
- package/storage/tiktok/prompts_master/CINEMATICBUNKERSURVIVAL.md +0 -180
- package/storage/tiktok/prompts_master/CINEMATICSLUMTRANSFORMATIONENGINE.md +0 -212
- package/storage/tiktok/prompts_master/CINEMATOGR/303/201FICOS3D.md +0 -207
- package/storage/tiktok/prompts_master/CartoonNostalgia.md +0 -131
- package/storage/tiktok/prompts_master/EPOXYFLOORTRANSFORMATION.md +0 -254
- package/storage/tiktok/prompts_master/Edgar'sHouseofTerrorsChooseYourStory.md +0 -134
- package/storage/tiktok/prompts_master/FOODBABYFACECHARACTER.md +0 -141
- package/storage/tiktok/prompts_master/FuitNovels.md +0 -166
- package/storage/tiktok/prompts_master/KIDSSTORYCREATOR(PROMODE).md +0 -235
- package/storage/tiktok/prompts_master/MatchstickPrompt.md +0 -86
- package/storage/tiktok/prompts_master/PERSONAGENSABANDONADOS.md +0 -208
- package/storage/tiktok/prompts_master/Pixar-styleanimated.md +0 -193
- package/storage/tiktok/prompts_master/RESTORATIONTIMELAPSE.md +0 -262
- package/storage/tiktok/prompts_master/RUSTREMOVAL.md +0 -275
- package/storage/tiktok/prompts_master/ReceitasIndianas.md +0 -60
- package/storage/tiktok/prompts_master/SkyscraperMonster.md +0 -140
- package/storage/tiktok/prompts_master/TIMETRAVELVLOG.md +0 -264
- package/storage/tiktok/prompts_master/Tarot.md +0 -58
- package/storage/tiktok/prompts_master/UFOs(OVNIs).md +0 -63
- package/storage/tiktok/prompts_master/VisualCraftSequenceGenerator.md +0 -284
- package/storage/tiktok/prompts_master/craftingStorytellingAI.md +0 -79
- package/storage/tiktok/prompts_master/hist/303/263riasinfantishoradedormir.md +0 -71
- package/storage/tiktok/prompts_master/viralTikTokdance.md +0 -77
|
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
14
15
|
from integrations.upload_routing import upload_with_default_route
|
|
15
16
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_title_and_keywords, generate_thumbnail_prompt, generate_topic_for_channel
|
|
16
17
|
from hermes_ui.script_documents import save_script_document
|
|
@@ -19,6 +20,7 @@ from hermes_ui.storage import STORAGE, ensure_storage, read_json, write_json
|
|
|
19
20
|
from hermes_ui.llm_providers import active_llm_card, provider_definition
|
|
20
21
|
from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, generate_image_from_pool, generate_video_from_pool
|
|
21
22
|
from hermes_ui.media_providers import media_cards_for_pool
|
|
23
|
+
from hermes_ui.material_sources import material_api_keys, selected_material_source
|
|
22
24
|
from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
23
25
|
|
|
24
26
|
PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
|
|
@@ -31,6 +33,10 @@ WORKER_HEARTBEAT_TIMEOUT_SECONDS = 15
|
|
|
31
33
|
class PipelineError(RuntimeError):
|
|
32
34
|
"""Raised when a pipeline stage cannot complete with an actionable error."""
|
|
33
35
|
|
|
36
|
+
def __init__(self, message: str, *, failure_metadata: dict[str, Any] | None = None):
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.failure_metadata = dict(failure_metadata or {})
|
|
39
|
+
|
|
34
40
|
|
|
35
41
|
class PipelineStopped(PipelineError):
|
|
36
42
|
"""Raised when the user stops a task while the worker is processing it."""
|
|
@@ -185,7 +191,9 @@ def _recover_stale_tasks() -> list[str]:
|
|
|
185
191
|
f"A tarefa ficou sem heartbeat durante mais de {STALE_TASK_SECONDS // 60} minutos. "
|
|
186
192
|
"Foi marcada como falhada para evitar execução eterna; reveja o log do worker."
|
|
187
193
|
)
|
|
188
|
-
|
|
194
|
+
failed_stage = str(task.get("stage") or "pipeline")
|
|
195
|
+
metadata = _failure_attribution(task, _settings(), failed_stage, error=message)
|
|
196
|
+
update_task(task_id, {"state": "failed", "error": _failure_message(message, metadata), "failed_stage": failed_stage, **metadata})
|
|
189
197
|
recovered.append(task_id)
|
|
190
198
|
return recovered
|
|
191
199
|
|
|
@@ -274,13 +282,160 @@ def _helper_output_value(output: str, key: str) -> str:
|
|
|
274
282
|
|
|
275
283
|
|
|
276
284
|
def _redact_helper_output(text: str) -> str:
|
|
277
|
-
for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY"):
|
|
285
|
+
for key in ("MPT_LLM_API_KEY", "MPT_PEXELS_API_KEY", "MPT_PIXABAY_API_KEY"):
|
|
278
286
|
secret = os.environ.get(key, "").strip()
|
|
279
287
|
if secret:
|
|
280
288
|
text = text.replace(secret, "[redacted]")
|
|
281
289
|
return text
|
|
282
290
|
|
|
283
291
|
|
|
292
|
+
def _helper_failure_markers(output: str) -> dict[str, Any]:
|
|
293
|
+
missing = [item.strip() for item in re.findall(r"(?m)^MISSING=(.+)$", output) if item.strip()]
|
|
294
|
+
invalid = [item.strip() for item in re.findall(r"(?m)^INVALID=(.+)$", output) if item.strip()]
|
|
295
|
+
return {
|
|
296
|
+
"helper_provider": _helper_output_value(output, "LLM_PROVIDER"),
|
|
297
|
+
"missing_fields": list(dict.fromkeys(missing)),
|
|
298
|
+
"invalid_fields": list(dict.fromkeys(invalid)),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _provider_api_label(provider: str) -> str:
|
|
303
|
+
code = str(provider or "").strip().casefold()
|
|
304
|
+
if code in {"pexels", "pixabay"}:
|
|
305
|
+
return f"{code.capitalize()} API"
|
|
306
|
+
if code in {"local", "local_storage"}:
|
|
307
|
+
return "Ficheiro local"
|
|
308
|
+
return f"{provider_definition(code).label} API"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _failure_attribution(
|
|
312
|
+
task: dict[str, Any],
|
|
313
|
+
settings: dict[str, Any],
|
|
314
|
+
stage: str,
|
|
315
|
+
*,
|
|
316
|
+
error: str = "",
|
|
317
|
+
output: str = "",
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Return safe, human-readable API attribution for every pipeline failure."""
|
|
320
|
+
route = _normalise_video_route(task, settings)
|
|
321
|
+
markers = _helper_failure_markers(output)
|
|
322
|
+
missing = list(markers["missing_fields"])
|
|
323
|
+
invalid = list(markers["invalid_fields"])
|
|
324
|
+
provider_code = str(markers["helper_provider"] or "").strip().casefold()
|
|
325
|
+
combined = f"{output} {error}".casefold()
|
|
326
|
+
|
|
327
|
+
if stage == "video" and missing:
|
|
328
|
+
api_labels: list[str] = []
|
|
329
|
+
provider_values: list[str] = []
|
|
330
|
+
for field in [*missing, *invalid]:
|
|
331
|
+
if field.endswith("_api_keys"):
|
|
332
|
+
source = field.removesuffix("_api_keys")
|
|
333
|
+
label = _provider_api_label(source)
|
|
334
|
+
provider_values.append(source)
|
|
335
|
+
elif field.endswith("_api_key"):
|
|
336
|
+
source = field.removesuffix("_api_key")
|
|
337
|
+
label = _provider_api_label(source)
|
|
338
|
+
provider_values.append(source)
|
|
339
|
+
else:
|
|
340
|
+
label = field
|
|
341
|
+
if label not in api_labels:
|
|
342
|
+
api_labels.append(label)
|
|
343
|
+
return {
|
|
344
|
+
"failure_api": " + ".join(api_labels) or _provider_api_label(route),
|
|
345
|
+
"failure_provider": ", ".join(dict.fromkeys(provider_values or ([provider_code] if provider_code else [route]))),
|
|
346
|
+
"failure_service": "MoneyPrinterTurbo",
|
|
347
|
+
"failure_route": route,
|
|
348
|
+
"failure_config_fields": ", ".join(dict.fromkeys([*missing, *invalid])),
|
|
349
|
+
"failure_stage": stage,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if stage == "video" and route in {"pexels", "pixabay"}:
|
|
353
|
+
return {
|
|
354
|
+
"failure_api": _provider_api_label(route),
|
|
355
|
+
"failure_provider": route,
|
|
356
|
+
"failure_service": "MoneyPrinterTurbo",
|
|
357
|
+
"failure_route": route,
|
|
358
|
+
"failure_config_fields": f"{route}_api_keys",
|
|
359
|
+
"failure_stage": stage,
|
|
360
|
+
}
|
|
361
|
+
if stage == "video" and route == "full_ia":
|
|
362
|
+
providers = [("fal_ai", "FAL AI"), ("kie_ai", "KIE AI"), ("agnes", "Agnes AI")]
|
|
363
|
+
found = [label for code, label in providers if code in combined or label.casefold() in combined]
|
|
364
|
+
labels = found or [label for _, label in providers]
|
|
365
|
+
codes = [code for code, label in providers if label in labels] or [code for code, _ in providers]
|
|
366
|
+
return {
|
|
367
|
+
"failure_api": " / ".join(f"{label} API" for label in labels),
|
|
368
|
+
"failure_provider": ", ".join(codes),
|
|
369
|
+
"failure_service": "Pool de vídeo Full IA",
|
|
370
|
+
"failure_route": route,
|
|
371
|
+
"failure_config_fields": "",
|
|
372
|
+
"failure_stage": stage,
|
|
373
|
+
}
|
|
374
|
+
if stage in {"topic", "script", "title", "keywords", "thumbnail_prompt"}:
|
|
375
|
+
card = active_llm_card(settings)
|
|
376
|
+
provider = str(card.get("provider") or provider_code or "openai").strip()
|
|
377
|
+
return {
|
|
378
|
+
"failure_api": _provider_api_label(provider),
|
|
379
|
+
"failure_provider": provider,
|
|
380
|
+
"failure_service": "LLM textual",
|
|
381
|
+
"failure_route": route,
|
|
382
|
+
"failure_config_fields": "",
|
|
383
|
+
"failure_stage": stage,
|
|
384
|
+
}
|
|
385
|
+
if stage == "thumbnail":
|
|
386
|
+
cards = media_cards_for_pool(settings, "image")
|
|
387
|
+
labels = []
|
|
388
|
+
codes = []
|
|
389
|
+
for card in cards:
|
|
390
|
+
code = str(card.get("provider") or "").strip().casefold()
|
|
391
|
+
if not code:
|
|
392
|
+
continue
|
|
393
|
+
label = _provider_api_label(code)
|
|
394
|
+
if code in combined or label.casefold() in combined or len(cards) == 1:
|
|
395
|
+
labels.append(label)
|
|
396
|
+
codes.append(code)
|
|
397
|
+
if not labels:
|
|
398
|
+
labels = [_provider_api_label(str(card.get("provider") or "imagem")) for card in cards] or ["Pool Imagem API"]
|
|
399
|
+
codes = [str(card.get("provider") or "").strip() for card in cards if card.get("provider")] or ["image_pool"]
|
|
400
|
+
return {
|
|
401
|
+
"failure_api": " / ".join(labels),
|
|
402
|
+
"failure_provider": ", ".join(codes),
|
|
403
|
+
"failure_service": "Pool de imagem",
|
|
404
|
+
"failure_route": route,
|
|
405
|
+
"failure_config_fields": "",
|
|
406
|
+
"failure_stage": stage,
|
|
407
|
+
}
|
|
408
|
+
if stage == "upload":
|
|
409
|
+
return {
|
|
410
|
+
"failure_api": "YouTube Upload API",
|
|
411
|
+
"failure_provider": "youtube_upload",
|
|
412
|
+
"failure_service": "Upload",
|
|
413
|
+
"failure_route": route,
|
|
414
|
+
"failure_config_fields": "",
|
|
415
|
+
"failure_stage": stage,
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
"failure_api": "API não identificada (falha anterior)",
|
|
419
|
+
"failure_provider": provider_code or "unknown",
|
|
420
|
+
"failure_service": "Thunderbolt",
|
|
421
|
+
"failure_route": route,
|
|
422
|
+
"failure_config_fields": ", ".join(dict.fromkeys([*missing, *invalid])),
|
|
423
|
+
"failure_stage": stage or "pipeline",
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _failure_message(message: str, metadata: dict[str, Any]) -> str:
|
|
428
|
+
api = str(metadata.get("failure_api") or "API não identificada").strip()
|
|
429
|
+
provider = str(metadata.get("failure_provider") or "").strip()
|
|
430
|
+
fields = str(metadata.get("failure_config_fields") or "").strip()
|
|
431
|
+
suffix = f" API/provider: {api}"
|
|
432
|
+
if provider and provider.casefold() not in api.casefold():
|
|
433
|
+
suffix += f" (provider: {provider})"
|
|
434
|
+
if fields:
|
|
435
|
+
suffix += f"; configuração: {fields}"
|
|
436
|
+
return f"{message.rstrip('.')} —{suffix}."
|
|
437
|
+
|
|
438
|
+
|
|
284
439
|
def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, str]:
|
|
285
440
|
"""Persist only bounded helper diagnostics and return its declared file paths."""
|
|
286
441
|
task_id = str(task.get("id") or "").strip()
|
|
@@ -289,11 +444,15 @@ def _persist_video_diagnostics(task: dict[str, Any], output: str) -> dict[str, s
|
|
|
289
444
|
log_file = _helper_output_value(output, "LOG_FILE")
|
|
290
445
|
result_file = _helper_output_value(output, "RESULT_FILE")
|
|
291
446
|
try:
|
|
447
|
+
markers = _helper_failure_markers(output)
|
|
292
448
|
payload: dict[str, Any] = {
|
|
293
449
|
"captured_at": _now(),
|
|
294
450
|
"log_file": log_file,
|
|
295
451
|
"result_file": result_file,
|
|
296
452
|
"output_tail": _redact_helper_output(output[-6000:]),
|
|
453
|
+
"helper_provider": markers["helper_provider"],
|
|
454
|
+
"missing_fields": markers["missing_fields"],
|
|
455
|
+
"invalid_fields": markers["invalid_fields"],
|
|
297
456
|
}
|
|
298
457
|
artifact_path = _save_json_artifact(task_id, "video-diagnostics", payload)
|
|
299
458
|
current = _task_by_id(task_id) or task
|
|
@@ -324,6 +483,156 @@ def _stop_process(process: subprocess.Popen[str]) -> None:
|
|
|
324
483
|
process.wait()
|
|
325
484
|
|
|
326
485
|
|
|
486
|
+
def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> str:
|
|
487
|
+
"""Resolve the per-task source without conflating stock, AI and music routes."""
|
|
488
|
+
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
489
|
+
if bool(task.get("music_mode")):
|
|
490
|
+
return "music"
|
|
491
|
+
explicit_source = str(
|
|
492
|
+
task.get("material_source")
|
|
493
|
+
or generation_settings.get("material_source")
|
|
494
|
+
or generation_settings.get("video_material_source")
|
|
495
|
+
or ""
|
|
496
|
+
).strip().casefold()
|
|
497
|
+
if explicit_source in {"pexels", "pixabay", "local"}:
|
|
498
|
+
return explicit_source
|
|
499
|
+
raw = str(task.get("style_wide") or generation_settings.get("video_source") or "pexels").strip().casefold()
|
|
500
|
+
if raw in {"full_ia", "full ia", "full-ai", "ai", "ia"}:
|
|
501
|
+
return "full_ia"
|
|
502
|
+
if raw in {"music", "apenas música", "apenas musica", "only music"}:
|
|
503
|
+
return "music"
|
|
504
|
+
if raw in {"pixabay", "pixabay only"}:
|
|
505
|
+
return "pixabay"
|
|
506
|
+
if raw in {"pexels", "pexels/pixabay", "stock", "materials", "materiales"}:
|
|
507
|
+
configured = selected_material_source(settings)
|
|
508
|
+
return configured if configured in {"pexels", "pixabay"} else "pexels"
|
|
509
|
+
return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _mpt_aspect_ratio(value: Any, format_value: Any = "") -> str:
|
|
513
|
+
raw = str(value or format_value or "").strip().casefold()
|
|
514
|
+
if raw in {"landscape 16:9", "wide", "16:9", "landscape"}:
|
|
515
|
+
return "16:9"
|
|
516
|
+
if raw in {"square 1:1", "square", "1:1"}:
|
|
517
|
+
return "1:1"
|
|
518
|
+
return "9:16"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _mpt_concat_mode(value: Any) -> str:
|
|
522
|
+
raw = str(value or "").strip().casefold()
|
|
523
|
+
return "sequential" if raw in {"sequential", "sequential concatenation", "ordem sequencial"} else "random"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _mpt_transition_mode(value: Any) -> str:
|
|
527
|
+
raw = str(value or "").strip().casefold()
|
|
528
|
+
return {"fade": "fade-in", "dissolve": "fade-out", "fade-in": "fade-in", "fade-out": "fade-out"}.get(raw, "none")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _mpt_percent(value: Any, fallback: float = 1.0) -> str:
|
|
532
|
+
raw = str(value or "").strip().replace("%", "")
|
|
533
|
+
try:
|
|
534
|
+
return str(max(0.0, min(2.0, float(raw) / 100.0)))
|
|
535
|
+
except ValueError:
|
|
536
|
+
return str(fallback)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _valid_audio_artifact(value: Any) -> Path | None:
|
|
540
|
+
path = _valid_artifact_path(value)
|
|
541
|
+
if path is None or path.suffix.lower() not in {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}:
|
|
542
|
+
return None
|
|
543
|
+
return path
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _mpt_rate(value: Any, fallback: float = 1.0) -> str:
|
|
547
|
+
raw = str(value or "").strip().casefold().replace("x", "")
|
|
548
|
+
try:
|
|
549
|
+
return str(max(0.1, float(raw)))
|
|
550
|
+
except ValueError:
|
|
551
|
+
return str(fallback)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _mpt_video_language(value: Any) -> str:
|
|
555
|
+
"""Convert common Thunderbolt labels to the language codes accepted by MPT."""
|
|
556
|
+
raw = str(value or "").strip()
|
|
557
|
+
folded = raw.casefold()
|
|
558
|
+
aliases = {
|
|
559
|
+
"português": "pt-BR", "portugues": "pt-BR", "português (brasil)": "pt-BR",
|
|
560
|
+
"portuguese": "pt-BR", "inglês": "en-US", "ingles": "en-US", "english": "en-US",
|
|
561
|
+
"espanhol": "es-ES", "español": "es-ES", "francês": "fr-FR", "frances": "fr-FR",
|
|
562
|
+
"alemão": "de-DE", "alemao": "de-DE", "italiano": "it-IT", "japonês": "ja-JP",
|
|
563
|
+
"japones": "ja-JP", "mandarim": "zh-CN", "chinês": "zh-CN", "chines": "zh-CN",
|
|
564
|
+
}
|
|
565
|
+
if folded in aliases:
|
|
566
|
+
return aliases[folded]
|
|
567
|
+
return raw if re.fullmatch(r"[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?", raw) else ""
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _moneyprinter_cli_args(task: dict[str, Any], route: str) -> list[str]:
|
|
571
|
+
"""Build the explicit MPT CLI contract for the stock Pexels/Pixabay route."""
|
|
572
|
+
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
573
|
+
args: list[str] = ["--video-source", route]
|
|
574
|
+
script = str(task.get("video_script") or generation_settings.get("video_script") or "").strip()
|
|
575
|
+
if script:
|
|
576
|
+
args.extend(["--video-script", script])
|
|
577
|
+
keywords = task.get("video_keywords") or generation_settings.get("video_keywords") or task.get("keywords") or task.get("tags")
|
|
578
|
+
if isinstance(keywords, str):
|
|
579
|
+
keywords = [part.strip() for part in re.split(r"[,;|\n]+", keywords) if part.strip()]
|
|
580
|
+
if isinstance(keywords, list):
|
|
581
|
+
terms = [str(item).strip() for item in keywords if str(item).strip()]
|
|
582
|
+
if terms:
|
|
583
|
+
args.extend(["--video-terms", ",".join(terms[:15])])
|
|
584
|
+
|
|
585
|
+
language = _mpt_video_language(task.get("language"))
|
|
586
|
+
if language:
|
|
587
|
+
args.extend(["--video-language", language])
|
|
588
|
+
format_value = str(task.get("format") or generation_settings.get("video_format") or "").strip().casefold()
|
|
589
|
+
if format_value in {"wide", "landscape", "16:9"}:
|
|
590
|
+
aspect_ratio = "16:9"
|
|
591
|
+
elif format_value in {"shorts", "portrait", "9:16"}:
|
|
592
|
+
aspect_ratio = "9:16"
|
|
593
|
+
else:
|
|
594
|
+
aspect_ratio = _mpt_aspect_ratio(generation_settings.get("video_aspect_ratio"), task.get("format"))
|
|
595
|
+
args.extend(["--video-aspect", aspect_ratio])
|
|
596
|
+
args.extend(["--video-concat-mode", _mpt_concat_mode(generation_settings.get("video_concatenation_mode"))])
|
|
597
|
+
args.extend(["--video-transition-mode", _mpt_transition_mode(generation_settings.get("video_transition_mode"))])
|
|
598
|
+
clip_duration = generation_settings.get("maximum_clip_duration")
|
|
599
|
+
if str(clip_duration or "").strip().isdigit() and int(clip_duration) > 0:
|
|
600
|
+
args.extend(["--video-clip-duration", str(int(clip_duration))])
|
|
601
|
+
if bool(generation_settings.get("match_visuals_to_script_order")):
|
|
602
|
+
args.append("--match-materials-to-script")
|
|
603
|
+
|
|
604
|
+
voice_mode = str(generation_settings.get("voiceover_mode") or "").strip().casefold()
|
|
605
|
+
voice = str(task.get("voice") or generation_settings.get("voice") or "").strip()
|
|
606
|
+
if voice_mode == "none" or voice_mode == "upload":
|
|
607
|
+
args.extend(["--voice-name", "no-voice"])
|
|
608
|
+
elif voice:
|
|
609
|
+
args.extend(["--voice-name", voice])
|
|
610
|
+
volume = generation_settings.get("voiceover_volume")
|
|
611
|
+
speed = generation_settings.get("voiceover_speed")
|
|
612
|
+
if volume is not None:
|
|
613
|
+
args.extend(["--voice-volume", _mpt_percent(volume)])
|
|
614
|
+
if speed is not None:
|
|
615
|
+
args.extend(["--voice-rate", _mpt_rate(speed)])
|
|
616
|
+
|
|
617
|
+
subtitles = generation_settings.get("enable_subtitles")
|
|
618
|
+
if subtitles is not None:
|
|
619
|
+
args.append("--subtitle-enabled" if bool(subtitles) else "--no-subtitle-enabled")
|
|
620
|
+
subtitle_position = str(generation_settings.get("subtitle_position") or "").strip().casefold()
|
|
621
|
+
if subtitle_position in {"top", "center", "bottom", "custom"}:
|
|
622
|
+
args.extend(["--subtitle-position", subtitle_position])
|
|
623
|
+
font_name = str(generation_settings.get("subtitle_font") or "").strip()
|
|
624
|
+
if font_name:
|
|
625
|
+
args.extend(["--font-name", font_name])
|
|
626
|
+
bgm_source = str(generation_settings.get("background_music_source") or "").strip().casefold()
|
|
627
|
+
bgm_type = {"sem música": "none", "sem musica": "none", "random background music": "random", "ficheiro existente": "custom"}.get(bgm_source)
|
|
628
|
+
if bgm_type:
|
|
629
|
+
args.extend(["--bgm-type", bgm_type])
|
|
630
|
+
bgm_volume = generation_settings.get("background_music_volume")
|
|
631
|
+
if bgm_volume is not None:
|
|
632
|
+
args.extend(["--bgm-volume", _mpt_percent(bgm_volume)])
|
|
633
|
+
return args
|
|
634
|
+
|
|
635
|
+
|
|
327
636
|
def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
328
637
|
helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
|
|
329
638
|
helper = helper_dir / "mpt_agent.py"
|
|
@@ -341,12 +650,27 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
341
650
|
card = active_llm_card(settings)
|
|
342
651
|
provider = str(card.get("provider") or "openai").strip()
|
|
343
652
|
definition = provider_definition(provider)
|
|
653
|
+
route = _normalise_video_route(task, settings)
|
|
654
|
+
source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
|
|
655
|
+
if route in {"pexels", "pixabay"} and not source_keys:
|
|
656
|
+
source_label = "Pexels" if route == "pexels" else "Pixabay"
|
|
657
|
+
message = f"Configure pelo menos uma API key de {source_label} em Configurações > Configuração API > Fontes de materiais."
|
|
658
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
659
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
660
|
+
if configured_root:
|
|
661
|
+
try:
|
|
662
|
+
sync_moneyprinter_config(settings, str(configured_root))
|
|
663
|
+
except OSError as exc:
|
|
664
|
+
raise PipelineError(f"Não foi possível sincronizar a configuração do MoneyPrinterTurbo: {exc}") from exc
|
|
344
665
|
env_values = {
|
|
345
666
|
"MPT_LLM_PROVIDER": provider,
|
|
346
667
|
"MPT_LLM_API_KEY": str(card.get("api_key") or "").strip(),
|
|
347
668
|
"MPT_LLM_BASE_URL": str(card.get("base_url") or definition.default_base_url or "").strip(),
|
|
348
669
|
"MPT_LLM_MODEL_NAME": str(card.get("model") or "").strip(),
|
|
349
|
-
"MPT_PEXELS_API_KEY":
|
|
670
|
+
"MPT_PEXELS_API_KEY": source_keys[0] if route == "pexels" and source_keys else "",
|
|
671
|
+
"MPT_PEXELS_API_KEYS": json.dumps(source_keys, ensure_ascii=False) if route == "pexels" and source_keys else "",
|
|
672
|
+
"MPT_PIXABAY_API_KEY": source_keys[0] if route == "pixabay" and source_keys else "",
|
|
673
|
+
"MPT_PIXABAY_API_KEYS": json.dumps(source_keys, ensure_ascii=False) if route == "pixabay" and source_keys else "",
|
|
350
674
|
}
|
|
351
675
|
for key, value in env_values.items():
|
|
352
676
|
if value:
|
|
@@ -356,10 +680,14 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
356
680
|
command.extend(["--root", str(configured_root)])
|
|
357
681
|
command.extend(["--subject", subject])
|
|
358
682
|
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
683
|
+
command.extend(_moneyprinter_cli_args(task, route))
|
|
359
684
|
if str(generation_settings.get("voiceover_mode") or "").strip().casefold() == "upload":
|
|
360
685
|
voiceover_file = Path(str(generation_settings.get("voiceover_file") or "").strip()).expanduser()
|
|
361
686
|
if not str(voiceover_file) or not voiceover_file.is_file() or voiceover_file.stat().st_size <= 0:
|
|
362
|
-
|
|
687
|
+
message = "O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio."
|
|
688
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
689
|
+
metadata.update({"failure_api": "Ficheiro local", "failure_provider": "local_storage", "failure_service": "Áudio de narração", "failure_config_fields": "voiceover_file"})
|
|
690
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
363
691
|
command.extend(["--custom-audio-file", str(voiceover_file.resolve())])
|
|
364
692
|
output_lines: list[str] = []
|
|
365
693
|
line_queue: queue.Queue[str | None] = queue.Queue()
|
|
@@ -386,7 +714,10 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
386
714
|
bufsize=1,
|
|
387
715
|
)
|
|
388
716
|
except FileNotFoundError as exc:
|
|
389
|
-
|
|
717
|
+
message = "O comando uv não está instalado; não foi possível iniciar a geração de vídeo."
|
|
718
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
719
|
+
metadata.update({"failure_service": "Runtime MoneyPrinterTurbo"})
|
|
720
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata) from exc
|
|
390
721
|
|
|
391
722
|
reader = threading.Thread(target=_read_output, name=f"mpt-output-{task.get('id', 'video')}", daemon=True)
|
|
392
723
|
reader.start()
|
|
@@ -429,7 +760,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
429
760
|
break
|
|
430
761
|
if elapsed >= VIDEO_TIMEOUT_SECONDS:
|
|
431
762
|
_stop_process(process)
|
|
432
|
-
|
|
763
|
+
message = f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
|
|
764
|
+
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
765
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
433
766
|
finally:
|
|
434
767
|
reader.join(timeout=2)
|
|
435
768
|
_persist_video_diagnostics(task, "\n".join(output_lines))
|
|
@@ -439,10 +772,14 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
439
772
|
output = "\n".join(output_lines)
|
|
440
773
|
_persist_video_diagnostics(task, output)
|
|
441
774
|
if result_code == 10:
|
|
442
|
-
|
|
775
|
+
metadata = _failure_attribution(task, settings, "video", output=output)
|
|
776
|
+
message = "A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo"
|
|
777
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
443
778
|
if result_code != 0:
|
|
444
779
|
detail = _redact_helper_output(output[-1200:]).strip() or "erro sem detalhes devolvidos pelo helper"
|
|
445
|
-
|
|
780
|
+
metadata = _failure_attribution(task, settings, "video", error=detail, output=output)
|
|
781
|
+
message = f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}"
|
|
782
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
446
783
|
match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
|
|
447
784
|
video_path = Path(match.group(1).strip()).expanduser() if match else None
|
|
448
785
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
@@ -455,7 +792,9 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
455
792
|
except (OSError, json.JSONDecodeError):
|
|
456
793
|
video_path = None
|
|
457
794
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
458
|
-
|
|
795
|
+
message = "MoneyPrinterTurbo terminou sem devolver um MP4 válido."
|
|
796
|
+
metadata = _failure_attribution(task, settings, "video", error=message, output=output)
|
|
797
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
|
|
459
798
|
return video_path
|
|
460
799
|
|
|
461
800
|
|
|
@@ -516,8 +855,9 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
516
855
|
channel = _channel_for_task(task)
|
|
517
856
|
settings = _settings()
|
|
518
857
|
blueprint = _blueprint_for_channel(channel)
|
|
858
|
+
route = _normalise_video_route(task, settings)
|
|
519
859
|
topic = str(task.get("topic") or "").strip()
|
|
520
|
-
if not topic or str(task.get("topic_source") or "") in {"auto", "llm_pending"}:
|
|
860
|
+
if route != "music" and (not topic or str(task.get("topic_source") or "") in {"auto", "llm_pending"}):
|
|
521
861
|
_update(task_id, stage="topic", state="doing", progress=5, error=None)
|
|
522
862
|
topic_result = generate_topic_for_channel(settings, channel, blueprint, user_context=str(task.get("topic_context") or ""))
|
|
523
863
|
topic = str(topic_result.get("topic") or "").strip()
|
|
@@ -527,6 +867,32 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
527
867
|
|
|
528
868
|
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
529
869
|
artifacts = dict(task.get("artifacts") or {})
|
|
870
|
+
if route == "music":
|
|
871
|
+
# Apenas Música não é uma tarefa de vídeo: reutiliza o áudio preparado e
|
|
872
|
+
# termina pronta para a integração de upload musical, sem chamar LLM,
|
|
873
|
+
# Pexels/Pixabay, MoviePy de vídeo, thumbnail ou upload de vídeo.
|
|
874
|
+
music_candidates = [
|
|
875
|
+
task.get("music_path"),
|
|
876
|
+
generation_settings.get("music_path"),
|
|
877
|
+
artifacts.get("music"),
|
|
878
|
+
]
|
|
879
|
+
music_path = next((path for path in (_valid_audio_artifact(item) for item in music_candidates) if path is not None), None)
|
|
880
|
+
if music_path is None:
|
|
881
|
+
raise PipelineError("A fonte Apenas Música exige um ficheiro de áudio local válido ou uma música Suno já descarregada antes de criar a tarefa.")
|
|
882
|
+
artifacts["music"] = str(music_path)
|
|
883
|
+
_update(
|
|
884
|
+
task_id,
|
|
885
|
+
stage="upload",
|
|
886
|
+
state="done",
|
|
887
|
+
progress=100,
|
|
888
|
+
artifacts=artifacts,
|
|
889
|
+
music_ready=True,
|
|
890
|
+
video_ready=False,
|
|
891
|
+
thumbnail_status="not_applicable",
|
|
892
|
+
error=None,
|
|
893
|
+
)
|
|
894
|
+
return _task_by_id(task_id) or task
|
|
895
|
+
|
|
530
896
|
script = _read_persisted_script(task, channel, blueprint, topic)
|
|
531
897
|
if script is None:
|
|
532
898
|
_update(task_id, stage="script", state="doing", progress=18, error=None)
|
|
@@ -618,18 +984,33 @@ def _run_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
|
618
984
|
existing_video = _valid_artifact_path(artifacts.get("video"))
|
|
619
985
|
if existing_video is None:
|
|
620
986
|
_update(task_id, stage="video", state="doing", progress=max(52, int(task.get("progress") or 0)), error=None)
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
987
|
+
try:
|
|
988
|
+
video_prompt = _append_generation_constraints(
|
|
989
|
+
f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
|
|
990
|
+
kind="video",
|
|
991
|
+
)
|
|
992
|
+
if route == "full_ia":
|
|
993
|
+
video_path = generate_video_from_pool(
|
|
994
|
+
settings,
|
|
995
|
+
video_prompt,
|
|
996
|
+
allowed_providers={"fal_ai", "kie_ai", "agnes"},
|
|
627
997
|
)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
998
|
+
else:
|
|
999
|
+
video_path = _run_video_helper({
|
|
1000
|
+
**task,
|
|
1001
|
+
"topic": topic,
|
|
1002
|
+
"title": title,
|
|
1003
|
+
"video_script": str(script.get("content") or ""),
|
|
1004
|
+
"video_keywords": keywords,
|
|
1005
|
+
"style_wide": route,
|
|
1006
|
+
})
|
|
1007
|
+
except MediaGenerationError as exc:
|
|
1008
|
+
if route == "full_ia":
|
|
1009
|
+
message = f"Pool Full IA (FAL/KIE AI/Agnes AI): {exc}"
|
|
1010
|
+
else:
|
|
1011
|
+
message = f"Pipeline MoneyPrinterTurbo ({route}): {exc}"
|
|
1012
|
+
metadata = _failure_attribution(task, settings, "video", error=str(exc))
|
|
1013
|
+
raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata) from exc
|
|
633
1014
|
current_after_video = _task_by_id(task_id) or {}
|
|
634
1015
|
artifacts = dict(current_after_video.get("artifacts") or artifacts)
|
|
635
1016
|
artifacts["video"] = str(video_path)
|
|
@@ -765,9 +1146,22 @@ def run_once() -> dict[str, Any]:
|
|
|
765
1146
|
message = str(exc)[:2000]
|
|
766
1147
|
from hermes_ui.domain import update_task
|
|
767
1148
|
current_task = _task_by_id(task_id) or candidate
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1149
|
+
failed_stage = str(current_task.get("stage") or "pipeline")
|
|
1150
|
+
settings = _settings()
|
|
1151
|
+
failure_metadata = dict(getattr(exc, "failure_metadata", {}) or {})
|
|
1152
|
+
if not failure_metadata:
|
|
1153
|
+
failure_metadata = _failure_attribution(current_task, settings, failed_stage, error=message)
|
|
1154
|
+
if "API/provider:" not in message:
|
|
1155
|
+
message = _failure_message(message, failure_metadata)[:2000]
|
|
1156
|
+
failure_updates = {
|
|
1157
|
+
"state": "failed",
|
|
1158
|
+
"error": message,
|
|
1159
|
+
"failed_stage": failed_stage,
|
|
1160
|
+
**failure_metadata,
|
|
1161
|
+
}
|
|
1162
|
+
update_task(task_id, failure_updates)
|
|
1163
|
+
_worker_heartbeat(status="failed", last_error=message, stage=failed_stage, progress=int(current_task.get("progress") or 0), task_id=task_id)
|
|
1164
|
+
return {"ok": False, "task_id": task_id, "error": message, "failure_metadata": failure_metadata, "recovered_task_ids": recovered}
|
|
771
1165
|
finally:
|
|
772
1166
|
try:
|
|
773
1167
|
lock.unlink()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danhachuel/thunderbolt",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.63",
|
|
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",
|
|
@@ -16,8 +16,6 @@
|
|
|
16
16
|
"integrations/*.py",
|
|
17
17
|
"integrations/data/*.json",
|
|
18
18
|
"scripts/*.mjs",
|
|
19
|
-
"storage/blueprints/**/*.json",
|
|
20
|
-
"storage/tiktok/prompts_master/**/*.md",
|
|
21
19
|
"seed/prompt_masters/**/*.md",
|
|
22
20
|
"seed/blueprints/**/*.json",
|
|
23
21
|
"seed/skills/*.md",
|
package/seed/skills/mpt_agent.py
CHANGED
|
@@ -206,7 +206,14 @@ def apply_environment_config(config_path: Path) -> None:
|
|
|
206
206
|
base_url = os.environ.get("MPT_LLM_BASE_URL", "").strip()
|
|
207
207
|
model_name = os.environ.get("MPT_LLM_MODEL_NAME", "").strip()
|
|
208
208
|
pexels_key = os.environ.get("MPT_PEXELS_API_KEY", "").strip()
|
|
209
|
-
|
|
209
|
+
pixabay_key = os.environ.get("MPT_PIXABAY_API_KEY", "").strip()
|
|
210
|
+
pexels_keys = _parse_string_list(os.environ.get("MPT_PEXELS_API_KEYS", ""))
|
|
211
|
+
pixabay_keys = _parse_string_list(os.environ.get("MPT_PIXABAY_API_KEYS", ""))
|
|
212
|
+
if not pexels_keys and pexels_key:
|
|
213
|
+
pexels_keys = [pexels_key]
|
|
214
|
+
if not pixabay_keys and pixabay_key:
|
|
215
|
+
pixabay_keys = [pixabay_key]
|
|
216
|
+
if not any((provider, llm_key, base_url, model_name, pexels_keys, pixabay_keys)):
|
|
210
217
|
return
|
|
211
218
|
|
|
212
219
|
text = config_path.read_text(encoding="utf-8")
|
|
@@ -225,9 +232,12 @@ def apply_environment_config(config_path: Path) -> None:
|
|
|
225
232
|
if model_name:
|
|
226
233
|
text = _replace_config_value(text, f"{provider}_model_name", model_name)
|
|
227
234
|
changes.append(f"{provider}_model_name")
|
|
228
|
-
if
|
|
229
|
-
text = _replace_config_value(text, "pexels_api_keys",
|
|
235
|
+
if pexels_keys:
|
|
236
|
+
text = _replace_config_value(text, "pexels_api_keys", pexels_keys)
|
|
230
237
|
changes.append("pexels_api_keys")
|
|
238
|
+
if pixabay_keys:
|
|
239
|
+
text = _replace_config_value(text, "pixabay_api_keys", pixabay_keys)
|
|
240
|
+
changes.append("pixabay_api_keys")
|
|
231
241
|
config_path.write_text(text, encoding="utf-8")
|
|
232
242
|
log("updated configuration fields: " + ", ".join(changes))
|
|
233
243
|
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "demo-content-hermes",
|
|
3
|
-
"name": "Blueprint de demonstração",
|
|
4
|
-
"channel_name": "Canal Hermes Demo",
|
|
5
|
-
"language": "Português",
|
|
6
|
-
"niche": "Conteúdo faceless educativo",
|
|
7
|
-
"style": {
|
|
8
|
-
"wide": "pexels",
|
|
9
|
-
"shorts": "vertical"
|
|
10
|
-
},
|
|
11
|
-
"script": {
|
|
12
|
-
"target_characters": "7000-9000",
|
|
13
|
-
"hook_patterns": ["prediction-stakes", "before-after"],
|
|
14
|
-
"humanization": true
|
|
15
|
-
},
|
|
16
|
-
"thumbnail": {
|
|
17
|
-
"aspect_ratio": "16:9",
|
|
18
|
-
"max_words": 4,
|
|
19
|
-
"mobile_test": true
|
|
20
|
-
},
|
|
21
|
-
"source": "demo",
|
|
22
|
-
"version": 1
|
|
23
|
-
}
|