@danhachuel/thunderbolt 0.5.78 → 0.5.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -5162,6 +5162,39 @@ def render_thumbnails():
5162
5162
 
5163
5163
 
5164
5164
  @st.fragment(run_every=5.0)
5165
+ def _render_tiktok_automation_cards():
5166
+ st.divider()
5167
+ st.subheader("Vídeos cadastrados TikTok")
5168
+ st.caption("Esta fila mostra exclusivamente tarefas associadas a canais TikTok.")
5169
+ tiktok_tasks = load_automation_tasks_for_platform("tiktok")
5170
+ if not tiktok_tasks:
5171
+ st.info("Ainda não existem vídeos TikTok cadastrados.")
5172
+ for task in tiktok_tasks:
5173
+ task_id = str(task["id"])
5174
+ with st.container(border=True):
5175
+ cols = st.columns([2.4, 1.4, 1.1, 1.6])
5176
+ with cols[0]:
5177
+ thumbnail_path = _task_thumbnail_path(task)
5178
+ if thumbnail_path:
5179
+ st.image(str(thumbnail_path), width=150, caption="Thumbnail")
5180
+ st.write(f"**{task.get('title') or task.get('topic') or 'Vídeo TikTok'}**")
5181
+ st.caption(f"{task.get('channel_name') or 'Canal TikTok'} · {task_id}")
5182
+ with cols[1]:
5183
+ _render_video_task_state(task)
5184
+ with cols[2]:
5185
+ st.caption("Plataforma")
5186
+ st.write("TikTok")
5187
+ st.caption("Portrait 9:16")
5188
+ with cols[3]:
5189
+ state = str(task.get("state") or "")
5190
+ if st.button("Start", key=f"tiktok_automation_start_{task_id}", disabled=state not in {"to_do", "blocked", "failed"}, use_container_width=True):
5191
+ _start_pipeline_task(task_id, state)
5192
+ st.rerun()
5193
+ if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", disabled=state != "doing", use_container_width=True):
5194
+ stop_task_by_user(task_id)
5195
+ st.rerun()
5196
+
5197
+
5165
5198
  def render_tiktok_automation():
5166
5199
  st.title("Automação Tiktok")
5167
5200
  st.caption("Agendamento diário da geração por canal. A fila TikTok usa Prompt Master e mantém exclusivamente o formato Portrait 9:16.")
@@ -5220,39 +5253,115 @@ def render_tiktok_automation():
5220
5253
  update_channel(channel_id, {"automation_on": bool(enabled), "automation_time": schedule_time.strip(), "default_prompt_master": prompt, "prompt_master": prompt, "platform": "tiktok", "format": automation_format, "video_aspect_ratio": "Portrait 9:16", "style_wide": "portrait", "avatar_url": avatar_url, "thumbnail_url": avatar_url})
5221
5254
  st.success("Automação TikTok guardada.")
5222
5255
  st.rerun()
5223
- st.divider()
5224
- st.subheader("Vídeos cadastrados TikTok")
5225
- st.caption("Esta fila mostra exclusivamente tarefas associadas a canais TikTok.")
5226
- tiktok_tasks = load_automation_tasks_for_platform("tiktok")
5227
- if not tiktok_tasks:
5228
- st.info("Ainda não existem vídeos TikTok cadastrados.")
5229
- for task in tiktok_tasks:
5230
- task_id = str(task["id"])
5231
- with st.container(border=True):
5232
- cols = st.columns([2.4, 1.4, 1.1, 1.6])
5233
- with cols[0]:
5256
+ _render_tiktok_automation_cards()
5257
+
5258
+ @st.fragment(run_every=5.0)
5259
+ def _render_youtube_automation_cards():
5260
+ st.divider()
5261
+ st.subheader("Vídeos cadastrados")
5262
+ st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Em tarefas falhadas ou bloqueadas, a nova tentativa lê as chaves, prioridades e configurações actualmente guardadas. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
5263
+ tasks = load_automation_tasks_for_platform("youtube")
5264
+ if not tasks:
5265
+ st.info("Ainda não existem vídeos cadastrados.")
5266
+ for task in tasks:
5267
+ with st.container(border=True):
5268
+ task_cols = st.columns([2.25, 1.55, 1.05, 2.15], gap="small")
5269
+ script_path = _task_artifact_path(task, "script")
5270
+ video_path = _task_artifact_path(task, "video")
5234
5271
  thumbnail_path = _task_thumbnail_path(task)
5235
- if thumbnail_path:
5236
- st.image(str(thumbnail_path), width=150, caption="Thumbnail")
5237
- st.write(f"**{task.get('title') or task.get('topic') or 'Vídeo TikTok'}**")
5238
- st.caption(f"{task.get('channel_name') or 'Canal TikTok'} · {task_id}")
5239
- with cols[1]:
5240
- _render_video_task_state(task)
5241
- with cols[2]:
5242
- st.caption("Plataforma")
5243
- st.write("TikTok")
5244
- st.caption("Portrait 9:16")
5245
- with cols[3]:
5246
- state = str(task.get("state") or "")
5247
- if st.button("Start", key=f"tiktok_automation_start_{task_id}", disabled=state not in {"to_do", "blocked", "failed"}, use_container_width=True):
5248
- _start_pipeline_task(task_id, state)
5249
- st.rerun()
5250
- if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", disabled=state != "doing", use_container_width=True):
5251
- stop_task_by_user(task_id)
5252
- st.rerun()
5272
+ thumbnail_prompt_path = _task_artifact_path(task, "thumbnail_prompt_json")
5273
+ thumbnail_prompt = str(task.get("thumbnail_prompt") or "").strip()
5274
+ with task_cols[0]:
5275
+ if thumbnail_path:
5276
+ st.image(str(thumbnail_path), width=180, caption="Thumbnail")
5277
+ else:
5278
+ st.caption("Thumbnail ainda não pronta")
5279
+ st.write(f"**{task.get('topic', 'Sem tópico')}**")
5280
+ st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
5281
+ thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
5282
+ with thumbnail_download_col:
5283
+ st.download_button(
5284
+ "Baixar Thumbnail",
5285
+ data=thumbnail_path.read_bytes() if thumbnail_path else b"",
5286
+ file_name=thumbnail_path.name if thumbnail_path else "thumbnail.png",
5287
+ mime="image/png",
5288
+ key=f"automation_download_thumbnail_{task['id']}",
5289
+ use_container_width=True,
5290
+ disabled=thumbnail_path is None,
5291
+ )
5292
+ with prompt_download_col:
5293
+ prompt_data = thumbnail_prompt_path.read_bytes() if thumbnail_prompt_path else thumbnail_prompt.encode("utf-8")
5294
+ st.download_button(
5295
+ "Baixar Thumbnail Prompt",
5296
+ data=prompt_data,
5297
+ file_name=thumbnail_prompt_path.name if thumbnail_prompt_path else "thumbnail-prompt.txt",
5298
+ mime="application/json" if thumbnail_prompt_path else "text/plain",
5299
+ key=f"automation_download_thumbnail_prompt_{task['id']}",
5300
+ use_container_width=True,
5301
+ disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5302
+ )
5303
+ with task_cols[1]:
5304
+ _render_video_task_state(task)
5305
+ with task_cols[2]:
5306
+ st.caption("Formato")
5307
+ st.write(_video_task_format(task))
5308
+ with task_cols[3]:
5309
+ state = str(task.get("state") or "")
5310
+ start_col, stop_col, delete_col = st.columns(3)
5311
+ with start_col:
5312
+ if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5313
+ if state in {"failed", "blocked"}:
5314
+ retry_task_with_current_settings(task["id"])
5315
+ else:
5316
+ transition_task(task["id"], "doing")
5317
+ st.rerun()
5318
+ with stop_col:
5319
+ if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5320
+ stop_task_by_user(task["id"])
5321
+ st.rerun()
5322
+ script_download_col, video_download_col = st.columns(2, gap="small")
5323
+ with script_download_col:
5324
+ st.download_button(
5325
+ "Baixar Roteiro",
5326
+ data=script_path.read_bytes() if script_path else b"",
5327
+ file_name=script_path.name if script_path else "roteiro.md",
5328
+ mime="text/markdown",
5329
+ key=f"automation_download_script_{task['id']}",
5330
+ use_container_width=True,
5331
+ disabled=script_path is None,
5332
+ )
5333
+ with video_download_col:
5334
+ st.download_button(
5335
+ "Baixar Vídeo",
5336
+ data=video_path.read_bytes() if video_path else b"",
5337
+ file_name=video_path.name if video_path else "video.mp4",
5338
+ mime="video/mp4",
5339
+ key=f"automation_download_video_{task['id']}",
5340
+ use_container_width=True,
5341
+ disabled=video_path is None,
5342
+ )
5343
+ with delete_col:
5344
+ confirm_delete_key = f"automation_confirm_delete_{task['id']}"
5345
+ if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
5346
+ st.session_state[confirm_delete_key] = True
5347
+ st.rerun()
5348
+ if st.session_state.get(confirm_delete_key):
5349
+ st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
5350
+ confirm_col, cancel_col = st.columns(2)
5351
+ with confirm_col:
5352
+ if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
5353
+ try:
5354
+ delete_task(task["id"])
5355
+ st.session_state.pop(confirm_delete_key, None)
5356
+ st.rerun()
5357
+ except ValueError as exc:
5358
+ st.error(str(exc))
5359
+ with cancel_col:
5360
+ if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
5361
+ st.session_state.pop(confirm_delete_key, None)
5362
+ st.rerun()
5253
5363
 
5254
5364
 
5255
- @st.fragment(run_every=5.0)
5256
5365
  def render_automation():
5257
5366
  st.title("Automação Youtube")
5258
5367
  st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
@@ -5338,110 +5447,7 @@ def render_automation():
5338
5447
  st.success("Agendamento guardado.")
5339
5448
  st.rerun()
5340
5449
 
5341
- st.divider()
5342
- st.subheader("Vídeos cadastrados")
5343
- st.caption("Start retoma as etapas já concluídas e só gera novamente o que ainda não estiver pronto. Em tarefas falhadas ou bloqueadas, a nova tentativa lê as chaves, prioridades e configurações actualmente guardadas. Apagar remove o card da fila após confirmação e preserva os artefactos locais.")
5344
- tasks = load_automation_tasks_for_platform("youtube")
5345
- if not tasks:
5346
- st.info("Ainda não existem vídeos cadastrados.")
5347
- for task in tasks:
5348
- with st.container(border=True):
5349
- task_cols = st.columns([2.25, 1.55, 1.05, 2.15], gap="small")
5350
- script_path = _task_artifact_path(task, "script")
5351
- video_path = _task_artifact_path(task, "video")
5352
- thumbnail_path = _task_thumbnail_path(task)
5353
- thumbnail_prompt_path = _task_artifact_path(task, "thumbnail_prompt_json")
5354
- thumbnail_prompt = str(task.get("thumbnail_prompt") or "").strip()
5355
- with task_cols[0]:
5356
- if thumbnail_path:
5357
- st.image(str(thumbnail_path), width=180, caption="Thumbnail")
5358
- else:
5359
- st.caption("Thumbnail ainda não pronta")
5360
- st.write(f"**{task.get('topic', 'Sem tópico')}**")
5361
- st.caption(f"{task.get('channel_name', 'Canal')} · {task.get('id', '')}")
5362
- thumbnail_download_col, prompt_download_col = st.columns(2, gap="small")
5363
- with thumbnail_download_col:
5364
- st.download_button(
5365
- "Baixar Thumbnail",
5366
- data=thumbnail_path.read_bytes() if thumbnail_path else b"",
5367
- file_name=thumbnail_path.name if thumbnail_path else "thumbnail.png",
5368
- mime="image/png",
5369
- key=f"automation_download_thumbnail_{task['id']}",
5370
- use_container_width=True,
5371
- disabled=thumbnail_path is None,
5372
- )
5373
- with prompt_download_col:
5374
- prompt_data = thumbnail_prompt_path.read_bytes() if thumbnail_prompt_path else thumbnail_prompt.encode("utf-8")
5375
- st.download_button(
5376
- "Baixar Thumbnail Prompt",
5377
- data=prompt_data,
5378
- file_name=thumbnail_prompt_path.name if thumbnail_prompt_path else "thumbnail-prompt.txt",
5379
- mime="application/json" if thumbnail_prompt_path else "text/plain",
5380
- key=f"automation_download_thumbnail_prompt_{task['id']}",
5381
- use_container_width=True,
5382
- disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5383
- )
5384
- with task_cols[1]:
5385
- _render_video_task_state(task)
5386
- with task_cols[2]:
5387
- st.caption("Formato")
5388
- st.write(_video_task_format(task))
5389
- with task_cols[3]:
5390
- state = str(task.get("state") or "")
5391
- start_col, stop_col, delete_col = st.columns(3)
5392
- with start_col:
5393
- if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5394
- if state in {"failed", "blocked"}:
5395
- retry_task_with_current_settings(task["id"])
5396
- else:
5397
- transition_task(task["id"], "doing")
5398
- st.rerun()
5399
- with stop_col:
5400
- if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5401
- stop_task_by_user(task["id"])
5402
- st.rerun()
5403
- script_download_col, video_download_col = st.columns(2, gap="small")
5404
- with script_download_col:
5405
- st.download_button(
5406
- "Baixar Roteiro",
5407
- data=script_path.read_bytes() if script_path else b"",
5408
- file_name=script_path.name if script_path else "roteiro.md",
5409
- mime="text/markdown",
5410
- key=f"automation_download_script_{task['id']}",
5411
- use_container_width=True,
5412
- disabled=script_path is None,
5413
- )
5414
- with video_download_col:
5415
- st.download_button(
5416
- "Baixar Vídeo",
5417
- data=video_path.read_bytes() if video_path else b"",
5418
- file_name=video_path.name if video_path else "video.mp4",
5419
- mime="video/mp4",
5420
- key=f"automation_download_video_{task['id']}",
5421
- use_container_width=True,
5422
- disabled=video_path is None,
5423
- )
5424
- with delete_col:
5425
- confirm_delete_key = f"automation_confirm_delete_{task['id']}"
5426
- if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
5427
- st.session_state[confirm_delete_key] = True
5428
- st.rerun()
5429
- if st.session_state.get(confirm_delete_key):
5430
- st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
5431
- confirm_col, cancel_col = st.columns(2)
5432
- with confirm_col:
5433
- if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
5434
- try:
5435
- delete_task(task["id"])
5436
- st.session_state.pop(confirm_delete_key, None)
5437
- st.rerun()
5438
- except ValueError as exc:
5439
- st.error(str(exc))
5440
- with cancel_col:
5441
- if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
5442
- st.session_state.pop(confirm_delete_key, None)
5443
- st.rerun()
5444
-
5450
+ _render_youtube_automation_cards()
5445
5451
 
5446
5452
  def render_upload_direct():
5447
5453
  st.subheader("Upload directo")
@@ -658,28 +658,37 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
658
658
  return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
659
659
 
660
660
 
661
- def _material_video_routes(task: dict[str, Any], settings: dict[str, Any]) -> list[str]:
662
- """Return stock providers to try, starting with the task's selected source."""
661
+ def _material_video_attempts(task: dict[str, Any], settings: dict[str, Any]) -> list[tuple[str, str]]:
662
+ """Return one stock attempt per configured key in persisted priority order."""
663
663
  route = _normalise_video_route(task, settings)
664
664
  if route not in {"pexels", "pixabay"}:
665
- return [route]
665
+ return [(route, "")]
666
666
 
667
- ordered_providers: list[str] = []
668
- for card in material_source_cards(settings, enabled_only=True):
667
+ has_explicit_cards = isinstance(settings.get("material_source_cards"), list) and bool(settings.get("material_source_cards"))
668
+ attempts: list[tuple[str, str]] = []
669
+ for card in material_source_cards(settings, enabled_only=True) if has_explicit_cards else []:
669
670
  provider = str(card.get("provider") or "").strip().casefold()
670
- if provider not in {"pexels", "pixabay"} or provider in ordered_providers:
671
+ api_key = str(card.get("api_key") or "").strip()
672
+ if provider not in {"pexels", "pixabay"} or not api_key:
671
673
  continue
672
- if material_api_keys(settings, provider):
673
- ordered_providers.append(provider)
674
+ attempts.append((provider, api_key))
675
+
676
+ # A selected source is an explicit preference for this task. Its individual
677
+ # keys remain in their configured priority order before other providers.
678
+ preferred = [item for item in attempts if item[0] == route]
679
+ fallback = [item for item in attempts if item[0] != route]
680
+ ordered = preferred + fallback
681
+ if ordered:
682
+ return ordered
683
+ # Legacy settings keep the complete key list in one config field. Let
684
+ # MoneyPrinterTurbo retain its own list handling when no individual cards
685
+ # are available, preserving backwards compatibility for older installs.
686
+ return [(route, "")]
674
687
 
675
- # A selected source is an explicit preference for this task. The remaining
676
- # configured sources follow their persisted priority and are true fallbacks.
677
- if route in ordered_providers:
678
- ordered_providers.remove(route)
679
- ordered_providers.insert(0, route)
680
- elif material_api_keys(settings, route):
681
- ordered_providers.insert(0, route)
682
- return ordered_providers or [route]
688
+
689
+ def _material_video_routes(task: dict[str, Any], settings: dict[str, Any]) -> list[str]:
690
+ """Return provider names for compatibility with diagnostics and callers."""
691
+ return [provider for provider, _api_key in _material_video_attempts(task, settings)]
683
692
 
684
693
 
685
694
  def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
@@ -890,6 +899,7 @@ def _run_video_helper_once(
890
899
  task: dict[str, Any],
891
900
  *,
892
901
  route_override: str = "",
902
+ api_key_override: str = "",
893
903
  settings: dict[str, Any] | None = None,
894
904
  ) -> Path:
895
905
  helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
@@ -917,6 +927,8 @@ def _run_video_helper_once(
917
927
  definition = provider_definition(provider)
918
928
  route = str(route_override or _normalise_video_route(task, settings)).strip().casefold()
919
929
  source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
930
+ if api_key_override and route in {"pexels", "pixabay"}:
931
+ source_keys = [str(api_key_override).strip()]
920
932
  if route in {"pexels", "pixabay"} and not source_keys:
921
933
  source_label = "Pexels" if route == "pexels" else "Pixabay"
922
934
  message = f"Configure pelo menos uma API key de {source_label} em Configurações > Configuração API > Fontes de materiais."
@@ -1175,31 +1187,31 @@ def _stock_fallback_is_eligible(route: str, error: PipelineError) -> bool:
1175
1187
  def _run_video_helper(task: dict[str, Any]) -> Path:
1176
1188
  """Run the stock helper with provider fallback in the configured priority order."""
1177
1189
  settings = _settings()
1178
- routes = _material_video_routes(task, settings)
1179
- if len(routes) <= 1:
1180
- return _run_video_helper_once(task, route_override=routes[0] if routes else "", settings=settings)
1181
-
1182
- for index, route in enumerate(routes):
1190
+ attempts = _material_video_attempts(task, settings)
1191
+ if len(attempts) <= 1:
1192
+ route, api_key = attempts[0] if attempts else ("", "")
1193
+ return _run_video_helper_once(task, route_override=route, api_key_override=api_key, settings=settings)
1194
+ for index, (route, api_key) in enumerate(attempts):
1183
1195
  attempt_task = dict(task)
1184
1196
  generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
1185
1197
  attempt_task["material_source"] = route
1186
1198
  attempt_task["generation_settings"] = {**generation_settings, "material_source": route}
1187
1199
  try:
1188
- return _run_video_helper_once(attempt_task, route_override=route, settings=settings)
1200
+ return _run_video_helper_once(attempt_task, route_override=route, api_key_override=api_key, settings=settings)
1189
1201
  except PipelineStopped:
1190
1202
  raise
1191
1203
  except PipelineError as exc:
1192
- if not _stock_fallback_is_eligible(route, exc) or index == len(routes) - 1:
1204
+ if not _stock_fallback_is_eligible(route, exc) or index == len(attempts) - 1:
1193
1205
  metadata = dict(getattr(exc, "failure_metadata", {}) or {})
1194
1206
  metadata["failure_route"] = route
1195
- metadata["fallback_attempts"] = " → ".join(_provider_api_label(item) for item in routes[: index + 1])
1207
+ metadata["fallback_attempts"] = " → ".join(_provider_api_label(item[0]) for item in attempts[: index + 1])
1196
1208
  message = (
1197
1209
  f"Falha no provider {_provider_api_label(route)} após tentar "
1198
1210
  f"{metadata['fallback_attempts']}. Último erro: {exc}"
1199
1211
  )
1200
1212
  raise PipelineError(message, failure_metadata=metadata) from exc
1201
1213
 
1202
- raise PipelineError("Nenhum provider de vídeo stock configurado.")
1214
+ raise PipelineError("Nenhuma chave de provider de vídeo stock configurada.")
1203
1215
 
1204
1216
 
1205
1217
  def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.5.78",
3
+ "version": "0.5.80",
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",