@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 +40 -2
- package/hermes_ui/domain.py +49 -0
- package/integrations/composio_upload.py +19 -9
- package/package.json +1 -1
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)
|
package/hermes_ui/domain.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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