@danhachuel/thunderbolt 0.2.48 → 0.2.50
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 +28 -19
- package/README.md +17 -5
- package/app/main.py +712 -413
- package/hermes_ui/automation_worker.py +49 -10
- package/hermes_ui/creative_generation.py +278 -0
- package/hermes_ui/domain.py +50 -21
- package/package.json +2 -1
- package/seed/references/ai-tells.md +336 -0
- package/seed/references/humanize-integration.md +147 -0
- package/seed/references/thumbnail-checklist.md +178 -0
- package/seed/references/title-formulas.md +288 -0
- package/seed/references/trend-intelligence.md +99 -0
- package/seed/references/viral-thumbnails.md +470 -0
- package/seed/references/viral-titles.md +440 -0
package/app/main.py
CHANGED
|
@@ -34,6 +34,7 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
|
|
|
34
34
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
35
35
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
36
36
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
37
|
+
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
37
38
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
38
39
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
39
40
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
@@ -191,6 +192,82 @@ def blueprint_catalog() -> list[tuple[str, str]]:
|
|
|
191
192
|
return options
|
|
192
193
|
|
|
193
194
|
|
|
195
|
+
def blueprint_for_channel(channel: dict) -> dict[str, Any]:
|
|
196
|
+
"""Resolve a channel Blueprint by id, filename stem or display name."""
|
|
197
|
+
blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
198
|
+
if not blueprint_id:
|
|
199
|
+
return {}
|
|
200
|
+
for path in list_blueprint_files():
|
|
201
|
+
try:
|
|
202
|
+
data = load_blueprint_file(path)
|
|
203
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
204
|
+
continue
|
|
205
|
+
identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
|
|
206
|
+
if blueprint_id in identifiers:
|
|
207
|
+
resolved = dict(data)
|
|
208
|
+
resolved.setdefault("id", blueprint_id)
|
|
209
|
+
resolved.setdefault("name", str(data.get("name") or path.stem))
|
|
210
|
+
return resolved
|
|
211
|
+
return {"id": blueprint_id, "name": blueprint_id}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def channel_blueprint_summary(channel: dict) -> dict[str, str]:
|
|
215
|
+
blueprint = blueprint_for_channel(channel)
|
|
216
|
+
configured_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
217
|
+
if not configured_id:
|
|
218
|
+
return {"id": "", "name": "SEM BLUEPRINT CONFIGURADO", "voice": str(channel.get("default_voice") or channel.get("voice") or "")}
|
|
219
|
+
return {
|
|
220
|
+
"id": configured_id,
|
|
221
|
+
"name": str(blueprint.get("name") or configured_id),
|
|
222
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_channel_blueprint_panel(channel: dict, *, compact: bool = False) -> None:
|
|
227
|
+
summary = channel_blueprint_summary(channel)
|
|
228
|
+
voice = summary["voice"] or "Sem voz padrão"
|
|
229
|
+
if summary["name"] == "SEM BLUEPRINT CONFIGURADO":
|
|
230
|
+
st.warning("**SEM BLUEPRINT CONFIGURADO** · configure um Blueprint padrão na aba Canais.")
|
|
231
|
+
elif compact:
|
|
232
|
+
st.caption(f"**Blueprint:** {summary['name']} · **Voz:** {voice}")
|
|
233
|
+
else:
|
|
234
|
+
st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def creative_payload_from_result(channel: dict, topic: str, creative: dict, topic_source: str = "manual") -> dict[str, Any]:
|
|
238
|
+
variant = creative.get("thumbnail_variant") or {}
|
|
239
|
+
return {
|
|
240
|
+
"topic": topic.strip(),
|
|
241
|
+
"topic_source": topic_source,
|
|
242
|
+
"title": str(creative.get("title") or topic).strip(),
|
|
243
|
+
"title_candidates": creative.get("title_candidates") or [],
|
|
244
|
+
"thumbnail_variant": variant,
|
|
245
|
+
"thumbnail_variants": creative.get("thumbnail_variants") or [],
|
|
246
|
+
"thumbnail_prompt": str(variant.get("image_prompt") or ""),
|
|
247
|
+
"thumbnail_text": str(variant.get("overlay_text") or ""),
|
|
248
|
+
"thumbnail_status": str(creative.get("thumbnail_status") or "prompt_ready"),
|
|
249
|
+
"blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
|
|
250
|
+
"blueprint_name": str(channel_blueprint_summary(channel).get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
251
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
252
|
+
"ai_generation": {"creative": creative},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def generate_topic_for_ui(settings: dict[str, Any], channel: dict, user_context: str = "") -> dict[str, Any]:
|
|
257
|
+
return generate_topic_for_channel(settings, channel, blueprint_for_channel(channel), user_context=user_context)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str, topic_source: str = "manual") -> dict[str, Any]:
|
|
261
|
+
creative = generate_creative_package(
|
|
262
|
+
settings,
|
|
263
|
+
channel,
|
|
264
|
+
topic,
|
|
265
|
+
blueprint_for_channel(channel),
|
|
266
|
+
language=str(channel.get("language") or "Português"),
|
|
267
|
+
)
|
|
268
|
+
return creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
|
|
269
|
+
|
|
270
|
+
|
|
194
271
|
def valid_hhmm(value: str) -> bool:
|
|
195
272
|
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
196
273
|
|
|
@@ -688,17 +765,94 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
688
765
|
st.title(page_title)
|
|
689
766
|
create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
|
|
690
767
|
with create_tab:
|
|
691
|
-
|
|
692
|
-
if
|
|
768
|
+
all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict)]
|
|
769
|
+
active_channels = [c for c in all_channels if c.get("active", True)]
|
|
770
|
+
if not all_channels:
|
|
693
771
|
st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
|
|
694
772
|
else:
|
|
695
|
-
mode_label = st.radio(
|
|
773
|
+
mode_label = st.radio(
|
|
774
|
+
"Modo de criação",
|
|
775
|
+
["Canal específico", "Lote no mesmo canal", "Lote geral"],
|
|
776
|
+
horizontal=True,
|
|
777
|
+
key="new_video_mode",
|
|
778
|
+
)
|
|
696
779
|
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
780
|
+
selected_one: dict[str, Any] | None = None
|
|
697
781
|
if mode == "general":
|
|
698
|
-
selected =
|
|
782
|
+
selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
|
|
783
|
+
st.info(f"**Lote geral:** será criada exactamente uma tarefa para cada um dos {len(selected)} canais cadastrados. Cada canal receberá um tema, título e thumbnail próprios; não existe selecção parcial.")
|
|
784
|
+
with st.container(border=True):
|
|
785
|
+
st.subheader("Canais que serão processados")
|
|
786
|
+
for channel in all_channels:
|
|
787
|
+
summary = channel_blueprint_summary(channel)
|
|
788
|
+
status = "Activo" if channel.get("active", True) else "Inactivo"
|
|
789
|
+
st.caption(f"**{channel.get('name', 'Canal')}** · {status} · Blueprint: **{summary['name']}** · Voz: {summary['voice'] or 'Sem voz padrão'}")
|
|
790
|
+
general_context = st.text_area(
|
|
791
|
+
"Contexto opcional para todos os canais",
|
|
792
|
+
value=st.session_state.get("new_video_general_context", ""),
|
|
793
|
+
key="new_video_general_context",
|
|
794
|
+
placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
|
|
795
|
+
)
|
|
796
|
+
if st.button("Gerar tópicos individuais para todos os canais", key="new_video_generate_general_topics", use_container_width=True):
|
|
797
|
+
settings = read_json("settings.json", {})
|
|
798
|
+
generated_topics: dict[str, dict[str, Any]] = {}
|
|
799
|
+
errors: list[str] = []
|
|
800
|
+
with st.spinner("A gerar um briefing específico para cada canal…"):
|
|
801
|
+
for channel in all_channels:
|
|
802
|
+
try:
|
|
803
|
+
generated_topics[channel["id"]] = generate_topic_for_ui(settings, channel, general_context)
|
|
804
|
+
except CreativeGenerationError as exc:
|
|
805
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
806
|
+
if errors:
|
|
807
|
+
for error in errors:
|
|
808
|
+
st.error(error)
|
|
809
|
+
else:
|
|
810
|
+
st.session_state["new_video_general_topics"] = generated_topics
|
|
811
|
+
st.success(f"Foram gerados {len(generated_topics)} briefings independentes.")
|
|
812
|
+
general_topics = st.session_state.get("new_video_general_topics", {})
|
|
813
|
+
if general_topics:
|
|
814
|
+
st.subheader("Briefings por canal")
|
|
815
|
+
for channel in all_channels:
|
|
816
|
+
result = general_topics.get(channel["id"])
|
|
817
|
+
if result:
|
|
818
|
+
st.write(f"**{channel.get('name', 'Canal')}**")
|
|
819
|
+
st.caption(f"{result.get('niche', '')} · {result.get('angle', '')}")
|
|
820
|
+
st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"new_video_general_topic_{channel['id']}", height=80)
|
|
699
821
|
else:
|
|
700
|
-
|
|
701
|
-
|
|
822
|
+
if not active_channels:
|
|
823
|
+
st.warning("Não existem canais activos disponíveis para os modos de canal específico.")
|
|
824
|
+
selected = []
|
|
825
|
+
else:
|
|
826
|
+
selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
827
|
+
selected = [selected_one["id"]]
|
|
828
|
+
# Intentionally sits between Canal and Estilo wide, as requested.
|
|
829
|
+
render_channel_blueprint_panel(selected_one)
|
|
830
|
+
topic = st.text_area(
|
|
831
|
+
"Tópico ou briefing",
|
|
832
|
+
value=st.session_state.get("new_video_topic", ""),
|
|
833
|
+
key="new_video_topic",
|
|
834
|
+
placeholder="Escreva um briefing ou gere-o com IA; não é obrigatório escrever manualmente.",
|
|
835
|
+
help="Pode escrever o tema ou usar o botão abaixo para gerar um briefing específico com o Blueprint e o nicho do canal.",
|
|
836
|
+
)
|
|
837
|
+
topic_cols = st.columns([1, 1.8])
|
|
838
|
+
with topic_cols[0]:
|
|
839
|
+
if st.button("Gerar tópico/briefing com IA", key="new_video_generate_topic", use_container_width=True):
|
|
840
|
+
if selected_one is None:
|
|
841
|
+
st.error("Seleccione primeiro um canal.")
|
|
842
|
+
else:
|
|
843
|
+
try:
|
|
844
|
+
result = generate_topic_for_ui(read_json("settings.json", {}), selected_one, topic)
|
|
845
|
+
st.session_state["new_video_topic"] = result["topic"]
|
|
846
|
+
st.session_state["new_video_topic_meta"] = result
|
|
847
|
+
st.success("Briefing gerado; reveja e edite o texto antes de criar as tarefas.")
|
|
848
|
+
st.rerun()
|
|
849
|
+
except CreativeGenerationError as exc:
|
|
850
|
+
st.error(str(exc))
|
|
851
|
+
with topic_cols[1]:
|
|
852
|
+
if st.session_state.get("new_video_topic_meta"):
|
|
853
|
+
meta = st.session_state["new_video_topic_meta"]
|
|
854
|
+
st.caption(f"Origem: IA · Nicho: {meta.get('niche', '—')} · Ângulo: {meta.get('angle', '—')}")
|
|
855
|
+
|
|
702
856
|
wide_style_label = st.selectbox("Estilo wide", WIDE_STYLE_OPTIONS, key="new_video_style_wide")
|
|
703
857
|
style_ia = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key="new_video_style_ia") if wide_style_label == "full_ia" else ""
|
|
704
858
|
music_path = ""
|
|
@@ -725,7 +879,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
725
879
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
726
880
|
else:
|
|
727
881
|
suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
|
|
728
|
-
suno_title = st.text_input("Título da música", value=
|
|
882
|
+
suno_title = st.text_input("Título da música", value=st.session_state.get("new_video_topic") or "Thunderbolt music", key="new_video_suno_title")
|
|
729
883
|
if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
|
|
730
884
|
suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
|
|
731
885
|
(st.success if suno_result["ok"] else st.error)(suno_result["message"])
|
|
@@ -740,32 +894,165 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
740
894
|
except (OSError, requests.RequestException, ValueError) as exc:
|
|
741
895
|
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
742
896
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
897
|
+
|
|
898
|
+
payloads: dict[str, dict[str, Any]] = {}
|
|
899
|
+
if mode == "general":
|
|
900
|
+
existing_topics = st.session_state.get("new_video_general_topics", {})
|
|
901
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
902
|
+
if st.button("Gerar títulos e thumbnails para todos os canais", key="new_video_generate_general_creative", use_container_width=True):
|
|
903
|
+
settings = read_json("settings.json", {})
|
|
904
|
+
new_payloads: dict[str, dict[str, Any]] = {}
|
|
905
|
+
errors: list[str] = []
|
|
906
|
+
with st.spinner("A gerar títulos e thumbnails independentes por canal…"):
|
|
907
|
+
for channel in all_channels:
|
|
908
|
+
try:
|
|
909
|
+
topic_result = existing_topics.get(channel["id"])
|
|
910
|
+
if not topic_result:
|
|
911
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
912
|
+
generated = generate_creative_for_ui(settings, channel, topic_result["topic"], topic_source="llm")
|
|
913
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
914
|
+
new_payloads[channel["id"]] = generated
|
|
915
|
+
except CreativeGenerationError as exc:
|
|
916
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
917
|
+
if errors:
|
|
918
|
+
for error in errors:
|
|
919
|
+
st.error(error)
|
|
920
|
+
else:
|
|
921
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
|
|
922
|
+
st.session_state["new_video_general_payloads"] = new_payloads
|
|
923
|
+
payloads = new_payloads
|
|
924
|
+
st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
|
|
925
|
+
payloads = st.session_state.get("new_video_general_payloads", payloads)
|
|
926
|
+
for channel in all_channels:
|
|
927
|
+
payload = payloads.get(channel["id"])
|
|
928
|
+
if not payload:
|
|
929
|
+
continue
|
|
930
|
+
with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
|
|
931
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
932
|
+
if title_options:
|
|
933
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"new_video_general_title_{channel['id']}")
|
|
934
|
+
payload["title"] = selected_title
|
|
935
|
+
variants = payload.get("thumbnail_variants", [])
|
|
936
|
+
if variants:
|
|
937
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
938
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
|
|
939
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
940
|
+
payload["thumbnail_variant"] = variant
|
|
941
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
942
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
943
|
+
st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
|
|
944
|
+
st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
|
|
945
|
+
else:
|
|
946
|
+
topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
947
|
+
if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
|
|
948
|
+
if selected_one is None:
|
|
949
|
+
st.error("Seleccione primeiro um canal.")
|
|
950
|
+
elif not topic_for_creative:
|
|
951
|
+
st.error("Escreva ou gere primeiro um tópico/briefing.")
|
|
952
|
+
else:
|
|
953
|
+
try:
|
|
954
|
+
generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
955
|
+
st.session_state["new_video_creative_payload"] = generated
|
|
956
|
+
st.success("Título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
|
|
957
|
+
st.rerun()
|
|
958
|
+
except CreativeGenerationError as exc:
|
|
959
|
+
st.error(str(exc))
|
|
960
|
+
payload = st.session_state.get("new_video_creative_payload")
|
|
961
|
+
if payload:
|
|
962
|
+
st.subheader("Título e Thumbnail automáticos")
|
|
963
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
964
|
+
if title_options:
|
|
965
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key="new_video_title_choice")
|
|
966
|
+
payload["title"] = selected_title
|
|
967
|
+
with st.expander(f"Ver {len(title_options)} candidatos de título"):
|
|
968
|
+
st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
|
|
969
|
+
variants = payload.get("thumbnail_variants", [])
|
|
970
|
+
if variants:
|
|
971
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
972
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
|
|
973
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
974
|
+
payload["thumbnail_variant"] = variant
|
|
975
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
976
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
977
|
+
st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
|
|
978
|
+
st.code(variant.get("image_prompt", ""), language="text")
|
|
979
|
+
st.info("Prompt de thumbnail pronto — a imagem será criada quando existir um provider de imagem configurado; não é criado um ficheiro falso.")
|
|
980
|
+
st.session_state["new_video_creative_payload"] = payload
|
|
981
|
+
|
|
743
982
|
with st.form("new_video_form"):
|
|
744
|
-
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
745
983
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
746
984
|
legacy_language = st.session_state.get("video_language")
|
|
747
|
-
legacy_language_map = {
|
|
748
|
-
"Português": "36 – Português (Brasil)",
|
|
749
|
-
"English": "01 – Inglês",
|
|
750
|
-
"Español": "41 – Espanhol (LatAm)",
|
|
751
|
-
}
|
|
985
|
+
legacy_language_map = {"Português": "36 – Português (Brasil)", "English": "01 – Inglês", "Español": "41 – Espanhol (LatAm)"}
|
|
752
986
|
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
753
987
|
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
754
988
|
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
755
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
989
|
+
fmt = st.selectbox("Formato", ["wide", "shorts", "music"], key="new_video_format")
|
|
756
990
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
757
991
|
if submitted:
|
|
758
|
-
|
|
759
|
-
|
|
992
|
+
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
993
|
+
if style == "music" and not music_path:
|
|
994
|
+
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
995
|
+
st.stop()
|
|
996
|
+
if mode == "general":
|
|
997
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
998
|
+
topics = dict(st.session_state.get("new_video_general_topics", {}))
|
|
999
|
+
channels_by_id = {str(channel["id"]): channel for channel in all_channels}
|
|
1000
|
+
payloads_need_refresh = len(payloads) != len(selected) or any(
|
|
1001
|
+
str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1002
|
+
and str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip() != str((payloads.get(channel_id) or {}).get("topic") or "").strip()
|
|
1003
|
+
for channel_id in selected
|
|
1004
|
+
)
|
|
1005
|
+
if payloads_need_refresh:
|
|
1006
|
+
settings = read_json("settings.json", {})
|
|
1007
|
+
generated_payloads: dict[str, dict[str, Any]] = {}
|
|
1008
|
+
errors: list[str] = []
|
|
1009
|
+
with st.spinner("A gerar automaticamente um pacote criativo independente para cada canal…"):
|
|
1010
|
+
for channel_id in selected:
|
|
1011
|
+
channel = channels_by_id[channel_id]
|
|
1012
|
+
edited_topic = str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1013
|
+
topic_result = topics.get(channel_id) or {}
|
|
1014
|
+
try:
|
|
1015
|
+
if not edited_topic:
|
|
1016
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
1017
|
+
edited_topic = topic_result["topic"]
|
|
1018
|
+
else:
|
|
1019
|
+
topic_result = {**topic_result, "topic": edited_topic, "topic_source": topic_result.get("topic_source", "manual")}
|
|
1020
|
+
generated = generate_creative_for_ui(settings, channel, edited_topic, topic_source=topic_result.get("topic_source", "llm"))
|
|
1021
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
1022
|
+
generated_payloads[channel_id] = generated
|
|
1023
|
+
except CreativeGenerationError as exc:
|
|
1024
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
1025
|
+
if errors:
|
|
1026
|
+
for error in errors:
|
|
1027
|
+
st.error(error)
|
|
1028
|
+
st.error("O Lote geral não foi criado porque faltou gerar o conteúdo específico de pelo menos um canal.")
|
|
1029
|
+
else:
|
|
1030
|
+
payloads = generated_payloads
|
|
1031
|
+
st.session_state["new_video_general_payloads"] = payloads
|
|
1032
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
|
|
1033
|
+
if len(payloads) == len(selected):
|
|
1034
|
+
batch_topic = "Lote geral — um vídeo independente por canal"
|
|
1035
|
+
channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source} for cid, payload in payloads.items()}
|
|
1036
|
+
batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": "llm", "channel_payloads": channel_payloads})
|
|
1037
|
+
tasks = create_tasks_for_batch(batch)
|
|
1038
|
+
st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
|
|
760
1039
|
else:
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1040
|
+
topic_value = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
1041
|
+
if not topic_value or not selected:
|
|
1042
|
+
st.error("Escreva ou gere um tópico e seleccione um canal.")
|
|
1043
|
+
else:
|
|
1044
|
+
quantity_value = int(quantity if mode == "same_channel" else 1)
|
|
1045
|
+
payload = dict(st.session_state.get("new_video_creative_payload") or {})
|
|
1046
|
+
if not payload.get("title") or not payload.get("thumbnail_variants"):
|
|
1047
|
+
try:
|
|
1048
|
+
payload = generate_creative_for_ui(read_json("settings.json", {}), selected_one or {}, topic_value, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
1049
|
+
except CreativeGenerationError as exc:
|
|
1050
|
+
st.warning(f"Título/thumbnail automáticos pendentes: {exc} A tarefa será criada com o tópico como título e sem ficheiro de thumbnail.")
|
|
1051
|
+
payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
|
|
1052
|
+
payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or ("llm" if st.session_state.get("new_video_topic_meta") else "manual"), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source})
|
|
1053
|
+
batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
|
|
1054
|
+
tasks = create_tasks_for_batch(batch)
|
|
1055
|
+
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
769
1056
|
with videos_tab:
|
|
770
1057
|
render_videos()
|
|
771
1058
|
|
|
@@ -1173,8 +1460,16 @@ def render_videos():
|
|
|
1173
1460
|
with st.container(border=True):
|
|
1174
1461
|
cols = st.columns([2.2, 1, 1, 1.2, 1.8])
|
|
1175
1462
|
with cols[0]:
|
|
1176
|
-
st.write(f"**{task.get('topic', 'Sem
|
|
1463
|
+
st.write(f"**{task.get('title') or task.get('topic', 'Sem título')}**")
|
|
1464
|
+
st.caption(f"Tópico: {task.get('topic', 'Sem tópico')}")
|
|
1177
1465
|
st.caption(f"{task.get('channel_name')} · {task.get('id')}")
|
|
1466
|
+
thumbnail_path = (task.get('artifacts') or {}).get('thumbnail', '')
|
|
1467
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
1468
|
+
st.image(thumbnail_path, width=180)
|
|
1469
|
+
else:
|
|
1470
|
+
status = task.get('thumbnail_status', 'not_generated')
|
|
1471
|
+
prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
|
|
1472
|
+
st.caption(f"Thumbnail: {status}{prompt_note}")
|
|
1178
1473
|
with cols[1]: st.write(task.get("format", "wide"))
|
|
1179
1474
|
with cols[2]: st.write(task.get("stage", "—"))
|
|
1180
1475
|
with cols[3]: st.write(task.get("state", "—"))
|
|
@@ -1312,7 +1607,7 @@ def render_upload_direct():
|
|
|
1312
1607
|
st.caption(f"Documento de credenciais pronto: {account.get('email', 'conta Google')} · dados do canal encontrados no documento")
|
|
1313
1608
|
direct_cols = st.columns([2.2, 1, 1])
|
|
1314
1609
|
with direct_cols[0]:
|
|
1315
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1610
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1316
1611
|
with direct_cols[1]:
|
|
1317
1612
|
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
1318
1613
|
with direct_cols[2]:
|
|
@@ -1387,7 +1682,7 @@ def render_upload_conventional():
|
|
|
1387
1682
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
1388
1683
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
1389
1684
|
if "YouTube" in destination:
|
|
1390
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1685
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1391
1686
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
1392
1687
|
tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
|
|
1393
1688
|
yt_cols = st.columns(3)
|
|
@@ -1426,7 +1721,7 @@ def render_upload_conventional():
|
|
|
1426
1721
|
with st.expander("Detalhes dos mecanismos de upload"):
|
|
1427
1722
|
st.json(result.data["attempts"])
|
|
1428
1723
|
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
1429
|
-
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
1724
|
+
result = TikTokAdapter(settings).upload_video(video_path, task.get("title") or task.get("topic", ""))
|
|
1430
1725
|
(st.success if result.ok else st.warning)(result.message)
|
|
1431
1726
|
if "Instagram" in destination:
|
|
1432
1727
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
@@ -1448,402 +1743,406 @@ def render_settings():
|
|
|
1448
1743
|
key=f"settings_{key}",
|
|
1449
1744
|
)
|
|
1450
1745
|
|
|
1451
|
-
st.
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
st.
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
if
|
|
1746
|
+
google_accounts_tab, api_keys_tab, voice_test_tab = st.tabs(["Contas Google/YouTube — canais em lote", "API Keys", "Teste de vozes"])
|
|
1747
|
+
|
|
1748
|
+
with google_accounts_tab:
|
|
1749
|
+
st.subheader("Contas Google/YouTube — canais em lote")
|
|
1750
|
+
st.caption("Cada registo representa uma conta Google que pode gerir vários canais do YouTube. O e-mail identifica a conta; esta área não lê a caixa Gmail. Cada conta tem Client ID, Client Secret e token OAuth próprios.")
|
|
1751
|
+
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1752
|
+
direct_document_uploads: dict[str, Any] = {}
|
|
1753
|
+
direct_account_save_buttons: dict[str, bool] = {}
|
|
1754
|
+
for batch_account in batch_accounts:
|
|
1755
|
+
account_id = str(batch_account["id"])
|
|
1756
|
+
with st.container(border=True):
|
|
1757
|
+
with st.form(f"batch_account_form_{account_id}"):
|
|
1758
|
+
account_cols = st.columns(4)
|
|
1759
|
+
with account_cols[0]:
|
|
1760
|
+
account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
|
|
1761
|
+
with account_cols[1]:
|
|
1762
|
+
account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
|
|
1763
|
+
with account_cols[2]:
|
|
1764
|
+
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
1765
|
+
with account_cols[3]:
|
|
1766
|
+
account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
|
|
1767
|
+
account_session_info = st.text_input(
|
|
1768
|
+
"sessionInfo token desta conta Google",
|
|
1769
|
+
value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
|
|
1770
|
+
type="password",
|
|
1771
|
+
key=f"batch_session_info_{account_id}",
|
|
1772
|
+
help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
|
|
1773
|
+
)
|
|
1774
|
+
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
1775
|
+
direct_status = direct_account_status(STORAGE, batch_account)
|
|
1776
|
+
st.markdown("**Credenciais do Upload directo desta conta Google**")
|
|
1777
|
+
st.caption("Estas credenciais pertencem apenas a este Gmail. O ficheiro contém SID, SSID, HSID, APISID e SAPISID e é guardado por ID da conta.")
|
|
1778
|
+
direct_account_cols = st.columns([2.4, 1])
|
|
1779
|
+
with direct_account_cols[0]:
|
|
1780
|
+
direct_document_uploads[account_id] = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key=f"direct_credentials_document_{account_id}", help="Documento JSON com cookies SID/SSID/HSID/APISID/SAPISID, sessionInfo token, INNERTUBE_API_KEY, chunk_size e mapa delegated_session_ids.")
|
|
1781
|
+
with direct_account_cols[1]:
|
|
1782
|
+
if direct_status["document_exists"] and direct_status["ready"]:
|
|
1783
|
+
st.success("Documento completo")
|
|
1784
|
+
elif direct_status["document_exists"]:
|
|
1785
|
+
missing_document_parts = list(direct_status["missing_cookies"])
|
|
1786
|
+
if not direct_status["has_session_info"]:
|
|
1787
|
+
missing_document_parts.append("sessionInfo")
|
|
1788
|
+
if not direct_status["has_innertube_api_key"]:
|
|
1789
|
+
missing_document_parts.append("INNERTUBE_API_KEY")
|
|
1790
|
+
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
1791
|
+
else:
|
|
1792
|
+
st.warning("Sem documento de credenciais")
|
|
1793
|
+
st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
|
|
1794
|
+
direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
|
|
1795
|
+
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
1796
|
+
status_cols = st.columns([2, 1, 1])
|
|
1797
|
+
with status_cols[0]:
|
|
1798
|
+
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
1799
|
+
with status_cols[1]:
|
|
1800
|
+
if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
|
|
1801
|
+
result = authorize_youtube_batch_account(batch_account, STORAGE)
|
|
1802
|
+
(st.success if result.ok else st.error)(result.message)
|
|
1803
|
+
if result.ok:
|
|
1804
|
+
st.rerun()
|
|
1805
|
+
with status_cols[2]:
|
|
1806
|
+
if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
|
|
1807
|
+
delete_youtube_batch_token(batch_account, STORAGE)
|
|
1808
|
+
delete_credentials_document(STORAGE, batch_account)
|
|
1809
|
+
remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
|
|
1810
|
+
settings["youtube_batch_accounts"] = remaining_accounts
|
|
1811
|
+
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
1812
|
+
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
1813
|
+
channels = read_json("channels.json", [])
|
|
1814
|
+
channels_changed = False
|
|
1815
|
+
for channel in channels:
|
|
1816
|
+
if str(channel.get("google_account_id") or "") == account_id:
|
|
1817
|
+
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
1818
|
+
channels_changed = True
|
|
1819
|
+
if channels_changed:
|
|
1820
|
+
write_json("channels.json", channels)
|
|
1821
|
+
write_json("settings.json", settings)
|
|
1506
1822
|
st.rerun()
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
1514
|
-
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
1515
|
-
channels = read_json("channels.json", [])
|
|
1516
|
-
channels_changed = False
|
|
1517
|
-
for channel in channels:
|
|
1518
|
-
if str(channel.get("google_account_id") or "") == account_id:
|
|
1519
|
-
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
1520
|
-
channels_changed = True
|
|
1521
|
-
if channels_changed:
|
|
1522
|
-
write_json("channels.json", channels)
|
|
1523
|
-
write_json("settings.json", settings)
|
|
1524
|
-
st.rerun()
|
|
1525
|
-
if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
|
|
1526
|
-
st.session_state["new_batch_account_label"] = account_label
|
|
1527
|
-
st.session_state["new_batch_account_email"] = account_email
|
|
1528
|
-
st.session_state["new_batch_account_client_id"] = account_client_id
|
|
1529
|
-
st.session_state["new_batch_account_client_secret"] = account_client_secret
|
|
1530
|
-
st.session_state["new_batch_account_session_info"] = account_session_info
|
|
1531
|
-
st.rerun()
|
|
1532
|
-
if save_account:
|
|
1533
|
-
if "@" not in account_email.strip():
|
|
1534
|
-
st.error("Informe um e-mail Google válido.")
|
|
1535
|
-
elif not account_client_id.strip() or not account_client_secret.strip():
|
|
1536
|
-
st.error("Informe o Client ID e o Client Secret desta conta.")
|
|
1537
|
-
else:
|
|
1538
|
-
for existing in batch_accounts:
|
|
1539
|
-
if str(existing.get("id")) == account_id:
|
|
1540
|
-
credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
|
|
1541
|
-
if credentials_changed:
|
|
1542
|
-
delete_youtube_batch_token(existing, STORAGE)
|
|
1543
|
-
existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
|
|
1544
|
-
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
1545
|
-
settings["youtube_batch_accounts"] = batch_accounts
|
|
1546
|
-
write_json("settings.json", settings)
|
|
1547
|
-
st.success("Conta Google/YouTube guardada.")
|
|
1823
|
+
if st.button("Repetir campos para nova conta", icon=":material/content_copy:", key=f"batch_repeat_settings_{account_id}", use_container_width=True):
|
|
1824
|
+
st.session_state["new_batch_account_label"] = account_label
|
|
1825
|
+
st.session_state["new_batch_account_email"] = account_email
|
|
1826
|
+
st.session_state["new_batch_account_client_id"] = account_client_id
|
|
1827
|
+
st.session_state["new_batch_account_client_secret"] = account_client_secret
|
|
1828
|
+
st.session_state["new_batch_account_session_info"] = account_session_info
|
|
1548
1829
|
st.rerun()
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1830
|
+
if save_account:
|
|
1831
|
+
if "@" not in account_email.strip():
|
|
1832
|
+
st.error("Informe um e-mail Google válido.")
|
|
1833
|
+
elif not account_client_id.strip() or not account_client_secret.strip():
|
|
1834
|
+
st.error("Informe o Client ID e o Client Secret desta conta.")
|
|
1835
|
+
else:
|
|
1836
|
+
for existing in batch_accounts:
|
|
1837
|
+
if str(existing.get("id")) == account_id:
|
|
1838
|
+
credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
|
|
1839
|
+
if credentials_changed:
|
|
1840
|
+
delete_youtube_batch_token(existing, STORAGE)
|
|
1841
|
+
existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
|
|
1842
|
+
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
1843
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
1844
|
+
write_json("settings.json", settings)
|
|
1845
|
+
st.success("Conta Google/YouTube guardada.")
|
|
1560
1846
|
st.rerun()
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
|
|
1566
|
-
add_cols = st.columns(4)
|
|
1567
|
-
with add_cols[0]:
|
|
1568
|
-
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
1569
|
-
with add_cols[1]:
|
|
1570
|
-
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
1571
|
-
with add_cols[2]:
|
|
1572
|
-
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
1573
|
-
with add_cols[3]:
|
|
1574
|
-
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
1575
|
-
new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento JSON.")
|
|
1576
|
-
new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, INNERTUBE_API_KEY, chunk_size e delegated_session_ids. O sessionInfo pode ser preenchido no campo acima ou no documento.")
|
|
1577
|
-
st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. O campo sessionInfo é específico desta conta; os cookies e restantes credenciais continuam apenas no documento.")
|
|
1578
|
-
add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
|
|
1579
|
-
if add_account:
|
|
1580
|
-
document_error = ""
|
|
1581
|
-
if "@" not in new_account_email.strip():
|
|
1582
|
-
st.error("Informe um e-mail Google válido.")
|
|
1583
|
-
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
1584
|
-
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
1585
|
-
elif new_account_document is not None:
|
|
1586
|
-
try:
|
|
1587
|
-
parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
1588
|
-
except ValueError as exc:
|
|
1589
|
-
document_error = str(exc)
|
|
1590
|
-
st.error(document_error)
|
|
1591
|
-
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
1592
|
-
new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
|
|
1593
|
-
if new_account_document is not None:
|
|
1594
|
-
document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
1595
|
-
document["account_id"] = new_account["id"]
|
|
1596
|
-
document["email"] = new_account["email"]
|
|
1597
|
-
save_credentials_document(STORAGE, new_account, document)
|
|
1598
|
-
batch_accounts.append(new_account)
|
|
1599
|
-
settings["youtube_batch_accounts"] = batch_accounts
|
|
1600
|
-
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
1601
|
-
write_json("settings.json", settings)
|
|
1602
|
-
if new_account_document is not None:
|
|
1603
|
-
st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
|
|
1604
|
-
else:
|
|
1605
|
-
st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
|
|
1606
|
-
st.rerun()
|
|
1607
|
-
|
|
1608
|
-
with st.form("settings_form"):
|
|
1609
|
-
st.subheader("Execução local")
|
|
1610
|
-
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
1611
|
-
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
1612
|
-
st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
|
|
1613
|
-
st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
|
|
1614
|
-
st.info(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
|
|
1615
|
-
youtube_cols = st.columns(2)
|
|
1616
|
-
with youtube_cols[0]:
|
|
1617
|
-
youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
|
|
1618
|
-
with youtube_cols[1]:
|
|
1619
|
-
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
|
|
1620
|
-
|
|
1621
|
-
with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
|
|
1622
|
-
st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
|
|
1623
|
-
kaggle_cols = st.columns(3)
|
|
1624
|
-
with kaggle_cols[0]:
|
|
1625
|
-
kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
|
|
1626
|
-
with kaggle_cols[1]:
|
|
1627
|
-
kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
|
|
1628
|
-
with kaggle_cols[2]:
|
|
1629
|
-
kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
|
|
1630
|
-
|
|
1631
|
-
with st.expander("Niche Finder — execução através da Apify", expanded=True):
|
|
1632
|
-
st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
|
|
1633
|
-
apify_cols = st.columns(4)
|
|
1634
|
-
with apify_cols[0]:
|
|
1635
|
-
apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
|
|
1636
|
-
with apify_cols[1]:
|
|
1637
|
-
apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
|
|
1638
|
-
with apify_cols[2]:
|
|
1639
|
-
apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
|
|
1640
|
-
with apify_cols[3]:
|
|
1641
|
-
apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
|
|
1642
|
-
|
|
1643
|
-
with st.expander("Consulta oficial de métricas — opcional"):
|
|
1644
|
-
st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
|
|
1645
|
-
youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
|
|
1646
|
-
|
|
1647
|
-
st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
|
|
1648
|
-
direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
|
|
1649
|
-
direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
|
|
1650
|
-
|
|
1651
|
-
with st.expander("Serviço, materiais e rede"):
|
|
1652
|
-
cols = st.columns(2)
|
|
1653
|
-
with cols[0]:
|
|
1654
|
-
log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
|
|
1655
|
-
listen_host = text_setting("API listen host", "listen_host")
|
|
1656
|
-
listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
|
|
1657
|
-
video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
|
|
1658
|
-
with cols[1]:
|
|
1659
|
-
endpoint = text_setting("Endpoint público", "endpoint")
|
|
1660
|
-
proxy_http = text_setting("Proxy HTTP", "proxy_http")
|
|
1661
|
-
proxy_https = text_setting("Proxy HTTPS", "proxy_https")
|
|
1662
|
-
match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
|
|
1663
|
-
|
|
1664
|
-
with st.expander("LLM — providers e modelos", expanded=True):
|
|
1665
|
-
provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
|
|
1666
|
-
llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
|
|
1667
|
-
st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
|
|
1668
|
-
st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
|
|
1669
|
-
openai_cols = st.columns(3)
|
|
1670
|
-
with openai_cols[0]:
|
|
1671
|
-
openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
|
|
1672
|
-
with openai_cols[1]:
|
|
1673
|
-
openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
|
|
1674
|
-
with openai_cols[2]:
|
|
1675
|
-
cached_catalog = st.session_state.get("openai_model_catalog", {})
|
|
1676
|
-
catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
|
|
1677
|
-
cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
|
|
1678
|
-
current_model_name = str(settings.get("openai_model_name", "") or "")
|
|
1679
|
-
manual_option = "__manual_model__"
|
|
1680
|
-
if cached_models:
|
|
1681
|
-
model_options = [manual_option, *cached_models]
|
|
1682
|
-
model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
|
|
1683
|
-
selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
|
|
1684
|
-
if selected_model == manual_option:
|
|
1685
|
-
openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
|
|
1847
|
+
if direct_account_save_buttons.get(account_id):
|
|
1848
|
+
uploaded_document = direct_document_uploads.get(account_id)
|
|
1849
|
+
if uploaded_document is None:
|
|
1850
|
+
st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
|
|
1686
1851
|
else:
|
|
1687
|
-
|
|
1852
|
+
try:
|
|
1853
|
+
document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
|
|
1854
|
+
document["account_id"] = account_id
|
|
1855
|
+
document["email"] = str(batch_account.get("email", ""))
|
|
1856
|
+
save_credentials_document(STORAGE, batch_account, document)
|
|
1857
|
+
st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
|
|
1858
|
+
st.rerun()
|
|
1859
|
+
except ValueError as exc:
|
|
1860
|
+
st.error(str(exc))
|
|
1861
|
+
with st.form("add_batch_account_form"):
|
|
1862
|
+
st.markdown("**Credenciais do Upload directo desta conta Google**")
|
|
1863
|
+
st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
|
|
1864
|
+
add_cols = st.columns(4)
|
|
1865
|
+
with add_cols[0]:
|
|
1866
|
+
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
1867
|
+
with add_cols[1]:
|
|
1868
|
+
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
1869
|
+
with add_cols[2]:
|
|
1870
|
+
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
1871
|
+
with add_cols[3]:
|
|
1872
|
+
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
1873
|
+
new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento JSON.")
|
|
1874
|
+
new_account_document = st.file_uploader("Documento de credenciais desta conta Google", type=["json"], key="new_batch_account_credentials_document", help="Documento JSON único com cookies SID/SSID/HSID/APISID/SAPISID, INNERTUBE_API_KEY, chunk_size e delegated_session_ids. O sessionInfo pode ser preenchido no campo acima ou no documento.")
|
|
1875
|
+
st.caption("O documento é guardado em storage/youtube_direct_accounts/<id-da-conta>/credentials.json. O campo sessionInfo é específico desta conta; os cookies e restantes credenciais continuam apenas no documento.")
|
|
1876
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
|
|
1877
|
+
if add_account:
|
|
1878
|
+
document_error = ""
|
|
1879
|
+
if "@" not in new_account_email.strip():
|
|
1880
|
+
st.error("Informe um e-mail Google válido.")
|
|
1881
|
+
elif not new_account_client_id.strip() or not new_account_client_secret.strip():
|
|
1882
|
+
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
1883
|
+
elif new_account_document is not None:
|
|
1884
|
+
try:
|
|
1885
|
+
parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
1886
|
+
except ValueError as exc:
|
|
1887
|
+
document_error = str(exc)
|
|
1888
|
+
st.error(document_error)
|
|
1889
|
+
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
1890
|
+
new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
|
|
1891
|
+
if new_account_document is not None:
|
|
1892
|
+
document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
1893
|
+
document["account_id"] = new_account["id"]
|
|
1894
|
+
document["email"] = new_account["email"]
|
|
1895
|
+
save_credentials_document(STORAGE, new_account, document)
|
|
1896
|
+
batch_accounts.append(new_account)
|
|
1897
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
1898
|
+
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
1899
|
+
write_json("settings.json", settings)
|
|
1900
|
+
if new_account_document is not None:
|
|
1901
|
+
st.success(f"Conta {new_account['email']} adicionada com documento de Upload directo por Gmail.")
|
|
1688
1902
|
else:
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
("
|
|
1703
|
-
|
|
1704
|
-
("
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
st.
|
|
1708
|
-
|
|
1903
|
+
st.warning(f"Conta {new_account['email']} adicionada. Carregue o documento completo no cartão desta conta antes do Upload directo.")
|
|
1904
|
+
st.rerun()
|
|
1905
|
+
|
|
1906
|
+
with api_keys_tab:
|
|
1907
|
+
with st.form("settings_form"):
|
|
1908
|
+
st.subheader("API Keys")
|
|
1909
|
+
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
1910
|
+
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
1911
|
+
st.markdown("**YouTube — OAuth 2.0 e consulta pública**")
|
|
1912
|
+
st.caption("Para autorizar uploads, preencha apenas o YouTube OAuth Client ID e o YouTube OAuth Client Secret. Depois, autorize o agente na aba Upload. Estes dados identificam a aplicação OAuth; não são uma Data API Key nem um token de acesso.")
|
|
1913
|
+
st.info(f"OAuth local: use um cliente do tipo Desktop app. Se o Google Cloud pedir uma URI autorizada, registe exactamente `{loopback_redirect_uri()}`.")
|
|
1914
|
+
youtube_cols = st.columns(2)
|
|
1915
|
+
with youtube_cols[0]:
|
|
1916
|
+
youtube_client_id = text_setting("YouTube OAuth Client ID", "youtube_client_id", help_text="Client ID do OAuth 2.0 criado no Google Cloud. É usado para iniciar a autorização da conta YouTube.")
|
|
1917
|
+
with youtube_cols[1]:
|
|
1918
|
+
youtube_client_secret = text_setting("YouTube OAuth Client Secret", "youtube_client_secret", secret=True, help_text="Client Secret do mesmo cliente OAuth 2.0. Não é uma API Key.")
|
|
1919
|
+
|
|
1920
|
+
with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
|
|
1921
|
+
st.caption("O dataset permanece no Kaggle. O Thunderbolt usa estas credenciais apenas para publicar/executar a kernel e obter os resultados pequenos da análise.")
|
|
1922
|
+
kaggle_cols = st.columns(3)
|
|
1923
|
+
with kaggle_cols[0]:
|
|
1924
|
+
kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
|
|
1925
|
+
with kaggle_cols[1]:
|
|
1926
|
+
kaggle_api_key = text_setting("Kaggle API Key", "kaggle_api_key", secret=True, help_text="Chave criada em Kaggle > Settings > API. Nunca é incluída no notebook ou no GitHub.")
|
|
1927
|
+
with kaggle_cols[2]:
|
|
1928
|
+
kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
|
|
1929
|
+
|
|
1930
|
+
with st.expander("Niche Finder — execução através da Apify", expanded=True):
|
|
1931
|
+
st.caption("O token fica guardado apenas no storage local. A aba Niche Finder Apify só usa este serviço depois de clicar no botão de pesquisa.")
|
|
1932
|
+
apify_cols = st.columns(4)
|
|
1933
|
+
with apify_cols[0]:
|
|
1934
|
+
apify_api_token = text_setting("Apify API Token", "apify_api_token", secret=True, help_text="Token pessoal da Apify. Não é incluído no workflow, logs ou GitHub.")
|
|
1935
|
+
with apify_cols[1]:
|
|
1936
|
+
apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
|
|
1937
|
+
with apify_cols[2]:
|
|
1938
|
+
apify_poll_interval = st.number_input("Intervalo de consulta (s)", min_value=1, max_value=120, value=int(settings.get("apify_poll_interval_seconds", 10)), step=1)
|
|
1939
|
+
with apify_cols[3]:
|
|
1940
|
+
apify_run_timeout = st.number_input("Limite da execução (s)", min_value=30, max_value=7200, value=int(settings.get("apify_run_timeout_seconds", 900)), step=30)
|
|
1941
|
+
|
|
1942
|
+
with st.expander("Consulta oficial de métricas — opcional"):
|
|
1943
|
+
st.caption("A YouTube Data API Key é uma credencial Google Cloud separada do OAuth. Só é necessária se escolher o método YouTube Data API para consultar métricas oficiais. Não é necessária para Página pública — sem API Key, para autorizar OAuth ou para fazer upload.")
|
|
1944
|
+
youtube_api_key = text_setting("YouTube Data API Key (opcional)", "youtube_api_key", secret=True, help_text="Credencial separada, criada em Google Cloud > APIs e serviços > Credenciais > Chave de API. Não cole aqui o Client ID nem o Client Secret.")
|
|
1945
|
+
|
|
1946
|
+
st.caption("As credenciais e parâmetros do Upload directo — cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e DELEGATED_SESSION_ID — são lidos exclusivamente do documento JSON por conta Google. Não são editados nesta UI.")
|
|
1947
|
+
direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
|
|
1948
|
+
direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
|
|
1949
|
+
|
|
1950
|
+
with st.expander("Serviço, materiais e rede"):
|
|
1951
|
+
cols = st.columns(2)
|
|
1709
1952
|
with cols[0]:
|
|
1710
|
-
if
|
|
1711
|
-
|
|
1953
|
+
log_level = st.selectbox("Log level", ["DEBUG", "INFO", "WARNING", "ERROR"], index=["DEBUG", "INFO", "WARNING", "ERROR"].index(settings.get("log_level", "DEBUG")) if settings.get("log_level", "DEBUG") in ["DEBUG", "INFO", "WARNING", "ERROR"] else 0)
|
|
1954
|
+
listen_host = text_setting("API listen host", "listen_host")
|
|
1955
|
+
listen_port = st.number_input("API listen port", 1, 65535, int(settings.get("listen_port", 8080)))
|
|
1956
|
+
video_source = st.selectbox("Fonte de materiais", ["pexels", "pixabay", "coverr", "loomloom", "local"], index=["pexels", "pixabay", "coverr", "loomloom", "local"].index(settings.get("video_source", "pexels")) if settings.get("video_source", "pexels") in ["pexels", "pixabay", "coverr", "loomloom", "local"] else 0)
|
|
1957
|
+
with cols[1]:
|
|
1958
|
+
endpoint = text_setting("Endpoint público", "endpoint")
|
|
1959
|
+
proxy_http = text_setting("Proxy HTTP", "proxy_http")
|
|
1960
|
+
proxy_https = text_setting("Proxy HTTPS", "proxy_https")
|
|
1961
|
+
match_materials_to_script = st.checkbox("Alinhar materiais ao roteiro", bool(settings.get("match_materials_to_script", False)))
|
|
1962
|
+
|
|
1963
|
+
with st.expander("LLM — providers e modelos", expanded=True):
|
|
1964
|
+
provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
|
|
1965
|
+
llm_provider = st.selectbox("LLM provider", provider_options, index=provider_options.index(settings.get("llm_provider", "moonshot")) if settings.get("llm_provider", "moonshot") in provider_options else 0)
|
|
1966
|
+
st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
|
|
1967
|
+
st.caption("O provider interno continua a ser `openai`, mas pode usar qualquer endpoint OpenAI-compatible. Para NVIDIA NIM, a Base URL predefinida é `https://integrate.api.nvidia.com/v1`; o selector consulta `/models` e deixa um campo manual como fallback.")
|
|
1968
|
+
openai_cols = st.columns(3)
|
|
1969
|
+
with openai_cols[0]:
|
|
1970
|
+
openai_api_key = text_setting("OpenAI/ NVIDIA NIM API key", "openai_api_key", secret=True, help_text="API key do OpenAI ou do NVIDIA Build/NIM. A credencial fica apenas no storage local.")
|
|
1971
|
+
with openai_cols[1]:
|
|
1972
|
+
openai_base_url = st.text_input("OpenAI/ NVIDIA NIM Base URL", value=str(settings.get("openai_base_url", "") or DEFAULT_NVIDIA_NIM_BASE_URL), help="Ex.: https://integrate.api.nvidia.com/v1. O Thunderbolt acrescenta /models para descobrir os modelos.", key="settings_openai_base_url")
|
|
1973
|
+
with openai_cols[2]:
|
|
1974
|
+
cached_catalog = st.session_state.get("openai_model_catalog", {})
|
|
1975
|
+
catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
|
|
1976
|
+
cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
|
|
1977
|
+
current_model_name = str(settings.get("openai_model_name", "") or "")
|
|
1978
|
+
manual_option = "__manual_model__"
|
|
1979
|
+
if cached_models:
|
|
1980
|
+
model_options = [manual_option, *cached_models]
|
|
1981
|
+
model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
|
|
1982
|
+
selected_model = st.selectbox("Modelo OpenAI/ NVIDIA NIM", model_options, index=model_index, format_func=lambda value: "Escrever modelo manualmente" if value == manual_option else value, key="settings_openai_model_select")
|
|
1983
|
+
if selected_model == manual_option:
|
|
1984
|
+
openai_model_name = st.text_input("Modelo manual", value=current_model_name if current_model_name not in cached_models else "", help="Ex.: nvidia_nim/minimaxai/minimax-m3", key="settings_openai_model_manual")
|
|
1985
|
+
else:
|
|
1986
|
+
openai_model_name = selected_model
|
|
1712
1987
|
else:
|
|
1713
|
-
|
|
1988
|
+
openai_model_name = st.text_input("Modelo OpenAI/ NVIDIA NIM", value=current_model_name, help="Pode escrever um ID manualmente se o endpoint não disponibilizar /models.", key="settings_openai_model_name")
|
|
1989
|
+
refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
|
|
1990
|
+
if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
|
|
1991
|
+
st.warning(str(cached_catalog["error"]))
|
|
1992
|
+
elif cached_models:
|
|
1993
|
+
st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
|
|
1994
|
+
else:
|
|
1995
|
+
st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
|
|
1996
|
+
llm_fields = [
|
|
1997
|
+
("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
|
|
1998
|
+
("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
|
|
1999
|
+
("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
|
|
2000
|
+
("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
|
|
2001
|
+
("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
|
|
2002
|
+
("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
|
|
2003
|
+
("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
|
|
2004
|
+
]
|
|
2005
|
+
for label, prefix, has_key in llm_fields:
|
|
2006
|
+
st.markdown(f"**{label}**")
|
|
2007
|
+
cols = st.columns(3)
|
|
2008
|
+
with cols[0]:
|
|
2009
|
+
if has_key:
|
|
2010
|
+
settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
|
|
2011
|
+
else:
|
|
2012
|
+
settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
|
|
2013
|
+
with cols[1]:
|
|
2014
|
+
settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
|
|
2015
|
+
with cols[2]:
|
|
2016
|
+
settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
|
|
2017
|
+
|
|
2018
|
+
with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
|
|
2019
|
+
cols = st.columns(2)
|
|
2020
|
+
with cols[0]:
|
|
2021
|
+
azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
|
|
2022
|
+
azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
|
|
2023
|
+
siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
|
|
2024
|
+
minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
|
|
2025
|
+
minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
|
|
2026
|
+
minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
|
|
2027
|
+
minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
|
|
1714
2028
|
with cols[1]:
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
|
|
1765
|
-
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
1766
|
-
upload_post_username = text_setting("Upload-Post username", "upload_post_username")
|
|
1767
|
-
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
1768
|
-
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
1769
|
-
|
|
1770
|
-
if refresh_openai_models:
|
|
1771
|
-
try:
|
|
1772
|
-
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
1773
|
-
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
|
|
1774
|
-
st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
|
|
1775
|
-
st.rerun()
|
|
1776
|
-
except ModelDiscoveryError as exc:
|
|
1777
|
-
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
|
|
1778
|
-
st.rerun()
|
|
2029
|
+
elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
|
|
2030
|
+
elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
|
|
2031
|
+
chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
|
|
2032
|
+
chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
|
|
2033
|
+
chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
|
|
2034
|
+
sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
|
|
2035
|
+
sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
|
|
2036
|
+
st.markdown("**Suno — agente musical opcional**")
|
|
2037
|
+
suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
|
|
2038
|
+
suno_api_base_url = text_setting("Suno API Base URL", "suno_api_base_url", help_text="Use o endpoint compatível fornecido pelo seu acesso Suno; não é inventado pelo Thunderbolt.")
|
|
2039
|
+
suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
|
|
2040
|
+
|
|
2041
|
+
with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
|
|
2042
|
+
cols = st.columns(2)
|
|
2043
|
+
with cols[0]:
|
|
2044
|
+
pexels_api_keys = text_setting("Pexels API keys", "pexels_api_keys", secret=True, help_text="Separe várias chaves por vírgula para rotação.")
|
|
2045
|
+
pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
|
|
2046
|
+
coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
|
|
2047
|
+
twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
|
|
2048
|
+
material_directory = text_setting("Pasta de materiais", "material_directory")
|
|
2049
|
+
with cols[1]:
|
|
2050
|
+
subtitle_provider = st.selectbox("Subtitle provider", ["edge", "whisper", ""], index=["edge", "whisper", ""].index(settings.get("subtitle_provider", "edge")) if settings.get("subtitle_provider", "edge") in ["edge", "whisper", ""] else 0)
|
|
2051
|
+
ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
|
|
2052
|
+
video_codec = text_setting("Codec de vídeo", "video_codec")
|
|
2053
|
+
whisper_model_size = text_setting("Whisper model", "whisper_model_size")
|
|
2054
|
+
whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
|
|
2055
|
+
whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
|
|
2056
|
+
|
|
2057
|
+
with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
|
|
2058
|
+
st.caption("Apenas as credenciais da aplicação ficam nesta UI. Redirect URI, scopes, autorização e tokens são geridos no TikTok for Developers Playground.")
|
|
2059
|
+
tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
|
|
2060
|
+
tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
|
|
2061
|
+
|
|
2062
|
+
with st.expander("Publicação através do Upload-Post"):
|
|
2063
|
+
upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
|
|
2064
|
+
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
2065
|
+
upload_post_username = text_setting("Upload-Post username", "upload_post_username")
|
|
2066
|
+
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
2067
|
+
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
2068
|
+
|
|
2069
|
+
if refresh_openai_models:
|
|
2070
|
+
try:
|
|
2071
|
+
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
2072
|
+
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
|
|
2073
|
+
st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
|
|
2074
|
+
st.rerun()
|
|
2075
|
+
except ModelDiscoveryError as exc:
|
|
2076
|
+
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
|
|
2077
|
+
st.rerun()
|
|
1779
2078
|
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2079
|
+
save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
|
|
2080
|
+
if save_all_settings:
|
|
2081
|
+
settings.update({
|
|
2082
|
+
"port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
|
|
2083
|
+
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
2084
|
+
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
2085
|
+
"apify_api_token": apify_api_token.strip(), "apify_actor_id": apify_actor_id.strip() or DEFAULT_ACTOR_ID, "apify_poll_interval_seconds": int(apify_poll_interval), "apify_run_timeout_seconds": int(apify_run_timeout),
|
|
2086
|
+
"direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
|
|
2087
|
+
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
2088
|
+
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
2089
|
+
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
|
2090
|
+
"azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
|
|
2091
|
+
"siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
|
|
2092
|
+
"minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
|
|
2093
|
+
"elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
|
|
2094
|
+
"pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
|
|
2095
|
+
"chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
|
|
2096
|
+
"sonilo_api_key": sonilo_api_key, "sonilo_base_url": sonilo_base_url, "suno_api_key": suno_api_key, "suno_api_base_url": suno_api_base_url, "suno_api_endpoint": suno_api_endpoint, "subtitle_provider": subtitle_provider,
|
|
2097
|
+
"ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
|
|
2098
|
+
"whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
|
|
2099
|
+
"tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
|
|
2100
|
+
"upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
|
|
2101
|
+
"upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
|
|
2102
|
+
"upload_post_auto_upload": upload_post_auto_upload,
|
|
2103
|
+
})
|
|
2104
|
+
write_json("settings.json", settings)
|
|
2105
|
+
try:
|
|
2106
|
+
synced = sync_moneyprinter_config(settings, moneyprinter_path)
|
|
2107
|
+
if synced:
|
|
2108
|
+
st.success(f"Configurações guardadas e sincronizadas com {synced}")
|
|
2109
|
+
else:
|
|
2110
|
+
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
2111
|
+
except Exception as exc:
|
|
2112
|
+
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
2113
|
+
|
|
2114
|
+
with voice_test_tab:
|
|
2115
|
+
st.subheader("Teste de vozes")
|
|
2116
|
+
st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
|
|
2117
|
+
preview_cols = st.columns([1.1, 2.2, 1.2])
|
|
2118
|
+
with preview_cols[0]:
|
|
2119
|
+
preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
|
|
2120
|
+
with preview_cols[1]:
|
|
2121
|
+
if preview_provider in {"edge", "azure_speech"}:
|
|
2122
|
+
preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
|
|
2123
|
+
preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
|
|
2124
|
+
else:
|
|
2125
|
+
preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
|
|
2126
|
+
with preview_cols[2]:
|
|
2127
|
+
preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
|
|
2128
|
+
preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
|
|
2129
|
+
if st.button("Testar voz", type="primary", key="voice_preview_generate"):
|
|
2130
|
+
st.session_state.pop("voice_preview_path", None)
|
|
1806
2131
|
try:
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
else:
|
|
1811
|
-
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
2132
|
+
preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
|
|
2133
|
+
st.session_state["voice_preview_path"] = str(preview_path)
|
|
2134
|
+
st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
|
|
1812
2135
|
except Exception as exc:
|
|
1813
|
-
st.
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
preview_voice_options = voice_catalog(settings.get("voice_preview_voice", "en-US-AriaNeural-Female"))
|
|
1824
|
-
preview_voice = st.selectbox("Voz", preview_voice_options, index=preview_voice_options.index(settings.get("voice_preview_voice", "en-US-AriaNeural-Female")) if settings.get("voice_preview_voice", "en-US-AriaNeural-Female") in preview_voice_options else 0, format_func=lambda item: item or "Escolha uma voz", key="voice_preview_voice")
|
|
1825
|
-
else:
|
|
1826
|
-
preview_voice = st.text_input("Voice ID", value=settings.get("voice_preview_voice", ""), key="voice_preview_voice_text")
|
|
1827
|
-
with preview_cols[2]:
|
|
1828
|
-
preview_rate = st.selectbox("Velocidade", ["-20%", "-10%", "+0%", "+10%", "+20%"], index=2, key="voice_preview_rate")
|
|
1829
|
-
preview_text = st.text_area("Texto de teste", value=DEFAULT_SAMPLE, max_chars=1000, height=110, key="voice_preview_text")
|
|
1830
|
-
if st.button("Testar voz", type="primary", key="voice_preview_generate"):
|
|
1831
|
-
st.session_state.pop("voice_preview_path", None)
|
|
1832
|
-
try:
|
|
1833
|
-
preview_path = synthesize_preview(preview_text, preview_provider, preview_voice, settings, preview_rate)
|
|
1834
|
-
st.session_state["voice_preview_path"] = str(preview_path)
|
|
1835
|
-
st.success("Amostra de voz gerada. Este ficheiro é apenas um preview.")
|
|
1836
|
-
except Exception as exc:
|
|
1837
|
-
st.error(f"Não foi possível gerar o preview: {exc}")
|
|
1838
|
-
preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
|
|
1839
|
-
loaded_preview = load_preview_file(preview_value)
|
|
1840
|
-
if loaded_preview:
|
|
1841
|
-
preview_path, preview_data = loaded_preview
|
|
1842
|
-
st.audio(preview_data, format="audio/mpeg")
|
|
1843
|
-
st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
|
|
1844
|
-
elif preview_value:
|
|
1845
|
-
st.session_state.pop("voice_preview_path", None)
|
|
1846
|
-
st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
|
|
2136
|
+
st.error(f"Não foi possível gerar o preview: {exc}")
|
|
2137
|
+
preview_value = str(st.session_state.get("voice_preview_path", "") or "").strip()
|
|
2138
|
+
loaded_preview = load_preview_file(preview_value)
|
|
2139
|
+
if loaded_preview:
|
|
2140
|
+
preview_path, preview_data = loaded_preview
|
|
2141
|
+
st.audio(preview_data, format="audio/mpeg")
|
|
2142
|
+
st.download_button("Descarregar amostra", data=preview_data, file_name=preview_path.name, mime="audio/mpeg", key="voice_preview_download")
|
|
2143
|
+
elif preview_value:
|
|
2144
|
+
st.session_state.pop("voice_preview_path", None)
|
|
2145
|
+
st.warning("A amostra de voz anterior não é um ficheiro de áudio legível e foi removida do estado local. Teste a voz novamente.")
|
|
1847
2146
|
|
|
1848
2147
|
|
|
1849
2148
|
def render_mcp():
|