@danhachuel/thunderbolt 0.2.74 → 0.2.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +23 -13
- package/README.md +20 -14
- package/THIRD-PARTY-NOTICES.md +4 -0
- package/app/main.py +344 -211
- package/hermes_ui/material_sources.py +64 -0
- package/hermes_ui/media_downloader.py +313 -0
- package/hermes_ui/notifications.py +2 -0
- package/hermes_ui/storage.py +6 -1
- package/integrations/moneyprinter_config.py +9 -3
- package/package.json +1 -1
- package/requirements.txt +1 -0
package/app/main.py
CHANGED
|
@@ -22,7 +22,7 @@ except (OSError, json.JSONDecodeError):
|
|
|
22
22
|
|
|
23
23
|
from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
|
|
24
24
|
from hermes_ui.automation_worker import load_worker_status
|
|
25
|
-
from hermes_ui.storage import BLUEPRINTS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
|
|
25
|
+
from hermes_ui.storage import BLUEPRINTS, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
|
|
26
26
|
from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
|
|
27
27
|
from app.modules.niche_finder.core import NicheAnalysisError, run_niche_analysis
|
|
28
28
|
from app.modules.niche_finder.data_loader import DatasetError, download_kaggle_dataset
|
|
@@ -33,7 +33,9 @@ from hermes_ui.python_editor import AUDIO_EXTENSIONS, VIDEO_EXTENSIONS, PythonEd
|
|
|
33
33
|
from hermes_ui.cuts import CutsError, download_direct_video_url, generate_clips, list_generated_videos as list_cut_generated_videos, list_runs as list_cut_runs, list_video_files as list_cut_video_files, manifest_bytes as cut_manifest_bytes, store_uploaded_video, zip_run as zip_cut_run
|
|
34
34
|
from hermes_ui.mcp import detect_local_service, install_skill_locally, load_integrations, load_server_config, read_packaged_skill, save_server_config, update_integration
|
|
35
35
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
36
|
+
from hermes_ui.material_sources import material_api_keys, material_source_catalog, selected_material_source, update_material_api_keys
|
|
36
37
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
38
|
+
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
|
|
37
39
|
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
|
|
38
40
|
from hermes_ui.script_documents import list_script_documents, read_script_document, save_script_document, script_storage_path
|
|
39
41
|
from hermes_ui.script_generation import generate_script_document
|
|
@@ -2110,6 +2112,120 @@ def render_edit_placeholder(page_title: str, description: str):
|
|
|
2110
2112
|
st.info("Esta aba está reservada para desenvolvimento futuro e ainda não executa nenhuma operação.")
|
|
2111
2113
|
|
|
2112
2114
|
|
|
2115
|
+
def render_media_download():
|
|
2116
|
+
st.title("Download Mídia")
|
|
2117
|
+
st.caption("Baixe vídeos e áudio de URLs públicas através da API oficial do yt-dlp. Use esta ferramenta apenas com conteúdo que tem autorização para descarregar e utilizar.")
|
|
2118
|
+
st.markdown("Baseado em [yt-dlp](https://github.com/yt-dlp/yt-dlp), um downloader open source para vídeo e áudio.")
|
|
2119
|
+
dependency = dependency_status()
|
|
2120
|
+
if not dependency["yt_dlp"]:
|
|
2121
|
+
st.warning("yt-dlp não está instalado neste ambiente. Execute a instalação das dependências do Thunderbolt antes de iniciar um download.")
|
|
2122
|
+
st.info("A combinação de streams, conversão de áudio e incorporação de metadados pode exigir FFmpeg. Downloads longos permanecem nesta página até terminarem.")
|
|
2123
|
+
|
|
2124
|
+
with st.form("media_download_form"):
|
|
2125
|
+
urls_text = st.text_area("URLs para descarregar", placeholder="Uma URL http(s) por linha", height=120, key="media_download_urls")
|
|
2126
|
+
mode_label = st.radio("Tipo de mídia", ["Vídeo", "Áudio"], horizontal=True, key="media_download_mode")
|
|
2127
|
+
option_cols = st.columns(3)
|
|
2128
|
+
with option_cols[0]:
|
|
2129
|
+
if mode_label == "Vídeo":
|
|
2130
|
+
quality_label = st.selectbox("Qualidade", list(VIDEO_QUALITY_OPTIONS), key="media_download_quality")
|
|
2131
|
+
video_container = st.selectbox("Contentor", list(VIDEO_CONTAINERS), key="media_download_container")
|
|
2132
|
+
audio_format = "mp3"
|
|
2133
|
+
else:
|
|
2134
|
+
quality_label = "Melhor qualidade"
|
|
2135
|
+
video_container = "mp4"
|
|
2136
|
+
audio_format = st.selectbox("Formato de áudio", list(AUDIO_FORMATS), key="media_download_audio_format")
|
|
2137
|
+
with option_cols[1]:
|
|
2138
|
+
allow_playlist = st.checkbox("Permitir playlist", value=False, key="media_download_allow_playlist")
|
|
2139
|
+
download_subtitles = st.checkbox("Descarregar legendas", value=False, key="media_download_subtitles")
|
|
2140
|
+
with option_cols[2]:
|
|
2141
|
+
embed_metadata = st.checkbox("Incorporar metadados", value=False, key="media_download_embed_metadata")
|
|
2142
|
+
st.caption("Playlist desactivada por padrão para evitar downloads acidentais em massa.")
|
|
2143
|
+
start_download = st.form_submit_button("Iniciar download", type="primary", use_container_width=True)
|
|
2144
|
+
|
|
2145
|
+
if start_download:
|
|
2146
|
+
progress = st.progress(0, text="A preparar o download…")
|
|
2147
|
+
progress_status = st.empty()
|
|
2148
|
+
|
|
2149
|
+
def on_progress(payload: dict[str, Any]) -> None:
|
|
2150
|
+
value = float(payload.get("progress") or 0)
|
|
2151
|
+
progress.progress(int(max(0, min(100, value))), text=f"{payload.get('status', 'processing').capitalize()} · {payload.get('current_file') or payload.get('display_url') or 'a processar'}")
|
|
2152
|
+
progress_status.caption(str(payload.get("hook_status") or payload.get("status") or "processing"))
|
|
2153
|
+
|
|
2154
|
+
try:
|
|
2155
|
+
results = download_media(
|
|
2156
|
+
urls_text,
|
|
2157
|
+
mode="video" if mode_label == "Vídeo" else "audio",
|
|
2158
|
+
quality=quality_label,
|
|
2159
|
+
container=video_container,
|
|
2160
|
+
audio_format=audio_format,
|
|
2161
|
+
allow_playlist=allow_playlist,
|
|
2162
|
+
download_subtitles=download_subtitles,
|
|
2163
|
+
embed_metadata=embed_metadata,
|
|
2164
|
+
progress_callback=on_progress,
|
|
2165
|
+
)
|
|
2166
|
+
st.session_state["media_download_last_results"] = results
|
|
2167
|
+
completed = sum(1 for item in results if item.get("status") == "completed")
|
|
2168
|
+
failed = len(results) - completed
|
|
2169
|
+
if completed:
|
|
2170
|
+
st.success(f"{completed} download(s) concluído(s) e guardado(s) em `{MEDIA_DOWNLOADS}`.")
|
|
2171
|
+
if failed:
|
|
2172
|
+
st.warning(f"{failed} download(s) terminou/terminaram com erro. Consulte o histórico abaixo.")
|
|
2173
|
+
except (MediaDownloadError, ValueError, OSError) as exc:
|
|
2174
|
+
progress.empty()
|
|
2175
|
+
st.error(str(exc))
|
|
2176
|
+
|
|
2177
|
+
latest_results = st.session_state.get("media_download_last_results", [])
|
|
2178
|
+
if latest_results:
|
|
2179
|
+
st.subheader("Resultado da última execução")
|
|
2180
|
+
for record in latest_results:
|
|
2181
|
+
with st.container(border=True):
|
|
2182
|
+
status_label = "Concluído" if record.get("status") == "completed" else "Falhou"
|
|
2183
|
+
st.write(f"**{record.get('title') or record.get('display_url') or 'Download'}** — {status_label}")
|
|
2184
|
+
st.caption(f"{record.get('display_url', 'URL não disponível')} · {record.get('mode', 'video')} · {record.get('completed_at') or record.get('created_at', '—')}")
|
|
2185
|
+
if record.get("error"):
|
|
2186
|
+
st.error(record["error"])
|
|
2187
|
+
for filename in record.get("files", []):
|
|
2188
|
+
output = media_download_file(record, str(filename))
|
|
2189
|
+
if output:
|
|
2190
|
+
st.download_button("Descarregar ficheiro", data=output.read_bytes(), file_name=output.name, mime="audio/*" if record.get("mode") == "audio" else "video/*", key=f"media_result_{record.get('operation_id')}_{filename}")
|
|
2191
|
+
|
|
2192
|
+
st.divider()
|
|
2193
|
+
st.subheader("Histórico de downloads")
|
|
2194
|
+
history = list_media_downloads()
|
|
2195
|
+
action_cols = st.columns([1, 1, 3])
|
|
2196
|
+
with action_cols[0]:
|
|
2197
|
+
if st.button("Actualizar histórico", key="media_download_refresh"):
|
|
2198
|
+
st.rerun()
|
|
2199
|
+
with action_cols[1]:
|
|
2200
|
+
clear_requested = st.button("Limpar histórico", key="media_download_clear")
|
|
2201
|
+
if clear_requested:
|
|
2202
|
+
st.session_state["media_download_confirm_clear"] = True
|
|
2203
|
+
if st.session_state.get("media_download_confirm_clear"):
|
|
2204
|
+
st.warning("Isto remove apenas o histórico, não os ficheiros guardados em storage/downloads.")
|
|
2205
|
+
confirm_cols = st.columns(2)
|
|
2206
|
+
with confirm_cols[0]:
|
|
2207
|
+
if st.button("Confirmar limpeza", type="primary", key="media_download_confirm_clear_button"):
|
|
2208
|
+
clear_media_download_history()
|
|
2209
|
+
st.session_state.pop("media_download_confirm_clear", None)
|
|
2210
|
+
st.rerun()
|
|
2211
|
+
with confirm_cols[1]:
|
|
2212
|
+
if st.button("Cancelar", key="media_download_cancel_clear_button"):
|
|
2213
|
+
st.session_state.pop("media_download_confirm_clear", None)
|
|
2214
|
+
st.rerun()
|
|
2215
|
+
if not history:
|
|
2216
|
+
st.caption("Ainda não existem downloads registados.")
|
|
2217
|
+
for record in history:
|
|
2218
|
+
status_label = {"completed": "Concluído", "failed": "Falhou", "processing": "Em processamento"}.get(str(record.get("status")), str(record.get("status") or "—"))
|
|
2219
|
+
with st.expander(f"{record.get('title') or record.get('display_url') or 'Download'} — {status_label}", expanded=False):
|
|
2220
|
+
st.caption(f"{record.get('display_url', 'URL não disponível')} · {record.get('mode', 'video')} · {record.get('created_at', '—')}")
|
|
2221
|
+
if record.get("error"):
|
|
2222
|
+
st.error(record["error"])
|
|
2223
|
+
for filename in record.get("files", []):
|
|
2224
|
+
output = media_download_file(record, str(filename))
|
|
2225
|
+
if output:
|
|
2226
|
+
st.download_button("Descarregar", data=output.read_bytes(), file_name=output.name, mime="audio/*" if record.get("mode") == "audio" else "video/*", key=f"media_history_{record.get('operation_id')}_{filename}")
|
|
2227
|
+
|
|
2228
|
+
|
|
2113
2229
|
def render_cuts():
|
|
2114
2230
|
st.title("Cortes")
|
|
2115
2231
|
st.caption("Crie clips verticais, quadrados ou horizontais a partir de vídeos longos, com um fluxo local inspirado no Clip Generator do OpenShorts.")
|
|
@@ -3123,6 +3239,51 @@ def render_google_accounts():
|
|
|
3123
3239
|
st.success("Configuração global do YouTube guardada em Contas Google.")
|
|
3124
3240
|
st.rerun()
|
|
3125
3241
|
|
|
3242
|
+
def render_material_source_api_keys(settings: dict[str, Any]) -> None:
|
|
3243
|
+
st.subheader("Fontes de materiais")
|
|
3244
|
+
st.caption("Seleccione a fonte que será usada na pipeline e guarde uma ou mais API keys. Qualidade, endpoints e parâmetros internos são definidos pelo Thunderbolt.")
|
|
3245
|
+
source_catalog = material_source_catalog()
|
|
3246
|
+
source_codes = [item["code"] for item in source_catalog] + ["local"]
|
|
3247
|
+
source_labels = {item["code"]: item["label"] for item in source_catalog} | {"local": "Ficheiros locais"}
|
|
3248
|
+
source_help = {item["code"]: item["description"] for item in source_catalog} | {"local": "Usar materiais já existentes no storage local; não requer API key."}
|
|
3249
|
+
selected_source = st.selectbox(
|
|
3250
|
+
"Fonte de materiais",
|
|
3251
|
+
source_codes,
|
|
3252
|
+
index=source_codes.index(selected_material_source(settings)) if selected_material_source(settings) in source_codes else 0,
|
|
3253
|
+
format_func=lambda value: source_labels.get(value, value),
|
|
3254
|
+
key="material_source_selector",
|
|
3255
|
+
)
|
|
3256
|
+
st.caption(source_help.get(selected_source, ""))
|
|
3257
|
+
current_keys = material_api_keys(settings, selected_source)
|
|
3258
|
+
row_key = f"material_source_key_rows_{selected_source}"
|
|
3259
|
+
row_count = max(len(current_keys), int(st.session_state.get(row_key, len(current_keys) or 1)))
|
|
3260
|
+
if selected_source == "local":
|
|
3261
|
+
st.info("A fonte local não usa API key. Os materiais devem existir na pasta configurada do storage.")
|
|
3262
|
+
if st.button("Guardar fonte local", type="primary", key="save_local_material_source"):
|
|
3263
|
+
settings["video_source"] = selected_source
|
|
3264
|
+
write_json("settings.json", settings)
|
|
3265
|
+
st.success("Fonte de materiais guardada: ficheiros locais.")
|
|
3266
|
+
st.rerun()
|
|
3267
|
+
return
|
|
3268
|
+
with st.form(f"material_api_keys_form_{selected_source}"):
|
|
3269
|
+
key_values: list[str] = []
|
|
3270
|
+
for index in range(row_count):
|
|
3271
|
+
key_values.append(st.text_input(f"API Key {index + 1}", value=current_keys[index] if index < len(current_keys) else "", type="password", key=f"material_api_key_{selected_source}_{index}"))
|
|
3272
|
+
save_keys = st.form_submit_button("Guardar fonte e chaves", type="primary", use_container_width=True)
|
|
3273
|
+
add_key = st.form_submit_button("Adicionar outra chave", use_container_width=True)
|
|
3274
|
+
if add_key:
|
|
3275
|
+
st.session_state[row_key] = row_count + 1
|
|
3276
|
+
st.rerun()
|
|
3277
|
+
if save_keys:
|
|
3278
|
+
update_material_api_keys(settings, selected_source, key_values)
|
|
3279
|
+
settings["video_source"] = selected_source
|
|
3280
|
+
write_json("settings.json", settings)
|
|
3281
|
+
st.session_state[row_key] = max(1, len(material_api_keys(settings, selected_source)))
|
|
3282
|
+
st.success(f"Fonte {source_labels.get(selected_source, selected_source)} guardada com {len(material_api_keys(settings, selected_source))} chave(s).")
|
|
3283
|
+
st.rerun()
|
|
3284
|
+
st.caption(f"{len(current_keys)} chave(s) actualmente guardada(s) para {source_labels.get(selected_source, selected_source)}. As chaves são mantidas no storage local e usadas com rotação interna.")
|
|
3285
|
+
|
|
3286
|
+
|
|
3126
3287
|
def render_settings():
|
|
3127
3288
|
st.title("Configuração API")
|
|
3128
3289
|
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.")
|
|
@@ -3140,221 +3301,191 @@ def render_settings():
|
|
|
3140
3301
|
api_keys_tab, voice_test_tab = st.tabs(["API Keys", "Teste de vozes"])
|
|
3141
3302
|
|
|
3142
3303
|
with api_keys_tab:
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
st.
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
with
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
|
|
3206
|
-
current_model_name = str(settings.get("openai_model_name", "") or "")
|
|
3207
|
-
manual_option = "__manual_model__"
|
|
3208
|
-
if cached_models:
|
|
3209
|
-
model_options = [manual_option, *cached_models]
|
|
3210
|
-
model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
|
|
3211
|
-
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")
|
|
3212
|
-
if selected_model == manual_option:
|
|
3213
|
-
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")
|
|
3304
|
+
api_service_tab, material_sources_tab = st.tabs(["Serviços e modelos", "Fontes de materiais"])
|
|
3305
|
+
with api_service_tab:
|
|
3306
|
+
with st.form("settings_form"):
|
|
3307
|
+
st.subheader("API Keys")
|
|
3308
|
+
port = st.number_input("Porta Streamlit", 1, 65535, int(settings.get("port", 3030)))
|
|
3309
|
+
moneyprinter_path = st.text_input("Pasta do motor de vídeo", settings.get("moneyprinter_path", ""), key="settings_moneyprinter_path")
|
|
3310
|
+
with st.expander("Niche Finder — execução remota no Kaggle", expanded=True):
|
|
3311
|
+
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.")
|
|
3312
|
+
kaggle_cols = st.columns(3)
|
|
3313
|
+
with kaggle_cols[0]:
|
|
3314
|
+
kaggle_username = text_setting("Kaggle Username", "kaggle_username", help_text="Nome de utilizador da sua conta Kaggle, sem @ e sem URL.")
|
|
3315
|
+
with kaggle_cols[1]:
|
|
3316
|
+
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.")
|
|
3317
|
+
with kaggle_cols[2]:
|
|
3318
|
+
kaggle_kernel_slug = text_setting("Slug da kernel", "kaggle_kernel_slug", help_text="Identificador da kernel remota, por exemplo thunderbolt-niche-finder.")
|
|
3319
|
+
|
|
3320
|
+
with st.expander("Niche Finder — execução através da Apify", expanded=True):
|
|
3321
|
+
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.")
|
|
3322
|
+
apify_cols = st.columns(4)
|
|
3323
|
+
with apify_cols[0]:
|
|
3324
|
+
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.")
|
|
3325
|
+
with apify_cols[1]:
|
|
3326
|
+
apify_actor_id = text_setting("Apify Actor ID", "apify_actor_id", help_text="Por padrão: streamers~youtube-scraper.")
|
|
3327
|
+
with apify_cols[2]:
|
|
3328
|
+
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)
|
|
3329
|
+
with apify_cols[3]:
|
|
3330
|
+
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)
|
|
3331
|
+
|
|
3332
|
+
with st.expander("Nano Banana — geração de thumbnails", expanded=True):
|
|
3333
|
+
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.")
|
|
3334
|
+
nano_cols = st.columns(2)
|
|
3335
|
+
with nano_cols[0]:
|
|
3336
|
+
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.")
|
|
3337
|
+
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)
|
|
3338
|
+
with nano_cols[1]:
|
|
3339
|
+
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)
|
|
3340
|
+
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)
|
|
3341
|
+
|
|
3342
|
+
with st.expander("LLM — providers e modelos", expanded=True):
|
|
3343
|
+
provider_options = ["moonshot", "shengsuanyun", "openai", "gemini", "deepseek", "qwen", "azure", "volcengine", "grok", "minimax", "mimo", "cloudflare", "modelscope", "aihubmix", "aimlapi", "evolink", "ollama", "oneapi", "litellm", "groq", "pollinations"]
|
|
3344
|
+
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)
|
|
3345
|
+
st.markdown("**OpenAI/ NVIDIA NIM — API key, Base URL e modelo**")
|
|
3346
|
+
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.")
|
|
3347
|
+
openai_cols = st.columns(3)
|
|
3348
|
+
with openai_cols[0]:
|
|
3349
|
+
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.")
|
|
3350
|
+
with openai_cols[1]:
|
|
3351
|
+
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")
|
|
3352
|
+
with openai_cols[2]:
|
|
3353
|
+
cached_catalog = st.session_state.get("openai_model_catalog", {})
|
|
3354
|
+
catalog_key = f"{openai_base_url.strip()}::{hashlib.sha256(openai_api_key.encode('utf-8')).hexdigest()}"
|
|
3355
|
+
cached_models = list(cached_catalog.get("models", [])) if cached_catalog.get("key") == catalog_key else []
|
|
3356
|
+
current_model_name = str(settings.get("openai_model_name", "") or "")
|
|
3357
|
+
manual_option = "__manual_model__"
|
|
3358
|
+
if cached_models:
|
|
3359
|
+
model_options = [manual_option, *cached_models]
|
|
3360
|
+
model_index = model_options.index(current_model_name) if current_model_name in model_options else 0
|
|
3361
|
+
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")
|
|
3362
|
+
if selected_model == manual_option:
|
|
3363
|
+
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")
|
|
3364
|
+
else:
|
|
3365
|
+
openai_model_name = selected_model
|
|
3214
3366
|
else:
|
|
3215
|
-
openai_model_name =
|
|
3367
|
+
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")
|
|
3368
|
+
refresh_openai_models = st.form_submit_button("Consultar/actualizar modelos NIM", use_container_width=True)
|
|
3369
|
+
if cached_catalog.get("key") == catalog_key and cached_catalog.get("error"):
|
|
3370
|
+
st.warning(str(cached_catalog["error"]))
|
|
3371
|
+
elif cached_models:
|
|
3372
|
+
st.caption(f"{len(cached_models)} modelo(s) carregado(s) a partir de {openai_base_url.rstrip('/')}/models.")
|
|
3216
3373
|
else:
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3374
|
+
st.info("Preencha a API key e clique em Consultar/actualizar modelos NIM para carregar os IDs disponíveis.")
|
|
3375
|
+
llm_fields = [
|
|
3376
|
+
("Moonshot / Kimi", "moonshot", True), ("Shengsuan Cloud", "shengsuanyun", True),
|
|
3377
|
+
("Google Gemini", "gemini", True), ("DeepSeek", "deepseek", True), ("Alibaba Qwen", "qwen", True),
|
|
3378
|
+
("Azure OpenAI", "azure", True), ("VolcEngine Ark", "volcengine", True), ("xAI Grok", "grok", True),
|
|
3379
|
+
("MiniMax", "minimax", True), ("Xiaomi MiMo", "mimo", True), ("Cloudflare AI Gateway", "cloudflare", True),
|
|
3380
|
+
("ModelScope", "modelscope", True), ("AIHubMix", "aihubmix", True), ("AIML API", "aimlapi", True),
|
|
3381
|
+
("EvoLink", "evolink", True), ("Ollama", "ollama", False), ("OneAPI", "oneapi", True),
|
|
3382
|
+
("LiteLLM", "litellm", False), ("Groq", "groq", True), ("Pollinations AI", "pollinations", True),
|
|
3383
|
+
]
|
|
3384
|
+
for label, prefix, has_key in llm_fields:
|
|
3385
|
+
st.markdown(f"**{label}**")
|
|
3386
|
+
cols = st.columns(3)
|
|
3387
|
+
with cols[0]:
|
|
3388
|
+
if has_key:
|
|
3389
|
+
settings[f"{prefix}_api_key"] = text_setting("API key", f"{prefix}_api_key", secret=True)
|
|
3390
|
+
else:
|
|
3391
|
+
settings[f"{prefix}_api_key"] = settings.get(f"{prefix}_api_key", "")
|
|
3392
|
+
with cols[1]:
|
|
3393
|
+
settings[f"{prefix}_base_url"] = text_setting("Base URL", f"{prefix}_base_url")
|
|
3394
|
+
with cols[2]:
|
|
3395
|
+
settings[f"{prefix}_model_name"] = text_setting("Model", f"{prefix}_model_name")
|
|
3396
|
+
|
|
3397
|
+
with st.expander("Voz, TTS e música — Azure Speech, restantes serviços e Suno", expanded=True):
|
|
3398
|
+
cols = st.columns(2)
|
|
3237
3399
|
with cols[0]:
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3400
|
+
azure_speech_key = text_setting("Azure Speech key", "azure_speech_key", secret=True)
|
|
3401
|
+
azure_speech_region = text_setting("Azure Speech region", "azure_speech_region")
|
|
3402
|
+
siliconflow_tts_api_key = text_setting("SiliconFlow TTS API key", "siliconflow_tts_api_key", secret=True)
|
|
3403
|
+
minimax_tts_api_key = text_setting("MiniMax TTS API key", "minimax_tts_api_key", secret=True)
|
|
3404
|
+
minimax_tts_base_url = text_setting("MiniMax TTS Base URL", "minimax_tts_base_url")
|
|
3405
|
+
minimax_tts_model_id = text_setting("MiniMax TTS model", "minimax_tts_model_id")
|
|
3406
|
+
minimax_tts_voice_id = text_setting("MiniMax TTS voice ID", "minimax_tts_voice_id")
|
|
3242
3407
|
with cols[1]:
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
st.
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
with st.expander("Publicação através do Upload-Post"):
|
|
3292
|
-
upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
|
|
3293
|
-
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
3294
|
-
upload_post_username = text_setting("Upload-Post username", "upload_post_username")
|
|
3295
|
-
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
3296
|
-
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
3297
|
-
|
|
3298
|
-
with st.expander("Postiz — API key, integração e MCP", expanded=True):
|
|
3299
|
-
st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
|
|
3300
|
-
postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
|
|
3301
|
-
postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
|
|
3302
|
-
postiz_cols = st.columns(2)
|
|
3303
|
-
with postiz_cols[0]:
|
|
3304
|
-
postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
|
|
3305
|
-
postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
|
|
3306
|
-
postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
|
|
3307
|
-
with postiz_cols[1]:
|
|
3308
|
-
postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
|
|
3309
|
-
postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
|
|
3310
|
-
st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
|
|
3311
|
-
|
|
3312
|
-
if refresh_openai_models:
|
|
3313
|
-
try:
|
|
3314
|
-
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
3315
|
-
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
|
|
3316
|
-
st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
|
|
3317
|
-
st.rerun()
|
|
3318
|
-
except ModelDiscoveryError as exc:
|
|
3319
|
-
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
|
|
3320
|
-
st.rerun()
|
|
3408
|
+
elevenlabs_api_key = text_setting("ElevenLabs API key", "elevenlabs_api_key", secret=True)
|
|
3409
|
+
elevenlabs_model_id = text_setting("ElevenLabs model", "elevenlabs_model_id")
|
|
3410
|
+
chatterbox_base_url = text_setting("Chatterbox Base URL", "chatterbox_base_url")
|
|
3411
|
+
chatterbox_api_key = text_setting("Chatterbox API key", "chatterbox_api_key", secret=True)
|
|
3412
|
+
chatterbox_model_id = text_setting("Chatterbox model", "chatterbox_model_id")
|
|
3413
|
+
sonilo_api_key = text_setting("Sonilo API key", "sonilo_api_key", secret=True)
|
|
3414
|
+
sonilo_base_url = text_setting("Sonilo Base URL", "sonilo_base_url")
|
|
3415
|
+
st.markdown("**Suno — agente musical opcional**")
|
|
3416
|
+
suno_api_key = text_setting("Suno API key", "suno_api_key", secret=True)
|
|
3417
|
+
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.")
|
|
3418
|
+
suno_api_endpoint = text_setting("Suno API endpoint", "suno_api_endpoint", help_text="Ex.: /api/generate")
|
|
3419
|
+
|
|
3420
|
+
with st.expander("TikTok for Developers — Client ID e Client Secret", expanded=True):
|
|
3421
|
+
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.")
|
|
3422
|
+
tiktok_client_key = text_setting("TikTok Client ID", "tiktok_client_key", secret=True)
|
|
3423
|
+
tiktok_client_secret = text_setting("TikTok Client Secret", "tiktok_client_secret", secret=True)
|
|
3424
|
+
|
|
3425
|
+
with st.expander("Publicação através do Upload-Post"):
|
|
3426
|
+
upload_post_enabled = st.checkbox("Activar Upload-Post", bool(settings.get("upload_post_enabled", False)))
|
|
3427
|
+
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
3428
|
+
upload_post_username = text_setting("Upload-Post username", "upload_post_username")
|
|
3429
|
+
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
3430
|
+
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
3431
|
+
|
|
3432
|
+
with st.expander("Postiz — API key, integração e MCP", expanded=True):
|
|
3433
|
+
st.caption("O Thunderbolt é o cliente. A API key é enviada exclusivamente ao servidor Postiz configurado; não é colocada em URLs, logs ou repositório.")
|
|
3434
|
+
postiz_enabled = st.checkbox("Activar Postiz como fallback final", bool(settings.get("postiz_enabled", False)))
|
|
3435
|
+
postiz_mode = st.selectbox("Modo de ligação", ["api", "mcp"], index=0 if settings.get("postiz_mode", "api") != "mcp" else 1, help="API é o modo determinístico de upload. MCP fica disponível para uma ligação compatível com Streamable HTTP.")
|
|
3436
|
+
postiz_cols = st.columns(2)
|
|
3437
|
+
with postiz_cols[0]:
|
|
3438
|
+
postiz_api_key = text_setting("Postiz API key", "postiz_api_key", secret=True, help_text="API key criada nas definições do Postiz. A API HTTP usa o valor bruto no cabeçalho Authorization.")
|
|
3439
|
+
postiz_base_url = text_setting("Postiz Public API Base URL", "postiz_base_url", help_text="Cloud: https://api.postiz.com/public/v1 · Self-hosted: https://seu-servidor/api/public/v1")
|
|
3440
|
+
postiz_integration_id = text_setting("Postiz integração padrão", "postiz_integration_id", help_text="ID do canal/integração devolvido por GET /integrations.")
|
|
3441
|
+
with postiz_cols[1]:
|
|
3442
|
+
postiz_mcp_url = text_setting("Postiz MCP URL", "postiz_mcp_url", help_text="Cloud: https://api.postiz.com/mcp · o cliente acrescenta a API key conforme o modo escolhido.")
|
|
3443
|
+
postiz_auto_publish = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)))
|
|
3444
|
+
st.caption("No Upload, a aba Postiz permite carregar as integrações e enviar vídeos manualmente. No fluxo recomendado, Postiz só é tentado depois da API Oficial e do Upload directo.")
|
|
3445
|
+
|
|
3446
|
+
if refresh_openai_models:
|
|
3447
|
+
try:
|
|
3448
|
+
discovered_models = fetch_openai_compatible_models(openai_api_key, openai_base_url)
|
|
3449
|
+
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": discovered_models, "error": ""}
|
|
3450
|
+
st.success(f"{len(discovered_models)} modelo(s) carregado(s) do endpoint OpenAI-compatible.")
|
|
3451
|
+
st.rerun()
|
|
3452
|
+
except ModelDiscoveryError as exc:
|
|
3453
|
+
st.session_state["openai_model_catalog"] = {"key": catalog_key, "models": [], "error": str(exc)}
|
|
3454
|
+
st.rerun()
|
|
3321
3455
|
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
"
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
3356
|
-
except Exception as exc:
|
|
3357
|
-
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
3456
|
+
save_all_settings = st.form_submit_button("Guardar configurações do Thunderbolt", type="primary")
|
|
3457
|
+
if save_all_settings:
|
|
3458
|
+
settings.update({
|
|
3459
|
+
"port": port, "moneyprinter_path": moneyprinter_path,
|
|
3460
|
+
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
3461
|
+
"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),
|
|
3462
|
+
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
|
3463
|
+
"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,
|
|
3464
|
+
"azure_speech_key": azure_speech_key, "azure_speech_region": azure_speech_region,
|
|
3465
|
+
"siliconflow_tts_api_key": siliconflow_tts_api_key, "minimax_tts_api_key": minimax_tts_api_key,
|
|
3466
|
+
"minimax_tts_base_url": minimax_tts_base_url, "minimax_tts_model_id": minimax_tts_model_id, "minimax_tts_voice_id": minimax_tts_voice_id,
|
|
3467
|
+
"elevenlabs_api_key": elevenlabs_api_key, "elevenlabs_model_id": elevenlabs_model_id,
|
|
3468
|
+
"chatterbox_base_url": chatterbox_base_url, "chatterbox_api_key": chatterbox_api_key, "chatterbox_model_id": chatterbox_model_id,
|
|
3469
|
+
"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,
|
|
3470
|
+
"tiktok_client_key": tiktok_client_key, "tiktok_client_secret": tiktok_client_secret,
|
|
3471
|
+
"upload_post_enabled": upload_post_enabled, "upload_post_api_key": upload_post_api_key,
|
|
3472
|
+
"upload_post_username": upload_post_username, "upload_post_platforms": upload_post_platforms,
|
|
3473
|
+
"upload_post_auto_upload": upload_post_auto_upload,
|
|
3474
|
+
"postiz_enabled": postiz_enabled, "postiz_api_key": postiz_api_key, "postiz_base_url": postiz_base_url.strip() or "https://api.postiz.com/public/v1",
|
|
3475
|
+
"postiz_mcp_url": postiz_mcp_url.strip() or "https://api.postiz.com/mcp", "postiz_mode": postiz_mode,
|
|
3476
|
+
"postiz_integration_id": postiz_integration_id.strip(), "postiz_auto_publish": bool(postiz_auto_publish),
|
|
3477
|
+
})
|
|
3478
|
+
write_json("settings.json", settings)
|
|
3479
|
+
try:
|
|
3480
|
+
synced = sync_moneyprinter_config(settings, moneyprinter_path)
|
|
3481
|
+
if synced:
|
|
3482
|
+
st.success(f"Configurações guardadas e sincronizadas com {synced}")
|
|
3483
|
+
else:
|
|
3484
|
+
st.success("Configurações guardadas localmente. Indique uma pasta válida do motor de vídeo para sincronizar config.toml.")
|
|
3485
|
+
except Exception as exc:
|
|
3486
|
+
st.warning(f"Configurações locais guardadas, mas não foi possível sincronizar config.toml: {exc}")
|
|
3487
|
+
with material_sources_tab:
|
|
3488
|
+
render_material_source_api_keys(settings)
|
|
3358
3489
|
|
|
3359
3490
|
with voice_test_tab:
|
|
3360
3491
|
st.subheader("Teste de vozes")
|
|
@@ -3776,6 +3907,7 @@ def main():
|
|
|
3776
3907
|
("Limpador de Metadados", ":material/edit_note:", "Limpador de Metadados"),
|
|
3777
3908
|
("Cortes", ":material/content_cut:", "Cortes"),
|
|
3778
3909
|
("Editor Python", ":material/code:", "Editor Python"),
|
|
3910
|
+
("Download Mídia", ":material/download:", "Download Mídia"),
|
|
3779
3911
|
]
|
|
3780
3912
|
models_ai_items = [
|
|
3781
3913
|
("Personagens", ":material/person:", "Personagens"),
|
|
@@ -3882,6 +4014,7 @@ def main():
|
|
|
3882
4014
|
"Limpador de Metadados": render_metadata_cleaner,
|
|
3883
4015
|
"Cortes": render_cuts,
|
|
3884
4016
|
"Editor Python": render_python_editor,
|
|
4017
|
+
"Download Mídia": render_media_download,
|
|
3885
4018
|
"AI Influencers": lambda: render_edit_placeholder("AI Influencers", "Seleccione uma das abas AI Influencers no menu expansível."),
|
|
3886
4019
|
"Tutorial Meta": render_models_ai_tutorial,
|
|
3887
4020
|
"Personagens": lambda: render_edit_placeholder("Personagens", "Área reservada para a futura funcionalidade de personagens."),
|