@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/MANUAL-INSTALACAO.md +19 -10
- package/README.md +23 -11
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/app/influencers_ui.py +183 -8
- package/app/main.py +206 -10
- package/hermes_ui/creative_generation.py +39 -0
- package/hermes_ui/influencers.py +19 -0
- package/hermes_ui/media_generation.py +335 -0
- package/integrations/bilibili_upload.py +259 -0
- package/integrations/distrokid_upload.py +227 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
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
|
|
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
|
-
"
|
|
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)
|
package/hermes_ui/influencers.py
CHANGED
|
@@ -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",
|