@danhachuel/thunderbolt 0.3.41 → 0.3.43

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
@@ -39,12 +39,13 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
39
39
  from hermes_ui.mcp_server import server_status, start_server, stop_server
40
40
  from hermes_ui.material_sources import apply_material_source_cards_to_settings, ensure_material_source_cards, material_source_catalog, material_source_definition, new_material_card, normalize_material_card, selected_material_source
41
41
  from hermes_ui.llm_providers import LLM_CARDS_KEY, LLM_ACTIVE_CARD_KEY, LLM_PROVIDER_CATALOG, apply_llm_cards_to_settings, ensure_llm_provider_cards, new_llm_card, normalize_llm_card, provider_definition, test_llm_provider_card, stamp_test_result
42
+ from hermes_ui.media_providers import MEDIA_CARDS_KEY, MEDIA_IMAGE_ACTIVE_CARD_KEY, MEDIA_VIDEO_ACTIVE_CARD_KEY, apply_media_provider_cards_to_settings, ensure_media_provider_cards, media_provider_catalog, media_provider_definition, new_media_card, normalize_media_card
42
43
  from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
43
44
  from hermes_ui.media_downloader import AUDIO_FORMATS, VIDEO_CONTAINERS, VIDEO_QUALITY_OPTIONS, MediaDownloadError, build_download_options, clear_media_download_history, dependency_status, download_media, list_media_downloads, media_download_file
44
45
  from hermes_ui.notifications import clear_notifications, list_notifications, mark_all_notifications_read, mark_notification_read, notification_event_catalog, notification_preferences, record_notification, reconcile_persisted_notifications, save_notification_preferences, unread_notification_count
45
46
  from hermes_ui.logs import list_logs, logs_to_rows
46
47
  from hermes_ui.languages import LANGUAGE_CODES, VIDEO_LANGUAGE_CODES, LANGUAGE_FLAG_DATA_URIS, language_code, language_label, ui_language_menu_label, ui_text, video_language_label, video_language_options
47
- from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
48
+ from hermes_ui.api_key_tests import test_apify_credentials, test_kaggle_credentials, test_material_source_credentials, test_media_provider_card, test_nano_banana_credentials, test_postiz_credentials, test_telegram_credentials, test_tiktok_credentials, test_upload_post_credentials, test_voice_provider
48
49
  from hermes_ui.tutorials import tutorial_body, tutorial_caption, tutorial_title
49
50
 
50
51
  from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
@@ -717,6 +718,75 @@ def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str
717
718
  return payload
718
719
 
719
720
 
721
+ def generate_thumbnail_for_ui(
722
+ settings: dict[str, Any],
723
+ channel: dict[str, Any],
724
+ topic: str,
725
+ *,
726
+ title: str = "",
727
+ topic_source: str = "manual",
728
+ ) -> dict[str, Any]:
729
+ """Generate exactly one thumbnail brief for an existing video topic/title."""
730
+ topic = str(topic or "").strip()
731
+ if not topic:
732
+ raise CreativeGenerationError("É necessário um tópico antes de gerar a thumbnail.")
733
+ variant = generate_thumbnail_prompt(
734
+ settings,
735
+ channel,
736
+ topic,
737
+ blueprint=blueprint_for_channel(channel),
738
+ language=str(channel.get("language") or "Português"),
739
+ )
740
+ return {
741
+ "topic": topic,
742
+ "title": str(title or topic).strip(),
743
+ "topic_source": topic_source or "manual",
744
+ "thumbnail_variant": variant,
745
+ "thumbnail_variants": [variant],
746
+ "thumbnail_prompt": variant.get("image_prompt", ""),
747
+ "thumbnail_text": variant.get("overlay_text", ""),
748
+ "thumbnail_status": "prompt_ready",
749
+ "title_candidates": [],
750
+ "ai_generation": {"thumbnail": variant},
751
+ }
752
+
753
+
754
+ def generate_thumbnail_variants_for_ui(
755
+ settings: dict[str, Any],
756
+ channel: dict[str, Any],
757
+ topic: str,
758
+ count: int,
759
+ *,
760
+ title: str = "",
761
+ topic_source: str = "manual",
762
+ ) -> dict[str, Any]:
763
+ """Generate one independent thumbnail brief for each video in a batch."""
764
+ count = max(1, min(int(count or 1), 100))
765
+ variants: list[dict[str, Any]] = []
766
+ for _index in range(count):
767
+ generated = generate_thumbnail_for_ui(
768
+ settings,
769
+ channel,
770
+ topic,
771
+ title=title,
772
+ topic_source=topic_source,
773
+ )
774
+ variants.append(dict(generated["thumbnail_variant"]))
775
+ first = variants[0]
776
+ return {
777
+ "topic": topic,
778
+ "title": str(title or topic).strip(),
779
+ "topic_source": topic_source or "manual",
780
+ "thumbnail_variant": first,
781
+ "thumbnail_variants": variants,
782
+ "thumbnail_prompt": first.get("image_prompt", ""),
783
+ "thumbnail_text": first.get("overlay_text", ""),
784
+ "thumbnail_status": "prompt_ready",
785
+ "title_candidates": [],
786
+ "ai_generation": {"thumbnail_variants": variants},
787
+ }
788
+
789
+
720
790
  def valid_hhmm(value: str) -> bool:
721
791
  return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
722
792
 
@@ -776,14 +846,14 @@ def render_video_generation_settings(
776
846
  key=f"{prefix}_script_language",
777
847
  )
778
848
  with subject_cols[1]:
779
- with st.expander("Advanced Script Settings", expanded=False):
780
- settings["script_structure_notes"] = st.text_area(
781
- "Estrutura e notas opcionais",
782
- value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
783
- key=f"{prefix}_script_structure_notes",
784
- height=100,
785
- placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
786
- )
849
+ st.markdown("**Advanced Script Settings**")
850
+ settings["script_structure_notes"] = st.text_area(
851
+ "Estrutura e notas opcionais",
852
+ value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
853
+ key=f"{prefix}_script_structure_notes",
854
+ height=100,
855
+ placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
856
+ )
787
857
  settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
788
858
  settings["video_script"] = st.text_area(
789
859
  "Video Script (Optional)",
@@ -825,66 +895,69 @@ def render_video_generation_settings(
825
895
  st.error(st.session_state[f"{prefix}_save_draft_error"])
826
896
 
827
897
  if "Configurações de vídeo" in visible_sections:
828
- st.markdown("### Video Settings")
829
- video_cols = st.columns(2)
830
- with video_cols[0]:
831
- settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
832
- if settings["video_source"] == "full_ia":
833
- settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
834
- else:
835
- settings["style_ia"] = ""
836
- settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
837
- settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
838
- settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
839
- settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
840
- with video_cols[1]:
841
- settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
842
- settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
843
- settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
844
- settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
898
+ with st.expander("Configurações de vídeo", expanded=False):
899
+ st.markdown("### Video Settings")
900
+ video_cols = st.columns(2)
901
+ with video_cols[0]:
902
+ settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
903
+ if settings["video_source"] == "full_ia":
904
+ settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
905
+ else:
906
+ settings["style_ia"] = ""
907
+ settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
908
+ settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
909
+ settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
910
+ settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
911
+ with video_cols[1]:
912
+ settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
913
+ settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
914
+ settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
915
+ settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
845
916
 
846
917
  if "Configurações de áudio" in visible_sections:
847
- st.markdown("### Audio Settings")
848
- audio_cols = st.columns(2)
849
- with audio_cols[0]:
850
- settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
851
- settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
852
- if channel is not None:
853
- channel_id = str(channel.get("id") or channel.get("name") or "")
854
- channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
855
- channel_state_key = f"{prefix}_voice_channel_id"
856
- if st.session_state.get(channel_state_key) != channel_id:
857
- st.session_state[f"{prefix}_voice"] = channel_voice
858
- st.session_state[channel_state_key] = channel_id
859
- current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
860
- voice_options = voice_catalog(current_voice)
861
- settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
862
- volume_speed_cols = st.columns(2)
863
- with volume_speed_cols[0]:
864
- settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
865
- with volume_speed_cols[1]:
866
- settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
867
- st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
868
- with audio_cols[1]:
869
- settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
870
- settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
918
+ with st.expander("Configurações de áudio", expanded=False):
919
+ st.markdown("### Audio Settings")
920
+ audio_cols = st.columns(2)
921
+ with audio_cols[0]:
922
+ settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
923
+ settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
924
+ if channel is not None:
925
+ channel_id = str(channel.get("id") or channel.get("name") or "")
926
+ channel_voice = str(channel.get("default_voice") or channel.get("voice") or "").strip()
927
+ channel_state_key = f"{prefix}_voice_channel_id"
928
+ if st.session_state.get(channel_state_key) != channel_id:
929
+ st.session_state[f"{prefix}_voice"] = channel_voice
930
+ st.session_state[channel_state_key] = channel_id
931
+ current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
932
+ voice_options = voice_catalog(current_voice)
933
+ settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
934
+ volume_speed_cols = st.columns(2)
935
+ with volume_speed_cols[0]:
936
+ settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
937
+ with volume_speed_cols[1]:
938
+ settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
939
+ st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
940
+ with audio_cols[1]:
941
+ settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
942
+ settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
871
943
 
872
944
  if "Configurações de legendas" in visible_sections:
873
- st.markdown("### Subtitle Settings")
874
- subtitle_cols = st.columns(2)
875
- with subtitle_cols[0]:
876
- settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
877
- settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
878
- settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
879
- settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
880
- settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
881
- settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
882
- settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
883
- with subtitle_cols[1]:
884
- settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
885
- settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
886
- settings["subtitle_outline_width"] = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=1.5, step=0.25, key=f"{prefix}_subtitle_outline_width")
887
- st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
945
+ with st.expander("Configurações de legendas", expanded=False):
946
+ st.markdown("### Subtitle Settings")
947
+ subtitle_cols = st.columns(2)
948
+ with subtitle_cols[0]:
949
+ settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
950
+ settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
951
+ settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
952
+ settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
953
+ settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
954
+ settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
955
+ settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
956
+ with subtitle_cols[1]:
957
+ settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
958
+ settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
959
+ settings["subtitle_outline_width"] = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=1.5, step=0.25, key=f"{prefix}_subtitle_outline_width")
960
+ st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
888
961
  return settings
889
962
 
890
963
 
@@ -2120,43 +2193,42 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2120
2193
  st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
2121
2194
  music_path = st.session_state.get(f"{prefix}_music_path", "")
2122
2195
 
2196
+ quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel", key=f"{prefix}_quantity")
2123
2197
  payloads: dict[str, dict[str, Any]] = {}
2124
2198
  if mode == "general":
2125
2199
  existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
2126
2200
  payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
2127
- if st.button("Gerar títulos e thumbnails para todos os canais", key=f"{prefix}_generate_general_creative", use_container_width=True):
2128
- settings = read_json("settings.json", {})
2129
- new_payloads: dict[str, dict[str, Any]] = {}
2130
- errors: list[str] = []
2131
- with st.spinner("A gerar títulos e thumbnails independentes por canal…"):
2132
- for channel in all_channels:
2133
- try:
2134
- topic_result = existing_topics.get(channel["id"])
2135
- if not topic_result:
2136
- topic_result = generate_topic_for_ui(settings, channel, general_context)
2137
- generated = generate_creative_for_ui(settings, channel, topic_result["topic"], topic_source="llm")
2138
- generated["ai_generation"]["topic"] = topic_result
2139
- new_payloads[channel["id"]] = generated
2140
- except CreativeGenerationError as exc:
2141
- errors.append(f"{channel.get('name', 'Canal')}: {exc}")
2142
- if errors:
2143
- for error in errors:
2144
- st.error(error)
2145
- else:
2146
- st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
2147
- st.session_state[f"{prefix}_general_payloads"] = new_payloads
2148
- payloads = new_payloads
2149
- st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
2201
+ with st.expander("Gerar Thumbnail com IA", expanded=False):
2202
+ if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", use_container_width=True):
2203
+ settings = read_json("settings.json", {})
2204
+ new_payloads: dict[str, dict[str, Any]] = {}
2205
+ errors: list[str] = []
2206
+ with st.spinner("A gerar uma thumbnail independente por vídeo…"):
2207
+ for channel in all_channels:
2208
+ try:
2209
+ topic_result = existing_topics.get(channel["id"])
2210
+ topic = str((topic_result or {}).get("topic") or "").strip()
2211
+ if not topic:
2212
+ errors.append(f"{channel.get('name', 'Canal')}: gere primeiro o tópico individual do canal.")
2213
+ continue
2214
+ generated = generate_thumbnail_for_ui(settings, channel, topic, title=topic, topic_source=str((topic_result or {}).get("topic_source") or "llm"))
2215
+ generated["ai_generation"]["topic"] = topic_result
2216
+ new_payloads[channel["id"]] = generated
2217
+ except CreativeGenerationError as exc:
2218
+ errors.append(f"{channel.get('name', 'Canal')}: {exc}")
2219
+ if errors:
2220
+ for error in errors:
2221
+ st.error(error)
2222
+ else:
2223
+ st.session_state[f"{prefix}_general_payloads"] = new_payloads
2224
+ payloads = new_payloads
2225
+ st.success(f"Thumbnail pronta para {len(new_payloads)} vídeo(s).")
2150
2226
  payloads = st.session_state.get(f"{prefix}_general_payloads", payloads)
2151
2227
  for channel in all_channels:
2152
2228
  payload = payloads.get(channel["id"])
2153
2229
  if not payload:
2154
2230
  continue
2155
- with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
2156
- title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
2157
- if title_options:
2158
- 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"{prefix}_general_title_{channel['id']}")
2159
- payload["title"] = selected_title
2231
+ with st.expander(f"{channel.get('name', 'Canal')} — thumbnail", expanded=False):
2160
2232
  variants = payload.get("thumbnail_variants", [])
2161
2233
  if variants:
2162
2234
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
@@ -2197,83 +2269,87 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2197
2269
  st.caption("A imagem ainda não foi gerada. Configure a API key em Configuração API > API Keys.")
2198
2270
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
2199
2271
  else:
2200
- topic_for_creative = str(generation_settings.get("video_subject") or "").strip()
2201
- if st.button("Gerar títulos e thumbnails com IA", key=f"{prefix}_generate_creative", use_container_width=True):
2202
- if selected_one is None:
2203
- st.error("Seleccione primeiro um canal.")
2204
- else:
2205
- try:
2206
- if not topic_for_creative:
2207
- topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one)
2208
- topic_for_creative = str(topic_result["topic"]).strip()
2209
- st.session_state[f"{prefix}_topic"] = topic_for_creative
2210
- st.session_state[f"{prefix}_topic_meta"] = topic_result
2211
- generated = generate_creative_for_ui(
2212
- read_json("settings.json", {}),
2213
- selected_one,
2214
- topic_for_creative,
2215
- topic_source=_video_topic_source(topic_for_creative, prefix),
2216
- )
2217
-
2218
- st.session_state[f"{prefix}_creative_payload"] = generated
2219
- st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
2220
- st.rerun()
2221
- except CreativeGenerationError as exc:
2222
- st.error(str(exc))
2223
- payload = st.session_state.get(f"{prefix}_creative_payload")
2224
- if payload:
2225
- st.subheader("Título e Thumbnail automáticos")
2226
- title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
2227
- if title_options:
2228
- 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"{prefix}_title_choice")
2229
- payload["title"] = selected_title
2230
- with st.expander(f"Ver {len(title_options)} candidatos de título"):
2231
- st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
2232
- variants = payload.get("thumbnail_variants", [])
2233
- if variants:
2234
- labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
2235
- selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
2236
- variant_index = labels.index(selected_variant_label)
2237
- variant = variants[variant_index]
2238
- payload["thumbnail_variant"] = variant
2239
- payload["thumbnail_prompt"] = variant.get("image_prompt", "")
2240
- payload["thumbnail_text"] = variant.get("overlay_text", "")
2241
- st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
2242
- st.code(variant.get("image_prompt", ""), language="text")
2243
- thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2244
- if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
2272
+ with st.expander("Gerar Thumbnail com IA", expanded=False):
2273
+ topic_for_thumbnail = str(generation_settings.get("video_subject") or "").strip()
2274
+ if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", use_container_width=True):
2275
+ if selected_one is None:
2276
+ st.error("Seleccione primeiro um canal.")
2277
+ elif not topic_for_thumbnail:
2278
+ st.error("Preencha o Video Subject antes de gerar a thumbnail.")
2279
+ else:
2245
2280
  try:
2246
- thumbnail_path = str(
2247
- generate_thumbnail_image(
2248
- read_json("settings.json", {}),
2249
- variant.get("image_prompt", ""),
2250
- topic=str(payload.get("topic") or ""),
2251
- variant_index=variant_index,
2252
- lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
2253
- lettering_prompt=str(variant.get("lettering_prompt") or ""),
2254
- )
2281
+ existing_payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
2282
+ existing_title = str(existing_payload.get("title") or topic_for_thumbnail).strip()
2283
+ generated = generate_thumbnail_variants_for_ui(
2284
+ read_json("settings.json", {}),
2285
+ selected_one,
2286
+ topic_for_thumbnail,
2287
+ int(quantity if mode == "same_channel" else 1),
2288
+ title=existing_title,
2289
+ topic_source=_video_topic_source(topic_for_thumbnail, prefix),
2255
2290
  )
2256
- variant["image_path"] = thumbnail_path
2257
- payload["thumbnail_path"] = thumbnail_path
2258
- payload["thumbnail_status"] = "generated"
2259
- st.session_state[f"{prefix}_creative_payload"] = payload
2260
- record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
2261
- st.success("Thumbnail gerada com Nano Banana.")
2291
+ generated = {**existing_payload, **generated}
2292
+ generated["topic"] = topic_for_thumbnail
2293
+ generated["title"] = existing_title
2294
+ generated["title_candidates"] = existing_payload.get("title_candidates", [])
2295
+ st.session_state[f"{prefix}_creative_payload"] = generated
2296
+ st.success(f"Thumbnail pronta para {int(quantity if mode == 'same_channel' else 1)} vídeo(s); o título existente foi preservado.")
2262
2297
  st.rerun()
2263
- except ThumbnailGenerationError as exc:
2298
+ except CreativeGenerationError as exc:
2264
2299
  st.error(str(exc))
2265
- if thumbnail_path and Path(thumbnail_path).is_file():
2266
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
2267
- payload["thumbnail_path"] = thumbnail_path
2268
- payload["thumbnail_status"] = "generated"
2269
- else:
2270
- st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
2271
- st.session_state[f"{prefix}_creative_payload"] = payload
2300
+ payload = st.session_state.get(f"{prefix}_creative_payload")
2301
+ if payload:
2302
+ st.subheader("Thumbnail automática")
2303
+ st.caption(f"Título preservado: {payload.get('title') or payload.get('topic') or 'Sem título'}")
2304
+ title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
2305
+ if title_options:
2306
+ 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"{prefix}_title_choice")
2307
+ payload["title"] = selected_title
2308
+ with st.expander(f"Ver {len(title_options)} candidatos de título"):
2309
+ st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
2310
+ variants = payload.get("thumbnail_variants", [])
2311
+ if variants:
2312
+ labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
2313
+ selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
2314
+ variant_index = labels.index(selected_variant_label)
2315
+ variant = variants[variant_index]
2316
+ payload["thumbnail_variant"] = variant
2317
+ payload["thumbnail_prompt"] = variant.get("image_prompt", "")
2318
+ payload["thumbnail_text"] = variant.get("overlay_text", "")
2319
+ st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
2320
+ st.code(variant.get("image_prompt", ""), language="text")
2321
+ thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
2322
+ if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
2323
+ try:
2324
+ thumbnail_path = str(
2325
+ generate_thumbnail_image(
2326
+ read_json("settings.json", {}),
2327
+ variant.get("image_prompt", ""),
2328
+ topic=str(payload.get("topic") or ""),
2329
+ variant_index=variant_index,
2330
+ lettering_text=str(variant.get("overlay_text") or payload.get("thumbnail_text") or ""),
2331
+ lettering_prompt=str(variant.get("lettering_prompt") or ""),
2332
+ )
2333
+ )
2334
+ variant["image_path"] = thumbnail_path
2335
+ payload["thumbnail_path"] = thumbnail_path
2336
+ payload["thumbnail_status"] = "generated"
2337
+ st.session_state[f"{prefix}_creative_payload"] = payload
2338
+ record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
2339
+ st.success("Thumbnail gerada com Nano Banana.")
2340
+ st.rerun()
2341
+ except ThumbnailGenerationError as exc:
2342
+ st.error(str(exc))
2343
+ if thumbnail_path and Path(thumbnail_path).is_file():
2344
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
2345
+ payload["thumbnail_path"] = thumbnail_path
2346
+ payload["thumbnail_status"] = "generated"
2347
+ else:
2348
+ st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
2349
+ st.session_state[f"{prefix}_creative_payload"] = payload
2272
2350
 
2273
2351
  st.session_state[f"{prefix}_generation_settings"] = dict(generation_settings)
2274
2352
  with st.form(f"{prefix}_form"):
2275
-
2276
- quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
2277
2353
  language = generation_settings["script_language"]
2278
2354
  fmt = generation_settings["video_format"]
2279
2355
  submitted = st.form_submit_button("Criar tarefas", type="primary")
@@ -3625,21 +3701,28 @@ def render_automation():
3625
3701
  for channel in channels:
3626
3702
  channel_id = channel["id"]
3627
3703
  with st.container(border=True):
3628
- cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
3629
- with cols[0]:
3704
+ header_cols = st.columns([0.55, 2.35, 1.35, 1.5, 1.35])
3705
+ with header_cols[0]:
3630
3706
  if channel.get("thumbnail_url"):
3631
3707
  st.image(channel["thumbnail_url"], width=48)
3632
3708
  else:
3633
3709
  st.markdown("### YT")
3634
- with cols[1]:
3710
+ with header_cols[1]:
3635
3711
  st.write(f"**{channel.get('name', 'Sem nome')}**")
3636
3712
  st.caption(channel.get("handle") or channel.get("url") or "sem URL")
3713
+ with header_cols[2]:
3714
+ enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
3715
+ with header_cols[3]:
3716
+ schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
3637
3717
  blueprint_ids, blueprint_labels, current_blueprint, voice_options, current_voice = channel_default_options(channel)
3638
- default_cols = st.columns(4, gap="small")
3718
+ default_cols = st.columns([1.15, 1.15, 1.5, 1.7, 1.35], gap="small")
3639
3719
  with default_cols[0]:
3640
3720
  st.markdown("**Idioma Padrão**")
3641
3721
  st.caption(language_label(channel.get("language") or "pt"))
3642
3722
  with default_cols[1]:
3723
+ st.markdown("**Nicho Padrão**")
3724
+ st.caption(channel_niche_label(channel))
3725
+ with default_cols[2]:
3643
3726
  automation_blueprint = st.selectbox(
3644
3727
  "Blueprint Padrão",
3645
3728
  blueprint_ids,
@@ -3647,9 +3730,6 @@ def render_automation():
3647
3730
  format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"),
3648
3731
  key=f"automation_blueprint_{channel_id}",
3649
3732
  )
3650
- with default_cols[2]:
3651
- st.markdown("**Nicho Padrão**")
3652
- st.caption(channel_niche_label(channel))
3653
3733
  with default_cols[3]:
3654
3734
  automation_voice = st.selectbox(
3655
3735
  "Narrador/Voz Padrão",
@@ -3658,11 +3738,7 @@ def render_automation():
3658
3738
  format_func=lambda item: item or "Sem voz padrão",
3659
3739
  key=f"automation_voice_{channel_id}",
3660
3740
  )
3661
- with cols[2]:
3662
- enabled = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
3663
- with cols[3]:
3664
- schedule_time = st.text_input("Horário (HH:MM)", value=channel.get("automation_time", "00:00"), key=f"automation_time_{channel_id}")
3665
- with cols[4]:
3741
+ with default_cols[4]:
3666
3742
  if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
3667
3743
  if not valid_hhmm(schedule_time):
3668
3744
  st.error("Use o formato HH:MM, por exemplo 08:30.")
@@ -5008,6 +5084,139 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
5008
5084
  st.rerun()
5009
5085
 
5010
5086
 
5087
+ def _media_card_config_status(card: dict[str, Any]) -> tuple[str, str]:
5088
+ definition = media_provider_definition(card.get("provider"))
5089
+ if definition.local:
5090
+ if not str(card.get("base_url") or "").strip():
5091
+ return "missing", "Endpoint local em falta"
5092
+ return "local", "Local / sem API key"
5093
+ if definition.requires_api_key and not str(card.get("api_key") or "").strip():
5094
+ return "missing", "Missing key"
5095
+ if not str(card.get("base_url") or "").strip():
5096
+ return "missing", "Missing Base URL"
5097
+ if not str(card.get("model") or "").strip() and card.get("supports_image") and card.get("provider") not in {"cloudflare_workers_ai"}:
5098
+ return "missing", "Missing model"
5099
+ return "ready", "Configured"
5100
+
5101
+
5102
+ def _persist_media_cards(settings: dict[str, Any], cards: list[dict[str, Any]], image_active_id: str = "", video_active_id: str = "") -> dict[str, Any]:
5103
+ updated = apply_media_provider_cards_to_settings(settings, cards, image_active_id, video_active_id)
5104
+ write_json("settings.json", updated)
5105
+ settings.update(updated)
5106
+ return updated
5107
+
5108
+
5109
+ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str, Any]], index: int, *, embedded: bool = False) -> None:
5110
+ card = normalize_media_card(cards[index], index)
5111
+ cards[index] = card
5112
+ card_id = str(card["id"])
5113
+ definition = media_provider_definition(card.get("provider"))
5114
+ with st.container(border=True):
5115
+ header_cols = st.columns([3.2, 1.2])
5116
+ with header_cols[0]:
5117
+ st.subheader(definition.label)
5118
+ if definition.description:
5119
+ st.caption(definition.description)
5120
+ with header_cols[1]:
5121
+ status_kind, status_label = _media_card_config_status(card)
5122
+ _api_status_badge(status_label, status_kind)
5123
+ card_form = nullcontext() if embedded else st.form(f"media_card_form_{card_id}")
5124
+ with card_form:
5125
+ key_col, model_col = st.columns(2)
5126
+ with key_col:
5127
+ api_key = str(card.get("api_key") or "")
5128
+ if definition.requires_api_key:
5129
+ api_key = st.text_input("API key", value=api_key, type="password", key=f"media_card_{card_id}_api_key")
5130
+ else:
5131
+ st.caption("Este provider não exige API key.")
5132
+ api_key = ""
5133
+ with model_col:
5134
+ model = st.text_input("Modelo", value=str(card.get("model") or ""), help="ID do modelo ou rota usada pelo provider.", key=f"media_card_{card_id}_model")
5135
+ base_url = st.text_input("Base URL", value=str(card.get("base_url") or definition.default_base_url), key=f"media_card_{card_id}_base_url")
5136
+ extra_values: dict[str, str] = {}
5137
+ if definition.extra_fields:
5138
+ extra_cols = st.columns(len(definition.extra_fields))
5139
+ for extra_col, field_name in zip(extra_cols, definition.extra_fields):
5140
+ with extra_col:
5141
+ extra_values[field_name] = st.text_input(field_name.replace("_", " ").title(), value=str(card.get(field_name) or ""), key=f"media_card_{card_id}_{field_name}")
5142
+ status_cols = st.columns(4)
5143
+ with status_cols[0]:
5144
+ enabled = st.checkbox("Provider activo", value=bool(card.get("enabled", True)), key=f"media_card_{card_id}_enabled")
5145
+ with status_cols[1]:
5146
+ supports_image = st.checkbox("Pool Imagem", value=bool(card.get("supports_image", definition.supports_image)), key=f"media_card_{card_id}_image")
5147
+ with status_cols[2]:
5148
+ supports_video = st.checkbox("Pool Vídeo", value=bool(card.get("supports_video", definition.supports_video)), key=f"media_card_{card_id}_video")
5149
+ with status_cols[3]:
5150
+ priority = st.number_input("Prioridade", min_value=0, max_value=999, value=int(card.get("priority", index)), step=1, key=f"media_card_{card_id}_priority")
5151
+ action_cols = st.columns(3)
5152
+ with action_cols[0]:
5153
+ test_clicked = st.form_submit_button("Testar Chamada API", use_container_width=True, key=f"media_card_{card_id}_test")
5154
+ with action_cols[1]:
5155
+ save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"media_card_{card_id}_save")
5156
+ with action_cols[2]:
5157
+ remove_clicked = st.form_submit_button("Remover provider", use_container_width=True, key=f"media_card_{card_id}_remove")
5158
+ edited = dict(card)
5159
+ edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "supports_image": bool(supports_image), "supports_video": bool(supports_video), "priority": int(priority), **extra_values})
5160
+ cards[index] = edited
5161
+ if test_clicked:
5162
+ result = test_media_provider_card(edited)
5163
+ edited["test_result"] = stamp_test_result(result)
5164
+ _persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5165
+ elif save_clicked:
5166
+ _persist_media_cards(settings, cards, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5167
+ st.success("Cartão de imagem/vídeo guardado.")
5168
+ st.rerun()
5169
+ elif remove_clicked:
5170
+ remaining = [item for item in cards if str(item.get("id")) != card_id]
5171
+ _persist_media_cards(settings, remaining, str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or ""), str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or ""))
5172
+ st.success("Provider de imagem/vídeo removido.")
5173
+ st.rerun()
5174
+ saved_test = edited.get("test_result") or card.get("test_result")
5175
+ if isinstance(saved_test, dict) and saved_test.get("message"):
5176
+ if saved_test.get("status") == "success":
5177
+ st.success("Último teste: API Key OK")
5178
+ else:
5179
+ st.error(f"Último teste: {saved_test['message']}")
5180
+
5181
+
5182
+ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = False) -> None:
5183
+ migrated, changed = ensure_media_provider_cards(settings)
5184
+ cards = [dict(item) for item in migrated.get(MEDIA_CARDS_KEY, [])]
5185
+ if changed:
5186
+ settings.update(migrated)
5187
+ write_json("settings.json", settings)
5188
+ with st.expander("Imagem e Video", expanded=False):
5189
+ st.caption("Configure providers de imagem e vídeo em cartões independentes. O router usa apenas o pool correspondente e faz failover entre providers activos.")
5190
+ image_cards = [card for card in cards if card.get("supports_image")]
5191
+ video_cards = [card for card in cards if card.get("supports_video")]
5192
+ selector_cols = st.columns(3)
5193
+ image_options = [""] + [str(card.get("id")) for card in image_cards]
5194
+ video_options = [""] + [str(card.get("id")) for card in video_cards]
5195
+ with selector_cols[0]:
5196
+ image_active_id = st.selectbox("Provider principal de imagem", image_options, index=image_options.index(str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_IMAGE_ACTIVE_CARD_KEY) or "") in image_options else 0, format_func=lambda value: "Automático / primeiro activo" if not value else next((media_provider_definition(card.get("provider")).label for card in image_cards if str(card.get("id")) == value), value), key="media_image_active_selector")
5197
+ with selector_cols[1]:
5198
+ video_active_id = st.selectbox("Provider principal de vídeo", video_options, index=video_options.index(str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "") in video_options else 0, format_func=lambda value: "Não usar pool externo" if not value else next((media_provider_definition(card.get("provider")).label for card in video_cards if str(card.get("id")) == value), value), key="media_video_active_selector")
5199
+ with selector_cols[2]:
5200
+ video_pool_enabled = st.checkbox("Usar pool de vídeo externo", value=bool(settings.get("media_video_pool_enabled", False)), key="media_video_pool_enabled_ui")
5201
+ if st.button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection") if not embedded else st.form_submit_button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection"):
5202
+ settings["media_video_pool_enabled"] = bool(video_pool_enabled)
5203
+ _persist_media_cards(settings, cards, image_active_id, video_active_id)
5204
+ st.success("Selecção dos pools guardada.")
5205
+ st.rerun()
5206
+ for index in range(len(cards)):
5207
+ _render_media_provider_card(settings, cards, index, embedded=embedded)
5208
+ st.divider()
5209
+ st.markdown("**Adicionar provider de imagem/vídeo**")
5210
+ provider_codes = [item["code"] for item in media_provider_catalog()]
5211
+ provider_to_add = st.selectbox("Provider de media", provider_codes, format_func=lambda value: media_provider_definition(value).label, key="media_new_provider_choice")
5212
+ add_clicked = st.form_submit_button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card") if embedded else st.button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card")
5213
+ if add_clicked:
5214
+ cards.append(new_media_card(provider_to_add, card_id=f"media-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
5215
+ _persist_media_cards(settings, cards, image_active_id, video_active_id)
5216
+ st.success("Novo provider de imagem/vídeo adicionado.")
5217
+ st.rerun()
5218
+
5219
+
5011
5220
  def render_settings():
5012
5221
  st.title("Configuração API")
5013
5222
  st.caption("Configuração das APIs, providers, serviços e ferramentas técnicas usados pelo Thunderbolt. As credenciais ficam no storage local e não são enviadas para o GitHub.")
@@ -5068,23 +5277,18 @@ def render_settings():
5068
5277
 
5069
5278
  render_llm_provider_cards(settings, embedded=True)
5070
5279
 
5071
- with st.expander("Nano Banana — geração de thumbnails", expanded=False):
5072
- st.caption("A Nano Banana gera a imagem final das thumbnails a partir da variante escolhida. A chave é guardada apenas no storage local e é distinta da chave do Gemini usado como LLM textual.")
5073
- nano_cols = st.columns(2)
5074
- with nano_cols[0]:
5075
- gemini_image_api_key = text_setting("Nano Banana API key", "gemini_image_api_key", secret=True, help_text="Chave criada no Google AI Studio para a API Gemini. Nunca é incluída no código, logs ou pacote.")
5076
- gemini_image_model = st.selectbox("Modelo Nano Banana", ["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"], index=["gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"].index(str(settings.get("gemini_image_model") or "gemini-3.1-flash-image")) if str(settings.get("gemini_image_model") or "gemini-3.1-flash-image") in {"gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-2.5-flash-image"} else 0)
5077
- with nano_cols[1]:
5078
- gemini_image_aspect_ratio = st.selectbox("Proporção da thumbnail", ["16:9", "9:16", "1:1", "4:5"], index=["16:9", "9:16", "1:1", "4:5"].index(str(settings.get("gemini_image_aspect_ratio") or "16:9")) if str(settings.get("gemini_image_aspect_ratio") or "16:9") in {"16:9", "9:16", "1:1", "4:5"} else 0)
5079
- gemini_image_size = st.selectbox("Tamanho da imagem", ["1K", "2K", "4K"], index=["1K", "2K", "4K"].index(str(settings.get("gemini_image_size") or "1K")) if str(settings.get("gemini_image_size") or "1K") in {"1K", "2K", "4K"} else 0)
5080
- _render_credential_status(gemini_image_api_key)
5081
- _render_api_test_control(
5082
- settings,
5083
- "nano_banana",
5084
- lambda: test_nano_banana_credentials(gemini_image_api_key, gemini_image_model),
5085
- widget_key="api_test_nano_banana",
5086
- )
5087
-
5280
+ with st.container(border=True):
5281
+ st.markdown("### Limite LLM NVIDIA NIM")
5282
+ st.caption("Quando ligado, limita apenas cartões cujo endpoint é integrate.api.nvidia.com a 40 pedidos por janela de 60 segundos, partilhados entre UI, pipeline e automações.")
5283
+ rpm_cols = st.columns(3)
5284
+ with rpm_cols[0]:
5285
+ llm_rpm_limit_enabled = st.checkbox("Activar limitador NVIDIA NIM 40 RPM", value=bool(settings.get("llm_rpm_limit_enabled", False)), key="settings_llm_rpm_limit_enabled")
5286
+ with rpm_cols[1]:
5287
+ llm_rpm_limit = st.number_input("Pedidos por janela", min_value=1, max_value=1000, value=int(settings.get("llm_rpm_limit", 40)), step=1, key="settings_llm_rpm_limit")
5288
+ with rpm_cols[2]:
5289
+ llm_rpm_window_seconds = st.number_input("Janela (segundos)", min_value=1, max_value=3600, value=int(settings.get("llm_rpm_window_seconds", 60)), step=1, key="settings_llm_rpm_window_seconds")
5290
+
5291
+ render_media_provider_cards(settings, embedded=True)
5088
5292
 
5089
5293
  with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=False):
5090
5294
  cols = st.columns(2)
@@ -5217,7 +5421,7 @@ def render_settings():
5217
5421
  "moneyprinter_path": moneyprinter_path,
5218
5422
  "kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
5219
5423
  "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),
5220
- "gemini_image_api_key": gemini_image_api_key, "gemini_image_model": gemini_image_model, "gemini_image_aspect_ratio": gemini_image_aspect_ratio, "gemini_image_size": gemini_image_size,
5424
+ "llm_rpm_limit_enabled": bool(llm_rpm_limit_enabled), "llm_rpm_limit": int(llm_rpm_limit), "llm_rpm_window_seconds": int(llm_rpm_window_seconds),
5221
5425
  "azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
5222
5426
  "siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
5223
5427
  "minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,