@danhachuel/thunderbolt 0.4.10 → 0.4.12

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/README.md CHANGED
@@ -44,9 +44,12 @@ A migração é baseada no workflow público [AI Agents A-Z — episódio 35](ht
44
44
 
45
45
  ## Pipeline de vídeo — fontes e ordem de execução
46
46
 
47
- Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações.
47
+ Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações. Em **Configurações > Configuração API > API Keys > Imagem e Video Montagem/MoviePy**, cada cartão possui o campo **Prioridade**: o menor número aparece primeiro e a ordenação fica guardada no storage local.
48
48
 
49
- A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**. O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa o pool de vídeo configurável com **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen**, respeitando apenas cartões activos que declarem capacidade de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno descarregado e deixa-o pronto para a integração de upload musical.
49
+ Quando existem cartões activos de **Pexels** e **Pixabay**, a Pipeline tenta primeiro a fonte seleccionada na tarefa e, se a falha for específica do provider stock, tenta os restantes providers stock configurados por ordem de prioridade. Falhas de LLM, Azure, áudio local ou configuração estrutural não são mascaradas por um fallback de materiais.
50
+
51
+ A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**.
52
+ O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa o pool de vídeo configurável com **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen**, respeitando apenas cartões activos que declarem capacidade de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
50
53
 
51
54
  Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Os timeouts `azure_tts_v1`/`edge_tts` são identificados como **Azure Speech / edge_tts API**. Quando há Azure Speech key e região, o worker encaminha a voz para o SDK Azure Speech V2. Para evitar o limite de 10 minutos da síntese em tempo real documentado pela [Microsoft](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-quotas-and-limits), o helper divide o roteiro em segmentos seguros, sintetiza-os sequencialmente com retry e concatena o MP3 antes de o entregar ao MoneyPrinterTurbo como áudio customizado. O erro `1007`/`600000ms` é atribuído explicitamente à **Azure Speech SDK V2 API**. Sem credenciais Azure, o fallback edge_tts usa um timeout interno de 90 segundos. O worker invoca o helper com `--` antes das flags MoneyPrinterTurbo, porque `mpt_agent.py` reserva esses argumentos para a CLI filha. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
52
55
 
package/app/main.py CHANGED
@@ -5617,7 +5617,7 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
5617
5617
  _render_credential_status("" if is_local else card.get("api_key"), local=is_local, required=not is_local)
5618
5618
  card_form = nullcontext() if embedded else st.form(f"material_source_card_form_{card_id}")
5619
5619
  with card_form:
5620
- content_cols = st.columns(2)
5620
+ content_cols = st.columns([1.4, 1.25, 0.85])
5621
5621
  with content_cols[0]:
5622
5622
  if is_local:
5623
5623
  st.caption("Esta fonte não usa API key.")
@@ -5631,6 +5631,16 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
5631
5631
  value=active_card_id == card_id,
5632
5632
  key=f"material_card_{card_id}_selected",
5633
5633
  )
5634
+ with content_cols[2]:
5635
+ priority = st.number_input(
5636
+ "Prioridade",
5637
+ min_value=1,
5638
+ max_value=999,
5639
+ value=max(1, int(card.get("priority", index + 1))),
5640
+ step=1,
5641
+ help="1 é o primeiro provider da fila. Em caso de falha elegível, os providers seguintes são considerados por ordem crescente.",
5642
+ key=f"material_card_{card_id}_priority",
5643
+ )
5634
5644
  if not is_local:
5635
5645
  _render_api_test_control(
5636
5646
  settings,
@@ -5640,7 +5650,12 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
5640
5650
  )
5641
5651
  save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
5642
5652
  if save_card:
5643
- cards[index] = {**card, "api_key": str(api_key or "").strip(), "enabled": bool(enabled)}
5653
+ cards[index] = {
5654
+ **card,
5655
+ "api_key": str(api_key or "").strip(),
5656
+ "enabled": bool(enabled),
5657
+ "priority": max(1, int(priority)),
5658
+ }
5644
5659
  selected_id = card_id if selected and enabled else active_card_id
5645
5660
  _persist_material_source_cards(settings, cards, selected_id)
5646
5661
  st.success(f"Fonte {definition['label']} guardada.")
@@ -5668,7 +5683,12 @@ def render_material_source_api_keys(settings: dict[str, Any], *, embedded: bool
5668
5683
  )
5669
5684
  add_source_clicked = st.form_submit_button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card") if embedded else st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card")
5670
5685
  if add_source_clicked:
5671
- cards.append(new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
5686
+ new_card = new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}")
5687
+ new_card["priority"] = max(
5688
+ (int(item.get("priority", index + 1)) for index, item in enumerate(cards)),
5689
+ default=0,
5690
+ ) + 1
5691
+ cards.append(new_card)
5672
5692
  _persist_material_source_cards(settings, cards, str(settings.get("material_active_card_id") or ""))
5673
5693
  st.rerun()
5674
5694
 
@@ -6,6 +6,7 @@ from uuid import uuid4
6
6
 
7
7
  MATERIAL_CARDS_KEY = "material_source_cards"
8
8
  MATERIAL_ACTIVE_CARD_KEY = "material_active_card_id"
9
+ DEFAULT_MATERIAL_PRIORITY = 1
9
10
 
10
11
  MATERIAL_SOURCE_CATALOG: tuple[dict[str, str], ...] = (
11
12
  {"code": "pexels", "label": "Pexels", "description": "Banco de vídeos e imagens para materiais da pipeline.", "legacy_key": "pexels_api_keys"},
@@ -43,15 +44,29 @@ def material_source_definition(source: Any) -> dict[str, str] | None:
43
44
  return _SOURCE_BY_CODE.get(str(source or "").strip().lower())
44
45
 
45
46
 
46
- def _new_card(provider: str, api_key: str = "", *, card_id: str | None = None) -> dict[str, Any]:
47
+ def _new_card(
48
+ provider: str,
49
+ api_key: str = "",
50
+ *,
51
+ card_id: str | None = None,
52
+ priority: int = DEFAULT_MATERIAL_PRIORITY,
53
+ ) -> dict[str, Any]:
47
54
  return {
48
55
  "id": card_id or f"material-{provider}-{uuid4().hex[:8]}",
49
56
  "provider": provider,
50
57
  "api_key": str(api_key or "").strip(),
51
58
  "enabled": True,
59
+ "priority": max(1, int(priority)),
52
60
  }
53
61
 
54
62
 
63
+ def _normalise_priority(value: Any, fallback: int = DEFAULT_MATERIAL_PRIORITY) -> int:
64
+ try:
65
+ return max(1, int(value))
66
+ except (TypeError, ValueError):
67
+ return max(1, int(fallback or DEFAULT_MATERIAL_PRIORITY))
68
+
69
+
55
70
  def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
56
71
  raw = card if isinstance(card, dict) else {}
57
72
  provider = str(raw.get("provider") or raw.get("source") or "pexels").strip().lower()
@@ -63,9 +78,25 @@ def normalize_material_card(card: Any, index: int = 0) -> dict[str, Any]:
63
78
  "provider": provider,
64
79
  "api_key": str(raw.get("api_key") or raw.get("key") or "").strip() if provider != "local" else "",
65
80
  "enabled": bool(raw.get("enabled", True)),
81
+ "priority": _normalise_priority(raw.get("priority", index + 1), index + 1),
66
82
  }
67
83
 
68
84
 
85
+ def _ordered_cards(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
86
+ indexed = [(index, normalize_material_card(card, index)) for index, card in enumerate(cards)]
87
+ indexed.sort(key=lambda pair: (pair[1].get("priority", pair[0] + 1), pair[0]))
88
+ return [card for _index, card in indexed]
89
+
90
+
91
+ def material_source_cards(settings: dict[str, Any], *, enabled_only: bool = False) -> list[dict[str, Any]]:
92
+ """Return material providers in stable priority order for the MoviePy queue."""
93
+ migrated, _ = ensure_material_source_cards(settings)
94
+ cards = [dict(item) for item in migrated.get(MATERIAL_CARDS_KEY, [])]
95
+ if enabled_only:
96
+ cards = [card for card in cards if card.get("enabled", True)]
97
+ return cards
98
+
99
+
69
100
  def _cards_key_mapping(cards: list[dict[str, Any]]) -> dict[str, list[str]]:
70
101
  mapping = {item["code"]: [] for item in MATERIAL_SOURCE_CATALOG}
71
102
  for index, raw_card in enumerate(cards):
@@ -94,12 +125,23 @@ def ensure_material_source_cards(settings: dict[str, Any]) -> tuple[dict[str, An
94
125
  cards: list[dict[str, Any]] = []
95
126
  for item in MATERIAL_SOURCE_CATALOG:
96
127
  for key in material_api_keys(settings, item["code"], _ignore_cards=True):
97
- cards.append(_new_card(item["code"], key, card_id=f"material-{item['code']}-{len(cards)}"))
128
+ cards.append(
129
+ _new_card(
130
+ item["code"],
131
+ key,
132
+ card_id=f"material-{item['code']}-{len(cards)}",
133
+ priority=len(cards) + 1,
134
+ )
135
+ )
98
136
  if not cards:
99
137
  selected = selected_material_source(settings)
100
138
  cards.append(_new_card(selected if selected in _SOURCE_BY_CODE else "pexels", card_id="material-default-0"))
101
139
  changed = True
102
140
 
141
+ ordered_cards = _ordered_cards(cards)
142
+ if ordered_cards != cards:
143
+ changed = True
144
+ cards = ordered_cards
103
145
  settings[MATERIAL_CARDS_KEY] = cards
104
146
  active_id = str(settings.get(MATERIAL_ACTIVE_CARD_KEY) or "").strip()
105
147
  valid_ids = {str(card["id"]) for card in cards}
@@ -137,6 +179,7 @@ def apply_material_source_cards_to_settings(
137
179
  normalized_cards = [normalize_material_card(item, index) for index, item in enumerate(cards)]
138
180
  if not normalized_cards:
139
181
  normalized_cards = [_new_card("pexels", card_id="material-default-0")]
182
+ normalized_cards = _ordered_cards(normalized_cards)
140
183
  settings[MATERIAL_CARDS_KEY] = normalized_cards
141
184
  selected_card = next((card for card in normalized_cards if str(card["id"]) == str(active_card_id)), None)
142
185
  if selected_card is None:
@@ -22,7 +22,7 @@ from hermes_ui.storage import STORAGE, atomic_write, ensure_storage, read_json,
22
22
  from hermes_ui.llm_providers import active_llm_card, provider_definition
23
23
  from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, generate_image_from_pool, generate_video_from_pool
24
24
  from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_for_pool, media_provider_definition
25
- from hermes_ui.material_sources import material_api_keys, selected_material_source
25
+ from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
26
26
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
27
27
 
28
28
  PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
@@ -38,9 +38,16 @@ CASCADE_STAGE_ORDER = ("topic", "script", "title", "keywords", "video", "thumbna
38
38
  class PipelineError(RuntimeError):
39
39
  """Raised when a pipeline stage cannot complete with an actionable error."""
40
40
 
41
- def __init__(self, message: str, *, failure_metadata: dict[str, Any] | None = None):
41
+ def __init__(
42
+ self,
43
+ message: str,
44
+ *,
45
+ failure_metadata: dict[str, Any] | None = None,
46
+ fallback_eligible: bool = False,
47
+ ):
42
48
  super().__init__(message)
43
49
  self.failure_metadata = dict(failure_metadata or {})
50
+ self.fallback_eligible = bool(fallback_eligible)
44
51
 
45
52
 
46
53
  class PipelineStopped(PipelineError):
@@ -620,6 +627,30 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
620
627
  return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
621
628
 
622
629
 
630
+ def _material_video_routes(task: dict[str, Any], settings: dict[str, Any]) -> list[str]:
631
+ """Return stock providers to try, starting with the task's selected source."""
632
+ route = _normalise_video_route(task, settings)
633
+ if route not in {"pexels", "pixabay"}:
634
+ return [route]
635
+
636
+ ordered_providers: list[str] = []
637
+ for card in material_source_cards(settings, enabled_only=True):
638
+ provider = str(card.get("provider") or "").strip().casefold()
639
+ if provider not in {"pexels", "pixabay"} or provider in ordered_providers:
640
+ continue
641
+ if material_api_keys(settings, provider):
642
+ ordered_providers.append(provider)
643
+
644
+ # A selected source is an explicit preference for this task. The remaining
645
+ # configured sources follow their persisted priority and are true fallbacks.
646
+ if route in ordered_providers:
647
+ ordered_providers.remove(route)
648
+ ordered_providers.insert(0, route)
649
+ elif material_api_keys(settings, route):
650
+ ordered_providers.insert(0, route)
651
+ return ordered_providers or [route]
652
+
653
+
623
654
  def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
624
655
  """Reserve extra bounded time only for long stock-video downloads and assembly."""
625
656
  effective_settings = settings if isinstance(settings, dict) else _settings()
@@ -633,7 +664,9 @@ def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None
633
664
  def _task_stale_timeout_seconds(task: dict[str, Any]) -> int:
634
665
  """Keep stale-task recovery aligned with the actual execution budget."""
635
666
  if str(task.get("stage") or "").strip().casefold() == "video":
636
- return _video_timeout_seconds(task) + 5 * 60
667
+ settings = _settings()
668
+ attempts = max(1, len(_material_video_routes(task, settings)))
669
+ return _video_timeout_seconds(task, settings) * attempts + 5 * 60
637
670
  return STALE_TASK_SECONDS
638
671
 
639
672
 
@@ -820,7 +853,12 @@ def _moneyprinter_cli_args(task: dict[str, Any], route: str, settings: dict[str,
820
853
  return args
821
854
 
822
855
 
823
- def _run_video_helper(task: dict[str, Any]) -> Path:
856
+ def _run_video_helper_once(
857
+ task: dict[str, Any],
858
+ *,
859
+ route_override: str = "",
860
+ settings: dict[str, Any] | None = None,
861
+ ) -> Path:
824
862
  helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
825
863
  helper = helper_dir / "mpt_agent.py"
826
864
  if not helper.is_file():
@@ -828,16 +866,23 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
828
866
  subject = str(task.get("topic") or "").strip()
829
867
  if not subject:
830
868
  raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
831
- settings = _settings()
869
+ settings = settings if isinstance(settings, dict) else _settings()
832
870
  configured_root = _configured_moneyprinter_root(settings)
833
871
  task_id = str(task.get("id") or "").strip()
834
872
  if not task_id:
835
873
  raise PipelineError("A tarefa de vídeo não tem um identificador válido.")
836
874
  env = os.environ.copy()
875
+ for key in (
876
+ "MPT_PEXELS_API_KEY",
877
+ "MPT_PEXELS_API_KEYS",
878
+ "MPT_PIXABAY_API_KEY",
879
+ "MPT_PIXABAY_API_KEYS",
880
+ ):
881
+ env.pop(key, None)
837
882
  card = active_llm_card(settings)
838
883
  provider = str(card.get("provider") or "openai").strip()
839
884
  definition = provider_definition(provider)
840
- route = _normalise_video_route(task, settings)
885
+ route = str(route_override or _normalise_video_route(task, settings)).strip().casefold()
841
886
  source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
842
887
  if route in {"pexels", "pixabay"} and not source_keys:
843
888
  source_label = "Pexels" if route == "pexels" else "Pixabay"
@@ -987,7 +1032,11 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
987
1032
  _stop_process(process)
988
1033
  message = f"A etapa Vídeo excedeu o limite de {timeout_seconds // 60} minutos e foi encerrada."
989
1034
  metadata = _failure_attribution(task, settings, "video", error=message)
990
- raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
1035
+ raise PipelineError(
1036
+ _failure_message(message, metadata),
1037
+ failure_metadata=metadata,
1038
+ fallback_eligible=True,
1039
+ )
991
1040
  if time.monotonic() - last_activity_at >= VIDEO_IDLE_TIMEOUT_SECONDS:
992
1041
  _stop_process(process)
993
1042
  message = (
@@ -995,7 +1044,11 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
995
1044
  f"{VIDEO_IDLE_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
996
1045
  )
997
1046
  metadata = _failure_attribution(task, settings, "video", error=message)
998
- raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
1047
+ raise PipelineError(
1048
+ _failure_message(message, metadata),
1049
+ failure_metadata=metadata,
1050
+ fallback_eligible=True,
1051
+ )
999
1052
  finally:
1000
1053
  reader.join(timeout=2)
1001
1054
  _persist_video_diagnostics(task, "\n".join(output_lines))
@@ -1010,12 +1063,20 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
1010
1063
  message = "A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo"
1011
1064
  if detail:
1012
1065
  message += f". Detalhe do helper: {detail}"
1013
- raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
1066
+ raise PipelineError(
1067
+ _failure_message(message, metadata),
1068
+ failure_metadata=metadata,
1069
+ fallback_eligible=True,
1070
+ )
1014
1071
  if result_code != 0:
1015
1072
  detail = _terminal_helper_detail(output) or "erro sem detalhes devolvidos pelo helper"
1016
1073
  metadata = _failure_attribution(task, settings, "video", error=detail, output=output)
1017
1074
  message = f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}"
1018
- raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
1075
+ raise PipelineError(
1076
+ _failure_message(message, metadata),
1077
+ failure_metadata=metadata,
1078
+ fallback_eligible=True,
1079
+ )
1019
1080
  match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
1020
1081
  video_path = Path(match.group(1).strip()).expanduser() if match else None
1021
1082
  if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
@@ -1030,10 +1091,66 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
1030
1091
  if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
1031
1092
  message = "MoneyPrinterTurbo terminou sem devolver um MP4 válido."
1032
1093
  metadata = _failure_attribution(task, settings, "video", error=message, output=output)
1033
- raise PipelineError(_failure_message(message, metadata), failure_metadata=metadata)
1094
+ raise PipelineError(
1095
+ _failure_message(message, metadata),
1096
+ failure_metadata=metadata,
1097
+ fallback_eligible=True,
1098
+ )
1034
1099
  return video_path
1035
1100
 
1036
1101
 
1102
+ def _stock_fallback_is_eligible(route: str, error: PipelineError) -> bool:
1103
+ """Allow fallback only when the failed attempt points to its stock source."""
1104
+ if route not in {"pexels", "pixabay"} or not getattr(error, "fallback_eligible", False):
1105
+ return False
1106
+ metadata = dict(getattr(error, "failure_metadata", {}) or {})
1107
+ providers = {
1108
+ item.strip().casefold()
1109
+ for item in str(metadata.get("failure_provider") or "").split(",")
1110
+ if item.strip()
1111
+ }
1112
+ if providers and providers - {route}:
1113
+ return False
1114
+ fields = {
1115
+ item.strip().casefold()
1116
+ for item in str(metadata.get("failure_config_fields") or "").split(",")
1117
+ if item.strip()
1118
+ }
1119
+ if fields and fields - {f"{route}_api_key", f"{route}_api_keys"}:
1120
+ return False
1121
+ return True
1122
+
1123
+
1124
+ def _run_video_helper(task: dict[str, Any]) -> Path:
1125
+ """Run the stock helper with provider fallback in the configured priority order."""
1126
+ settings = _settings()
1127
+ routes = _material_video_routes(task, settings)
1128
+ if len(routes) <= 1:
1129
+ return _run_video_helper_once(task, route_override=routes[0] if routes else "", settings=settings)
1130
+
1131
+ for index, route in enumerate(routes):
1132
+ attempt_task = dict(task)
1133
+ generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
1134
+ attempt_task["material_source"] = route
1135
+ attempt_task["generation_settings"] = {**generation_settings, "material_source": route}
1136
+ try:
1137
+ return _run_video_helper_once(attempt_task, route_override=route, settings=settings)
1138
+ except PipelineStopped:
1139
+ raise
1140
+ except PipelineError as exc:
1141
+ if not _stock_fallback_is_eligible(route, exc) or index == len(routes) - 1:
1142
+ metadata = dict(getattr(exc, "failure_metadata", {}) or {})
1143
+ metadata["failure_route"] = route
1144
+ metadata["fallback_attempts"] = " → ".join(_provider_api_label(item) for item in routes[: index + 1])
1145
+ message = (
1146
+ f"Falha no provider {_provider_api_label(route)} após tentar "
1147
+ f"{metadata['fallback_attempts']}. Último erro: {exc}"
1148
+ )
1149
+ raise PipelineError(message, failure_metadata=metadata) from exc
1150
+
1151
+ raise PipelineError("Nenhum provider de vídeo stock configurado.")
1152
+
1153
+
1037
1154
  def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
1038
1155
  """Load a previously saved script so retries do not regenerate it."""
1039
1156
  artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.4.10",
3
+ "version": "0.4.12",
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",