@danhachuel/thunderbolt 0.2.59 → 0.2.61
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
|
@@ -42,7 +42,7 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
|
|
|
42
42
|
from integrations.postiz import PostizAdapter
|
|
43
43
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
44
44
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
45
|
-
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
45
|
+
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
46
46
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
47
47
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
48
48
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -120,6 +120,21 @@ VIDEO_LANGUAGE_OPTIONS = [
|
|
|
120
120
|
"50 – Hausa",
|
|
121
121
|
]
|
|
122
122
|
|
|
123
|
+
# Mantemos estes valores separados para que Criação de Vídeos e Roteiros partilhem
|
|
124
|
+
# exactamente os mesmos selectores sem alterar a lista histórica de idiomas.
|
|
125
|
+
VIDEO_FORMAT_OPTIONS = ["wide", "shorts", "music"]
|
|
126
|
+
VIDEO_CONCATENATION_OPTIONS = ["Random Concatenation (Recommended)", "Sequential Concatenation"]
|
|
127
|
+
VIDEO_TRANSITION_OPTIONS = ["None", "Fade", "Dissolve"]
|
|
128
|
+
VIDEO_ENCODER_OPTIONS = ["Default (Recommended)", "H.264", "H.265"]
|
|
129
|
+
VOICEOVER_MODE_OPTIONS = ["Auto", "Upload", "None"]
|
|
130
|
+
VOICEOVER_SERVICE_OPTIONS = ["Azure TTS V1"]
|
|
131
|
+
VOICEOVER_VOLUME_OPTIONS = ["20%", "40%", "60%", "80%", "100%"]
|
|
132
|
+
VOICEOVER_SPEED_OPTIONS = ["0.5x", "0.75x", "1.0x", "1.25x", "1.5x", "2.0x"]
|
|
133
|
+
BACKGROUND_MUSIC_SOURCE_OPTIONS = ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API", "Random Background Music", "Sem música"]
|
|
134
|
+
BACKGROUND_MUSIC_VOLUME_OPTIONS = ["0%", "10%", "20%", "30%", "50%", "75%", "100%"]
|
|
135
|
+
SUBTITLE_FONT_OPTIONS = ["MicrosoftYaHeiBold.ttc", "Arial.ttf", "DejaVuSans.ttf"]
|
|
136
|
+
SUBTITLE_POSITION_OPTIONS = ["Bottom (Recommended)", "Top", "Center"]
|
|
137
|
+
|
|
123
138
|
ensure_storage()
|
|
124
139
|
st.set_page_config(page_title="Thunderbolt", page_icon="T", layout="wide", initial_sidebar_state="expanded")
|
|
125
140
|
|
|
@@ -300,6 +315,101 @@ def voice_catalog(current: str = "") -> list[str]:
|
|
|
300
315
|
return voices
|
|
301
316
|
|
|
302
317
|
|
|
318
|
+
def render_video_generation_settings(prefix: str, *, current_language: str = "") -> dict[str, Any]:
|
|
319
|
+
"""Render the shared MoneyPrinter-style settings and return a serializable payload."""
|
|
320
|
+
settings: dict[str, Any] = {}
|
|
321
|
+
st.markdown("### Video Subject Settings")
|
|
322
|
+
subject_cols = st.columns(2)
|
|
323
|
+
with subject_cols[0]:
|
|
324
|
+
settings["video_subject"] = st.text_input(
|
|
325
|
+
"Video Subject",
|
|
326
|
+
value=str(st.session_state.get(f"{prefix}_video_subject", "")),
|
|
327
|
+
key=f"{prefix}_video_subject",
|
|
328
|
+
placeholder="Ex.: How AI is changing everyday life",
|
|
329
|
+
)
|
|
330
|
+
settings["script_language"] = st.selectbox(
|
|
331
|
+
"Script Language",
|
|
332
|
+
VIDEO_LANGUAGE_OPTIONS,
|
|
333
|
+
index=VIDEO_LANGUAGE_OPTIONS.index(current_language) if current_language in VIDEO_LANGUAGE_OPTIONS else 0,
|
|
334
|
+
key=f"{prefix}_script_language",
|
|
335
|
+
)
|
|
336
|
+
with subject_cols[1]:
|
|
337
|
+
with st.expander("Advanced Script Settings", expanded=False):
|
|
338
|
+
settings["script_structure_notes"] = st.text_area(
|
|
339
|
+
"Estrutura e notas opcionais",
|
|
340
|
+
value=str(st.session_state.get(f"{prefix}_script_structure_notes", "")),
|
|
341
|
+
key=f"{prefix}_script_structure_notes",
|
|
342
|
+
height=100,
|
|
343
|
+
placeholder="Ex.: gancho forte, 6 cenas, narração documental…",
|
|
344
|
+
)
|
|
345
|
+
settings["generate_script_with_ai"] = st.checkbox("Generate Script & Keywords with AI", value=True, key=f"{prefix}_generate_script_with_ai")
|
|
346
|
+
settings["video_script"] = st.text_area(
|
|
347
|
+
"Video Script (Optional)",
|
|
348
|
+
value=str(st.session_state.get(f"{prefix}_video_script", "")),
|
|
349
|
+
key=f"{prefix}_video_script",
|
|
350
|
+
height=130,
|
|
351
|
+
)
|
|
352
|
+
settings["video_keywords"] = st.text_area(
|
|
353
|
+
"Video Keywords (English, Optional)",
|
|
354
|
+
value=str(st.session_state.get(f"{prefix}_video_keywords", "")),
|
|
355
|
+
key=f"{prefix}_video_keywords",
|
|
356
|
+
height=90,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
st.markdown("### Video Settings")
|
|
360
|
+
video_cols = st.columns(2)
|
|
361
|
+
with video_cols[0]:
|
|
362
|
+
settings["video_source"] = st.selectbox("Video Source", WIDE_STYLE_OPTIONS, key=f"{prefix}_video_source")
|
|
363
|
+
if settings["video_source"] == "full_ia":
|
|
364
|
+
settings["style_ia"] = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key=f"{prefix}_style_ia")
|
|
365
|
+
else:
|
|
366
|
+
settings["style_ia"] = ""
|
|
367
|
+
settings["video_format"] = st.selectbox("Formato", VIDEO_FORMAT_OPTIONS, key=f"{prefix}_video_format")
|
|
368
|
+
settings["video_concatenation_mode"] = st.selectbox("Video Concatenation Mode", VIDEO_CONCATENATION_OPTIONS, key=f"{prefix}_video_concatenation")
|
|
369
|
+
settings["match_visuals_to_script_order"] = st.checkbox("Match Visuals to Script Order", value=False, key=f"{prefix}_match_visuals")
|
|
370
|
+
settings["video_transition_mode"] = st.selectbox("Video Transition Mode", VIDEO_TRANSITION_OPTIONS, key=f"{prefix}_video_transition")
|
|
371
|
+
with video_cols[1]:
|
|
372
|
+
settings["video_aspect_ratio"] = st.selectbox("Video Aspect Ratio", ["Portrait 9:16", "Landscape 16:9", "Square 1:1"], key=f"{prefix}_video_aspect_ratio")
|
|
373
|
+
settings["maximum_clip_duration"] = st.selectbox("Maximum Clip Duration (seconds)", [3, 5, 8, 10, 15], key=f"{prefix}_maximum_clip_duration")
|
|
374
|
+
settings["videos_per_run"] = st.selectbox("Videos per Run", list(range(1, 11)), key=f"{prefix}_videos_per_run")
|
|
375
|
+
settings["video_encoder"] = st.selectbox("Video Encoder", VIDEO_ENCODER_OPTIONS, key=f"{prefix}_video_encoder")
|
|
376
|
+
|
|
377
|
+
st.markdown("### Audio Settings")
|
|
378
|
+
audio_cols = st.columns(2)
|
|
379
|
+
with audio_cols[0]:
|
|
380
|
+
settings["voiceover_mode"] = st.radio("Voiceover Mode", VOICEOVER_MODE_OPTIONS, horizontal=True, key=f"{prefix}_voiceover_mode")
|
|
381
|
+
settings["voiceover_service"] = st.selectbox("Voiceover Service", VOICEOVER_SERVICE_OPTIONS, key=f"{prefix}_voiceover_service")
|
|
382
|
+
current_voice = str(st.session_state.get(f"{prefix}_voice", ""))
|
|
383
|
+
voice_options = voice_catalog(current_voice)
|
|
384
|
+
settings["voice"] = st.selectbox("Voice (match script language)", voice_options, format_func=lambda value: value or "Sem voz seleccionada", key=f"{prefix}_voice")
|
|
385
|
+
volume_speed_cols = st.columns(2)
|
|
386
|
+
with volume_speed_cols[0]:
|
|
387
|
+
settings["voiceover_volume"] = st.selectbox("Voiceover Volume", VOICEOVER_VOLUME_OPTIONS, index=VOICEOVER_VOLUME_OPTIONS.index("100%"), key=f"{prefix}_voiceover_volume")
|
|
388
|
+
with volume_speed_cols[1]:
|
|
389
|
+
settings["voiceover_speed"] = st.selectbox("Voiceover Speed", VOICEOVER_SPEED_OPTIONS, index=VOICEOVER_SPEED_OPTIONS.index("1.0x"), key=f"{prefix}_voiceover_speed")
|
|
390
|
+
st.button("Preview Voice", key=f"{prefix}_preview_voice", disabled=True, help="A pré-visualização de voz será ligada ao provider configurado.")
|
|
391
|
+
with audio_cols[1]:
|
|
392
|
+
settings["background_music_source"] = st.selectbox("Background Music Source", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=3, key=f"{prefix}_background_music_source")
|
|
393
|
+
settings["background_music_volume"] = st.selectbox("Background Music Volume", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=2, key=f"{prefix}_background_music_volume")
|
|
394
|
+
|
|
395
|
+
st.markdown("### Subtitle Settings")
|
|
396
|
+
subtitle_cols = st.columns(2)
|
|
397
|
+
with subtitle_cols[0]:
|
|
398
|
+
settings["enable_subtitles"] = st.checkbox("Enable Subtitles", value=True, key=f"{prefix}_enable_subtitles")
|
|
399
|
+
settings["subtitle_font"] = st.selectbox("Font", SUBTITLE_FONT_OPTIONS, key=f"{prefix}_subtitle_font")
|
|
400
|
+
settings["subtitle_position"] = st.selectbox("Position", SUBTITLE_POSITION_OPTIONS, key=f"{prefix}_subtitle_position")
|
|
401
|
+
settings["subtitle_color"] = st.color_picker("Color", "#FFFFFF", key=f"{prefix}_subtitle_color")
|
|
402
|
+
settings["subtitle_background"] = st.checkbox("Background", value=True, key=f"{prefix}_subtitle_background")
|
|
403
|
+
settings["subtitle_background_color"] = st.color_picker("Background Color", "#000000", key=f"{prefix}_subtitle_background_color")
|
|
404
|
+
settings["subtitle_rounded_background"] = st.checkbox("Rounded Background", value=False, key=f"{prefix}_subtitle_rounded_background")
|
|
405
|
+
with subtitle_cols[1]:
|
|
406
|
+
settings["subtitle_font_size"] = st.slider("Font Size", min_value=12, max_value=96, value=60, key=f"{prefix}_subtitle_font_size")
|
|
407
|
+
settings["subtitle_outline"] = st.color_picker("Outline", "#000000", key=f"{prefix}_subtitle_outline")
|
|
408
|
+
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")
|
|
409
|
+
st.button("Restore Subtitle Defaults", key=f"{prefix}_restore_subtitle_defaults", disabled=True, help="Os valores predefinidos já estão activos nesta configuração.")
|
|
410
|
+
return settings
|
|
411
|
+
|
|
412
|
+
|
|
303
413
|
def channel_default_options(channel: dict) -> tuple[list[str], dict[str, str], str, list[str], str]:
|
|
304
414
|
"""Return synchronised Blueprint and voice options for a channel editor."""
|
|
305
415
|
blueprint_items = blueprint_catalog()
|
|
@@ -959,6 +1069,11 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
959
1069
|
)
|
|
960
1070
|
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
961
1071
|
selected_one: dict[str, Any] | None = None
|
|
1072
|
+
legacy_language = st.session_state.get("video_language")
|
|
1073
|
+
legacy_language_map = {"Português": "36 – Português (Brasil)", "English": "01 – Inglês", "Español": "41 – Espanhol (LatAm)"}
|
|
1074
|
+
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
1075
|
+
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
1076
|
+
generation_settings: dict[str, Any] = {}
|
|
962
1077
|
if mode == "general":
|
|
963
1078
|
selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
|
|
964
1079
|
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.")
|
|
@@ -999,6 +1114,10 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
999
1114
|
st.write(f"**{channel.get('name', 'Canal')}**")
|
|
1000
1115
|
st.caption(f"{result.get('niche', '')} · {result.get('angle', '')}")
|
|
1001
1116
|
st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"new_video_general_topic_{channel['id']}", height=80)
|
|
1117
|
+
generation_settings = render_video_generation_settings(
|
|
1118
|
+
"new_video",
|
|
1119
|
+
current_language=str(st.session_state.get("video_language") or ""),
|
|
1120
|
+
)
|
|
1002
1121
|
else:
|
|
1003
1122
|
if not active_channels:
|
|
1004
1123
|
st.warning("Não existem canais activos disponíveis para os modos de canal específico.")
|
|
@@ -1006,8 +1125,12 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1006
1125
|
else:
|
|
1007
1126
|
selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
1008
1127
|
selected = [selected_one["id"]]
|
|
1009
|
-
# Intentionally sits between Canal and
|
|
1128
|
+
# Intentionally sits between Canal and the generation settings, as requested.
|
|
1010
1129
|
render_channel_blueprint_panel(selected_one)
|
|
1130
|
+
generation_settings = render_video_generation_settings(
|
|
1131
|
+
"new_video",
|
|
1132
|
+
current_language=str(st.session_state.get("video_language") or ""),
|
|
1133
|
+
)
|
|
1011
1134
|
topic = st.text_area(
|
|
1012
1135
|
"Tópico ou briefing",
|
|
1013
1136
|
value=st.session_state.get("new_video_topic", ""),
|
|
@@ -1034,8 +1157,13 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1034
1157
|
meta = st.session_state["new_video_topic_meta"]
|
|
1035
1158
|
st.caption(f"Origem: IA · Nicho: {meta.get('niche', '—')} · Ângulo: {meta.get('angle', '—')}")
|
|
1036
1159
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1160
|
+
if not generation_settings:
|
|
1161
|
+
generation_settings = render_video_generation_settings(
|
|
1162
|
+
"new_video",
|
|
1163
|
+
current_language=str(st.session_state.get("video_language") or ""),
|
|
1164
|
+
)
|
|
1165
|
+
wide_style_label = generation_settings["video_source"]
|
|
1166
|
+
style_ia = generation_settings.get("style_ia", "")
|
|
1039
1167
|
music_path = ""
|
|
1040
1168
|
music_source = ""
|
|
1041
1169
|
if wide_style_label == "Apenas Música":
|
|
@@ -1162,12 +1290,8 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1162
1290
|
|
|
1163
1291
|
with st.form("new_video_form"):
|
|
1164
1292
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
1168
|
-
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
1169
|
-
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
1170
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"], key="new_video_format")
|
|
1293
|
+
language = generation_settings["script_language"]
|
|
1294
|
+
fmt = generation_settings["video_format"]
|
|
1171
1295
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
1172
1296
|
if submitted:
|
|
1173
1297
|
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
@@ -1213,8 +1337,8 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1213
1337
|
st.session_state["new_video_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
|
|
1214
1338
|
if len(payloads) == len(selected):
|
|
1215
1339
|
batch_topic = "Lote geral — um vídeo independente por canal"
|
|
1216
|
-
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()}
|
|
1217
|
-
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})
|
|
1340
|
+
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, "generation_settings": generation_settings} for cid, payload in payloads.items()}
|
|
1341
|
+
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, "generation_settings": generation_settings, "topic_source": "llm", "channel_payloads": channel_payloads})
|
|
1218
1342
|
tasks = create_tasks_for_batch(batch)
|
|
1219
1343
|
st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
|
|
1220
1344
|
else:
|
|
@@ -1230,8 +1354,8 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
1230
1354
|
except CreativeGenerationError as exc:
|
|
1231
1355
|
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.")
|
|
1232
1356
|
payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
|
|
1233
|
-
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})
|
|
1234
|
-
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}})
|
|
1357
|
+
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, "generation_settings": generation_settings})
|
|
1358
|
+
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, "generation_settings": generation_settings, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
|
|
1235
1359
|
tasks = create_tasks_for_batch(batch)
|
|
1236
1360
|
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
1237
1361
|
with videos_tab:
|
|
@@ -1295,14 +1419,13 @@ def render_scripts():
|
|
|
1295
1419
|
height=120,
|
|
1296
1420
|
placeholder="Descreva o tema, a mensagem, o conflito ou a ideia musical que o Blueprint deve orientar.",
|
|
1297
1421
|
)
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
key="script_structure_notes",
|
|
1303
|
-
height=90,
|
|
1304
|
-
placeholder="Ex.: 6 cenas, narração documental, refrão repetível, atmosfera sombria…",
|
|
1422
|
+
legacy_script_language = str(st.session_state.get("script_language") or "")
|
|
1423
|
+
script_settings = render_video_generation_settings(
|
|
1424
|
+
"pipeline_scripts",
|
|
1425
|
+
current_language=legacy_script_language if legacy_script_language in VIDEO_LANGUAGE_OPTIONS else "",
|
|
1305
1426
|
)
|
|
1427
|
+
language = script_settings["script_language"]
|
|
1428
|
+
structure_notes = script_settings["script_structure_notes"]
|
|
1306
1429
|
generate_col, clear_col = st.columns([1.4, 1])
|
|
1307
1430
|
with generate_col:
|
|
1308
1431
|
generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", use_container_width=True, key="generate_script_document")
|
|
@@ -1324,6 +1447,7 @@ def render_scripts():
|
|
|
1324
1447
|
channel=selected_channel or {},
|
|
1325
1448
|
blueprint=selected_blueprint,
|
|
1326
1449
|
structure_notes=structure_notes,
|
|
1450
|
+
generation_settings=script_settings,
|
|
1327
1451
|
)
|
|
1328
1452
|
st.session_state["script_draft"] = generated
|
|
1329
1453
|
st.session_state["script_draft_title"] = generated["title"]
|
|
@@ -1356,6 +1480,7 @@ def render_scripts():
|
|
|
1356
1480
|
"content": draft_content,
|
|
1357
1481
|
"document_type": "video_script" if document_type == "Roteiro de vídeo" else "music_lyrics",
|
|
1358
1482
|
"language": language,
|
|
1483
|
+
"generation_settings": script_settings,
|
|
1359
1484
|
"channel_id": str((selected_channel or {}).get("id") or ""),
|
|
1360
1485
|
"channel_name": str((selected_channel or {}).get("name") or "Documento independente"),
|
|
1361
1486
|
"blueprint_id": str(selected_blueprint.get("id") or selected_blueprint_id or ""),
|
|
@@ -2446,7 +2571,7 @@ def render_settings():
|
|
|
2446
2571
|
account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
|
|
2447
2572
|
account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
|
|
2448
2573
|
ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
|
|
2449
|
-
direct_status = direct_account_status(STORAGE, batch_account)
|
|
2574
|
+
direct_status = direct_account_status(STORAGE, batch_account, settings)
|
|
2450
2575
|
missing_document_parts = list(direct_status.get("missing_cookies", []))
|
|
2451
2576
|
if not direct_status.get("has_session_info"):
|
|
2452
2577
|
missing_document_parts.append("sessionInfo")
|
|
@@ -2553,6 +2678,31 @@ def render_settings():
|
|
|
2553
2678
|
if youtube_accounts_missing_document:
|
|
2554
2679
|
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
2555
2680
|
|
|
2681
|
+
st.divider()
|
|
2682
|
+
st.markdown("### INNERTUBE_API_KEY")
|
|
2683
|
+
st.caption("Esta chave pertence à conta Google/YouTube seleccionada e fica guardada na configuração da conta. Não faz parte do documento de cookies/credenciais e não é editada no separador API Keys.")
|
|
2684
|
+
account_key_options = [str(account.get("id")) for account in batch_accounts if account.get("id")]
|
|
2685
|
+
account_key_labels = {str(account.get("id")): f"{account.get('label', 'Canais YouTube')} — {account.get('email', 'sem e-mail')}" for account in batch_accounts if account.get("id")}
|
|
2686
|
+
if account_key_options:
|
|
2687
|
+
with st.form("innertube_api_key_form"):
|
|
2688
|
+
selected_key_account_id = st.selectbox("Conta Google/YouTube", account_key_options, format_func=lambda value: account_key_labels.get(value, value), key="innertube_key_account")
|
|
2689
|
+
selected_key_account = next(account for account in batch_accounts if str(account.get("id")) == selected_key_account_id)
|
|
2690
|
+
current_innertube_api_key = direct_account_status(STORAGE, selected_key_account, settings).get("innertube_api_key", "")
|
|
2691
|
+
innertube_api_key_value = st.text_input("INNERTUBE_API_KEY", value=current_innertube_api_key, type="password", key=f"innertube_api_key_{selected_key_account_id}", help="Chave usada pelo Upload directo desta conta. Guarde-a aqui, separada do documento de cookies.")
|
|
2692
|
+
save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY", type="primary", use_container_width=True)
|
|
2693
|
+
if save_innertube_api_key:
|
|
2694
|
+
selected_key_account["innertube_api_key"] = innertube_api_key_value.strip()
|
|
2695
|
+
selected_key_account.pop("INNERTUBE_API_KEY", None)
|
|
2696
|
+
settings.pop("direct_innertube_api_key", None)
|
|
2697
|
+
settings["youtube_batch_accounts"] = batch_accounts
|
|
2698
|
+
write_json("settings.json", settings)
|
|
2699
|
+
document = load_credentials_document(STORAGE, selected_key_account, settings, channel_state, create=True)
|
|
2700
|
+
save_credentials_document(STORAGE, selected_key_account, document)
|
|
2701
|
+
st.success("INNERTUBE_API_KEY guardada na configuração da conta Google/YouTube, fora do documento de cookies.")
|
|
2702
|
+
st.rerun()
|
|
2703
|
+
else:
|
|
2704
|
+
st.info("Adicione primeiro uma conta Google/YouTube para configurar a INNERTUBE_API_KEY.")
|
|
2705
|
+
|
|
2556
2706
|
st.divider()
|
|
2557
2707
|
st.markdown("### Adicionar outra conta Gmail")
|
|
2558
2708
|
st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
|
|
@@ -2564,7 +2714,7 @@ def render_settings():
|
|
|
2564
2714
|
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
2565
2715
|
with add_cols[1]:
|
|
2566
2716
|
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
2567
|
-
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
|
|
2717
|
+
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 e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
|
|
2568
2718
|
new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
|
|
2569
2719
|
add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
|
|
2570
2720
|
if add_account:
|
|
@@ -2638,10 +2788,6 @@ def render_settings():
|
|
|
2638
2788
|
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.")
|
|
2639
2789
|
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.")
|
|
2640
2790
|
|
|
2641
|
-
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.")
|
|
2642
|
-
direct_innertube_api_key = str(settings.get("direct_innertube_api_key", "") or "")
|
|
2643
|
-
direct_chunk_size = int(settings.get("direct_chunk_size", 262144) or 262144)
|
|
2644
|
-
|
|
2645
2791
|
with st.expander("Serviço, materiais e rede"):
|
|
2646
2792
|
cols = st.columns(2)
|
|
2647
2793
|
with cols[0]:
|
|
@@ -2792,7 +2938,6 @@ def render_settings():
|
|
|
2792
2938
|
"youtube_client_id": youtube_client_id, "youtube_client_secret": youtube_client_secret,
|
|
2793
2939
|
"kaggle_username": kaggle_username.strip(), "kaggle_api_key": kaggle_api_key.strip(), "kaggle_kernel_slug": kaggle_kernel_slug.strip() or "thunderbolt-niche-finder",
|
|
2794
2940
|
"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),
|
|
2795
|
-
"direct_innertube_api_key": direct_innertube_api_key, "direct_chunk_size": direct_chunk_size,
|
|
2796
2941
|
"log_level": log_level, "listen_host": listen_host, "listen_port": listen_port, "video_source": video_source,
|
|
2797
2942
|
"endpoint": endpoint, "proxy_http": proxy_http, "proxy_https": proxy_https, "match_materials_to_script": match_materials_to_script,
|
|
2798
2943
|
"llm_provider": llm_provider, "openai_api_key": openai_api_key, "openai_base_url": openai_base_url, "openai_model_name": openai_model_name,
|
package/hermes_ui/domain.py
CHANGED
|
@@ -154,6 +154,7 @@ def create_tasks_for_batch(batch: dict[str, Any]) -> list[dict[str, Any]]:
|
|
|
154
154
|
"music_path": payload.get("music_path", options.get("music_path", "")),
|
|
155
155
|
"music_source": payload.get("music_source", options.get("music_source", "")),
|
|
156
156
|
"background_mode": payload.get("background_mode", options.get("background_mode", "stock")),
|
|
157
|
+
"generation_settings": payload.get("generation_settings", options.get("generation_settings", {})),
|
|
157
158
|
"blueprint_id": payload.get("blueprint_id") or channel.get("default_blueprint_id") or channel.get("blueprint_id", ""),
|
|
158
159
|
"blueprint_name": payload.get("blueprint_name", ""),
|
|
159
160
|
"voice": payload.get("voice") or channel.get("default_voice") or channel.get("voice", ""),
|
|
@@ -24,6 +24,7 @@ def generate_script_document(
|
|
|
24
24
|
channel: dict[str, Any] | None = None,
|
|
25
25
|
blueprint: dict[str, Any] | None = None,
|
|
26
26
|
structure_notes: str = "",
|
|
27
|
+
generation_settings: dict[str, Any] | None = None,
|
|
27
28
|
) -> dict[str, Any]:
|
|
28
29
|
"""Generate an editable Markdown document from the configured LLM.
|
|
29
30
|
|
|
@@ -38,6 +39,7 @@ def generate_script_document(
|
|
|
38
39
|
|
|
39
40
|
channel_context_value = channel_context(channel or {}, blueprint or {})
|
|
40
41
|
blueprint_payload = blueprint or {}
|
|
42
|
+
generation_settings_payload = generation_settings or {}
|
|
41
43
|
if normalized_type == "video_script":
|
|
42
44
|
output_requirements = {
|
|
43
45
|
"content": "roteiro completo em Markdown, com título, gancho, cenas, narração e indicações visuais/sonoras",
|
|
@@ -69,6 +71,7 @@ def generate_script_document(
|
|
|
69
71
|
"channel": channel_context_value,
|
|
70
72
|
"blueprint": blueprint_payload,
|
|
71
73
|
"structure_notes": structure_notes.strip(),
|
|
74
|
+
"generation_settings": generation_settings_payload,
|
|
72
75
|
"output_requirements": output_requirements,
|
|
73
76
|
},
|
|
74
77
|
ensure_ascii=False,
|
|
@@ -87,5 +90,6 @@ def generate_script_document(
|
|
|
87
90
|
"blueprint_name": str(blueprint_payload.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
88
91
|
"channel_id": str((channel or {}).get("id") or ""),
|
|
89
92
|
"channel_name": str((channel or {}).get("name") or "Documento independente"),
|
|
93
|
+
"generation_settings": generation_settings_payload,
|
|
90
94
|
"generated_by": "configured_llm",
|
|
91
95
|
}
|
|
@@ -101,8 +101,7 @@ def parse_credentials_document(content: bytes, filename: str = DIRECT_DOCUMENT_N
|
|
|
101
101
|
raise ValueError(f"Faltam cookies obrigatórios no documento {filename}: {', '.join(missing)}.")
|
|
102
102
|
if not document["sessionInfo"]:
|
|
103
103
|
raise ValueError(f"Falta sessionInfo no documento {filename}.")
|
|
104
|
-
|
|
105
|
-
raise ValueError(f"Falta INNERTUBE_API_KEY no documento {filename}.")
|
|
104
|
+
# INNERTUBE_API_KEY é configurada separadamente na UI de Contas Google/YouTube.
|
|
106
105
|
return document
|
|
107
106
|
|
|
108
107
|
|
|
@@ -128,6 +127,20 @@ def _account_session_info(account: dict[str, Any]) -> str:
|
|
|
128
127
|
return str(account.get("sessionInfo") or account.get("session_info") or account.get("direct_session_info") or "").strip()
|
|
129
128
|
|
|
130
129
|
|
|
130
|
+
def account_innertube_api_key(account: dict[str, Any] | None, document: dict[str, Any] | None = None, settings: dict[str, Any] | None = None) -> str:
|
|
131
|
+
"""Return the account-level key; document value is only a legacy migration fallback."""
|
|
132
|
+
account = account or {}
|
|
133
|
+
document = document or {}
|
|
134
|
+
settings = settings or {}
|
|
135
|
+
return str(
|
|
136
|
+
account.get("innertube_api_key")
|
|
137
|
+
or account.get("INNERTUBE_API_KEY")
|
|
138
|
+
or settings.get("direct_innertube_api_key")
|
|
139
|
+
or document.get("INNERTUBE_API_KEY")
|
|
140
|
+
or ""
|
|
141
|
+
).strip()
|
|
142
|
+
|
|
143
|
+
|
|
131
144
|
def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
132
145
|
raw = raw if isinstance(raw, dict) else {}
|
|
133
146
|
cookies = _normalise_pairs(raw.get("cookies", raw))
|
|
@@ -187,7 +200,7 @@ def save_credentials_document(storage_root: Path, account: dict[str, Any], docum
|
|
|
187
200
|
"email": normalised["email"],
|
|
188
201
|
"sessionInfo": normalised["sessionInfo"],
|
|
189
202
|
"cookies": normalised["cookies"],
|
|
190
|
-
|
|
203
|
+
# INNERTUBE_API_KEY não pertence ao documento de cookies/credenciais.
|
|
191
204
|
"chunk_size": normalised["chunk_size"],
|
|
192
205
|
"delegated_session_ids": normalised["delegated_session_ids"],
|
|
193
206
|
}
|
|
@@ -277,7 +290,7 @@ def merge_credentials_document(
|
|
|
277
290
|
|
|
278
291
|
The upload may contain only cookies or the full Frontend API document. Existing
|
|
279
292
|
non-empty values are preserved, so uploading cookies never discards sessionInfo,
|
|
280
|
-
|
|
293
|
+
chunk_size or delegated channel IDs. INNERTUBE_API_KEY is configured separately.
|
|
281
294
|
"""
|
|
282
295
|
try:
|
|
283
296
|
raw = json.loads(content.decode("utf-8-sig", errors="replace"))
|
|
@@ -295,8 +308,8 @@ def merge_credentials_document(
|
|
|
295
308
|
incoming_session = _placeholder_to_empty(session_info_override) or incoming.get("sessionInfo", "")
|
|
296
309
|
if incoming_session:
|
|
297
310
|
merged["sessionInfo"] = incoming_session
|
|
298
|
-
|
|
299
|
-
|
|
311
|
+
# INNERTUBE_API_KEY recebida num JSON é deliberadamente ignorada: a fonte oficial
|
|
312
|
+
# é a configuração separada da secção Contas Google/YouTube.
|
|
300
313
|
if "chunk_size" in raw:
|
|
301
314
|
merged["chunk_size"] = incoming["chunk_size"]
|
|
302
315
|
for key, value in incoming.get("delegated_session_ids", {}).items():
|
|
@@ -325,14 +338,16 @@ def document_status(storage_root: Path, account: dict[str, Any], channel: dict[s
|
|
|
325
338
|
document = load_credentials_document(storage_root, account, settings, channels, create=True)
|
|
326
339
|
missing_cookies = [key for key in COOKIE_KEYS if not document["cookies"].get(key)]
|
|
327
340
|
delegated = delegated_session_id(document, channel or {}) if channel else ""
|
|
341
|
+
innertube_api_key = account_innertube_api_key(account, document, settings)
|
|
328
342
|
return {
|
|
329
343
|
"document_file": str(path),
|
|
330
344
|
"document_exists": path.exists(),
|
|
331
345
|
"missing_cookies": missing_cookies,
|
|
332
346
|
"has_session_info": bool(document.get("sessionInfo")),
|
|
333
|
-
"has_innertube_api_key": bool(
|
|
347
|
+
"has_innertube_api_key": bool(innertube_api_key),
|
|
348
|
+
"innertube_api_key": innertube_api_key,
|
|
334
349
|
"has_delegated_session_id": bool(delegated) if channel is not None else None,
|
|
335
|
-
"ready": not missing_cookies and bool(document.get("sessionInfo")) and bool(
|
|
350
|
+
"ready": not missing_cookies and bool(document.get("sessionInfo")) and bool(innertube_api_key) and (channel is None or bool(delegated)),
|
|
336
351
|
}
|
|
337
352
|
|
|
338
353
|
|
|
@@ -364,9 +379,9 @@ def save_cookie_file(storage_root: Path, account: dict[str, Any], content: bytes
|
|
|
364
379
|
return destination
|
|
365
380
|
|
|
366
381
|
|
|
367
|
-
def direct_account_status(storage_root: Path, account: dict[str, Any]) -> dict[str, Any]:
|
|
368
|
-
"""
|
|
369
|
-
status = document_status(storage_root, account)
|
|
382
|
+
def direct_account_status(storage_root: Path, account: dict[str, Any], settings: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
383
|
+
"""Return account status; cookies stay in credentials.json and the API key is separate."""
|
|
384
|
+
status = document_status(storage_root, account, settings=settings)
|
|
370
385
|
return {
|
|
371
386
|
"cookie_file": status["document_file"],
|
|
372
387
|
"document_exists": status["document_exists"],
|
|
@@ -374,6 +389,7 @@ def direct_account_status(storage_root: Path, account: dict[str, Any]) -> dict[s
|
|
|
374
389
|
"missing_cookies": status["missing_cookies"],
|
|
375
390
|
"has_session_info": status["has_session_info"],
|
|
376
391
|
"has_innertube_api_key": status["has_innertube_api_key"],
|
|
392
|
+
"innertube_api_key": status.get("innertube_api_key", ""),
|
|
377
393
|
"ready": status["ready"],
|
|
378
394
|
"document_file": status["document_file"],
|
|
379
395
|
}
|
|
@@ -9,7 +9,7 @@ from dataclasses import dataclass
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any
|
|
11
11
|
|
|
12
|
-
from integrations.youtube_direct_credentials import COOKIE_KEYS, delegated_session_id, load_credentials_document
|
|
12
|
+
from integrations.youtube_direct_credentials import COOKIE_KEYS, account_innertube_api_key, delegated_session_id, load_credentials_document
|
|
13
13
|
from urllib.parse import quote
|
|
14
14
|
|
|
15
15
|
import requests
|
|
@@ -49,7 +49,8 @@ def _session_info(settings: dict[str, Any], channel: dict[str, Any], account: di
|
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def _innertube_api_key(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str:
|
|
52
|
-
|
|
52
|
+
document = _direct_document(settings, channel, account, storage_root)
|
|
53
|
+
return account_innertube_api_key(account, document, settings)
|
|
53
54
|
|
|
54
55
|
|
|
55
56
|
def _delegated_session(settings: dict[str, Any], channel: dict[str, Any], account: dict[str, Any] | None = None, storage_root: Path | None = None) -> str:
|
|
@@ -96,7 +97,7 @@ def validate_direct_upload(video_path: str | Path, channel: dict[str, Any], sett
|
|
|
96
97
|
if not _session_info(settings, channel, account, storage_root):
|
|
97
98
|
return "Falta sessionInfo no documento de credenciais da conta."
|
|
98
99
|
if not _innertube_api_key(settings, channel, account, storage_root):
|
|
99
|
-
return "Falta INNERTUBE_API_KEY no
|
|
100
|
+
return "Falta INNERTUBE_API_KEY no cartão da conta Google/YouTube."
|
|
100
101
|
if not _delegated_session(settings, channel, account, storage_root):
|
|
101
102
|
return "Falta DELEGATED_SESSION_ID deste canal no documento de credenciais da conta."
|
|
102
103
|
return None
|
package/package.json
CHANGED