@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/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
- channels = channel_options()
692
- if not channels:
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("Modo de criação", ["Canal específico", "Lote no mesmo canal", "Lote geral"], horizontal=True, key="new_video_mode")
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 = st.multiselect("Canais incluídos", [c["id"] for c in channels], default=[c["id"] for c in channels], format_func=lambda cid: next(c["name"] for c in channels if c["id"] == cid), key="new_video_channels_general")
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
- selected_one = st.selectbox("Canal", channels, format_func=lambda c: c["name"], key="new_video_channel")
701
- selected = [selected_one["id"]]
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=topic if "topic" in locals() else "Thunderbolt music", key="new_video_suno_title")
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
- if not topic.strip() or not selected:
759
- st.error("Informe um tópico e seleccione pelo menos um canal.")
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
- quantity = int(quantity if mode == "same_channel" else 1)
762
- style = {"Pexels/Pixabay": "pexels", "full_ia": "full_ia", "Apenas Música": "music"}[wide_style_label]
763
- if style == "music" and not music_path:
764
- st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
765
- st.stop()
766
- batch = create_batch(mode, selected, topic, quantity, {"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})
767
- tasks = create_tasks_for_batch(batch)
768
- st.success(f"Lote {batch['id']} criado com {len(tasks)} tarefa(s). Abra a subaba Vídeos para acompanhar.")
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 tópico')}**")
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 _automation_topic(channel: dict[str, Any]) -> str:
131
- topic = str(channel.get("automation_topic") or "").strip()
132
- return topic or f"Geração automática — {channel.get('name') or 'Canal'}"
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