@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/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
- channels = channel_options()
692
- if not channels:
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("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True, key="new_video_mode")
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 = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid), key="new_video_channels_general")
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
- selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
701
- selected = [selected_one["id"]]
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=topic if "topic" in locals() else "Thunderbolt music", key="new_video_suno_title")
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
- if not topic.strip() or not selected:
759
- st.error("Informe um tópico e seleccione pelo menos um canal.")
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
- quantity = int(quantity if mode == "same_channel" else 1)
762
- style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
763
- if style == "music" and not music_path:
764
- st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
765
- st.stop()
766
- batch = create_batch(mode, selected, topic, quantity, {"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})
767
- tasks = create_tasks_for_batch(batch)
768
- st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
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 tópico')}**")
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.subheader("Contas Google/YouTube — canais em lote")
1452
- 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.")
1453
- batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
1454
- direct_document_uploads: dict[str, Any] = {}
1455
- direct_account_save_buttons: dict[str, bool] = {}
1456
- for batch_account in batch_accounts:
1457
- account_id = str(batch_account["id"])
1458
- with st.container(border=True):
1459
- with st.form(f"batch_account_form_{account_id}"):
1460
- account_cols = st.columns(4)
1461
- with account_cols[0]:
1462
- account_label = st.text_input("Nome da conta", value=str(batch_account.get("label", "Canais YouTube")), key=f"batch_label_{account_id}")
1463
- with account_cols[1]:
1464
- account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
1465
- with account_cols[2]:
1466
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
1467
- with account_cols[3]:
1468
- 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}")
1469
- account_session_info = st.text_input(
1470
- "sessionInfo token desta conta Google",
1471
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
1472
- type="password",
1473
- key=f"batch_session_info_{account_id}",
1474
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
1475
- )
1476
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
1477
- direct_status = direct_account_status(STORAGE, batch_account)
1478
- st.markdown("**Credenciais do Upload directo desta conta Google**")
1479
- 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.")
1480
- direct_account_cols = st.columns([2.4, 1])
1481
- with direct_account_cols[0]:
1482
- 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.")
1483
- with direct_account_cols[1]:
1484
- if direct_status["document_exists"] and direct_status["ready"]:
1485
- st.success("Documento completo")
1486
- elif direct_status["document_exists"]:
1487
- missing_document_parts = list(direct_status["missing_cookies"])
1488
- if not direct_status["has_session_info"]:
1489
- missing_document_parts.append("sessionInfo")
1490
- if not direct_status["has_innertube_api_key"]:
1491
- missing_document_parts.append("INNERTUBE_API_KEY")
1492
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
1493
- else:
1494
- st.warning("Sem documento de credenciais")
1495
- st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
1496
- 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}")
1497
- account_status = youtube_batch_account_status(batch_account, STORAGE)
1498
- status_cols = st.columns([2, 1, 1])
1499
- with status_cols[0]:
1500
- (st.success if account_status.ok else st.warning)(account_status.message)
1501
- with status_cols[1]:
1502
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
1503
- result = authorize_youtube_batch_account(batch_account, STORAGE)
1504
- (st.success if result.ok else st.error)(result.message)
1505
- if result.ok:
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
- with status_cols[2]:
1508
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
1509
- delete_youtube_batch_token(batch_account, STORAGE)
1510
- delete_credentials_document(STORAGE, batch_account)
1511
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
1512
- settings["youtube_batch_accounts"] = remaining_accounts
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
- if direct_account_save_buttons.get(account_id):
1550
- uploaded_document = direct_document_uploads.get(account_id)
1551
- if uploaded_document is None:
1552
- st.error("Seleccione o documento JSON completo desta conta Google antes de guardar.")
1553
- else:
1554
- try:
1555
- document = parse_credentials_document(uploaded_document.getvalue(), uploaded_document.name, session_info_override=account_session_info.strip())
1556
- document["account_id"] = account_id
1557
- document["email"] = str(batch_account.get("email", ""))
1558
- save_credentials_document(STORAGE, batch_account, document)
1559
- st.success("Documento de Upload directo guardado exclusivamente para esta conta Google.")
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
- except ValueError as exc:
1562
- st.error(str(exc))
1563
- with st.form("add_batch_account_form"):
1564
- st.markdown("**Credenciais do Upload directo desta conta Google**")
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
- openai_model_name = selected_model
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
- 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")
1690
- refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
1691
- if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
1692
- st.warning(str(cached_catalog["error"]))
1693
- elif cached_models:
1694
- st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
1695
- else:
1696
- st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
1697
- llm_fields = [
1698
- ("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
1699
- ("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
1700
- ("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
1701
- ("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
1702
- ("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
1703
- ("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
1704
- ("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
1705
- ]
1706
- for label, prefix, has_key in llm_fields:
1707
- st.markdown(f"**{label}**")
1708
- cols = st.columns(3)
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 has_key:
1711
- settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
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
- settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
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
- settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
1716
- with cols[2]:
1717
- settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
1718
-
1719
- with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
1720
- cols = st.columns(2)
1721
- with cols[0]:
1722
- azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
1723
- azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
1724
- siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
1725
- minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
1726
- minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
1727
- minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
1728
- minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
1729
- with cols[1]:
1730
- elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
1731
- elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
1732
- chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
1733
- chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
1734
- chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
1735
- sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
1736
- sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
1737
- st.markdown("**Suno agente musical opcional**")
1738
- suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
1739
- 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.")
1740
- suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
1741
-
1742
- with st.expander("Vídeo, materiais, Whisper e FFmpeg"):
1743
- cols = st.columns(2)
1744
- with cols[0]:
1745
- 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.")
1746
- pixabay_api_keys = text_setting("Pixabay API keys", "pixabay_api_keys", secret=True)
1747
- coverr_api_keys = text_setting("Coverr API keys", "coverr_api_keys", secret=True)
1748
- twelvelabs_api_keys = text_setting("TwelveLabs API keys", "twelvelabs_api_keys", secret=True)
1749
- material_directory = text_setting("Pasta de materiais", "material_directory")
1750
- with cols[1]:
1751
- 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)
1752
- ffmpeg_path = text_setting("Caminho FFmpeg", "ffmpeg_path")
1753
- video_codec = text_setting("Codec de vídeo", "video_codec")
1754
- whisper_model_size = text_setting("Whisper model", "whisper_model_size")
1755
- whisper_device = st.selectbox("Whisper device", ["cpu", "cuda"], index=0 if settings.get("whisper_device", "cpu") == "cpu" else 1)
1756
- whisper_compute_type = text_setting("Whisper compute type", "whisper_compute_type")
1757
-
1758
- with st.expander("TikTok for Developers Client ID e Client Secret", expanded=True):
1759
- 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.")
1760
- tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
1761
- tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
1762
-
1763
- with st.expander("Publicação através do Upload-Post"):
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
- save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
1781
- if save_all_settings:
1782
- settings.update({
1783
- "port": port, "moneyprinter_path": moneyprinter_path, "youtube_api_key": youtube_api_key,
1784
- "youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
1785
- "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
1786
- "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),
1787
- "direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
1788
- "log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
1789
- "endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
1790
- "llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
1791
- "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
1792
- "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
1793
- "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
1794
- "elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
1795
- "pexels_api_keys": pexels_api_keys, "pixabay_api_keys": pixabay_api_keys, "coverr_api_keys": coverr_api_keys, "twelvelabs_api_keys": twelvelabs_api_keys,
1796
- "chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
1797
- "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,
1798
- "ffmpeg_path": ffmpeg_path, "video_codec": video_codec, "material_directory": material_directory,
1799
- "whisper_model_size": whisper_model_size, "whisper_device": whisper_device, "whisper_compute_type": whisper_compute_type,
1800
- "tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
1801
- "upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
1802
- "upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
1803
- "upload_post_auto_upload": upload_post_auto_upload,
1804
- })
1805
- write_json("settings.json", settings)
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
- synced = sync_moneyprinter_config(settings, moneyprinter_path)
1808
- if synced:
1809
- st.success(f"Configurações guardadas e sincronizadas com {synced}")
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.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
1814
-
1815
- st.divider()
1816
- st.subheader("Teste de vozes")
1817
- st.caption("Este painel é exclusivamente um preview. O áudio gerado não altera vídeos, tarefas, Blueprints ou a configuração da pipeline.")
1818
- preview_cols = st.columns([1.1, 2.2, 1.2])
1819
- with preview_cols[0]:
1820
- preview_provider = st.selectbox("Provider", ["edge", "azure_speech", "elevenlabs", "minimax", "siliconflow", "gemini", "chatterbox"], index=0, key="voice_preview_provider")
1821
- with preview_cols[1]:
1822
- if preview_provider in {"edge", "azure_speech"}:
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():