@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.
@@ -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
- """Render the local product-image to video workflow without Telegram or social publishing."""
446
+ """Create portrait UGC photos or videos from a product and a saved character."""
428
447
  st.title("UGC Products")
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.")
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
- video_cards = _workflow_provider_cards(settings, provider=None)
434
- if not video_cards:
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.")
436
- return
437
- image_cards, _ = _provider_options(settings, "image")
438
- with st.form("influencer_ugc_products_form"):
439
- product_upload = st.file_uploader("Imagem do produto", type=["jpg", "jpeg", "png", "webp"], key="ugc_products_image")
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")
441
- improve_image = st.checkbox("Melhorar a imagem com o provider de imagem activo (opcional)", value=False, key="ugc_products_improve_image")
442
- image_provider_id = ""
443
- if improve_image and image_cards:
444
- image_provider_options = [str(card.get("id") or "") for card in image_cards]
445
- image_provider_id = st.selectbox("Provider de melhoria da imagem", image_provider_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")
446
- elif improve_image:
447
- st.info("Não existe um provider activo no pool de imagem; será usada a imagem original.")
448
- card_options = [str(card.get("id") or "") for card in video_cards]
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")
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
- product_path = _store_uploaded_file(product_upload, "ugc-products-inputs")
458
- image_path = product_path
459
- if improve_image and image_provider_id:
460
- image_card = next(card for card in image_cards if str(card.get("id")) == image_provider_id)
461
- with st.spinner("A melhorar a imagem do produto…"):
462
- image_path = generate_image_for_card(settings, image_card, "Melhorar a apresentação fotográfica do mesmo produto sem alterar a embalagem, logótipo, cores, forma ou objectos visíveis. Fundo comercial limpo e luz natural.", topic="UGC Product", reference_image=product_path)
463
- prompts = generate_ugc_segment_prompts(settings, script)
464
- owner_id = _workflow_owner(repository)
465
- record = repository.create_content(
466
- {
467
- "influencer_id": owner_id,
468
- "content_type": "video",
469
- "prompt": script,
470
- "caption": "",
471
- "provider": card.get("provider"),
472
- "model": str(card.get("model") or ""),
473
- "platform": "",
474
- "state": "running",
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},
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
- 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…"):
480
- try:
481
- output, task_ids = generate_ugc_product_video(settings, card, image_url=image_url, prompts=prompts)
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}})
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")
484
- st.success("UGC Product concluído. O vídeo final está disponível no histórico local abaixo.")
485
- with st.expander("Prompts dos dois segmentos", expanded=False):
486
- for index, prompt_item in enumerate(prompts, start=1):
487
- st.markdown(f"**Segmento {index}**")
488
- st.code(prompt_item)
489
- except Exception as exc:
490
- repository.update_content(record["id"], {"state": "failed", "error": str(exc)[:1000]})
491
- record_notification("influencer_content_failed", "UGC Products falhou", f"A criação UGC Products falhou: {str(exc)[:240]}", metadata={"content_id": record["id"], "workflow": "ugc_products", "provider": card.get("provider")}, dedupe_key=f"influencer-workflow:{record['id']}:failed")
492
- st.error(f"Não foi possível gerar o UGC Product: {exc}")
493
- except (MediaGenerationError, ValueError, OSError) as exc:
494
- st.error(str(exc))
495
- _render_content_history(repository, STANDALONE_CONTENT_INFLUENCER_ID)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.6.81",
3
+ "version": "0.6.83",
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",