@danhachuel/thunderbolt 0.6.81 → 0.6.83
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 +162 -64
- package/hermes_ui/media_generation.py +16 -5
- package/package.json +1 -1
package/app/influencers_ui.py
CHANGED
|
@@ -115,6 +115,25 @@ def _provider_label(card: Mapping[str, Any]) -> str:
|
|
|
115
115
|
return f"{definition.label} · {model}"
|
|
116
116
|
|
|
117
117
|
|
|
118
|
+
def _content_metadata(item: Mapping[str, Any]) -> dict[str, Any]:
|
|
119
|
+
raw = item.get("metadata") if isinstance(item.get("metadata"), Mapping) else item.get("metadata_json")
|
|
120
|
+
if isinstance(raw, Mapping):
|
|
121
|
+
return dict(raw)
|
|
122
|
+
try:
|
|
123
|
+
parsed = json.loads(str(raw or "{}"))
|
|
124
|
+
except (TypeError, json.JSONDecodeError):
|
|
125
|
+
return {}
|
|
126
|
+
return dict(parsed) if isinstance(parsed, Mapping) else {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _ugc_media_records(repository: Any) -> list[dict[str, Any]]:
|
|
130
|
+
try:
|
|
131
|
+
records = repository.list_content("", limit=200)
|
|
132
|
+
except Exception:
|
|
133
|
+
return []
|
|
134
|
+
return [item for item in records if _content_metadata(item).get("workflow") == "ugc_products"]
|
|
135
|
+
|
|
136
|
+
|
|
118
137
|
def render_ai_influencers_api_status(settings: dict[str, Any]) -> None:
|
|
119
138
|
"""Show the effective backend status after selector and credentials are loaded."""
|
|
120
139
|
status = backend_status(settings)
|
|
@@ -424,76 +443,155 @@ def render_motion_control(settings: dict[str, Any]) -> None:
|
|
|
424
443
|
|
|
425
444
|
|
|
426
445
|
def render_ugc_products(settings: dict[str, Any]) -> None:
|
|
427
|
-
"""
|
|
446
|
+
"""Create portrait UGC photos or videos from a product and a saved character."""
|
|
428
447
|
st.title("UGC Products")
|
|
429
|
-
st.caption("Crie
|
|
448
|
+
st.caption("Crie fotos ou vídeos UGC em formato vertical 9:16 para TikTok e Instagram usando um produto e um personagem cadastrados.")
|
|
430
449
|
repository = _repository(settings)
|
|
431
450
|
if repository is None:
|
|
432
451
|
return
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
generate = st.form_submit_button("Gerar UGC Product", type="primary", use_container_width=True)
|
|
451
|
-
if generate:
|
|
452
|
-
if product_upload is None:
|
|
453
|
-
st.error("Seleccione a imagem do produto.")
|
|
454
|
-
return
|
|
455
|
-
card = next(card for card in video_cards if str(card.get("id")) == provider_id)
|
|
452
|
+
media_tab, ready_tab = st.tabs(["Criar UGC", "Midias Prontas"])
|
|
453
|
+
try:
|
|
454
|
+
influencers = [item for item in repository.list_influencers() if str(item.get("id") or "") != STANDALONE_CONTENT_INFLUENCER_ID]
|
|
455
|
+
except Exception:
|
|
456
|
+
influencers = []
|
|
457
|
+
|
|
458
|
+
with media_tab:
|
|
459
|
+
if not influencers:
|
|
460
|
+
influencers = [{"id": "", "name": "Sem personagem", "bio": ""}]
|
|
461
|
+
influencer_options = _influencer_options(influencers)
|
|
462
|
+
selected_influencer = st.selectbox(
|
|
463
|
+
"Personagem (opcional)",
|
|
464
|
+
influencer_options,
|
|
465
|
+
format_func=lambda value: _influencer_label(influencers, value),
|
|
466
|
+
key="ugc_products_character",
|
|
467
|
+
)
|
|
468
|
+
selected_character = next(item for item in influencers if str(item.get("id")) == selected_influencer)
|
|
456
469
|
try:
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
470
|
+
character_assets = repository.list_assets(selected_influencer)
|
|
471
|
+
except Exception:
|
|
472
|
+
character_assets = []
|
|
473
|
+
character_images = [item for item in character_assets if str(item.get("asset_type") or "") == "image"]
|
|
474
|
+
product_name = st.text_input("Nome do produto", key="ugc_products_name", placeholder="Ex.: Sérum facial hidratante")
|
|
475
|
+
product_info = st.text_area("Informação do Produto ou link do produto", key="ugc_products_info", height=110, placeholder="Descreva benefícios, características, oferta ou cole o link do produto…")
|
|
476
|
+
product_uploads = st.file_uploader(
|
|
477
|
+
"Imagem do produto (fotos/vídeos)",
|
|
478
|
+
type=["jpg", "jpeg", "png", "webp", "mp4", "mov", "webm"],
|
|
479
|
+
accept_multiple_files=True,
|
|
480
|
+
key="ugc_products_inputs",
|
|
481
|
+
help="Anexe uma ou mais referências do produto. Para geração, a primeira imagem será usada como referência visual principal.",
|
|
482
|
+
)
|
|
483
|
+
generation_mode = st.radio("Geração", ["Gerar Foto", "Gerar Video"], horizontal=True, key="ugc_products_generation_mode")
|
|
484
|
+
duration = st.number_input("Duração de cada clip (segundos)", min_value=2, max_value=30, value=8, step=1, key="ugc_products_duration", disabled=generation_mode != "Gerar Video")
|
|
485
|
+
image_cards, image_options = _provider_options(settings, "image")
|
|
486
|
+
video_cards = _workflow_provider_cards(settings, provider=None)
|
|
487
|
+
video_options = [str(card.get("id") or "") for card in video_cards]
|
|
488
|
+
image_provider_id = st.selectbox("Provider / modelo de imagem", image_options, format_func=lambda value: _provider_label(next(card for card in image_cards if str(card.get("id")) == value)), key="ugc_products_image_provider") if image_cards else ""
|
|
489
|
+
video_provider_id = st.selectbox("Provider / modelo de vídeo", video_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") if video_cards else ""
|
|
490
|
+
script = st.text_area("Roteiro de vídeo (UGC)", height=150, key="ugc_products_script", placeholder="O roteiro gerado pela IA aparecerá aqui; também pode editá-lo antes de gerar.")
|
|
491
|
+
generate_script = st.button("Gerar roteiro com IA", use_container_width=True, key="ugc_products_generate_script")
|
|
492
|
+
generate = st.button("Gerar Foto" if generation_mode == "Gerar Foto" else "Gerar Video", type="primary", use_container_width=True, key="ugc_products_generate")
|
|
493
|
+
if generate_script:
|
|
494
|
+
context = "\n".join(
|
|
495
|
+
item for item in [
|
|
496
|
+
f"Produto: {product_name.strip()}" if product_name.strip() else "",
|
|
497
|
+
f"Informação/link: {product_info.strip()}" if product_info.strip() else "",
|
|
498
|
+
f"Personagem: {selected_character.get('name') or selected_influencer}",
|
|
499
|
+
f"Biografia do personagem: {selected_character.get('bio') or ''}",
|
|
500
|
+
] if item
|
|
477
501
|
)
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
502
|
+
prompts = generate_ugc_segment_prompts(settings, context or "Criar uma demonstração UGC natural do produto.")
|
|
503
|
+
st.session_state["ugc_products_script"] = "\n---\n".join(prompts)
|
|
504
|
+
st.rerun()
|
|
505
|
+
if generate:
|
|
506
|
+
if not product_name.strip():
|
|
507
|
+
st.error("Informe o nome do produto.")
|
|
508
|
+
elif not product_info.strip() and not product_uploads:
|
|
509
|
+
st.error("Informe os dados/link do produto ou anexe pelo menos uma mídia.")
|
|
510
|
+
elif not script.strip():
|
|
511
|
+
st.error("Escreva o roteiro ou use o botão Gerar roteiro com IA.")
|
|
512
|
+
elif generation_mode == "Gerar Foto" and not image_provider_id:
|
|
513
|
+
st.error("Active um provider de imagem antes de gerar a foto.")
|
|
514
|
+
elif generation_mode == "Gerar Video" and not video_provider_id:
|
|
515
|
+
st.error("Active um provider de vídeo antes de gerar o vídeo.")
|
|
516
|
+
else:
|
|
517
|
+
image_upload = next((item for item in product_uploads or [] if Path(str(getattr(item, "name", ""))).suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}), None)
|
|
518
|
+
if image_upload is None:
|
|
519
|
+
st.error("Anexe pelo menos uma imagem do produto para usar como referência visual.")
|
|
520
|
+
else:
|
|
521
|
+
try:
|
|
522
|
+
product_path = _store_uploaded_file(image_upload, "ugc-products-inputs")
|
|
523
|
+
character_path = _local_asset_path(character_images[0]) if character_images else None
|
|
524
|
+
prompts = [part.strip() for part in script.split("\n---\n") if part.strip()]
|
|
525
|
+
if len(prompts) != 2:
|
|
526
|
+
prompts = generate_ugc_segment_prompts(settings, script)
|
|
527
|
+
card = next((item for item in (image_cards if generation_mode == "Gerar Foto" else video_cards) if str(item.get("id")) == (image_provider_id if generation_mode == "Gerar Foto" else video_provider_id)), None)
|
|
528
|
+
character_name = str(selected_character.get("name") or selected_influencer)
|
|
529
|
+
enriched_prompts = [f"Personagem: {character_name}. Produto: {product_name.strip()}. Informação: {product_info.strip()}. {item} Formato vertical 9:16, enquadramento para telemóvel, TikTok e Instagram." for item in prompts]
|
|
530
|
+
owner_id = selected_influencer or _workflow_owner(repository)
|
|
531
|
+
record = repository.create_content({
|
|
532
|
+
"influencer_id": owner_id,
|
|
533
|
+
"content_type": "image" if generation_mode == "Gerar Foto" else "video",
|
|
534
|
+
"prompt": script,
|
|
535
|
+
"caption": f"{product_name.strip()} + {character_name}",
|
|
536
|
+
"provider": card.get("provider") if card else "",
|
|
537
|
+
"model": str(card.get("model") or "") if card else "",
|
|
538
|
+
"platform": "TikTok, Instagram",
|
|
539
|
+
"state": "running",
|
|
540
|
+
"metadata": {"workflow": "ugc_products", "product_name": product_name.strip(), "product_info": product_info.strip(), "character_name": character_name, "character_asset_path": str(character_path or ""), "product_image_path": str(product_path), "generation_mode": generation_mode, "duration_seconds": int(duration), "aspect_ratio": "9:16", "segment_prompts": enriched_prompts, "telegram": False, "social_publish": False},
|
|
541
|
+
})
|
|
542
|
+
with st.spinner("A gerar a mídia UGC em formato vertical 9:16…"):
|
|
543
|
+
if generation_mode == "Gerar Foto":
|
|
544
|
+
image_card = {**card, "aspect_ratio": "9:16"}
|
|
545
|
+
output = generate_image_for_card(settings, image_card, enriched_prompts[0], topic=product_name.strip(), reference_image=product_path)
|
|
546
|
+
task_ids = []
|
|
547
|
+
else:
|
|
548
|
+
output = generate_ugc_product_video(settings, card, image_url=_image_input({"stored_path": str(product_path), "mime_type": mimetypes.guess_type(product_path.name)[0] or "image/jpeg"}), prompts=enriched_prompts, duration=int(duration))[0]
|
|
549
|
+
task_ids = []
|
|
550
|
+
repository.update_content(record["id"], {"state": "completed", "artifact_path": str(output), "provider_request_id": ",".join(task_ids)})
|
|
551
|
+
st.success("Mídia UGC criada e guardada em Midias Prontas.")
|
|
552
|
+
except Exception as exc:
|
|
553
|
+
repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
|
|
554
|
+
st.error(f"Não foi possível gerar a mídia UGC: {exc}")
|
|
555
|
+
if not image_cards and not video_cards:
|
|
556
|
+
st.warning("Configure pelo menos um provider de imagem ou vídeo em Configuração API > API Keys > Imagem e Video IA.")
|
|
557
|
+
|
|
558
|
+
with ready_tab:
|
|
559
|
+
st.subheader("Midias Prontas")
|
|
560
|
+
records = _ugc_media_records(repository)
|
|
561
|
+
if not records:
|
|
562
|
+
st.info("Ainda não existem mídias UGC prontas.")
|
|
563
|
+
for item in records:
|
|
564
|
+
metadata = _content_metadata(item)
|
|
565
|
+
artifact = Path(str(item.get("artifact_path") or ""))
|
|
566
|
+
content_id = str(item.get("id") or "content")
|
|
567
|
+
title = str(item.get("caption") or f"{metadata.get('product_name') or 'Produto'} + {metadata.get('character_name') or 'Personagem'}")
|
|
568
|
+
with st.container(border=True):
|
|
569
|
+
preview_col, detail_col, edit_col, download_col = st.columns([1.2, 2.7, 0.35, 0.9])
|
|
570
|
+
with preview_col:
|
|
571
|
+
if artifact.is_file() and str(item.get("content_type") or "") == "image":
|
|
572
|
+
st.image(str(artifact), use_container_width=True)
|
|
573
|
+
elif artifact.is_file():
|
|
574
|
+
st.video(str(artifact))
|
|
575
|
+
else:
|
|
576
|
+
st.caption("Artefacto indisponível")
|
|
577
|
+
with detail_col:
|
|
578
|
+
st.write(f"**{title}**")
|
|
579
|
+
st.caption(f"{metadata.get('product_name') or 'Produto'} · {metadata.get('character_name') or 'Personagem'} · {metadata.get('generation_mode') or 'UGC'} · 9:16")
|
|
580
|
+
st.caption(f"Criado: {item.get('created_at') or '—'}")
|
|
581
|
+
with edit_col:
|
|
582
|
+
if st.button("", icon=":material/edit:", help="Renomear mídia", key=f"ugc_ready_rename_{content_id}"):
|
|
583
|
+
st.session_state[f"ugc_ready_editing_{content_id}"] = True
|
|
584
|
+
with download_col:
|
|
585
|
+
if artifact.is_file() and str(item.get("state") or "") == "completed":
|
|
586
|
+
mime = mimetypes.guess_type(artifact.name)[0] or ("image/png" if str(item.get("content_type") or "") == "image" else "video/mp4")
|
|
587
|
+
st.download_button("Download", data=artifact.read_bytes(), file_name=artifact.name, mime=mime, key=f"ugc_ready_download_{content_id}", use_container_width=True)
|
|
588
|
+
if st.session_state.get(f"ugc_ready_editing_{content_id}"):
|
|
589
|
+
with st.form(f"ugc_ready_rename_form_{content_id}"):
|
|
590
|
+
renamed = st.text_input("Nome da mídia", value=title, key=f"ugc_ready_name_{content_id}")
|
|
591
|
+
if st.form_submit_button("Guardar nome"):
|
|
592
|
+
repository.update_content(content_id, {"caption": renamed.strip() or title})
|
|
593
|
+
st.session_state.pop(f"ugc_ready_editing_{content_id}", None)
|
|
594
|
+
st.rerun()
|
|
497
595
|
|
|
498
596
|
def render_ai_influencer_content(settings: dict[str, Any]) -> None:
|
|
499
597
|
st.title("Geração de Conteúdo IA")
|
|
@@ -584,7 +584,7 @@ def _video_endpoint(card: Mapping[str, Any]) -> str:
|
|
|
584
584
|
raise MediaGenerationError(f"O provider {card.get('provider')} não tem endpoint de vídeo configurado.")
|
|
585
585
|
|
|
586
586
|
|
|
587
|
-
def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> Any:
|
|
587
|
+
def _video_request(card: dict[str, Any], prompt: str, image_url: str = "", duration: int | None = None, aspect_ratio: str | None = None) -> Any:
|
|
588
588
|
style = str(card.get("api_style") or media_provider_definition(card.get("provider")).api_style)
|
|
589
589
|
endpoint = _video_endpoint(card)
|
|
590
590
|
body: dict[str, Any] = {
|
|
@@ -596,20 +596,26 @@ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> An
|
|
|
596
596
|
size=str(card.get("video_size") or ""),
|
|
597
597
|
),
|
|
598
598
|
}
|
|
599
|
+
if aspect_ratio:
|
|
600
|
+
body["aspect_ratio"] = str(aspect_ratio)
|
|
599
601
|
if image_url:
|
|
600
602
|
body["image_url"] = image_url
|
|
603
|
+
if duration is not None:
|
|
604
|
+
body["duration"] = max(1, int(duration))
|
|
601
605
|
if style == "openrouter":
|
|
602
|
-
body["aspect_ratio"] = str(card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO)
|
|
606
|
+
body["aspect_ratio"] = str(aspect_ratio or card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO)
|
|
603
607
|
body["resolution"] = str(card.get("video_size") or INTERNAL_VIDEO_SIZE)
|
|
604
608
|
return requests.post(endpoint, headers=_headers(card), json=body, timeout=180)
|
|
605
609
|
if style == "kie":
|
|
606
610
|
input_payload: dict[str, Any] = {
|
|
607
611
|
"prompt": body["prompt"],
|
|
608
|
-
"aspect_ratio": str(card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO),
|
|
612
|
+
"aspect_ratio": str(aspect_ratio or card.get("aspect_ratio") or INTERNAL_VIDEO_ASPECT_RATIO),
|
|
609
613
|
"resolution": str(card.get("video_size") or INTERNAL_VIDEO_SIZE),
|
|
610
614
|
}
|
|
611
615
|
if image_url:
|
|
612
616
|
input_payload["imageUrls"] = [image_url]
|
|
617
|
+
if duration is not None:
|
|
618
|
+
input_payload["duration"] = max(1, int(duration))
|
|
613
619
|
return requests.post(endpoint, headers=_headers(card), json={"model": _model(card), "input": input_payload}, timeout=180)
|
|
614
620
|
if style == "heygen":
|
|
615
621
|
avatar_id = str(card.get("avatar_id") or "").strip()
|
|
@@ -632,6 +638,8 @@ def _video_request(card: dict[str, Any], prompt: str, image_url: str = "") -> An
|
|
|
632
638
|
if style == "replicate":
|
|
633
639
|
image_input_key = str(card.get("image_input_key") or "image").strip() or "image"
|
|
634
640
|
input_payload = {"prompt": body["prompt"]}
|
|
641
|
+
if duration is not None:
|
|
642
|
+
input_payload["duration"] = max(1, int(duration))
|
|
635
643
|
if image_url:
|
|
636
644
|
input_payload[image_input_key] = image_url
|
|
637
645
|
return requests.post(endpoint, headers=_headers(card), json={"version": _model(card), "input": input_payload}, timeout=180)
|
|
@@ -725,6 +733,8 @@ def generate_video_for_card(
|
|
|
725
733
|
prompt: str,
|
|
726
734
|
*,
|
|
727
735
|
image_url: str = "",
|
|
736
|
+
duration: int | None = None,
|
|
737
|
+
aspect_ratio: str | None = None,
|
|
728
738
|
output_path: Path | None = None,
|
|
729
739
|
) -> Path:
|
|
730
740
|
"""Submit and resolve one video generation request."""
|
|
@@ -732,7 +742,7 @@ def generate_video_for_card(
|
|
|
732
742
|
provider = str(card.get("provider") or "").strip().lower()
|
|
733
743
|
|
|
734
744
|
def request(current: dict[str, Any]) -> Any:
|
|
735
|
-
return _video_request(current, prompt, image_url=image_url)
|
|
745
|
+
return _video_request(current, prompt, image_url=image_url, duration=duration, aspect_ratio=aspect_ratio)
|
|
736
746
|
|
|
737
747
|
try:
|
|
738
748
|
routed = route_json_request(settings, pool=POOL_VIDEO, cards=[card], request=request)
|
|
@@ -1100,6 +1110,7 @@ def generate_ugc_product_video(
|
|
|
1100
1110
|
*,
|
|
1101
1111
|
image_url: str,
|
|
1102
1112
|
prompts: list[str],
|
|
1113
|
+
duration: int = 8,
|
|
1103
1114
|
output_path: Path | None = None,
|
|
1104
1115
|
) -> tuple[Path, list[str]]:
|
|
1105
1116
|
"""Generate two 8-second clips with the selected video card and concatenate them locally."""
|
|
@@ -1111,6 +1122,6 @@ def generate_ugc_product_video(
|
|
|
1111
1122
|
segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
|
|
1112
1123
|
task_ids: list[str] = []
|
|
1113
1124
|
for prompt, segment_path in zip(clean_prompts, segment_paths):
|
|
1114
|
-
generate_video_for_card(settings, card, prompt, image_url=image_url, output_path=segment_path)
|
|
1125
|
+
generate_video_for_card(settings, card, prompt, image_url=image_url, duration=duration, aspect_ratio="9:16", output_path=segment_path)
|
|
1115
1126
|
task_ids.append(segment_path.stem)
|
|
1116
1127
|
return concatenate_video_files(segment_paths, destination), task_ids
|
package/package.json
CHANGED