@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.
- package/app/influencers_ui.py +14 -12
- package/hermes_ui/media_generation.py +3 -3
- package/hermes_ui/storage.py +11 -2
- package/package.json +1 -1
package/app/influencers_ui.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
433
|
+
video_cards = _workflow_provider_cards(settings, provider=None)
|
|
431
434
|
if not video_cards:
|
|
432
|
-
st.warning("Active e configure
|
|
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
|
|
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 "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1115
|
-
task_ids.append(
|
|
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
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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