@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 +354 -156
- package/hermes_ui/drafts.py +33 -0
- package/hermes_ui/languages.py +77 -10
- package/hermes_ui/storage.py +1 -0
- package/hermes_ui/thumbnails.py +127 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
585
|
-
|
|
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("
|
|
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
|
|
599
|
-
|
|
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="
|
|
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("
|
|
1705
|
-
key="
|
|
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="
|
|
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["
|
|
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("
|
|
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"
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
1778
|
-
if uploaded_music and st.button("Guardar música local", key="
|
|
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["
|
|
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("
|
|
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="
|
|
1788
|
-
suno_title = st.text_input("Título da música", value=st.session_state.get("
|
|
1789
|
-
if st.button("Solicitar música no Suno", key="
|
|
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["
|
|
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("
|
|
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("
|
|
1807
|
-
payloads = dict(st.session_state.get("
|
|
1808
|
-
if st.button("Gerar títulos e thumbnails para todos os canais", key="
|
|
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["
|
|
1828
|
-
st.session_state["
|
|
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("
|
|
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"
|
|
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"
|
|
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"
|
|
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["
|
|
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="
|
|
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["
|
|
1882
|
-
st.session_state["
|
|
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["
|
|
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("
|
|
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="
|
|
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="
|
|
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="
|
|
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["
|
|
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["
|
|
2036
|
+
st.session_state[f"{prefix}_creative_payload"] = payload
|
|
1935
2037
|
|
|
1936
|
-
with st.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("
|
|
1949
|
-
topics = dict(st.session_state.get("
|
|
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"
|
|
1953
|
-
and str(st.session_state.get(f"
|
|
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"
|
|
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["
|
|
1983
|
-
st.session_state["
|
|
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("
|
|
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.
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
with
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
"
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
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
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
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
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
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
|
-
|
|
3536
|
-
|
|
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,
|