@danhachuel/thunderbolt 0.2.58 → 0.2.60
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 +233 -23
- package/hermes_ui/domain.py +1 -0
- package/hermes_ui/script_generation.py +4 -0
- package/package.json +1 -1
package/app/main.py
CHANGED
|
@@ -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 ""),
|
|
@@ -2223,6 +2348,68 @@ def render_upload_postiz():
|
|
|
2223
2348
|
(st.success if result.ok else st.error)(result.message)
|
|
2224
2349
|
|
|
2225
2350
|
|
|
2351
|
+
UPLOAD_DESTINATION_TARGET_KEYS = {
|
|
2352
|
+
"TikTok": "tiktok_profiles",
|
|
2353
|
+
"Instagram": "instagram_profiles",
|
|
2354
|
+
"Facebook Pages": "facebook_pages",
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def upload_target_label(target: Any) -> str:
|
|
2359
|
+
if isinstance(target, dict):
|
|
2360
|
+
name = str(target.get("name") or target.get("label") or target.get("title") or target.get("username") or target.get("id") or "Sem nome")
|
|
2361
|
+
handle = str(target.get("handle") or target.get("username") or target.get("url") or "")
|
|
2362
|
+
return f"{name} — {handle}" if handle and handle not in name else name
|
|
2363
|
+
return str(target)
|
|
2364
|
+
|
|
2365
|
+
|
|
2366
|
+
def upload_target_reference(target: Any) -> dict[str, str] | str | None:
|
|
2367
|
+
if target is None:
|
|
2368
|
+
return None
|
|
2369
|
+
if isinstance(target, dict):
|
|
2370
|
+
public_fields = ("id", "name", "label", "handle", "username", "url")
|
|
2371
|
+
return {field: str(target[field]) for field in public_fields if target.get(field)}
|
|
2372
|
+
return str(target)
|
|
2373
|
+
|
|
2374
|
+
|
|
2375
|
+
def upload_targets_for_destination(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> list[Any]:
|
|
2376
|
+
if destination == "YouTube":
|
|
2377
|
+
return [channel for channel in channels if isinstance(channel, dict) and channel.get("id") and channel.get("active", True)]
|
|
2378
|
+
setting_key = UPLOAD_DESTINATION_TARGET_KEYS.get(destination)
|
|
2379
|
+
if not setting_key:
|
|
2380
|
+
return []
|
|
2381
|
+
configured_targets = settings.get(setting_key, [])
|
|
2382
|
+
if not isinstance(configured_targets, list):
|
|
2383
|
+
return []
|
|
2384
|
+
targets: list[Any] = []
|
|
2385
|
+
for target in configured_targets:
|
|
2386
|
+
if isinstance(target, dict) and target.get("id"):
|
|
2387
|
+
targets.append(target)
|
|
2388
|
+
elif isinstance(target, str) and target.strip():
|
|
2389
|
+
targets.append(target.strip())
|
|
2390
|
+
return targets
|
|
2391
|
+
|
|
2392
|
+
|
|
2393
|
+
def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
|
|
2394
|
+
options = upload_targets_for_destination(destination, channels, settings)
|
|
2395
|
+
destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
|
|
2396
|
+
select_label = "Canal" if destination == "YouTube" else "Perfil / página"
|
|
2397
|
+
empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else f"Nenhum {destination} configurado"
|
|
2398
|
+
if not options:
|
|
2399
|
+
st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
|
|
2400
|
+
if destination == "YouTube":
|
|
2401
|
+
st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
|
|
2402
|
+
else:
|
|
2403
|
+
st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
|
|
2404
|
+
return None
|
|
2405
|
+
return st.selectbox(
|
|
2406
|
+
select_label,
|
|
2407
|
+
options,
|
|
2408
|
+
format_func=upload_target_label,
|
|
2409
|
+
key=f"upload_target_{destination_key}",
|
|
2410
|
+
)
|
|
2411
|
+
|
|
2412
|
+
|
|
2226
2413
|
def render_upload_conventional():
|
|
2227
2414
|
st.title("Upload")
|
|
2228
2415
|
settings = read_json("settings.json", {})
|
|
@@ -2233,6 +2420,12 @@ def render_upload_conventional():
|
|
|
2233
2420
|
postiz = PostizAdapter(settings)
|
|
2234
2421
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
2235
2422
|
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
|
|
2423
|
+
upload_targets: dict[str, Any | None] = {}
|
|
2424
|
+
if destination:
|
|
2425
|
+
st.markdown("**Onde enviar**")
|
|
2426
|
+
for target_destination in destination:
|
|
2427
|
+
with st.container(border=True):
|
|
2428
|
+
upload_targets[target_destination] = render_upload_destination_target(target_destination, channels, settings)
|
|
2236
2429
|
|
|
2237
2430
|
if "Instagram" in destination:
|
|
2238
2431
|
st.info("Instagram está disponível no front end. A publicação real será ligada numa etapa de credenciais/API própria.")
|
|
@@ -2277,7 +2470,8 @@ def render_upload_conventional():
|
|
|
2277
2470
|
thumbnail_path = artifacts.get("thumbnail") or artifacts.get("cover", "")
|
|
2278
2471
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
2279
2472
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
2280
|
-
|
|
2473
|
+
selected_youtube_channel = upload_targets.get("YouTube") if "YouTube" in destination else None
|
|
2474
|
+
channel = selected_youtube_channel or channel_map.get(str(task.get("channel_id")), {})
|
|
2281
2475
|
account = direct_accounts.get(str(channel.get("google_account_id", "")))
|
|
2282
2476
|
if "YouTube" in destination:
|
|
2283
2477
|
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
@@ -2292,7 +2486,9 @@ def render_upload_conventional():
|
|
|
2292
2486
|
language = st.text_input("Idioma", value="pt-BR", key=f"yt_language_{task['id']}")
|
|
2293
2487
|
quota_count = official_upload_count(channel, account)
|
|
2294
2488
|
st.caption(f"API Oficial hoje: {quota_count}/{OFFICIAL_DAILY_LIMIT} envios nesta conta Gmail.")
|
|
2295
|
-
if
|
|
2489
|
+
if not selected_youtube_channel:
|
|
2490
|
+
st.caption("Seleccione primeiro um canal YouTube no selector acima para activar este envio.")
|
|
2491
|
+
if st.button("Enviar pelo fluxo recomendado", type="primary", key=f"upload_youtube_{task['id']}", disabled=not selected_youtube_channel, help="Escolha o canal YouTube no selector acima." if not selected_youtube_channel else None):
|
|
2296
2492
|
tags = [tag.strip() for tag in tags_raw.split(",") if tag.strip()]
|
|
2297
2493
|
result = upload_with_default_route(
|
|
2298
2494
|
settings,
|
|
@@ -2312,6 +2508,7 @@ def render_upload_conventional():
|
|
|
2312
2508
|
record = {
|
|
2313
2509
|
"task_id": task.get("id"),
|
|
2314
2510
|
"destination": "YouTube",
|
|
2511
|
+
"target": upload_target_reference(channel),
|
|
2315
2512
|
"status": "published" if result.ok else "failed",
|
|
2316
2513
|
"message": result.message,
|
|
2317
2514
|
"data": result.data,
|
|
@@ -2324,8 +2521,21 @@ def render_upload_conventional():
|
|
|
2324
2521
|
if result.data.get("attempts"):
|
|
2325
2522
|
with st.expander("Detalhes dos mecanismos de upload"):
|
|
2326
2523
|
st.json(result.data["attempts"])
|
|
2327
|
-
if "TikTok" in destination
|
|
2524
|
+
tiktok_target = upload_targets.get("TikTok") if "TikTok" in destination else None
|
|
2525
|
+
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}", disabled=not tiktok_target, help="Escolha o perfil TikTok no selector acima." if not tiktok_target else None):
|
|
2328
2526
|
result = TikTokAdapter(settings).upload_video(video_path, task.get("title") or task.get("topic", ""))
|
|
2527
|
+
record = {
|
|
2528
|
+
"task_id": task.get("id"),
|
|
2529
|
+
"destination": "TikTok",
|
|
2530
|
+
"target": upload_target_reference(tiktok_target),
|
|
2531
|
+
"status": "published" if result.ok else "failed",
|
|
2532
|
+
"message": result.message,
|
|
2533
|
+
"data": result.data,
|
|
2534
|
+
"created_at": now(),
|
|
2535
|
+
}
|
|
2536
|
+
uploads = read_json("uploads.json", [])
|
|
2537
|
+
uploads.append(record)
|
|
2538
|
+
write_json("uploads.json", uploads)
|
|
2329
2539
|
(st.success if result.ok else st.warning)(result.message)
|
|
2330
2540
|
if "Instagram" in destination:
|
|
2331
2541
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
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
|
}
|
package/package.json
CHANGED