@danhachuel/thunderbolt 0.6.79 → 0.6.81

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.
@@ -28,7 +28,6 @@ from hermes_ui.media_generation import (
28
28
  generate_motion_control_video,
29
29
  generate_ugc_product_video,
30
30
  generate_video_for_card,
31
- upload_kie_file,
32
31
  validate_motion_control_file,
33
32
  )
34
33
  from hermes_ui.creative_generation import generate_ugc_segment_prompts
@@ -349,8 +348,12 @@ def _workflow_owner(repository: Any) -> str:
349
348
  return ensure_standalone_content_owner(repository)
350
349
 
351
350
 
352
- def _workflow_provider_cards(settings: Mapping[str, Any]) -> list[dict[str, Any]]:
353
- return [card for card in media_cards_for_pool(settings, "video") if str(card.get("provider") or "").strip().lower() == "kie_ai"]
351
+ def _workflow_provider_cards(settings: Mapping[str, Any], *, provider: str | None = "kie_ai") -> list[dict[str, Any]]:
352
+ cards = media_cards_for_pool(settings, "video")
353
+ if provider is None:
354
+ return cards
355
+ normalized_provider = str(provider).strip().lower()
356
+ return [card for card in cards if str(card.get("provider") or "").strip().lower() == normalized_provider]
354
357
 
355
358
 
356
359
  def render_motion_control(settings: dict[str, Any]) -> None:
@@ -421,18 +424,17 @@ def render_motion_control(settings: dict[str, Any]) -> None:
421
424
 
422
425
 
423
426
  def render_ugc_products(settings: dict[str, Any]) -> None:
424
- """Render the local product-image to VEO3 workflow without Telegram or social publishing."""
427
+ """Render the local product-image to video workflow without Telegram or social publishing."""
425
428
  st.title("UGC Products")
426
- st.caption("Crie um anúncio UGC a partir de uma imagem de produto e de um roteiro local. O workflow gera dois clips VEO3 de 8 segundos e junta-os com FFmpeg no storage local.")
429
+ st.caption("Crie um anúncio UGC a partir de uma imagem de produto e de um roteiro local. O workflow gera dois clips de vídeo com o provider e modelo seleccionados e junta-os com FFmpeg no storage local.")
427
430
  repository = _repository(settings)
428
431
  if repository is None:
429
432
  return
430
- video_cards = [card for card in _workflow_provider_cards(settings) if str(card.get("model") or "").strip().lower() in {"", "veo3", "veo3_fast", "veo3_lite"}]
433
+ video_cards = _workflow_provider_cards(settings, provider=None)
431
434
  if not video_cards:
432
- st.warning("Active e configure um cartão KIE AI com modelo VEO3 compatível no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
435
+ st.warning("Active e configure pelo menos um provider de vídeo no pool de vídeo em Configuração API > API Keys > Imagem e Video IA.")
433
436
  return
434
437
  image_cards, _ = _provider_options(settings, "image")
435
- image_cards = [card for card in image_cards if str(card.get("provider") or "").strip().lower() == "nano_banana"]
436
438
  with st.form("influencer_ugc_products_form"):
437
439
  product_upload = st.file_uploader("Imagem do produto", type=["jpg", "jpeg", "png", "webp"], key="ugc_products_image")
438
440
  script = st.text_area("Roteiro de vídeo", height=180, placeholder="Escreva a demonstração, falas e acções. Para controlar os dois clips manualmente, separe-os com uma linha contendo ---.", key="ugc_products_script")
@@ -444,7 +446,7 @@ def render_ugc_products(settings: dict[str, Any]) -> None:
444
446
  elif improve_image:
445
447
  st.info("Não existe um provider activo no pool de imagem; será usada a imagem original.")
446
448
  card_options = [str(card.get("id") or "") for card in video_cards]
447
- provider_id = st.selectbox("Provider VEO3", card_options, format_func=lambda value: _provider_label(next(card for card in video_cards if str(card.get("id")) == value)), key="ugc_products_video_provider")
449
+ provider_id = st.selectbox("Provider / modelo de vídeo", card_options, format_func=lambda value: _provider_label(next(card for card in video_cards if str(card.get("id")) == value)), key="ugc_products_video_provider")
448
450
  generate = st.form_submit_button("Gerar UGC Product", type="primary", use_container_width=True)
449
451
  if generate:
450
452
  if product_upload is None:
@@ -467,15 +469,15 @@ def render_ugc_products(settings: dict[str, Any]) -> None:
467
469
  "prompt": script,
468
470
  "caption": "",
469
471
  "provider": card.get("provider"),
470
- "model": str(card.get("model") or "veo3_fast"),
472
+ "model": str(card.get("model") or ""),
471
473
  "platform": "",
472
474
  "state": "running",
473
475
  "metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "telegram": False, "social_publish": False},
474
476
  }
475
477
  )
476
- with st.spinner("A enviar a imagem para KIE, a gerar os dois clips VEO3 e a juntá-los localmente…"):
478
+ image_url = _image_input({"stored_path": str(image_path), "mime_type": mimetypes.guess_type(image_path.name)[0] or "image/jpeg"})
479
+ with st.spinner("A gerar os dois clips com o provider seleccionado e a juntá-los localmente…"):
477
480
  try:
478
- image_url = upload_kie_file(image_path, card, upload_path="thunderbolt/ugc-products")
479
481
  output, task_ids = generate_ugc_product_video(settings, card, image_url=image_url, prompts=prompts)
480
482
  repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": ",".join(task_ids), "metadata": {"workflow": "ugc_products", "product_image_path": str(product_path), "effective_image_path": str(image_path), "segment_prompts": prompts, "task_ids": task_ids, "telegram": False, "social_publish": False}})
481
483
  record_notification("influencer_content_completed", "UGC Products concluído", "O vídeo UGC Product foi gerado, unido e guardado localmente.", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:completed")
@@ -1102,7 +1102,7 @@ def generate_ugc_product_video(
1102
1102
  prompts: list[str],
1103
1103
  output_path: Path | None = None,
1104
1104
  ) -> tuple[Path, list[str]]:
1105
- """Generate two 8-second KIE VEO3 clips and concatenate them locally."""
1105
+ """Generate two 8-second clips with the selected video card and concatenate them locally."""
1106
1106
  clean_prompts = [str(item or "").strip() for item in prompts if str(item or "").strip()]
1107
1107
  if len(clean_prompts) != 2:
1108
1108
  raise MediaGenerationError("UGC Products requer exactamente dois prompts de segmento.")
@@ -1111,6 +1111,6 @@ def generate_ugc_product_video(
1111
1111
  segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
1112
1112
  task_ids: list[str] = []
1113
1113
  for prompt, segment_path in zip(clean_prompts, segment_paths):
1114
- _, task_id = generate_ugc_segment(settings, card, image_url=image_url, prompt=prompt, output_path=segment_path, duration=8)
1115
- task_ids.append(task_id)
1114
+ generate_video_for_card(settings, card, prompt, image_url=image_url, output_path=segment_path)
1115
+ task_ids.append(segment_path.stem)
1116
1116
  return concatenate_video_files(segment_paths, destination), task_ids
@@ -457,7 +457,7 @@ class StorageIntegrityError(RuntimeError):
457
457
 
458
458
 
459
459
  @contextmanager
460
- def _state_lock(path: Path) -> Iterator[None]:
460
+ def _state_lock(path: Path, *, read_only: bool = False) -> Iterator[None]:
461
461
  """Serialise state mutations across the UI and both local workers.
462
462
 
463
463
  A lock file is used instead of an in-memory mutex because the launcher
@@ -472,6 +472,15 @@ def _state_lock(path: Path) -> Iterator[None]:
472
472
  try:
473
473
  descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
474
474
  os.write(descriptor, f"pid={os.getpid()}\n".encode("ascii"))
475
+ except PermissionError:
476
+ if not read_only:
477
+ raise
478
+ # Windows pode manter um lock residual aberto por outro processo
479
+ # (ou por antivírus). Leituras de JSON continuam seguras porque as
480
+ # escritas usam replace atómico; não bloquear a leitura evita que
481
+ # uma página inteira falhe por causa de um lock de metadados.
482
+ yield
483
+ return
475
484
  except FileExistsError:
476
485
  try:
477
486
  if time.time() - lock_path.stat().st_mtime > _LOCK_STALE_SECONDS:
@@ -557,7 +566,7 @@ def _recover_json_unlocked(name: str, path: Path, default: Any | None) -> Any:
557
566
  def read_json(name: str, default: Any | None = None) -> Any:
558
567
  ensure_storage()
559
568
  path = STATE / name
560
- with _state_lock(path):
569
+ with _state_lock(path, read_only=True):
561
570
  try:
562
571
  data = _load_json_unlocked(path)
563
572
  except (json.JSONDecodeError, OSError):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.79",
3
+ "version": "0.6.81",
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",