@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.
Files changed (42) hide show
  1. package/MANUAL-INSTALACAO.md +20 -6
  2. package/README.md +10 -2
  3. package/app/main.py +26 -6
  4. package/hermes_ui/automation_worker.py +6 -1
  5. package/hermes_ui/domain.py +14 -2
  6. package/hermes_ui/logs.py +43 -0
  7. package/hermes_ui/media_generation.py +5 -1
  8. package/hermes_ui/notifications.py +18 -1
  9. package/hermes_ui/pipeline_worker.py +418 -24
  10. package/package.json +1 -3
  11. package/seed/skills/mpt_agent.py +13 -3
  12. package/storage/blueprints/importados/demo-content-hermes.json +0 -23
  13. package/storage/tiktok/prompts_master/2Dstickman.md +0 -193
  14. package/storage/tiktok/prompts_master/3Dskeletoncharacter.md +0 -1
  15. package/storage/tiktok/prompts_master/ANIMALMOUNTEDCAMERA.md +0 -299
  16. package/storage/tiktok/prompts_master/ASMR.md +0 -74
  17. package/storage/tiktok/prompts_master/ASMRDEBOCACOMDOCES.md +0 -130
  18. package/storage/tiktok/prompts_master/AnimalX-Ray.md +0 -136
  19. package/storage/tiktok/prompts_master/CAREVOLUTION.md +0 -176
  20. package/storage/tiktok/prompts_master/CINEMATICBUNKERSURVIVAL.md +0 -180
  21. package/storage/tiktok/prompts_master/CINEMATICSLUMTRANSFORMATIONENGINE.md +0 -212
  22. package/storage/tiktok/prompts_master/CINEMATOGR/303/201FICOS3D.md +0 -207
  23. package/storage/tiktok/prompts_master/CartoonNostalgia.md +0 -131
  24. package/storage/tiktok/prompts_master/EPOXYFLOORTRANSFORMATION.md +0 -254
  25. package/storage/tiktok/prompts_master/Edgar'sHouseofTerrorsChooseYourStory.md +0 -134
  26. package/storage/tiktok/prompts_master/FOODBABYFACECHARACTER.md +0 -141
  27. package/storage/tiktok/prompts_master/FuitNovels.md +0 -166
  28. package/storage/tiktok/prompts_master/KIDSSTORYCREATOR(PROMODE).md +0 -235
  29. package/storage/tiktok/prompts_master/MatchstickPrompt.md +0 -86
  30. package/storage/tiktok/prompts_master/PERSONAGENSABANDONADOS.md +0 -208
  31. package/storage/tiktok/prompts_master/Pixar-styleanimated.md +0 -193
  32. package/storage/tiktok/prompts_master/RESTORATIONTIMELAPSE.md +0 -262
  33. package/storage/tiktok/prompts_master/RUSTREMOVAL.md +0 -275
  34. package/storage/tiktok/prompts_master/ReceitasIndianas.md +0 -60
  35. package/storage/tiktok/prompts_master/SkyscraperMonster.md +0 -140
  36. package/storage/tiktok/prompts_master/TIMETRAVELVLOG.md +0 -264
  37. package/storage/tiktok/prompts_master/Tarot.md +0 -58
  38. package/storage/tiktok/prompts_master/UFOs(OVNIs).md +0 -63
  39. package/storage/tiktok/prompts_master/VisualCraftSequenceGenerator.md +0 -284
  40. package/storage/tiktok/prompts_master/craftingStorytellingAI.md +0 -79
  41. package/storage/tiktok/prompts_master/hist/303/263riasinfantishoradedormir.md +0 -71
  42. 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
- update_task(task_id, {"state": "failed", "error": message, "failed_stage": task.get("stage") or "pipeline"})
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": str(settings.get("pexels_api_key") or "").strip(),
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
- raise PipelineError("O modo Upload foi seleccionado, mas não existe um ficheiro de narração válido. Carregue o áudio em Configurações de áudio.")
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
- raise PipelineError("O comando uv não está instalado; não foi possível iniciar a geração de vídeo.") from exc
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
- raise PipelineError(f"A etapa Vídeo excedeu o limite de {VIDEO_TIMEOUT_SECONDS // 60} minutos e foi encerrada.")
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
- raise PipelineError("A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo.")
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
- raise PipelineError(f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}")
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
- raise PipelineError("MoneyPrinterTurbo terminou sem devolver um MP4 válido.")
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
- video_cards = media_cards_for_pool(settings, "video")
622
- if bool(settings.get("media_video_pool_enabled")) and video_cards:
623
- try:
624
- video_prompt = _append_generation_constraints(
625
- f"Título: {title}\n\nRoteiro:\n{str(script.get('content') or '')[:12000]}",
626
- kind="video",
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
- video_path = generate_video_from_pool(settings, video_prompt)
629
- except MediaGenerationError as exc:
630
- raise PipelineError(f"Pool de vídeo externo: {exc}") from exc
631
- else:
632
- video_path = _run_video_helper({**task, "topic": topic, "title": title})
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
- update_task(task_id, {"state": "failed", "error": message, "failed_stage": current_task.get("stage") or "pipeline"})
769
- _worker_heartbeat(status="failed", last_error=message, stage=str(current_task.get("stage") or "pipeline"), progress=int(current_task.get("progress") or 0), task_id=task_id)
770
- return {"ok": False, "task_id": task_id, "error": message, "recovered_task_ids": recovered}
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.61",
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",
@@ -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
- if not any((provider, llm_key, base_url, model_name, pexels_key)):
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 pexels_key:
229
- text = _replace_config_value(text, "pexels_api_keys", [pexels_key])
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
- }