@danhachuel/thunderbolt 0.3.21 → 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.")
@@ -4548,6 +4726,7 @@ def main():
4548
4726
  ("Criação de Vídeos", ":material/add_circle:", "Criação de Vídeos"),
4549
4727
  ("Backlog Vídeos", ":material/video_library:", "Backlog Vídeos"),
4550
4728
  ("Roteiros", ":material/article:", "Roteiros"),
4729
+ ("Thumbnails", ":material/image:", "Thumbnails"),
4551
4730
  ("Upload", ":material/cloud_upload:", "Upload"),
4552
4731
  ]
4553
4732
  base_files_items = [
@@ -4682,6 +4861,7 @@ def main():
4682
4861
  "Criação de Músicas": render_music_creation,
4683
4862
  "Upload Música": lambda: render_edit_placeholder("Upload Música", ""),
4684
4863
  "Roteiros": render_scripts,
4864
+ "Thumbnails": render_thumbnails,
4685
4865
  "Upload": render_upload,
4686
4866
  "Blueprints Youtube": render_blueprints,
4687
4867
  "Prompt Masters": render_tiktok_prompt_masters,
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any
5
+
6
+ from .storage import now, read_json, write_json
7
+
8
+ DRAFTS_FILE = "drafts.json"
9
+ MAX_DRAFTS = 200
10
+
11
+
12
+ def list_drafts() -> list[dict[str, Any]]:
13
+ """Return locally persisted drafts, newest first."""
14
+ records = read_json(DRAFTS_FILE, [])
15
+ return [record for record in records if isinstance(record, dict)] if isinstance(records, list) else []
16
+
17
+
18
+ def save_draft(draft: dict[str, Any]) -> dict[str, Any]:
19
+ """Persist one editable pipeline draft without advancing any pipeline task."""
20
+ record = {
21
+ **draft,
22
+ "id": str(draft.get("id") or f"draft_{uuid.uuid4().hex[:12]}"),
23
+ "created_at": str(draft.get("created_at") or now()),
24
+ "updated_at": now(),
25
+ }
26
+ drafts = list_drafts()
27
+ drafts.insert(0, record)
28
+ write_json(DRAFTS_FILE, drafts[:MAX_DRAFTS])
29
+ return record
30
+
31
+
32
+ __all__ = ["DRAFTS_FILE", "list_drafts", "save_draft"]
33
+
@@ -229,34 +229,34 @@ for _language_code, _api_key_values in _API_KEY_EXPANDER_TRANSLATIONS.items():
229
229
  # translation path as the legacy pages.
230
230
  UI_NAV_TRANSLATIONS: dict[str, dict[str, str]] = {
231
231
  "pt": {
232
- "Arquivos Base": "Arquivos Base", "Pipeline Vídeos": "Pipeline Vídeos", "Canais e Perfis de Vídeos": "Canais e Perfis de Vídeos", "Canais YouTube": "Canais YouTube", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Backlog Vídeos", "Música": "Música", "Upload Música": "Upload Música", "Geração de Conteúdo IA": "Geração de Conteúdo IA", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "Analista Growth Youtube", "Analista Growth Tiktok": "Analista Growth Tiktok", "Analista Growth Instagram": "Analista Growth Instagram", "Documentação": "Documentação", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline Música", "módulos disponíveis": "módulos disponíveis",
232
+ "Arquivos Base": "Arquivos Base", "Pipeline Vídeos": "Pipeline Vídeos", "Canais e Perfis de Vídeos": "Canais e Perfis de Vídeos", "Canais YouTube": "Canais YouTube", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Backlog Vídeos", "Thumbnails": "Thumbnails", "Música": "Música", "Upload Música": "Upload Música", "Geração de Conteúdo IA": "Geração de Conteúdo IA", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "Analista Growth Youtube", "Analista Growth Tiktok": "Analista Growth Tiktok", "Analista Growth Instagram": "Analista Growth Instagram", "Documentação": "Documentação", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline Música", "módulos disponíveis": "módulos disponíveis",
233
233
  },
234
234
  "en": {
235
- "Arquivos Base": "Base Files", "Pipeline Vídeos": "Video Pipeline", "Canais e Perfis de Vídeos": "Video Channels & Profiles", "Canais YouTube": "YouTube Channels", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video Backlog", "Música": "Music", "Upload Música": "Music Upload", "Geração de Conteúdo IA": "AI Content Generation", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "YouTube Growth Analyst", "Analista Growth Tiktok": "TikTok Growth Analyst", "Analista Growth Instagram": "Instagram Growth Analyst", "Documentação": "Documentation", "Tutorial Kaggle": "Kaggle Tutorial", "Tutorial Apify": "Apify Tutorial", "Pipeline Música": "Music Pipeline", "módulos disponíveis": "available modules",
235
+ "Arquivos Base": "Base Files", "Pipeline Vídeos": "Video Pipeline", "Canais e Perfis de Vídeos": "Video Channels & Profiles", "Canais YouTube": "YouTube Channels", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video Backlog", "Thumbnails": "Thumbnails", "Música": "Music", "Upload Música": "Music Upload", "Geração de Conteúdo IA": "AI Content Generation", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "YouTube Growth Analyst", "Analista Growth Tiktok": "TikTok Growth Analyst", "Analista Growth Instagram": "Instagram Growth Analyst", "Documentação": "Documentation", "Tutorial Kaggle": "Kaggle Tutorial", "Tutorial Apify": "Apify Tutorial", "Pipeline Música": "Music Pipeline", "módulos disponíveis": "available modules",
236
236
  },
237
237
  "zh": {
238
- "Arquivos Base": "基础文件", "Pipeline Vídeos": "视频流程", "Canais e Perfis de Vídeos": "视频频道与资料", "Canais YouTube": "YouTube 频道", "Facebook Pages": "Facebook 页面", "Prompt Masters": "主提示词", "Backlog Vídeos": "视频待办", "Música": "音乐", "Upload Música": "音乐上传", "Geração de Conteúdo IA": "AI 内容生成", "Motion Control": "动作控制", "UGC Products": "UGC 产品", "Growth": "增长", "Analista Growth Youtube": "YouTube 增长分析师", "Analista Growth Tiktok": "TikTok 增长分析师", "Analista Growth Instagram": "Instagram 增长分析师", "Documentação": "文档", "Tutorial Kaggle": "Kaggle 教程", "Tutorial Apify": "Apify 教程", "Pipeline Música": "音乐流程", "módulos disponíveis": "可用模块",
238
+ "Arquivos Base": "基础文件", "Pipeline Vídeos": "视频流程", "Canais e Perfis de Vídeos": "视频频道与资料", "Canais YouTube": "YouTube 频道", "Facebook Pages": "Facebook 页面", "Prompt Masters": "主提示词", "Backlog Vídeos": "视频待办", "Thumbnails": "缩略图", "Música": "音乐", "Upload Música": "音乐上传", "Geração de Conteúdo IA": "AI 内容生成", "Motion Control": "动作控制", "UGC Products": "UGC 产品", "Growth": "增长", "Analista Growth Youtube": "YouTube 增长分析师", "Analista Growth Tiktok": "TikTok 增长分析师", "Analista Growth Instagram": "Instagram 增长分析师", "Documentação": "文档", "Tutorial Kaggle": "Kaggle 教程", "Tutorial Apify": "Apify 教程", "Pipeline Música": "音乐流程", "módulos disponíveis": "可用模块",
239
239
  },
240
240
  "de": {
241
- "Arquivos Base": "Basisdateien", "Pipeline Vídeos": "Video-Pipeline", "Canais e Perfis de Vídeos": "Videokanäle und Profile", "Canais YouTube": "YouTube-Kanäle", "Facebook Pages": "Facebook-Seiten", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video-Backlog", "Música": "Musik", "Upload Música": "Musik-Upload", "Geração de Conteúdo IA": "KI-Inhaltserstellung", "Motion Control": "Motion Control", "UGC Products": "UGC-Produkte", "Growth": "Wachstum", "Analista Growth Youtube": "YouTube-Wachstumsanalyst", "Analista Growth Tiktok": "TikTok-Wachstumsanalyst", "Analista Growth Instagram": "Instagram-Wachstumsanalyst", "Documentação": "Dokumentation", "Tutorial Kaggle": "Kaggle-Tutorial", "Tutorial Apify": "Apify-Tutorial", "Pipeline Música": "Musik-Pipeline", "módulos disponíveis": "verfügbare Module",
241
+ "Arquivos Base": "Basisdateien", "Pipeline Vídeos": "Video-Pipeline", "Canais e Perfis de Vídeos": "Videokanäle und Profile", "Canais YouTube": "YouTube-Kanäle", "Facebook Pages": "Facebook-Seiten", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video-Backlog", "Thumbnails": "Thumbnails", "Música": "Musik", "Upload Música": "Musik-Upload", "Geração de Conteúdo IA": "KI-Inhaltserstellung", "Motion Control": "Motion Control", "UGC Products": "UGC-Produkte", "Growth": "Wachstum", "Analista Growth Youtube": "YouTube-Wachstumsanalyst", "Analista Growth Tiktok": "TikTok-Wachstumsanalyst", "Analista Growth Instagram": "Instagram-Wachstumsanalyst", "Documentação": "Dokumentation", "Tutorial Kaggle": "Kaggle-Tutorial", "Tutorial Apify": "Apify-Tutorial", "Pipeline Música": "Musik-Pipeline", "módulos disponíveis": "verfügbare Module",
242
242
  },
243
243
  "vi": {
244
- "Arquivos Base": "Tệp cơ sở", "Pipeline Vídeos": "Quy trình video", "Canais e Perfis de Vídeos": "Kênh và hồ sơ video", "Canais YouTube": "Kênh YouTube", "Facebook Pages": "Trang Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Danh sách video", "Música": "Âm nhạc", "Upload Música": "Tải nhạc lên", "Geração de Conteúdo IA": "Tạo nội dung AI", "Motion Control": "Điều khiển chuyển động", "UGC Products": "Sản phẩm UGC", "Growth": "Tăng trưởng", "Analista Growth Youtube": "Chuyên viên phân tích tăng trưởng YouTube", "Analista Growth Tiktok": "Chuyên viên phân tích tăng trưởng TikTok", "Analista Growth Instagram": "Chuyên viên phân tích tăng trưởng Instagram", "Documentação": "Tài liệu", "Tutorial Kaggle": "Hướng dẫn Kaggle", "Tutorial Apify": "Hướng dẫn Apify", "Pipeline Música": "Quy trình âm nhạc", "módulos disponíveis": "mô-đun khả dụng",
244
+ "Arquivos Base": "Tệp cơ sở", "Pipeline Vídeos": "Quy trình video", "Canais e Perfis de Vídeos": "Kênh và hồ sơ video", "Canais YouTube": "Kênh YouTube", "Facebook Pages": "Trang Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Danh sách video", "Thumbnails": "Ảnh thu nhỏ", "Música": "Âm nhạc", "Upload Música": "Tải nhạc lên", "Geração de Conteúdo IA": "Tạo nội dung AI", "Motion Control": "Điều khiển chuyển động", "UGC Products": "Sản phẩm UGC", "Growth": "Tăng trưởng", "Analista Growth Youtube": "Chuyên viên phân tích tăng trưởng YouTube", "Analista Growth Tiktok": "Chuyên viên phân tích tăng trưởng TikTok", "Analista Growth Instagram": "Chuyên viên phân tích tăng trưởng Instagram", "Documentação": "Tài liệu", "Tutorial Kaggle": "Hướng dẫn Kaggle", "Tutorial Apify": "Hướng dẫn Apify", "Pipeline Música": "Quy trình âm nhạc", "módulos disponíveis": "mô-đun khả dụng",
245
245
  },
246
246
  "tr": {
247
- "Arquivos Base": "Temel Dosyalar", "Pipeline Vídeos": "Video Akışı", "Canais e Perfis de Vídeos": "Video Kanalları ve Profilleri", "Canais YouTube": "YouTube Kanalları", "Facebook Pages": "Facebook Sayfaları", "Prompt Masters": "Prompt Master'lar", "Backlog Vídeos": "Video Bekleme Listesi", "Música": "Müzik", "Upload Música": "Müzik Yükleme", "Geração de Conteúdo IA": "Yapay Zekâ İçeriği Oluşturma", "Motion Control": "Hareket Kontrolü", "UGC Products": "UGC Ürünleri", "Growth": "Büyüme", "Analista Growth Youtube": "YouTube Büyüme Analisti", "Analista Growth Tiktok": "TikTok Büyüme Analisti", "Analista Growth Instagram": "Instagram Büyüme Analisti", "Documentação": "Dokümantasyon", "Tutorial Kaggle": "Kaggle Eğitimi", "Tutorial Apify": "Apify Eğitimi", "Pipeline Música": "Müzik Akışı", "módulos disponíveis": "kullanılabilir modüller",
247
+ "Arquivos Base": "Temel Dosyalar", "Pipeline Vídeos": "Video Akışı", "Canais e Perfis de Vídeos": "Video Kanalları ve Profilleri", "Canais YouTube": "YouTube Kanalları", "Facebook Pages": "Facebook Sayfaları", "Prompt Masters": "Prompt Master'lar", "Backlog Vídeos": "Video Bekleme Listesi", "Thumbnails": "Küçük resimler", "Música": "Müzik", "Upload Música": "Müzik Yükleme", "Geração de Conteúdo IA": "Yapay Zekâ İçeriği Oluşturma", "Motion Control": "Hareket Kontrolü", "UGC Products": "UGC Ürünleri", "Growth": "Büyüme", "Analista Growth Youtube": "YouTube Büyüme Analisti", "Analista Growth Tiktok": "TikTok Büyüme Analisti", "Analista Growth Instagram": "Instagram Büyüme Analisti", "Documentação": "Dokümantasyon", "Tutorial Kaggle": "Kaggle Eğitimi", "Tutorial Apify": "Apify Eğitimi", "Pipeline Música": "Müzik Akışı", "módulos disponíveis": "kullanılabilir modüller",
248
248
  },
249
249
  "ru": {
250
- "Arquivos Base": "Базовые файлы", "Pipeline Vídeos": "Конвейер видео", "Canais e Perfis de Vídeos": "Каналы и профили видео", "Canais YouTube": "Каналы YouTube", "Facebook Pages": "Страницы Facebook", "Prompt Masters": "Мастер-промпты", "Backlog Vídeos": "Очередь видео", "Música": "Музыка", "Upload Música": "Загрузка музыки", "Geração de Conteúdo IA": "Генерация контента ИИ", "Motion Control": "Управление движением", "UGC Products": "UGC-продукты", "Growth": "Рост", "Analista Growth Youtube": "Аналитик роста YouTube", "Analista Growth Tiktok": "Аналитик роста TikTok", "Analista Growth Instagram": "Аналитик роста Instagram", "Documentação": "Документация", "Tutorial Kaggle": "Руководство Kaggle", "Tutorial Apify": "Руководство Apify", "Pipeline Música": "Конвейер музыки", "módulos disponíveis": "доступные модули",
250
+ "Arquivos Base": "Базовые файлы", "Pipeline Vídeos": "Конвейер видео", "Canais e Perfis de Vídeos": "Каналы и профили видео", "Canais YouTube": "Каналы YouTube", "Facebook Pages": "Страницы Facebook", "Prompt Masters": "Мастер-промпты", "Backlog Vídeos": "Очередь видео", "Thumbnails": "Миниатюры", "Música": "Музыка", "Upload Música": "Загрузка музыки", "Geração de Conteúdo IA": "Генерация контента ИИ", "Motion Control": "Управление движением", "UGC Products": "UGC-продукты", "Growth": "Рост", "Analista Growth Youtube": "Аналитик роста YouTube", "Analista Growth Tiktok": "Аналитик роста TikTok", "Analista Growth Instagram": "Аналитик роста Instagram", "Documentação": "Документация", "Tutorial Kaggle": "Руководство Kaggle", "Tutorial Apify": "Руководство Apify", "Pipeline Música": "Конвейер музыки", "módulos disponíveis": "доступные модули",
251
251
  },
252
252
  "es": {
253
- "Arquivos Base": "Archivos base", "Pipeline Vídeos": "Flujo de vídeos", "Canais e Perfis de Vídeos": "Canales y perfiles de vídeo", "Canais YouTube": "Canales de YouTube", "Facebook Pages": "Páginas de Facebook", "Prompt Masters": "Prompts maestros", "Backlog Vídeos": "Cola de vídeos", "Música": "Música", "Upload Música": "Subir música", "Geração de Conteúdo IA": "Generación de contenido con IA", "Motion Control": "Control de movimiento", "UGC Products": "Productos UGC", "Growth": "Crecimiento", "Analista Growth Youtube": "Analista de crecimiento de YouTube", "Analista Growth Tiktok": "Analista de crecimiento de TikTok", "Analista Growth Instagram": "Analista de crecimiento de Instagram", "Documentação": "Documentación", "Tutorial Kaggle": "Tutorial de Kaggle", "Tutorial Apify": "Tutorial de Apify", "Pipeline Música": "Flujo de música", "módulos disponíveis": "módulos disponibles",
253
+ "Arquivos Base": "Archivos base", "Pipeline Vídeos": "Flujo de vídeos", "Canais e Perfis de Vídeos": "Canales y perfiles de vídeo", "Canais YouTube": "Canales de YouTube", "Facebook Pages": "Páginas de Facebook", "Prompt Masters": "Prompts maestros", "Backlog Vídeos": "Cola de vídeos", "Thumbnails": "Miniaturas", "Música": "Música", "Upload Música": "Subir música", "Geração de Conteúdo IA": "Generación de contenido con IA", "Motion Control": "Control de movimiento", "UGC Products": "Productos UGC", "Growth": "Crecimiento", "Analista Growth Youtube": "Analista de crecimiento de YouTube", "Analista Growth Tiktok": "Analista de crecimiento de TikTok", "Analista Growth Instagram": "Analista de crecimiento de Instagram", "Documentação": "Documentación", "Tutorial Kaggle": "Tutorial de Kaggle", "Tutorial Apify": "Tutorial de Apify", "Pipeline Música": "Flujo de música", "módulos disponíveis": "módulos disponibles",
254
254
  },
255
255
  "id": {
256
- "Arquivos Base": "File Dasar", "Pipeline Vídeos": "Alur Video", "Canais e Perfis de Vídeos": "Kanal dan Profil Video", "Canais YouTube": "Kanal YouTube", "Facebook Pages": "Halaman Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Antrean Video", "Música": "Musik", "Upload Música": "Unggah Musik", "Geração de Conteúdo IA": "Pembuatan Konten AI", "Motion Control": "Kontrol Gerakan", "UGC Products": "Produk UGC", "Growth": "Pertumbuhan", "Analista Growth Youtube": "Analis Pertumbuhan YouTube", "Analista Growth Tiktok": "Analis Pertumbuhan TikTok", "Analista Growth Instagram": "Analis Pertumbuhan Instagram", "Documentação": "Dokumentasi", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Alur Musik", "módulos disponíveis": "modul tersedia",
256
+ "Arquivos Base": "File Dasar", "Pipeline Vídeos": "Alur Video", "Canais e Perfis de Vídeos": "Kanal dan Profil Video", "Canais YouTube": "Kanal YouTube", "Facebook Pages": "Halaman Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Antrean Video", "Thumbnails": "Thumbnail", "Música": "Musik", "Upload Música": "Unggah Musik", "Geração de Conteúdo IA": "Pembuatan Konten AI", "Motion Control": "Kontrol Gerakan", "UGC Products": "Produk UGC", "Growth": "Pertumbuhan", "Analista Growth Youtube": "Analis Pertumbuhan YouTube", "Analista Growth Tiktok": "Analis Pertumbuhan TikTok", "Analista Growth Instagram": "Analis Pertumbuhan Instagram", "Documentação": "Dokumentasi", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Alur Musik", "módulos disponíveis": "modul tersedia",
257
257
  },
258
258
  "it": {
259
- "Arquivos Base": "File di base", "Pipeline Vídeos": "Pipeline video", "Canais e Perfis de Vídeos": "Canali e profili video", "Canais YouTube": "Canali YouTube", "Facebook Pages": "Pagine Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Coda video", "Música": "Musica", "Upload Música": "Caricamento musica", "Geração de Conteúdo IA": "Generazione di contenuti IA", "Motion Control": "Controllo del movimento", "UGC Products": "Prodotti UGC", "Growth": "Crescita", "Analista Growth Youtube": "Analista della crescita YouTube", "Analista Growth Tiktok": "Analista della crescita TikTok", "Analista Growth Instagram": "Analista della crescita Instagram", "Documentação": "Documentazione", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline musicale", "módulos disponíveis": "moduli disponibili",
259
+ "Arquivos Base": "File di base", "Pipeline Vídeos": "Pipeline video", "Canais e Perfis de Vídeos": "Canali e profili video", "Canais YouTube": "Canali YouTube", "Facebook Pages": "Pagine Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Coda video", "Thumbnails": "Miniature", "Música": "Musica", "Upload Música": "Caricamento musica", "Geração de Conteúdo IA": "Generazione di contenuti IA", "Motion Control": "Controllo del movimento", "UGC Products": "Prodotti UGC", "Growth": "Crescita", "Analista Growth Youtube": "Analista della crescita YouTube", "Analista Growth Tiktok": "Analista della crescita TikTok", "Analista Growth Instagram": "Analista della crescita Instagram", "Documentação": "Documentazione", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline musicale", "módulos disponíveis": "moduli disponibili",
260
260
  },
261
261
  }
262
262
  for _language_code, _navigation_values in UI_NAV_TRANSLATIONS.items():
@@ -407,6 +407,72 @@ for _language_code, _video_generation_translation in VIDEO_GENERATION_TRANSLATIO
407
407
  UI_TRANSLATIONS[_language_code].update(_video_generation_translation)
408
408
 
409
409
 
410
+ _PIPELINE_FEATURE_TRANSLATIONS: dict[str, dict[str, str]] = {
411
+ "pt": {
412
+ "Thumbnails": "Thumbnails", "Refazer thumbnail": "Refazer thumbnail", "Salvar rascunho": "Salvar rascunho",
413
+ "Rascunho guardado localmente": "Rascunho guardado localmente", "Thumbnail refeita com sucesso.": "Thumbnail refeita com sucesso.",
414
+ "Ver prompt da thumbnail": "Ver prompt da thumbnail", "Sem imagem": "Sem imagem", "Imagem ainda não gerada": "Imagem ainda não gerada",
415
+ "Palavras-chave": "Palavras-chave", "Título do rascunho": "Título do rascunho",
416
+ },
417
+ "en": {
418
+ "Thumbnails": "Thumbnails", "Refazer thumbnail": "Regenerate thumbnail", "Salvar rascunho": "Save draft",
419
+ "Rascunho guardado localmente": "Draft saved locally", "Thumbnail refeita com sucesso.": "Thumbnail regenerated successfully.",
420
+ "Ver prompt da thumbnail": "View thumbnail prompt", "Sem imagem": "No image", "Imagem ainda não gerada": "Image not generated yet",
421
+ "Palavras-chave": "Keywords", "Título do rascunho": "Draft title",
422
+ },
423
+ "zh": {
424
+ "Thumbnails": "缩略图", "Refazer thumbnail": "重新生成缩略图", "Salvar rascunho": "保存草稿",
425
+ "Rascunho guardado localmente": "草稿已保存到本地", "Thumbnail refeita com sucesso.": "缩略图已成功重新生成。",
426
+ "Ver prompt da thumbnail": "查看缩略图提示词", "Sem imagem": "无图像", "Imagem ainda não gerada": "尚未生成图像",
427
+ "Palavras-chave": "关键词", "Título do rascunho": "草稿标题",
428
+ },
429
+ "de": {
430
+ "Thumbnails": "Thumbnails", "Refazer thumbnail": "Thumbnail neu generieren", "Salvar rascunho": "Entwurf speichern",
431
+ "Rascunho guardado localmente": "Entwurf lokal gespeichert", "Thumbnail refeita com sucesso.": "Thumbnail erfolgreich neu generiert.",
432
+ "Ver prompt da thumbnail": "Thumbnail-Prompt anzeigen", "Sem imagem": "Kein Bild", "Imagem ainda não gerada": "Bild noch nicht generiert",
433
+ "Palavras-chave": "Schlüsselwörter", "Título do rascunho": "Entwurfstitel",
434
+ },
435
+ "vi": {
436
+ "Thumbnails": "Ảnh thu nhỏ", "Refazer thumbnail": "Tạo lại ảnh thu nhỏ", "Salvar rascunho": "Lưu bản nháp",
437
+ "Rascunho guardado localmente": "Đã lưu bản nháp cục bộ", "Thumbnail refeita com sucesso.": "Đã tạo lại ảnh thu nhỏ thành công.",
438
+ "Ver prompt da thumbnail": "Xem prompt ảnh thu nhỏ", "Sem imagem": "Không có hình ảnh", "Imagem ainda não gerada": "Chưa tạo hình ảnh",
439
+ "Palavras-chave": "Từ khóa", "Título do rascunho": "Tiêu đề bản nháp",
440
+ },
441
+ "tr": {
442
+ "Thumbnails": "Küçük resimler", "Refazer thumbnail": "Küçük resmi yeniden oluştur", "Salvar rascunho": "Taslağı kaydet",
443
+ "Rascunho guardado localmente": "Taslak yerel olarak kaydedildi", "Thumbnail refeita com sucesso.": "Küçük resim başarıyla yeniden oluşturuldu.",
444
+ "Ver prompt da thumbnail": "Küçük resim istemini görüntüle", "Sem imagem": "Görsel yok", "Imagem ainda não gerada": "Görsel henüz oluşturulmadı",
445
+ "Palavras-chave": "Anahtar kelimeler", "Título do rascunho": "Taslak başlığı",
446
+ },
447
+ "ru": {
448
+ "Thumbnails": "Миниатюры", "Refazer thumbnail": "Создать миниатюру заново", "Salvar rascunho": "Сохранить черновик",
449
+ "Rascunho guardado localmente": "Черновик сохранён локально", "Thumbnail refeita com sucesso.": "Миниатюра успешно создана заново.",
450
+ "Ver prompt da thumbnail": "Показать промпт миниатюры", "Sem imagem": "Нет изображения", "Imagem ainda não gerada": "Изображение ещё не создано",
451
+ "Palavras-chave": "Ключевые слова", "Título do rascunho": "Название черновика",
452
+ },
453
+ "es": {
454
+ "Thumbnails": "Miniaturas", "Refazer thumbnail": "Regenerar miniatura", "Salvar rascunho": "Guardar borrador",
455
+ "Rascunho guardado localmente": "Borrador guardado localmente", "Thumbnail refeita com sucesso.": "Miniatura regenerada correctamente.",
456
+ "Ver prompt da thumbnail": "Ver prompt de la miniatura", "Sem imagem": "Sin imagen", "Imagem ainda não gerada": "Imagen aún no generada",
457
+ "Palavras-chave": "Palabras clave", "Título do rascunho": "Título del borrador",
458
+ },
459
+ "id": {
460
+ "Thumbnails": "Thumbnail", "Refazer thumbnail": "Buat ulang thumbnail", "Salvar rascunho": "Simpan draf",
461
+ "Rascunho guardado localmente": "Draf disimpan secara lokal", "Thumbnail refeita com sucesso.": "Thumbnail berhasil dibuat ulang.",
462
+ "Ver prompt da thumbnail": "Lihat prompt thumbnail", "Sem imagem": "Tidak ada gambar", "Imagem ainda não gerada": "Gambar belum dibuat",
463
+ "Palavras-chave": "Kata kunci", "Título do rascunho": "Judul draf",
464
+ },
465
+ "it": {
466
+ "Thumbnails": "Miniature", "Refazer thumbnail": "Rigenera miniatura", "Salvar rascunho": "Salva bozza",
467
+ "Rascunho guardado localmente": "Bozza salvata localmente", "Thumbnail refeita com sucesso.": "Miniatura rigenerata con successo.",
468
+ "Ver prompt da thumbnail": "Mostra prompt della miniatura", "Sem imagem": "Nessuna immagine", "Imagem ainda não gerada": "Immagine non ancora generata",
469
+ "Palavras-chave": "Parole chiave", "Título do rascunho": "Titolo della bozza",
470
+ },
471
+ }
472
+ for _language_code, _pipeline_feature_translation in _PIPELINE_FEATURE_TRANSLATIONS.items():
473
+ UI_TRANSLATIONS[_language_code].update(_pipeline_feature_translation)
474
+
475
+
410
476
  _TAB_LABELS = (
411
477
  "Blueprints", "Brandings", "Pesquisa pública", "Cadastro manual", "Contas cadastradas", "Biblioteca",
412
478
  "Importar do YouTube", "Canais em lote gmail", "Criar vídeo", "Vídeos", "Novo roteiro/letra", "Histórico guardado",
@@ -34,6 +34,7 @@ DEFAULTS: dict[str, Any] = {
34
34
  "metadata_edits.json": [],
35
35
  "python_editor_edits.json": [],
36
36
  "scripts.json": [],
37
+ "drafts.json": [],
37
38
  "mcp_server.json": {
38
39
  "enabled": False,
39
40
  "host": "127.0.0.1",
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .storage import STORAGE, now, read_json, write_json
9
+ from .thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
10
+
11
+
12
+ def _as_path(value: Any) -> Path | None:
13
+ raw = str(value or "").strip()
14
+ if not raw:
15
+ return None
16
+ candidates = [Path(raw)]
17
+ path = Path(raw)
18
+ if not path.is_absolute():
19
+ candidates.extend([STORAGE / path, STORAGE.parent / path])
20
+ for candidate in candidates:
21
+ if candidate.is_file():
22
+ return candidate
23
+ return Path(raw)
24
+
25
+
26
+ def _variant_index(task: dict[str, Any], variant: dict[str, Any], variants: list[Any]) -> int:
27
+ for key in ("variant_index", "index"):
28
+ try:
29
+ value = int(variant.get(key))
30
+ except (TypeError, ValueError):
31
+ continue
32
+ if 0 <= value < len(variants):
33
+ return value
34
+ for index, candidate in enumerate(variants):
35
+ if isinstance(candidate, dict) and candidate is variant:
36
+ return index
37
+ if isinstance(candidate, dict) and variant and candidate.get("image_prompt") == variant.get("image_prompt"):
38
+ return index
39
+ return 0
40
+
41
+
42
+ def normalize_thumbnail_task(task: dict[str, Any]) -> dict[str, Any]:
43
+ variants = task.get("thumbnail_variants") if isinstance(task.get("thumbnail_variants"), list) else []
44
+ variant = task.get("thumbnail_variant") if isinstance(task.get("thumbnail_variant"), dict) else {}
45
+ artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
46
+ image_path = variant.get("image_path") or task.get("thumbnail_path") or artifacts.get("thumbnail")
47
+ prompt = variant.get("image_prompt") or task.get("thumbnail_prompt") or ""
48
+ title = str(task.get("title") or task.get("topic") or "Vídeo sem título").strip()
49
+ return {
50
+ "task": task,
51
+ "task_id": str(task.get("id") or ""),
52
+ "title": title,
53
+ "topic": str(task.get("topic") or "").strip(),
54
+ "channel_name": str(task.get("channel_name") or "Canal sem nome").strip(),
55
+ "status": str(task.get("thumbnail_status") or "not_generated"),
56
+ "prompt": str(prompt or "").strip(),
57
+ "image_path": _as_path(image_path),
58
+ "variant": variant,
59
+ "variants": variants,
60
+ "variant_index": _variant_index(task, variant, variants),
61
+ }
62
+
63
+
64
+ def list_thumbnail_tasks() -> list[dict[str, Any]]:
65
+ tasks = read_json("tasks.json", [])
66
+ if not isinstance(tasks, list):
67
+ return []
68
+ records = []
69
+ for task in tasks:
70
+ if not isinstance(task, dict):
71
+ continue
72
+ record = normalize_thumbnail_task(task)
73
+ has_thumbnail_signal = bool(
74
+ record["image_path"]
75
+ or record["prompt"]
76
+ or task.get("thumbnail_status")
77
+ or task.get("thumbnail_variant")
78
+ or task.get("thumbnail_variants")
79
+ )
80
+ if record["task_id"] and has_thumbnail_signal:
81
+ records.append(record)
82
+ return records
83
+
84
+
85
+ def regenerate_thumbnail(task_id: str, settings: dict[str, Any]) -> tuple[dict[str, Any], Path]:
86
+ tasks = read_json("tasks.json", [])
87
+ if not isinstance(tasks, list):
88
+ raise ThumbnailGenerationError("O índice de tarefas não está disponível.")
89
+ for task in tasks:
90
+ if not isinstance(task, dict) or str(task.get("id") or "") != str(task_id):
91
+ continue
92
+ record = normalize_thumbnail_task(task)
93
+ if not record["prompt"]:
94
+ raise ThumbnailGenerationError("Esta tarefa não tem um prompt de imagem para refazer a thumbnail.")
95
+ previous_image = record.get("image_path")
96
+ if previous_image and previous_image.is_file():
97
+ history_dir = STORAGE / "thumbnails" / "history"
98
+ history_dir.mkdir(parents=True, exist_ok=True)
99
+ history_path = history_dir / f"{task_id}-{uuid.uuid4().hex[:10]}-{previous_image.name}"
100
+ shutil.copy2(previous_image, history_path)
101
+ image_path = generate_thumbnail_image(
102
+ settings,
103
+ record["prompt"],
104
+ topic=record["title"] or record["topic"],
105
+ variant_index=record["variant_index"],
106
+ )
107
+ artifacts = dict(task.get("artifacts") or {})
108
+ artifacts["thumbnail"] = str(image_path)
109
+ task["artifacts"] = artifacts
110
+ task["thumbnail_path"] = str(image_path)
111
+ task["thumbnail_status"] = "generated"
112
+ variant = dict(record["variant"])
113
+ if variant:
114
+ variant["image_path"] = str(image_path)
115
+ task["thumbnail_variant"] = variant
116
+ variants = task.get("thumbnail_variants")
117
+ if isinstance(variants, list) and 0 <= record["variant_index"] < len(variants) and isinstance(variants[record["variant_index"]], dict):
118
+ variants[record["variant_index"]] = {**variants[record["variant_index"]], "image_path": str(image_path)}
119
+ task["thumbnail_variants"] = variants
120
+ task["updated_at"] = now()
121
+ write_json("tasks.json", tasks)
122
+ return task, image_path
123
+ raise ThumbnailGenerationError(f"A tarefa {task_id} não foi encontrada.")
124
+
125
+
126
+ __all__ = ["list_thumbnail_tasks", "normalize_thumbnail_task", "regenerate_thumbnail"]
127
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.21",
3
+ "version": "0.3.22",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",