@danhachuel/thunderbolt 0.3.70 → 0.3.72

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
@@ -31,7 +31,7 @@ from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_a
31
31
  from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
32
32
  from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
33
33
  from app.modules.niche_finder.summarizer import summarize_items
34
- from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status
34
+ from app.influencers_ui import render_ai_influencer_characters, render_ai_influencer_content, render_ai_influencers_api_status, render_motion_control, render_ugc_products
35
35
  from hermes_ui.blueprints import create_blueprint_from_link, list_branding_files, save_generated_blueprint
36
36
  from hermes_ui.metadata_cleaner import build_description, clean_video_metadata, list_edit_records, metadata_manifest, normalize_tags, save_edit_record, store_external_video
37
37
  from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEditorError, change_speed, editor_manifest, extract_audio, list_edit_records as list_python_editor_records, list_generated_videos, list_scripts, list_video_files, read_script, remove_audio, replace_audio, resize_video, save_edit_record as save_python_editor_record, save_script, store_uploaded_asset, trim_video
@@ -70,6 +70,8 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
70
70
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
71
71
  from integrations.postiz import PostizAdapter
72
72
  from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
73
+ from integrations.bilibili_upload import BilibiliApiAdapter, BILIBILI_DEFAULT_TID, BILIBILI_VIDEO_EXTENSIONS, normalise_bilibili_api_cards
74
+ from integrations.distrokid_upload import DistroKidAdapter, DISTROKID_AUDIO_EXTENSIONS, DISTROKID_COVER_EXTENSIONS, close_distrokid_session
73
75
  from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
74
76
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
75
77
  from integrations.youtube_direct_upload import YouTubeDirectUploader
@@ -4099,7 +4101,7 @@ def _render_music_upload_history() -> None:
4099
4101
  record
4100
4102
  for record in read_json("uploads.json", [])
4101
4103
  if isinstance(record, dict)
4102
- and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi"))
4104
+ and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi", "distrokid"))
4103
4105
  ]
4104
4106
  st.divider()
4105
4107
  st.subheader("Histórico de uploads de música")
@@ -4283,16 +4285,104 @@ def _render_ytmusicapi_upload_tab() -> None:
4283
4285
  (st.success if result.ok else st.error)(result.message)
4284
4286
 
4285
4287
 
4288
+ def _store_distrokid_cover(uploaded: Any) -> str:
4289
+ target_dir = STORAGE / "distrokid"
4290
+ target_dir.mkdir(parents=True, exist_ok=True)
4291
+ safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(uploaded.name).name).strip("._") or "cover.jpg"
4292
+ target = target_dir / f"{uuid.uuid4().hex[:10]}_{safe_name}"
4293
+ target.write_bytes(uploaded.getvalue())
4294
+ return str(target)
4295
+
4296
+ def _render_distrokid_upload_tab() -> None:
4297
+ st.subheader("DistroKid")
4298
+ st.caption("Upload assistido baseado no fluxo de publish do musikai. O browser preenche o novo lançamento e carrega as faixas; a submissão final fica sempre manual no DistroKid.")
4299
+ settings = read_json("settings.json", {})
4300
+ with st.form("distrokid_settings_form"):
4301
+ enabled = st.checkbox("Activar DistroKid", value=bool(settings.get("distrokid_enabled", False)))
4302
+ cookie = st.text_input("Cookie de sessão DistroKid", value=str(settings.get("distrokid_cookie") or ""), type="password", help="Cole o cabeçalho Cookie da sua sessão DistroKid. O valor é guardado apenas no storage local.")
4303
+ account = st.text_input("Nome da conta DistroKid", value=str(settings.get("distrokid_account") or ""))
4304
+ browser_path = st.text_input("Executável Chrome/Chromium (opcional)", value=str(settings.get("distrokid_browser_path") or ""), help="Deixe vazio para usar Google Chrome; também pode definir THUNDERBOLT_CHROME_PATH.")
4305
+ settings_cols = st.columns(2)
4306
+ with settings_cols[0]:
4307
+ first_name = st.text_input("Primeiro nome", value=str(settings.get("distrokid_first_name") or ""))
4308
+ artist = st.text_input("Artista", value=str(settings.get("distrokid_artist") or ""))
4309
+ release_title = st.text_input("Título do lançamento", value=str(settings.get("distrokid_release_title") or ""))
4310
+ with settings_cols[1]:
4311
+ last_name = st.text_input("Apelido", value=str(settings.get("distrokid_last_name") or ""))
4312
+ record_label = st.text_input("Record label", value=str(settings.get("distrokid_record_label") or ""))
4313
+ genre = st.text_input("Género principal", value=str(settings.get("distrokid_genre") or ""), help="O género deve corresponder a uma opção aceite pelo formulário DistroKid.")
4314
+ save = st.form_submit_button("Guardar configuração DistroKid", type="primary", use_container_width=True)
4315
+ if save:
4316
+ _persist_music_upload_settings({
4317
+ "distrokid_enabled": bool(enabled), "distrokid_cookie": cookie.strip(), "distrokid_account": account.strip(),
4318
+ "distrokid_browser_path": browser_path.strip(), "distrokid_first_name": first_name.strip(), "distrokid_last_name": last_name.strip(),
4319
+ "distrokid_artist": artist.strip(), "distrokid_release_title": release_title.strip(), "distrokid_record_label": record_label.strip(), "distrokid_genre": genre.strip(),
4320
+ })
4321
+ st.success("Configuração DistroKid guardada no storage local.")
4322
+ settings = read_json("settings.json", {})
4323
+ adapter = DistroKidAdapter(settings)
4324
+ status = adapter.status()
4325
+ (st.success if status.ok else st.warning)(status.message)
4326
+ if st.button("Testar sessão DistroKid", key="distrokid_test", use_container_width=True):
4327
+ result = DistroKidAdapter(read_json("settings.json", {})).test_connection()
4328
+ (st.success if result.ok else st.error)(result.message)
4329
+ uploaded_tracks = st.file_uploader("Faixas para o lançamento", type=sorted(extension.lstrip(".") for extension in DISTROKID_AUDIO_EXTENSIONS), accept_multiple_files=True, key="distrokid_track_upload")
4330
+ if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", use_container_width=True):
4331
+ stored_paths: list[str] = []
4332
+ for uploaded in uploaded_tracks:
4333
+ try:
4334
+ stored_paths.append(str(store_music_file(f"distrokid_{uuid.uuid4().hex[:8]}_{uploaded.name}", uploaded.getvalue())))
4335
+ except (OSError, ValueError) as exc:
4336
+ st.error(str(exc))
4337
+ if stored_paths:
4338
+ st.session_state["distrokid_track_paths"] = stored_paths
4339
+ st.success(f"{len(stored_paths)} faixa(s) guardada(s) no storage local.")
4340
+ selected_paths = [str(path) for path in st.session_state.get("distrokid_track_paths", []) if Path(str(path)).is_file()]
4341
+ track_rows: list[dict[str, Any]] = []
4342
+ if selected_paths:
4343
+ st.markdown("**Faixas seleccionadas**")
4344
+ for index, path in enumerate(selected_paths, start=1):
4345
+ track_title = st.text_input(f"Título da faixa {index}", value=Path(path).stem, key=f"distrokid_track_title_{index}")
4346
+ instrumental = st.checkbox("Instrumental", value=False, key=f"distrokid_track_instrumental_{index}")
4347
+ track_rows.append({"path": path, "title": track_title.strip() or Path(path).stem, "instrumental": instrumental})
4348
+ cover_upload = st.file_uploader("Capa do lançamento (opcional)", type=sorted(extension.lstrip(".") for extension in DISTROKID_COVER_EXTENSIONS), key="distrokid_cover_upload")
4349
+ if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", use_container_width=True):
4350
+ try:
4351
+ st.session_state["distrokid_cover_path"] = _store_distrokid_cover(cover_upload)
4352
+ st.success("Capa guardada no storage local.")
4353
+ except OSError as exc:
4354
+ st.error(f"Não foi possível guardar a capa: {exc}")
4355
+ cover_path = str(st.session_state.get("distrokid_cover_path") or "")
4356
+ if cover_path and Path(cover_path).is_file():
4357
+ st.caption(f"Capa seleccionada: `{cover_path}`")
4358
+ session_id = str(st.session_state.get("distrokid_session_id") or "")
4359
+ if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", use_container_width=True):
4360
+ result = close_distrokid_session(session_id)
4361
+ (st.success if result.ok else st.warning)(result.message)
4362
+ st.session_state.pop("distrokid_session_id", None)
4363
+ if st.button("Abrir formulário e carregar para DistroKid", type="primary", key="distrokid_prepare_upload", use_container_width=True, disabled=not status.ok or not track_rows):
4364
+ current = read_json("settings.json", {})
4365
+ result = DistroKidAdapter(current).prepare_upload(
4366
+ track_rows, artist=str(current.get("distrokid_artist") or ""), release_title=str(current.get("distrokid_release_title") or ""),
4367
+ record_label=str(current.get("distrokid_record_label") or ""), cover_path=cover_path or None, genre=str(current.get("distrokid_genre") or ""),
4368
+ )
4369
+ _record_music_upload("DistroKid", result, music_path=selected_paths[0] if selected_paths else "", target={"service": "DistroKid", "account": str(current.get("distrokid_account") or "")})
4370
+ (st.success if result.ok else st.error)(result.message)
4371
+ if result.ok and result.data.get("session_id"):
4372
+ st.session_state["distrokid_session_id"] = result.data["session_id"]
4373
+
4286
4374
  def render_music_upload() -> None:
4287
4375
  st.title("Upload Música")
4288
- st.caption("Carregue e encaminhe músicas por JewelMusic, sincronize bibliotecas com Pushtunes ou envie directamente para YouTube Music com ytmusicapi. As credenciais e o histórico permanecem locais.")
4289
- jewel_tab, pushtunes_tab, ytmusicapi_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi"])
4376
+ st.caption("Carregue músicas por JewelMusic, sincronize bibliotecas com Pushtunes, envie para YouTube Music com ytmusicapi ou prepare um lançamento DistroKid. As credenciais, browser e histórico permanecem locais.")
4377
+ jewel_tab, pushtunes_tab, ytmusicapi_tab, distrokid_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi", "DistroKid"])
4290
4378
  with jewel_tab:
4291
4379
  _render_jewelmusic_upload_tab()
4292
4380
  with pushtunes_tab:
4293
4381
  _render_pushtunes_upload_tab()
4294
4382
  with ytmusicapi_tab:
4295
4383
  _render_ytmusicapi_upload_tab()
4384
+ with distrokid_tab:
4385
+ _render_distrokid_upload_tab()
4296
4386
  _render_music_upload_history()
4297
4387
 
4298
4388
 
@@ -4460,6 +4550,7 @@ def render_upload_post():
4460
4550
 
4461
4551
  UPLOAD_DESTINATION_TARGET_KEYS = {
4462
4552
  "TikTok": "tiktok_accounts",
4553
+ "Bilibili": "bilibili_api_cards",
4463
4554
  "Instagram": "instagram_profiles",
4464
4555
  "Facebook Pages": "facebook_pages",
4465
4556
  }
@@ -4491,6 +4582,8 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
4491
4582
  configured_targets = settings.get(setting_key, [])
4492
4583
  if destination == "TikTok" and (not isinstance(configured_targets, list) or not configured_targets):
4493
4584
  configured_targets = settings.get("tiktok_profiles", [])
4585
+ if destination == "Bilibili" and isinstance(configured_targets, list):
4586
+ configured_targets = [item for item in configured_targets if isinstance(item, dict) and bool(item.get("active", True))]
4494
4587
  if not isinstance(configured_targets, list):
4495
4588
  return []
4496
4589
  targets: list[Any] = []
@@ -4505,14 +4598,16 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
4505
4598
  def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
4506
4599
  options = upload_targets_for_destination(destination, channels, settings)
4507
4600
  destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
4508
- select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else "Perfil / página")
4509
- empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else f"Nenhum {destination} configurado")
4601
+ select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else ("Conta Bilibili" if destination == "Bilibili" else "Perfil / página"))
4602
+ empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else ("Nenhuma conta Bilibili activa" if destination == "Bilibili" else f"Nenhum {destination} configurado"))
4510
4603
  if not options:
4511
4604
  st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
4512
4605
  if destination == "YouTube":
4513
4606
  st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
4514
4607
  elif destination == "TikTok":
4515
4608
  st.caption("Cadastre uma conta em Pipeline TikTok > Contas TikTok antes de escolher o destino de envio.")
4609
+ elif destination == "Bilibili":
4610
+ st.caption("Configure e active uma conta em Configuração API > API Bilibili antes de escolher o destino de envio.")
4516
4611
  else:
4517
4612
  st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
4518
4613
  return None
@@ -4533,7 +4628,7 @@ def render_upload_conventional():
4533
4628
  direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
4534
4629
  postiz = PostizAdapter(settings)
4535
4630
  tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
4536
- destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
4631
+ destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Bilibili", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
4537
4632
  upload_targets: dict[str, Any | None] = {}
4538
4633
  if destination:
4539
4634
  st.markdown("**Onde enviar**")
@@ -4654,6 +4749,31 @@ def render_upload_conventional():
4654
4749
  write_json("uploads.json", uploads)
4655
4750
  reconcile_persisted_notifications()
4656
4751
  (st.success if result.ok else st.warning)(result.message)
4752
+ bilibili_target = upload_targets.get("Bilibili") if "Bilibili" in destination else None
4753
+ if "Bilibili" in destination:
4754
+ bilibili_title = st.text_input("Título Bilibili", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"bilibili_title_{task['id']}")
4755
+ bilibili_description = st.text_area("Descrição Bilibili", value=task.get("description", ""), key=f"bilibili_description_{task['id']}", height=90)
4756
+ bilibili_tags = st.text_input("Tags Bilibili separadas por vírgulas", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"bilibili_tags_{task['id']}")
4757
+ bilibili_tid = st.number_input("ID da secção Bilibili", min_value=1, max_value=9999, value=BILIBILI_DEFAULT_TID, step=1, key=f"bilibili_tid_{task['id']}")
4758
+ if st.button("Enviar via bilibili-api (Python)", type="primary", key=f"upload_bilibili_{task['id']}", disabled=not bilibili_target, help="Configure e active uma conta Bilibili em Configuração API > API Bilibili." if not bilibili_target else None):
4759
+ result = BilibiliApiAdapter(bilibili_target, settings).upload_video(
4760
+ video_path, title=bilibili_title, description=bilibili_description, tags=bilibili_tags, tid=int(bilibili_tid),
4761
+ cover_path=thumbnail_path or None,
4762
+ )
4763
+ record = {
4764
+ "task_id": task.get("id"),
4765
+ "destination": "Bilibili",
4766
+ "target": upload_target_reference(bilibili_target),
4767
+ "status": "published" if result.ok else "failed",
4768
+ "message": result.message,
4769
+ "data": result.data,
4770
+ "created_at": now(),
4771
+ }
4772
+ uploads = read_json("uploads.json", [])
4773
+ uploads.append(record)
4774
+ write_json("uploads.json", uploads)
4775
+ reconcile_persisted_notifications()
4776
+ (st.success if result.ok else st.error)(result.message)
4657
4777
  if "Instagram" in destination:
4658
4778
  st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
4659
4779
  if "Facebook Pages" in destination:
@@ -4757,6 +4877,78 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
4757
4877
  st.rerun()
4758
4878
 
4759
4879
 
4880
+ def _persist_bilibili_api_cards(settings: dict[str, Any], cards: list[dict[str, Any]]) -> None:
4881
+ clean_cards: list[dict[str, Any]] = []
4882
+ for item in cards:
4883
+ normalised, _ = normalise_bilibili_api_cards({"bilibili_api_cards": [item]})
4884
+ if normalised:
4885
+ clean_cards.append(normalised[0])
4886
+ settings["bilibili_api_cards"] = clean_cards
4887
+ first = next((card for card in clean_cards if card.get("active") and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3")), clean_cards[0] if clean_cards else {})
4888
+ for field in ("sessdata", "bili_jct", "buvid3", "buvid4", "dedeuserid", "ac_time_value", "proxy"):
4889
+ settings[f"bilibili_{field}"] = str(first.get(field) or "")
4890
+ write_json("settings.json", settings)
4891
+
4892
+ def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
4893
+ st.title("API Bilibili")
4894
+ st.caption("Configure várias contas Bilibili em cards separados. O upload usa bilibili-api-python; os cookies permanecem locais e nunca são mostrados em logs, históricos ou metadados.")
4895
+ st.warning("O pacote bilibili-api-python é uma integração opcional e depende da sessão do browser Bilibili. Use apenas contas e conteúdos que tenha autorização para operar.")
4896
+ cards, changed = normalise_bilibili_api_cards(settings)
4897
+ if changed and cards:
4898
+ _persist_bilibili_api_cards(settings, cards)
4899
+ if not cards:
4900
+ st.info("Ainda não existe nenhuma conta Bilibili configurada. Use o botão abaixo para criar o primeiro card.")
4901
+ for index, card in enumerate(cards):
4902
+ card_id = str(card["id"])
4903
+ with st.container(border=True):
4904
+ header_cols = st.columns([3.2, 1.2])
4905
+ with header_cols[0]:
4906
+ st.subheader(str(card.get("label") or f"Conta Bilibili {index + 1}"))
4907
+ with header_cols[1]:
4908
+ configured = bool(card.get("active", True) and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3"))
4909
+ _api_status_badge("Configured" if configured else "Missing configuration", "ready" if configured else "missing")
4910
+ with st.form(f"bilibili_api_card_form_{card_id}"):
4911
+ label = st.text_input("Nome da conta", value=str(card.get("label") or f"Conta Bilibili {index + 1}"), key=f"bilibili_api_{card_id}_label")
4912
+ active = st.checkbox("Conta activa no Upload", value=bool(card.get("active", True)), key=f"bilibili_api_{card_id}_active")
4913
+ credential_cols = st.columns(2)
4914
+ with credential_cols[0]:
4915
+ sessdata = st.text_input("SESSDATA", value=str(card.get("sessdata") or ""), type="password", key=f"bilibili_api_{card_id}_sessdata")
4916
+ bili_jct = st.text_input("bili_jct", value=str(card.get("bili_jct") or ""), type="password", key=f"bilibili_api_{card_id}_bili_jct")
4917
+ buvid3 = st.text_input("BUVID3", value=str(card.get("buvid3") or ""), type="password", key=f"bilibili_api_{card_id}_buvid3")
4918
+ buvid4 = st.text_input("BUVID4 (opcional)", value=str(card.get("buvid4") or ""), type="password", key=f"bilibili_api_{card_id}_buvid4")
4919
+ with credential_cols[1]:
4920
+ dedeuserid = st.text_input("DedeUserID (opcional)", value=str(card.get("dedeuserid") or ""), type="password", key=f"bilibili_api_{card_id}_dedeuserid")
4921
+ ac_time_value = st.text_input("ac_time_value (opcional)", value=str(card.get("ac_time_value") or ""), type="password", key=f"bilibili_api_{card_id}_ac_time_value")
4922
+ proxy = st.text_input("Proxy opcional", value=str(card.get("proxy") or ""), key=f"bilibili_api_{card_id}_proxy", placeholder="http://127.0.0.1:8080")
4923
+ action_cols = st.columns(3)
4924
+ with action_cols[0]:
4925
+ test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
4926
+ with action_cols[1]:
4927
+ save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
4928
+ with action_cols[2]:
4929
+ delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
4930
+ edited = {"id": card_id, "label": label.strip() or f"Conta Bilibili {index + 1}", "active": bool(active), "sessdata": sessdata.strip(), "bili_jct": bili_jct.strip(), "buvid3": buvid3.strip(), "buvid4": buvid4.strip(), "dedeuserid": dedeuserid.strip(), "ac_time_value": ac_time_value.strip(), "proxy": proxy.strip()}
4931
+ if test_clicked:
4932
+ cards[index] = edited
4933
+ _persist_bilibili_api_cards(settings, cards)
4934
+ result = BilibiliApiAdapter(edited, settings).test_connection()
4935
+ (st.success if result.ok else st.error)(result.message)
4936
+ elif save_clicked:
4937
+ cards[index] = edited
4938
+ _persist_bilibili_api_cards(settings, cards)
4939
+ st.success("Card Bilibili guardado.")
4940
+ st.rerun()
4941
+ elif delete_clicked:
4942
+ cards = [item for item in cards if str(item.get("id")) != card_id]
4943
+ _persist_bilibili_api_cards(settings, cards)
4944
+ st.success("Card Bilibili apagado.")
4945
+ st.rerun()
4946
+ if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_bilibili_api_card"):
4947
+ cards.append({"id": f"bilibili-api-{uuid.uuid4().hex[:10]}", "label": f"Conta Bilibili {len(cards) + 1}", "active": True, "sessdata": "", "bili_jct": "", "buvid3": "", "buvid4": "", "dedeuserid": "", "ac_time_value": "", "proxy": ""})
4948
+ _persist_bilibili_api_cards(settings, cards)
4949
+ st.success("Novo card Bilibili criado.")
4950
+ st.rerun()
4951
+
4760
4952
  def render_google_accounts():
4761
4953
  st.title("Contas Google")
4762
4954
  st.caption("Gestão das contas Google/YouTube, documentos de credenciais, canais em lote e credenciais globais do YouTube.")
@@ -5539,7 +5731,7 @@ def render_settings():
5539
5731
  key=f"settings_{key}",
5540
5732
  )
5541
5733
 
5542
- api_keys_tab, google_accounts_tab, tiktok_api_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "AI Influencers", "Teste de Voz"])
5734
+ api_keys_tab, google_accounts_tab, tiktok_api_tab, bilibili_api_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "API Bilibili", "AI Influencers", "Teste de Voz"])
5543
5735
 
5544
5736
  with api_keys_tab:
5545
5737
  with st.container(border=True):
@@ -5697,7 +5889,7 @@ def render_settings():
5697
5889
  upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
5698
5890
  _render_credential_status(upload_post_api_key)
5699
5891
  upload_post_username = text_setting("Upload-Post username", "upload_post_username")
5700
- upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
5892
+ upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms", help_text="Escreva as plataformas separadas por vírgulas, como em Pipeline Vídeos > Upload > Destino. Ex.: youtube,tiktok")
5701
5893
  upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
5702
5894
  _render_api_test_control(
5703
5895
  settings,
@@ -5762,6 +5954,8 @@ def render_settings():
5762
5954
  with tiktok_api_tab:
5763
5955
  render_tiktok_api_cards(settings)
5764
5956
 
5957
+ with bilibili_api_tab:
5958
+ render_bilibili_api_cards(settings)
5765
5959
  with ai_influencers_tab:
5766
5960
  st.subheader("AI Influencers")
5767
5961
  st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA. O selector e as credenciais são editados nesta aba, em Banco de Dados Influencers.")
@@ -6397,6 +6591,7 @@ def main():
6397
6591
  models_ai_items = [
6398
6592
  ("Personagens", ":material/person:", "Personagens"),
6399
6593
  ("Geração de Conteúdo IA", ":material/auto_awesome:", "Geração de Conteúdo IA"),
6594
+ ("Motion Control", ":material/motion_photos_on:", "Motion Control"),
6400
6595
  ("UGC Products", ":material/shopping_bag:", "UGC Products"),
6401
6596
  ("Redes Sociais", ":material/share:", "Redes Sociais"),
6402
6597
  ]
@@ -6538,7 +6733,8 @@ def main():
6538
6733
  language_normalizer=normalize_video_language,
6539
6734
  ),
6540
6735
  "Geração de Conteúdo IA": lambda: render_ai_influencer_content(read_json("settings.json", {})),
6541
- "UGC Products": lambda: render_edit_placeholder("UGC Products", ""),
6736
+ "Motion Control": lambda: render_motion_control(read_json("settings.json", {})),
6737
+ "UGC Products": lambda: render_ugc_products(read_json("settings.json", {})),
6542
6738
  "Redes Sociais": lambda: render_edit_placeholder("Redes Sociais", "Área reservada para a futura funcionalidade de redes sociais."),
6543
6739
  "Analista Growth Youtube": lambda: render_edit_placeholder("Analista Growth Youtube", ""),
6544
6740
  "Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
@@ -481,3 +481,42 @@ def generate_creative_package(
481
481
  "topic_source": "llm",
482
482
  "generated_by": "configured_llm",
483
483
  }
484
+
485
+
486
+ UGC_SEGMENT_SYSTEM_PROMPT = (
487
+ "És um realizador de anúncios UGC de produto. Responde apenas com JSON válido no formato "
488
+ "{\"segments\":[{\"prompt\":\"...\"},{\"prompt\":\"...\"}]}. "
489
+ "Cria exactamente dois prompts para clips consecutivos de 8 segundos cada. "
490
+ "Preserva exactamente o produto, embalagem, cores, logótipo e objectos visíveis na imagem; não inventes, removas ou alteres características. "
491
+ "Descreve apenas acções fisicamente possíveis, continuidade visual entre segmentos, diálogo natural quando solicitado e nenhuma legenda, texto incorporado, watermark ou interface."
492
+ )
493
+
494
+
495
+ def _ugc_fallback_prompts(instructions: str) -> list[str]:
496
+ base = str(instructions or "Apresentar o produto de forma clara, natural e convincente.").strip()
497
+ return [
498
+ f"Clip UGC 1 de 8 segundos. {base} Mostrar o produto exactamente como aparece na imagem, com enquadramento estável, luz natural e uma demonstração inicial fisicamente possível. Sem texto no vídeo, sem legendas e sem watermark.",
499
+ f"Clip UGC 2 de 8 segundos, continuidade directa do clip anterior. {base} Completar a demonstração com uma reacção humana natural e uma apresentação final do produto, sem alterar os objectos visíveis. Sem texto no vídeo, sem legendas e sem watermark.",
500
+ ]
501
+
502
+
503
+ def generate_ugc_segment_prompts(settings: dict[str, Any], instructions: str) -> list[str]:
504
+ """Return exactly two safe VEO prompts, accepting an explicit `---` split without LLM."""
505
+ clean = str(instructions or "").strip()
506
+ explicit = [part.strip() for part in re.split(r"\n\s*(?:---|===|SEGMENTO\s*2)\s*\n", clean, flags=re.IGNORECASE) if part.strip()]
507
+ if len(explicit) == 2:
508
+ return [
509
+ f"Clip UGC 1 de 8 segundos. {explicit[0]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
510
+ f"Clip UGC 2 de 8 segundos, em continuidade. {explicit[1]} Preserve a imagem do produto. Sem texto, legendas ou watermark.",
511
+ ]
512
+ try:
513
+ result = _chat_json(settings, UGC_SEGMENT_SYSTEM_PROMPT, clean or "Cria uma demonstração UGC curta e natural do produto.")
514
+ raw_segments = result.get("segments")
515
+ if isinstance(raw_segments, list):
516
+ prompts = [str(item.get("prompt") or "").strip() for item in raw_segments if isinstance(item, dict)]
517
+ prompts = [item for item in prompts if item]
518
+ if len(prompts) >= 2:
519
+ return [f"{item} Sem texto incorporado, sem legendas e sem watermark." for item in prompts[:2]]
520
+ except CreativeGenerationError:
521
+ pass
522
+ return _ugc_fallback_prompts(clean)
@@ -20,6 +20,7 @@ from typing import Any, Iterable, Mapping
20
20
  from .storage import ROOT, STORAGE
21
21
 
22
22
  BACKEND_OPTIONS = ("Supabase", "SQLite")
23
+ STANDALONE_CONTENT_INFLUENCER_ID = "__standalone_ai_content__"
23
24
  IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
24
25
  DOCUMENT_EXTENSIONS = {".md", ".json"}
25
26
  ASSET_EXTENSIONS = IMAGE_EXTENSIONS | DOCUMENT_EXTENSIONS
@@ -516,6 +517,22 @@ class SupabaseInfluencerRepository:
516
517
  return self._data(response)
517
518
 
518
519
 
520
+ def ensure_standalone_content_owner(repository: Any) -> str:
521
+ """Return a hidden owner row for AI content that has no character dependency."""
522
+ existing = repository.get_influencer(STANDALONE_CONTENT_INFLUENCER_ID)
523
+ if existing:
524
+ return STANDALONE_CONTENT_INFLUENCER_ID
525
+ repository.create_influencer(
526
+ {
527
+ "id": STANDALONE_CONTENT_INFLUENCER_ID,
528
+ "name": "__Thunderbolt AI content__",
529
+ "bio": "Internal owner for standalone Motion Control and UGC Products content.",
530
+ "language": "",
531
+ }
532
+ )
533
+ return STANDALONE_CONTENT_INFLUENCER_ID
534
+
535
+
519
536
  def sqlite_path_from_settings(settings: Mapping[str, Any]) -> Path:
520
537
  raw = str(settings.get("influencer_sqlite_path") or "storage/state/ai_influencers.db").strip()
521
538
  return _resolve_sqlite_path(raw)
@@ -567,12 +584,14 @@ __all__ = [
567
584
  "DOCUMENT_EXTENSIONS",
568
585
  "IMAGE_EXTENSIONS",
569
586
  "InfluencerBackendError",
587
+ "STANDALONE_CONTENT_INFLUENCER_ID",
570
588
  "SQLiteInfluencerRepository",
571
589
  "SupabaseInfluencerRepository",
572
590
  "SQLITE_SCHEMA",
573
591
  "backend_name",
574
592
  "backend_status",
575
593
  "get_repository",
594
+ "ensure_standalone_content_owner",
576
595
  "parse_document",
577
596
  "sqlite_path_from_settings",
578
597
  "test_backend",