@danhachuel/thunderbolt 0.3.20 → 0.3.22

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
@@ -22,6 +22,7 @@ except (OSError, json.JSONDecodeError):
22
22
  APP_VERSION = ""
23
23
 
24
24
  from hermes_ui.domain import STAGES, create_batch, create_channel, create_tasks_for_batch, delete_channel, pipeline_summary, set_channel_defaults, transition_task, update_channel, update_channel_video
25
+ from hermes_ui.drafts import save_draft
25
26
  from hermes_ui.automation_worker import load_worker_status
26
27
  from hermes_ui.storage import BLUEPRINTS, DEFAULT_LLM_PROVIDER, MEDIA_DOWNLOADS, STORAGE, TIKTOK_PROMPT_MASTERS, ensure_storage, get_display_name, list_blueprint_files, list_prompt_master_files, load_blueprint_file, load_prompt_master_file, now, read_json, set_display_name, write_json
27
28
  from app.modules.niche_finder.apify import ApifyError, DEFAULT_ACTOR_ID, abort_actor_run, build_actor_input, get_dataset_items, normalize_video_items, start_actor_run, wait_for_actor_run
@@ -45,6 +46,7 @@ from hermes_ui.script_documents import list_script_documents, read_script_docume
45
46
  from hermes_ui.script_generation import generate_script_document
46
47
  from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesize_preview
47
48
  from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
49
+ from hermes_ui.thumbnails import list_thumbnail_tasks, regenerate_thumbnail
48
50
  from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel, generate_video_keywords
49
51
  from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
50
52
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
@@ -520,17 +522,19 @@ def generate_video_content_for_ui(
520
522
  subject: str,
521
523
  language: str,
522
524
  generation_settings: dict[str, Any] | None = None,
525
+ blueprint: dict[str, Any] | None = None,
523
526
  ) -> dict[str, Any]:
524
527
  """Generate the subject, script and English keywords in one MoneyPrinter-style action."""
525
528
  subject = str(subject or "").strip()
526
529
  topic_result: dict[str, Any] | None = None
527
530
  if not subject:
528
- topic_result = generate_topic_for_ui(settings, channel)
531
+ selected_blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
532
+ topic_result = generate_topic_for_channel(settings, channel, selected_blueprint)
529
533
  subject = str(topic_result.get("topic") or "").strip()
530
534
  if not subject:
531
535
  raise CreativeGenerationError("A IA não devolveu um Video Subject válido.")
532
536
 
533
- blueprint = blueprint_for_channel(channel)
537
+ blueprint = blueprint if isinstance(blueprint, dict) else blueprint_for_channel(channel)
534
538
  script_result = generate_script_document(
535
539
  settings,
536
540
  document_type="Roteiro de vídeo",
@@ -561,7 +565,7 @@ def generate_video_content_for_ui(
561
565
  }
562
566
 
563
567
 
564
- def _generate_video_content_callback(prefix: str, channel: dict, fallback_language: str) -> None:
568
+ def _generate_video_content_callback(prefix: str, channel: dict, fallback_language: str, blueprint: dict[str, Any] | None = None) -> None:
565
569
  """Streamlit callback for the single button below Subject, Script and Keywords."""
566
570
  settings = read_json("settings.json", {})
567
571
  generation_settings = {
@@ -577,17 +581,30 @@ def _generate_video_content_callback(prefix: str, channel: dict, fallback_langua
577
581
  generation_settings["video_subject"],
578
582
  generation_settings["script_language"],
579
583
  generation_settings=generation_settings,
584
+ blueprint=blueprint,
580
585
  )
581
586
  st.session_state[f"{prefix}_video_subject"] = result["topic"]
582
587
  st.session_state[f"{prefix}_video_script"] = result["script"]
583
588
  st.session_state[f"{prefix}_video_keywords"] = ", ".join(result["keywords"])
584
- st.session_state["new_video_topic"] = result["topic"]
585
- st.session_state["new_video_topic_meta"] = result.get("topic_result") or {
589
+ if prefix == "pipeline_scripts":
590
+ script_result = result.get("script_result") or {}
591
+ st.session_state["script_draft"] = {
592
+ "title": result["topic"],
593
+ "summary": str(script_result.get("summary") or ""),
594
+ "content": result["script"],
595
+ "keywords": result["keywords"],
596
+ }
597
+ st.session_state["script_draft_title"] = result["topic"]
598
+ st.session_state["script_draft_summary"] = str(script_result.get("summary") or "")
599
+ st.session_state["script_draft_content"] = result["script"]
600
+ st.session_state["script_draft_keywords"] = ", ".join(result["keywords"])
601
+ st.session_state[f"{prefix}_topic"] = result["topic"]
602
+ st.session_state[f"{prefix}_topic_meta"] = result.get("topic_result") or {
586
603
  "topic": result["topic"],
587
604
  "topic_source": result.get("topic_source", "manual"),
588
605
  }
589
606
  # A subject change invalidates a previously generated title/thumbnail package.
590
- st.session_state.pop("new_video_creative_payload", None)
607
+ st.session_state.pop(f"{prefix}_creative_payload", None)
591
608
  st.session_state[f"{prefix}_generate_content_notice"] = "Tema, roteiro e palavras-chave gerados com IA."
592
609
  st.session_state.pop(f"{prefix}_generate_content_error", None)
593
610
  except CreativeGenerationError as exc:
@@ -595,8 +612,57 @@ def _generate_video_content_callback(prefix: str, channel: dict, fallback_langua
595
612
  st.session_state.pop(f"{prefix}_generate_content_notice", None)
596
613
 
597
614
 
598
- def _video_topic_source(subject: str) -> str:
599
- meta = st.session_state.get("new_video_topic_meta") or {}
615
+ def _save_pipeline_draft_callback(
616
+ prefix: str,
617
+ draft_kind: str,
618
+ page_title: str,
619
+ *,
620
+ channel: dict[str, Any] | None = None,
621
+ blueprint: dict[str, Any] | None = None,
622
+ document_type: str = "video_script",
623
+ title: str = "",
624
+ brief: str = "",
625
+ ) -> None:
626
+ """Persist the current editable pipeline fields as a local draft."""
627
+ channel = channel or {}
628
+ blueprint = blueprint or {}
629
+ is_script_draft = prefix == "pipeline_scripts"
630
+ subject = str(st.session_state.get(f"{prefix}_video_subject") or "").strip()
631
+ script = str(st.session_state.get(f"{prefix}_video_script") or "").strip()
632
+ keywords = str(st.session_state.get(f"{prefix}_video_keywords") or (st.session_state.get("script_draft_keywords") if is_script_draft else "") or "").strip()
633
+ draft_title = str((st.session_state.get("script_draft_title") if is_script_draft else "") or title or subject or page_title).strip()
634
+ draft_brief = str((st.session_state.get("script_brief") if is_script_draft else "") or brief or subject).strip()
635
+ draft_content = str((st.session_state.get("script_draft_content") if is_script_draft else "") or script).strip()
636
+ if not any((draft_title, draft_brief, draft_content, keywords)):
637
+ st.session_state[f"{prefix}_save_draft_error"] = "Preencha pelo menos um campo antes de guardar o rascunho."
638
+ st.session_state.pop(f"{prefix}_save_draft_notice", None)
639
+ return
640
+
641
+ record = save_draft(
642
+ {
643
+ "draft_kind": draft_kind,
644
+ "page": page_title,
645
+ "title": draft_title,
646
+ "brief": draft_brief,
647
+ "content": draft_content,
648
+ "video_subject": subject,
649
+ "video_script": script,
650
+ "video_keywords": keywords,
651
+ "summary": str((st.session_state.get("script_draft_summary") if is_script_draft else "") or "").strip(),
652
+ "document_type": document_type,
653
+ "language": str(st.session_state.get(f"{prefix}_script_language") or "").strip(),
654
+ "channel_id": str(channel.get("id") or ""),
655
+ "channel_name": str(channel.get("name") or "Documento independente"),
656
+ "blueprint_id": str(blueprint.get("id") or ""),
657
+ "blueprint_name": str(blueprint.get("name") or "SEM BLUEPRINT CONFIGURADO"),
658
+ }
659
+ )
660
+ st.session_state[f"{prefix}_save_draft_notice"] = f"Rascunho guardado localmente: {record['id']}."
661
+ st.session_state.pop(f"{prefix}_save_draft_error", None)
662
+
663
+
664
+ def _video_topic_source(subject: str, prefix: str = "new_video") -> str:
665
+ meta = st.session_state.get(f"{prefix}_topic_meta") or {}
600
666
  generated_topic = str(meta.get("topic") or "").strip() if isinstance(meta, dict) else ""
601
667
  return "llm" if generated_topic and generated_topic == str(subject or "").strip() and meta.get("topic_source") == "llm" else "manual"
602
668
 
@@ -645,6 +711,7 @@ def render_video_generation_settings(
645
711
  current_language: str = "",
646
712
  channel: dict[str, Any] | None = None,
647
713
  generate_content_callback: Any | None = None,
714
+ save_draft_callback: Any | None = None,
648
715
  ) -> dict[str, Any]:
649
716
  """Render the shared MoneyPrinter-style settings and return a serializable payload."""
650
717
  settings: dict[str, Any] = {}
@@ -701,6 +768,20 @@ def render_video_generation_settings(
701
768
  if st.session_state.get(f"{prefix}_generate_content_error"):
702
769
  st.error(st.session_state[f"{prefix}_generate_content_error"])
703
770
 
771
+ if save_draft_callback is not None:
772
+ st.button(
773
+ "Salvar rascunho",
774
+ key=f"{prefix}_save_draft",
775
+ use_container_width=True,
776
+ type="secondary",
777
+ icon=":material/save:",
778
+ on_click=save_draft_callback,
779
+ )
780
+ if st.session_state.get(f"{prefix}_save_draft_notice"):
781
+ st.success(st.session_state[f"{prefix}_save_draft_notice"])
782
+ if st.session_state.get(f"{prefix}_save_draft_error"):
783
+ st.error(st.session_state[f"{prefix}_save_draft_error"])
784
+
704
785
  st.markdown("### Video Settings")
705
786
  video_cols = st.columns(2)
706
787
  with video_cols[0]:
@@ -1667,7 +1748,7 @@ def render_channels():
1667
1748
  render_channel_videos(channel)
1668
1749
 
1669
1750
 
1670
- def render_new_video(page_title: str = "Criação de Vídeos"):
1751
+ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "new_video"):
1671
1752
  st.title(page_title)
1672
1753
  create_tab = render_localized_tabs(["Criar vídeo"])[0]
1673
1754
  with create_tab:
@@ -1680,7 +1761,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1680
1761
  "Modo de criação",
1681
1762
  ["Canal específico", "Lote no mesmo canal", "Lote geral"],
1682
1763
  horizontal=True,
1683
- key="new_video_mode",
1764
+ key=f"{prefix}_mode",
1684
1765
  )
1685
1766
  mode = {"Canal específico": "single", "Lote no mesmo canal": "same_channel", "Lote geral": "general"}[mode_label]
1686
1767
  selected_one: dict[str, Any] | None = None
@@ -1701,11 +1782,11 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1701
1782
  st.caption(f"**{channel.get('name', 'Canal')}** · {status} · Blueprint: **{summary['name']}** · Voz: {summary['voice'] or 'Sem voz padrão'}")
1702
1783
  general_context = st.text_area(
1703
1784
  "Contexto opcional para todos os canais",
1704
- value=st.session_state.get("new_video_general_context", ""),
1705
- key="new_video_general_context",
1785
+ value=st.session_state.get(f"{prefix}_general_context", ""),
1786
+ key=f"{prefix}_general_context",
1706
1787
  placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
1707
1788
  )
1708
- if st.button("Gerar tópicos individuais para todos os canais", key="new_video_generate_general_topics", use_container_width=True):
1789
+ if st.button("Gerar tópicos individuais para todos os canais", key=f"{prefix}_generate_general_topics", use_container_width=True):
1709
1790
  settings = read_json("settings.json", {})
1710
1791
  generated_topics: dict[str, dict[str, Any]] = {}
1711
1792
  errors: list[str] = []
@@ -1719,9 +1800,9 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1719
1800
  for error in errors:
1720
1801
  st.error(error)
1721
1802
  else:
1722
- st.session_state["new_video_general_topics"] = generated_topics
1803
+ st.session_state[f"{prefix}_general_topics"] = generated_topics
1723
1804
  st.success(f"Foram gerados {len(generated_topics)} briefings independentes.")
1724
- general_topics = st.session_state.get("new_video_general_topics", {})
1805
+ general_topics = st.session_state.get(f"{prefix}_general_topics", {})
1725
1806
  if general_topics:
1726
1807
  st.subheader("Briefings por canal")
1727
1808
  for channel in all_channels:
@@ -1729,35 +1810,56 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1729
1810
  if result:
1730
1811
  st.write(f"**{channel.get('name', 'Canal')}**")
1731
1812
  st.caption(f"{result.get('niche', '')} · {result.get('angle', '')}")
1732
- st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"new_video_general_topic_{channel['id']}", height=80)
1813
+ st.text_area("Briefing gerado", value=result.get("topic", ""), key=f"{prefix}_general_topic_{channel['id']}", height=80)
1733
1814
  generation_settings = render_video_generation_settings(
1734
- "new_video",
1815
+ prefix,
1735
1816
  current_language=str(st.session_state.get("video_language") or ""),
1817
+ save_draft_callback=lambda: _save_pipeline_draft_callback(
1818
+ prefix,
1819
+ "music" if page_title == "Criação de Músicas" else "video",
1820
+ page_title,
1821
+ channel=selected_one,
1822
+ blueprint=blueprint_for_channel(selected_one or {}),
1823
+ ),
1736
1824
  )
1737
1825
  else:
1738
1826
  if not active_channels:
1739
1827
  st.warning("Não existem canais activos disponíveis para os modos de canal específico.")
1740
1828
  selected = []
1741
1829
  else:
1742
- selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key="new_video_channel")
1830
+ selected_one = st.selectbox("Canal", active_channels, format_func=lambda c: c["name"], key=f"{prefix}_channel")
1743
1831
  selected = [selected_one["id"]]
1744
1832
  # Intentionally sits between Canal and the generation settings, as requested.
1745
1833
  render_channel_blueprint_panel(selected_one)
1746
1834
  generation_settings = render_video_generation_settings(
1747
- "new_video",
1835
+ prefix,
1748
1836
  current_language=str(st.session_state.get("video_language") or ""),
1749
1837
  channel=selected_one,
1750
1838
  generate_content_callback=lambda: _generate_video_content_callback(
1751
- "new_video",
1839
+ prefix,
1752
1840
  selected_one,
1753
1841
  str(st.session_state.get("video_language") or "pt"),
1754
1842
  ),
1843
+ save_draft_callback=lambda: _save_pipeline_draft_callback(
1844
+ prefix,
1845
+ "music" if page_title == "Criação de Músicas" else "video",
1846
+ page_title,
1847
+ channel=selected_one,
1848
+ blueprint=blueprint_for_channel(selected_one or {}),
1849
+ ),
1755
1850
  )
1756
1851
 
1757
1852
  if not generation_settings:
1758
1853
  generation_settings = render_video_generation_settings(
1759
- "new_video",
1854
+ prefix,
1760
1855
  current_language=str(st.session_state.get("video_language") or ""),
1856
+ save_draft_callback=lambda: _save_pipeline_draft_callback(
1857
+ prefix,
1858
+ "music" if page_title == "Criação de Músicas" else "video",
1859
+ page_title,
1860
+ channel=selected_one,
1861
+ blueprint=blueprint_for_channel(selected_one or {}),
1862
+ ),
1761
1863
  )
1762
1864
  wide_style_label = generation_settings["video_source"]
1763
1865
  style_ia = generation_settings.get("style_ia", "")
@@ -1765,47 +1867,47 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1765
1867
  music_source = ""
1766
1868
  if wide_style_label == "Apenas Música":
1767
1869
  st.caption("Apenas Música não gera Pexels/Pixabay nem fundo IA; o áudio musical será usado como elemento principal.")
1768
- music_source = st.radio("Fonte da música", ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API"], horizontal=True, key="new_video_music_source")
1870
+ music_source = st.radio("Fonte da música", ["Ficheiro existente", "Carregar ficheiro", "Criar via Suno API"], horizontal=True, key=f"{prefix}_music_source")
1769
1871
  if music_source == "Ficheiro existente":
1770
1872
  local_music = list_music_files()
1771
1873
  if local_music:
1772
- selected_music = st.selectbox("Música local", local_music, format_func=lambda item: item.name, key="new_video_music_existing")
1874
+ selected_music = st.selectbox("Música local", local_music, format_func=lambda item: item.name, key=f"{prefix}_music_existing")
1773
1875
  music_path = str(selected_music)
1774
1876
  else:
1775
1877
  st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
1776
1878
  elif music_source == "Carregar ficheiro":
1777
- uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key="new_video_music_upload")
1778
- if uploaded_music and st.button("Guardar música local", key="new_video_music_store", use_container_width=True):
1879
+ uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key=f"{prefix}_music_upload")
1880
+ if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", use_container_width=True):
1779
1881
  try:
1780
1882
  stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
1781
- st.session_state["new_video_music_path"] = str(stored_music)
1883
+ st.session_state[f"{prefix}_music_path"] = str(stored_music)
1782
1884
  st.success(f"Música guardada em `{stored_music}`")
1783
1885
  except (OSError, ValueError) as exc:
1784
1886
  st.error(str(exc))
1785
- music_path = st.session_state.get("new_video_music_path", "")
1887
+ music_path = st.session_state.get(f"{prefix}_music_path", "")
1786
1888
  else:
1787
- suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key="new_video_suno_prompt")
1788
- 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")
1789
- if st.button("Solicitar música no Suno", key="new_video_suno_request", use_container_width=True):
1889
+ suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key=f"{prefix}_suno_prompt")
1890
+ suno_title = st.text_input("Título da música", value=st.session_state.get(f"{prefix}_topic") or "Thunderbolt music", key=f"{prefix}_suno_title")
1891
+ if st.button("Solicitar música no Suno", key=f"{prefix}_suno_request", use_container_width=True):
1790
1892
  suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
1791
1893
  (st.success if suno_result["ok"] else st.error)(suno_result["message"])
1792
1894
  if suno_result["ok"]:
1793
1895
  try:
1794
1896
  generated = materialize_suno_audio(suno_result.get("data", {}), suno_title or "suno-generated.mp3")
1795
1897
  if generated:
1796
- st.session_state["new_video_music_path"] = str(generated)
1898
+ st.session_state[f"{prefix}_music_path"] = str(generated)
1797
1899
  st.success(f"Música descarregada para `{generated}`")
1798
1900
  else:
1799
1901
  st.info("O pedido foi aceite, mas o endpoint ainda não devolveu uma URL de áudio. Consulte o estado no serviço Suno e adicione o ficheiro quando estiver pronto.")
1800
1902
  except (OSError, requests.RequestException, ValueError) as exc:
1801
1903
  st.warning(f"Pedido criado, mas não foi possível descarregar o áudio: {exc}")
1802
- music_path = st.session_state.get("new_video_music_path", "")
1904
+ music_path = st.session_state.get(f"{prefix}_music_path", "")
1803
1905
 
1804
1906
  payloads: dict[str, dict[str, Any]] = {}
1805
1907
  if mode == "general":
1806
- existing_topics = st.session_state.get("new_video_general_topics", {})
1807
- payloads = dict(st.session_state.get("new_video_general_payloads", {}))
1808
- if st.button("Gerar títulos e thumbnails para todos os canais", key="new_video_generate_general_creative", use_container_width=True):
1908
+ existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
1909
+ payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
1910
+ if st.button("Gerar títulos e thumbnails para todos os canais", key=f"{prefix}_generate_general_creative", use_container_width=True):
1809
1911
  settings = read_json("settings.json", {})
1810
1912
  new_payloads: dict[str, dict[str, Any]] = {}
1811
1913
  errors: list[str] = []
@@ -1824,11 +1926,11 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1824
1926
  for error in errors:
1825
1927
  st.error(error)
1826
1928
  else:
1827
- st.session_state["new_video_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
1828
- st.session_state["new_video_general_payloads"] = new_payloads
1929
+ st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": item["topic"], "topic_source": "llm"} for cid, item in new_payloads.items()}
1930
+ st.session_state[f"{prefix}_general_payloads"] = new_payloads
1829
1931
  payloads = new_payloads
1830
1932
  st.success(f"Pacote criativo pronto para {len(new_payloads)} canais.")
1831
- payloads = st.session_state.get("new_video_general_payloads", payloads)
1933
+ payloads = st.session_state.get(f"{prefix}_general_payloads", payloads)
1832
1934
  for channel in all_channels:
1833
1935
  payload = payloads.get(channel["id"])
1834
1936
  if not payload:
@@ -1836,12 +1938,12 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1836
1938
  with st.expander(f"{channel.get('name', 'Canal')} — título e thumbnail", expanded=False):
1837
1939
  title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
1838
1940
  if title_options:
1839
- 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']}")
1941
+ 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"{prefix}_general_title_{channel['id']}")
1840
1942
  payload["title"] = selected_title
1841
1943
  variants = payload.get("thumbnail_variants", [])
1842
1944
  if variants:
1843
1945
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1844
- selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"new_video_general_thumbnail_{channel['id']}")
1946
+ selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_general_thumbnail_{channel['id']}")
1845
1947
  variant_index = labels.index(selected_variant_label)
1846
1948
  variant = variants[variant_index]
1847
1949
  payload["thumbnail_variant"] = variant
@@ -1849,13 +1951,13 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1849
1951
  payload["thumbnail_text"] = variant.get("overlay_text", "")
1850
1952
  st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
1851
1953
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
1852
- if st.button("Gerar imagem com Nano Banana", key=f"new_video_general_generate_thumbnail_{channel['id']}", use_container_width=True):
1954
+ if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
1853
1955
  try:
1854
1956
  thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
1855
1957
  variant["image_path"] = thumbnail_path
1856
1958
  payload["thumbnail_path"] = thumbnail_path
1857
1959
  payload["thumbnail_status"] = "generated"
1858
- st.session_state["new_video_general_payloads"] = payloads
1960
+ st.session_state[f"{prefix}_general_payloads"] = payloads
1859
1961
  record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": channel.get("name") or "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
1860
1962
  st.success("Thumbnail gerada com Nano Banana.")
1861
1963
  st.rerun()
@@ -1870,7 +1972,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1870
1972
  st.caption(f"Estado da thumbnail: {payload.get('thumbnail_status', 'prompt_ready')} · texto: {payload.get('thumbnail_text') or 'sem texto'}")
1871
1973
  else:
1872
1974
  topic_for_creative = str(generation_settings.get("video_subject") or "").strip()
1873
- if st.button("Gerar títulos e thumbnails com IA", key="new_video_generate_creative", use_container_width=True):
1975
+ if st.button("Gerar títulos e thumbnails com IA", key=f"{prefix}_generate_creative", use_container_width=True):
1874
1976
  if selected_one is None:
1875
1977
  st.error("Seleccione primeiro um canal.")
1876
1978
  else:
@@ -1878,33 +1980,33 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1878
1980
  if not topic_for_creative:
1879
1981
  topic_result = generate_topic_for_ui(read_json("settings.json", {}), selected_one)
1880
1982
  topic_for_creative = str(topic_result["topic"]).strip()
1881
- st.session_state["new_video_topic"] = topic_for_creative
1882
- st.session_state["new_video_topic_meta"] = topic_result
1983
+ st.session_state[f"{prefix}_topic"] = topic_for_creative
1984
+ st.session_state[f"{prefix}_topic_meta"] = topic_result
1883
1985
  generated = generate_creative_for_ui(
1884
1986
  read_json("settings.json", {}),
1885
1987
  selected_one,
1886
1988
  topic_for_creative,
1887
- topic_source=_video_topic_source(topic_for_creative),
1989
+ topic_source=_video_topic_source(topic_for_creative, prefix),
1888
1990
  )
1889
1991
 
1890
- st.session_state["new_video_creative_payload"] = generated
1992
+ st.session_state[f"{prefix}_creative_payload"] = generated
1891
1993
  st.success("Tema, título e thumbnails gerados; escolha a variante antes de criar as tarefas.")
1892
1994
  st.rerun()
1893
1995
  except CreativeGenerationError as exc:
1894
1996
  st.error(str(exc))
1895
- payload = st.session_state.get("new_video_creative_payload")
1997
+ payload = st.session_state.get(f"{prefix}_creative_payload")
1896
1998
  if payload:
1897
1999
  st.subheader("Título e Thumbnail automáticos")
1898
2000
  title_options = [item.get("title", "") for item in payload.get("title_candidates", []) if item.get("title")]
1899
2001
  if title_options:
1900
- 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")
2002
+ 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"{prefix}_title_choice")
1901
2003
  payload["title"] = selected_title
1902
2004
  with st.expander(f"Ver {len(title_options)} candidatos de título"):
1903
2005
  st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
1904
2006
  variants = payload.get("thumbnail_variants", [])
1905
2007
  if variants:
1906
2008
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
1907
- selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key="new_video_thumbnail_choice")
2009
+ selected_variant_label = st.selectbox("Thumbnail escolhida", labels, key=f"{prefix}_thumbnail_choice")
1908
2010
  variant_index = labels.index(selected_variant_label)
1909
2011
  variant = variants[variant_index]
1910
2012
  payload["thumbnail_variant"] = variant
@@ -1913,13 +2015,13 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1913
2015
  st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
1914
2016
  st.code(variant.get("image_prompt", ""), language="text")
1915
2017
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
1916
- if st.button("Gerar imagem da thumbnail com Nano Banana", key="new_video_generate_thumbnail_image", use_container_width=True):
2018
+ if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
1917
2019
  try:
1918
2020
  thumbnail_path = str(generate_thumbnail_image(read_json("settings.json", {}), variant.get("image_prompt", ""), topic=str(payload.get("topic") or ""), variant_index=variant_index))
1919
2021
  variant["image_path"] = thumbnail_path
1920
2022
  payload["thumbnail_path"] = thumbnail_path
1921
2023
  payload["thumbnail_status"] = "generated"
1922
- st.session_state["new_video_creative_payload"] = payload
2024
+ st.session_state[f"{prefix}_creative_payload"] = payload
1923
2025
  record_notification("thumbnail_generation_completed", f"Thumbnail gerada: {payload.get('title') or payload.get('topic') or 'Vídeo'}", "A thumbnail foi gerada com sucesso pelo Nano Banana.", metadata={"channel_name": selected_one.get("name") if selected_one else "", "image_path": Path(thumbnail_path).name}, dedupe_key=f"thumbnail:{thumbnail_path}")
1924
2026
  st.success("Thumbnail gerada com Nano Banana.")
1925
2027
  st.rerun()
@@ -1931,9 +2033,9 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1931
2033
  payload["thumbnail_status"] = "generated"
1932
2034
  else:
1933
2035
  st.info("Escolha a variante e clique em **Gerar imagem da thumbnail com Nano Banana**. A API key é configurada em Configuração API > API Keys.")
1934
- st.session_state["new_video_creative_payload"] = payload
2036
+ st.session_state[f"{prefix}_creative_payload"] = payload
1935
2037
 
1936
- with st.form("new_video_form"):
2038
+ with st.form(f"{prefix}_form"):
1937
2039
  quantity = st.number_input("Quantidade", min_value=1, max_value=100, value=1, disabled=mode != "same_channel")
1938
2040
  language = generation_settings["script_language"]
1939
2041
  fmt = generation_settings["video_format"]
@@ -1945,12 +2047,12 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1945
2047
  st.error("Escolha, carregue ou gere uma música antes de criar o vídeo Apenas Música.")
1946
2048
  st.stop()
1947
2049
  if mode == "general":
1948
- payloads = dict(st.session_state.get("new_video_general_payloads", {}))
1949
- topics = dict(st.session_state.get("new_video_general_topics", {}))
2050
+ payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
2051
+ topics = dict(st.session_state.get(f"{prefix}_general_topics", {}))
1950
2052
  channels_by_id = {str(channel["id"]): channel for channel in all_channels}
1951
2053
  payloads_need_refresh = len(payloads) != len(selected) or any(
1952
- str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
1953
- 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()
2054
+ str(st.session_state.get(f"{prefix}_general_topic_{channel_id}", "") or "").strip()
2055
+ and str(st.session_state.get(f"{prefix}_general_topic_{channel_id}", "") or "").strip() != str((payloads.get(channel_id) or {}).get("topic") or "").strip()
1954
2056
  for channel_id in selected
1955
2057
  )
1956
2058
  if payloads_need_refresh:
@@ -1960,7 +2062,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1960
2062
  with st.spinner("A gerar automaticamente um pacote criativo independente para cada canal…"):
1961
2063
  for channel_id in selected:
1962
2064
  channel = channels_by_id[channel_id]
1963
- edited_topic = str(st.session_state.get(f"new_video_general_topic_{channel_id}", "") or "").strip()
2065
+ edited_topic = str(st.session_state.get(f"{prefix}_general_topic_{channel_id}", "") or "").strip()
1964
2066
  topic_result = topics.get(channel_id) or {}
1965
2067
  try:
1966
2068
  if not edited_topic:
@@ -1979,8 +2081,8 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1979
2081
  st.error("O Lote geral não foi criado porque faltou gerar o conteúdo específico de pelo menos um canal.")
1980
2082
  else:
1981
2083
  payloads = generated_payloads
1982
- st.session_state["new_video_general_payloads"] = payloads
1983
- st.session_state["new_video_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
2084
+ st.session_state[f"{prefix}_general_payloads"] = payloads
2085
+ st.session_state[f"{prefix}_general_topics"] = {cid: {"topic": payload["topic"], "topic_source": payload.get("topic_source", "llm")} for cid, payload in payloads.items()}
1984
2086
  if len(payloads) == len(selected):
1985
2087
  batch_topic = "Lote geral — um vídeo independente por canal"
1986
2088
  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()}
@@ -1996,14 +2098,14 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
1996
2098
  st.error("Preencha o campo Video Subject ou use o botão de geração automática abaixo das keywords.")
1997
2099
  st.stop()
1998
2100
  quantity_value = int(quantity if mode == "same_channel" else 1)
1999
- payload = dict(st.session_state.get("new_video_creative_payload") or {})
2101
+ payload = dict(st.session_state.get(f"{prefix}_creative_payload") or {})
2000
2102
  if not payload.get("title") or not payload.get("thumbnail_variants"):
2001
2103
  try:
2002
2104
  payload = generate_creative_for_ui(
2003
2105
  read_json("settings.json", {}),
2004
2106
  selected_one or {},
2005
2107
  topic_value,
2006
- topic_source=_video_topic_source(topic_value),
2108
+ topic_source=_video_topic_source(topic_value, prefix),
2007
2109
  )
2008
2110
  except CreativeGenerationError as exc:
2009
2111
  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.")
@@ -2016,7 +2118,7 @@ def render_new_video(page_title: str = "Criação de Vídeos"):
2016
2118
 
2017
2119
  def render_music_creation():
2018
2120
  """Expose the complete video-creation UI under the music-oriented navigation entry without changing the original page."""
2019
- render_new_video(page_title="Criação de Músicas")
2121
+ render_new_video(page_title="Criação de Músicas", prefix="new_music")
2020
2122
 
2021
2123
 
2022
2124
  def render_scripts():
@@ -2072,9 +2174,29 @@ def render_scripts():
2072
2174
  placeholder="Descreva o tema, a mensagem, o conflito ou a ideia musical que o Blueprint deve orientar.",
2073
2175
  )
2074
2176
  legacy_script_language = str(st.session_state.get("script_language") or "")
2177
+ script_subject_key = "pipeline_scripts_video_subject"
2178
+ if script_subject_key not in st.session_state:
2179
+ st.session_state[script_subject_key] = str(brief or title or "")
2075
2180
  script_settings = render_video_generation_settings(
2076
2181
  "pipeline_scripts",
2077
2182
  current_language=legacy_script_language or str(read_json("settings.json", {}).get("video_language") or "pt"),
2183
+ channel=selected_channel,
2184
+ generate_content_callback=lambda: _generate_video_content_callback(
2185
+ "pipeline_scripts",
2186
+ selected_channel or {},
2187
+ str(st.session_state.get("pipeline_scripts_script_language") or language_code(legacy_script_language or "pt")),
2188
+ selected_blueprint,
2189
+ ),
2190
+ save_draft_callback=lambda: _save_pipeline_draft_callback(
2191
+ "pipeline_scripts",
2192
+ "script",
2193
+ "Roteiros",
2194
+ channel=selected_channel,
2195
+ blueprint=selected_blueprint,
2196
+ document_type="video_script" if document_type == "Roteiro de vídeo" else "music_lyrics",
2197
+ title=title,
2198
+ brief=brief,
2199
+ ),
2078
2200
  )
2079
2201
  language = script_settings["script_language"]
2080
2202
  structure_notes = script_settings["script_structure_notes"]
@@ -2084,7 +2206,7 @@ def render_scripts():
2084
2206
  with clear_col:
2085
2207
  clear_clicked = st.button("Limpar rascunho", use_container_width=True, key="clear_script_document")
2086
2208
  if clear_clicked:
2087
- for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary"):
2209
+ for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary", "script_draft_keywords"):
2088
2210
  st.session_state.pop(key, None)
2089
2211
  st.rerun()
2090
2212
  if generate_clicked:
@@ -2129,8 +2251,11 @@ def render_scripts():
2129
2251
  st.session_state["script_draft_summary"] = str(draft.get("summary") or "")
2130
2252
  if "script_draft_content" not in st.session_state:
2131
2253
  st.session_state["script_draft_content"] = str(draft.get("content") or "")
2254
+ if "script_draft_keywords" not in st.session_state:
2255
+ st.session_state["script_draft_keywords"] = str(draft.get("keywords") or "")
2132
2256
  draft_title = st.text_input("Título do rascunho", key="script_draft_title")
2133
2257
  draft_summary = st.text_input("Resumo", key="script_draft_summary")
2258
+ draft_keywords = st.text_area("Palavras-chave", height=90, key="script_draft_keywords")
2134
2259
  draft_content = st.text_area("Conteúdo guardado", height=460, key="script_draft_content")
2135
2260
  if st.button("Guardar documento no storage", type="primary", use_container_width=True, key="save_script_document"):
2136
2261
  try:
@@ -2139,6 +2264,7 @@ def render_scripts():
2139
2264
  **draft,
2140
2265
  "title": draft_title,
2141
2266
  "summary": draft_summary,
2267
+ "keywords": draft_keywords,
2142
2268
  "content": draft_content,
2143
2269
  "document_type": "video_script" if document_type == "Roteiro de vídeo" else "music_lyrics",
2144
2270
  "language": language,
@@ -2900,6 +3026,58 @@ def render_videos():
2900
3026
  st.rerun()
2901
3027
 
2902
3028
 
3029
+ def render_thumbnails():
3030
+ st.title("Thumbnails")
3031
+ st.caption("Biblioteca de thumbnails associadas às tarefas da pipeline. A refacção actualiza apenas a imagem da tarefa e preserva o histórico local.")
3032
+ records = list_thumbnail_tasks()
3033
+ if not records:
3034
+ st.info("Ainda não existem tarefas com thumbnail gerada ou prompt de imagem disponível.")
3035
+ return
3036
+
3037
+ settings = read_json("settings.json", {})
3038
+ for record in records:
3039
+ with st.container(border=True):
3040
+ image_col, details_col, action_col = st.columns([1.35, 2.65, 1.25])
3041
+ with image_col:
3042
+ image_path = record.get("image_path")
3043
+ if image_path and image_path.is_file():
3044
+ st.image(str(image_path), use_container_width=True)
3045
+ else:
3046
+ st.markdown("### Sem imagem")
3047
+ st.caption("Imagem ainda não gerada")
3048
+ with details_col:
3049
+ st.write(f"**{record['title']}**")
3050
+ st.caption(f"Canal: {record['channel_name']} · Tarefa: {record['task_id']}")
3051
+ st.caption(f"Estado: {record['status']} · Variante: {record['variant_index'] + 1}")
3052
+ if record["prompt"]:
3053
+ with st.expander("Ver prompt da thumbnail", expanded=False):
3054
+ st.code(record["prompt"], language="text")
3055
+ else:
3056
+ st.warning("Esta tarefa não tem prompt de imagem. Não é possível refazer a thumbnail.")
3057
+ with action_col:
3058
+ if st.button(
3059
+ "Refazer thumbnail",
3060
+ key=f"regenerate_thumbnail_{record['task_id']}",
3061
+ icon=":material/refresh:",
3062
+ use_container_width=True,
3063
+ disabled=not bool(record["prompt"]),
3064
+ ):
3065
+ try:
3066
+ with st.spinner("A refazer a thumbnail…"):
3067
+ _task, image_path = regenerate_thumbnail(record["task_id"], settings)
3068
+ record_notification(
3069
+ "thumbnail_generation_completed",
3070
+ f"Thumbnail refeita: {record['title']}",
3071
+ "A thumbnail da tarefa foi regenerada com sucesso.",
3072
+ metadata={"task_id": record["task_id"], "channel_name": record["channel_name"], "image_path": str(image_path)},
3073
+ dedupe_key=f"thumbnail:regenerated:{record['task_id']}:{image_path}",
3074
+ )
3075
+ st.success("Thumbnail refeita com sucesso.")
3076
+ st.rerun()
3077
+ except ThumbnailGenerationError as exc:
3078
+ st.error(str(exc))
3079
+
3080
+
2903
3081
  def render_automation():
2904
3082
  st.title("Automação Youtube")
2905
3083
  st.caption("Agendamento diário da geração por canal. O worker verifica o relógio local do computador e coloca os lotes agendados na fila.")
@@ -3437,103 +3615,116 @@ def render_google_accounts():
3437
3615
  missing_document_parts = list(direct_status.get("missing_cookies", []))
3438
3616
  if not direct_status.get("has_session_info"):
3439
3617
  missing_document_parts.append("sessionInfo")
3618
+ account_ready = bool(
3619
+ account_email_snapshot != "sem e-mail"
3620
+ and str(batch_account.get("client_id") or "").strip()
3621
+ and str(batch_account.get("client_secret") or "").strip()
3622
+ and bool(direct_status.get("document_exists"))
3623
+ and not missing_document_parts
3624
+ )
3440
3625
  if missing_document_parts:
3441
3626
  youtube_accounts_missing_document.append(account_email_snapshot)
3442
3627
 
3443
- with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
3444
- with st.form(f"batch_account_form_{account_id}"):
3445
- account_cols = st.columns(2)
3446
- with account_cols[0]:
3447
- account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
3448
- account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
3449
- account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
3450
- with account_cols[1]:
3451
- account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
3452
- account_session_info = st.text_input(
3453
- "sessionInfo token desta conta Google",
3454
- value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
3455
- type="password",
3456
- key=f"batch_session_info_{account_id}",
3457
- help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
3458
- )
3459
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
3460
-
3461
- st.markdown("**Documento de cookies/credenciais desta conta Google**")
3462
- st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
3463
- document_upload = st.file_uploader(
3464
- "Subir documento de cookies/credenciais",
3465
- type=["json"],
3466
- key=f"direct_credentials_document_{account_id}",
3467
- help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
3468
- )
3469
- if missing_document_parts:
3470
- st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
3471
- else:
3472
- st.success("Documento completo para a conta Google")
3473
- st.caption(f"Documento guardado em: {direct_status['document_file']}")
3474
- document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
3475
-
3476
- account_status = youtube_batch_account_status(batch_account, STORAGE)
3477
- status_cols = st.columns(3)
3478
- with status_cols[0]:
3479
- (st.success if account_status.ok else st.warning)(account_status.message)
3480
- with status_cols[1]:
3481
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
3482
- result = authorize_youtube_batch_account(batch_account, STORAGE)
3483
- (st.success if result.ok else st.error)(result.message)
3484
- if result.ok:
3485
- st.rerun()
3486
- with status_cols[2]:
3487
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
3488
- delete_youtube_batch_token(batch_account, STORAGE)
3489
- delete_credentials_document(STORAGE, batch_account)
3490
- remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
3491
- settings["youtube_batch_accounts"] = remaining_accounts
3492
- if settings.get("youtube_batch_selected_account_id") == account_id:
3493
- settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
3494
- for channel in channel_state:
3495
- if str(channel.get("google_account_id") or "") == account_id:
3496
- channel.update({"google_account_id": "", "google_account_email": ""})
3497
- write_json("channels.json", channel_state)
3498
- write_json("settings.json", settings)
3499
- st.rerun()
3500
-
3501
- if save_account:
3502
- if "@" not in account_email.strip():
3503
- st.error("Informe um e-mail Google válido.")
3504
- elif not account_client_id.strip() or not account_client_secret.strip():
3505
- st.error("Informe o Client ID e o Client Secret desta conta.")
3628
+ with st.container(border=True):
3629
+ account_header_cols = st.columns([3.2, 1.2])
3630
+ with account_header_cols[0]:
3631
+ st.subheader(f"{account_label_snapshot} — {account_email_snapshot}")
3632
+ with account_header_cols[1]:
3633
+ _api_status_badge("Configured" if account_ready else "Missing configuration", "ready" if account_ready else "missing")
3634
+ with st.expander("Detalhes da conta Google", expanded=False):
3635
+ with st.form(f"batch_account_form_{account_id}"):
3636
+ account_cols = st.columns(2)
3637
+ with account_cols[0]:
3638
+ account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
3639
+ account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
3640
+ account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
3641
+ with account_cols[1]:
3642
+ account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
3643
+ account_session_info = st.text_input(
3644
+ "sessionInfo token desta conta Google",
3645
+ value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
3646
+ type="password",
3647
+ key=f"batch_session_info_{account_id}",
3648
+ help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
3649
+ )
3650
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
3651
+
3652
+ st.markdown("**Documento de cookies/credenciais desta conta Google**")
3653
+ st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
3654
+ document_upload = st.file_uploader(
3655
+ "Subir documento de cookies/credenciais",
3656
+ type=["json"],
3657
+ key=f"direct_credentials_document_{account_id}",
3658
+ help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
3659
+ )
3660
+ if missing_document_parts:
3661
+ st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
3506
3662
  else:
3507
- for existing in batch_accounts:
3508
- if str(existing.get("id")) == account_id:
3509
- credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
3510
- if credentials_changed:
3511
- delete_youtube_batch_token(existing, STORAGE)
3512
- existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
3513
- update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
3514
- ensure_credentials_document(STORAGE, existing, settings, channel_state)
3515
- settings["youtube_batch_accounts"] = batch_accounts
3516
- write_json("settings.json", settings)
3517
- st.success("Conta Google/YouTube guardada.")
3518
- st.rerun()
3663
+ st.success("Documento completo para a conta Google")
3664
+ st.caption(f"Documento guardado em: {direct_status['document_file']}")
3665
+ document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
3666
+
3667
+ account_status = youtube_batch_account_status(batch_account, STORAGE)
3668
+ status_cols = st.columns(3)
3669
+ with status_cols[0]:
3670
+ (st.success if account_status.ok else st.warning)(account_status.message)
3671
+ with status_cols[1]:
3672
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
3673
+ result = authorize_youtube_batch_account(batch_account, STORAGE)
3674
+ (st.success if result.ok else st.error)(result.message)
3675
+ if result.ok:
3676
+ st.rerun()
3677
+ with status_cols[2]:
3678
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
3679
+ delete_youtube_batch_token(batch_account, STORAGE)
3680
+ delete_credentials_document(STORAGE, batch_account)
3681
+ remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
3682
+ settings["youtube_batch_accounts"] = remaining_accounts
3683
+ if settings.get("youtube_batch_selected_account_id") == account_id:
3684
+ settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
3685
+ for channel in channel_state:
3686
+ if str(channel.get("google_account_id") or "") == account_id:
3687
+ channel.update({"google_account_id": "", "google_account_email": ""})
3688
+ write_json("channels.json", channel_state)
3689
+ write_json("settings.json", settings)
3690
+ st.rerun()
3519
3691
 
3520
- if document_save:
3521
- if document_upload is None:
3522
- st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
3523
- else:
3524
- try:
3525
- merge_credentials_document(
3526
- STORAGE,
3527
- batch_account,
3528
- document_upload.getvalue(),
3529
- document_upload.name,
3530
- session_info_override=str(batch_account.get("sessionInfo") or ""),
3531
- channels=channel_state,
3532
- )
3533
- st.success("Documento incorporado e guardado nesta conta Google.")
3692
+ if save_account:
3693
+ if "@" not in account_email.strip():
3694
+ st.error("Informe um e-mail Google válido.")
3695
+ elif not account_client_id.strip() or not account_client_secret.strip():
3696
+ st.error("Informe o Client ID e o Client Secret desta conta.")
3697
+ else:
3698
+ for existing in batch_accounts:
3699
+ if str(existing.get("id")) == account_id:
3700
+ credentials_changed = any(existing.get(field, "") != value for field, value in (("email", account_email.strip()), ("client_id", account_client_id.strip()), ("client_secret", account_client_secret.strip())))
3701
+ if credentials_changed:
3702
+ delete_youtube_batch_token(existing, STORAGE)
3703
+ existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
3704
+ update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
3705
+ ensure_credentials_document(STORAGE, existing, settings, channel_state)
3706
+ settings["youtube_batch_accounts"] = batch_accounts
3707
+ write_json("settings.json", settings)
3708
+ st.success("Conta Google/YouTube guardada.")
3534
3709
  st.rerun()
3535
- except ValueError as exc:
3536
- st.error(str(exc))
3710
+
3711
+ if document_save:
3712
+ if document_upload is None:
3713
+ st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
3714
+ else:
3715
+ try:
3716
+ merge_credentials_document(
3717
+ STORAGE,
3718
+ batch_account,
3719
+ document_upload.getvalue(),
3720
+ document_upload.name,
3721
+ session_info_override=str(batch_account.get("sessionInfo") or ""),
3722
+ channels=channel_state,
3723
+ )
3724
+ st.success("Documento incorporado e guardado nesta conta Google.")
3725
+ st.rerun()
3726
+ except ValueError as exc:
3727
+ st.error(str(exc))
3537
3728
 
3538
3729
  if youtube_accounts_missing_document:
3539
3730
  st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
@@ -3559,6 +3750,11 @@ def render_google_accounts():
3559
3750
  legacy_account.pop("INNERTUBE_API_KEY", None)
3560
3751
  settings["youtube_batch_accounts"] = batch_accounts
3561
3752
  write_json("settings.json", settings)
3753
+ innertube_status_cols = st.columns([3.2, 1.2])
3754
+ with innertube_status_cols[0]:
3755
+ st.caption("Estado da chave global")
3756
+ with innertube_status_cols[1]:
3757
+ _render_credential_status(current_innertube_api_key)
3562
3758
  with st.form("innertube_api_key_form"):
3563
3759
  innertube_api_key_value = st.text_input(
3564
3760
  "INNERTUBE_API_KEY",
@@ -4530,6 +4726,7 @@ def main():
4530
4726
  ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
4531
4727
  ("Backlog Vídeos", ":material/video_library:", "Backlog Vídeos"),
4532
4728
  ("Roteiros", ":material/article:", "Roteiros"),
4729
+ ("Thumbnails", ":material/image:", "Thumbnails"),
4533
4730
  ("Upload", ":material/cloud_upload:", "Upload"),
4534
4731
  ]
4535
4732
  base_files_items = [
@@ -4664,6 +4861,7 @@ def main():
4664
4861
  "Criação de Músicas": render_music_creation,
4665
4862
  "Upload Música": lambda: render_edit_placeholder("Upload Música", ""),
4666
4863
  "Roteiros": render_scripts,
4864
+ "Thumbnails": render_thumbnails,
4667
4865
  "Upload": render_upload,
4668
4866
  "Blueprints Youtube": render_blueprints,
4669
4867
  "Prompt Masters": render_tiktok_prompt_masters,