@danhachuel/thunderbolt 0.7.31 → 0.7.32

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
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import sys
3
3
  import io
4
+ import shutil
5
+ import subprocess
4
6
 
5
7
  os.environ["PYTHONIOENCODING"] = "utf-8"
6
8
  os.environ["PYTHONUTF8"] = "1"
@@ -633,6 +635,32 @@ def _library_card_key(kind: str, path: Path) -> str:
633
635
  return hashlib.sha1(f"{kind}:{path.resolve()}".encode("utf-8")).hexdigest()[:12]
634
636
 
635
637
 
638
+ def _video_thumbnail(path: Path) -> bytes | None:
639
+ """Create a small cached preview frame for a local video card."""
640
+ if not path.is_file():
641
+ return None
642
+ thumbnail_dir = STORAGE / "ui_cache" / "video_thumbnails"
643
+ thumbnail_dir.mkdir(parents=True, exist_ok=True)
644
+ target = thumbnail_dir / f"{hashlib.sha1(str(path.resolve()).encode('utf-8')).hexdigest()}.jpg"
645
+ try:
646
+ if target.is_file() and target.stat().st_mtime >= path.stat().st_mtime:
647
+ return target.read_bytes()
648
+ ffmpeg = shutil.which("ffmpeg")
649
+ if not ffmpeg:
650
+ return None
651
+ completed = subprocess.run(
652
+ [ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-ss", "00:00:01", "-i", str(path), "-frames:v", "1", "-vf", "scale=640:-2", str(target)],
653
+ capture_output=True,
654
+ check=False,
655
+ timeout=20,
656
+ )
657
+ if completed.returncode == 0 and target.is_file():
658
+ return target.read_bytes()
659
+ except (OSError, subprocess.SubprocessError):
660
+ return None
661
+ return None
662
+
663
+
636
664
  def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str:
637
665
  """Render the inline name editor while keeping the physical filename unchanged."""
638
666
  edit_key = f"rename_{kind}_{_library_card_key(kind, path)}"
@@ -4833,12 +4861,7 @@ def render_media_download():
4833
4861
 
4834
4862
  def render_cuts():
4835
4863
  st.title("Cortes")
4836
- st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
4837
-
4838
- st.markdown(
4839
- "<div class='tb-cuts-hero'><div class='tb-cuts-kicker'>01 · CLIP GENERATOR</div><h2>Create Viral Shorts</h2><p>Escolha um vídeo longo, defina o formato e gere clips locais sem sobrescrever a fonte.</p></div>",
4840
- unsafe_allow_html=True,
4841
- )
4864
+ st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, sem sobrescrever a fonte.")
4842
4865
 
4843
4866
  source_path = None
4844
4867
  source_tab, url_tab, generated_tab, folder_tab = render_localized_tabs(["Upload ficheiro", "URL de vídeo", "Vídeos gerados", "Pasta local"])
@@ -4870,14 +4893,27 @@ def render_cuts():
4870
4893
  with generated_tab:
4871
4894
  generated_paths = list_cut_generated_videos(read_json("tasks.json", []))
4872
4895
  if not generated_paths:
4873
- st.info("Ainda não existem vídeos gerados com caminho registado na pipeline.")
4896
+ st.info("Ainda não existem vídeos prontos na pipeline.")
4874
4897
  else:
4875
- generated_labels = [f"{path.name} — {path}" for path in generated_paths]
4876
- selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
4877
- if st.button("Usar vídeo seleccionado", key="cuts_use_generated", width="stretch"):
4878
- source_path = generated_paths[selected_generated]
4879
- st.session_state["cuts_source_path"] = str(source_path)
4880
- st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
4898
+ st.caption(f"{len(generated_paths)} vídeo(s) pronto(s) disponível(is).")
4899
+ for index, generated_path in enumerate(generated_paths):
4900
+ with st.expander(generated_path.stem, expanded=False):
4901
+ card_cols = st.columns([1.35, 2.4, 1.1])
4902
+ with card_cols[0]:
4903
+ thumbnail = _video_thumbnail(generated_path)
4904
+ if thumbnail:
4905
+ st.image(thumbnail, width="stretch")
4906
+ else:
4907
+ st.video(str(generated_path))
4908
+ with card_cols[1]:
4909
+ st.markdown(f"**{generated_path.stem}**")
4910
+ st.caption(f"Vídeo pronto · {generated_path.suffix.lstrip('.').upper()}")
4911
+ st.caption(f"{generated_path.stat().st_size / (1024 * 1024):.2f} MB")
4912
+ with card_cols[2]:
4913
+ if st.button("Usar vídeo", key=f"cuts_use_generated_{index}_{_library_card_key('cuts', generated_path)}", type="primary", width="stretch"):
4914
+ st.session_state["cuts_source_path"] = str(generated_path)
4915
+ st.session_state["cuts_source_label"] = f"Pipeline · {generated_path.stem}"
4916
+ st.rerun()
4881
4917
  with folder_tab:
4882
4918
  folder_value = st.text_input("Pasta de vídeos", value=str(STORAGE / "videos"), key="cuts_video_folder")
4883
4919
  folder_paths = list_cut_video_files(folder_value)
@@ -5028,11 +5064,28 @@ def render_python_editor():
5028
5064
  tasks = read_json("tasks.json", [])
5029
5065
  generated_paths = list_generated_videos(tasks)
5030
5066
  if not generated_paths:
5031
- st.info("Ainda não existem vídeos gerados com caminho registado nos artefactos da pipeline.")
5067
+ st.info("Ainda não existem vídeos prontos nos artefactos da pipeline.")
5032
5068
  else:
5033
- labels = [f"{path.name} — {path}" for path in generated_paths]
5034
- selected_index = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: labels[index], key="python_editor_generated_index")
5035
- source_path = generated_paths[selected_index]
5069
+ st.caption("Seleccione um vídeo pronto na pipeline.")
5070
+ for index, generated_path in enumerate(generated_paths):
5071
+ with st.expander(generated_path.stem, expanded=False):
5072
+ card_cols = st.columns([1.35, 2.4, 1.1])
5073
+ with card_cols[0]:
5074
+ thumbnail = _video_thumbnail(generated_path)
5075
+ if thumbnail:
5076
+ st.image(thumbnail, width="stretch")
5077
+ else:
5078
+ st.video(str(generated_path))
5079
+ with card_cols[1]:
5080
+ st.markdown(f"**{generated_path.stem}**")
5081
+ st.caption(f"Vídeo pronto · {generated_path.suffix.lstrip('.').upper()}")
5082
+ with card_cols[2]:
5083
+ if st.button("Editar vídeo", key=f"python_use_generated_{index}_{_library_card_key('python', generated_path)}", type="primary", width="stretch"):
5084
+ st.session_state["python_editor_source_path"] = str(generated_path)
5085
+ st.rerun()
5086
+ stored_generated = Path(str(st.session_state.get("python_editor_source_path") or ""))
5087
+ if stored_generated.is_file() and stored_generated in generated_paths:
5088
+ source_path = stored_generated
5036
5089
  elif source_mode == "Pasta local":
5037
5090
  default_folder = str(STORAGE / "videos")
5038
5091
  folder_value = st.text_input("Pasta de vídeos", value=st.session_state.get("python_editor_folder", default_folder), key="python_editor_folder")
package/hermes_ui/cuts.py CHANGED
@@ -121,6 +121,10 @@ def list_video_files(folder: str | Path) -> list[Path]:
121
121
  def list_generated_videos(tasks: Iterable[dict[str, Any]]) -> list[Path]:
122
122
  paths: dict[str, Path] = {}
123
123
  for task in tasks:
124
+ # Um caminho pode existir enquanto a tarefa ainda está a ser processada;
125
+ # só artefactos explicitamente prontos devem entrar nesta biblioteca.
126
+ if "video_ready" in task and not bool(task.get("video_ready")):
127
+ continue
124
128
  artifacts = task.get("artifacts") or {}
125
129
  value = artifacts.get("video")
126
130
  if isinstance(value, str) and value.strip():
@@ -96,6 +96,8 @@ def list_video_files(folder: str | Path) -> list[Path]:
96
96
  def list_generated_videos(tasks: list[dict[str, Any]]) -> list[Path]:
97
97
  paths: dict[str, Path] = {}
98
98
  for task in tasks:
99
+ if "video_ready" in task and not bool(task.get("video_ready")):
100
+ continue
99
101
  artifacts = task.get("artifacts") or {}
100
102
  value = artifacts.get("video")
101
103
  if isinstance(value, str) and value.strip():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.7.31",
3
+ "version": "0.7.32",
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",