@danhachuel/thunderbolt 0.2.49 → 0.2.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +27 -18
- package/README.md +15 -5
- package/app/main.py +323 -28
- package/hermes_ui/automation_worker.py +49 -10
- package/hermes_ui/creative_generation.py +278 -0
- package/hermes_ui/domain.py +50 -21
- package/package.json +2 -1
- package/seed/references/ai-tells.md +336 -0
- package/seed/references/humanize-integration.md +147 -0
- package/seed/references/thumbnail-checklist.md +178 -0
- package/seed/references/title-formulas.md +288 -0
- package/seed/references/trend-intelligence.md +99 -0
- package/seed/references/viral-thumbnails.md +470 -0
- package/seed/references/viral-titles.md +440 -0
package/app/main.py
CHANGED
|
@@ -34,6 +34,7 @@ from hermes_ui.mcp import detect_local_service, install_skill_locally, load_inte
|
|
|
34
34
|
from hermes_ui.mcp_server import server_status, start_server, stop_server
|
|
35
35
|
from hermes_ui.music import list_music_files, materialize_suno_audio, request_suno_generation, store_music_file
|
|
36
36
|
from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
|
|
37
|
+
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
37
38
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter
|
|
38
39
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
39
40
|
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
@@ -191,6 +192,82 @@ def blueprint_catalog() -> list[tuple[str, str]]:
|
|
|
191
192
|
return options
|
|
192
193
|
|
|
193
194
|
|
|
195
|
+
def blueprint_for_channel(channel: dict) -> dict[str, Any]:
|
|
196
|
+
"""Resolve a channel Blueprint by id, filename stem or display name."""
|
|
197
|
+
blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
198
|
+
if not blueprint_id:
|
|
199
|
+
return {}
|
|
200
|
+
for path in list_blueprint_files():
|
|
201
|
+
try:
|
|
202
|
+
data = load_blueprint_file(path)
|
|
203
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
204
|
+
continue
|
|
205
|
+
identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
|
|
206
|
+
if blueprint_id in identifiers:
|
|
207
|
+
resolved = dict(data)
|
|
208
|
+
resolved.setdefault("id", blueprint_id)
|
|
209
|
+
resolved.setdefault("name", str(data.get("name") or path.stem))
|
|
210
|
+
return resolved
|
|
211
|
+
return {"id": blueprint_id, "name": blueprint_id}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def channel_blueprint_summary(channel: dict) -> dict[str, str]:
|
|
215
|
+
blueprint = blueprint_for_channel(channel)
|
|
216
|
+
configured_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
217
|
+
if not configured_id:
|
|
218
|
+
return {"id": "", "name": "SEM BLUEPRINT CONFIGURADO", "voice": str(channel.get("default_voice") or channel.get("voice") or "")}
|
|
219
|
+
return {
|
|
220
|
+
"id": configured_id,
|
|
221
|
+
"name": str(blueprint.get("name") or configured_id),
|
|
222
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_channel_blueprint_panel(channel: dict, *, compact: bool = False) -> None:
|
|
227
|
+
summary = channel_blueprint_summary(channel)
|
|
228
|
+
voice = summary["voice"] or "Sem voz padrão"
|
|
229
|
+
if summary["name"] == "SEM BLUEPRINT CONFIGURADO":
|
|
230
|
+
st.warning("**SEM BLUEPRINT CONFIGURADO** · configure um Blueprint padrão na aba Canais.")
|
|
231
|
+
elif compact:
|
|
232
|
+
st.caption(f"**Blueprint:** {summary['name']} · **Voz:** {voice}")
|
|
233
|
+
else:
|
|
234
|
+
st.info(f"**Blueprint utilizado pelo canal:** {summary['name']} · `{summary['id']}` · **Voz:** {voice}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def creative_payload_from_result(channel: dict, topic: str, creative: dict, topic_source: str = "manual") -> dict[str, Any]:
|
|
238
|
+
variant = creative.get("thumbnail_variant") or {}
|
|
239
|
+
return {
|
|
240
|
+
"topic": topic.strip(),
|
|
241
|
+
"topic_source": topic_source,
|
|
242
|
+
"title": str(creative.get("title") or topic).strip(),
|
|
243
|
+
"title_candidates": creative.get("title_candidates") or [],
|
|
244
|
+
"thumbnail_variant": variant,
|
|
245
|
+
"thumbnail_variants": creative.get("thumbnail_variants") or [],
|
|
246
|
+
"thumbnail_prompt": str(variant.get("image_prompt") or ""),
|
|
247
|
+
"thumbnail_text": str(variant.get("overlay_text") or ""),
|
|
248
|
+
"thumbnail_status": str(creative.get("thumbnail_status") or "prompt_ready"),
|
|
249
|
+
"blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
|
|
250
|
+
"blueprint_name": str(channel_blueprint_summary(channel).get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
251
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
252
|
+
"ai_generation": {"creative": creative},
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def generate_topic_for_ui(settings: dict[str, Any], channel: dict, user_context: str = "") -> dict[str, Any]:
|
|
257
|
+
return generate_topic_for_channel(settings, channel, blueprint_for_channel(channel), user_context=user_context)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def generate_creative_for_ui(settings: dict[str, Any], channel: dict, topic: str, topic_source: str = "manual") -> dict[str, Any]:
|
|
261
|
+
creative = generate_creative_package(
|
|
262
|
+
settings,
|
|
263
|
+
channel,
|
|
264
|
+
topic,
|
|
265
|
+
blueprint_for_channel(channel),
|
|
266
|
+
language=str(channel.get("language") or "Português"),
|
|
267
|
+
)
|
|
268
|
+
return creative_payload_from_result(channel, topic, creative, topic_source=topic_source)
|
|
269
|
+
|
|
270
|
+
|
|
194
271
|
def valid_hhmm(value: str) -> bool:
|
|
195
272
|
return bool(re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", str(value or "").strip()))
|
|
196
273
|
|
|
@@ -688,17 +765,94 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
688
765
|
st.title(page_title)
|
|
689
766
|
create_tab, videos_tab = st.tabs(["Criar vídeo", "Vídeos"])
|
|
690
767
|
with create_tab:
|
|
691
|
-
|
|
692
|
-
if
|
|
768
|
+
all_channels = [c for c in read_json("channels.json", []) if isinstance(c, dict)]
|
|
769
|
+
active_channels = [c for c in all_channels if c.get("active", True)]
|
|
770
|
+
if not all_channels:
|
|
693
771
|
st.warning("Cadastre pelo menos um canal antes de criar vídeos.")
|
|
694
772
|
else:
|
|
695
|
-
mode_label = st.radio(
|
|
773
|
+
mode_label = st.radio(
|
|
774
|
+
"Modo de criação",
|
|
775
|
+
["Canal específico", "Lote no mesmo canal", "Lote geral"],
|
|
776
|
+
horizontal=True,
|
|
777
|
+
key="new_video_mode",
|
|
778
|
+
)
|
|
696
779
|
mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
|
|
780
|
+
selected_one: dict[str, Any] | None = None
|
|
697
781
|
if mode == "general":
|
|
698
|
-
selected =
|
|
782
|
+
selected = [str(channel["id"]) for channel in all_channels if channel.get("id")]
|
|
783
|
+
st.info(f"**Lote geral:** será criada exactamente uma tarefa para cada um dos {len(selected)} canais cadastrados. Cada canal receberá um tema, título e thumbnail próprios; não existe selecção parcial.")
|
|
784
|
+
with st.container(border=True):
|
|
785
|
+
st.subheader("Canais que serão processados")
|
|
786
|
+
for channel in all_channels:
|
|
787
|
+
summary = channel_blueprint_summary(channel)
|
|
788
|
+
status = "Activo" if channel.get("active", True) else "Inactivo"
|
|
789
|
+
st.caption(f"**{channel.get('name', 'Canal')}** · {status} · Blueprint: **{summary['name']}** · Voz: {summary['voice'] or 'Sem voz padrão'}")
|
|
790
|
+
general_context = st.text_area(
|
|
791
|
+
"Contexto opcional para todos os canais",
|
|
792
|
+
value=st.session_state.get("new_video_general_context", ""),
|
|
793
|
+
key="new_video_general_context",
|
|
794
|
+
placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
|
|
795
|
+
)
|
|
796
|
+
if st.button("Gerar tópicos individuais para todos os canais", key="new_video_generate_general_topics", use_container_width=True):
|
|
797
|
+
settings = read_json("settings.json", {})
|
|
798
|
+
generated_topics: dict[str, dict[str, Any]] = {}
|
|
799
|
+
errors: list[str] = []
|
|
800
|
+
with st.spinner("A gerar um briefing específico para cada canal…"):
|
|
801
|
+
for channel in all_channels:
|
|
802
|
+
try:
|
|
803
|
+
generated_topics[channel["id"]] = generate_topic_for_ui(settings, channel, general_context)
|
|
804
|
+
except CreativeGenerationError as exc:
|
|
805
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
806
|
+
if errors:
|
|
807
|
+
for error in errors:
|
|
808
|
+
st.error(error)
|
|
809
|
+
else:
|
|
810
|
+
st.session_state["new_video_general_topics"] = generated_topics
|
|
811
|
+
st.success(f"Foram gerados {len(generated_topics)} briefings independentes.")
|
|
812
|
+
general_topics = st.session_state.get("new_video_general_topics", {})
|
|
813
|
+
if general_topics:
|
|
814
|
+
st.subheader("Briefings por canal")
|
|
815
|
+
for channel in all_channels:
|
|
816
|
+
result = general_topics.get(channel["id"])
|
|
817
|
+
if result:
|
|
818
|
+
st.write(f"**{channel.get('name', 'Canal')}**")
|
|
819
|
+
st.caption(f"{result.get('niche', '')} · {result.get('angle', '')}")
|
|
820
|
+
st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"new_video_general_topic_{channel['id']}", height=80)
|
|
699
821
|
else:
|
|
700
|
-
|
|
701
|
-
|
|
822
|
+
if not active_channels:
|
|
823
|
+
st.warning("Não existem canais activos disponíveis para os modos de canal específico.")
|
|
824
|
+
selected = []
|
|
825
|
+
else:
|
|
826
|
+
selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key="new_video_channel")
|
|
827
|
+
selected = [selected_one["id"]]
|
|
828
|
+
# Intentionally sits between Canal and Estilo wide, as requested.
|
|
829
|
+
render_channel_blueprint_panel(selected_one)
|
|
830
|
+
topic = st.text_area(
|
|
831
|
+
"Tópico ou briefing",
|
|
832
|
+
value=st.session_state.get("new_video_topic", ""),
|
|
833
|
+
key="new_video_topic",
|
|
834
|
+
placeholder="Escreva um briefing ou gere-o com IA; não é obrigatório escrever manualmente.",
|
|
835
|
+
help="Pode escrever o tema ou usar o botão abaixo para gerar um briefing específico com o Blueprint e o nicho do canal.",
|
|
836
|
+
)
|
|
837
|
+
topic_cols = st.columns([1, 1.8])
|
|
838
|
+
with topic_cols[0]:
|
|
839
|
+
if st.button("Gerar tópico/briefing com IA", key="new_video_generate_topic", use_container_width=True):
|
|
840
|
+
if selected_one is None:
|
|
841
|
+
st.error("Seleccione primeiro um canal.")
|
|
842
|
+
else:
|
|
843
|
+
try:
|
|
844
|
+
result = generate_topic_for_ui(read_json("settings.json", {}), selected_one, topic)
|
|
845
|
+
st.session_state["new_video_topic"] = result["topic"]
|
|
846
|
+
st.session_state["new_video_topic_meta"] = result
|
|
847
|
+
st.success("Briefing gerado; reveja e edite o texto antes de criar as tarefas.")
|
|
848
|
+
st.rerun()
|
|
849
|
+
except CreativeGenerationError as exc:
|
|
850
|
+
st.error(str(exc))
|
|
851
|
+
with topic_cols[1]:
|
|
852
|
+
if st.session_state.get("new_video_topic_meta"):
|
|
853
|
+
meta = st.session_state["new_video_topic_meta"]
|
|
854
|
+
st.caption(f"Origem: IA · Nicho: {meta.get('niche', '—')} · Ângulo: {meta.get('angle', '—')}")
|
|
855
|
+
|
|
702
856
|
wide_style_label = st.selectbox("Estilo wide", WIDE_STYLE_OPTIONS, key="new_video_style_wide")
|
|
703
857
|
style_ia = st.selectbox("Estilo IA", AI_STYLE_OPTIONS, key="new_video_style_ia") if wide_style_label == "full_ia" else ""
|
|
704
858
|
music_path = ""
|
|
@@ -725,7 +879,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
725
879
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
726
880
|
else:
|
|
727
881
|
suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
|
|
728
|
-
suno_title = st.text_input("Título da música", value=
|
|
882
|
+
suno_title = st.text_input("Título da música", value=st.session_state.get("new_video_topic") or "Thunderbolt music", key="new_video_suno_title")
|
|
729
883
|
if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
|
|
730
884
|
suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
|
|
731
885
|
(st.success if suno_result["ok"] else st.error)(suno_result["message"])
|
|
@@ -740,32 +894,165 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
|
|
|
740
894
|
except (OSError, requests.RequestException, ValueError) as exc:
|
|
741
895
|
st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
|
|
742
896
|
music_path = st.session_state.get("new_video_music_path", "")
|
|
897
|
+
|
|
898
|
+
payloads: dict[str, dict[str, Any]] = {}
|
|
899
|
+
if mode == "general":
|
|
900
|
+
existing_topics = st.session_state.get("new_video_general_topics", {})
|
|
901
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
902
|
+
if st.button("Gerar títulos e thumbnails para todos os canais", key="new_video_generate_general_creative", use_container_width=True):
|
|
903
|
+
settings = read_json("settings.json", {})
|
|
904
|
+
new_payloads: dict[str, dict[str, Any]] = {}
|
|
905
|
+
errors: list[str] = []
|
|
906
|
+
with st.spinner("A gerar títulos e thumbnails independentes por canal…"):
|
|
907
|
+
for channel in all_channels:
|
|
908
|
+
try:
|
|
909
|
+
topic_result = existing_topics.get(channel["id"])
|
|
910
|
+
if not topic_result:
|
|
911
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
912
|
+
generated = generate_creative_for_ui(settings, channel, topic_result["topic"], topic_source="llm")
|
|
913
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
914
|
+
new_payloads[channel["id"]] = generated
|
|
915
|
+
except CreativeGenerationError as exc:
|
|
916
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
917
|
+
if errors:
|
|
918
|
+
for error in errors:
|
|
919
|
+
st.error(error)
|
|
920
|
+
else:
|
|
921
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
|
|
922
|
+
st.session_state["new_video_general_payloads"] = new_payloads
|
|
923
|
+
payloads = new_payloads
|
|
924
|
+
st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
|
|
925
|
+
payloads = st.session_state.get("new_video_general_payloads", payloads)
|
|
926
|
+
for channel in all_channels:
|
|
927
|
+
payload = payloads.get(channel["id"])
|
|
928
|
+
if not payload:
|
|
929
|
+
continue
|
|
930
|
+
with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
|
|
931
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
932
|
+
if title_options:
|
|
933
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"new_video_general_title_{channel['id']}")
|
|
934
|
+
payload["title"] = selected_title
|
|
935
|
+
variants = payload.get("thumbnail_variants", [])
|
|
936
|
+
if variants:
|
|
937
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
938
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
|
|
939
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
940
|
+
payload["thumbnail_variant"] = variant
|
|
941
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
942
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
943
|
+
st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
|
|
944
|
+
st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
|
|
945
|
+
else:
|
|
946
|
+
topic_for_creative = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
947
|
+
if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
|
|
948
|
+
if selected_one is None:
|
|
949
|
+
st.error("Seleccione primeiro um canal.")
|
|
950
|
+
elif not topic_for_creative:
|
|
951
|
+
st.error("Escreva ou gere primeiro um tópico/briefing.")
|
|
952
|
+
else:
|
|
953
|
+
try:
|
|
954
|
+
generated = generate_creative_for_ui(read_json("settings.json", {}), selected_one, topic_for_creative, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
955
|
+
st.session_state["new_video_creative_payload"] = generated
|
|
956
|
+
st.success("Título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
|
|
957
|
+
st.rerun()
|
|
958
|
+
except CreativeGenerationError as exc:
|
|
959
|
+
st.error(str(exc))
|
|
960
|
+
payload = st.session_state.get("new_video_creative_payload")
|
|
961
|
+
if payload:
|
|
962
|
+
st.subheader("Título e Thumbnail automáticos")
|
|
963
|
+
title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
|
|
964
|
+
if title_options:
|
|
965
|
+
selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key="new_video_title_choice")
|
|
966
|
+
payload["title"] = selected_title
|
|
967
|
+
with st.expander(f"Ver {len(title_options)} candidatos de título"):
|
|
968
|
+
st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
|
|
969
|
+
variants = payload.get("thumbnail_variants", [])
|
|
970
|
+
if variants:
|
|
971
|
+
labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
|
|
972
|
+
selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
|
|
973
|
+
variant = variants[labels.index(selected_variant_label)]
|
|
974
|
+
payload["thumbnail_variant"] = variant
|
|
975
|
+
payload["thumbnail_prompt"] = variant.get("image_prompt", "")
|
|
976
|
+
payload["thumbnail_text"] = variant.get("overlay_text", "")
|
|
977
|
+
st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
|
|
978
|
+
st.code(variant.get("image_prompt", ""), language="text")
|
|
979
|
+
st.info("Prompt de thumbnail pronto — a imagem será criada quando existir um provider de imagem configurado; não é criado um ficheiro falso.")
|
|
980
|
+
st.session_state["new_video_creative_payload"] = payload
|
|
981
|
+
|
|
743
982
|
with st.form("new_video_form"):
|
|
744
|
-
topic = st.text_area("Tópico ou briefing", placeholder="Ex.: A história pouco conhecida por trás de...")
|
|
745
983
|
quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
|
|
746
984
|
legacy_language = st.session_state.get("video_language")
|
|
747
|
-
legacy_language_map = {
|
|
748
|
-
"Português": "36 – Português (Brasil)",
|
|
749
|
-
"English": "01 – Inglês",
|
|
750
|
-
"Español": "41 – Espanhol (LatAm)",
|
|
751
|
-
}
|
|
985
|
+
legacy_language_map = {"Português": "36 – Português (Brasil)", "English": "01 – Inglês", "Español": "41 – Espanhol (LatAm)"}
|
|
752
986
|
if legacy_language not in VIDEO_LANGUAGE_OPTIONS:
|
|
753
987
|
st.session_state["video_language"] = legacy_language_map.get(legacy_language, VIDEO_LANGUAGE_OPTIONS[0])
|
|
754
988
|
language = st.selectbox("Idioma", VIDEO_LANGUAGE_OPTIONS, key="video_language")
|
|
755
|
-
fmt = st.selectbox("Formato", ["wide", "shorts", "music"])
|
|
989
|
+
fmt = st.selectbox("Formato", ["wide", "shorts", "music"], key="new_video_format")
|
|
756
990
|
submitted = st.form_submit_button("Criar tarefas", type="primary")
|
|
757
991
|
if submitted:
|
|
758
|
-
|
|
759
|
-
|
|
992
|
+
style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
|
|
993
|
+
if style == "music" and not music_path:
|
|
994
|
+
st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
|
|
995
|
+
st.stop()
|
|
996
|
+
if mode == "general":
|
|
997
|
+
payloads = dict(st.session_state.get("new_video_general_payloads", {}))
|
|
998
|
+
topics = dict(st.session_state.get("new_video_general_topics", {}))
|
|
999
|
+
channels_by_id = {str(channel["id"]): channel for channel in all_channels}
|
|
1000
|
+
payloads_need_refresh = len(payloads) != len(selected) or any(
|
|
1001
|
+
str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1002
|
+
and str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip() != str((payloads.get(channel_id) or {}).get("topic") or "").strip()
|
|
1003
|
+
for channel_id in selected
|
|
1004
|
+
)
|
|
1005
|
+
if payloads_need_refresh:
|
|
1006
|
+
settings = read_json("settings.json", {})
|
|
1007
|
+
generated_payloads: dict[str, dict[str, Any]] = {}
|
|
1008
|
+
errors: list[str] = []
|
|
1009
|
+
with st.spinner("A gerar automaticamente um pacote criativo independente para cada canal…"):
|
|
1010
|
+
for channel_id in selected:
|
|
1011
|
+
channel = channels_by_id[channel_id]
|
|
1012
|
+
edited_topic = str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
|
|
1013
|
+
topic_result = topics.get(channel_id) or {}
|
|
1014
|
+
try:
|
|
1015
|
+
if not edited_topic:
|
|
1016
|
+
topic_result = generate_topic_for_ui(settings, channel, general_context)
|
|
1017
|
+
edited_topic = topic_result["topic"]
|
|
1018
|
+
else:
|
|
1019
|
+
topic_result = {**topic_result, "topic": edited_topic, "topic_source": topic_result.get("topic_source", "manual")}
|
|
1020
|
+
generated = generate_creative_for_ui(settings, channel, edited_topic, topic_source=topic_result.get("topic_source", "llm"))
|
|
1021
|
+
generated["ai_generation"]["topic"] = topic_result
|
|
1022
|
+
generated_payloads[channel_id] = generated
|
|
1023
|
+
except CreativeGenerationError as exc:
|
|
1024
|
+
errors.append(f"{channel.get('name', 'Canal')}: {exc}")
|
|
1025
|
+
if errors:
|
|
1026
|
+
for error in errors:
|
|
1027
|
+
st.error(error)
|
|
1028
|
+
st.error("O Lote geral não foi criado porque faltou gerar o conteúdo específico de pelo menos um canal.")
|
|
1029
|
+
else:
|
|
1030
|
+
payloads = generated_payloads
|
|
1031
|
+
st.session_state["new_video_general_payloads"] = payloads
|
|
1032
|
+
st.session_state["new_video_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
|
|
1033
|
+
if len(payloads) == len(selected):
|
|
1034
|
+
batch_topic = "Lote geral — um vídeo independente por canal"
|
|
1035
|
+
channel_payloads = {cid: {**payload, "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source} for cid, payload in payloads.items()}
|
|
1036
|
+
batch = create_batch("general", selected, batch_topic, 1, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": "llm", "channel_payloads": channel_payloads})
|
|
1037
|
+
tasks = create_tasks_for_batch(batch)
|
|
1038
|
+
st.success(f"Lote geral {batch['id']} criado com {len(tasks)} tarefas independentes, uma por canal.")
|
|
760
1039
|
else:
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
1040
|
+
topic_value = str(st.session_state.get("new_video_topic", "") or "").strip()
|
|
1041
|
+
if not topic_value or not selected:
|
|
1042
|
+
st.error("Escreva ou gere um tópico e seleccione um canal.")
|
|
1043
|
+
else:
|
|
1044
|
+
quantity_value = int(quantity if mode == "same_channel" else 1)
|
|
1045
|
+
payload = dict(st.session_state.get("new_video_creative_payload") or {})
|
|
1046
|
+
if not payload.get("title") or not payload.get("thumbnail_variants"):
|
|
1047
|
+
try:
|
|
1048
|
+
payload = generate_creative_for_ui(read_json("settings.json", {}), selected_one or {}, topic_value, topic_source="llm" if st.session_state.get("new_video_topic_meta") else "manual")
|
|
1049
|
+
except CreativeGenerationError as exc:
|
|
1050
|
+
st.warning(f"Título/thumbnail automáticos pendentes: {exc} A tarefa será criada com o tópico como título e sem ficheiro de thumbnail.")
|
|
1051
|
+
payload = {"topic": topic_value, "title": topic_value, "topic_source": "manual", "thumbnail_status": "pending_provider", "thumbnail_variants": [], "thumbnail_variant": {}, "thumbnail_prompt": "", "thumbnail_text": ""}
|
|
1052
|
+
payload.update({"topic": topic_value, "topic_source": payload.get("topic_source") or ("llm" if st.session_state.get("new_video_topic_meta") else "manual"), "language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source})
|
|
1053
|
+
batch = create_batch(mode, selected, topic_value, quantity_value, {"language": language, "format": fmt, "style_wide": style, "style_ia": style_ia, "music_mode": style == "music", "background_mode": "none" if style == "music" else ("ai" if style == "full_ia" else "stock"), "music_path": music_path, "music_source": music_source, "topic_source": payload.get("topic_source", "manual"), "channel_payloads": {selected[0]: payload}})
|
|
1054
|
+
tasks = create_tasks_for_batch(batch)
|
|
1055
|
+
st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
|
|
769
1056
|
with videos_tab:
|
|
770
1057
|
render_videos()
|
|
771
1058
|
|
|
@@ -1173,8 +1460,16 @@ def render_videos():
|
|
|
1173
1460
|
with st.container(border=True):
|
|
1174
1461
|
cols = st.columns([2.2, 1, 1, 1.2, 1.8])
|
|
1175
1462
|
with cols[0]:
|
|
1176
|
-
st.write(f"**{task.get('topic', 'Sem
|
|
1463
|
+
st.write(f"**{task.get('title') or task.get('topic', 'Sem título')}**")
|
|
1464
|
+
st.caption(f"Tópico: {task.get('topic', 'Sem tópico')}")
|
|
1177
1465
|
st.caption(f"{task.get('channel_name')} · {task.get('id')}")
|
|
1466
|
+
thumbnail_path = (task.get('artifacts') or {}).get('thumbnail', '')
|
|
1467
|
+
if thumbnail_path and Path(thumbnail_path).is_file():
|
|
1468
|
+
st.image(thumbnail_path, width=180)
|
|
1469
|
+
else:
|
|
1470
|
+
status = task.get('thumbnail_status', 'not_generated')
|
|
1471
|
+
prompt_note = ' · prompt pronto' if task.get('thumbnail_prompt') else ''
|
|
1472
|
+
st.caption(f"Thumbnail: {status}{prompt_note}")
|
|
1178
1473
|
with cols[1]: st.write(task.get("format", "wide"))
|
|
1179
1474
|
with cols[2]: st.write(task.get("stage", "—"))
|
|
1180
1475
|
with cols[3]: st.write(task.get("state", "—"))
|
|
@@ -1312,7 +1607,7 @@ def render_upload_direct():
|
|
|
1312
1607
|
st.caption(f"Documento de credenciais pronto: {account.get('email', 'conta Google')} · dados do canal encontrados no documento")
|
|
1313
1608
|
direct_cols = st.columns([2.2, 1, 1])
|
|
1314
1609
|
with direct_cols[0]:
|
|
1315
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1610
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"direct_title_{task['id']}")
|
|
1316
1611
|
with direct_cols[1]:
|
|
1317
1612
|
privacy = st.selectbox("Privacidade", ["private", "unlisted", "public"], key=f"direct_privacy_{task['id']}")
|
|
1318
1613
|
with direct_cols[2]:
|
|
@@ -1387,7 +1682,7 @@ def render_upload_conventional():
|
|
|
1387
1682
|
captions_path = artifacts.get("captions") or artifacts.get("subtitle", "")
|
|
1388
1683
|
st.caption(video_path or "Sem caminho de vídeo registado")
|
|
1389
1684
|
if "YouTube" in destination:
|
|
1390
|
-
title = st.text_input("Título", value=task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1685
|
+
title = st.text_input("Título", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"yt_title_{task['id']}")
|
|
1391
1686
|
description = st.text_area("Descrição", value=task.get("description", ""), key=f"yt_description_{task['id']}", height=100)
|
|
1392
1687
|
tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
|
|
1393
1688
|
yt_cols = st.columns(3)
|
|
@@ -1426,7 +1721,7 @@ def render_upload_conventional():
|
|
|
1426
1721
|
with st.expander("Detalhes dos mecanismos de upload"):
|
|
1427
1722
|
st.json(result.data["attempts"])
|
|
1428
1723
|
if "TikTok" in destination and st.button("Enviar para TikTok", key=f"upload_tiktok_{task['id']}"):
|
|
1429
|
-
result = TikTokAdapter(settings).upload_video(video_path, task.get("topic", ""))
|
|
1724
|
+
result = TikTokAdapter(settings).upload_video(video_path, task.get("title") or task.get("topic", ""))
|
|
1430
1725
|
(st.success if result.ok else st.warning)(result.message)
|
|
1431
1726
|
if "Instagram" in destination:
|
|
1432
1727
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
from typing import Any
|
|
9
9
|
|
|
10
10
|
from . import storage
|
|
11
|
+
from .creative_generation import generate_creative_package, generate_topic_for_channel
|
|
11
12
|
from .domain import create_batch, create_tasks_for_batch
|
|
12
13
|
|
|
13
14
|
WORKER_STATE_FILE = "automation_worker.json"
|
|
@@ -127,9 +128,50 @@ def _daily_quantity(channel: dict[str, Any]) -> int:
|
|
|
127
128
|
return 1
|
|
128
129
|
|
|
129
130
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
def _blueprint_for_channel(channel: dict[str, Any]) -> dict[str, Any]:
|
|
132
|
+
blueprint_id = str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or "").strip()
|
|
133
|
+
if not blueprint_id:
|
|
134
|
+
return {}
|
|
135
|
+
for path in storage.list_blueprint_files():
|
|
136
|
+
try:
|
|
137
|
+
data = storage.load_blueprint_file(path)
|
|
138
|
+
except (OSError, ValueError):
|
|
139
|
+
continue
|
|
140
|
+
identifiers = {str(data.get("id") or ""), path.stem, str(data.get("name") or "")}
|
|
141
|
+
if blueprint_id in identifiers:
|
|
142
|
+
return data
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _creative_payload(channel: dict[str, Any]) -> tuple[str, dict[str, Any]]:
|
|
147
|
+
settings = storage.read_json("settings.json", {})
|
|
148
|
+
blueprint = _blueprint_for_channel(channel)
|
|
149
|
+
user_context = str(channel.get("automation_topic") or "").strip()
|
|
150
|
+
topic_package = generate_topic_for_channel(settings, channel, blueprint, user_context=user_context)
|
|
151
|
+
creative = generate_creative_package(
|
|
152
|
+
settings,
|
|
153
|
+
channel,
|
|
154
|
+
topic_package["topic"],
|
|
155
|
+
blueprint,
|
|
156
|
+
language=str(channel.get("language") or "Português"),
|
|
157
|
+
)
|
|
158
|
+
variant = creative["thumbnail_variant"]
|
|
159
|
+
payload = {
|
|
160
|
+
"topic": topic_package["topic"],
|
|
161
|
+
"topic_source": "llm",
|
|
162
|
+
"title": creative["title"],
|
|
163
|
+
"title_candidates": creative["title_candidates"],
|
|
164
|
+
"thumbnail_variant": variant,
|
|
165
|
+
"thumbnail_variants": creative["thumbnail_variants"],
|
|
166
|
+
"thumbnail_prompt": variant.get("image_prompt", ""),
|
|
167
|
+
"thumbnail_text": variant.get("overlay_text", ""),
|
|
168
|
+
"thumbnail_status": creative.get("thumbnail_status", "prompt_ready"),
|
|
169
|
+
"blueprint_id": str(channel.get("default_blueprint_id") or channel.get("blueprint_id") or ""),
|
|
170
|
+
"blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
|
|
171
|
+
"voice": str(channel.get("default_voice") or channel.get("voice") or ""),
|
|
172
|
+
"ai_generation": {"topic": topic_package, "creative": creative},
|
|
173
|
+
}
|
|
174
|
+
return topic_package["topic"], payload
|
|
133
175
|
|
|
134
176
|
|
|
135
177
|
def _batch_for_day(channel_id: str, day: str) -> dict[str, Any] | None:
|
|
@@ -155,6 +197,7 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
|
|
|
155
197
|
date_key = when.date().isoformat()
|
|
156
198
|
style_wide = str(channel.get("style_wide") or "pexels")
|
|
157
199
|
music_mode = style_wide == "music"
|
|
200
|
+
topic, payload = _creative_payload(channel)
|
|
158
201
|
options = {
|
|
159
202
|
"language": channel.get("language") or "Português",
|
|
160
203
|
"format": "wide",
|
|
@@ -164,17 +207,13 @@ def _create_channel_batch(channel: dict[str, Any], when: datetime) -> dict[str,
|
|
|
164
207
|
"background_mode": "none" if music_mode else ("ai" if style_wide == "full_ia" else "stock"),
|
|
165
208
|
"music_path": channel.get("music_path") or "",
|
|
166
209
|
"music_source": channel.get("music_source") or "",
|
|
210
|
+
"topic_source": "llm",
|
|
211
|
+
"channel_payloads": {channel_id: payload},
|
|
167
212
|
"automation_worker": True,
|
|
168
213
|
"automation_date": date_key,
|
|
169
214
|
"automation_scheduled_at": _local_iso(when),
|
|
170
215
|
}
|
|
171
|
-
batch = create_batch(
|
|
172
|
-
"single",
|
|
173
|
-
[channel_id],
|
|
174
|
-
_automation_topic(channel),
|
|
175
|
-
_daily_quantity(channel),
|
|
176
|
-
options,
|
|
177
|
-
)
|
|
216
|
+
batch = create_batch("single", [channel_id], topic, _daily_quantity(channel), options)
|
|
178
217
|
tasks = create_tasks_for_batch(batch)
|
|
179
218
|
return {"batch": batch, "tasks": tasks, "channel_id": channel_id}
|
|
180
219
|
|