@danhachuel/thunderbolt 0.6.85 → 0.6.87

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
@@ -77,7 +77,7 @@ def display_version(version: str) -> str:
77
77
 
78
78
  APP_VERSION_LABEL = display_version(APP_VERSION)
79
79
 
80
- from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, retry_task_with_current_settings, set_channel_defaults, stop_task_by_user, transition_task, update_channel, update_channel_video
80
+ from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, delete_task, pipeline_summary, remake_video_task, retry_task_with_current_settings, set_channel_defaults, stop_task_by_user, transition_task, update_channel, update_channel_video
81
81
  from hermes_ui.channel_import import build_channel_template_xlsx, channel_is_duplicate, find_duplicate_channel, parse_channel_workbook, resolve_blueprint, resolve_google_account, resolve_voice
82
82
  from hermes_ui.drafts import list_drafts, save_draft
83
83
  from hermes_ui.automation_worker import load_worker_status
@@ -5057,6 +5057,21 @@ def _start_pipeline_task(task_id: str, state: str) -> bool:
5057
5057
  return True
5058
5058
 
5059
5059
 
5060
+ def _remake_video_from_card(task: dict[str, Any]) -> bool:
5061
+ """Queue a new render while keeping the script and all creative artefacts."""
5062
+ task_id = str(task.get("id") or "")
5063
+ try:
5064
+ updated = remake_video_task(task_id)
5065
+ except ValueError as exc:
5066
+ st.error(str(exc))
5067
+ return False
5068
+ if not updated:
5069
+ st.error("Não foi possível refazer este vídeo: a tarefa já não existe na fila.")
5070
+ return False
5071
+ st.success("Vídeo colocado na fila para remontagem. Roteiro, tags, voz, thumbnail e artefactos existentes foram preservados.")
5072
+ return True
5073
+
5074
+
5060
5075
  def render_videos():
5061
5076
  st.subheader("Backlog Videos")
5062
5077
  st.caption("Acompanhamento dos vídeos criados, estados da pipeline e controlos de execução.")
@@ -5597,6 +5612,17 @@ def _render_tiktok_automation_cards():
5597
5612
  use_container_width=True,
5598
5613
  disabled=video_path is None,
5599
5614
  )
5615
+ if st.button(
5616
+ "Refazer Vídeo",
5617
+ key=f"tiktok_automation_remake_video_{task_id}",
5618
+ icon=":material/refresh:",
5619
+ type="primary",
5620
+ use_container_width=True,
5621
+ disabled=state == "doing",
5622
+ help="Remonta apenas o vídeo, mantendo o mesmo roteiro, Blueprint/Prompt Master, tags, thumbnail, voz e artefactos já baixados.",
5623
+ ):
5624
+ if _remake_video_from_card(task):
5625
+ st.rerun()
5600
5626
  with delete_col:
5601
5627
  confirm_delete_key = f"tiktok_automation_confirm_delete_{task_id}"
5602
5628
  if st.button("Apagar", key=f"tiktok_automation_delete_{task_id}", use_container_width=True, disabled=state == "doing"):
@@ -5769,6 +5795,17 @@ def _render_youtube_automation_cards():
5769
5795
  use_container_width=True,
5770
5796
  disabled=video_path is None,
5771
5797
  )
5798
+ if st.button(
5799
+ "Refazer Vídeo",
5800
+ key=f"automation_remake_video_{task['id']}",
5801
+ icon=":material/refresh:",
5802
+ type="primary",
5803
+ use_container_width=True,
5804
+ disabled=state == "doing",
5805
+ help="Remonta apenas o vídeo, mantendo o mesmo roteiro, Blueprint/Prompt Master, tags, thumbnail, voz e artefactos já baixados.",
5806
+ ):
5807
+ if _remake_video_from_card(task):
5808
+ st.rerun()
5772
5809
  with delete_col:
5773
5810
  confirm_delete_key = f"automation_confirm_delete_{task['id']}"
5774
5811
  if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
@@ -6326,6 +6363,7 @@ def render_upload_composio():
6326
6363
  settings = read_json("settings.json", {})
6327
6364
  api_key = str(settings.get("composio_api_key") or "").strip()
6328
6365
  user_id = str(settings.get("composio_user_id") or "").strip()
6366
+ connected_account_id = str(settings.get("composio_connected_account_id") or "").strip()
6329
6367
  if not settings.get("composio_enabled", False) or not api_key:
6330
6368
  st.warning("Composio está desactivado ou sem API key. Configure-o em Configuração API > API Keys Upload > Composio.")
6331
6369
  return
@@ -6377,7 +6415,7 @@ def render_upload_composio():
6377
6415
  if st.button("Enviar vídeo via Composio", type="primary", key=f"composio_upload_send_{selected_task_id}", use_container_width=True):
6378
6416
  try:
6379
6417
  parse_arguments(arguments_json)
6380
- result = execute_upload(api_key, user_id, selected_slug, video_path, file_field, arguments_json)
6418
+ result = execute_upload(api_key, user_id, selected_slug, video_path, file_field, arguments_json, connected_account_id)
6381
6419
  record = {"id": uuid.uuid4().hex, "task_id": task.get("id"), "destination": "Composio", "target": {"toolkit": selected_tool.get("toolkit"), "slug": selected_slug, "file_field": file_field}, "status": "published" if result.get("successful") else "failed", "message": result.get("error") or "Upload Composio concluído.", "data": result.get("data") or {}, "log_id": result.get("log_id") or "", "created_at": now()}
6382
6420
  uploads = read_json("uploads.json", [])
6383
6421
  uploads.append(record)
@@ -470,6 +470,55 @@ def retry_task_with_current_settings(task_id: str) -> dict[str, Any] | None:
470
470
  return update_json("tasks.json", [], mutate)
471
471
 
472
472
 
473
+ def remake_video_task(task_id: str) -> dict[str, Any] | None:
474
+ """Queue a fresh video render while retaining the task's creative inputs.
475
+
476
+ The persisted script, blueprint, keywords/tags, voice, thumbnail prompt/image,
477
+ generation settings and any non-video media artefacts are intentionally kept.
478
+ Only the rendered video and publication result are invalidated so the worker
479
+ rebuilds the video instead of regenerating the creative brief.
480
+ """
481
+ normalized_id = str(task_id or "").strip()
482
+
483
+ def mutate(tasks: Any) -> dict[str, Any] | None:
484
+ if not isinstance(tasks, list):
485
+ raise StorageIntegrityError("O ficheiro tasks.json não contém uma lista válida.")
486
+ for task in tasks:
487
+ if not isinstance(task, dict) or str(task.get("id") or "") != normalized_id:
488
+ continue
489
+ if str(task.get("state") or "") == "doing":
490
+ raise ValueError("Pare a tarefa antes de refazer o vídeo.")
491
+ artifacts = dict(task.get("artifacts") or {})
492
+ artifacts.pop("video", None)
493
+ artifacts.pop("upload", None)
494
+ try:
495
+ remake_count = int(task.get("remake_count") or 0)
496
+ except (TypeError, ValueError):
497
+ remake_count = 0
498
+ for field in ("video_log", "video_result", "video_helper_status", "video_elapsed_seconds", "error"):
499
+ task.pop(field, None)
500
+ task.update({
501
+ "artifacts": artifacts,
502
+ "stage": "video",
503
+ "state": "to_do",
504
+ "progress": 50,
505
+ "video_ready": False,
506
+ "remake_requested_at": now(),
507
+ "remake_count": remake_count + 1,
508
+ "error": None,
509
+ "updated_at": now(),
510
+ })
511
+ orchestration = dict(task.get("orchestration") or {})
512
+ orchestration["current_stage"] = "video"
513
+ orchestration["resumable"] = True
514
+ orchestration["last_transition_at"] = now()
515
+ task["orchestration"] = orchestration
516
+ return task
517
+ return None
518
+
519
+ return update_json("tasks.json", [], mutate)
520
+
521
+
473
522
  def pipeline_summary() -> dict[str, Any]:
474
523
  tasks = read_json("tasks.json", [])
475
524
  channels = read_json("channels.json", [])
@@ -112,28 +112,34 @@ def _client(api_key: str, *, upload_dir: Path | None = None):
112
112
 
113
113
 
114
114
  def _connected_account_id(client: Any, user_id: str, toolkit: str, selector: str) -> str:
115
- """Resolve a connected-account ID from an ID or UI alias."""
115
+ """Resolve a connected-account ID from an ID, alias, or sole active account."""
116
116
  value = str(selector or "").strip()
117
- if not value:
118
- return ""
119
117
  try:
120
118
  response = client.connected_accounts.list(user_ids=[_require_user_id(user_id)], statuses=["ACTIVE"])
121
119
  raw = _safe_value(response)
122
120
  items = raw.get("items", []) if isinstance(raw, dict) else raw
123
121
  if not isinstance(items, list):
124
122
  items = []
125
- wanted = value.casefold()
123
+ matching_items = []
126
124
  for item in items:
127
125
  if not isinstance(item, dict):
128
126
  continue
129
127
  toolkit_value = item.get("toolkit")
130
128
  if isinstance(toolkit_value, dict):
131
129
  toolkit_value = toolkit_value.get("slug") or toolkit_value.get("name")
132
- candidates = [item.get("id"), item.get("nanoid"), item.get("alias"), item.get("name")]
133
130
  if str(toolkit or "").strip() and toolkit.casefold() not in str(toolkit_value or "").casefold():
134
131
  continue
135
- if any(str(candidate or "").strip().casefold() == wanted for candidate in candidates):
136
- return str(item.get("id") or item.get("nanoid") or value).strip()
132
+ matching_items.append(item)
133
+ if value:
134
+ wanted = value.casefold()
135
+ for item in matching_items:
136
+ candidates = [item.get("id"), item.get("nanoid"), item.get("alias"), item.get("name")]
137
+ if any(str(candidate or "").strip().casefold() == wanted for candidate in candidates):
138
+ return str(item.get("id") or item.get("nanoid") or value).strip()
139
+ return value
140
+ if len(matching_items) == 1:
141
+ item = matching_items[0]
142
+ return str(item.get("id") or item.get("nanoid") or "").strip()
137
143
  except Exception:
138
144
  # Preserve the original selector so Composio returns its actionable error.
139
145
  return value
@@ -237,8 +243,12 @@ def execute_upload(api_key: str, user_id: str, slug: str, video_path: str, file_
237
243
  "dangerously_skip_version_check": True,
238
244
  }
239
245
  selected_account = _connected_account_id(client, user_id, "youtube", connected_account_id)
240
- if selected_account:
241
- execute_kwargs["connected_account_id"] = selected_account
246
+ if not selected_account:
247
+ raise ComposioUploadError(
248
+ f"Nenhuma conta YouTube activa está ligada ao Composio user ID `{_require_user_id(user_id)}`. "
249
+ "Use `Autorizar toolkit no Composio` ou configure o Connected account ID correcto."
250
+ )
251
+ execute_kwargs["connected_account_id"] = selected_account
242
252
  result = client.tools.execute(slug, **execute_kwargs)
243
253
  response = _response(result)
244
254
  if not response["successful"] and not response["error"]:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.85",
3
+ "version": "0.6.87",
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",